Back to Repositories

Testing GitHub Issue Handler Implementation in OpenHands

This test suite validates the GitHub issue and pull request handling functionality in the OpenHands project, focusing on comment processing, issue conversion, and reference management. It ensures robust handling of GitHub API interactions and data transformations.

Test Coverage Overview

The test suite provides comprehensive coverage of issue and PR handling mechanisms:
  • Issue conversion and initialization testing
  • Empty body handling and data validation
  • PR comment processing and thread management
  • Issue reference resolution and deduplication
  • Specific comment filtering and retrieval

Implementation Analysis

The testing approach employs mock objects and patches to simulate GitHub API interactions:
  • Uses unittest.mock for API response simulation
  • Implements detailed request/response patterns
  • Validates both REST and GraphQL API handling
  • Tests complex data transformation logic

Technical Details

Key technical components include:
  • unittest.mock for dependency isolation
  • pytest for test framework
  • MagicMock for response simulation
  • Patch decorators for context management
  • Custom assertion patterns for validation

Best Practices Demonstrated

The test suite exemplifies several testing best practices:
  • Comprehensive mock setup and teardown
  • Isolated test cases with clear boundaries
  • Thorough edge case coverage
  • Consistent assertion patterns
  • Clear test case organization

all-hands-ai/openhands

tests/unit/resolver/test_issue_handler.py

            
from unittest.mock import MagicMock, patch

from openhands.core.config import LLMConfig
from openhands.resolver.github_issue import ReviewThread
from openhands.resolver.issue_definitions import IssueHandler, PRHandler


def test_get_converted_issues_initializes_review_comments():
    # Mock the necessary dependencies
    with patch('requests.get') as mock_get:
        # Mock the response for issues
        mock_issues_response = MagicMock()
        mock_issues_response.json.return_value = [
            {'number': 1, 'title': 'Test Issue', 'body': 'Test Body'}
        ]
        # Mock the response for comments
        mock_comments_response = MagicMock()
        mock_comments_response.json.return_value = []

        # Set up the mock to return different responses for different calls
        # First call is for issues, second call is for comments
        mock_get.side_effect = [
            mock_issues_response,
            mock_comments_response,
            mock_comments_response,
        ]  # Need two comment responses because we make two API calls

        # Create an instance of IssueHandler
        llm_config = LLMConfig(model='test', api_key='test')
        handler = IssueHandler('test-owner', 'test-repo', 'test-token', llm_config)

        # Get converted issues
        issues = handler.get_converted_issues(issue_numbers=[1])

        # Verify that we got exactly one issue
        assert len(issues) == 1

        # Verify that review_comments is initialized as None
        assert issues[0].review_comments is None

        # Verify other fields are set correctly
        assert issues[0].number == 1
        assert issues[0].title == 'Test Issue'
        assert issues[0].body == 'Test Body'
        assert issues[0].owner == 'test-owner'
        assert issues[0].repo == 'test-repo'


def test_get_converted_issues_handles_empty_body():
    # Mock the necessary dependencies
    with patch('requests.get') as mock_get:
        # Mock the response for issues
        mock_issues_response = MagicMock()
        mock_issues_response.json.return_value = [
            {'number': 1, 'title': 'Test Issue', 'body': None}
        ]
        # Mock the response for comments
        mock_comments_response = MagicMock()
        mock_comments_response.json.return_value = []

        # Set up the mock to return different responses
        mock_get.side_effect = [
            mock_issues_response,
            mock_comments_response,
            mock_comments_response,
        ]

        # Create an instance of IssueHandler
        llm_config = LLMConfig(model='test', api_key='test')
        handler = IssueHandler('test-owner', 'test-repo', 'test-token', llm_config)

        # Get converted issues
        issues = handler.get_converted_issues(issue_numbers=[1])

        # Verify that we got exactly one issue
        assert len(issues) == 1

        # Verify that body is empty string when None
        assert issues[0].body == ''

        # Verify other fields are set correctly
        assert issues[0].number == 1
        assert issues[0].title == 'Test Issue'
        assert issues[0].owner == 'test-owner'
        assert issues[0].repo == 'test-repo'

        # Verify that review_comments is initialized as None
        assert issues[0].review_comments is None


