test_issue_handler.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711
  1. from unittest.mock import MagicMock, patch
  2. from openhands.core.config import LLMConfig
  3. from openhands.events.action.message import MessageAction
  4. from openhands.resolver.github_issue import GithubIssue, ReviewThread
  5. from openhands.resolver.issue_definitions import IssueHandler, PRHandler
  6. def test_get_converted_issues_initializes_review_comments():
  7. # Mock the necessary dependencies
  8. with patch('requests.get') as mock_get:
  9. # Mock the response for issues
  10. mock_issues_response = MagicMock()
  11. mock_issues_response.json.return_value = [
  12. {'number': 1, 'title': 'Test Issue', 'body': 'Test Body'}
  13. ]
  14. # Mock the response for comments
  15. mock_comments_response = MagicMock()
  16. mock_comments_response.json.return_value = []
  17. # Set up the mock to return different responses for different calls
  18. # First call is for issues, second call is for comments
  19. mock_get.side_effect = [
  20. mock_issues_response,
  21. mock_comments_response,
  22. mock_comments_response,
  23. ] # Need two comment responses because we make two API calls
  24. # Create an instance of IssueHandler
  25. handler = IssueHandler('test-owner', 'test-repo', 'test-token')
  26. # Get converted issues
  27. issues = handler.get_converted_issues(issue_numbers=[1])
  28. # Verify that we got exactly one issue
  29. assert len(issues) == 1
  30. # Verify that review_comments is initialized as None
  31. assert issues[0].review_comments is None
  32. # Verify other fields are set correctly
  33. assert issues[0].number == 1
  34. assert issues[0].title == 'Test Issue'
  35. assert issues[0].body == 'Test Body'
  36. assert issues[0].owner == 'test-owner'
  37. assert issues[0].repo == 'test-repo'
  38. def test_pr_handler_guess_success_with_thread_comments():
  39. # Create a PR handler instance
  40. handler = PRHandler('test-owner', 'test-repo', 'test-token')
  41. # Create a mock issue with thread comments but no review comments
  42. issue = GithubIssue(
  43. owner='test-owner',
  44. repo='test-repo',
  45. number=1,
  46. title='Test PR',
  47. body='Test Body',
  48. thread_comments=['First comment', 'Second comment'],
  49. closing_issues=['Issue description'],
  50. review_comments=None,
  51. thread_ids=None,
  52. head_branch='test-branch',
  53. )
  54. # Create mock history
  55. history = [MessageAction(content='Fixed the issue by implementing X and Y')]
  56. # Create mock LLM config
  57. llm_config = LLMConfig(model='test-model', api_key='test-key')
  58. # Mock the LLM response
  59. mock_response = MagicMock()
  60. mock_response.choices = [
  61. MagicMock(
  62. message=MagicMock(
  63. content="""--- success
  64. true
  65. --- explanation
  66. The changes successfully address the feedback."""
  67. )
  68. )
  69. ]
  70. # Test the guess_success method
  71. with patch('litellm.completion', return_value=mock_response):
  72. success, success_list, explanation = handler.guess_success(
  73. issue, history, llm_config
  74. )
  75. # Verify the results
  76. assert success is True
  77. assert success_list == [True]
  78. assert 'successfully address' in explanation
  79. def test_pr_handler_get_converted_issues_with_comments():
  80. # Mock the necessary dependencies
  81. with patch('requests.get') as mock_get:
  82. # Mock the response for PRs
  83. mock_prs_response = MagicMock()
  84. mock_prs_response.json.return_value = [
  85. {
  86. 'number': 1,
  87. 'title': 'Test PR',
  88. 'body': 'Test Body fixes #1',
  89. 'head': {'ref': 'test-branch'},
  90. }
  91. ]
  92. # Mock the response for PR comments
  93. mock_comments_response = MagicMock()
  94. mock_comments_response.json.return_value = [
  95. {'body': 'First comment'},
  96. {'body': 'Second comment'},
  97. ]
  98. # Mock the response for PR metadata (GraphQL)
  99. mock_graphql_response = MagicMock()
  100. mock_graphql_response.json.return_value = {
  101. 'data': {
  102. 'repository': {
  103. 'pullRequest': {
  104. 'closingIssuesReferences': {'edges': []},
  105. 'reviews': {'nodes': []},
  106. 'reviewThreads': {'edges': []},
  107. }
  108. }
  109. }
  110. }
  111. # Set up the mock to return different responses
  112. # We need to return empty responses for subsequent pages
  113. mock_empty_response = MagicMock()
  114. mock_empty_response.json.return_value = []
  115. # Mock the response for fetching the external issue referenced in PR body
  116. mock_external_issue_response = MagicMock()
  117. mock_external_issue_response.json.return_value = {
  118. 'body': 'This is additional context from an externally referenced issue.'
  119. }
  120. mock_get.side_effect = [
  121. mock_prs_response, # First call for PRs
  122. mock_empty_response, # Second call for PRs (empty page)
  123. mock_comments_response, # Third call for PR comments
  124. mock_empty_response, # Fourth call for PR comments (empty page)
  125. mock_external_issue_response, # Mock response for the external issue reference #1
  126. ]
  127. # Mock the post request for GraphQL
  128. with patch('requests.post') as mock_post:
  129. mock_post.return_value = mock_graphql_response
  130. # Create an instance of PRHandler
  131. handler = PRHandler('test-owner', 'test-repo', 'test-token')
  132. # Get converted issues
  133. prs = handler.get_converted_issues(issue_numbers=[1])
  134. # Verify that we got exactly one PR
  135. assert len(prs) == 1
  136. # Verify that thread_comments are set correctly
  137. assert prs[0].thread_comments == ['First comment', 'Second comment']
  138. # Verify other fields are set correctly
  139. assert prs[0].number == 1
  140. assert prs[0].title == 'Test PR'
  141. assert prs[0].body == 'Test Body fixes #1'
  142. assert prs[0].owner == 'test-owner'
  143. assert prs[0].repo == 'test-repo'
  144. assert prs[0].head_branch == 'test-branch'
  145. assert prs[0].closing_issues == [
  146. 'This is additional context from an externally referenced issue.'
  147. ]
  148. def test_pr_handler_guess_success_only_review_comments():
  149. # Create a PR handler instance
  150. handler = PRHandler('test-owner', 'test-repo', 'test-token')
  151. # Create a mock issue with only review comments
  152. issue = GithubIssue(
  153. owner='test-owner',
  154. repo='test-repo',
  155. number=1,
  156. title='Test PR',
  157. body='Test Body',
  158. thread_comments=None,
  159. closing_issues=['Issue description'],
  160. review_comments=['Please fix the formatting', 'Add more tests'],
  161. thread_ids=None,
  162. head_branch='test-branch',
  163. )
  164. # Create mock history
  165. history = [MessageAction(content='Fixed the formatting and added more tests')]
  166. # Create mock LLM config
  167. llm_config = LLMConfig(model='test-model', api_key='test-key')
  168. # Mock the LLM response
  169. mock_response = MagicMock()
  170. mock_response.choices = [
  171. MagicMock(
  172. message=MagicMock(
  173. content="""--- success
  174. true
  175. --- explanation
  176. The changes successfully address the review comments."""
  177. )
  178. )
  179. ]
  180. # Test the guess_success method
  181. with patch('litellm.completion', return_value=mock_response):
  182. success, success_list, explanation = handler.guess_success(
  183. issue, history, llm_config
  184. )
  185. # Verify the results
  186. assert success is True
  187. assert success_list == [True]
  188. assert 'successfully address' in explanation
  189. def test_pr_handler_guess_success_no_comments():
  190. # Create a PR handler instance
  191. handler = PRHandler('test-owner', 'test-repo', 'test-token')
  192. # Create a mock issue with no comments
  193. issue = GithubIssue(
  194. owner='test-owner',
  195. repo='test-repo',
  196. number=1,
  197. title='Test PR',
  198. body='Test Body',
  199. thread_comments=None,
  200. closing_issues=['Issue description'],
  201. review_comments=None,
  202. thread_ids=None,
  203. head_branch='test-branch',
  204. )
  205. # Create mock history
  206. history = [MessageAction(content='Fixed the issue')]
  207. # Create mock LLM config
  208. llm_config = LLMConfig(model='test-model', api_key='test-key')
  209. # Test that it returns appropriate message when no comments are present
  210. success, success_list, explanation = handler.guess_success(
  211. issue, history, llm_config
  212. )
  213. assert success is False
  214. assert success_list is None
  215. assert explanation == 'No feedback was found to process'
  216. def test_get_issue_comments_with_specific_comment_id():
  217. # Mock the necessary dependencies
  218. with patch('requests.get') as mock_get:
  219. # Mock the response for comments
  220. mock_comments_response = MagicMock()
  221. mock_comments_response.json.return_value = [
  222. {'id': 123, 'body': 'First comment'},
  223. {'id': 456, 'body': 'Second comment'},
  224. ]
  225. mock_get.return_value = mock_comments_response
  226. # Create an instance of IssueHandler
  227. handler = IssueHandler('test-owner', 'test-repo', 'test-token')
  228. # Get comments with a specific comment_id
  229. specific_comment = handler._get_issue_comments(issue_number=1, comment_id=123)
  230. # Verify only the specific comment is returned
  231. assert specific_comment == ['First comment']
  232. def test_pr_handler_get_converted_issues_with_specific_thread_comment():
  233. # Define the specific comment_id to filter
  234. specific_comment_id = 123
  235. # Mock GraphQL response for review threads
  236. with patch('requests.get') as mock_get:
  237. # Mock the response for PRs
  238. mock_prs_response = MagicMock()
  239. mock_prs_response.json.return_value = [
  240. {
  241. 'number': 1,
  242. 'title': 'Test PR',
  243. 'body': 'Test Body',
  244. 'head': {'ref': 'test-branch'},
  245. }
  246. ]
  247. # Mock the response for PR comments
  248. mock_comments_response = MagicMock()
  249. mock_comments_response.json.return_value = [
  250. {'body': 'First comment', 'id': 123},
  251. {'body': 'Second comment', 'id': 124},
  252. ]
  253. # Mock the response for PR metadata (GraphQL)
  254. mock_graphql_response = MagicMock()
  255. mock_graphql_response.json.return_value = {
  256. 'data': {
  257. 'repository': {
  258. 'pullRequest': {
  259. 'closingIssuesReferences': {'edges': []},
  260. 'reviews': {'nodes': []},
  261. 'reviewThreads': {
  262. 'edges': [
  263. {
  264. 'node': {
  265. 'id': 'review-thread-1',
  266. 'isResolved': False,
  267. 'comments': {
  268. 'nodes': [
  269. {
  270. 'fullDatabaseId': 121,
  271. 'body': 'Specific review comment',
  272. 'path': 'file1.txt',
  273. },
  274. {
  275. 'fullDatabaseId': 456,
  276. 'body': 'Another review comment',
  277. 'path': 'file2.txt',
  278. },
  279. ]
  280. },
  281. }
  282. }
  283. ]
  284. },
  285. }
  286. }
  287. }
  288. }
  289. # Set up the mock to return different responses
  290. # We need to return empty responses for subsequent pages
  291. mock_empty_response = MagicMock()
  292. mock_empty_response.json.return_value = []
  293. mock_get.side_effect = [
  294. mock_prs_response, # First call for PRs
  295. mock_empty_response, # Second call for PRs (empty page)
  296. mock_comments_response, # Third call for PR comments
  297. mock_empty_response, # Fourth call for PR comments (empty page)
  298. ]
  299. # Mock the post request for GraphQL
  300. with patch('requests.post') as mock_post:
  301. mock_post.return_value = mock_graphql_response
  302. # Create an instance of PRHandler
  303. handler = PRHandler('test-owner', 'test-repo', 'test-token')
  304. # Get converted issues
  305. prs = handler.get_converted_issues(
  306. issue_numbers=[1], comment_id=specific_comment_id
  307. )
  308. # Verify that we got exactly one PR
  309. assert len(prs) == 1
  310. # Verify that thread_comments are set correctly
  311. assert prs[0].thread_comments == ['First comment']
  312. assert prs[0].review_comments == []
  313. assert prs[0].review_threads == []
  314. # Verify other fields are set correctly
  315. assert prs[0].number == 1
  316. assert prs[0].title == 'Test PR'
  317. assert prs[0].body == 'Test Body'
  318. assert prs[0].owner == 'test-owner'
  319. assert prs[0].repo == 'test-repo'
  320. assert prs[0].head_branch == 'test-branch'
  321. def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
  322. # Define the specific comment_id to filter
  323. specific_comment_id = 123
  324. # Mock GraphQL response for review threads
  325. with patch('requests.get') as mock_get:
  326. # Mock the response for PRs
  327. mock_prs_response = MagicMock()
  328. mock_prs_response.json.return_value = [
  329. {
  330. 'number': 1,
  331. 'title': 'Test PR',
  332. 'body': 'Test Body',
  333. 'head': {'ref': 'test-branch'},
  334. }
  335. ]
  336. # Mock the response for PR comments
  337. mock_comments_response = MagicMock()
  338. mock_comments_response.json.return_value = [
  339. {'body': 'First comment', 'id': 120},
  340. {'body': 'Second comment', 'id': 124},
  341. ]
  342. # Mock the response for PR metadata (GraphQL)
  343. mock_graphql_response = MagicMock()
  344. mock_graphql_response.json.return_value = {
  345. 'data': {
  346. 'repository': {
  347. 'pullRequest': {
  348. 'closingIssuesReferences': {'edges': []},
  349. 'reviews': {'nodes': []},
  350. 'reviewThreads': {
  351. 'edges': [
  352. {
  353. 'node': {
  354. 'id': 'review-thread-1',
  355. 'isResolved': False,
  356. 'comments': {
  357. 'nodes': [
  358. {
  359. 'fullDatabaseId': specific_comment_id,
  360. 'body': 'Specific review comment',
  361. 'path': 'file1.txt',
  362. },
  363. {
  364. 'fullDatabaseId': 456,
  365. 'body': 'Another review comment',
  366. 'path': 'file1.txt',
  367. },
  368. ]
  369. },
  370. }
  371. }
  372. ]
  373. },
  374. }
  375. }
  376. }
  377. }
  378. # Set up the mock to return different responses
  379. # We need to return empty responses for subsequent pages
  380. mock_empty_response = MagicMock()
  381. mock_empty_response.json.return_value = []
  382. mock_get.side_effect = [
  383. mock_prs_response, # First call for PRs
  384. mock_empty_response, # Second call for PRs (empty page)
  385. mock_comments_response, # Third call for PR comments
  386. mock_empty_response, # Fourth call for PR comments (empty page)
  387. ]
  388. # Mock the post request for GraphQL
  389. with patch('requests.post') as mock_post:
  390. mock_post.return_value = mock_graphql_response
  391. # Create an instance of PRHandler
  392. handler = PRHandler('test-owner', 'test-repo', 'test-token')
  393. # Get converted issues
  394. prs = handler.get_converted_issues(
  395. issue_numbers=[1], comment_id=specific_comment_id
  396. )
  397. # Verify that we got exactly one PR
  398. assert len(prs) == 1
  399. # Verify that thread_comments are set correctly
  400. assert prs[0].thread_comments is None
  401. assert prs[0].review_comments == []
  402. assert len(prs[0].review_threads) == 1
  403. assert isinstance(prs[0].review_threads[0], ReviewThread)
  404. assert (
  405. prs[0].review_threads[0].comment
  406. == 'Specific review comment\n---\nlatest feedback:\nAnother review comment\n'
  407. )
  408. assert prs[0].review_threads[0].files == ['file1.txt']
  409. # Verify other fields are set correctly
  410. assert prs[0].number == 1
  411. assert prs[0].title == 'Test PR'
  412. assert prs[0].body == 'Test Body'
  413. assert prs[0].owner == 'test-owner'
  414. assert prs[0].repo == 'test-repo'
  415. assert prs[0].head_branch == 'test-branch'
  416. def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
  417. # Define the specific comment_id to filter
  418. specific_comment_id = 123
  419. # Mock GraphQL response for review threads
  420. with patch('requests.get') as mock_get:
  421. # Mock the response for PRs
  422. mock_prs_response = MagicMock()
  423. mock_prs_response.json.return_value = [
  424. {
  425. 'number': 1,
  426. 'title': 'Test PR fixes #3',
  427. 'body': 'Test Body',
  428. 'head': {'ref': 'test-branch'},
  429. }
  430. ]
  431. # Mock the response for PR comments
  432. mock_comments_response = MagicMock()
  433. mock_comments_response.json.return_value = [
  434. {'body': 'First comment', 'id': 120},
  435. {'body': 'Second comment', 'id': 124},
  436. ]
  437. # Mock the response for PR metadata (GraphQL)
  438. mock_graphql_response = MagicMock()
  439. mock_graphql_response.json.return_value = {
  440. 'data': {
  441. 'repository': {
  442. 'pullRequest': {
  443. 'closingIssuesReferences': {'edges': []},
  444. 'reviews': {'nodes': []},
  445. 'reviewThreads': {
  446. 'edges': [
  447. {
  448. 'node': {
  449. 'id': 'review-thread-1',
  450. 'isResolved': False,
  451. 'comments': {
  452. 'nodes': [
  453. {
  454. 'fullDatabaseId': specific_comment_id,
  455. 'body': 'Specific review comment that references #6',
  456. 'path': 'file1.txt',
  457. },
  458. {
  459. 'fullDatabaseId': 456,
  460. 'body': 'Another review comment referencing #7',
  461. 'path': 'file2.txt',
  462. },
  463. ]
  464. },
  465. }
  466. }
  467. ]
  468. },
  469. }
  470. }
  471. }
  472. }
  473. # Set up the mock to return different responses
  474. # We need to return empty responses for subsequent pages
  475. mock_empty_response = MagicMock()
  476. mock_empty_response.json.return_value = []
  477. # Mock the response for fetching the external issue referenced in PR body
  478. mock_external_issue_response_in_body = MagicMock()
  479. mock_external_issue_response_in_body.json.return_value = {
  480. 'body': 'External context #1.'
  481. }
  482. # Mock the response for fetching the external issue referenced in review thread
  483. mock_external_issue_response_review_thread = MagicMock()
  484. mock_external_issue_response_review_thread.json.return_value = {
  485. 'body': 'External context #2.'
  486. }
  487. mock_get.side_effect = [
  488. mock_prs_response, # First call for PRs
  489. mock_empty_response, # Second call for PRs (empty page)
  490. mock_comments_response, # Third call for PR comments
  491. mock_empty_response, # Fourth call for PR comments (empty page)
  492. mock_external_issue_response_in_body,
  493. mock_external_issue_response_review_thread,
  494. ]
  495. # Mock the post request for GraphQL
  496. with patch('requests.post') as mock_post:
  497. mock_post.return_value = mock_graphql_response
  498. # Create an instance of PRHandler
  499. handler = PRHandler('test-owner', 'test-repo', 'test-token')
  500. # Get converted issues
  501. prs = handler.get_converted_issues(
  502. issue_numbers=[1], comment_id=specific_comment_id
  503. )
  504. # Verify that we got exactly one PR
  505. assert len(prs) == 1
  506. # Verify that thread_comments are set correctly
  507. assert prs[0].thread_comments is None
  508. assert prs[0].review_comments == []
  509. assert len(prs[0].review_threads) == 1
  510. assert isinstance(prs[0].review_threads[0], ReviewThread)
  511. assert (
  512. prs[0].review_threads[0].comment
  513. == 'Specific review comment that references #6\n---\nlatest feedback:\nAnother review comment referencing #7\n'
  514. )
  515. assert prs[0].closing_issues == [
  516. 'External context #1.',
  517. 'External context #2.',
  518. ] # Only includes references inside comment ID and body PR
  519. # Verify other fields are set correctly
  520. assert prs[0].number == 1
  521. assert prs[0].title == 'Test PR fixes #3'
  522. assert prs[0].body == 'Test Body'
  523. assert prs[0].owner == 'test-owner'
  524. assert prs[0].repo == 'test-repo'
  525. assert prs[0].head_branch == 'test-branch'
  526. def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
  527. # Mock the necessary dependencies
  528. with patch('requests.get') as mock_get:
  529. # Mock the response for PRs
  530. mock_prs_response = MagicMock()
  531. mock_prs_response.json.return_value = [
  532. {
  533. 'number': 1,
  534. 'title': 'Test PR',
  535. 'body': 'Test Body fixes #1',
  536. 'head': {'ref': 'test-branch'},
  537. }
  538. ]
  539. # Mock the response for PR comments
  540. mock_comments_response = MagicMock()
  541. mock_comments_response.json.return_value = [
  542. {'body': 'First comment addressing #1'},
  543. {'body': 'Second comment addressing #2'},
  544. ]
  545. # Mock the response for PR metadata (GraphQL)
  546. mock_graphql_response = MagicMock()
  547. mock_graphql_response.json.return_value = {
  548. 'data': {
  549. 'repository': {
  550. 'pullRequest': {
  551. 'closingIssuesReferences': {'edges': []},
  552. 'reviews': {'nodes': []},
  553. 'reviewThreads': {'edges': []},
  554. }
  555. }
  556. }
  557. }
  558. # Set up the mock to return different responses
  559. # We need to return empty responses for subsequent pages
  560. mock_empty_response = MagicMock()
  561. mock_empty_response.json.return_value = []
  562. # Mock the response for fetching the external issue referenced in PR body
  563. mock_external_issue_response_in_body = MagicMock()
  564. mock_external_issue_response_in_body.json.return_value = {
  565. 'body': 'External context #1.'
  566. }
  567. # Mock the response for fetching the external issue referenced in review thread
  568. mock_external_issue_response_in_comment = MagicMock()
  569. mock_external_issue_response_in_comment.json.return_value = {
  570. 'body': 'External context #2.'
  571. }
  572. mock_get.side_effect = [
  573. mock_prs_response, # First call for PRs
  574. mock_empty_response, # Second call for PRs (empty page)
  575. mock_comments_response, # Third call for PR comments
  576. mock_empty_response, # Fourth call for PR comments (empty page)
  577. mock_external_issue_response_in_body, # Mock response for the external issue reference #1
  578. mock_external_issue_response_in_comment,
  579. ]
  580. # Mock the post request for GraphQL
  581. with patch('requests.post') as mock_post:
  582. mock_post.return_value = mock_graphql_response
  583. # Create an instance of PRHandler
  584. handler = PRHandler('test-owner', 'test-repo', 'test-token')
  585. # Get converted issues
  586. prs = handler.get_converted_issues(issue_numbers=[1])
  587. # Verify that we got exactly one PR
  588. assert len(prs) == 1
  589. # Verify that thread_comments are set correctly
  590. assert prs[0].thread_comments == [
  591. 'First comment addressing #1',
  592. 'Second comment addressing #2',
  593. ]
  594. # Verify other fields are set correctly
  595. assert prs[0].number == 1
  596. assert prs[0].title == 'Test PR'
  597. assert prs[0].body == 'Test Body fixes #1'
  598. assert prs[0].owner == 'test-owner'
  599. assert prs[0].repo == 'test-repo'
  600. assert prs[0].head_branch == 'test-branch'
  601. assert prs[0].closing_issues == [
  602. 'External context #1.',
  603. 'External context #2.',
  604. ]