From 9afedea1708c0875bbd95a7c5adbb269c60f2914 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Wed, 27 Aug 2025 03:07:09 -0400 Subject: [PATCH] [Bug, GitHub]: fix missing context in cloud resolver (#10517) Co-authored-by: openhands --- .../integrations/github/github_service.py | 220 ++++++++++++++++++ openhands/integrations/github/queries.py | 77 ++++++ .../integrations/gitlab/gitlab_service.py | 2 +- openhands/integrations/service_types.py | 2 +- ...issue_comment_conversation_instructions.j2 | 22 -- .../resolver/github/issue_comment_prompt.j2 | 1 - .../github/issue_conversation_instructions.j2 | 41 ++++ ...issue_labeled_conversation_instructions.j2 | 17 -- .../resolver/github/issue_labeled_prompt.j2 | 1 - .../templates/resolver/github/issue_prompt.j2 | 5 + .../pr_update_conversation_instructions.j2 | 22 +- 11 files changed, 364 insertions(+), 46 deletions(-) delete mode 100644 openhands/integrations/templates/resolver/github/issue_comment_conversation_instructions.j2 delete mode 100644 openhands/integrations/templates/resolver/github/issue_comment_prompt.j2 create mode 100644 openhands/integrations/templates/resolver/github/issue_conversation_instructions.j2 delete mode 100644 openhands/integrations/templates/resolver/github/issue_labeled_conversation_instructions.j2 delete mode 100644 openhands/integrations/templates/resolver/github/issue_labeled_prompt.j2 create mode 100644 openhands/integrations/templates/resolver/github/issue_prompt.j2 diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index 468aedb4cb..bfc637efd3 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -9,12 +9,16 @@ from pydantic import SecretStr from openhands.core.logger import openhands_logger as logger from openhands.integrations.github.queries import ( + get_review_threads_graphql_query, + get_thread_comments_graphql_query, + get_thread_from_comment_graphql_query, suggested_task_issue_graphql_query, suggested_task_pr_graphql_query, ) from openhands.integrations.service_types import ( BaseGitService, Branch, + Comment, GitService, InstallationsService, OwnerType, @@ -695,6 +699,222 @@ class GitHubService(BaseGitService, GitService, InstallationsService): # Parse the content to extract triggers from frontmatter return self._parse_microagent_content(file_content, file_path) + async def get_issue_or_pr_comments( + self, repository: str, issue_number: int, max_comments: int = 10 + ) -> list[Comment]: + """Get comments for an issue. + + Args: + repository: Repository name in format 'owner/repo' + issue_number: The issue number + discussion_id: Not used for GitHub (kept for compatibility with GitLab) + + Returns: + List of Comment objects ordered by creation date + """ + url = f'{self.BASE_URL}/repos/{repository}/issues/{issue_number}/comments' + page = 1 + all_comments: list[dict] = [] + + while len(all_comments) < max_comments: + params = { + 'per_page': 10, + 'sort': 'created', + 'direction': 'asc', + 'page': page, + } + response, headers = await self._make_request(url, params=params) + all_comments.extend(response or []) + + # Parse the Link header for rel="next" + link_header = headers.get('Link', '') + if 'rel="next"' not in link_header: + break + + page += 1 + + return self._process_raw_comments(all_comments) + + async def get_issue_or_pr_title_and_body( + self, repository: str, issue_number: int + ) -> tuple[str, str]: + """Get the title and body of an issue. + + Args: + repository: Repository name in format 'owner/repo' + issue_number: The issue number + + Returns: + A tuple of (title, body) + """ + url = f'{self.BASE_URL}/repos/{repository}/issues/{issue_number}' + response, _ = await self._make_request(url) + title = response.get('title') or '' + body = response.get('body') or '' + return title, body + + async def get_review_thread_comments( + self, + comment_id: str, + repository: str, + pr_number: int, + ) -> list[Comment]: + """Get all comments in a review thread starting from a specific comment. + + Uses GraphQL to traverse the reply chain from the given comment up to the root + comment, then finds the review thread and returns all comments in the thread. + + Args: + comment_id: The GraphQL node ID of any comment in the thread + repo: Repository name + pr_number: Pull request number + + Returns: + List of Comment objects representing the entire thread + """ + + # Step 1: Use existing GraphQL query to get the comment and check for replyTo + variables = {'commentId': comment_id} + data = await self.execute_graphql_query( + get_thread_from_comment_graphql_query, variables + ) + + comment_node = data.get('data', {}).get('node') + if not comment_node: + return [] + + # Step 2: If replyTo exists, traverse to the root comment + root_comment_id = comment_id + reply_to = comment_node.get('replyTo') + if reply_to: + root_comment_id = reply_to['id'] + + # Step 3: Get all review threads and find the one containing our root comment + owner, repo = repository.split('/') + thread_id = None + after_cursor = None + has_next_page = True + + while has_next_page and not thread_id: + threads_variables: dict[str, Any] = { + 'owner': owner, + 'repo': repo, + 'number': pr_number, + 'first': 50, + } + if after_cursor: + threads_variables['after'] = after_cursor + + threads_data = await self.execute_graphql_query( + get_review_threads_graphql_query, threads_variables + ) + + review_threads_data = ( + threads_data.get('data', {}) + .get('repository', {}) + .get('pullRequest', {}) + .get('reviewThreads', {}) + ) + + review_threads = review_threads_data.get('nodes', []) + page_info = review_threads_data.get('pageInfo', {}) + + # Search for the thread containing our root comment + for thread in review_threads: + first_comments = thread.get('comments', {}).get('nodes', []) + for first_comment in first_comments: + if first_comment.get('id') == root_comment_id: + thread_id = thread.get('id') + break + if thread_id: + break + + # Update pagination variables + has_next_page = page_info.get('hasNextPage', False) + after_cursor = page_info.get('endCursor') + + if not thread_id: + # Fallback: return just the comments we found during traversal + logger.warning( + f'Could not find review thread for comment {comment_id}, returning traversed comments' + ) + return [] + + # Step 4: Get all comments from the review thread using the thread ID + all_thread_comments = [] + after_cursor = None + has_next_page = True + + while has_next_page: + comments_variables: dict[str, Any] = {} + comments_variables['threadId'] = thread_id + comments_variables['page'] = 50 + if after_cursor: + comments_variables['after'] = after_cursor + + thread_comments_data = await self.execute_graphql_query( + get_thread_comments_graphql_query, comments_variables + ) + + thread_node = thread_comments_data.get('data', {}).get('node') + if not thread_node: + break + + comments_data = thread_node.get('comments', {}) + comments_nodes = comments_data.get('nodes', []) + page_info = comments_data.get('pageInfo', {}) + + all_thread_comments.extend(comments_nodes) + + has_next_page = page_info.get('hasNextPage', False) + after_cursor = page_info.get('endCursor') + + return self._process_raw_comments(all_thread_comments) + + def _truncate_comment( + self, comment_body: str, max_comment_length: int = 500 + ) -> str: + """Truncate comment body to a maximum length.""" + if len(comment_body) > max_comment_length: + return comment_body[:max_comment_length] + '...' + return comment_body + + def _process_raw_comments( + self, comments_data: list, max_comments: int = 10 + ) -> list[Comment]: + """Convert raw comment data to Comment objects.""" + comments: list[Comment] = [] + for comment in comments_data: + author = 'unknown' + + if comment.get('author'): + author = comment.get('author', {}).get('login', 'unknown') + elif comment.get('user'): + author = comment.get('user', {}).get('login', 'unknown') + + comments.append( + Comment( + id=str(comment.get('id', 'unknown')), + body=self._truncate_comment(comment.get('body', '')), + author=author, + created_at=datetime.fromisoformat( + comment.get('createdAt', '').replace('Z', '+00:00') + ) + if comment.get('createdAt') + else datetime.fromtimestamp(0), + updated_at=datetime.fromisoformat( + comment.get('updatedAt', '').replace('Z', '+00:00') + ) + if comment.get('updatedAt') + else datetime.fromtimestamp(0), + system=False, + ) + ) + + # Sort comments by creation date to maintain chronological order + comments.sort(key=lambda c: c.created_at) + return comments[-max_comments:] + github_service_cls = os.environ.get( 'OPENHANDS_GITHUB_SERVICE_CLS', diff --git a/openhands/integrations/github/queries.py b/openhands/integrations/github/queries.py index 6e4d48bce8..a8b2739846 100644 --- a/openhands/integrations/github/queries.py +++ b/openhands/integrations/github/queries.py @@ -45,3 +45,80 @@ suggested_task_issue_graphql_query = """ } } """ + +get_thread_from_comment_graphql_query = """ + query GetThreadFromComment($commentId: ID!) { + node(id: $commentId) { + ... on PullRequestReviewComment { + id + body + author { + login + } + createdAt + updatedAt + replyTo { + id + body + author { + login + } + createdAt + updatedAt + } + } + } + } +""" + +get_review_threads_graphql_query = """ +query($owner: String!, $repo: String!, $number: Int!, $first: Int = 50, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: $first, after: $after) { + nodes { + id + path + isResolved + comments(first: 1) { + nodes { + id + databaseId + body + author { + login + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +""" + +get_thread_comments_graphql_query = """ +query ($threadId: ID!, $page: Int = 50, $after: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + id + path + isResolved + comments(first: $page, after: $after) { + nodes { + id + databaseId + body + author { login } + createdAt + } + pageInfo { hasNextPage endCursor } + } + } + } +} +""" diff --git a/openhands/integrations/gitlab/gitlab_service.py b/openhands/integrations/gitlab/gitlab_service.py index b4b26aa97f..fe4ea431d8 100644 --- a/openhands/integrations/gitlab/gitlab_service.py +++ b/openhands/integrations/gitlab/gitlab_service.py @@ -727,7 +727,7 @@ class GitLabService(BaseGitService, GitService): continue comment = Comment( - id=comment_data['id'], + id=str(comment_data['id']), body=comment_data['body'], author=comment_data.get('author', {}).get('username', 'unknown'), created_at=datetime.fromisoformat( diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 20bf31c102..15975d6018 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -141,7 +141,7 @@ class Repository(BaseModel): class Comment(BaseModel): - id: int + id: str body: str author: str created_at: datetime diff --git a/openhands/integrations/templates/resolver/github/issue_comment_conversation_instructions.j2 b/openhands/integrations/templates/resolver/github/issue_comment_conversation_instructions.j2 deleted file mode 100644 index cba8892e33..0000000000 --- a/openhands/integrations/templates/resolver/github/issue_comment_conversation_instructions.j2 +++ /dev/null @@ -1,22 +0,0 @@ -You are requested to fix issue number #{{ issue_number }} in a repository. - -A comment on the issue has been addressed to you. - -# Steps to Handle the Comment - -1. Address the comment. Use the $GITHUB_TOKEN and GitHub API to read issue title, body, and comments if you need more context -2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed -3. Run the tests, and if they pass you are done! -4. You do NOT need to write new tests if there are only changes to documentation or configuration files. - -When you're done, make sure to - -1. Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements. -2. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`) -3. Commit your changes with a clear commit message -4. Push the branch to GitHub -5. Use the `create_pr` tool to open a new PR -6. The PR description should: - - Follow the repository's PR template (check `.github/pull_request_template.md` if it exists) - - Mention that it "fixes" or "closes" the issue number - - Include all required sections from the template diff --git a/openhands/integrations/templates/resolver/github/issue_comment_prompt.j2 b/openhands/integrations/templates/resolver/github/issue_comment_prompt.j2 deleted file mode 100644 index a84b0bdbc7..0000000000 --- a/openhands/integrations/templates/resolver/github/issue_comment_prompt.j2 +++ /dev/null @@ -1 +0,0 @@ -{{ issue_comment }} diff --git a/openhands/integrations/templates/resolver/github/issue_conversation_instructions.j2 b/openhands/integrations/templates/resolver/github/issue_conversation_instructions.j2 new file mode 100644 index 0000000000..e9c8b91120 --- /dev/null +++ b/openhands/integrations/templates/resolver/github/issue_conversation_instructions.j2 @@ -0,0 +1,41 @@ +{% if issue_number %} +You are requested to fix issue #{{ issue_number }}: "{{ issue_title }}" in a repository. +A comment on the issue has been addressed to you. +{% else %} +Your task is to fix the issue: "{{ issue_title }}". +{% endif %} + +# Issue Body +{{ issue_body }} + +{% if previous_comments %} +# Previous Comments +For reference, here are the previous comments on the issue: + +{% for comment in previous_comments %} +- @{{ comment.author }} said: +{{ comment.body }} +{% if not loop.last %}\n\n{% endif %} +{% endfor %} +{% endif %} + +# Guidelines + +1. Review the task carefully. +2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed +3. Run the tests, and if they pass you are done! +4. You do NOT need to write new tests if there are only changes to documentation or configuration files. + +# Final Checklist +Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements. + +Use the $GITHUB_TOKEN and GitHub APIs to + +1. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`) +2. Commit your changes with a clear commit message +3. Push the branch to GitHub +4. Use the `create_pr` tool to open a new PR +5. The PR description should: + - Follow the repository's PR template (check `.github/pull_request_template.md` if it exists) + - Mention that it "fixes" or "closes" the issue number + - Include all required sections from the template diff --git a/openhands/integrations/templates/resolver/github/issue_labeled_conversation_instructions.j2 b/openhands/integrations/templates/resolver/github/issue_labeled_conversation_instructions.j2 deleted file mode 100644 index 3e2457598c..0000000000 --- a/openhands/integrations/templates/resolver/github/issue_labeled_conversation_instructions.j2 +++ /dev/null @@ -1,17 +0,0 @@ -Your tasking is to fix an issue in your repository. Do the following - -1. Read the issue body and comments using the $GITHUB_TOKEN and Github API -2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed -3. Run the tests, and if they pass you are done! -4. You do NOT need to write new tests if there are only changes to documentation or configuration files. - -When you're done, make sure to - -1. Create a new branch with a descriptive name (e.g., `openhands/fix-issue-123`) -2. Commit your changes with a clear commit message -3. Push the branch to GitHub -4. Use the `create_pr` tool to open a new PR -5. The PR description should: - - Follow the repository's PR template (check `.github/pull_request_template.md` if it exists) - - Mention that it "fixes" or "closes" the issue number - - Include all required sections from the template diff --git a/openhands/integrations/templates/resolver/github/issue_labeled_prompt.j2 b/openhands/integrations/templates/resolver/github/issue_labeled_prompt.j2 deleted file mode 100644 index 358ed79a74..0000000000 --- a/openhands/integrations/templates/resolver/github/issue_labeled_prompt.j2 +++ /dev/null @@ -1 +0,0 @@ -Please fix issue number #{{ issue_number }} in your repository. diff --git a/openhands/integrations/templates/resolver/github/issue_prompt.j2 b/openhands/integrations/templates/resolver/github/issue_prompt.j2 new file mode 100644 index 0000000000..4fb91742dd --- /dev/null +++ b/openhands/integrations/templates/resolver/github/issue_prompt.j2 @@ -0,0 +1,5 @@ +{% if issue_comment %} +{{ issue_comment }} +{% else %} +Please fix issue number #{{ issue_number }}. +{% endif %} diff --git a/openhands/integrations/templates/resolver/github/pr_update_conversation_instructions.j2 b/openhands/integrations/templates/resolver/github/pr_update_conversation_instructions.j2 index c6e26ff26a..368610eb69 100644 --- a/openhands/integrations/templates/resolver/github/pr_update_conversation_instructions.j2 +++ b/openhands/integrations/templates/resolver/github/pr_update_conversation_instructions.j2 @@ -1,7 +1,23 @@ -You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}. -A comment on the PR has been addressed to you. Do NOT respond to this comment via the GitHub API. +You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}: "{{ pr_title }}". +A comment on the PR has been addressed to you. -{% if file_location %} The comment is in the file `{{ file_location }}` on line #{{ line_number }}{% endif %}. +# PR Description +{{ pr_body }} + +{% if comments %} +# Previous Comments +You may find these other comments relevant: +{% for comment in comments %} +- @{{ comment.author }} said at {{ comment.created_at }}: +{{ comment.body }} +{% if not loop.last %}\n\n{% endif %} +{% endfor %} +{% endif %} + +{% if file_location %} +# Comment location +The comment is in the file `{{ file_location }}` on line #{{ line_number }} +{% endif %}. # Steps to Handle the Comment