diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml index f24a8e90cbfb..199fadf4f849 100644 --- a/.github/workflows/openhands-resolver.yml +++ b/.github/workflows/openhands-resolver.yml @@ -45,6 +45,87 @@ permissions: issues: write jobs: + review-pr: + if: | + github.event.label.name == 'review-pr' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install poetry + run: | + python -m pip install --upgrade pip + pip install poetry + + - name: Install OpenHands dependencies + run: | + poetry install --without evaluation,llama-index + + - name: Check required environment variables + env: + LLM_MODEL: ${{ secrets.LLM_MODEL }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + PAT_USERNAME: ${{ secrets.PAT_USERNAME }} + run: | + required_vars=("LLM_MODEL" "LLM_API_KEY" "PAT_TOKEN" "PAT_USERNAME") + for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo "Error: Required environment variable $var is not set." + exit 1 + fi + done + + - name: Set environment variables + run: | + echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV + echo "ISSUE_TYPE=pr" >> $GITHUB_ENV + echo "COMMENT_ID=None" >> $GITHUB_ENV + echo "MAX_ITERATIONS=1" >> $GITHUB_ENV + echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV + echo "TARGET_BRANCH=${{ inputs.target_branch }}" >> $GITHUB_ENV + + - name: Install OpenHands from PR branch + run: | + python -m pip install --upgrade pip + pip install git+https://github.com/${{ github.repository }}.git@${{ github.head_ref }} + + - name: Comment on PR with start message + uses: actions/github-script@v7 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + github.rest.issues.createComment({ + issue_number: ${{ env.ISSUE_NUMBER }}, + owner: context.repo.owner, + repo: context.repo.repo, + body: `[OpenHands](https://github.com/All-Hands-AI/OpenHands) started reviewing the PR! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).` + }); + + + - name: Review PR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_USERNAME: ${{ secrets.PAT_USERNAME }} + LLM_MODEL: ${{ secrets.LLM_MODEL }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + PYTHONPATH: "" + run: | + cd /tmp && python -m openhands.resolver.review_pr \ + --repo ${{ github.repository }} \ + --issue-number ${{ env.ISSUE_NUMBER }} \ + --issue-type ${{ env.ISSUE_TYPE }} + auto-fix: if: | github.event_name == 'workflow_call' || diff --git a/openhands/resolver/prompts/review b/openhands/resolver/prompts/review new file mode 100644 index 000000000000..2fe885b5f693 --- /dev/null +++ b/openhands/resolver/prompts/review @@ -0,0 +1,14 @@ +You are a helpful AI code reviewer. Your task is to review the following pull request: + +{{ body }} + +Please provide a thorough and constructive review that: +1. Summarizes the changes and their purpose +2. Evaluates code quality, readability, and maintainability +3. Identifies potential bugs, edge cases, or performance issues +4. Suggests improvements while being respectful and helpful +5. Checks for test coverage and documentation +6. Verifies the changes match the PR description and requirements + +Format your review with clear sections and use markdown for better readability. +Focus on being specific and actionable in your feedback. diff --git a/openhands/resolver/review_pr.py b/openhands/resolver/review_pr.py new file mode 100644 index 000000000000..e9fe27cc1c32 --- /dev/null +++ b/openhands/resolver/review_pr.py @@ -0,0 +1,144 @@ +"""Module for reviewing pull requests.""" + +import argparse +import os +import pathlib + +import jinja2 +import litellm +import requests + +from openhands.core.config import LLMConfig +from openhands.core.logger import openhands_logger as logger +from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.issue_definitions import PRHandler + + +def get_pr_diff(owner: str, repo: str, pr_number: int, token: str) -> str: + """Get the diff for a pull request.""" + url = f'https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}' + headers = { + 'Authorization': f'token {token}', + 'Accept': 'application/vnd.github.v3.diff', + } + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.text + + +def post_review_comment( + owner: str, repo: str, pr_number: int, token: str, review: str +) -> None: + """Post a review comment on a pull request.""" + url = f'https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments' + headers = { + 'Authorization': f'token {token}', + 'Accept': 'application/vnd.github.v3+json', + } + data = {'body': review} + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + + +def review_pr( + owner: str, + repo: str, + token: str, + username: str, + output_dir: str, + llm_config: LLMConfig, + issue_number: int, +) -> None: + """Review a pull request. + + Args: + owner: Github owner of the repo. + repo: Github repository name. + token: Github token to access the repository. + username: Github username to access the repository. + output_dir: Output directory to write the results. + llm_config: Configuration for the language model. + issue_number: PR number to review. + """ + # Create output directory + pathlib.Path(output_dir).mkdir(parents=True, exist_ok=True) + pathlib.Path(os.path.join(output_dir, 'infer_logs')).mkdir( + parents=True, exist_ok=True + ) + logger.info(f'Using output directory: {output_dir}') + + # Get PR handler + pr_handler = PRHandler(owner, repo, token) + + # Get PR details + issues: list[GithubIssue] = pr_handler.get_converted_issues( + issue_numbers=[issue_number], comment_id=None + ) + pr = issues[0] + + # Get PR diff + diff = get_pr_diff(owner, repo, issue_number, token) + + # Load review template + with open( + os.path.join(os.path.dirname(__file__), 'prompts/review'), + 'r', + ) as f: + template = jinja2.Template(f.read()) + + # Generate review instruction + instruction = template.render( + body=f'PR #{pr.number}: {pr.title}\n\n{pr.body}\n\nDiff:\n```diff\n{diff}\n```' + ) + + # Get review from LLM + response = litellm.completion( + model=llm_config.model, + messages=[{'role': 'user', 'content': instruction}], + api_key=llm_config.api_key, + base_url=llm_config.base_url, + ) + review = response.choices[0].message.content.strip() + + # Post review comment + post_review_comment(owner, repo, issue_number, token, review) + logger.info('Posted review comment successfully') + + +def main() -> None: + """Main function.""" + parser = argparse.ArgumentParser(description='Review a pull request.') + parser.add_argument( + '--repo', type=str, required=True, help='Repository in owner/repo format' + ) + parser.add_argument( + '--issue-number', type=int, required=True, help='PR number to review' + ) + parser.add_argument('--issue-type', type=str, required=True, help='Issue type (pr)') + + args = parser.parse_args() + + # Split repo into owner and name + owner, repo = args.repo.split('/') + + # Configure LLM + llm_config = LLMConfig( + model=os.environ['LLM_MODEL'], + api_key=os.environ['LLM_API_KEY'], + base_url=os.environ.get('LLM_BASE_URL'), + ) + + # Review PR + review_pr( + owner=owner, + repo=repo, + token=os.environ['GITHUB_TOKEN'], + username=os.environ['GITHUB_USERNAME'], + output_dir='/tmp/output', + llm_config=llm_config, + issue_number=args.issue_number, + ) + + +if __name__ == '__main__': + main() diff --git a/poetry.lock b/poetry.lock index d97ef683fe6b..a176da3e57be 100644 --- a/poetry.lock +++ b/poetry.lock @@ -10350,4 +10350,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "ff370b7b5077720b73fe3b90cc1b7fb9c7a262bfbd35885bb717369061e8a466" +content-hash = "e7a29a0e396d5515ecb381b0f8bcfc74fd5a1d839884b23bc336db7df312a5a7" diff --git a/pyproject.toml b/pyproject.toml index ec148baadc5c..b73821f59639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,8 @@ opentelemetry-exporter-otlp-proto-grpc = "1.25.0" modal = "^0.66.26" runloop-api-client = "0.10.0" pygithub = "^2.5.0" -openhands-aci = "^0.1.1" +openhands-aci = "^0.1.1"sue-5219-try2 +pre-commit = "^4.0.1" python-socketio = "^5.11.4" redis = "^5.2.0" diff --git a/tests/unit/test_review_pr.py b/tests/unit/test_review_pr.py new file mode 100644 index 000000000000..cd7d871f4df1 --- /dev/null +++ b/tests/unit/test_review_pr.py @@ -0,0 +1,99 @@ +"""Tests for the review_pr module.""" + +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +from openhands.core.config import LLMConfig +from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.review_pr import get_pr_diff, post_review_comment, review_pr + + +@pytest.fixture +def mock_pr() -> GithubIssue: + """Create a mock PR.""" + return GithubIssue( + owner='owner', + repo='repo', + number=1, + title='Test PR', + body='Test PR description', + thread_comments=None, + review_comments=None, + ) + + +@pytest.fixture +def mock_llm_config() -> LLMConfig: + """Create a mock LLM config.""" + return LLMConfig( + model='test-model', + api_key='test-key', + base_url=None, + ) + + +def test_get_pr_diff() -> None: + """Test getting PR diff.""" + with patch('requests.get') as mock_get: + mock_get.return_value.text = 'test diff' + diff = get_pr_diff('owner', 'repo', 1, 'token') + assert diff == 'test diff' + mock_get.assert_called_once_with( + 'https://api.github.com/repos/owner/repo/pulls/1', + headers={ + 'Authorization': 'token token', + 'Accept': 'application/vnd.github.v3.diff', + }, + ) + + +def test_post_review_comment() -> None: + """Test posting review comment.""" + with patch('requests.post') as mock_post: + post_review_comment('owner', 'repo', 1, 'token', 'test review') + mock_post.assert_called_once_with( + 'https://api.github.com/repos/owner/repo/issues/1/comments', + headers={ + 'Authorization': 'token token', + 'Accept': 'application/vnd.github.v3+json', + }, + json={'body': 'test review'}, + ) + + +def test_review_pr(mock_pr: GithubIssue, mock_llm_config: LLMConfig) -> None: + """Test reviewing PR.""" + with ( + tempfile.TemporaryDirectory() as temp_dir, + patch('openhands.resolver.review_pr.PRHandler') as mock_handler, + patch('openhands.resolver.review_pr.get_pr_diff') as mock_get_diff, + patch('openhands.resolver.review_pr.post_review_comment') as mock_post_comment, + patch('litellm.completion') as mock_completion, + ): + # Setup mocks + mock_handler.return_value.get_converted_issues.return_value = [mock_pr] + mock_get_diff.return_value = 'test diff' + mock_completion.return_value.choices = [ + MagicMock(message=MagicMock(content='test review')) + ] + + # Run review + review_pr( + owner='owner', + repo='repo', + token='token', + username='username', + output_dir=temp_dir, + llm_config=mock_llm_config, + issue_number=1, + ) + + # Verify calls + mock_handler.assert_called_once_with('owner', 'repo', 'token') + mock_get_diff.assert_called_once_with('owner', 'repo', 1, 'token') + mock_completion.assert_called_once() + mock_post_comment.assert_called_once_with( + 'owner', 'repo', 1, 'token', 'test review' + )