check_dependent_pr_test.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. """Tests for check_dependent_pr.py."""
  2. __copyright__ = """
  3. Part of the Carbon Language project, under the Apache License v2.0 with LLVM
  4. Exceptions. See /LICENSE for license information.
  5. SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
  6. """
  7. import json
  8. import unittest
  9. from unittest import mock
  10. from typing import Any
  11. import check_dependent_pr
  12. import github_helpers
  13. _OID1 = "1" * 40
  14. _OID2 = "2" * 40
  15. _OID3 = "3" * 40
  16. _OID4 = "4" * 40
  17. _OID9 = "9" * 40
  18. class TestCheckDependentPR(unittest.TestCase):
  19. def setUp(self) -> None:
  20. self.mock_client = mock.MagicMock(spec=github_helpers.Client)
  21. # Mock requests.post to avoid network calls and track status updates.
  22. self.requests_post_patcher = mock.patch("requests.post")
  23. self.mock_post = self.requests_post_patcher.start()
  24. def tearDown(self) -> None:
  25. self.requests_post_patcher.stop()
  26. def _assert_status(self, sha: str, state: str, description: str) -> None:
  27. """Validates that requests.post was called to set the commit status."""
  28. self.mock_post.assert_called_once()
  29. args, kwargs = self.mock_post.call_args
  30. self.assertIn(f"statuses/{sha}", args[0])
  31. self.assertEqual(kwargs["json"]["state"], state)
  32. self.assertEqual(kwargs["json"]["context"], "PR dependencies check")
  33. self.assertEqual(kwargs["json"]["description"], description)
  34. def _make_comment(
  35. self,
  36. open_deps: list[int],
  37. merged_deps: list[int] = None,
  38. first_commit: str = None,
  39. comment_id: str = "comment_id",
  40. ) -> dict[str, str]:
  41. """Builds a boilerplate PR comment."""
  42. state: dict[str, Any] = {
  43. "open": open_deps,
  44. "merged": merged_deps if merged_deps else [],
  45. }
  46. if first_commit:
  47. state["first_commit"] = first_commit
  48. return {
  49. "id": comment_id,
  50. "body": f"<!-- check_dependent_pr {json.dumps(state)} -->",
  51. }
  52. def _make_pr_response(
  53. self,
  54. pr_id: str,
  55. head_ref_oid: str,
  56. commits: list[str],
  57. comments: list[dict[str, str]] = None,
  58. has_dependent_label: bool = False,
  59. ) -> dict[str, Any]:
  60. """Builds a boilerplate GitHub response for a PR."""
  61. labels = (
  62. [{"name": "dependent", "id": "label_dependent"}]
  63. if has_dependent_label
  64. else []
  65. )
  66. return {
  67. "repository": {
  68. "pullRequest": {
  69. "id": pr_id,
  70. "headRefOid": head_ref_oid,
  71. "labels": {"nodes": labels},
  72. "commits": {
  73. "nodes": [{"commit": {"oid": oid}} for oid in commits]
  74. },
  75. "comments": {"nodes": comments if comments else []},
  76. }
  77. }
  78. }
  79. def test_process_pr_no_overlap(self) -> None:
  80. self.mock_client.execute.return_value = self._make_pr_response(
  81. pr_id="pr_1",
  82. head_ref_oid=_OID1,
  83. commits=[_OID1],
  84. )
  85. check_dependent_pr._process_pr(
  86. self.mock_client,
  87. pr_number=1,
  88. pr_to_commits={1: {_OID1}},
  89. pr_to_head={1: _OID1},
  90. open_pr_numbers={1},
  91. label_id="label_id",
  92. token="test_token",
  93. )
  94. self.assertEqual(self.mock_client.execute.call_count, 1)
  95. self._assert_status(
  96. _OID1, "success", "This PR has no open dependencies"
  97. )
  98. def test_process_pr_with_overlap(self) -> None:
  99. self.mock_client.execute.return_value = self._make_pr_response(
  100. pr_id="pr_2",
  101. head_ref_oid=_OID2,
  102. commits=[_OID1, _OID2],
  103. )
  104. check_dependent_pr._process_pr(
  105. self.mock_client,
  106. pr_number=2,
  107. pr_to_commits={1: {_OID1}, 2: {_OID1, _OID2}},
  108. pr_to_head={1: _OID1, 2: _OID2},
  109. open_pr_numbers={1, 2},
  110. label_id="label_dependent",
  111. token="test_token",
  112. )
  113. self.assertEqual(self.mock_client.execute.call_count, 3)
  114. calls = self.mock_client.execute.call_args_list
  115. self.assertIn("addLabelsToLabelable", calls[1][0][0])
  116. self.assertIn("addComment", calls[2][0][0])
  117. self._assert_status(
  118. _OID2, "pending", "This PR has open dependencies: #1"
  119. )
  120. def test_process_pr_dependencies_merged(self) -> None:
  121. self.mock_client.execute.return_value = self._make_pr_response(
  122. pr_id="pr_3",
  123. head_ref_oid=_OID2,
  124. commits=[_OID1, _OID2],
  125. comments=[self._make_comment(open_deps=[1])],
  126. has_dependent_label=True,
  127. )
  128. check_dependent_pr._process_pr(
  129. self.mock_client,
  130. pr_number=3,
  131. pr_to_commits={3: {_OID1, _OID2}},
  132. pr_to_head={3: _OID2},
  133. open_pr_numbers={3},
  134. label_id="label_dependent",
  135. token="test_token",
  136. )
  137. calls = self.mock_client.execute.call_args_list
  138. self.assertIn("removeLabelsFromLabelable", calls[1][0][0])
  139. self.assertIn("updateIssueComment", calls[2][0][0])
  140. self._assert_status(
  141. _OID2, "success", "This PR has no open dependencies"
  142. )
  143. def test_process_pr_dependency_got_new_commits(self) -> None:
  144. self.mock_client.execute.return_value = self._make_pr_response(
  145. pr_id="pr_3",
  146. head_ref_oid=_OID2,
  147. commits=[_OID1, _OID2],
  148. comments=[self._make_comment(open_deps=[1, 2])],
  149. has_dependent_label=True,
  150. )
  151. check_dependent_pr._process_pr(
  152. self.mock_client,
  153. pr_number=3,
  154. pr_to_commits={1: {_OID1, _OID4}, 3: {_OID1, _OID2}},
  155. pr_to_head={1: _OID4, 3: _OID2},
  156. open_pr_numbers={1, 3},
  157. label_id="label_dependent",
  158. token="test_token",
  159. )
  160. calls = self.mock_client.execute.call_args_list
  161. update_mutation = calls[1][0][0]
  162. self.assertIn("updateIssueComment", update_mutation)
  163. variable_values = calls[1][1]["variable_values"]
  164. self.assertIn('"open": [1]', variable_values["body"])
  165. self.assertIn('"merged": [2]', variable_values["body"])
  166. self._assert_status(
  167. _OID2, "pending", "This PR has open dependencies: #1"
  168. )
  169. def test_process_pr_non_coherent_prefix(self) -> None:
  170. self.mock_client.execute.return_value = self._make_pr_response(
  171. pr_id="pr_10",
  172. head_ref_oid=_OID2,
  173. commits=[_OID1, _OID2],
  174. )
  175. check_dependent_pr._process_pr(
  176. self.mock_client,
  177. pr_number=10,
  178. pr_to_commits={10: {_OID1, _OID2}, 11: {_OID1, _OID3}},
  179. pr_to_head={10: _OID2, 11: _OID3},
  180. open_pr_numbers={10, 11},
  181. label_id="label_dependent",
  182. token="test_token",
  183. )
  184. self.assertEqual(self.mock_client.execute.call_count, 1)
  185. self._assert_status(
  186. _OID2, "success", "This PR has no open dependencies"
  187. )
  188. def test_process_pr_overlap_only_on_head_ref(self) -> None:
  189. self.mock_client.execute.return_value = self._make_pr_response(
  190. pr_id="pr_9",
  191. head_ref_oid=_OID2,
  192. commits=[_OID1, _OID2],
  193. )
  194. check_dependent_pr._process_pr(
  195. self.mock_client,
  196. pr_number=9,
  197. pr_to_commits={1: {_OID2}, 9: {_OID1, _OID2}},
  198. pr_to_head={1: _OID2, 9: _OID2},
  199. open_pr_numbers={1, 9},
  200. label_id="label_dependent",
  201. token="test_token",
  202. )
  203. self.assertEqual(self.mock_client.execute.call_count, 3)
  204. calls = self.mock_client.execute.call_args_list
  205. self.assertIn("addLabelsToLabelable", calls[1][0][0])
  206. self.assertIn("addComment", calls[2][0][0])
  207. self._assert_status(
  208. _OID2, "pending", "This PR has open dependencies: #1"
  209. )
  210. def test_process_pr_scanning_no_add(self) -> None:
  211. self.mock_client.execute.return_value = self._make_pr_response(
  212. pr_id="pr_7",
  213. head_ref_oid=_OID2,
  214. commits=[_OID1, _OID2],
  215. )
  216. check_dependent_pr._process_pr(
  217. self.mock_client,
  218. pr_number=7,
  219. pr_to_commits={1: {_OID1}, 7: {_OID1, _OID2}},
  220. pr_to_head={1: _OID1, 7: _OID2},
  221. open_pr_numbers={1, 7},
  222. label_id="label_dependent",
  223. token="test_token",
  224. scanning=True,
  225. )
  226. self.assertEqual(self.mock_client.execute.call_count, 1)
  227. self._assert_status(
  228. _OID2, "pending", "This PR has open dependencies: #1"
  229. )
  230. def test_process_pr_no_changes_needed(self) -> None:
  231. self.mock_client.execute.return_value = self._make_pr_response(
  232. pr_id="pr_6",
  233. head_ref_oid=_OID2,
  234. commits=[_OID1, _OID2],
  235. comments=[self._make_comment(open_deps=[1], first_commit=_OID2)],
  236. has_dependent_label=True,
  237. )
  238. check_dependent_pr._process_pr(
  239. self.mock_client,
  240. pr_number=6,
  241. pr_to_commits={1: {_OID1}, 6: {_OID1, _OID2}},
  242. pr_to_head={1: _OID1, 6: _OID2},
  243. open_pr_numbers={1, 6},
  244. label_id="label_dependent",
  245. token="test_token",
  246. )
  247. self.assertEqual(self.mock_client.execute.call_count, 1)
  248. self._assert_status(
  249. _OID2, "pending", "This PR has open dependencies: #1"
  250. )
  251. def test_process_pr_invalid_marker(self) -> None:
  252. self.mock_client.execute.return_value = self._make_pr_response(
  253. pr_id="pr_5",
  254. head_ref_oid=_OID1,
  255. commits=[_OID1],
  256. comments=[
  257. {
  258. "id": "comment_id",
  259. "body": "<!-- check_dependent_pr {invalid_json} -->",
  260. }
  261. ],
  262. )
  263. import json
  264. self.assertRaises(
  265. json.decoder.JSONDecodeError,
  266. check_dependent_pr._process_pr,
  267. self.mock_client,
  268. pr_number=5,
  269. pr_to_commits={5: {_OID1}},
  270. pr_to_head={5: _OID1},
  271. open_pr_numbers={5},
  272. label_id="label_dependent",
  273. token="test_token",
  274. )
  275. def test_process_pr_hidden_comment(self) -> None:
  276. self.mock_client.execute.return_value = self._make_pr_response(
  277. pr_id="pr_14",
  278. head_ref_oid=_OID2,
  279. commits=[_OID1, _OID2],
  280. comments=[
  281. {
  282. "id": "hidden_comment_id",
  283. "body": '<!-- check_dependent_pr {"open": [1]} -->',
  284. "isMinimized": True,
  285. }
  286. ],
  287. has_dependent_label=True,
  288. )
  289. check_dependent_pr._process_pr(
  290. self.mock_client,
  291. pr_number=14,
  292. pr_to_commits={1: {_OID1}, 14: {_OID1, _OID2}},
  293. pr_to_head={1: _OID1, 14: _OID2},
  294. open_pr_numbers={1, 14},
  295. label_id="label_dependent",
  296. token="test_token",
  297. )
  298. calls = self.mock_client.execute.call_args_list
  299. self.assertEqual(self.mock_client.execute.call_count, 2)
  300. self.assertIn("addComment", calls[1][0][0])
  301. self._assert_status(
  302. _OID2, "pending", "This PR has open dependencies: #1"
  303. )
  304. def test_process_pr_sticky_first_commit(self) -> None:
  305. self.mock_client.execute.return_value = self._make_pr_response(
  306. pr_id="pr_11",
  307. head_ref_oid=_OID3,
  308. commits=[_OID1, _OID2, _OID3],
  309. comments=[self._make_comment(open_deps=[1, 2], first_commit=_OID2)],
  310. has_dependent_label=True,
  311. )
  312. check_dependent_pr._process_pr(
  313. self.mock_client,
  314. pr_number=11,
  315. pr_to_commits={1: {_OID1}, 11: {_OID1, _OID2, _OID3}},
  316. pr_to_head={1: _OID1, 11: _OID3},
  317. open_pr_numbers={1, 11},
  318. label_id="label_dependent",
  319. token="test_token",
  320. )
  321. calls = self.mock_client.execute.call_args_list
  322. variable_values = calls[1][1]["variable_values"]
  323. # Uses the last dependency's HEAD (_OID1) instead of sticky first
  324. # commit (_OID2)
  325. self.assertIn(_OID1[:8], variable_values["body"])
  326. self._assert_status(
  327. _OID3, "pending", "This PR has open dependencies: #1"
  328. )
  329. def test_process_pr_rebase_first_commit(self) -> None:
  330. self.mock_client.execute.return_value = self._make_pr_response(
  331. pr_id="pr_12",
  332. head_ref_oid=_OID2,
  333. commits=[_OID1, _OID2],
  334. comments=[self._make_comment(open_deps=[1, 2])],
  335. has_dependent_label=True,
  336. )
  337. check_dependent_pr._process_pr(
  338. self.mock_client,
  339. pr_number=12,
  340. pr_to_commits={1: {_OID9}, 12: {_OID1, _OID2}},
  341. pr_to_head={1: _OID9, 12: _OID2},
  342. open_pr_numbers={1, 12},
  343. label_id="label_dependent",
  344. token="test_token",
  345. )
  346. calls = self.mock_client.execute.call_args_list
  347. variable_values = calls[1][1]["variable_values"]
  348. self.assertIn(
  349. "unable to identify starting review commit", variable_values["body"]
  350. )
  351. self._assert_status(
  352. _OID2, "pending", "This PR has open dependencies: #1"
  353. )
  354. def test_process_pr_fallback_no_independent_commit(self) -> None:
  355. self.mock_client.execute.return_value = self._make_pr_response(
  356. pr_id="pr_13",
  357. head_ref_oid=_OID2,
  358. commits=[_OID1, _OID2],
  359. comments=[self._make_comment(open_deps=[1, 2])],
  360. has_dependent_label=True,
  361. )
  362. check_dependent_pr._process_pr(
  363. self.mock_client,
  364. pr_number=13,
  365. pr_to_commits={1: {_OID1, _OID2}, 13: {_OID1, _OID2}},
  366. pr_to_head={1: _OID2, 13: _OID2},
  367. open_pr_numbers={1, 13},
  368. label_id="label_dependent",
  369. token="test_token",
  370. )
  371. calls = self.mock_client.execute.call_args_list
  372. variable_values = calls[1][1]["variable_values"]
  373. # Uses the last dependency's HEAD even if there are no independent
  374. # commits
  375. self.assertIn(_OID2[:8], variable_values["body"])
  376. self._assert_status(
  377. _OID2, "pending", "This PR has open dependencies: #1"
  378. )
  379. def test_process_pr_sequence_failure(self) -> None:
  380. self.mock_client.execute.return_value = self._make_pr_response(
  381. pr_id="pr_1",
  382. head_ref_oid=_OID1,
  383. commits=[_OID1],
  384. )
  385. check_dependent_pr._process_pr(
  386. self.mock_client,
  387. pr_number=1,
  388. pr_to_commits={1: {_OID1}, 2: {_OID1, _OID2}},
  389. pr_to_head={1: _OID1, 2: _OID2},
  390. open_pr_numbers={1, 2},
  391. label_id="label_dependent",
  392. token="test_token",
  393. )
  394. self.assertEqual(self.mock_client.execute.call_count, 1)
  395. self._assert_status(
  396. _OID1, "success", "This PR has no open dependencies"
  397. )
  398. def test_process_pr_no_overlap_different_commits(self) -> None:
  399. self.mock_client.execute.return_value = self._make_pr_response(
  400. pr_id="pr_2",
  401. head_ref_oid=_OID2,
  402. commits=[_OID2],
  403. )
  404. check_dependent_pr._process_pr(
  405. self.mock_client,
  406. pr_number=2,
  407. pr_to_commits={1: {_OID1}, 2: {_OID2}},
  408. pr_to_head={1: _OID1, 2: _OID2},
  409. open_pr_numbers={1, 2},
  410. label_id="label_dependent",
  411. token="test_token",
  412. )
  413. self.assertEqual(self.mock_client.execute.call_count, 1)
  414. self._assert_status(
  415. _OID2, "success", "This PR has no open dependencies"
  416. )
  417. def test_process_pr_no_unique_commit(self) -> None:
  418. self.mock_client.execute.return_value = self._make_pr_response(
  419. pr_id="pr_2",
  420. head_ref_oid=_OID2,
  421. commits=[_OID1, _OID2],
  422. )
  423. check_dependent_pr._process_pr(
  424. self.mock_client,
  425. pr_number=2,
  426. pr_to_commits={1: {_OID1, _OID2, _OID3}, 2: {_OID1, _OID2}},
  427. pr_to_head={1: _OID3, 2: _OID2},
  428. open_pr_numbers={1, 2},
  429. label_id="label_dependent",
  430. token="test_token",
  431. )
  432. self.assertEqual(self.mock_client.execute.call_count, 1)
  433. self._assert_status(
  434. _OID2, "success", "This PR has no open dependencies"
  435. )
  436. def test_process_pr_multiple_non_overlapping_commits(self) -> None:
  437. self.mock_client.execute.return_value = self._make_pr_response(
  438. pr_id="pr_2",
  439. head_ref_oid=_OID4,
  440. commits=[_OID1, _OID3, _OID4],
  441. )
  442. check_dependent_pr._process_pr(
  443. self.mock_client,
  444. pr_number=2,
  445. pr_to_commits={1: {_OID1, _OID2}, 2: {_OID1, _OID3, _OID4}},
  446. pr_to_head={1: _OID2, 2: _OID4},
  447. open_pr_numbers={1, 2},
  448. label_id="label_dependent",
  449. token="test_token",
  450. )
  451. self.assertEqual(self.mock_client.execute.call_count, 3)
  452. calls = self.mock_client.execute.call_args_list
  453. self.assertIn("addLabelsToLabelable", calls[1][0][0])
  454. self._assert_status(
  455. _OID4, "pending", "This PR has open dependencies: #1"
  456. )
  457. def test_always_sets_status_check_success(self) -> None:
  458. self.mock_client.execute.return_value = self._make_pr_response(
  459. pr_id="pr_1",
  460. head_ref_oid=_OID1,
  461. commits=[_OID1],
  462. )
  463. check_dependent_pr._process_pr(
  464. self.mock_client,
  465. pr_number=1,
  466. pr_to_commits={1: {_OID1}},
  467. pr_to_head={1: _OID1},
  468. open_pr_numbers={1},
  469. label_id="label_id",
  470. token="test_token",
  471. )
  472. self._assert_status(
  473. _OID1, "success", "This PR has no open dependencies"
  474. )
  475. def test_always_sets_status_check_pending(self) -> None:
  476. self.mock_client.execute.return_value = self._make_pr_response(
  477. pr_id="pr_2",
  478. head_ref_oid=_OID2,
  479. commits=[_OID1, _OID2],
  480. )
  481. check_dependent_pr._process_pr(
  482. self.mock_client,
  483. pr_number=2,
  484. pr_to_commits={1: {_OID1}, 2: {_OID1, _OID2}},
  485. pr_to_head={1: _OID1, 2: _OID2},
  486. open_pr_numbers={1, 2},
  487. label_id="label_dependent",
  488. token="test_token",
  489. )
  490. self._assert_status(
  491. _OID2, "pending", "This PR has open dependencies: #1"
  492. )
  493. def test_process_pr_sibling_prs(self) -> None:
  494. # PR 1: [_OID1]
  495. # PR 2: [_OID1, _OID2]
  496. # PR 3: [_OID1, _OID3]
  497. # PR 3 depends on PR 1, but not PR 2.
  498. self.mock_client.execute.return_value = self._make_pr_response(
  499. pr_id="pr_3",
  500. head_ref_oid=_OID3,
  501. commits=[_OID1, _OID3],
  502. )
  503. check_dependent_pr._process_pr(
  504. self.mock_client,
  505. pr_number=3,
  506. pr_to_commits={
  507. 1: {_OID1},
  508. 2: {_OID1, _OID2},
  509. 3: {_OID1, _OID3},
  510. },
  511. pr_to_head={1: _OID1, 2: _OID2, 3: _OID3},
  512. open_pr_numbers={1, 2, 3},
  513. label_id="label_dependent",
  514. token="test_token",
  515. )
  516. self.assertEqual(self.mock_client.execute.call_count, 3)
  517. calls = self.mock_client.execute.call_args_list
  518. self.assertIn("addLabelsToLabelable", calls[1][0][0])
  519. # Link should only mention PR #1
  520. variable_values = calls[2][1]["variable_values"]
  521. self.assertIn("Depends on #1", variable_values["body"])
  522. self.assertNotIn("#2", variable_values["body"])
  523. self._assert_status(
  524. _OID3, "pending", "This PR has open dependencies: #1"
  525. )
  526. def test_query_max_merged_pr_explicit_orderBy_and_first_one(self) -> None:
  527. self.assertIn(
  528. "orderBy: {field: CREATED_AT, direction: DESC}",
  529. check_dependent_pr._QUERY_MAX_MERGED_PR,
  530. )
  531. self.assertIn(
  532. "first: 1",
  533. check_dependent_pr._QUERY_MAX_MERGED_PR,
  534. )
  535. if __name__ == "__main__":
  536. unittest.main()