def test_pr_handler_get_converted_issues_with_comments():
    # Mock the necessary dependencies
    with patch('requests.get') as mock_get:
        # Mock the response for PRs
        mock_prs_response = MagicMock()
        mock_prs_response.json.return_value = [
            {
                'number': 1,
                'title': 'Test PR',
                'body': 'Test Body fixes #1',
                'head': {'ref': 'test-branch'},
            }
        ]

        # Mock the response for PR comments
        mock_comments_response = MagicMock()
        mock_comments_response.json.return_value = [
            {'body': 'First comment'},
            {'body': 'Second comment'},
        ]

        # Mock the response for PR metadata (GraphQL)
        mock_graphql_response = MagicMock()
        mock_graphql_response.json.return_value = {
            'data': {
                'repository': {
                    'pullRequest': {
                        'closingIssuesReferences': {'edges': []},
                        'reviews': {'nodes': []},
                        'reviewThreads': {'edges': []},
                    }
                }
            }
        }

        # Set up the mock to return different responses
        # We need to return empty responses for subsequent pages
        mock_empty_response = MagicMock()
        mock_empty_response.json.return_value = []

        # Mock the response for fetching the external issue referenced in PR body
        mock_external_issue_response = MagicMock()
        mock_external_issue_response.json.return_value = {
            'body': 'This is additional context from an externally referenced issue.'
        }

        mock_get.side_effect = [
            mock_prs_response,  # First call for PRs
            mock_empty_response,  # Second call for PRs (empty page)
            mock_comments_response,  # Third call for PR comments
            mock_empty_response,  # Fourth call for PR comments (empty page)
            mock_external_issue_response,  # Mock response for the external issue reference #1
        ]

        # Mock the post request for GraphQL
        with patch('requests.post') as mock_post:
            mock_post.return_value = mock_graphql_response

            # Create an instance of PRHandler
            llm_config = LLMConfig(model='test', api_key='test')
            handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)

            # Get converted issues
            prs = handler.get_converted_issues(issue_numbers=[1])

            # Verify that we got exactly one PR
            assert len(prs) == 1

            # Verify that thread_comments are set correctly
            assert prs[0].thread_comments == ['First comment', 'Second comment']

            # Verify other fields are set correctly
            assert prs[0].number == 1
            assert prs[0].title == 'Test PR'
            assert prs[0].body == 'Test Body fixes #1'
            assert prs[0].owner == 'test-owner'
            assert prs[0].repo == 'test-repo'
            assert prs[0].head_branch == 'test-branch'
            assert prs[0].closing_issues == [
                'This is additional context from an externally referenced issue.'
            ]


def test_get_issue_comments_with_specific_comment_id():
    # Mock the necessary dependencies
    with patch('requests.get') as mock_get:
        # Mock the response for comments
        mock_comments_response = MagicMock()
        mock_comments_response.json.return_value = [
            {'id': 123, 'body': 'First comment'},
            {'id': 456, 'body': 'Second comment'},
        ]

        mock_get.return_value = mock_comments_response

        # Create an instance of IssueHandler
        llm_config = LLMConfig(model='test', api_key='test')
        handler = IssueHandler('test-owner', 'test-repo', 'test-token', llm_config)

        # Get comments with a specific comment_id
        specific_comment = handler._get_issue_comments(issue_number=1, comment_id=123)

        # Verify only the specific comment is returned
        assert specific_comment == ['First comment']


