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
Implementation Analysis
Technical Details
Best Practices Demonstrated
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.',
]