@openhands/extensions 0.0.1-alpha → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/custom-codereview-guide.md +25 -0
- package/.github/pull_request_template.md +38 -0
- package/.github/release.yml +14 -0
- package/.github/workflows/check-extensions.yml +72 -0
- package/.github/workflows/npm-publish.yml +89 -0
- package/.github/workflows/pr.yml +30 -0
- package/.github/workflows/release.yml +24 -0
- package/.github/workflows/tests.yml +25 -0
- package/.github/workflows/vulnerability-scan.yml +87 -0
- package/.release-please-manifest.json +3 -0
- package/AGENTS.md +132 -0
- package/README.md +10 -0
- package/analysis_results.md +162 -0
- package/marketplaces/large-codebase.json +66 -0
- package/marketplaces/openhands-extensions.json +682 -0
- package/package.json +4 -10
- package/plugins/README.md +30 -0
- package/plugins/city-weather/.plugin/plugin.json +13 -0
- package/plugins/city-weather/README.md +145 -0
- package/plugins/city-weather/commands/now.md +56 -0
- package/plugins/cobol-modernization/.plugin/plugin.json +19 -0
- package/plugins/cobol-modernization/README.md +201 -0
- package/plugins/cobol-modernization/references/troubleshooting.md +18 -0
- package/plugins/cobol-modernization/skills/build-setup/SKILL.md +78 -0
- package/plugins/cobol-modernization/skills/build-setup/scripts/install-gnucobol.sh +32 -0
- package/plugins/cobol-modernization/skills/cobol-modernization-overview/SKILL.md +113 -0
- package/plugins/cobol-modernization/skills/mainfraime-removal/SKILL.md +62 -0
- package/plugins/cobol-modernization/skills/mainfraime-removal/references/cics-transformation-examples.md +45 -0
- package/plugins/cobol-modernization/skills/mainframe-planning/SKILL.md +78 -0
- package/plugins/cobol-modernization/skills/to-java-migration/SKILL.md +59 -0
- package/plugins/cobol-modernization/skills/to-java-migration/references/cobol-to-java-example.md +58 -0
- package/plugins/cobol-modernization/skills/to-java-migration/references/datatype-mappings.md +19 -0
- package/plugins/issue-duplicate-checker/.plugin/plugin.json +13 -0
- package/plugins/issue-duplicate-checker/README.md +51 -0
- package/plugins/issue-duplicate-checker/action.yml +349 -0
- package/plugins/issue-duplicate-checker/scripts/auto_close_duplicate_issues.py +569 -0
- package/plugins/issue-duplicate-checker/scripts/issue_duplicate_check_openhands.py +681 -0
- package/plugins/issue-duplicate-checker/scripts/post_duplicate_notice.js +220 -0
- package/plugins/issue-duplicate-checker/scripts/remove_duplicate_candidate_label.js +27 -0
- package/plugins/magic-test/.plugin/plugin.json +13 -0
- package/plugins/magic-test/skills/magic-word/SKILL.md +33 -0
- package/plugins/migration-scoring/.plugin/plugin.json +19 -0
- package/plugins/migration-scoring/README.md +244 -0
- package/plugins/migration-scoring/skills/migration-mapping/SKILL.md +72 -0
- package/plugins/migration-scoring/skills/migration-report/SKILL.md +118 -0
- package/plugins/migration-scoring/skills/migration-scoring-overview/SKILL.md +126 -0
- package/plugins/migration-scoring/skills/score-quality/SKILL.md +54 -0
- package/plugins/migration-scoring/skills/score-quality/references/scoring-criteria.md +30 -0
- package/plugins/migration-scoring/skills/score-style/SKILL.md +106 -0
- package/plugins/onboarding/.plugin/plugin.json +20 -0
- package/plugins/onboarding/README.md +30 -0
- package/plugins/onboarding/references/criteria.md +144 -0
- package/plugins/onboarding/skills/agent-readiness-report/README.md +23 -0
- package/plugins/onboarding/skills/agent-readiness-report/SKILL.md +122 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_agent_instructions.sh +88 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_build_env.sh +114 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_feedback_loops.sh +133 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_policy.sh +113 -0
- package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_workflows.sh +127 -0
- package/plugins/onboarding/skills/improve-agent-readiness/README.md +19 -0
- package/plugins/onboarding/skills/improve-agent-readiness/SKILL.md +167 -0
- package/plugins/onboarding/skills/setup-agents-md/README.md +15 -0
- package/plugins/onboarding/skills/setup-agents-md/SKILL.md +150 -0
- package/plugins/onboarding/skills/setup-openhands/README.md +20 -0
- package/plugins/onboarding/skills/setup-openhands/SKILL.md +56 -0
- package/plugins/onboarding/skills/setup-pr-review/README.md +23 -0
- package/plugins/onboarding/skills/setup-pr-review/SKILL.md +72 -0
- package/plugins/openhands/.plugin/plugin.json +13 -0
- package/plugins/openhands/README.md +52 -0
- package/plugins/openhands/SKILL.md +61 -0
- package/plugins/openhands/commands/create.md +55 -0
- package/plugins/openhands/commands/openhands-cloud.md +8 -0
- package/plugins/openhands/scripts/run.sh +69 -0
- package/plugins/pr-review/.plugin/plugin.json +13 -0
- package/plugins/pr-review/README.md +393 -0
- package/plugins/pr-review/action.yml +298 -0
- package/plugins/pr-review/scripts/agent_script.py +1282 -0
- package/plugins/pr-review/scripts/evaluate_review.py +655 -0
- package/plugins/pr-review/scripts/prompt.py +260 -0
- package/plugins/pr-review/workflows/pr-review-by-openhands.yml +51 -0
- package/plugins/pr-review/workflows/pr-review-evaluation.yml +85 -0
- package/plugins/qa-changes/.plugin/plugin.json +11 -0
- package/plugins/qa-changes/README.md +185 -0
- package/plugins/qa-changes/action.yml +181 -0
- package/plugins/qa-changes/scripts/agent_script.py +406 -0
- package/plugins/qa-changes/scripts/evaluate_qa_changes.py +385 -0
- package/plugins/qa-changes/scripts/prompt.py +174 -0
- package/plugins/qa-changes/workflows/qa-changes-by-openhands.yml +50 -0
- package/plugins/qa-changes/workflows/qa-changes-evaluation.yml +85 -0
- package/plugins/release-notes/.plugin/plugin.json +19 -0
- package/plugins/release-notes/README.md +283 -0
- package/plugins/release-notes/SKILL.md +83 -0
- package/plugins/release-notes/action.yml +117 -0
- package/plugins/release-notes/commands/release-notes.md +8 -0
- package/plugins/release-notes/scripts/agent_script.py +292 -0
- package/plugins/release-notes/scripts/generate_release_notes.py +733 -0
- package/plugins/release-notes/scripts/prompt.py +90 -0
- package/plugins/release-notes/scripts/validate_release_notes.py +328 -0
- package/plugins/release-notes/workflows/release-notes.yml +76 -0
- package/plugins/vulnerability-remediation/.plugin/plugin.json +19 -0
- package/plugins/vulnerability-remediation/README.md +217 -0
- package/plugins/vulnerability-remediation/action.yml +187 -0
- package/plugins/vulnerability-remediation/scripts/scan_and_remediate.py +561 -0
- package/plugins/vulnerability-remediation/workflows/vulnerability-scan.yml +87 -0
- package/pyproject.toml +12 -0
- package/release-please-config.json +16 -0
- package/scripts/sync_extensions.py +494 -0
- package/scripts/sync_openhands_sdk_skill.py +264 -0
- package/skills/README.md +159 -0
- package/skills/add-javadoc/.plugin/plugin.json +18 -0
- package/skills/add-javadoc/README.md +40 -0
- package/skills/add-javadoc/SKILL.md +35 -0
- package/skills/add-javadoc/references/example.md +32 -0
- package/skills/add-skill/.plugin/plugin.json +18 -0
- package/skills/add-skill/README.md +67 -0
- package/skills/add-skill/SKILL.md +47 -0
- package/skills/add-skill/scripts/fetch_skill.py +259 -0
- package/skills/agent-creator/.plugin/plugin.json +20 -0
- package/skills/agent-creator/README.md +104 -0
- package/skills/agent-creator/SKILL.md +190 -0
- package/skills/agent-creator/commands/agent-creator.md +8 -0
- package/skills/agent-creator/references/fallback.md +117 -0
- package/skills/agent-memory/.plugin/plugin.json +18 -0
- package/skills/agent-memory/README.md +35 -0
- package/skills/agent-memory/SKILL.md +30 -0
- package/skills/agent-memory/commands/remember.md +8 -0
- package/skills/agent-sdk-builder/.plugin/plugin.json +18 -0
- package/skills/agent-sdk-builder/README.md +40 -0
- package/skills/agent-sdk-builder/SKILL.md +37 -0
- package/skills/agent-sdk-builder/commands/agent-builder.md +8 -0
- package/skills/azure-devops/.plugin/plugin.json +18 -0
- package/skills/azure-devops/README.md +55 -0
- package/skills/azure-devops/SKILL.md +50 -0
- package/skills/bitbucket/.plugin/plugin.json +17 -0
- package/skills/bitbucket/README.md +50 -0
- package/skills/bitbucket/SKILL.md +45 -0
- package/skills/code-review/.plugin/plugin.json +19 -0
- package/skills/code-review/README.md +18 -0
- package/skills/code-review/SKILL.md +208 -0
- package/skills/code-review/commands/codereview-roasted.md +8 -0
- package/skills/code-review/commands/codereview.md +8 -0
- package/skills/code-review/references/risk-evaluation.md +41 -0
- package/skills/code-review/references/supply-chain-security.md +31 -0
- package/skills/code-simplifier/.plugin/plugin.json +21 -0
- package/skills/code-simplifier/README.md +30 -0
- package/skills/code-simplifier/SKILL.md +91 -0
- package/skills/code-simplifier/commands/simplify.md +8 -0
- package/skills/code-simplifier/references/code-quality-review.md +86 -0
- package/skills/code-simplifier/references/code-reuse-review.md +63 -0
- package/skills/code-simplifier/references/efficiency-review.md +81 -0
- package/skills/datadog/.plugin/plugin.json +19 -0
- package/skills/datadog/README.md +100 -0
- package/skills/datadog/SKILL.md +95 -0
- package/skills/deno/.plugin/plugin.json +18 -0
- package/skills/deno/README.md +5 -0
- package/skills/deno/SKILL.md +99 -0
- package/skills/deno/references/README.md +6 -0
- package/skills/discord/.plugin/plugin.json +18 -0
- package/skills/discord/README.md +31 -0
- package/skills/discord/SKILL.md +109 -0
- package/skills/discord/__init__.py +0 -0
- package/skills/discord/references/REFERENCE.md +78 -0
- package/skills/discord/scripts/__init__.py +0 -0
- package/skills/discord/scripts/_http.py +127 -0
- package/skills/discord/scripts/post_webhook.py +106 -0
- package/skills/discord/scripts/send_message.py +102 -0
- package/skills/docker/.plugin/plugin.json +17 -0
- package/skills/docker/README.md +34 -0
- package/skills/docker/SKILL.md +29 -0
- package/skills/evidence-based-citations/.plugin/plugin.json +20 -0
- package/skills/evidence-based-citations/README.md +31 -0
- package/skills/evidence-based-citations/SKILL.md +59 -0
- package/skills/flarglebargle/.plugin/plugin.json +16 -0
- package/skills/flarglebargle/README.md +14 -0
- package/skills/flarglebargle/SKILL.md +9 -0
- package/skills/frontend-design/.plugin/plugin.json +21 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/README.md +42 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/github/.plugin/plugin.json +19 -0
- package/skills/github/README.md +42 -0
- package/skills/github/SKILL.md +106 -0
- package/skills/github-pr-review/.plugin/plugin.json +18 -0
- package/skills/github-pr-review/README.md +145 -0
- package/skills/github-pr-review/SKILL.md +148 -0
- package/skills/github-pr-review/commands/github-pr-review.md +8 -0
- package/skills/github-pr-reviewer/.plugin/plugin.json +20 -0
- package/skills/github-pr-reviewer/README.md +34 -0
- package/skills/github-pr-reviewer/SKILL.md +89 -0
- package/skills/github-pr-reviewer/commands/pr-reviewer:setup.md +8 -0
- package/skills/github-repo-monitor/.plugin/plugin.json +22 -0
- package/skills/github-repo-monitor/README.md +70 -0
- package/skills/github-repo-monitor/SKILL.md +316 -0
- package/skills/github-repo-monitor/commands/github-monitor:poll.md +8 -0
- package/skills/github-repo-monitor/references/github-api.md +241 -0
- package/skills/github-repo-monitor/references/state-schema.md +160 -0
- package/skills/github-repo-monitor/scripts/main.py +915 -0
- package/skills/github-repo-monitor/tests/test_main.py +400 -0
- package/skills/gitlab/.plugin/plugin.json +17 -0
- package/skills/gitlab/README.md +37 -0
- package/skills/gitlab/SKILL.md +32 -0
- package/skills/incident-retrospective/.plugin/plugin.json +21 -0
- package/skills/incident-retrospective/README.md +34 -0
- package/skills/incident-retrospective/SKILL.md +98 -0
- package/skills/incident-retrospective/commands/incident-retro:setup.md +8 -0
- package/skills/iterate/.plugin/plugin.json +13 -0
- package/skills/iterate/README.md +25 -0
- package/skills/iterate/SKILL.md +399 -0
- package/skills/iterate/commands/babysit.md +8 -0
- package/skills/iterate/commands/iterate.md +8 -0
- package/skills/iterate/commands/verify.md +8 -0
- package/skills/iterate/references/heuristics.md +58 -0
- package/skills/iterate/references/verification.md +96 -0
- package/skills/jupyter/.plugin/plugin.json +18 -0
- package/skills/jupyter/README.md +55 -0
- package/skills/jupyter/SKILL.md +50 -0
- package/skills/kubernetes/.plugin/plugin.json +18 -0
- package/skills/kubernetes/README.md +53 -0
- package/skills/kubernetes/SKILL.md +48 -0
- package/skills/learn-from-code-review/.plugin/plugin.json +19 -0
- package/skills/learn-from-code-review/README.md +64 -0
- package/skills/learn-from-code-review/SKILL.md +186 -0
- package/skills/learn-from-code-review/commands/learn-from-reviews.md +8 -0
- package/skills/linear/.plugin/plugin.json +19 -0
- package/skills/linear/README.md +58 -0
- package/skills/linear/SKILL.md +213 -0
- package/skills/linear-triage/.plugin/plugin.json +21 -0
- package/skills/linear-triage/README.md +34 -0
- package/skills/linear-triage/SKILL.md +91 -0
- package/skills/linear-triage/commands/linear-triage:setup.md +8 -0
- package/skills/notion/.plugin/plugin.json +17 -0
- package/skills/notion/README.md +114 -0
- package/skills/notion/SKILL.md +109 -0
- package/skills/npm/.plugin/plugin.json +17 -0
- package/skills/npm/README.md +14 -0
- package/skills/npm/SKILL.md +9 -0
- package/skills/openhands-api/.plugin/plugin.json +22 -0
- package/skills/openhands-api/README.md +48 -0
- package/skills/openhands-api/SKILL.md +399 -0
- package/skills/openhands-api/references/README.md +33 -0
- package/skills/openhands-api/references/TROUBLESHOOTING.md +81 -0
- package/skills/openhands-api/references/example_prompt.md +12 -0
- package/skills/openhands-api/scripts/openhands_api.py +606 -0
- package/skills/openhands-api/scripts/openhands_api.ts +252 -0
- package/skills/openhands-automation/.plugin/plugin.json +19 -0
- package/skills/openhands-automation/README.md +89 -0
- package/skills/openhands-automation/SKILL.md +875 -0
- package/skills/openhands-automation/commands/automation:create.md +8 -0
- package/skills/openhands-automation/references/ab-testing.md +185 -0
- package/skills/openhands-automation/references/custom-automation.md +644 -0
- package/skills/openhands-sdk/.plugin/plugin.json +20 -0
- package/skills/openhands-sdk/README.md +22 -0
- package/skills/openhands-sdk/SKILL.md +229 -0
- package/skills/openhands-sdk/commands/sdk.md +8 -0
- package/skills/pdflatex/.plugin/plugin.json +18 -0
- package/skills/pdflatex/README.md +39 -0
- package/skills/pdflatex/SKILL.md +34 -0
- package/skills/prd/.plugin/plugin.json +19 -0
- package/skills/prd/README.md +28 -0
- package/skills/prd/SKILL.md +237 -0
- package/skills/prd/commands/prd.md +8 -0
- package/skills/qa-changes/README.md +18 -0
- package/skills/qa-changes/SKILL.md +229 -0
- package/skills/qa-changes/commands/qa-changes.md +8 -0
- package/skills/release-notes/README.md +24 -0
- package/skills/release-notes/SKILL.md +19 -0
- package/skills/release-notes/commands/release-notes.md +8 -0
- package/skills/research-brief/.plugin/plugin.json +20 -0
- package/skills/research-brief/README.md +34 -0
- package/skills/research-brief/SKILL.md +99 -0
- package/skills/research-brief/commands/research-brief:setup.md +8 -0
- package/skills/security/.plugin/plugin.json +18 -0
- package/skills/security/README.md +38 -0
- package/skills/security/SKILL.md +33 -0
- package/skills/skill-creator/.plugin/plugin.json +17 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/README.md +182 -0
- package/skills/skill-creator/SKILL.md +545 -0
- package/skills/skill-creator/references/output-patterns.md +82 -0
- package/skills/skill-creator/references/workflows.md +28 -0
- package/skills/skill-creator/scripts/init_skill.py +303 -0
- package/skills/skill-creator/scripts/quick_validate.py +95 -0
- package/skills/slack-channel-monitor/.plugin/plugin.json +21 -0
- package/skills/slack-channel-monitor/README.md +91 -0
- package/skills/slack-channel-monitor/SKILL.md +276 -0
- package/skills/slack-channel-monitor/commands/slack-monitor:poll.md +8 -0
- package/skills/slack-channel-monitor/references/slack-api.md +207 -0
- package/skills/slack-channel-monitor/references/state-schema.md +180 -0
- package/skills/slack-channel-monitor/scripts/main.py +962 -0
- package/skills/slack-standup-digest/.plugin/plugin.json +21 -0
- package/skills/slack-standup-digest/README.md +34 -0
- package/skills/slack-standup-digest/SKILL.md +92 -0
- package/skills/slack-standup-digest/commands/standup-digest:setup.md +8 -0
- package/skills/spark-version-upgrade/.plugin/plugin.json +20 -0
- package/skills/spark-version-upgrade/README.md +54 -0
- package/skills/spark-version-upgrade/SKILL.md +233 -0
- package/skills/ssh/.plugin/plugin.json +18 -0
- package/skills/ssh/README.md +140 -0
- package/skills/ssh/SKILL.md +135 -0
- package/skills/swift-linux/.plugin/plugin.json +17 -0
- package/skills/swift-linux/README.md +86 -0
- package/skills/swift-linux/SKILL.md +81 -0
- package/skills/theme-factory/.plugin/plugin.json +19 -0
- package/skills/theme-factory/LICENSE.txt +202 -0
- package/skills/theme-factory/README.md +58 -0
- package/skills/theme-factory/SKILL.md +59 -0
- package/skills/theme-factory/theme-showcase.pdf +0 -0
- package/skills/theme-factory/themes/arctic-frost.md +19 -0
- package/skills/theme-factory/themes/botanical-garden.md +19 -0
- package/skills/theme-factory/themes/desert-rose.md +19 -0
- package/skills/theme-factory/themes/forest-canopy.md +19 -0
- package/skills/theme-factory/themes/golden-hour.md +19 -0
- package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
- package/skills/theme-factory/themes/modern-minimalist.md +19 -0
- package/skills/theme-factory/themes/ocean-depths.md +19 -0
- package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
- package/skills/theme-factory/themes/tech-innovation.md +19 -0
- package/skills/uv/.plugin/plugin.json +18 -0
- package/skills/uv/README.md +5 -0
- package/skills/uv/SKILL.md +95 -0
- package/skills/uv/references/README.md +5 -0
- package/skills/vercel/.plugin/plugin.json +18 -0
- package/skills/vercel/README.md +108 -0
- package/skills/vercel/SKILL.md +103 -0
- package/tests/test_add_skill_installs_to_agents_dir.py +42 -0
- package/tests/test_catalogs.py +109 -0
- package/tests/test_code_review_risk_evaluation.py +94 -0
- package/tests/test_issue_duplicate_checker.py +240 -0
- package/tests/test_openhands_api_python.py +152 -0
- package/tests/test_plugin_manifest.py +83 -0
- package/tests/test_pr_review_diff_payload.py +202 -0
- package/tests/test_pr_review_feedback.py +263 -0
- package/tests/test_pr_review_prompt.py +152 -0
- package/tests/test_pr_review_review_context.py +253 -0
- package/tests/test_qa_changes.py +232 -0
- package/tests/test_qa_changes_evaluation.py +259 -0
- package/tests/test_release_notes_generator.py +990 -0
- package/tests/test_sdk_loading.py +150 -0
- package/tests/test_skill_plugin_loading.py +149 -0
- package/tests/test_skills_have_readme.py +66 -0
- package/tests/test_sync_extensions.py +292 -0
- package/tests/test_workflow_sync.py +46 -0
- package/utils/analysis/README.md +7 -0
- package/utils/analysis/laminar_signals/README.md +211 -0
- package/utils/analysis/laminar_signals/analyze.py +780 -0
- package/utils/analysis/laminar_signals/templates/default.j2 +49 -0
- package/utils/analysis/laminar_signals/templates/pr_review.j2 +61 -0
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Example: PR Review Agent
|
|
4
|
+
|
|
5
|
+
This script runs OpenHands agent to review a pull request and provide
|
|
6
|
+
fine-grained review comments. The agent has full repository access and
|
|
7
|
+
uses bash commands to analyze changes in context and post detailed review
|
|
8
|
+
feedback directly via `gh` or the GitHub API.
|
|
9
|
+
|
|
10
|
+
This example demonstrates how to use the `/codereview` skill for code review.
|
|
11
|
+
|
|
12
|
+
The agent posts inline review comments on specific lines of code using
|
|
13
|
+
the GitHub API, rather than posting one giant comment under the PR.
|
|
14
|
+
|
|
15
|
+
The agent also considers previous review context including:
|
|
16
|
+
- Existing review comments and their resolution status
|
|
17
|
+
- Previous review decisions (APPROVED, CHANGES_REQUESTED, etc.)
|
|
18
|
+
- Review threads (resolved and unresolved)
|
|
19
|
+
|
|
20
|
+
Designed for use with GitHub Actions workflows triggered by PR labels.
|
|
21
|
+
|
|
22
|
+
Environment Variables:
|
|
23
|
+
AGENT_KIND: Review agent backend, either 'openhands' or 'acp'
|
|
24
|
+
(default: 'openhands')
|
|
25
|
+
ACP_COMMAND: Command used to start the ACP server when AGENT_KIND='acp'
|
|
26
|
+
ACP_PROMPT_TIMEOUT: Timeout in seconds for one ACP prompt turn
|
|
27
|
+
LLM_API_KEY: API key for the LLM (required for OpenHands agent kind)
|
|
28
|
+
LLM_MODEL: Language model to use (default: anthropic/claude-sonnet-4-5-20250929)
|
|
29
|
+
LLM_BASE_URL: Optional base URL for LLM API
|
|
30
|
+
GITHUB_TOKEN: GitHub token for API access (required)
|
|
31
|
+
PR_NUMBER: Pull request number (required)
|
|
32
|
+
PR_TITLE: Pull request title (required)
|
|
33
|
+
PR_BODY: Pull request body (optional)
|
|
34
|
+
PR_BASE_BRANCH: Base branch name (required)
|
|
35
|
+
PR_HEAD_BRANCH: Head branch name (required)
|
|
36
|
+
REPO_NAME: Repository name in format owner/repo (required)
|
|
37
|
+
REQUIRE_EVIDENCE: Whether to require PR description evidence showing the code
|
|
38
|
+
works ('true'/'false', default: 'false')
|
|
39
|
+
COLLECT_FEEDBACK: Whether to ask maintainers for thumbs up/down feedback by
|
|
40
|
+
appending a short footer to the main review body ('true'/'false',
|
|
41
|
+
default: 'false')
|
|
42
|
+
REVIEW_RUN_URL: Optional GitHub Actions run URL to include in the feedback
|
|
43
|
+
footer when COLLECT_FEEDBACK is enabled
|
|
44
|
+
USE_SUB_AGENTS: Enable sub-agent delegation for file-level reviews
|
|
45
|
+
('true'/'false', default: 'false'). When enabled, the main agent acts
|
|
46
|
+
as a coordinator that delegates per-file review work to
|
|
47
|
+
file_reviewer sub-agents via the TaskToolSet, then consolidates
|
|
48
|
+
findings into a single GitHub PR review.
|
|
49
|
+
LOAD_PUBLIC_SKILLS: Whether to load the public skills repository
|
|
50
|
+
('true'/'false', default: 'true')
|
|
51
|
+
|
|
52
|
+
For setup instructions, usage examples, and GitHub Actions integration,
|
|
53
|
+
see README.md in this directory.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import json
|
|
59
|
+
import os
|
|
60
|
+
import shlex
|
|
61
|
+
import sys
|
|
62
|
+
import time
|
|
63
|
+
import urllib.error
|
|
64
|
+
import urllib.request
|
|
65
|
+
from collections.abc import Callable
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from typing import Any
|
|
68
|
+
|
|
69
|
+
from lmnr import Laminar
|
|
70
|
+
from openhands.sdk import (
|
|
71
|
+
LLM,
|
|
72
|
+
Agent,
|
|
73
|
+
AgentContext,
|
|
74
|
+
Conversation,
|
|
75
|
+
Tool,
|
|
76
|
+
get_logger,
|
|
77
|
+
register_agent,
|
|
78
|
+
)
|
|
79
|
+
from openhands.sdk.context import Skill
|
|
80
|
+
from openhands.sdk.conversation import get_agent_final_response
|
|
81
|
+
from openhands.sdk.git.utils import run_git_command
|
|
82
|
+
from openhands.sdk.plugin import PluginSource
|
|
83
|
+
from openhands.sdk.skills import load_project_skills
|
|
84
|
+
from openhands.tools.delegate import DelegationVisualizer
|
|
85
|
+
from openhands.tools.preset.default import get_default_condenser, get_default_tools
|
|
86
|
+
from openhands.tools.task import TaskToolSet
|
|
87
|
+
|
|
88
|
+
# Add the script directory to Python path so we can import prompt.py
|
|
89
|
+
script_dir = Path(__file__).parent
|
|
90
|
+
sys.path.insert(0, str(script_dir))
|
|
91
|
+
|
|
92
|
+
from prompt import FILE_REVIEWER_SKILL, format_prompt # noqa: E402
|
|
93
|
+
|
|
94
|
+
logger = get_logger(__name__)
|
|
95
|
+
|
|
96
|
+
# Maximum total size of all patches combined in the prompt
|
|
97
|
+
MAX_TOTAL_DIFF = 100000
|
|
98
|
+
|
|
99
|
+
# Maximum size for a single file's patch body. Prevents a single huge file
|
|
100
|
+
# (e.g. a regenerated lockfile) from starving smaller files' patches.
|
|
101
|
+
MAX_PER_FILE_PATCH = 8000
|
|
102
|
+
|
|
103
|
+
# Maximum size for review context to avoid overwhelming the prompt
|
|
104
|
+
# Keeps context under ~7500 tokens (assuming ~4 chars/token average)
|
|
105
|
+
MAX_REVIEW_CONTEXT = 30000
|
|
106
|
+
|
|
107
|
+
# Maximum time (seconds) for GraphQL pagination to prevent hanging on slow APIs
|
|
108
|
+
MAX_PAGINATION_TIME = 120
|
|
109
|
+
|
|
110
|
+
DEFAULT_ACP_PROMPT_TIMEOUT_SECONDS = 1800.0
|
|
111
|
+
|
|
112
|
+
# GraphQL queries as module-level constants for reusability and testability
|
|
113
|
+
REVIEWS_QUERY = """
|
|
114
|
+
query(
|
|
115
|
+
$owner: String!
|
|
116
|
+
$repo: String!
|
|
117
|
+
$pr_number: Int!
|
|
118
|
+
$count: Int!
|
|
119
|
+
$cursor: String
|
|
120
|
+
) {
|
|
121
|
+
repository(owner: $owner, name: $repo) {
|
|
122
|
+
pullRequest(number: $pr_number) {
|
|
123
|
+
reviews(last: $count, before: $cursor) {
|
|
124
|
+
pageInfo {
|
|
125
|
+
hasPreviousPage
|
|
126
|
+
startCursor
|
|
127
|
+
}
|
|
128
|
+
nodes {
|
|
129
|
+
id
|
|
130
|
+
author { login }
|
|
131
|
+
body
|
|
132
|
+
state
|
|
133
|
+
submittedAt
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
THREADS_QUERY = """
|
|
142
|
+
query($owner: String!, $repo: String!, $pr_number: Int!, $cursor: String) {
|
|
143
|
+
repository(owner: $owner, name: $repo) {
|
|
144
|
+
pullRequest(number: $pr_number) {
|
|
145
|
+
reviewThreads(last: 100, before: $cursor) {
|
|
146
|
+
pageInfo {
|
|
147
|
+
hasPreviousPage
|
|
148
|
+
startCursor
|
|
149
|
+
}
|
|
150
|
+
nodes {
|
|
151
|
+
id
|
|
152
|
+
isResolved
|
|
153
|
+
isOutdated
|
|
154
|
+
path
|
|
155
|
+
line
|
|
156
|
+
comments(first: 50) {
|
|
157
|
+
nodes {
|
|
158
|
+
id
|
|
159
|
+
author { login }
|
|
160
|
+
body
|
|
161
|
+
bodyText
|
|
162
|
+
createdAt
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _get_required_env(name: str) -> str:
|
|
174
|
+
value = os.getenv(name)
|
|
175
|
+
if not value:
|
|
176
|
+
raise ValueError(f"{name} environment variable is required")
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _get_bool_env(name: str, default: bool = False) -> bool:
|
|
181
|
+
value = os.getenv(name)
|
|
182
|
+
if value is None:
|
|
183
|
+
return default
|
|
184
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _call_github_api(
|
|
188
|
+
url: str,
|
|
189
|
+
method: str = "GET",
|
|
190
|
+
data: dict[str, Any] | None = None,
|
|
191
|
+
accept: str = "application/vnd.github+json",
|
|
192
|
+
) -> Any:
|
|
193
|
+
"""Make a GitHub API request (REST or GraphQL).
|
|
194
|
+
|
|
195
|
+
This function handles both REST API calls and GraphQL queries
|
|
196
|
+
(via the /graphql endpoint). The function name reflects this dual purpose.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
url: Full API URL or path (will be prefixed with api.github.com if needed)
|
|
200
|
+
method: HTTP method (GET, POST, etc.)
|
|
201
|
+
data: JSON data to send (for POST/PUT requests, including GraphQL queries)
|
|
202
|
+
accept: Accept header value
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Parsed JSON response or raw text for diff requests
|
|
206
|
+
"""
|
|
207
|
+
token = _get_required_env("GITHUB_TOKEN")
|
|
208
|
+
if not url.startswith("http"):
|
|
209
|
+
url = f"https://api.github.com{url}"
|
|
210
|
+
|
|
211
|
+
request = urllib.request.Request(url, method=method)
|
|
212
|
+
request.add_header("Accept", accept)
|
|
213
|
+
request.add_header("Authorization", f"Bearer {token}")
|
|
214
|
+
request.add_header("X-GitHub-Api-Version", "2022-11-28")
|
|
215
|
+
|
|
216
|
+
if data:
|
|
217
|
+
request.add_header("Content-Type", "application/json")
|
|
218
|
+
request.data = json.dumps(data).encode("utf-8")
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
222
|
+
raw_data = response.read()
|
|
223
|
+
if "diff" in accept:
|
|
224
|
+
return raw_data.decode("utf-8", errors="replace")
|
|
225
|
+
return json.loads(raw_data.decode("utf-8"))
|
|
226
|
+
except urllib.error.HTTPError as e:
|
|
227
|
+
details = (e.read() or b"").decode("utf-8", errors="replace").strip()
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
f"GitHub API request failed: HTTP {e.code} {e.reason}. {details}"
|
|
230
|
+
) from e
|
|
231
|
+
except urllib.error.URLError as e:
|
|
232
|
+
raise RuntimeError(f"GitHub API request failed: {e.reason}") from e
|
|
233
|
+
except json.JSONDecodeError as e:
|
|
234
|
+
raise RuntimeError(f"GitHub API returned invalid JSON: {e}") from e
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _paginate_graphql(
|
|
238
|
+
query: str,
|
|
239
|
+
variables: dict[str, Any],
|
|
240
|
+
path_to_nodes: list[str],
|
|
241
|
+
max_items: int | None = None,
|
|
242
|
+
item_name: str = "items",
|
|
243
|
+
) -> list[dict[str, Any]]:
|
|
244
|
+
"""Generic GraphQL pagination with timeout.
|
|
245
|
+
|
|
246
|
+
Handles cursor-based pagination for GitHub GraphQL queries using `last`/`before`.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
query: GraphQL query string with $cursor variable
|
|
250
|
+
variables: Base variables for the query (will be updated with cursor)
|
|
251
|
+
path_to_nodes: Path to the nodes in the response, e.g.
|
|
252
|
+
["pullRequest", "reviews"] to access
|
|
253
|
+
data.repository.pullRequest.reviews
|
|
254
|
+
max_items: Maximum number of items to fetch (None for unlimited)
|
|
255
|
+
item_name: Name for logging purposes
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
List of all nodes fetched, in reverse order (oldest first)
|
|
259
|
+
"""
|
|
260
|
+
all_items: list[dict[str, Any]] = []
|
|
261
|
+
cursor = None
|
|
262
|
+
start_time = time.time()
|
|
263
|
+
page_count = 0
|
|
264
|
+
has_more_pages = False
|
|
265
|
+
|
|
266
|
+
while max_items is None or len(all_items) < max_items:
|
|
267
|
+
elapsed = time.time() - start_time
|
|
268
|
+
if elapsed > MAX_PAGINATION_TIME:
|
|
269
|
+
logger.warning(
|
|
270
|
+
f"{item_name} pagination timeout after {elapsed:.1f}s, "
|
|
271
|
+
f"fetched {len(all_items)} {item_name} across {page_count} pages"
|
|
272
|
+
)
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
# Update cursor for pagination
|
|
276
|
+
vars_with_cursor = {**variables, "cursor": cursor}
|
|
277
|
+
|
|
278
|
+
# Adjust count if max_items is set
|
|
279
|
+
if max_items is not None and "count" in vars_with_cursor:
|
|
280
|
+
remaining = max_items - len(all_items)
|
|
281
|
+
vars_with_cursor["count"] = min(remaining, vars_with_cursor["count"])
|
|
282
|
+
|
|
283
|
+
result = _call_github_api(
|
|
284
|
+
"https://api.github.com/graphql",
|
|
285
|
+
method="POST",
|
|
286
|
+
data={"query": query, "variables": vars_with_cursor},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if "errors" in result:
|
|
290
|
+
logger.warning(f"GraphQL errors fetching {item_name}: {result['errors']}")
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
# Navigate to the data using path
|
|
294
|
+
data = result.get("data", {}).get("repository", {})
|
|
295
|
+
for key in path_to_nodes:
|
|
296
|
+
data = data.get(key, {}) if data else {}
|
|
297
|
+
|
|
298
|
+
if not data:
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
nodes = data.get("nodes", [])
|
|
302
|
+
page_count += 1
|
|
303
|
+
|
|
304
|
+
if not nodes:
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
all_items.extend(nodes)
|
|
308
|
+
|
|
309
|
+
logger.debug(
|
|
310
|
+
f"Fetched page {page_count} with {len(nodes)} {item_name} "
|
|
311
|
+
f"(total: {len(all_items)})"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
page_info = data.get("pageInfo", {})
|
|
315
|
+
has_more_pages = page_info.get("hasPreviousPage", False)
|
|
316
|
+
if not has_more_pages:
|
|
317
|
+
break
|
|
318
|
+
cursor = page_info.get("startCursor")
|
|
319
|
+
|
|
320
|
+
if has_more_pages and max_items is None:
|
|
321
|
+
logger.warning(
|
|
322
|
+
f"{item_name} limited to {len(all_items)} items. "
|
|
323
|
+
"Some items may be omitted for PRs with extensive history."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Items are fetched newest-first with `last`, reverse for chronological order
|
|
327
|
+
return list(reversed(all_items))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def get_pr_reviews(pr_number: str, max_reviews: int = 100) -> list[dict[str, Any]]:
|
|
331
|
+
"""Fetch the latest reviews for a PR using GraphQL.
|
|
332
|
+
|
|
333
|
+
Uses GraphQL with `last` to fetch the most recent reviews directly,
|
|
334
|
+
avoiding the need to paginate through all reviews from oldest to newest.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
pr_number: The PR number
|
|
338
|
+
max_reviews: Maximum number of reviews to return (default: 100)
|
|
339
|
+
|
|
340
|
+
Returns a list of review objects containing:
|
|
341
|
+
- id: Review ID
|
|
342
|
+
- user: Author information
|
|
343
|
+
- body: Review body text
|
|
344
|
+
- state: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING
|
|
345
|
+
- submitted_at: When the review was submitted
|
|
346
|
+
"""
|
|
347
|
+
repo = _get_required_env("REPO_NAME")
|
|
348
|
+
owner, repo_name = repo.split("/")
|
|
349
|
+
|
|
350
|
+
variables = {
|
|
351
|
+
"owner": owner,
|
|
352
|
+
"repo": repo_name,
|
|
353
|
+
"pr_number": int(pr_number),
|
|
354
|
+
"count": 100, # GraphQL max per request
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
nodes = _paginate_graphql(
|
|
358
|
+
query=REVIEWS_QUERY,
|
|
359
|
+
variables=variables,
|
|
360
|
+
path_to_nodes=["pullRequest", "reviews"],
|
|
361
|
+
max_items=max_reviews,
|
|
362
|
+
item_name="reviews",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Convert GraphQL format to REST-like format for compatibility
|
|
366
|
+
reviews = []
|
|
367
|
+
for node in nodes:
|
|
368
|
+
author = node.get("author") or {}
|
|
369
|
+
reviews.append(
|
|
370
|
+
{
|
|
371
|
+
"id": node.get("id"),
|
|
372
|
+
"user": {"login": author.get("login", "unknown")},
|
|
373
|
+
"body": node.get("body", ""),
|
|
374
|
+
"state": node.get("state", "UNKNOWN"),
|
|
375
|
+
"submitted_at": node.get("submittedAt"),
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return reviews
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def get_review_threads_graphql(pr_number: str) -> list[dict[str, Any]]:
|
|
383
|
+
"""Fetch the latest review threads with resolution status using GraphQL API.
|
|
384
|
+
|
|
385
|
+
The REST API doesn't expose thread resolution status, so we use GraphQL.
|
|
386
|
+
Uses `last` to fetch the most recent threads first, ensuring we get the
|
|
387
|
+
latest discussions rather than the oldest ones.
|
|
388
|
+
|
|
389
|
+
Note: This query fetches up to 100 review threads per page, each with
|
|
390
|
+
up to 50 comments. For PRs exceeding these limits, older threads/comments
|
|
391
|
+
may be omitted. We paginate through threads but not through comments
|
|
392
|
+
within threads.
|
|
393
|
+
|
|
394
|
+
Returns a list of thread objects containing:
|
|
395
|
+
- id: Thread ID
|
|
396
|
+
- isResolved: Whether the thread is resolved
|
|
397
|
+
- isOutdated: Whether the thread is outdated (code changed)
|
|
398
|
+
- path: File path
|
|
399
|
+
- line: Line number
|
|
400
|
+
- comments: List of comments in the thread (up to 50 per thread)
|
|
401
|
+
"""
|
|
402
|
+
repo = _get_required_env("REPO_NAME")
|
|
403
|
+
owner, repo_name = repo.split("/")
|
|
404
|
+
|
|
405
|
+
variables = {
|
|
406
|
+
"owner": owner,
|
|
407
|
+
"repo": repo_name,
|
|
408
|
+
"pr_number": int(pr_number),
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return _paginate_graphql(
|
|
412
|
+
query=THREADS_QUERY,
|
|
413
|
+
variables=variables,
|
|
414
|
+
path_to_nodes=["pullRequest", "reviewThreads"],
|
|
415
|
+
item_name="review threads",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def format_review_context(
|
|
420
|
+
reviews: list[dict[str, Any]],
|
|
421
|
+
threads: list[dict[str, Any]],
|
|
422
|
+
max_size: int = MAX_REVIEW_CONTEXT,
|
|
423
|
+
) -> str:
|
|
424
|
+
"""Format review history into a context string for the agent.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
reviews: List of review objects from get_pr_reviews()
|
|
428
|
+
threads: List of thread objects from get_review_threads_graphql()
|
|
429
|
+
max_size: Maximum size of the formatted context
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Formatted markdown string with review history
|
|
433
|
+
"""
|
|
434
|
+
if not reviews and not threads:
|
|
435
|
+
return ""
|
|
436
|
+
|
|
437
|
+
sections: list[str] = []
|
|
438
|
+
current_size = 0
|
|
439
|
+
|
|
440
|
+
def _add_section(section: str) -> bool:
|
|
441
|
+
"""Add a section if it fits within max_size. Returns True if added."""
|
|
442
|
+
nonlocal current_size
|
|
443
|
+
section_size = len(section) + 1 # +1 for newline separator
|
|
444
|
+
if current_size + section_size > max_size:
|
|
445
|
+
return False
|
|
446
|
+
sections.append(section)
|
|
447
|
+
current_size += section_size
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
# Format reviews (high-level review decisions)
|
|
451
|
+
if reviews:
|
|
452
|
+
review_lines: list[str] = ["### Previous Reviews\n"]
|
|
453
|
+
for review in reviews:
|
|
454
|
+
user_data = review.get("user") or {}
|
|
455
|
+
user = user_data.get("login", "unknown")
|
|
456
|
+
state = review.get("state") or "UNKNOWN"
|
|
457
|
+
body = (review.get("body") or "").strip()
|
|
458
|
+
|
|
459
|
+
# Map state to emoji for visual clarity
|
|
460
|
+
state_emoji = {
|
|
461
|
+
"APPROVED": "✅",
|
|
462
|
+
"CHANGES_REQUESTED": "🔴",
|
|
463
|
+
"COMMENTED": "💬",
|
|
464
|
+
"DISMISSED": "❌",
|
|
465
|
+
"PENDING": "⏳",
|
|
466
|
+
}.get(state, "❓")
|
|
467
|
+
|
|
468
|
+
review_lines.append(f"- {state_emoji} **{user}** ({state})")
|
|
469
|
+
if body:
|
|
470
|
+
# Indent the body and truncate if too long
|
|
471
|
+
body_preview = body[:500] + "..." if len(body) > 500 else body
|
|
472
|
+
indented = "\n".join(f" > {line}" for line in body_preview.split("\n"))
|
|
473
|
+
review_lines.append(indented)
|
|
474
|
+
review_lines.append("")
|
|
475
|
+
|
|
476
|
+
review_section = "\n".join(review_lines)
|
|
477
|
+
if not _add_section(review_section):
|
|
478
|
+
# Even reviews section doesn't fit, return truncation message
|
|
479
|
+
return (
|
|
480
|
+
f"... [review context truncated, "
|
|
481
|
+
f"content exceeds {max_size:,} chars] ..."
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Format review threads with resolution status
|
|
485
|
+
if threads:
|
|
486
|
+
resolved_threads = [t for t in threads if t.get("isResolved")]
|
|
487
|
+
unresolved_threads = [t for t in threads if not t.get("isResolved")]
|
|
488
|
+
|
|
489
|
+
# Unresolved threads (higher priority)
|
|
490
|
+
if unresolved_threads:
|
|
491
|
+
header = (
|
|
492
|
+
"### Unresolved Review Threads\n\n"
|
|
493
|
+
"*These threads have not been resolved and may need attention:*\n"
|
|
494
|
+
)
|
|
495
|
+
if not _add_section(header):
|
|
496
|
+
count = len(unresolved_threads)
|
|
497
|
+
sections.append(
|
|
498
|
+
f"\n... [truncated, {count} unresolved threads omitted] ..."
|
|
499
|
+
)
|
|
500
|
+
else:
|
|
501
|
+
threads_added = 0
|
|
502
|
+
for thread in unresolved_threads:
|
|
503
|
+
thread_lines = _format_thread(thread)
|
|
504
|
+
thread_section = "\n".join(thread_lines)
|
|
505
|
+
if not _add_section(thread_section):
|
|
506
|
+
remaining = len(unresolved_threads) - threads_added
|
|
507
|
+
sections.append(
|
|
508
|
+
f"\n... [truncated, {remaining} unresolved "
|
|
509
|
+
"threads omitted] ..."
|
|
510
|
+
)
|
|
511
|
+
break
|
|
512
|
+
threads_added += 1
|
|
513
|
+
|
|
514
|
+
# Resolved threads (lower priority, add if space remains)
|
|
515
|
+
if resolved_threads and current_size < max_size:
|
|
516
|
+
header = (
|
|
517
|
+
"### Resolved Review Threads\n\n"
|
|
518
|
+
"*These threads have been resolved but provide context:*\n"
|
|
519
|
+
)
|
|
520
|
+
if _add_section(header):
|
|
521
|
+
threads_added = 0
|
|
522
|
+
for thread in resolved_threads:
|
|
523
|
+
thread_lines = _format_thread(thread)
|
|
524
|
+
thread_section = "\n".join(thread_lines)
|
|
525
|
+
if not _add_section(thread_section):
|
|
526
|
+
remaining = len(resolved_threads) - threads_added
|
|
527
|
+
sections.append(
|
|
528
|
+
f"\n... [truncated, {remaining} resolved "
|
|
529
|
+
"threads omitted] ..."
|
|
530
|
+
)
|
|
531
|
+
break
|
|
532
|
+
threads_added += 1
|
|
533
|
+
|
|
534
|
+
return "\n".join(sections)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _is_empty_suggestion_block(body: str) -> bool:
|
|
538
|
+
"""Return True when a suggestion fence contains no visible replacement text."""
|
|
539
|
+
lines = body.splitlines()
|
|
540
|
+
return (
|
|
541
|
+
len(lines) >= 2
|
|
542
|
+
and lines[0].strip() == "```suggestion"
|
|
543
|
+
and lines[-1].strip() == "```"
|
|
544
|
+
and all(not line.strip() for line in lines[1:-1])
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _normalize_review_comment_text(text: str) -> str:
|
|
549
|
+
"""Normalize GitHub review comment text for prompt readability."""
|
|
550
|
+
normalized_lines = [line.rstrip() for line in text.splitlines()]
|
|
551
|
+
cleaned_lines: list[str] = []
|
|
552
|
+
previous_blank = False
|
|
553
|
+
|
|
554
|
+
for line in normalized_lines:
|
|
555
|
+
is_blank = not line.strip()
|
|
556
|
+
if is_blank:
|
|
557
|
+
if previous_blank:
|
|
558
|
+
continue
|
|
559
|
+
cleaned_lines.append("")
|
|
560
|
+
else:
|
|
561
|
+
cleaned_lines.append(line)
|
|
562
|
+
previous_blank = is_blank
|
|
563
|
+
|
|
564
|
+
return "\n".join(cleaned_lines).strip()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _get_review_comment_body(comment: dict[str, Any]) -> str:
|
|
568
|
+
"""Get the best available comment text for review context.
|
|
569
|
+
|
|
570
|
+
GitHub stores deletion-only suggestions as an empty ```suggestion``` block in
|
|
571
|
+
`body`, but exposes the rendered suggestion content in `bodyText`/`bodyHTML`.
|
|
572
|
+
Prefer the original markdown when it contains real text, and fall back to the
|
|
573
|
+
normalized plain-text rendering when the raw body would look empty to the agent.
|
|
574
|
+
"""
|
|
575
|
+
body = _normalize_review_comment_text(comment.get("body") or "")
|
|
576
|
+
body_text = _normalize_review_comment_text(comment.get("bodyText") or "")
|
|
577
|
+
|
|
578
|
+
if not body or _is_empty_suggestion_block(body):
|
|
579
|
+
return body_text or body
|
|
580
|
+
|
|
581
|
+
return body
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _format_thread(thread: dict[str, Any]) -> list[str]:
|
|
585
|
+
"""Format a single review thread.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
thread: Thread object from GraphQL
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
List of formatted lines
|
|
592
|
+
"""
|
|
593
|
+
lines: list[str] = []
|
|
594
|
+
|
|
595
|
+
path = thread.get("path", "unknown")
|
|
596
|
+
line_num = thread.get("line")
|
|
597
|
+
is_outdated = thread.get("isOutdated", False)
|
|
598
|
+
is_resolved = thread.get("isResolved", False)
|
|
599
|
+
|
|
600
|
+
# Thread header
|
|
601
|
+
status = "✅ RESOLVED" if is_resolved else "⚠️ UNRESOLVED"
|
|
602
|
+
outdated = " (outdated)" if is_outdated else ""
|
|
603
|
+
location = f"{path}"
|
|
604
|
+
if line_num:
|
|
605
|
+
location += f":{line_num}"
|
|
606
|
+
|
|
607
|
+
lines.append(f"**{location}**{outdated} - {status}")
|
|
608
|
+
|
|
609
|
+
# Thread comments
|
|
610
|
+
comments_data = thread.get("comments") or {}
|
|
611
|
+
comments = comments_data.get("nodes") or []
|
|
612
|
+
|
|
613
|
+
for comment in comments:
|
|
614
|
+
author_data = comment.get("author") or {}
|
|
615
|
+
author = author_data.get("login", "unknown")
|
|
616
|
+
body = _get_review_comment_body(comment)
|
|
617
|
+
|
|
618
|
+
if body:
|
|
619
|
+
# Truncate individual comments if too long
|
|
620
|
+
body_preview = body[:300] + "..." if len(body) > 300 else body
|
|
621
|
+
indented = "\n".join(f" > {line}" for line in body_preview.split("\n"))
|
|
622
|
+
lines.append(f" - **{author}**:")
|
|
623
|
+
lines.append(indented)
|
|
624
|
+
|
|
625
|
+
lines.append("")
|
|
626
|
+
return lines
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _fetch_with_fallback(
|
|
630
|
+
name: str, fetch_fn: Callable[[], list[dict[str, Any]]]
|
|
631
|
+
) -> list[dict[str, Any]]:
|
|
632
|
+
"""Fetch data with error handling and logging.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
name: Name of the data being fetched (for logging)
|
|
636
|
+
fetch_fn: Function to call to fetch the data
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
Fetched data or empty list on error
|
|
640
|
+
"""
|
|
641
|
+
try:
|
|
642
|
+
data = fetch_fn()
|
|
643
|
+
logger.info(f"Fetched {len(data)} {name}")
|
|
644
|
+
return data
|
|
645
|
+
except Exception as e:
|
|
646
|
+
logger.warning(f"Failed to fetch {name}: {e}")
|
|
647
|
+
return []
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def get_pr_review_context(pr_number: str) -> str:
|
|
651
|
+
"""Get all review context for a PR.
|
|
652
|
+
|
|
653
|
+
Fetches reviews and review threads, then formats them into a context string.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
pr_number: The PR number
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
Formatted review context string, or empty string if no context
|
|
660
|
+
"""
|
|
661
|
+
reviews = _fetch_with_fallback("reviews", lambda: get_pr_reviews(pr_number))
|
|
662
|
+
threads = _fetch_with_fallback(
|
|
663
|
+
"review threads", lambda: get_review_threads_graphql(pr_number)
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
return format_review_context(reviews, threads)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def get_pr_files(pr_number: str) -> list[dict[str, Any]]:
|
|
670
|
+
"""Fetch every file in the PR via the `/pulls/{n}/files` REST endpoint.
|
|
671
|
+
|
|
672
|
+
Returns structured per-file metadata (filename, status, +/- counts) plus
|
|
673
|
+
each file's `patch` text. Paginates with `per_page=100` until the page
|
|
674
|
+
is short or empty. GitHub caps the response at 3000 files; review of
|
|
675
|
+
larger PRs is out of scope.
|
|
676
|
+
"""
|
|
677
|
+
repo = _get_required_env("REPO_NAME")
|
|
678
|
+
files: list[dict[str, Any]] = []
|
|
679
|
+
page = 1
|
|
680
|
+
while True:
|
|
681
|
+
url = (
|
|
682
|
+
f"/repos/{repo}/pulls/{pr_number}/files"
|
|
683
|
+
f"?per_page=100&page={page}"
|
|
684
|
+
)
|
|
685
|
+
page_files = _call_github_api(url)
|
|
686
|
+
if not isinstance(page_files, list) or not page_files:
|
|
687
|
+
break
|
|
688
|
+
files.extend(page_files)
|
|
689
|
+
if len(page_files) < 100:
|
|
690
|
+
break
|
|
691
|
+
page += 1
|
|
692
|
+
return files
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _format_file_stats(file: dict[str, Any]) -> str:
|
|
696
|
+
"""Format adds/deletes for a single file: `+12/-3`, `+24`, `-7`, or ``."""
|
|
697
|
+
additions = file.get("additions", 0) or 0
|
|
698
|
+
deletions = file.get("deletions", 0) or 0
|
|
699
|
+
if additions and deletions:
|
|
700
|
+
return f"+{additions}/-{deletions}"
|
|
701
|
+
if additions:
|
|
702
|
+
return f"+{additions}"
|
|
703
|
+
if deletions:
|
|
704
|
+
return f"-{deletions}"
|
|
705
|
+
return ""
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _format_file_status(file: dict[str, Any]) -> str:
|
|
709
|
+
"""Map GitHub's status field to a short bracketed tag."""
|
|
710
|
+
status = (file.get("status") or "").lower()
|
|
711
|
+
if status == "renamed":
|
|
712
|
+
previous = file.get("previous_filename") or "?"
|
|
713
|
+
return f"[renamed from {previous}]"
|
|
714
|
+
if status in {"added", "modified", "removed", "copied", "changed"}:
|
|
715
|
+
return f"[{status}]"
|
|
716
|
+
return f"[{status}]" if status else ""
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def format_files_manifest(files: list[dict[str, Any]]) -> str:
|
|
720
|
+
"""Build the 'Files Changed' manifest shown before the patch block.
|
|
721
|
+
|
|
722
|
+
Invariant: every file in `files` appears exactly once in the output,
|
|
723
|
+
regardless of patch size or budget. The patch block may abbreviate or
|
|
724
|
+
omit individual patches; the manifest never does.
|
|
725
|
+
"""
|
|
726
|
+
if not files:
|
|
727
|
+
return "## Files Changed\n\n_(no files reported by GitHub)_\n"
|
|
728
|
+
|
|
729
|
+
total_additions = sum((f.get("additions") or 0) for f in files)
|
|
730
|
+
total_deletions = sum((f.get("deletions") or 0) for f in files)
|
|
731
|
+
header = (
|
|
732
|
+
f"## Files Changed ({len(files)} files, "
|
|
733
|
+
f"+{total_additions} / -{total_deletions})\n\n"
|
|
734
|
+
"All files in the PR are listed here. If a file's patch is missing "
|
|
735
|
+
"or abbreviated in the Patches section below, read the file from the "
|
|
736
|
+
"workspace (it is checked out) rather than treating it as absent.\n"
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
lines = [header]
|
|
740
|
+
for file in files:
|
|
741
|
+
path = file.get("filename", "?")
|
|
742
|
+
status = _format_file_status(file)
|
|
743
|
+
stats = _format_file_stats(file)
|
|
744
|
+
suffix_parts: list[str] = []
|
|
745
|
+
if not file.get("patch") and (
|
|
746
|
+
file.get("additions") or file.get("deletions")
|
|
747
|
+
):
|
|
748
|
+
suffix_parts.append("(binary or unavailable, no patch)")
|
|
749
|
+
bits = [f"- `{path}`", status, stats, *suffix_parts]
|
|
750
|
+
lines.append(" ".join(b for b in bits if b))
|
|
751
|
+
|
|
752
|
+
return "\n".join(lines) + "\n"
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _abbreviate_patch(patch: str, limit: int) -> tuple[str, bool]:
|
|
756
|
+
"""Truncate a single file's patch to `limit` chars on a line boundary.
|
|
757
|
+
|
|
758
|
+
Returns `(text, truncated)`. The truncated text ends on a complete line
|
|
759
|
+
so the closing `[patch abbreviated]` marker isn't dangling mid-line.
|
|
760
|
+
"""
|
|
761
|
+
if len(patch) <= limit:
|
|
762
|
+
return patch, False
|
|
763
|
+
cut = patch.rfind("\n", 0, limit)
|
|
764
|
+
if cut <= 0:
|
|
765
|
+
cut = limit
|
|
766
|
+
return patch[:cut], True
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def format_patches(
|
|
770
|
+
files: list[dict[str, Any]],
|
|
771
|
+
max_total: int = MAX_TOTAL_DIFF,
|
|
772
|
+
max_per_file: int = MAX_PER_FILE_PATCH,
|
|
773
|
+
) -> str:
|
|
774
|
+
"""Assemble per-file patches into a single diff block within budget.
|
|
775
|
+
|
|
776
|
+
Every file gets at least its diff header. Patches are taken from each
|
|
777
|
+
file's `patch` field (the same text the raw-diff endpoint returns) and
|
|
778
|
+
abbreviated to `max_per_file` chars; if the running total would exceed
|
|
779
|
+
`max_total`, later files get a header-only stub. Each abbreviation is
|
|
780
|
+
annotated inline so the agent can tell "I was given a short patch" from
|
|
781
|
+
"no patch was given" — both are visible.
|
|
782
|
+
"""
|
|
783
|
+
sections: list[str] = []
|
|
784
|
+
total = 0
|
|
785
|
+
for file in files:
|
|
786
|
+
path = file.get("filename", "?")
|
|
787
|
+
previous = file.get("previous_filename")
|
|
788
|
+
status = (file.get("status") or "").lower()
|
|
789
|
+
header_lines = [f"diff --git a/{previous or path} b/{path}"]
|
|
790
|
+
if status == "renamed":
|
|
791
|
+
header_lines.append(f"rename from {previous}")
|
|
792
|
+
header_lines.append(f"rename to {path}")
|
|
793
|
+
if status == "added":
|
|
794
|
+
header_lines.append("new file")
|
|
795
|
+
elif status == "removed":
|
|
796
|
+
header_lines.append("deleted file")
|
|
797
|
+
header = "\n".join(header_lines) + "\n"
|
|
798
|
+
|
|
799
|
+
patch = file.get("patch") or ""
|
|
800
|
+
remaining_budget = max_total - total
|
|
801
|
+
if remaining_budget <= len(header):
|
|
802
|
+
sections.append(
|
|
803
|
+
header
|
|
804
|
+
+ f"[patch omitted: total budget of {max_total:,} chars reached; "
|
|
805
|
+
f"read `{path}` from the workspace to inspect]\n"
|
|
806
|
+
)
|
|
807
|
+
total += len(sections[-1])
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
if not patch:
|
|
811
|
+
note = (
|
|
812
|
+
"[no patch available — likely a binary file, rename without "
|
|
813
|
+
"content change, or otherwise unrepresentable as text]\n"
|
|
814
|
+
if status not in {"added", "removed"}
|
|
815
|
+
or (file.get("additions") or file.get("deletions"))
|
|
816
|
+
else ""
|
|
817
|
+
)
|
|
818
|
+
sections.append(header + note)
|
|
819
|
+
total += len(sections[-1])
|
|
820
|
+
continue
|
|
821
|
+
|
|
822
|
+
per_file_cap = min(max_per_file, remaining_budget - len(header))
|
|
823
|
+
truncated_patch, was_truncated = _abbreviate_patch(patch, per_file_cap)
|
|
824
|
+
section = header + truncated_patch
|
|
825
|
+
if not section.endswith("\n"):
|
|
826
|
+
section += "\n"
|
|
827
|
+
if was_truncated:
|
|
828
|
+
section += (
|
|
829
|
+
f"[patch abbreviated: {len(patch):,} chars total, showing first "
|
|
830
|
+
f"{len(truncated_patch):,}; read `{path}` from the workspace "
|
|
831
|
+
"to inspect the rest]\n"
|
|
832
|
+
)
|
|
833
|
+
sections.append(section)
|
|
834
|
+
total += len(section)
|
|
835
|
+
|
|
836
|
+
return "\n".join(sections)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def get_pr_diff_payload(pr_number: str) -> tuple[str, str]:
|
|
840
|
+
"""Fetch PR files and produce `(manifest, patches)` for the prompt.
|
|
841
|
+
|
|
842
|
+
The manifest is rendered as markdown above the patch fence so the agent
|
|
843
|
+
always sees the complete file list, even when individual patches are
|
|
844
|
+
abbreviated. The patches string is what goes inside the ```diff fence.
|
|
845
|
+
"""
|
|
846
|
+
files = get_pr_files(pr_number)
|
|
847
|
+
logger.info(f"Fetched {len(files)} files for PR #{pr_number}")
|
|
848
|
+
manifest = format_files_manifest(files)
|
|
849
|
+
patches = format_patches(files)
|
|
850
|
+
return manifest, patches
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def get_head_commit_sha(repo_dir: Path | None = None) -> str:
|
|
854
|
+
"""
|
|
855
|
+
Get the SHA of the HEAD commit.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
repo_dir: Path to the repository (defaults to cwd)
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
The commit SHA
|
|
862
|
+
"""
|
|
863
|
+
if repo_dir is None:
|
|
864
|
+
repo_dir = Path.cwd()
|
|
865
|
+
return run_git_command(["git", "rev-parse", "HEAD"], repo_dir).strip()
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def validate_environment() -> dict[str, Any]:
|
|
869
|
+
"""Validate required environment variables and return config.
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
Dictionary with validated environment variables
|
|
873
|
+
|
|
874
|
+
Raises:
|
|
875
|
+
SystemExit if required variables are missing
|
|
876
|
+
"""
|
|
877
|
+
required_vars = [
|
|
878
|
+
"GITHUB_TOKEN",
|
|
879
|
+
"PR_NUMBER",
|
|
880
|
+
"PR_TITLE",
|
|
881
|
+
"PR_BASE_BRANCH",
|
|
882
|
+
"PR_HEAD_BRANCH",
|
|
883
|
+
"REPO_NAME",
|
|
884
|
+
]
|
|
885
|
+
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
|
886
|
+
if missing_vars:
|
|
887
|
+
logger.error(f"Missing required environment variables: {missing_vars}")
|
|
888
|
+
sys.exit(1)
|
|
889
|
+
|
|
890
|
+
agent_kind = os.getenv("AGENT_KIND", "openhands")
|
|
891
|
+
if agent_kind not in ("openhands", "acp"):
|
|
892
|
+
logger.error("AGENT_KIND must be 'openhands' or 'acp'")
|
|
893
|
+
sys.exit(1)
|
|
894
|
+
|
|
895
|
+
api_key = os.getenv("LLM_API_KEY")
|
|
896
|
+
if agent_kind == "openhands" and not api_key:
|
|
897
|
+
logger.error(
|
|
898
|
+
"LLM_API_KEY is required when AGENT_KIND is 'openhands'"
|
|
899
|
+
)
|
|
900
|
+
sys.exit(1)
|
|
901
|
+
|
|
902
|
+
use_sub_agents = _get_bool_env("USE_SUB_AGENTS")
|
|
903
|
+
if agent_kind == "acp" and use_sub_agents:
|
|
904
|
+
logger.info(
|
|
905
|
+
"Sub-agent delegation is disabled in ACP mode because delegation "
|
|
906
|
+
"depends on OpenHands agent runtime details such as TaskToolSet, "
|
|
907
|
+
"agent registration, and tool routing that ACP servers do not "
|
|
908
|
+
"expose consistently."
|
|
909
|
+
)
|
|
910
|
+
use_sub_agents = False
|
|
911
|
+
|
|
912
|
+
try:
|
|
913
|
+
acp_prompt_timeout = float(
|
|
914
|
+
os.getenv("ACP_PROMPT_TIMEOUT", str(DEFAULT_ACP_PROMPT_TIMEOUT_SECONDS))
|
|
915
|
+
)
|
|
916
|
+
except ValueError:
|
|
917
|
+
logger.error("ACP_PROMPT_TIMEOUT must be a number")
|
|
918
|
+
sys.exit(1)
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
"agent_kind": agent_kind,
|
|
922
|
+
"acp_command": os.getenv("ACP_COMMAND", ""),
|
|
923
|
+
"acp_prompt_timeout": acp_prompt_timeout,
|
|
924
|
+
"api_key": api_key,
|
|
925
|
+
"github_token": os.getenv("GITHUB_TOKEN"),
|
|
926
|
+
"model": os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
|
|
927
|
+
"base_url": os.getenv("LLM_BASE_URL"),
|
|
928
|
+
"require_evidence": _get_bool_env("REQUIRE_EVIDENCE"),
|
|
929
|
+
"collect_feedback": _get_bool_env("COLLECT_FEEDBACK"),
|
|
930
|
+
"review_run_url": os.getenv("REVIEW_RUN_URL", ""),
|
|
931
|
+
"use_sub_agents": use_sub_agents,
|
|
932
|
+
"load_public_skills": _get_bool_env("LOAD_PUBLIC_SKILLS", default=True),
|
|
933
|
+
"pr_info": {
|
|
934
|
+
"number": os.getenv("PR_NUMBER"),
|
|
935
|
+
"title": os.getenv("PR_TITLE"),
|
|
936
|
+
"body": os.getenv("PR_BODY", ""),
|
|
937
|
+
"repo_name": os.getenv("REPO_NAME"),
|
|
938
|
+
"base_branch": os.getenv("PR_BASE_BRANCH"),
|
|
939
|
+
"head_branch": os.getenv("PR_HEAD_BRANCH"),
|
|
940
|
+
},
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def fetch_pr_context(pr_number: str) -> tuple[str, str, str, str]:
|
|
945
|
+
"""Fetch PR manifest, patches, commit SHA, and review context.
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
Tuple of (manifest, patches, commit_id, review_context).
|
|
949
|
+
`manifest` is the markdown 'Files Changed' block; `patches` is the
|
|
950
|
+
per-file diff text to render inside the ```diff fence.
|
|
951
|
+
"""
|
|
952
|
+
manifest, patches = get_pr_diff_payload(pr_number)
|
|
953
|
+
logger.info(
|
|
954
|
+
f"Got PR diff: manifest {len(manifest)} chars, "
|
|
955
|
+
f"patches {len(patches)} chars"
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
commit_id = get_head_commit_sha()
|
|
959
|
+
logger.info(f"HEAD commit SHA: {commit_id}")
|
|
960
|
+
|
|
961
|
+
review_context = get_pr_review_context(pr_number)
|
|
962
|
+
if review_context:
|
|
963
|
+
logger.info(f"Got review context with {len(review_context)} characters")
|
|
964
|
+
else:
|
|
965
|
+
logger.info("No previous review context found")
|
|
966
|
+
|
|
967
|
+
return manifest, patches, commit_id, review_context
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _create_file_reviewer_agent(llm: LLM) -> Agent:
|
|
971
|
+
"""Factory for file_reviewer sub-agents used during delegation.
|
|
972
|
+
|
|
973
|
+
Each sub-agent receives a skill that defines its review persona and
|
|
974
|
+
expected output format. It has read-only terminal and file_editor
|
|
975
|
+
access so it can inspect surrounding code context in the PR repo,
|
|
976
|
+
but the coordinator handles all GitHub API interaction.
|
|
977
|
+
"""
|
|
978
|
+
skills = [
|
|
979
|
+
Skill(
|
|
980
|
+
name="file_review_instructions",
|
|
981
|
+
content=FILE_REVIEWER_SKILL,
|
|
982
|
+
trigger=None,
|
|
983
|
+
),
|
|
984
|
+
]
|
|
985
|
+
return Agent(
|
|
986
|
+
llm=llm,
|
|
987
|
+
tools=[
|
|
988
|
+
Tool(name="terminal"),
|
|
989
|
+
Tool(name="file_editor"),
|
|
990
|
+
],
|
|
991
|
+
agent_context=AgentContext(skills=skills),
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _register_sub_agents() -> None:
|
|
996
|
+
"""Register the file_reviewer agent type.
|
|
997
|
+
|
|
998
|
+
TaskToolSet auto-registers on import, so no explicit
|
|
999
|
+
``register_tool()`` call is needed.
|
|
1000
|
+
"""
|
|
1001
|
+
register_agent(
|
|
1002
|
+
name="file_reviewer",
|
|
1003
|
+
factory_func=_create_file_reviewer_agent,
|
|
1004
|
+
description=(
|
|
1005
|
+
"Reviews one or more files from a PR diff and returns structured "
|
|
1006
|
+
"findings as a JSON array."
|
|
1007
|
+
),
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def create_conversation(
|
|
1012
|
+
config: dict[str, Any],
|
|
1013
|
+
secrets: dict[str, str],
|
|
1014
|
+
) -> Conversation:
|
|
1015
|
+
"""Create the review conversation with the plugin loaded.
|
|
1016
|
+
|
|
1017
|
+
The pr-review plugin is passed to Conversation via PluginSource, which
|
|
1018
|
+
handles wiring skills, MCP config, and hooks automatically.
|
|
1019
|
+
Project-specific skills from the workspace are loaded separately.
|
|
1020
|
+
|
|
1021
|
+
When ``config["use_sub_agents"]`` is True the coordinator agent is
|
|
1022
|
+
given the TaskToolSet so it can delegate to file_reviewer sub-agents.
|
|
1023
|
+
|
|
1024
|
+
Args:
|
|
1025
|
+
config: Configuration dictionary from validate_environment()
|
|
1026
|
+
secrets: Secrets to mask in output
|
|
1027
|
+
|
|
1028
|
+
Returns:
|
|
1029
|
+
Configured Conversation instance
|
|
1030
|
+
"""
|
|
1031
|
+
# Load project-specific skills from the workspace
|
|
1032
|
+
cwd = os.getcwd()
|
|
1033
|
+
project_skills = load_project_skills(cwd)
|
|
1034
|
+
logger.info(
|
|
1035
|
+
f"Loaded {len(project_skills)} project skills: "
|
|
1036
|
+
f"{[s.name for s in project_skills]}"
|
|
1037
|
+
)
|
|
1038
|
+
load_public_skills = config.get("load_public_skills", True)
|
|
1039
|
+
logger.info("Load public skills: %s", load_public_skills)
|
|
1040
|
+
|
|
1041
|
+
agent_context = AgentContext(
|
|
1042
|
+
load_public_skills=load_public_skills,
|
|
1043
|
+
skills=project_skills,
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
plugin_dir = script_dir.parent # plugins/pr-review/
|
|
1047
|
+
|
|
1048
|
+
if config["agent_kind"] == "acp":
|
|
1049
|
+
from openhands.sdk.agent import ACPAgent
|
|
1050
|
+
|
|
1051
|
+
acp_command = shlex.split(config["acp_command"])
|
|
1052
|
+
if not acp_command:
|
|
1053
|
+
raise ValueError("ACP_COMMAND must not be empty")
|
|
1054
|
+
logger.info(
|
|
1055
|
+
"Using ACP review agent with command: %s",
|
|
1056
|
+
" ".join(shlex.quote(part) for part in acp_command),
|
|
1057
|
+
)
|
|
1058
|
+
agent = ACPAgent(
|
|
1059
|
+
acp_command=acp_command,
|
|
1060
|
+
acp_model=config["model"],
|
|
1061
|
+
acp_prompt_timeout=config["acp_prompt_timeout"],
|
|
1062
|
+
agent_context=agent_context,
|
|
1063
|
+
)
|
|
1064
|
+
return Conversation(
|
|
1065
|
+
agent=agent,
|
|
1066
|
+
workspace=cwd,
|
|
1067
|
+
secrets=secrets,
|
|
1068
|
+
plugins=[PluginSource(source=str(plugin_dir))],
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
llm_config: dict[str, Any] = {
|
|
1072
|
+
"model": config["model"],
|
|
1073
|
+
"api_key": config["api_key"],
|
|
1074
|
+
"usage_id": "pr_review_agent",
|
|
1075
|
+
"drop_params": True,
|
|
1076
|
+
}
|
|
1077
|
+
if config["base_url"]:
|
|
1078
|
+
llm_config["base_url"] = config["base_url"]
|
|
1079
|
+
|
|
1080
|
+
llm = LLM(**llm_config)
|
|
1081
|
+
|
|
1082
|
+
tools = get_default_tools(enable_browser=False)
|
|
1083
|
+
|
|
1084
|
+
use_sub_agents = config.get("use_sub_agents", False)
|
|
1085
|
+
if use_sub_agents:
|
|
1086
|
+
_register_sub_agents()
|
|
1087
|
+
tools.append(Tool(name=TaskToolSet.name))
|
|
1088
|
+
logger.info("Sub-agent delegation enabled — TaskToolSet added")
|
|
1089
|
+
|
|
1090
|
+
# When sub-agents are enabled, allow the coordinator to launch
|
|
1091
|
+
# multiple file_reviewer sub-agents concurrently via TaskToolSet.
|
|
1092
|
+
concurrency_kwargs: dict[str, int] = {}
|
|
1093
|
+
if use_sub_agents:
|
|
1094
|
+
concurrency_kwargs["tool_concurrency_limit"] = 4
|
|
1095
|
+
|
|
1096
|
+
agent = Agent(
|
|
1097
|
+
llm=llm,
|
|
1098
|
+
tools=tools,
|
|
1099
|
+
agent_context=agent_context,
|
|
1100
|
+
system_prompt_kwargs={"cli_mode": True},
|
|
1101
|
+
condenser=get_default_condenser(
|
|
1102
|
+
llm=llm.model_copy(update={"usage_id": "condenser"})
|
|
1103
|
+
),
|
|
1104
|
+
**concurrency_kwargs,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
conversation_kwargs: dict[str, Any] = {
|
|
1108
|
+
"agent": agent,
|
|
1109
|
+
"workspace": cwd,
|
|
1110
|
+
"secrets": secrets,
|
|
1111
|
+
"plugins": [PluginSource(source=str(plugin_dir))],
|
|
1112
|
+
}
|
|
1113
|
+
if use_sub_agents:
|
|
1114
|
+
conversation_kwargs["visualizer"] = DelegationVisualizer(
|
|
1115
|
+
name="PR Review Coordinator"
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
return Conversation(**conversation_kwargs)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def run_review(
|
|
1122
|
+
conversation: Conversation,
|
|
1123
|
+
prompt: str,
|
|
1124
|
+
) -> Conversation:
|
|
1125
|
+
"""Execute the PR review.
|
|
1126
|
+
|
|
1127
|
+
Args:
|
|
1128
|
+
conversation: Configured Conversation instance
|
|
1129
|
+
prompt: Review prompt
|
|
1130
|
+
|
|
1131
|
+
Returns:
|
|
1132
|
+
Completed Conversation
|
|
1133
|
+
"""
|
|
1134
|
+
logger.info("Starting PR review analysis...")
|
|
1135
|
+
logger.info("Agent will post inline review comments directly via GitHub API")
|
|
1136
|
+
|
|
1137
|
+
conversation.send_message(prompt)
|
|
1138
|
+
conversation.run()
|
|
1139
|
+
|
|
1140
|
+
review_content = get_agent_final_response(conversation.state.events)
|
|
1141
|
+
if review_content:
|
|
1142
|
+
logger.info(f"Agent final response: {len(review_content)} characters")
|
|
1143
|
+
|
|
1144
|
+
return conversation
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def log_cost_summary(conversation: Conversation) -> None:
|
|
1148
|
+
"""Print cost information for CI output."""
|
|
1149
|
+
metrics = conversation.conversation_stats.get_combined_metrics()
|
|
1150
|
+
print("\n=== PR Review Cost Summary ===")
|
|
1151
|
+
print(f"Total Cost: ${metrics.accumulated_cost:.6f}")
|
|
1152
|
+
if metrics.accumulated_token_usage:
|
|
1153
|
+
token_usage = metrics.accumulated_token_usage
|
|
1154
|
+
print(f"Prompt Tokens: {token_usage.prompt_tokens}")
|
|
1155
|
+
print(f"Completion Tokens: {token_usage.completion_tokens}")
|
|
1156
|
+
if token_usage.cache_read_tokens > 0:
|
|
1157
|
+
print(f"Cache Read Tokens: {token_usage.cache_read_tokens}")
|
|
1158
|
+
if token_usage.cache_write_tokens > 0:
|
|
1159
|
+
print(f"Cache Write Tokens: {token_usage.cache_write_tokens}")
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def save_trace_context(
|
|
1163
|
+
pr_info: dict[str, Any],
|
|
1164
|
+
commit_id: str,
|
|
1165
|
+
model: str,
|
|
1166
|
+
) -> None:
|
|
1167
|
+
"""Capture and store Laminar trace context for evaluation.
|
|
1168
|
+
|
|
1169
|
+
Saves trace info to file for GitHub artifact upload, enabling
|
|
1170
|
+
the evaluation workflow to continue the trace.
|
|
1171
|
+
"""
|
|
1172
|
+
trace_id = Laminar.get_trace_id()
|
|
1173
|
+
laminar_span_context = Laminar.get_laminar_span_context()
|
|
1174
|
+
span_context = (
|
|
1175
|
+
laminar_span_context.model_dump(mode="json") if laminar_span_context else None
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
if not trace_id or not laminar_span_context:
|
|
1179
|
+
logger.warning(
|
|
1180
|
+
"No Laminar trace ID found - observability may not be enabled"
|
|
1181
|
+
)
|
|
1182
|
+
return
|
|
1183
|
+
|
|
1184
|
+
with Laminar.start_as_current_span(
|
|
1185
|
+
name="pr-review-metadata",
|
|
1186
|
+
parent_span_context=laminar_span_context,
|
|
1187
|
+
) as _:
|
|
1188
|
+
pr_url = f"https://github.com/{pr_info['repo_name']}/pull/{pr_info['number']}"
|
|
1189
|
+
Laminar.set_trace_metadata(
|
|
1190
|
+
{
|
|
1191
|
+
"pr_number": pr_info["number"],
|
|
1192
|
+
"repo_name": pr_info["repo_name"],
|
|
1193
|
+
"pr_url": pr_url,
|
|
1194
|
+
"workflow_phase": "review",
|
|
1195
|
+
"model": model,
|
|
1196
|
+
}
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
trace_data = {
|
|
1200
|
+
"trace_id": str(trace_id),
|
|
1201
|
+
"span_context": span_context,
|
|
1202
|
+
"pr_number": pr_info["number"],
|
|
1203
|
+
"repo_name": pr_info["repo_name"],
|
|
1204
|
+
"commit_id": commit_id,
|
|
1205
|
+
"model": model,
|
|
1206
|
+
}
|
|
1207
|
+
with open("laminar_trace_info.json", "w") as f:
|
|
1208
|
+
json.dump(trace_data, f, indent=2)
|
|
1209
|
+
|
|
1210
|
+
logger.info(f"Laminar trace ID: {trace_id}")
|
|
1211
|
+
logger.info(f"Model used: {model}")
|
|
1212
|
+
if span_context:
|
|
1213
|
+
logger.info("Laminar span context captured for trace continuation")
|
|
1214
|
+
print("\n=== Laminar Trace ===")
|
|
1215
|
+
print(f"Trace ID: {trace_id}")
|
|
1216
|
+
|
|
1217
|
+
Laminar.flush()
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def main():
|
|
1221
|
+
"""Run the PR review agent."""
|
|
1222
|
+
logger.info("Starting PR review process...")
|
|
1223
|
+
|
|
1224
|
+
config = validate_environment()
|
|
1225
|
+
pr_info = config["pr_info"]
|
|
1226
|
+
require_evidence = config["require_evidence"]
|
|
1227
|
+
collect_feedback = config["collect_feedback"]
|
|
1228
|
+
use_sub_agents = config["use_sub_agents"]
|
|
1229
|
+
|
|
1230
|
+
logger.info(f"Reviewing PR #{pr_info['number']}: {pr_info['title']}")
|
|
1231
|
+
logger.info(f"Require PR evidence: {require_evidence}")
|
|
1232
|
+
logger.info(f"Collect review feedback: {collect_feedback}")
|
|
1233
|
+
logger.info(f"Sub-agent delegation: {use_sub_agents}")
|
|
1234
|
+
logger.info(f"Agent kind: {config['agent_kind']}")
|
|
1235
|
+
|
|
1236
|
+
try:
|
|
1237
|
+
manifest, patches, commit_id, review_context = fetch_pr_context(
|
|
1238
|
+
pr_info["number"]
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
skill_trigger = "/codereview"
|
|
1242
|
+
logger.info(f"Using skill trigger: {skill_trigger}")
|
|
1243
|
+
|
|
1244
|
+
prompt = format_prompt(
|
|
1245
|
+
skill_trigger=skill_trigger,
|
|
1246
|
+
title=pr_info.get("title", "N/A"),
|
|
1247
|
+
body=pr_info.get("body") or "No description provided",
|
|
1248
|
+
repo_name=pr_info.get("repo_name", "N/A"),
|
|
1249
|
+
base_branch=pr_info.get("base_branch", "main"),
|
|
1250
|
+
head_branch=pr_info.get("head_branch", "N/A"),
|
|
1251
|
+
pr_number=pr_info["number"],
|
|
1252
|
+
commit_id=commit_id,
|
|
1253
|
+
diff=patches,
|
|
1254
|
+
files_manifest=manifest,
|
|
1255
|
+
review_context=review_context,
|
|
1256
|
+
require_evidence=require_evidence,
|
|
1257
|
+
collect_feedback=collect_feedback,
|
|
1258
|
+
review_run_url=config["review_run_url"],
|
|
1259
|
+
use_sub_agents=use_sub_agents,
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
secrets = {}
|
|
1263
|
+
if config["api_key"]:
|
|
1264
|
+
secrets["LLM_API_KEY"] = config["api_key"]
|
|
1265
|
+
if config["github_token"]:
|
|
1266
|
+
secrets["GITHUB_TOKEN"] = config["github_token"]
|
|
1267
|
+
|
|
1268
|
+
conversation = create_conversation(config, secrets)
|
|
1269
|
+
conversation = run_review(conversation, prompt)
|
|
1270
|
+
|
|
1271
|
+
log_cost_summary(conversation)
|
|
1272
|
+
save_trace_context(pr_info, commit_id, config["model"])
|
|
1273
|
+
|
|
1274
|
+
logger.info("PR review completed successfully")
|
|
1275
|
+
|
|
1276
|
+
except Exception as e:
|
|
1277
|
+
logger.error(f"PR review failed: {e}")
|
|
1278
|
+
sys.exit(1)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
if __name__ == "__main__":
|
|
1282
|
+
main()
|