def test_pr_handler_get_converted_issues_with_specific_thread_comment():
    # Define the specific comment_id to filter
    specific_comment_id = 123

    # Mock GraphQL response for review threads
    with patch('requests.get') as mock_get:
        # Mock the response for PRs
        mock_prs_response = MagicMock()
        mock_prs_response.json.return_value = [
            {
                'number': 1,
                'title': 'Test PR',
                'body': 'Test Body',
                'head': {'ref': 'test-branch'},
            }
        ]

        # Mock the response for PR comments
        mock_comments_response = MagicMock()
        mock_comments_response.json.return_value = [
            {'body': 'First comment', 'id': 123},
            {'body': 'Second comment', 'id': 124},
        ]

        # Mock the response for PR metadata (GraphQL)
        mock_graphql_response = MagicMock()
        mock_graphql_response.json.return_value = {
            'data': {
                'repository': {
                    'pullRequest': {
                        'closingIssuesReferences': {'edges': []},
                        'reviews': {'nodes': []},
                        'reviewThreads': {
                            'edges': [
                                {
                                    'node': {
                                        'id': 'review-thread-1',
                                        'isResolved': False,
                                        'comments': {
                                            'nodes': [
                                                {
                                                    'fullDatabaseId': 121,
                                                    'body': 'Specific review comment',
                                                    'path': 'file1.txt',
                                                },
                                                {
                                                    'fullDatabaseId': 456,
                                                    'body': 'Another review comment',
                                                    'path': 'file2.txt',
                                                },
                                            ]
                                        },
                                    }
                                }
                            ]
                        },
                    }
                }
            }
        }

        # Set up the mock to return different responses
        # We need to return empty responses for subsequent pages
        mock_empty_response = MagicMock()
        mock_empty_response.json.return_value = []

        mock_get.side_effect = [
            mock_prs_response,  # First call for PRs
            mock_empty_response,  # Second call for PRs (empty page)
            mock_comments_response,  # Third call for PR comments
            mock_empty_response,  # Fourth call for PR comments (empty page)
        ]

        # Mock the post request for GraphQL
        with patch('requests.post') as mock_post:
            mock_post.return_value = mock_graphql_response

            # Create an instance of PRHandler
            llm_config = LLMConfig(model='test', api_key='test')
            handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)

            # Get converted issues
            prs = handler.get_converted_issues(
                issue_numbers=[1], comment_id=specific_comment_id
            )

            # Verify that we got exactly one PR
            assert len(prs) == 1

            # Verify that thread_comments are set correctly
            assert prs[0].thread_comments == ['First comment']
            assert prs[0].review_comments == []
            assert prs[0].review_threads == []

            # Verify other fields are set correctly
            assert prs[0].number == 1
            assert prs[0].title == 'Test PR'
            assert prs[0].body == 'Test Body'
            assert prs[0].owner == 'test-owner'
            assert prs[0].repo == 'test-repo'
            assert prs[0].head_branch == 'test-branch'


def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
    # Define the specific comment_id to filter
    specific_comment_id = 123

    # Mock GraphQL response for review threads
    with patch('requests.get') as mock_get:
        # Mock the response for PRs
        mock_prs_response = MagicMock()
        mock_prs_response.json.return_value = [
            {
                'number': 1,
                'title': 'Test PR',
                'body': 'Test Body',
                'head': {'ref': 'test-branch'},
            }
        ]

        # Mock the response for PR comments
        mock_comments_response = MagicMock()
        mock_comments_response.json.return_value = [
            {'body': 'First comment', 'id': 120},
            {'body': 'Second comment', 'id': 124},
        ]

        # Mock the response for PR metadata (GraphQL)
        mock_graphql_response = MagicMock()
        mock_graphql_response.json.return_value = {
            'data': {
                'repository': {
                    'pullRequest': {
                        'closingIssuesReferences': {'edges': []},
                        'reviews': {'nodes': []},
                        'reviewThreads': {
                            'edges': [
                                {
                                    'node': {
                                        'id': 'review-thread-1',
                                        'isResolved': False,
                                        'comments': {
                                            'nodes': [
                                                {
                                                    'fullDatabaseId': specific_comment_id,
                                                    'body': 'Specific review comment',
                                                    'path': 'file1.txt',
                                                },
                                                {
                                                    'fullDatabaseId': 456,
                                                    'body': 'Another review comment',
                                                    'path': 'file1.txt',
                                                },
                                            ]
                                        },
                                    }
                                }
                            ]
                        },
                    }
                }
            }
        }

        # Set up the mock to return different responses
        # We need to return empty responses for subsequent pages
        mock_empty_response = MagicMock()
        mock_empty_response.json.return_value = []

        mock_get.side_effect = [
            mock_prs_response,  # First call for PRs
            mock_empty_response,  # Second call for PRs (empty page)
            mock_comments_response,  # Third call for PR comments
            mock_empty_response,  # Fourth call for PR comments (empty page)
        ]

        # Mock the post request for GraphQL
        with patch('requests.post') as mock_post:
            mock_post.return_value = mock_graphql_response

            # Create an instance of PRHandler
            llm_config = LLMConfig(model='test', api_key='test')
            handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)

            # Get converted issues
            prs = handler.get_converted_issues(
                issue_numbers=[1], comment_id=specific_comment_id
            )

            # Verify that we got exactly one PR
            assert len(prs) == 1

            # Verify that thread_comments are set correctly
            assert prs[0].thread_comments is None
            assert prs[0].review_comments == []
            assert len(prs[0].review_threads) == 1
            assert isinstance(prs[0].review_threads[0], ReviewThread)
            assert (
                prs[0].review_threads[0].comment
                == 'Specific review comment
---
latest feedback:
Another review comment
'
            )
            assert prs[0].review_threads[0].files == ['file1.txt']

            # Verify other fields are set correctly
            assert prs[0].number == 1
            assert prs[0].title == 'Test PR'
            assert prs[0].body == 'Test Body'
            assert prs[0].owner == 'test-owner'
            assert prs[0].repo == 'test-repo'
            assert prs[0].head_branch == 'test-branch'


