test_send_pull_request.py 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208
  1. import os
  2. import tempfile
  3. from unittest.mock import MagicMock, call, patch
  4. import pytest
  5. from openhands.core.config import LLMConfig
  6. from openhands.resolver.github_issue import ReviewThread
  7. from openhands.resolver.resolver_output import GithubIssue, ResolverOutput
  8. from openhands.resolver.send_pull_request import (
  9. apply_patch,
  10. initialize_repo,
  11. load_single_resolver_output,
  12. make_commit,
  13. process_all_successful_issues,
  14. process_single_issue,
  15. reply_to_comment,
  16. send_pull_request,
  17. update_existing_pull_request,
  18. )
  19. @pytest.fixture
  20. def mock_output_dir():
  21. with tempfile.TemporaryDirectory() as temp_dir:
  22. repo_path = os.path.join(temp_dir, 'repo')
  23. # Initialize a GitHub repo in "repo" and add a commit with "README.md"
  24. os.makedirs(repo_path)
  25. os.system(f'git init {repo_path}')
  26. readme_path = os.path.join(repo_path, 'README.md')
  27. with open(readme_path, 'w') as f:
  28. f.write('hello world')
  29. os.system(f'git -C {repo_path} add README.md')
  30. os.system(f"git -C {repo_path} commit -m 'Initial commit'")
  31. yield temp_dir
  32. @pytest.fixture
  33. def mock_github_issue():
  34. return GithubIssue(
  35. number=42,
  36. title='Test Issue',
  37. owner='test-owner',
  38. repo='test-repo',
  39. body='Test body',
  40. )
  41. @pytest.fixture
  42. def mock_llm_config():
  43. return LLMConfig()
  44. def test_load_single_resolver_output():
  45. mock_output_jsonl = 'tests/unit/resolver/mock_output/output.jsonl'
  46. # Test loading an existing issue
  47. resolver_output = load_single_resolver_output(mock_output_jsonl, 5)
  48. assert isinstance(resolver_output, ResolverOutput)
  49. assert resolver_output.issue.number == 5
  50. assert resolver_output.issue.title == 'Add MIT license'
  51. assert resolver_output.issue.owner == 'neubig'
  52. assert resolver_output.issue.repo == 'pr-viewer'
  53. # Test loading a non-existent issue
  54. with pytest.raises(ValueError):
  55. load_single_resolver_output(mock_output_jsonl, 999)
  56. def test_apply_patch(mock_output_dir):
  57. # Create a sample file in the mock repo
  58. sample_file = os.path.join(mock_output_dir, 'sample.txt')
  59. with open(sample_file, 'w') as f:
  60. f.write('Original content')
  61. # Create a sample patch
  62. patch_content = """
  63. diff --git a/sample.txt b/sample.txt
  64. index 9daeafb..b02def2 100644
  65. --- a/sample.txt
  66. +++ b/sample.txt
  67. @@ -1 +1,2 @@
  68. -Original content
  69. +Updated content
  70. +New line
  71. """
  72. # Apply the patch
  73. apply_patch(mock_output_dir, patch_content)
  74. # Check if the file was updated correctly
  75. with open(sample_file, 'r') as f:
  76. updated_content = f.read()
  77. assert updated_content.strip() == 'Updated content\nNew line'.strip()
  78. def test_apply_patch_preserves_line_endings(mock_output_dir):
  79. # Create sample files with different line endings
  80. unix_file = os.path.join(mock_output_dir, 'unix_style.txt')
  81. dos_file = os.path.join(mock_output_dir, 'dos_style.txt')
  82. with open(unix_file, 'w', newline='\n') as f:
  83. f.write('Line 1\nLine 2\nLine 3')
  84. with open(dos_file, 'w', newline='\r\n') as f:
  85. f.write('Line 1\r\nLine 2\r\nLine 3')
  86. # Create patches for both files
  87. unix_patch = """
  88. diff --git a/unix_style.txt b/unix_style.txt
  89. index 9daeafb..b02def2 100644
  90. --- a/unix_style.txt
  91. +++ b/unix_style.txt
  92. @@ -1,3 +1,3 @@
  93. Line 1
  94. -Line 2
  95. +Updated Line 2
  96. Line 3
  97. """
  98. dos_patch = """
  99. diff --git a/dos_style.txt b/dos_style.txt
  100. index 9daeafb..b02def2 100644
  101. --- a/dos_style.txt
  102. +++ b/dos_style.txt
  103. @@ -1,3 +1,3 @@
  104. Line 1
  105. -Line 2
  106. +Updated Line 2
  107. Line 3
  108. """
  109. # Apply patches
  110. apply_patch(mock_output_dir, unix_patch)
  111. apply_patch(mock_output_dir, dos_patch)
  112. # Check if line endings are preserved
  113. with open(unix_file, 'rb') as f:
  114. unix_content = f.read()
  115. with open(dos_file, 'rb') as f:
  116. dos_content = f.read()
  117. assert (
  118. b'\r\n' not in unix_content
  119. ), 'Unix-style line endings were changed to DOS-style'
  120. assert b'\r\n' in dos_content, 'DOS-style line endings were changed to Unix-style'
  121. # Check if content was updated correctly
  122. assert unix_content.decode('utf-8').split('\n')[1] == 'Updated Line 2'
  123. assert dos_content.decode('utf-8').split('\r\n')[1] == 'Updated Line 2'
  124. def test_apply_patch_create_new_file(mock_output_dir):
  125. # Create a patch that adds a new file
  126. patch_content = """
  127. diff --git a/new_file.txt b/new_file.txt
  128. new file mode 100644
  129. index 0000000..3b18e51
  130. --- /dev/null
  131. +++ b/new_file.txt
  132. @@ -0,0 +1 @@
  133. +hello world
  134. """
  135. # Apply the patch
  136. apply_patch(mock_output_dir, patch_content)
  137. # Check if the new file was created
  138. new_file_path = os.path.join(mock_output_dir, 'new_file.txt')
  139. assert os.path.exists(new_file_path), 'New file was not created'
  140. # Check if the file content is correct
  141. with open(new_file_path, 'r') as f:
  142. content = f.read().strip()
  143. assert content == 'hello world', 'File content is incorrect'
  144. def test_apply_patch_rename_file(mock_output_dir):
  145. # Create a sample file in the mock repo
  146. old_file = os.path.join(mock_output_dir, 'old_name.txt')
  147. with open(old_file, 'w') as f:
  148. f.write('This file will be renamed')
  149. # Create a patch that renames the file
  150. patch_content = """diff --git a/old_name.txt b/new_name.txt
  151. similarity index 100%
  152. rename from old_name.txt
  153. rename to new_name.txt"""
  154. # Apply the patch
  155. apply_patch(mock_output_dir, patch_content)
  156. # Check if the file was renamed
  157. new_file = os.path.join(mock_output_dir, 'new_name.txt')
  158. assert not os.path.exists(old_file), 'Old file still exists'
  159. assert os.path.exists(new_file), 'New file was not created'
  160. # Check if the content is preserved
  161. with open(new_file, 'r') as f:
  162. content = f.read()
  163. assert content == 'This file will be renamed'
  164. def test_apply_patch_delete_file(mock_output_dir):
  165. # Create a sample file in the mock repo
  166. sample_file = os.path.join(mock_output_dir, 'to_be_deleted.txt')
  167. with open(sample_file, 'w') as f:
  168. f.write('This file will be deleted')
  169. # Create a patch that deletes the file
  170. patch_content = """
  171. diff --git a/to_be_deleted.txt b/to_be_deleted.txt
  172. deleted file mode 100644
  173. index 9daeafb..0000000
  174. --- a/to_be_deleted.txt
  175. +++ /dev/null
  176. @@ -1 +0,0 @@
  177. -This file will be deleted
  178. """
  179. # Apply the patch
  180. apply_patch(mock_output_dir, patch_content)
  181. # Check if the file was deleted
  182. assert not os.path.exists(sample_file), 'File was not deleted'
  183. def test_initialize_repo(mock_output_dir):
  184. issue_type = 'issue'
  185. # Copy the repo to patches
  186. ISSUE_NUMBER = 3
  187. initialize_repo(mock_output_dir, ISSUE_NUMBER, issue_type)
  188. patches_dir = os.path.join(mock_output_dir, 'patches', f'issue_{ISSUE_NUMBER}')
  189. # Check if files were copied correctly
  190. assert os.path.exists(os.path.join(patches_dir, 'README.md'))
  191. # Check file contents
  192. with open(os.path.join(patches_dir, 'README.md'), 'r') as f:
  193. assert f.read() == 'hello world'
  194. @patch('openhands.resolver.send_pull_request.reply_to_comment')
  195. @patch('requests.post')
  196. @patch('subprocess.run')
  197. def test_update_existing_pull_request(
  198. mock_subprocess_run, mock_requests_post, mock_reply_to_comment
  199. ):
  200. # Arrange: Set up test data
  201. github_issue = GithubIssue(
  202. owner='test-owner',
  203. repo='test-repo',
  204. number=1,
  205. title='Test PR',
  206. body='This is a test PR',
  207. thread_ids=['comment1', 'comment2'],
  208. head_branch='test-branch',
  209. )
  210. github_token = 'test-token'
  211. github_username = 'test-user'
  212. patch_dir = '/path/to/patch'
  213. additional_message = '["Fixed bug in function A", "Updated documentation for B"]'
  214. # Mock the subprocess.run call for git push
  215. mock_subprocess_run.return_value = MagicMock(returncode=0)
  216. # Mock the requests.post call for adding a PR comment
  217. mock_requests_post.return_value.status_code = 201
  218. mock_completion_response = MagicMock()
  219. mock_completion_response.choices = [
  220. MagicMock(message=MagicMock(content='This is an issue resolution.'))
  221. ]
  222. llm_config = LLMConfig()
  223. # Act: Call the function without comment_message to test auto-generation
  224. with patch('litellm.completion', MagicMock(return_value=mock_completion_response)):
  225. result = update_existing_pull_request(
  226. github_issue,
  227. github_token,
  228. github_username,
  229. patch_dir,
  230. llm_config,
  231. comment_message=None,
  232. additional_message=additional_message,
  233. )
  234. # Assert: Check if the git push command was executed
  235. push_command = (
  236. f'git -C {patch_dir} push '
  237. f'https://{github_username}:{github_token}@github.com/'
  238. f'{github_issue.owner}/{github_issue.repo}.git {github_issue.head_branch}'
  239. )
  240. mock_subprocess_run.assert_called_once_with(
  241. push_command, shell=True, capture_output=True, text=True
  242. )
  243. # Assert: Check if the auto-generated comment was posted to the PR
  244. comment_url = f'https://api.github.com/repos/{github_issue.owner}/{github_issue.repo}/issues/{github_issue.number}/comments'
  245. expected_comment = 'This is an issue resolution.'
  246. mock_requests_post.assert_called_once_with(
  247. comment_url,
  248. headers={
  249. 'Authorization': f'token {github_token}',
  250. 'Accept': 'application/vnd.github.v3+json',
  251. },
  252. json={'body': expected_comment},
  253. )
  254. # Assert: Check if the reply_to_comment function was called for each thread ID
  255. mock_reply_to_comment.assert_has_calls(
  256. [
  257. call(github_token, 'comment1', 'Fixed bug in function A'),
  258. call(github_token, 'comment2', 'Updated documentation for B'),
  259. ]
  260. )
  261. # Assert: Check the returned PR URL
  262. assert (
  263. result
  264. == f'https://github.com/{github_issue.owner}/{github_issue.repo}/pull/{github_issue.number}'
  265. )
  266. @pytest.mark.parametrize(
  267. 'pr_type,target_branch',
  268. [
  269. ('branch', None),
  270. ('draft', None),
  271. ('ready', None),
  272. ('branch', 'feature'),
  273. ('draft', 'develop'),
  274. ('ready', 'staging'),
  275. ],
  276. )
  277. @patch('subprocess.run')
  278. @patch('requests.post')
  279. @patch('requests.get')
  280. def test_send_pull_request(
  281. mock_get,
  282. mock_post,
  283. mock_run,
  284. mock_github_issue,
  285. mock_output_dir,
  286. mock_llm_config,
  287. pr_type,
  288. target_branch,
  289. ):
  290. repo_path = os.path.join(mock_output_dir, 'repo')
  291. # Mock API responses based on whether target_branch is specified
  292. if target_branch:
  293. mock_get.side_effect = [
  294. MagicMock(status_code=404), # Branch doesn't exist
  295. MagicMock(status_code=200), # Target branch exists
  296. ]
  297. else:
  298. mock_get.side_effect = [
  299. MagicMock(status_code=404), # Branch doesn't exist
  300. MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
  301. ]
  302. mock_post.return_value.json.return_value = {
  303. 'html_url': 'https://github.com/test-owner/test-repo/pull/1'
  304. }
  305. # Mock subprocess.run calls
  306. mock_run.side_effect = [
  307. MagicMock(returncode=0), # git checkout -b
  308. MagicMock(returncode=0), # git push
  309. ]
  310. # Call the function
  311. result = send_pull_request(
  312. github_issue=mock_github_issue,
  313. github_token='test-token',
  314. github_username='test-user',
  315. patch_dir=repo_path,
  316. pr_type=pr_type,
  317. llm_config=mock_llm_config,
  318. target_branch=target_branch,
  319. )
  320. # Assert API calls
  321. expected_get_calls = 2
  322. assert mock_get.call_count == expected_get_calls
  323. # Check branch creation and push
  324. assert mock_run.call_count == 2
  325. checkout_call, push_call = mock_run.call_args_list
  326. assert checkout_call == call(
  327. ['git', '-C', repo_path, 'checkout', '-b', 'openhands-fix-issue-42'],
  328. capture_output=True,
  329. text=True,
  330. )
  331. assert push_call == call(
  332. [
  333. 'git',
  334. '-C',
  335. repo_path,
  336. 'push',
  337. 'https://test-user:test-token@github.com/test-owner/test-repo.git',
  338. 'openhands-fix-issue-42',
  339. ],
  340. capture_output=True,
  341. text=True,
  342. )
  343. # Check PR creation based on pr_type
  344. if pr_type == 'branch':
  345. assert (
  346. result
  347. == 'https://github.com/test-owner/test-repo/compare/openhands-fix-issue-42?expand=1'
  348. )
  349. mock_post.assert_not_called()
  350. else:
  351. assert result == 'https://github.com/test-owner/test-repo/pull/1'
  352. mock_post.assert_called_once()
  353. post_data = mock_post.call_args[1]['json']
  354. assert post_data['title'] == 'Fix issue #42: Test Issue'
  355. assert post_data['body'].startswith('This pull request fixes #42.')
  356. assert post_data['head'] == 'openhands-fix-issue-42'
  357. assert post_data['base'] == (target_branch if target_branch else 'main')
  358. assert post_data['draft'] == (pr_type == 'draft')
  359. @patch('requests.get')
  360. def test_send_pull_request_invalid_target_branch(
  361. mock_get, mock_github_issue, mock_output_dir, mock_llm_config
  362. ):
  363. """Test that an error is raised when specifying a non-existent target branch"""
  364. repo_path = os.path.join(mock_output_dir, 'repo')
  365. # Mock API response for non-existent branch
  366. mock_get.side_effect = [
  367. MagicMock(status_code=404), # Branch doesn't exist
  368. MagicMock(status_code=404), # Target branch doesn't exist
  369. ]
  370. # Test that ValueError is raised when target branch doesn't exist
  371. with pytest.raises(
  372. ValueError, match='Target branch nonexistent-branch does not exist'
  373. ):
  374. send_pull_request(
  375. github_issue=mock_github_issue,
  376. github_token='test-token',
  377. github_username='test-user',
  378. patch_dir=repo_path,
  379. pr_type='ready',
  380. llm_config=mock_llm_config,
  381. target_branch='nonexistent-branch',
  382. )
  383. # Verify API calls
  384. assert mock_get.call_count == 2
  385. @patch('subprocess.run')
  386. @patch('requests.post')
  387. @patch('requests.get')
  388. def test_send_pull_request_git_push_failure(
  389. mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir, mock_llm_config
  390. ):
  391. repo_path = os.path.join(mock_output_dir, 'repo')
  392. # Mock API responses
  393. mock_get.return_value = MagicMock(json=lambda: {'default_branch': 'main'})
  394. # Mock the subprocess.run calls
  395. mock_run.side_effect = [
  396. MagicMock(returncode=0), # git checkout -b
  397. MagicMock(returncode=1, stderr='Error: failed to push some refs'), # git push
  398. ]
  399. # Test that RuntimeError is raised when git push fails
  400. with pytest.raises(
  401. RuntimeError, match='Failed to push changes to the remote repository'
  402. ):
  403. send_pull_request(
  404. github_issue=mock_github_issue,
  405. github_token='test-token',
  406. github_username='test-user',
  407. patch_dir=repo_path,
  408. pr_type='ready',
  409. llm_config=mock_llm_config,
  410. )
  411. # Assert that subprocess.run was called twice
  412. assert mock_run.call_count == 2
  413. # Check the git checkout -b command
  414. checkout_call = mock_run.call_args_list[0]
  415. assert checkout_call[0][0] == [
  416. 'git',
  417. '-C',
  418. repo_path,
  419. 'checkout',
  420. '-b',
  421. 'openhands-fix-issue-42',
  422. ]
  423. # Check the git push command
  424. push_call = mock_run.call_args_list[1]
  425. assert push_call[0][0] == [
  426. 'git',
  427. '-C',
  428. repo_path,
  429. 'push',
  430. 'https://test-user:test-token@github.com/test-owner/test-repo.git',
  431. 'openhands-fix-issue-42',
  432. ]
  433. # Assert that no pull request was created
  434. mock_post.assert_not_called()
  435. @patch('subprocess.run')
  436. @patch('requests.post')
  437. @patch('requests.get')
  438. def test_send_pull_request_permission_error(
  439. mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir, mock_llm_config
  440. ):
  441. repo_path = os.path.join(mock_output_dir, 'repo')
  442. # Mock API responses
  443. mock_get.return_value = MagicMock(json=lambda: {'default_branch': 'main'})
  444. mock_post.return_value.status_code = 403
  445. # Mock subprocess.run calls
  446. mock_run.side_effect = [
  447. MagicMock(returncode=0), # git checkout -b
  448. MagicMock(returncode=0), # git push
  449. ]
  450. # Test that RuntimeError is raised when PR creation fails due to permissions
  451. with pytest.raises(
  452. RuntimeError, match='Failed to create pull request due to missing permissions.'
  453. ):
  454. send_pull_request(
  455. github_issue=mock_github_issue,
  456. github_token='test-token',
  457. github_username='test-user',
  458. patch_dir=repo_path,
  459. pr_type='ready',
  460. llm_config=mock_llm_config,
  461. )
  462. # Assert that the branch was created and pushed
  463. assert mock_run.call_count == 2
  464. mock_post.assert_called_once()
  465. @patch('requests.post')
  466. def test_reply_to_comment(mock_post):
  467. # Arrange: set up the test data
  468. github_token = 'test_token'
  469. comment_id = 'test_comment_id'
  470. reply = 'This is a test reply.'
  471. # Mock the response from the GraphQL API
  472. mock_response = MagicMock()
  473. mock_response.status_code = 200
  474. mock_response.json.return_value = {
  475. 'data': {
  476. 'addPullRequestReviewThreadReply': {
  477. 'comment': {
  478. 'id': 'test_reply_id',
  479. 'body': 'Openhands fix success summary\n\n\nThis is a test reply.',
  480. 'createdAt': '2024-10-01T12:34:56Z',
  481. }
  482. }
  483. }
  484. }
  485. mock_post.return_value = mock_response
  486. # Act: call the function
  487. reply_to_comment(github_token, comment_id, reply)
  488. # Assert: check that the POST request was made with the correct parameters
  489. query = """
  490. mutation($body: String!, $pullRequestReviewThreadId: ID!) {
  491. addPullRequestReviewThreadReply(input: { body: $body, pullRequestReviewThreadId: $pullRequestReviewThreadId }) {
  492. comment {
  493. id
  494. body
  495. createdAt
  496. }
  497. }
  498. }
  499. """
  500. expected_variables = {
  501. 'body': 'Openhands fix success summary\n\n\nThis is a test reply.',
  502. 'pullRequestReviewThreadId': comment_id,
  503. }
  504. # Check that the correct request was made to the API
  505. mock_post.assert_called_once_with(
  506. 'https://api.github.com/graphql',
  507. json={'query': query, 'variables': expected_variables},
  508. headers={
  509. 'Authorization': f'Bearer {github_token}',
  510. 'Content-Type': 'application/json',
  511. },
  512. )
  513. # Check that the response status was checked (via response.raise_for_status)
  514. mock_response.raise_for_status.assert_called_once()
  515. @patch('openhands.resolver.send_pull_request.initialize_repo')
  516. @patch('openhands.resolver.send_pull_request.apply_patch')
  517. @patch('openhands.resolver.send_pull_request.update_existing_pull_request')
  518. @patch('openhands.resolver.send_pull_request.make_commit')
  519. def test_process_single_pr_update(
  520. mock_make_commit,
  521. mock_update_existing_pull_request,
  522. mock_apply_patch,
  523. mock_initialize_repo,
  524. mock_output_dir,
  525. mock_llm_config,
  526. ):
  527. # Initialize test data
  528. github_token = 'test_token'
  529. github_username = 'test_user'
  530. pr_type = 'draft'
  531. resolver_output = ResolverOutput(
  532. issue=GithubIssue(
  533. owner='test-owner',
  534. repo='test-repo',
  535. number=1,
  536. title='Issue 1',
  537. body='Body 1',
  538. closing_issues=[],
  539. review_threads=[
  540. ReviewThread(comment='review comment for feedback', files=[])
  541. ],
  542. thread_ids=['1'],
  543. head_branch='branch 1',
  544. ),
  545. issue_type='pr',
  546. instruction='Test instruction 1',
  547. base_commit='def456',
  548. git_patch='Test patch 1',
  549. history=[],
  550. metrics={},
  551. success=True,
  552. comment_success=None,
  553. success_explanation='[Test success 1]',
  554. error=None,
  555. )
  556. mock_update_existing_pull_request.return_value = (
  557. 'https://github.com/test-owner/test-repo/pull/1'
  558. )
  559. mock_initialize_repo.return_value = f'{mock_output_dir}/patches/pr_1'
  560. process_single_issue(
  561. mock_output_dir,
  562. resolver_output,
  563. github_token,
  564. github_username,
  565. pr_type,
  566. mock_llm_config,
  567. None,
  568. False,
  569. None,
  570. )
  571. mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'pr', 'branch 1')
  572. mock_apply_patch.assert_called_once_with(
  573. f'{mock_output_dir}/patches/pr_1', resolver_output.git_patch
  574. )
  575. mock_make_commit.assert_called_once_with(
  576. f'{mock_output_dir}/patches/pr_1', resolver_output.issue, 'pr'
  577. )
  578. mock_update_existing_pull_request.assert_called_once_with(
  579. github_issue=resolver_output.issue,
  580. github_token=github_token,
  581. github_username=github_username,
  582. patch_dir=f'{mock_output_dir}/patches/pr_1',
  583. additional_message='[Test success 1]',
  584. llm_config=mock_llm_config,
  585. )
  586. @patch('openhands.resolver.send_pull_request.initialize_repo')
  587. @patch('openhands.resolver.send_pull_request.apply_patch')
  588. @patch('openhands.resolver.send_pull_request.send_pull_request')
  589. @patch('openhands.resolver.send_pull_request.make_commit')
  590. def test_process_single_issue(
  591. mock_make_commit,
  592. mock_send_pull_request,
  593. mock_apply_patch,
  594. mock_initialize_repo,
  595. mock_output_dir,
  596. mock_llm_config,
  597. ):
  598. # Initialize test data
  599. github_token = 'test_token'
  600. github_username = 'test_user'
  601. pr_type = 'draft'
  602. resolver_output = ResolverOutput(
  603. issue=GithubIssue(
  604. owner='test-owner',
  605. repo='test-repo',
  606. number=1,
  607. title='Issue 1',
  608. body='Body 1',
  609. ),
  610. issue_type='issue',
  611. instruction='Test instruction 1',
  612. base_commit='def456',
  613. git_patch='Test patch 1',
  614. history=[],
  615. metrics={},
  616. success=True,
  617. comment_success=None,
  618. success_explanation='Test success 1',
  619. error=None,
  620. )
  621. # Mock return value
  622. mock_send_pull_request.return_value = (
  623. 'https://github.com/test-owner/test-repo/pull/1'
  624. )
  625. mock_initialize_repo.return_value = f'{mock_output_dir}/patches/issue_1'
  626. # Call the function
  627. process_single_issue(
  628. mock_output_dir,
  629. resolver_output,
  630. github_token,
  631. github_username,
  632. pr_type,
  633. mock_llm_config,
  634. None,
  635. False,
  636. None,
  637. )
  638. # Assert that the mocked functions were called with correct arguments
  639. mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'issue', 'def456')
  640. mock_apply_patch.assert_called_once_with(
  641. f'{mock_output_dir}/patches/issue_1', resolver_output.git_patch
  642. )
  643. mock_make_commit.assert_called_once_with(
  644. f'{mock_output_dir}/patches/issue_1', resolver_output.issue, 'issue'
  645. )
  646. mock_send_pull_request.assert_called_once_with(
  647. github_issue=resolver_output.issue,
  648. github_token=github_token,
  649. github_username=github_username,
  650. patch_dir=f'{mock_output_dir}/patches/issue_1',
  651. pr_type=pr_type,
  652. llm_config=mock_llm_config,
  653. fork_owner=None,
  654. additional_message=resolver_output.success_explanation,
  655. target_branch=None,
  656. )
  657. @patch('openhands.resolver.send_pull_request.initialize_repo')
  658. @patch('openhands.resolver.send_pull_request.apply_patch')
  659. @patch('openhands.resolver.send_pull_request.send_pull_request')
  660. @patch('openhands.resolver.send_pull_request.make_commit')
  661. def test_process_single_issue_unsuccessful(
  662. mock_make_commit,
  663. mock_send_pull_request,
  664. mock_apply_patch,
  665. mock_initialize_repo,
  666. mock_output_dir,
  667. mock_llm_config,
  668. ):
  669. # Initialize test data
  670. github_token = 'test_token'
  671. github_username = 'test_user'
  672. pr_type = 'draft'
  673. resolver_output = ResolverOutput(
  674. issue=GithubIssue(
  675. owner='test-owner',
  676. repo='test-repo',
  677. number=1,
  678. title='Issue 1',
  679. body='Body 1',
  680. ),
  681. issue_type='issue',
  682. instruction='Test instruction 1',
  683. base_commit='def456',
  684. git_patch='Test patch 1',
  685. history=[],
  686. metrics={},
  687. success=False,
  688. comment_success=None,
  689. success_explanation='',
  690. error='Test error',
  691. )
  692. # Call the function
  693. process_single_issue(
  694. mock_output_dir,
  695. resolver_output,
  696. github_token,
  697. github_username,
  698. pr_type,
  699. mock_llm_config,
  700. None,
  701. False,
  702. None,
  703. )
  704. # Assert that none of the mocked functions were called
  705. mock_initialize_repo.assert_not_called()
  706. mock_apply_patch.assert_not_called()
  707. mock_make_commit.assert_not_called()
  708. mock_send_pull_request.assert_not_called()
  709. @patch('openhands.resolver.send_pull_request.load_all_resolver_outputs')
  710. @patch('openhands.resolver.send_pull_request.process_single_issue')
  711. def test_process_all_successful_issues(
  712. mock_process_single_issue, mock_load_all_resolver_outputs, mock_llm_config
  713. ):
  714. # Create ResolverOutput objects with properly initialized GithubIssue instances
  715. resolver_output_1 = ResolverOutput(
  716. issue=GithubIssue(
  717. owner='test-owner',
  718. repo='test-repo',
  719. number=1,
  720. title='Issue 1',
  721. body='Body 1',
  722. ),
  723. issue_type='issue',
  724. instruction='Test instruction 1',
  725. base_commit='def456',
  726. git_patch='Test patch 1',
  727. history=[],
  728. metrics={},
  729. success=True,
  730. comment_success=None,
  731. success_explanation='Test success 1',
  732. error=None,
  733. )
  734. resolver_output_2 = ResolverOutput(
  735. issue=GithubIssue(
  736. owner='test-owner',
  737. repo='test-repo',
  738. number=2,
  739. title='Issue 2',
  740. body='Body 2',
  741. ),
  742. issue_type='issue',
  743. instruction='Test instruction 2',
  744. base_commit='ghi789',
  745. git_patch='Test patch 2',
  746. history=[],
  747. metrics={},
  748. success=False,
  749. comment_success=None,
  750. success_explanation='',
  751. error='Test error 2',
  752. )
  753. resolver_output_3 = ResolverOutput(
  754. issue=GithubIssue(
  755. owner='test-owner',
  756. repo='test-repo',
  757. number=3,
  758. title='Issue 3',
  759. body='Body 3',
  760. ),
  761. issue_type='issue',
  762. instruction='Test instruction 3',
  763. base_commit='jkl012',
  764. git_patch='Test patch 3',
  765. history=[],
  766. metrics={},
  767. success=True,
  768. comment_success=None,
  769. success_explanation='Test success 3',
  770. error=None,
  771. )
  772. mock_load_all_resolver_outputs.return_value = [
  773. resolver_output_1,
  774. resolver_output_2,
  775. resolver_output_3,
  776. ]
  777. # Call the function
  778. process_all_successful_issues(
  779. 'output_dir',
  780. 'github_token',
  781. 'github_username',
  782. 'draft',
  783. mock_llm_config, # llm_config
  784. None, # fork_owner
  785. )
  786. # Assert that process_single_issue was called for successful issues only
  787. assert mock_process_single_issue.call_count == 2
  788. # Check that the function was called with the correct arguments for successful issues
  789. mock_process_single_issue.assert_has_calls(
  790. [
  791. call(
  792. 'output_dir',
  793. resolver_output_1,
  794. 'github_token',
  795. 'github_username',
  796. 'draft',
  797. mock_llm_config,
  798. None,
  799. False,
  800. None,
  801. ),
  802. call(
  803. 'output_dir',
  804. resolver_output_3,
  805. 'github_token',
  806. 'github_username',
  807. 'draft',
  808. mock_llm_config,
  809. None,
  810. False,
  811. None,
  812. ),
  813. ]
  814. )
  815. # Add more assertions as needed to verify the behavior of the function
  816. @patch('requests.get')
  817. @patch('subprocess.run')
  818. def test_send_pull_request_branch_naming(
  819. mock_run, mock_get, mock_github_issue, mock_output_dir, mock_llm_config
  820. ):
  821. repo_path = os.path.join(mock_output_dir, 'repo')
  822. # Mock API responses
  823. mock_get.side_effect = [
  824. MagicMock(status_code=200), # First branch exists
  825. MagicMock(status_code=200), # Second branch exists
  826. MagicMock(status_code=404), # Third branch doesn't exist
  827. MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
  828. ]
  829. # Mock subprocess.run calls
  830. mock_run.side_effect = [
  831. MagicMock(returncode=0), # git checkout -b
  832. MagicMock(returncode=0), # git push
  833. ]
  834. # Call the function
  835. result = send_pull_request(
  836. github_issue=mock_github_issue,
  837. github_token='test-token',
  838. github_username='test-user',
  839. patch_dir=repo_path,
  840. pr_type='branch',
  841. llm_config=mock_llm_config,
  842. )
  843. # Assert API calls
  844. assert mock_get.call_count == 4
  845. # Check branch creation and push
  846. assert mock_run.call_count == 2
  847. checkout_call, push_call = mock_run.call_args_list
  848. assert checkout_call == call(
  849. ['git', '-C', repo_path, 'checkout', '-b', 'openhands-fix-issue-42-try3'],
  850. capture_output=True,
  851. text=True,
  852. )
  853. assert push_call == call(
  854. [
  855. 'git',
  856. '-C',
  857. repo_path,
  858. 'push',
  859. 'https://test-user:test-token@github.com/test-owner/test-repo.git',
  860. 'openhands-fix-issue-42-try3',
  861. ],
  862. capture_output=True,
  863. text=True,
  864. )
  865. # Check the result
  866. assert (
  867. result
  868. == 'https://github.com/test-owner/test-repo/compare/openhands-fix-issue-42-try3?expand=1'
  869. )
  870. @patch('openhands.resolver.send_pull_request.argparse.ArgumentParser')
  871. @patch('openhands.resolver.send_pull_request.process_all_successful_issues')
  872. @patch('openhands.resolver.send_pull_request.process_single_issue')
  873. @patch('openhands.resolver.send_pull_request.load_single_resolver_output')
  874. @patch('os.path.exists')
  875. @patch('os.getenv')
  876. def test_main(
  877. mock_getenv,
  878. mock_path_exists,
  879. mock_load_single_resolver_output,
  880. mock_process_single_issue,
  881. mock_process_all_successful_issues,
  882. mock_parser,
  883. ):
  884. from openhands.resolver.send_pull_request import main
  885. # Setup mock parser
  886. mock_args = MagicMock()
  887. mock_args.github_token = None
  888. mock_args.github_username = 'mock_username'
  889. mock_args.output_dir = '/mock/output'
  890. mock_args.pr_type = 'draft'
  891. mock_args.issue_number = '42'
  892. mock_args.fork_owner = None
  893. mock_args.send_on_failure = False
  894. mock_args.llm_model = 'mock_model'
  895. mock_args.llm_base_url = 'mock_url'
  896. mock_args.llm_api_key = 'mock_key'
  897. mock_args.target_branch = None
  898. mock_parser.return_value.parse_args.return_value = mock_args
  899. # Setup environment variables
  900. mock_getenv.side_effect = (
  901. lambda key, default=None: 'mock_token' if key == 'GITHUB_TOKEN' else default
  902. )
  903. # Setup path exists
  904. mock_path_exists.return_value = True
  905. # Setup mock resolver output
  906. mock_resolver_output = MagicMock()
  907. mock_load_single_resolver_output.return_value = mock_resolver_output
  908. # Run main function
  909. main()
  910. llm_config = LLMConfig(
  911. model=mock_args.llm_model,
  912. base_url=mock_args.llm_base_url,
  913. api_key=mock_args.llm_api_key,
  914. )
  915. # Use any_call instead of assert_called_with for more flexible matching
  916. assert mock_process_single_issue.call_args == call(
  917. '/mock/output',
  918. mock_resolver_output,
  919. 'mock_token',
  920. 'mock_username',
  921. 'draft',
  922. llm_config,
  923. None,
  924. False,
  925. mock_args.target_branch,
  926. )
  927. # Other assertions
  928. mock_parser.assert_called_once()
  929. mock_getenv.assert_any_call('GITHUB_TOKEN')
  930. mock_path_exists.assert_called_with('/mock/output')
  931. mock_load_single_resolver_output.assert_called_with('/mock/output/output.jsonl', 42)
  932. # Test for 'all_successful' issue number
  933. mock_args.issue_number = 'all_successful'
  934. main()
  935. mock_process_all_successful_issues.assert_called_with(
  936. '/mock/output',
  937. 'mock_token',
  938. 'mock_username',
  939. 'draft',
  940. llm_config,
  941. None,
  942. )
  943. # Test for invalid issue number
  944. mock_args.issue_number = 'invalid'
  945. with pytest.raises(ValueError):
  946. main()
  947. @patch('subprocess.run')
  948. def test_make_commit_escapes_issue_title(mock_subprocess_run):
  949. # Setup
  950. repo_dir = '/path/to/repo'
  951. issue = GithubIssue(
  952. owner='test-owner',
  953. repo='test-repo',
  954. number=42,
  955. title='Issue with "quotes" and $pecial characters',
  956. body='Test body',
  957. )
  958. # Mock subprocess.run to return success for all calls
  959. mock_subprocess_run.return_value = MagicMock(
  960. returncode=0, stdout='sample output', stderr=''
  961. )
  962. # Call the function
  963. issue_type = 'issue'
  964. make_commit(repo_dir, issue, issue_type)
  965. # Assert that subprocess.run was called with the correct arguments
  966. calls = mock_subprocess_run.call_args_list
  967. assert len(calls) == 4 # git config check, git add, git commit
  968. # Check the git commit call
  969. git_commit_call = calls[3][0][0]
  970. expected_commit_message = (
  971. 'Fix issue #42: Issue with "quotes" and $pecial characters'
  972. )
  973. assert [
  974. 'git',
  975. '-C',
  976. '/path/to/repo',
  977. 'commit',
  978. '-m',
  979. expected_commit_message,
  980. ] == git_commit_call
  981. @patch('subprocess.run')
  982. def test_make_commit_no_changes(mock_subprocess_run):
  983. # Setup
  984. repo_dir = '/path/to/repo'
  985. issue = GithubIssue(
  986. owner='test-owner',
  987. repo='test-repo',
  988. number=42,
  989. title='Issue with no changes',
  990. body='Test body',
  991. )
  992. # Mock subprocess.run to simulate no changes in the repo
  993. mock_subprocess_run.side_effect = [
  994. MagicMock(returncode=0),
  995. MagicMock(returncode=0),
  996. MagicMock(returncode=1, stdout=''), # git status --porcelain (no changes)
  997. ]
  998. with pytest.raises(
  999. RuntimeError, match='ERROR: Openhands failed to make code changes.'
  1000. ):
  1001. make_commit(repo_dir, issue, 'issue')
  1002. # Check that subprocess.run was called for checking git status and add, but not commit
  1003. assert mock_subprocess_run.call_count == 3
  1004. git_status_call = mock_subprocess_run.call_args_list[2][0][0]
  1005. assert f'git -C {repo_dir} status --porcelain' in git_status_call
  1006. def test_apply_patch_rename_directory(mock_output_dir):
  1007. # Create a sample directory structure
  1008. old_dir = os.path.join(mock_output_dir, 'prompts', 'resolve')
  1009. os.makedirs(old_dir)
  1010. # Create test files
  1011. test_files = [
  1012. 'issue-success-check.jinja',
  1013. 'pr-feedback-check.jinja',
  1014. 'pr-thread-check.jinja',
  1015. ]
  1016. for filename in test_files:
  1017. file_path = os.path.join(old_dir, filename)
  1018. with open(file_path, 'w') as f:
  1019. f.write(f'Content of {filename}')
  1020. # Create a patch that renames the directory
  1021. patch_content = """diff --git a/prompts/resolve/issue-success-check.jinja b/prompts/guess_success/issue-success-check.jinja
  1022. similarity index 100%
  1023. rename from prompts/resolve/issue-success-check.jinja
  1024. rename to prompts/guess_success/issue-success-check.jinja
  1025. diff --git a/prompts/resolve/pr-feedback-check.jinja b/prompts/guess_success/pr-feedback-check.jinja
  1026. similarity index 100%
  1027. rename from prompts/resolve/pr-feedback-check.jinja
  1028. rename to prompts/guess_success/pr-feedback-check.jinja
  1029. diff --git a/prompts/resolve/pr-thread-check.jinja b/prompts/guess_success/pr-thread-check.jinja
  1030. similarity index 100%
  1031. rename from prompts/resolve/pr-thread-check.jinja
  1032. rename to prompts/guess_success/pr-thread-check.jinja"""
  1033. # Apply the patch
  1034. apply_patch(mock_output_dir, patch_content)
  1035. # Check if files were moved correctly
  1036. new_dir = os.path.join(mock_output_dir, 'prompts', 'guess_success')
  1037. assert not os.path.exists(old_dir), 'Old directory still exists'
  1038. assert os.path.exists(new_dir), 'New directory was not created'
  1039. # Check if all files were moved and content preserved
  1040. for filename in test_files:
  1041. old_path = os.path.join(old_dir, filename)
  1042. new_path = os.path.join(new_dir, filename)
  1043. assert not os.path.exists(old_path), f'Old file {filename} still exists'
  1044. assert os.path.exists(new_path), f'New file {filename} was not created'
  1045. with open(new_path, 'r') as f:
  1046. content = f.read()
  1047. assert content == f'Content of {filename}', f'Content mismatch for {filename}'