Back to Repositories

Testing Pull Request Management Operations in OpenHands

This test suite verifies the pull request creation and management functionality in the OpenHands project. It includes comprehensive testing of PR creation, patch application, commit handling, and GitHub API interactions.

Test Coverage Overview

The test suite provides extensive coverage of pull request workflows including:

  • Pull request creation and updates
  • Patch application and file modifications
  • Branch management and naming
  • Error handling and edge cases
  • GitHub API interactions and authentication
Key integration points include git operations, GitHub API endpoints, and file system interactions.

Implementation Analysis

The testing approach uses pytest fixtures and mocking extensively to isolate components:

The tests utilize pytest’s parameterization and fixtures to manage test data and dependencies. Mock objects simulate GitHub API responses and git operations. The implementation covers both happy paths and error scenarios with proper assertions.

Technical Details

  • Testing Framework: pytest
  • Mock Libraries: unittest.mock
  • Test Fixtures: mock_output_dir, mock_github_issue, mock_llm_config
  • Key Dependencies: requests, subprocess, os
  • Test Data: Sample files, patches, API responses

Best Practices Demonstrated

The test suite demonstrates several testing best practices:

  • Comprehensive mocking of external dependencies
  • Proper test isolation and cleanup
  • Thorough error case coverage
  • Clear test naming and organization
  • Effective use of pytest fixtures and parameterization

all-hands-ai/openhands

tests/unit/resolver/test_send_pull_request.py

            
import os
import tempfile
from unittest.mock import MagicMock, call, patch

import pytest

from openhands.core.config import LLMConfig
from openhands.resolver.github_issue import ReviewThread
from openhands.resolver.resolver_output import GithubIssue, ResolverOutput
from openhands.resolver.send_pull_request import (
    apply_patch,
    initialize_repo,
    load_single_resolver_output,
    make_commit,
    process_all_successful_issues,
    process_single_issue,
    reply_to_comment,
    send_pull_request,
    update_existing_pull_request,
)


@pytest.fixture
def mock_output_dir():
    with tempfile.TemporaryDirectory() as temp_dir:
        repo_path = os.path.join(temp_dir, 'repo')
        # Initialize a GitHub repo in "repo" and add a commit with "README.md"
        os.makedirs(repo_path)
        os.system(f'git init {repo_path}')
        readme_path = os.path.join(repo_path, 'README.md')
        with open(readme_path, 'w') as f:
            f.write('hello world')
        os.system(f'git -C {repo_path} add README.md')
        os.system(f"git -C {repo_path} commit -m 'Initial commit'")
        yield temp_dir


@pytest.fixture
def mock_github_issue():
    return GithubIssue(
        number=42,
        title='Test Issue',
        owner='test-owner',
        repo='test-repo',
        body='Test body',
    )


@pytest.fixture
def mock_llm_config():
    return LLMConfig()


def test_load_single_resolver_output():
    mock_output_jsonl = 'tests/unit/resolver/mock_output/output.jsonl'

    # Test loading an existing issue
    resolver_output = load_single_resolver_output(mock_output_jsonl, 5)
    assert isinstance(resolver_output, ResolverOutput)
    assert resolver_output.issue.number == 5
    assert resolver_output.issue.title == 'Add MIT license'
    assert resolver_output.issue.owner == 'neubig'
    assert resolver_output.issue.repo == 'pr-viewer'

    # Test loading a non-existent issue
    with pytest.raises(ValueError):
        load_single_resolver_output(mock_output_jsonl, 999)


def test_apply_patch(mock_output_dir):
    # Create a sample file in the mock repo
    sample_file = os.path.join(mock_output_dir, 'sample.txt')
    with open(sample_file, 'w') as f:
        f.write('Original content')

    # Create a sample patch
    patch_content = """
diff --git a/sample.txt b/sample.txt
index 9daeafb..b02def2 100644
--- a/sample.txt
+++ b/sample.txt
@@ -1 +1,2 @@
-Original content
+Updated content
+New line
"""

    # Apply the patch
    apply_patch(mock_output_dir, patch_content)

    # Check if the file was updated correctly
    with open(sample_file, 'r') as f:
        updated_content = f.read()

    assert updated_content.strip() == 'Updated content
New line'.strip()