def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
    # Define the specific comment_id to filter
    specific_comment_id = 123

    # Mock GraphQL response for review threads
    with patch('requests.get') as mock_get:
        # Mock the response for PRs
        mock_prs_response = MagicMock()
        mock_prs_response.json.return_value = [
            {
                'number': 1,
                'title': 'Test PR fixes #3',
                'body': 'Test Body',
                'head': {'ref': 'test-branch'},
            }
        ]

        # Mock the response for PR comments
        mock_comments_response = MagicMock()
        mock_comments_response.json.return_value = [
            {'body': 'First comment', 'id': 120},
            {'body': 'Second comment', 'id': 124},
        ]

        # Mock the response for PR metadata (GraphQL)
        mock_graphql_response = MagicMock()
        mock_graphql_response.json.return_value = {
            'data': {
                'repository': {
                    'pullRequest': {
                        'closingIssuesReferences': {'edges': []},
                        'reviews': {'nodes': []},
                        'reviewThreads': {
                            'edges': [
                                {
                                    'node': {
                                        'id': 'review-thread-1',
                                        'isResolved': False,
                                        'comments': {
                                            'nodes': [
                                                {
                                                    'fullDatabaseId': specific_comment_id,
                                                    'body': 'Specific review comment that references #6',
                                                    'path': 'file1.txt',
                                                },
                                                {
                                                    'fullDatabaseId': 456,
                                                    'body': 'Another review comment referencing #7',
                                                    'path': 'file2.txt',
                                                },
                                            ]
                                        },
                                    }
                                }
                            ]
                        },
                    }
                }
            }
        }

        # Set up the mock to return different responses
        # We need to return empty responses for subsequent pages
        mock_empty_response = MagicMock()
        mock_empty_response.json.return_value = []

        # Mock the response for fetching the external issue referenced in PR body
        mock_external_issue_response_in_body = MagicMock()
        mock_external_issue_response_in_body.json.return_value = {
            'body': 'External context #1.'
        }

        # Mock the response for fetching the external issue referenced in review thread
        mock_external_issue_response_review_thread = MagicMock()
        mock_external_issue_response_review_thread.json.return_value = {
            'body': 'External context #2.'
        }

        mock_get.side_effect = [
            mock_prs_response,  # First call for PRs
            mock_empty_response,  # Second call for PRs (empty page)
            mock_comments_response,  # Third call for PR comments
            mock_empty_response,  # Fourth call for PR comments (empty page)
            mock_external_issue_response_in_body,
            mock_external_issue_response_review_thread,
        ]

        # Mock the post request for GraphQL
        with patch('requests.post') as mock_post:
            mock_post.return_value = mock_graphql_response

            # Create an instance of PRHandler
            llm_config = LLMConfig(model='test', api_key='test')
            handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)

            # Get converted issues
            prs = handler.get_converted_issues(
                issue_numbers=[1], comment_id=specific_comment_id
            )

            # Verify that we got exactly one PR
            assert len(prs) == 1

            # Verify that thread_comments are set correctly
            assert prs[0].thread_comments is None
            assert prs[0].review_comments == []
            assert len(prs[0].review_threads) == 1
            assert isinstance(prs[0].review_threads[0], ReviewThread)
            assert (
                prs[0].review_threads[0].comment
                == 'Specific review comment that references #6
---
latest feedback:
Another review comment referencing #7
'
            )
            assert prs[0].closing_issues == [
                'External context #1.',
                'External context #2.',
            ]  # Only includes references inside comment ID and body PR

            # Verify other fields are set correctly
            assert prs[0].number == 1
            assert prs[0].title == 'Test PR fixes #3'
            assert prs[0].body == 'Test Body'
            assert prs[0].owner == 'test-owner'
            assert prs[0].repo == 'test-repo'
            assert prs[0].head_branch == 'test-branch'


