diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml index 03fdab4..9fe6e20 100644 --- a/.github/workflows/openhands-resolver.yml +++ b/.github/workflows/openhands-resolver.yml @@ -23,6 +23,8 @@ on: types: [labeled] pull_request: types: [labeled] + issue_comment: + types: [created] permissions: contents: write @@ -31,7 +33,11 @@ permissions: jobs: auto-fix: - if: github.event_name == 'workflow_call' || github.event.label.name == 'fix-me' || github.event.label.name == 'fix-me-experimental' + if: | + github.event_name == 'workflow_call' || + github.event.label.name == 'fix-me' || + github.event.label.name == 'fix-me-experimental' || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@openhands-agent')) runs-on: ubuntu-latest steps: - name: Checkout repository @@ -83,6 +89,8 @@ jobs: echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV echo "ISSUE_TYPE=issue" >> $GITHUB_ENV fi + + echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV - name: Comment on issue with start message @@ -119,7 +127,8 @@ jobs: --repo ${{ github.repository }} \ --issue-number ${{ env.ISSUE_NUMBER }} \ --issue-type ${{ env.ISSUE_TYPE }} \ - --max-iterations ${{ env.MAX_ITERATIONS }} + --max-iterations ${{ env.MAX_ITERATIONS }} \ + --comment-id ${{ env.COMMENT_ID }} - name: Check resolution result id: check_result @@ -132,11 +141,11 @@ jobs: - name: Upload output.jsonl as artifact uses: actions/upload-artifact@v4 - if: always() # Upload even if the previous steps fail + if: always() # Upload even if the previous steps fail with: name: resolver-output path: /tmp/output/output.jsonl - retention-days: 30 # Keep the artifact for 30 days + retention-days: 30 # Keep the artifact for 30 days - name: Create draft PR or push branch env: diff --git a/openhands_resolver/issue_definitions.py b/openhands_resolver/issue_definitions.py index 4b5437c..fd5ab38 100644 --- a/openhands_resolver/issue_definitions.py +++ b/openhands_resolver/issue_definitions.py @@ -18,7 +18,7 @@ class IssueHandlerInterface(ABC): issue_type: ClassVar[str] @abstractmethod - def get_converted_issues(self) -> list[GithubIssue]: + def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]: """Download issues from GitHub.""" pass @@ -76,7 +76,7 @@ def _extract_image_urls(self, issue_body: str) -> list[str]: image_pattern = r'!\[.*?\]\((https?://[^\s)]+)\)' return re.findall(image_pattern, issue_body) - def _get_issue_comments(self, issue_number: int) -> list[str] | None: + def _get_issue_comments(self, issue_number: int, comment_id: int | None = None) -> list[str] | None: """Download comments for a specific issue from Github.""" url = f"https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments" headers = { @@ -94,18 +94,23 @@ def _get_issue_comments(self, issue_number: int) -> list[str] | None: if not comments: break - all_comments.extend([comment["body"] for comment in comments]) + if comment_id: + matching_comment = next((comment["body"] for comment in comments if comment["id"] == comment_id), None) + if matching_comment: + return [matching_comment] + else: + all_comments.extend([comment["body"] for comment in comments]) + params["page"] += 1 return all_comments if all_comments else None - def get_converted_issues(self) -> list[GithubIssue]: + def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]: """Download issues from Github. Returns: List of Github issues. """ - all_issues = self._download_issues_from_github() converted_issues = [] for issue in all_issues: @@ -119,7 +124,7 @@ def get_converted_issues(self) -> list[GithubIssue]: continue # Get issue thread comments - thread_comments = self._get_issue_comments(issue["number"]) + thread_comments = self._get_issue_comments(issue["number"], comment_id=comment_id) # Convert empty lists to None for optional fields issue_details = GithubIssue( owner=self.owner, @@ -132,6 +137,7 @@ def get_converted_issues(self) -> list[GithubIssue]: ) converted_issues.append(issue_details) + return converted_issues def get_instruction(self, issue: GithubIssue, prompt_template: str, repo_instruction: str | None = None) -> tuple[str, list[str]]: @@ -332,7 +338,7 @@ def _get_pr_comments(self, pr_number: int) -> list[str] | None: return all_comments if all_comments else None - def get_converted_issues(self) -> list[GithubIssue]: + def get_converted_issues(self, comment_id: int | None = None) -> list[GithubIssue]: all_issues = self._download_issues_from_github() converted_issues = [] for issue in all_issues: diff --git a/openhands_resolver/resolve_issue.py b/openhands_resolver/resolve_issue.py index 288cb45..bfe2a36 100644 --- a/openhands_resolver/resolve_issue.py +++ b/openhands_resolver/resolve_issue.py @@ -302,6 +302,7 @@ async def resolve_issue( issue_type: str, repo_instruction: str | None, issue_number: int, + comment_id: int | None, reset_logger: bool = False, ) -> None: """Resolve a single github issue. @@ -322,7 +323,7 @@ async def resolve_issue( issue_handler = issue_handler_factory(issue_type, owner, repo, token) # Load dataset - issues: list[GithubIssue] = issue_handler.get_converted_issues() + issues: list[GithubIssue] = issue_handler.get_converted_issues(comment_id=comment_id) # Find the specific issue issue = next((i for i in issues if i.number == issue_number), None) @@ -429,6 +430,13 @@ async def resolve_issue( def main(): import argparse + def int_or_none(value): + if value.lower() == 'none': + return None + else: + return int(value) + + parser = argparse.ArgumentParser(description="Resolve a single issue from Github.") parser.add_argument( "--repo", @@ -466,6 +474,13 @@ def main(): required=True, help="Issue number to resolve.", ) + parser.add_argument( + "--comment-id", + type=int_or_none, + required=False, + default=None, + help="Resolve a specific comment" + ) parser.add_argument( "--output-dir", type=str, @@ -566,6 +581,7 @@ def main(): issue_type=issue_type, repo_instruction=repo_instruction, issue_number=my_args.issue_number, + comment_id=my_args.comment_id, ) ) diff --git a/tests/test_resolve_issues.py b/tests/test_resolve_issues.py index 38ba6de..14d6444 100644 --- a/tests/test_resolve_issues.py +++ b/tests/test_resolve_issues.py @@ -736,6 +736,45 @@ def get_mock_response(url, *args, **kwargs): assert not issues[0].closing_issues assert not issues[0].thread_ids +def test_download_issue_with_specific_comment(): + handler = IssueHandler("owner", "repo", "token") + + # Define the specific comment_id to filter + specific_comment_id = 101 + + # Mock issue and comment responses + mock_issue_response = MagicMock() + mock_issue_response.json.side_effect = [ + [ + {"number": 1, "title": "Issue 1", "body": "This is an issue"}, + ], + None, + ] + mock_issue_response.raise_for_status = MagicMock() + + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [ + {"id": specific_comment_id, "body": "Specific comment body", "issue_url": "https://api.github.com/repos/owner/repo/issues/1"}, + {"id": 102, "body": "Another comment body", "issue_url": "https://api.github.com/repos/owner/repo/issues/2"}, + ] + mock_comments_response.raise_for_status = MagicMock() + + + def get_mock_response(url, *args, **kwargs): + if "/comments" in url: + return mock_comments_response + + return mock_issue_response + + + with patch('requests.get', side_effect=get_mock_response): + issues = handler.get_converted_issues(comment_id=specific_comment_id) + + assert len(issues) == 1 + assert issues[0].number == 1 + assert issues[0].title == "Issue 1" + assert issues[0].thread_comments == ["Specific comment body"] + if __name__ == "__main__": pytest.main()