def test_apply_patch_preserves_line_endings(mock_output_dir):
    # Create sample files with different line endings
    unix_file = os.path.join(mock_output_dir, 'unix_style.txt')
    dos_file = os.path.join(mock_output_dir, 'dos_style.txt')

    with open(unix_file, 'w', newline='
') as f:
        f.write('Line 1
Line 2
Line 3')

    with open(dos_file, 'w', newline='\r
') as f:
        f.write('Line 1\r
Line 2\r
Line 3')

    # Create patches for both files
    unix_patch = """
diff --git a/unix_style.txt b/unix_style.txt
index 9daeafb..b02def2 100644
--- a/unix_style.txt
+++ b/unix_style.txt
@@ -1,3 +1,3 @@
 Line 1
-Line 2
+Updated Line 2
 Line 3
"""

    dos_patch = """
diff --git a/dos_style.txt b/dos_style.txt
index 9daeafb..b02def2 100644
--- a/dos_style.txt
+++ b/dos_style.txt
@@ -1,3 +1,3 @@
 Line 1
-Line 2
+Updated Line 2
 Line 3
"""

    # Apply patches
    apply_patch(mock_output_dir, unix_patch)
    apply_patch(mock_output_dir, dos_patch)

    # Check if line endings are preserved
    with open(unix_file, 'rb') as f:
        unix_content = f.read()
    with open(dos_file, 'rb') as f:
        dos_content = f.read()

    assert (
        b'\r
' not in unix_content
    ), 'Unix-style line endings were changed to DOS-style'
    assert b'\r
' in dos_content, 'DOS-style line endings were changed to Unix-style'

    # Check if content was updated correctly
    assert unix_content.decode('utf-8').split('
')[1] == 'Updated Line 2'
    assert dos_content.decode('utf-8').split('\r
')[1] == 'Updated Line 2'


def test_apply_patch_create_new_file(mock_output_dir):
    # Create a patch that adds a new file
    patch_content = """
diff --git a/new_file.txt b/new_file.txt
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/new_file.txt
@@ -0,0 +1 @@
+hello world
"""

    # Apply the patch
    apply_patch(mock_output_dir, patch_content)

    # Check if the new file was created
    new_file_path = os.path.join(mock_output_dir, 'new_file.txt')
    assert os.path.exists(new_file_path), 'New file was not created'

    # Check if the file content is correct
    with open(new_file_path, 'r') as f:
        content = f.read().strip()
    assert content == 'hello world', 'File content is incorrect'


def test_apply_patch_rename_file(mock_output_dir):
    # Create a sample file in the mock repo
    old_file = os.path.join(mock_output_dir, 'old_name.txt')
    with open(old_file, 'w') as f:
        f.write('This file will be renamed')

    # Create a patch that renames the file
    patch_content = """diff --git a/old_name.txt b/new_name.txt
similarity index 100%
rename from old_name.txt
rename to new_name.txt"""

    # Apply the patch
    apply_patch(mock_output_dir, patch_content)

    # Check if the file was renamed
    new_file = os.path.join(mock_output_dir, 'new_name.txt')
    assert not os.path.exists(old_file), 'Old file still exists'
    assert os.path.exists(new_file), 'New file was not created'

    # Check if the content is preserved
    with open(new_file, 'r') as f:
        content = f.read()
    assert content == 'This file will be renamed'


def test_apply_patch_delete_file(mock_output_dir):
    # Create a sample file in the mock repo
    sample_file = os.path.join(mock_output_dir, 'to_be_deleted.txt')
    with open(sample_file, 'w') as f:
        f.write('This file will be deleted')

    # Create a patch that deletes the file
    patch_content = """
diff --git a/to_be_deleted.txt b/to_be_deleted.txt
deleted file mode 100644
index 9daeafb..0000000
--- a/to_be_deleted.txt
+++ /dev/null
@@ -1 +0,0 @@
-This file will be deleted
"""

    # Apply the patch
    apply_patch(mock_output_dir, patch_content)

    # Check if the file was deleted
    assert not os.path.exists(sample_file), 'File was not deleted'


def test_initialize_repo(mock_output_dir):
    issue_type = 'issue'
    # Copy the repo to patches
    ISSUE_NUMBER = 3
    initialize_repo(mock_output_dir, ISSUE_NUMBER, issue_type)
    patches_dir = os.path.join(mock_output_dir, 'patches', f'issue_{ISSUE_NUMBER}')

    # Check if files were copied correctly
    assert os.path.exists(os.path.join(patches_dir, 'README.md'))

    # Check file contents
    with open(os.path.join(patches_dir, 'README.md'), 'r') as f:
        assert f.read() == 'hello world'


@patch('openhands.resolver.send_pull_request.reply_to_comment')
@patch('requests.post')
@patch('subprocess.run')
@patch('openhands.resolver.send_pull_request.LLM')
def test_update_existing_pull_request(
    mock_llm_class,
    mock_subprocess_run,
    mock_requests_post,
    mock_reply_to_comment,
):
    # Arrange: Set up test data
    github_issue = GithubIssue(
        owner='test-owner',
        repo='test-repo',
        number=1,
        title='Test PR',
        body='This is a test PR',
        thread_ids=['comment1', 'comment2'],
        head_branch='test-branch',
    )
    github_token = 'test-token'
    github_username = 'test-user'
    patch_dir = '/path/to/patch'
    additional_message = '["Fixed bug in function A", "Updated documentation for B"]'

    # Mock the subprocess.run call for git push
    mock_subprocess_run.return_value = MagicMock(returncode=0)

    # Mock the requests.post call for adding a PR comment
    mock_requests_post.return_value.status_code = 201

    # Mock LLM instance and completion call
    mock_llm_instance = MagicMock()
    mock_completion_response = MagicMock()
    mock_completion_response.choices = [
        MagicMock(message=MagicMock(content='This is an issue resolution.'))
    ]
    mock_llm_instance.completion.return_value = mock_completion_response
    mock_llm_class.return_value = mock_llm_instance

    llm_config = LLMConfig()

    # Act: Call the function without comment_message to test auto-generation
    result = update_existing_pull_request(
        github_issue,
        github_token,
        github_username,
        patch_dir,
        llm_config,
        comment_message=None,
        additional_message=additional_message,
    )

    # Assert: Check if the git push command was executed
    push_command = (
        f'git -C {patch_dir} push '
        f'https://{github_username}:{github_token}@github.com/'
        f'{github_issue.owner}/{github_issue.repo}.git {github_issue.head_branch}'
    )
    mock_subprocess_run.assert_called_once_with(
        push_command, shell=True, capture_output=True, text=True
    )

    # Assert: Check if the auto-generated comment was posted to the PR
    comment_url = f'https://api.github.com/repos/{github_issue.owner}/{github_issue.repo}/issues/{github_issue.number}/comments'
    expected_comment = 'This is an issue resolution.'
    mock_requests_post.assert_called_once_with(
        comment_url,
        headers={
            'Authorization': f'token {github_token}',
            'Accept': 'application/vnd.github.v3+json',
        },
        json={'body': expected_comment},
    )

    # Assert: Check if the reply_to_comment function was called for each thread ID
    mock_reply_to_comment.assert_has_calls(
        [
            call(github_token, 'comment1', 'Fixed bug in function A'),
            call(github_token, 'comment2', 'Updated documentation for B'),
        ]
    )

    # Assert: Check the returned PR URL
    assert (
        result
        == f'https://github.com/{github_issue.owner}/{github_issue.repo}/pull/{github_issue.number}'
    )


@pytest.mark.parametrize(
    'pr_type,target_branch',
    [
        ('branch', None),
        ('draft', None),
        ('ready', None),
        ('branch', 'feature'),
        ('draft', 'develop'),
        ('ready', 'staging'),
    ],
)
@patch('subprocess.run')
@patch('requests.post')
@patch('requests.get')
def test_send_pull_request(
    mock_get,
    mock_post,
    mock_run,
    mock_github_issue,
    mock_output_dir,
    pr_type,
    target_branch,
):
    repo_path = os.path.join(mock_output_dir, 'repo')

    # Mock API responses based on whether target_branch is specified
    if target_branch:
        mock_get.side_effect = [
            MagicMock(status_code=404),  # Branch doesn't exist
            MagicMock(status_code=200),  # Target branch exists
        ]
    else:
        mock_get.side_effect = [
            MagicMock(status_code=404),  # Branch doesn't exist
            MagicMock(json=lambda: {'default_branch': 'main'}),  # Get default branch
        ]

    mock_post.return_value.json.return_value = {
        'html_url': 'https://github.com/test-owner/test-repo/pull/1'
    }

    # Mock subprocess.run calls
    mock_run.side_effect = [
        MagicMock(returncode=0),  # git checkout -b
        MagicMock(returncode=0),  # git push
    ]

    # Call the function
    result = send_pull_request(
        github_issue=mock_github_issue,
        github_token='test-token',
        github_username='test-user',
        patch_dir=repo_path,
        pr_type=pr_type,
        target_branch=target_branch,
    )

    # Assert API calls
    expected_get_calls = 2
    assert mock_get.call_count == expected_get_calls

    # Check branch creation and push
    assert mock_run.call_count == 2
    checkout_call, push_call = mock_run.call_args_list

    assert checkout_call == call(
        ['git', '-C', repo_path, 'checkout', '-b', 'openhands-fix-issue-42'],
        capture_output=True,
        text=True,
    )
    assert push_call == call(
        [
            'git',
            '-C',
            repo_path,
            'push',
            'https://test-user:[email protected]/test-owner/test-repo.git',
            'openhands-fix-issue-42',
        ],
        capture_output=True,
        text=True,
    )

    # Check PR creation based on pr_type
    if pr_type == 'branch':
        assert (
            result
            == 'https://github.com/test-owner/test-repo/compare/openhands-fix-issue-42?expand=1'
        )
        mock_post.assert_not_called()
    else:
        assert result == 'https://github.com/test-owner/test-repo/pull/1'
        mock_post.assert_called_once()
        post_data = mock_post.call_args[1]['json']
        assert post_data['title'] == 'Fix issue #42: Test Issue'
        assert post_data['body'].startswith('This pull request fixes #42.')
        assert post_data['head'] == 'openhands-fix-issue-42'
        assert post_data['base'] == (target_branch if target_branch else 'main')
        assert post_data['draft'] == (pr_type == 'draft')


@patch('subprocess.run')
@patch('requests.post')
@patch('requests.get')
def test_send_pull_request_with_reviewer(
    mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir
):
    repo_path = os.path.join(mock_output_dir, 'repo')
    reviewer = 'test-reviewer'

    # Mock API responses
    mock_get.side_effect = [
        MagicMock(status_code=404),  # Branch doesn't exist
        MagicMock(json=lambda: {'default_branch': 'main'}),  # Get default branch
    ]

    # Mock PR creation response
    mock_post.side_effect = [
        MagicMock(
            status_code=201,
            json=lambda: {
                'html_url': 'https://github.com/test-owner/test-repo/pull/1',
                'number': 1,
            },
        ),  # PR creation
        MagicMock(status_code=201),  # Reviewer request
    ]

    # Mock subprocess.run calls
    mock_run.side_effect = [
        MagicMock(returncode=0),  # git checkout -b
        MagicMock(returncode=0),  # git push
    ]

    # Call the function with reviewer
    result = send_pull_request(
        github_issue=mock_github_issue,
        github_token='test-token',
        github_username='test-user',
        patch_dir=repo_path,
        pr_type='ready',
        reviewer=reviewer,
    )

    # Assert API calls
    assert mock_get.call_count == 2
    assert mock_post.call_count == 2

    # Check PR creation
    pr_create_call = mock_post.call_args_list[0]
    assert pr_create_call[1]['json']['title'] == 'Fix issue #42: Test Issue'

    # Check reviewer request
    reviewer_request_call = mock_post.call_args_list[1]
    assert (
        reviewer_request_call[0][0]
        == 'https://api.github.com/repos/test-owner/test-repo/pulls/1/requested_reviewers'
    )
    assert reviewer_request_call[1]['json'] == {'reviewers': ['test-reviewer']}

    # Check the result URL
    assert result == 'https://github.com/test-owner/test-repo/pull/1'


@patch('requests.get')
def test_send_pull_request_invalid_target_branch(
    mock_get, mock_github_issue, mock_output_dir
):
    """Test that an error is raised when specifying a non-existent target branch"""
    repo_path = os.path.join(mock_output_dir, 'repo')

    # Mock API response for non-existent branch
    mock_get.side_effect = [
        MagicMock(status_code=404),  # Branch doesn't exist
        MagicMock(status_code=404),  # Target branch doesn't exist
    ]

    # Test that ValueError is raised when target branch doesn't exist
    with pytest.raises(
        ValueError, match='Target branch nonexistent-branch does not exist'
    ):
        send_pull_request(
            github_issue=mock_github_issue,
            github_token='test-token',
            github_username='test-user',
            patch_dir=repo_path,
            pr_type='ready',
            target_branch='nonexistent-branch',
        )

    # Verify API calls
    assert mock_get.call_count == 2


@patch('subprocess.run')
@patch('requests.post')
@patch('requests.get')
def test_send_pull_request_git_push_failure(
    mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir
):
    repo_path = os.path.join(mock_output_dir, 'repo')

    # Mock API responses
    mock_get.return_value = MagicMock(json=lambda: {'default_branch': 'main'})

    # Mock the subprocess.run calls
    mock_run.side_effect = [
        MagicMock(returncode=0),  # git checkout -b
        MagicMock(returncode=1, stderr='Error: failed to push some refs'),  # git push
    ]

    # Test that RuntimeError is raised when git push fails
    with pytest.raises(
        RuntimeError, match='Failed to push changes to the remote repository'
    ):
        send_pull_request(
            github_issue=mock_github_issue,
            github_token='test-token',
            github_username='test-user',
            patch_dir=repo_path,
            pr_type='ready',
        )

    # Assert that subprocess.run was called twice
    assert mock_run.call_count == 2

    # Check the git checkout -b command
    checkout_call = mock_run.call_args_list[0]
    assert checkout_call[0][0] == [
        'git',
        '-C',
        repo_path,
        'checkout',
        '-b',
        'openhands-fix-issue-42',
    ]

    # Check the git push command
    push_call = mock_run.call_args_list[1]
    assert push_call[0][0] == [
        'git',
        '-C',
        repo_path,
        'push',
        'https://test-user:[email protected]/test-owner/test-repo.git',
        'openhands-fix-issue-42',
    ]

    # Assert that no pull request was created
    mock_post.assert_not_called()


@patch('subprocess.run')
@patch('requests.post')
@patch('requests.get')
def test_send_pull_request_permission_error(
    mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir
):
    repo_path = os.path.join(mock_output_dir, 'repo')

    # Mock API responses
    mock_get.return_value = MagicMock(json=lambda: {'default_branch': 'main'})
    mock_post.return_value.status_code = 403

    # Mock subprocess.run calls
    mock_run.side_effect = [
        MagicMock(returncode=0),  # git checkout -b
        MagicMock(returncode=0),  # git push
    ]

    # Test that RuntimeError is raised when PR creation fails due to permissions
    with pytest.raises(
        RuntimeError, match='Failed to create pull request due to missing permissions.'
    ):
        send_pull_request(
            github_issue=mock_github_issue,
            github_token='test-token',
            github_username='test-user',
            patch_dir=repo_path,
            pr_type='ready',
        )

    # Assert that the branch was created and pushed
    assert mock_run.call_count == 2
    mock_post.assert_called_once()


@patch('requests.post')
def test_reply_to_comment(mock_post):
    # Arrange: set up the test data
    github_token = 'test_token'
    comment_id = 'test_comment_id'
    reply = 'This is a test reply.'

    # Mock the response from the GraphQL API
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        'data': {
            'addPullRequestReviewThreadReply': {
                'comment': {
                    'id': 'test_reply_id',
                    'body': 'Openhands fix success summary


This is a test reply.',
                    'createdAt': '2024-10-01T12:34:56Z',
                }
            }
        }
    }

    mock_post.return_value = mock_response

    # Act: call the function
    reply_to_comment(github_token, comment_id, reply)

    # Assert: check that the POST request was made with the correct parameters
    query = """
            mutation($body: String!, $pullRequestReviewThreadId: ID!) {
                addPullRequestReviewThreadReply(input: { body: $body, pullRequestReviewThreadId: $pullRequestReviewThreadId }) {
                    comment {
                        id
                        body
                        createdAt
                    }
                }
            }
            """

    expected_variables = {
        'body': 'Openhands fix success summary


This is a test reply.',
        'pullRequestReviewThreadId': comment_id,
    }

    # Check that the correct request was made to the API
    mock_post.assert_called_once_with(
        'https://api.github.com/graphql',
        json={'query': query, 'variables': expected_variables},
        headers={
            'Authorization': f'Bearer {github_token}',
            'Content-Type': 'application/json',
        },
    )

    # Check that the response status was checked (via response.raise_for_status)
    mock_response.raise_for_status.assert_called_once()


@patch('openhands.resolver.send_pull_request.initialize_repo')
@patch('openhands.resolver.send_pull_request.apply_patch')
@patch('openhands.resolver.send_pull_request.update_existing_pull_request')
@patch('openhands.resolver.send_pull_request.make_commit')
def test_process_single_pr_update(
    mock_make_commit,
    mock_update_existing_pull_request,
    mock_apply_patch,
    mock_initialize_repo,
    mock_output_dir,
    mock_llm_config,
):
    # Initialize test data
    github_token = 'test_token'
    github_username = 'test_user'
    pr_type = 'draft'

    resolver_output = ResolverOutput(
        issue=GithubIssue(
            owner='test-owner',
            repo='test-repo',
            number=1,
            title='Issue 1',
            body='Body 1',
            closing_issues=[],
            review_threads=[
                ReviewThread(comment='review comment for feedback', files=[])
            ],
            thread_ids=['1'],
            head_branch='branch 1',
        ),
        issue_type='pr',
        instruction='Test instruction 1',
        base_commit='def456',
        git_patch='Test patch 1',
        history=[],
        metrics={},
        success=True,
        comment_success=None,
        success_explanation='[Test success 1]',
        error=None,
    )

    mock_update_existing_pull_request.return_value = (
        'https://github.com/test-owner/test-repo/pull/1'
    )
    mock_initialize_repo.return_value = f'{mock_output_dir}/patches/pr_1'

    process_single_issue(
        mock_output_dir,
        resolver_output,
        github_token,
        github_username,
        pr_type,
        mock_llm_config,
        None,
        False,
        None,
    )

    mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'pr', 'branch 1')
    mock_apply_patch.assert_called_once_with(
        f'{mock_output_dir}/patches/pr_1', resolver_output.git_patch
    )
    mock_make_commit.assert_called_once_with(
        f'{mock_output_dir}/patches/pr_1', resolver_output.issue, 'pr'
    )
    mock_update_existing_pull_request.assert_called_once_with(
        github_issue=resolver_output.issue,
        github_token=github_token,
        github_username=github_username,
        patch_dir=f'{mock_output_dir}/patches/pr_1',
        additional_message='[Test success 1]',
        llm_config=mock_llm_config,
    )


@patch('openhands.resolver.send_pull_request.initialize_repo')
@patch('openhands.resolver.send_pull_request.apply_patch')
@patch('openhands.resolver.send_pull_request.send_pull_request')
@patch('openhands.resolver.send_pull_request.make_commit')
def test_process_single_issue(
    mock_make_commit,
    mock_send_pull_request,
    mock_apply_patch,
    mock_initialize_repo,
    mock_output_dir,
    mock_llm_config,
):
    # Initialize test data
    github_token = 'test_token'
    github_username = 'test_user'
    pr_type = 'draft'

    resolver_output = ResolverOutput(
        issue=GithubIssue(
            owner='test-owner',
            repo='test-repo',
            number=1,
            title='Issue 1',
            body='Body 1',
        ),
        issue_type='issue',
        instruction='Test instruction 1',
        base_commit='def456',
        git_patch='Test patch 1',
        history=[],
        metrics={},
        success=True,
        comment_success=None,
        success_explanation='Test success 1',
        error=None,
    )

    # Mock return value
    mock_send_pull_request.return_value = (
        'https://github.com/test-owner/test-repo/pull/1'
    )
    mock_initialize_repo.return_value = f'{mock_output_dir}/patches/issue_1'

    # Call the function
    process_single_issue(
        mock_output_dir,
        resolver_output,
        github_token,
        github_username,
        pr_type,
        mock_llm_config,
        None,
        False,
        None,
    )

    # Assert that the mocked functions were called with correct arguments
    mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'issue', 'def456')
    mock_apply_patch.assert_called_once_with(
        f'{mock_output_dir}/patches/issue_1', resolver_output.git_patch
    )
    mock_make_commit.assert_called_once_with(
        f'{mock_output_dir}/patches/issue_1', resolver_output.issue, 'issue'
    )
    mock_send_pull_request.assert_called_once_with(
        github_issue=resolver_output.issue,
        github_token=github_token,
        github_username=github_username,
        patch_dir=f'{mock_output_dir}/patches/issue_1',
        pr_type=pr_type,
        fork_owner=None,
        additional_message=resolver_output.success_explanation,
        target_branch=None,
        reviewer=None,
    )


@patch('openhands.resolver.send_pull_request.initialize_repo')
@patch('openhands.resolver.send_pull_request.apply_patch')
@patch('openhands.resolver.send_pull_request.send_pull_request')
@patch('openhands.resolver.send_pull_request.make_commit')
def test_process_single_issue_unsuccessful(
    mock_make_commit,
    mock_send_pull_request,
    mock_apply_patch,
    mock_initialize_repo,
    mock_output_dir,
    mock_llm_config,
):
    # Initialize test data
    github_token = 'test_token'
    github_username = 'test_user'
    pr_type = 'draft'

    resolver_output = ResolverOutput(
        issue=GithubIssue(
            owner='test-owner',
            repo='test-repo',
            number=1,
            title='Issue 1',
            body='Body 1',
        ),
        issue_type='issue',
        instruction='Test instruction 1',
        base_commit='def456',
        git_patch='Test patch 1',
        history=[],
        metrics={},
        success=False,
        comment_success=None,
        success_explanation='',
        error='Test error',
    )

    # Call the function
    process_single_issue(
        mock_output_dir,
        resolver_output,
        github_token,
        github_username,
        pr_type,
        mock_llm_config,
        None,
        False,
        None,
    )

    # Assert that none of the mocked functions were called
    mock_initialize_repo.assert_not_called()
    mock_apply_patch.assert_not_called()
    mock_make_commit.assert_not_called()
    mock_send_pull_request.assert_not_called()


@patch('openhands.resolver.send_pull_request.load_all_resolver_outputs')
@patch('openhands.resolver.send_pull_request.process_single_issue')
def test_process_all_successful_issues(
    mock_process_single_issue, mock_load_all_resolver_outputs, mock_llm_config
):
    # Create ResolverOutput objects with properly initialized GithubIssue instances
    resolver_output_1 = ResolverOutput(
        issue=GithubIssue(
            owner='test-owner',
            repo='test-repo',
            number=1,
            title='Issue 1',
            body='Body 1',
        ),
        issue_type='issue',
        instruction='Test instruction 1',
        base_commit='def456',
        git_patch='Test patch 1',
        history=[],
        metrics={},
        success=True,
        comment_success=None,
        success_explanation='Test success 1',
        error=None,
    )

    resolver_output_2 = ResolverOutput(
        issue=GithubIssue(
            owner='test-owner',
            repo='test-repo',
            number=2,
            title='Issue 2',
            body='Body 2',
        ),
        issue_type='issue',
        instruction='Test instruction 2',
        base_commit='ghi789',
        git_patch='Test patch 2',
        history=[],
        metrics={},
        success=False,
        comment_success=None,
        success_explanation='',
        error='Test error 2',
    )

    resolver_output_3 = ResolverOutput(
        issue=GithubIssue(
            owner='test-owner',
            repo='test-repo',
            number=3,
            title='Issue 3',
            body='Body 3',
        ),
        issue_type='issue',
        instruction='Test instruction 3',
        base_commit='jkl012',
        git_patch='Test patch 3',
        history=[],
        metrics={},
        success=True,
        comment_success=None,
        success_explanation='Test success 3',
        error=None,
    )

    mock_load_all_resolver_outputs.return_value = [
        resolver_output_1,
        resolver_output_2,
        resolver_output_3,
    ]

    # Call the function
    process_all_successful_issues(
        'output_dir',
        'github_token',
        'github_username',
        'draft',
        mock_llm_config,  # llm_config
        None,  # fork_owner
    )

    # Assert that process_single_issue was called for successful issues only
    assert mock_process_single_issue.call_count == 2

    # Check that the function was called with the correct arguments for successful issues
    mock_process_single_issue.assert_has_calls(
        [
            call(
                'output_dir',
                resolver_output_1,
                'github_token',
                'github_username',
                'draft',
                mock_llm_config,
                None,
                False,
                None,
            ),
            call(
                'output_dir',
                resolver_output_3,
                'github_token',
                'github_username',
                'draft',
                mock_llm_config,
                None,
                False,
                None,
            ),
        ]
    )

    # Add more assertions as needed to verify the behavior of the function


@patch('requests.get')
@patch('subprocess.run')
def test_send_pull_request_branch_naming(
    mock_run, mock_get, mock_github_issue, mock_output_dir
):
    repo_path = os.path.join(mock_output_dir, 'repo')

    # Mock API responses
    mock_get.side_effect = [
        MagicMock(status_code=200),  # First branch exists
        MagicMock(status_code=200),  # Second branch exists
        MagicMock(status_code=404),  # Third branch doesn't exist
        MagicMock(json=lambda: {'default_branch': 'main'}),  # Get default branch
    ]

    # Mock subprocess.run calls
    mock_run.side_effect = [
        MagicMock(returncode=0),  # git checkout -b
        MagicMock(returncode=0),  # git push
    ]

    # Call the function
    result = send_pull_request(
        github_issue=mock_github_issue,
        github_token='test-token',
        github_username='test-user',
        patch_dir=repo_path,
        pr_type='branch',
    )

    # Assert API calls
    assert mock_get.call_count == 4

    # Check branch creation and push
    assert mock_run.call_count == 2
    checkout_call, push_call = mock_run.call_args_list

    assert checkout_call == call(
        ['git', '-C', repo_path, 'checkout', '-b', 'openhands-fix-issue-42-try3'],
        capture_output=True,
        text=True,
    )
    assert push_call == call(
        [
            'git',
            '-C',
            repo_path,
            'push',
            'https://test-user:[email protected]/test-owner/test-repo.git',
            'openhands-fix-issue-42-try3',
        ],
        capture_output=True,
        text=True,
    )

    # Check the result
    assert (
        result
        == 'https://github.com/test-owner/test-repo/compare/openhands-fix-issue-42-try3?expand=1'
    )


@patch('openhands.resolver.send_pull_request.argparse.ArgumentParser')
@patch('openhands.resolver.send_pull_request.process_all_successful_issues')
@patch('openhands.resolver.send_pull_request.process_single_issue')
@patch('openhands.resolver.send_pull_request.load_single_resolver_output')
@patch('os.path.exists')
@patch('os.getenv')
def test_main(
    mock_getenv,
    mock_path_exists,
    mock_load_single_resolver_output,
    mock_process_single_issue,
    mock_process_all_successful_issues,
    mock_parser,
):
    from openhands.resolver.send_pull_request import main

    # Setup mock parser
    mock_args = MagicMock()
    mock_args.github_token = None
    mock_args.github_username = 'mock_username'
    mock_args.output_dir = '/mock/output'
    mock_args.pr_type = 'draft'
    mock_args.issue_number = '42'
    mock_args.fork_owner = None
    mock_args.send_on_failure = False
    mock_args.llm_model = 'mock_model'
    mock_args.llm_base_url = 'mock_url'
    mock_args.llm_api_key = 'mock_key'
    mock_args.target_branch = None
    mock_args.reviewer = None
    mock_parser.return_value.parse_args.return_value = mock_args

    # Setup environment variables
    mock_getenv.side_effect = (
        lambda key, default=None: 'mock_token' if key == 'GITHUB_TOKEN' else default
    )

    # Setup path exists
    mock_path_exists.return_value = True

    # Setup mock resolver output
    mock_resolver_output = MagicMock()
    mock_load_single_resolver_output.return_value = mock_resolver_output

    # Run main function
    main()

    llm_config = LLMConfig(
        model=mock_args.llm_model,
        base_url=mock_args.llm_base_url,
        api_key=mock_args.llm_api_key,
    )

    # Use any_call instead of assert_called_with for more flexible matching
    assert mock_process_single_issue.call_args == call(
        '/mock/output',
        mock_resolver_output,
        'mock_token',
        'mock_username',
        'draft',
        llm_config,
        None,
        False,
        mock_args.target_branch,
        mock_args.reviewer,
    )

    # Other assertions
    mock_parser.assert_called_once()
    mock_getenv.assert_any_call('GITHUB_TOKEN')
    mock_path_exists.assert_called_with('/mock/output')
    mock_load_single_resolver_output.assert_called_with('/mock/output/output.jsonl', 42)

    # Test for 'all_successful' issue number
    mock_args.issue_number = 'all_successful'
    main()
    mock_process_all_successful_issues.assert_called_with(
        '/mock/output',
        'mock_token',
        'mock_username',
        'draft',
        llm_config,
        None,
    )

    # Test for invalid issue number
    mock_args.issue_number = 'invalid'
    with pytest.raises(ValueError):
        main()


@patch('subprocess.run')
def test_make_commit_escapes_issue_title(mock_subprocess_run):
    # Setup
    repo_dir = '/path/to/repo'
    issue = GithubIssue(
        owner='test-owner',
        repo='test-repo',
        number=42,
        title='Issue with "quotes" and $pecial characters',
        body='Test body',
    )

    # Mock subprocess.run to return success for all calls
    mock_subprocess_run.return_value = MagicMock(
        returncode=0, stdout='sample output', stderr=''
    )

    # Call the function
    issue_type = 'issue'
    make_commit(repo_dir, issue, issue_type)

    # Assert that subprocess.run was called with the correct arguments
    calls = mock_subprocess_run.call_args_list
    assert len(calls) == 4  # git config check, git add, git commit

    # Check the git commit call
    git_commit_call = calls[3][0][0]
    expected_commit_message = (
        'Fix issue #42: Issue with "quotes" and $pecial characters'
    )
    assert [
        'git',
        '-C',
        '/path/to/repo',
        'commit',
        '-m',
        expected_commit_message,
    ] == git_commit_call


@patch('subprocess.run')
def test_make_commit_no_changes(mock_subprocess_run):
    # Setup
    repo_dir = '/path/to/repo'
    issue = GithubIssue(
        owner='test-owner',
        repo='test-repo',
        number=42,
        title='Issue with no changes',
        body='Test body',
    )

    # Mock subprocess.run to simulate no changes in the repo
    mock_subprocess_run.side_effect = [
        MagicMock(returncode=0),
        MagicMock(returncode=0),
        MagicMock(returncode=1, stdout=''),  # git status --porcelain (no changes)
    ]

    with pytest.raises(
        RuntimeError, match='ERROR: Openhands failed to make code changes.'
    ):
        make_commit(repo_dir, issue, 'issue')

    # Check that subprocess.run was called for checking git status and add, but not commit
    assert mock_subprocess_run.call_count == 3
    git_status_call = mock_subprocess_run.call_args_list[2][0][0]
    assert f'git -C {repo_dir} status --porcelain' in git_status_call


def test_apply_patch_rename_directory(mock_output_dir):
    # Create a sample directory structure
    old_dir = os.path.join(mock_output_dir, 'prompts', 'resolve')
    os.makedirs(old_dir)

    # Create test files
    test_files = [
        'issue-success-check.jinja',
        'pr-feedback-check.jinja',
        'pr-thread-check.jinja',
    ]
    for filename in test_files:
        file_path = os.path.join(old_dir, filename)
        with open(file_path, 'w') as f:
            f.write(f'Content of {filename}')

    # Create a patch that renames the directory
    patch_content = """diff --git a/prompts/resolve/issue-success-check.jinja b/prompts/guess_success/issue-success-check.jinja
similarity index 100%
rename from prompts/resolve/issue-success-check.jinja
rename to prompts/guess_success/issue-success-check.jinja
diff --git a/prompts/resolve/pr-feedback-check.jinja b/prompts/guess_success/pr-feedback-check.jinja
similarity index 100%
rename from prompts/resolve/pr-feedback-check.jinja
rename to prompts/guess_success/pr-feedback-check.jinja
diff --git a/prompts/resolve/pr-thread-check.jinja b/prompts/guess_success/pr-thread-check.jinja
similarity index 100%
rename from prompts/resolve/pr-thread-check.jinja
rename to prompts/guess_success/pr-thread-check.jinja"""

    # Apply the patch
    apply_patch(mock_output_dir, patch_content)

    # Check if files were moved correctly
    new_dir = os.path.join(mock_output_dir, 'prompts', 'guess_success')
    assert not os.path.exists(old_dir), 'Old directory still exists'
    assert os.path.exists(new_dir), 'New directory was not created'

    # Check if all files were moved and content preserved
    for filename in test_files:
        old_path = os.path.join(old_dir, filename)
        new_path = os.path.join(new_dir, filename)
        assert not os.path.exists(old_path), f'Old file {filename} still exists'
        assert os.path.exists(new_path), f'New file {filename} was not created'
        with open(new_path, 'r') as f:
            content = f.read()
        assert content == f'Content of {filename}', f'Content mismatch for {filename}'