def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
    # Mock the necessary dependencies
    with patch('requests.get') as mock_get:
        # Mock the response for PRs
        mock_prs_response = MagicMock()
        mock_prs_response.json.return_value = [
            {
                'number': 1,
                'title': 'Test PR',
                'body': 'Test Body fixes #1',
                'head': {'ref': 'test-branch'},
            }
        ]

        # Mock the response for PR comments
        mock_comments_response = MagicMock()
        mock_comments_response.json.return_value = [
            {'body': 'First comment addressing #1'},
            {'body': 'Second comment addressing #2'},
        ]

        # Mock the response for PR metadata (GraphQL)
        mock_graphql_response = MagicMock()
        mock_graphql_response.json.return_value = {
            'data': {
                'repository': {
                    'pullRequest': {
                        'closingIssuesReferences': {'edges': []},
                        'reviews': {'nodes': []},
                        'reviewThreads': {'edges': []},
                    }
                }
            }
        }

        # Set up the mock to return different responses
        # We need to return empty responses for subsequent pages
        mock_empty_response = MagicMock()
        mock_empty_response.json.return_value = []

        # Mock the response for fetching the external issue referenced in PR body
        mock_external_issue_response_in_body = MagicMock()
        mock_external_issue_response_in_body.json.return_value = {
            'body': 'External context #1.'
        }

        # Mock the response for fetching the external issue referenced in review thread
        mock_external_issue_response_in_comment = MagicMock()
        mock_external_issue_response_in_comment.json.return_value = {
            'body': 'External context #2.'
        }

        mock_get.side_effect = [
            mock_prs_response,  # First call for PRs
            mock_empty_response,  # Second call for PRs (empty page)
            mock_comments_response,  # Third call for PR comments
            mock_empty_response,  # Fourth call for PR comments (empty page)
            mock_external_issue_response_in_body,  # Mock response for the external issue reference #1
            mock_external_issue_response_in_comment,
        ]

        # Mock the post request for GraphQL
        with patch('requests.post') as mock_post:
            mock_post.return_value = mock_graphql_response

            # Create an instance of PRHandler
            llm_config = LLMConfig(model='test', api_key='test')
            handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)

            # Get converted issues
            prs = handler.get_converted_issues(issue_numbers=[1])

            # Verify that we got exactly one PR
            assert len(prs) == 1

            # Verify that thread_comments are set correctly
            assert prs[0].thread_comments == [
                'First comment addressing #1',
                'Second comment addressing #2',
            ]

            # Verify other fields are set correctly
            assert prs[0].number == 1
            assert prs[0].title == 'Test PR'
            assert prs[0].body == 'Test Body fixes #1'
            assert prs[0].owner == 'test-owner'
            assert prs[0].repo == 'test-repo'
            assert prs[0].head_branch == 'test-branch'
            assert prs[0].closing_issues == [
                'External context #1.',
                'External context #2.',
            ]