@openhands/extensions 0.1.0 → 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.
Files changed (347) hide show
  1. package/.agents/skills/custom-codereview-guide.md +25 -0
  2. package/.github/pull_request_template.md +38 -0
  3. package/.github/release.yml +14 -0
  4. package/.github/workflows/check-extensions.yml +72 -0
  5. package/.github/workflows/npm-publish.yml +89 -0
  6. package/.github/workflows/pr.yml +30 -0
  7. package/.github/workflows/release.yml +24 -0
  8. package/.github/workflows/tests.yml +25 -0
  9. package/.github/workflows/vulnerability-scan.yml +87 -0
  10. package/.release-please-manifest.json +3 -0
  11. package/AGENTS.md +132 -0
  12. package/README.md +10 -0
  13. package/analysis_results.md +162 -0
  14. package/marketplaces/large-codebase.json +66 -0
  15. package/marketplaces/openhands-extensions.json +682 -0
  16. package/package.json +4 -10
  17. package/plugins/README.md +30 -0
  18. package/plugins/city-weather/.plugin/plugin.json +13 -0
  19. package/plugins/city-weather/README.md +145 -0
  20. package/plugins/city-weather/commands/now.md +56 -0
  21. package/plugins/cobol-modernization/.plugin/plugin.json +19 -0
  22. package/plugins/cobol-modernization/README.md +201 -0
  23. package/plugins/cobol-modernization/references/troubleshooting.md +18 -0
  24. package/plugins/cobol-modernization/skills/build-setup/SKILL.md +78 -0
  25. package/plugins/cobol-modernization/skills/build-setup/scripts/install-gnucobol.sh +32 -0
  26. package/plugins/cobol-modernization/skills/cobol-modernization-overview/SKILL.md +113 -0
  27. package/plugins/cobol-modernization/skills/mainfraime-removal/SKILL.md +62 -0
  28. package/plugins/cobol-modernization/skills/mainfraime-removal/references/cics-transformation-examples.md +45 -0
  29. package/plugins/cobol-modernization/skills/mainframe-planning/SKILL.md +78 -0
  30. package/plugins/cobol-modernization/skills/to-java-migration/SKILL.md +59 -0
  31. package/plugins/cobol-modernization/skills/to-java-migration/references/cobol-to-java-example.md +58 -0
  32. package/plugins/cobol-modernization/skills/to-java-migration/references/datatype-mappings.md +19 -0
  33. package/plugins/issue-duplicate-checker/.plugin/plugin.json +13 -0
  34. package/plugins/issue-duplicate-checker/README.md +51 -0
  35. package/plugins/issue-duplicate-checker/action.yml +349 -0
  36. package/plugins/issue-duplicate-checker/scripts/auto_close_duplicate_issues.py +569 -0
  37. package/plugins/issue-duplicate-checker/scripts/issue_duplicate_check_openhands.py +681 -0
  38. package/plugins/issue-duplicate-checker/scripts/post_duplicate_notice.js +220 -0
  39. package/plugins/issue-duplicate-checker/scripts/remove_duplicate_candidate_label.js +27 -0
  40. package/plugins/magic-test/.plugin/plugin.json +13 -0
  41. package/plugins/magic-test/skills/magic-word/SKILL.md +33 -0
  42. package/plugins/migration-scoring/.plugin/plugin.json +19 -0
  43. package/plugins/migration-scoring/README.md +244 -0
  44. package/plugins/migration-scoring/skills/migration-mapping/SKILL.md +72 -0
  45. package/plugins/migration-scoring/skills/migration-report/SKILL.md +118 -0
  46. package/plugins/migration-scoring/skills/migration-scoring-overview/SKILL.md +126 -0
  47. package/plugins/migration-scoring/skills/score-quality/SKILL.md +54 -0
  48. package/plugins/migration-scoring/skills/score-quality/references/scoring-criteria.md +30 -0
  49. package/plugins/migration-scoring/skills/score-style/SKILL.md +106 -0
  50. package/plugins/onboarding/.plugin/plugin.json +20 -0
  51. package/plugins/onboarding/README.md +30 -0
  52. package/plugins/onboarding/references/criteria.md +144 -0
  53. package/plugins/onboarding/skills/agent-readiness-report/README.md +23 -0
  54. package/plugins/onboarding/skills/agent-readiness-report/SKILL.md +122 -0
  55. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_agent_instructions.sh +88 -0
  56. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_build_env.sh +114 -0
  57. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_feedback_loops.sh +133 -0
  58. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_policy.sh +113 -0
  59. package/plugins/onboarding/skills/agent-readiness-report/scripts/scan_workflows.sh +127 -0
  60. package/plugins/onboarding/skills/improve-agent-readiness/README.md +19 -0
  61. package/plugins/onboarding/skills/improve-agent-readiness/SKILL.md +167 -0
  62. package/plugins/onboarding/skills/setup-agents-md/README.md +15 -0
  63. package/plugins/onboarding/skills/setup-agents-md/SKILL.md +150 -0
  64. package/plugins/onboarding/skills/setup-openhands/README.md +20 -0
  65. package/plugins/onboarding/skills/setup-openhands/SKILL.md +56 -0
  66. package/plugins/onboarding/skills/setup-pr-review/README.md +23 -0
  67. package/plugins/onboarding/skills/setup-pr-review/SKILL.md +72 -0
  68. package/plugins/openhands/.plugin/plugin.json +13 -0
  69. package/plugins/openhands/README.md +52 -0
  70. package/plugins/openhands/SKILL.md +61 -0
  71. package/plugins/openhands/commands/create.md +55 -0
  72. package/plugins/openhands/commands/openhands-cloud.md +8 -0
  73. package/plugins/openhands/scripts/run.sh +69 -0
  74. package/plugins/pr-review/.plugin/plugin.json +13 -0
  75. package/plugins/pr-review/README.md +393 -0
  76. package/plugins/pr-review/action.yml +298 -0
  77. package/plugins/pr-review/scripts/agent_script.py +1282 -0
  78. package/plugins/pr-review/scripts/evaluate_review.py +655 -0
  79. package/plugins/pr-review/scripts/prompt.py +260 -0
  80. package/plugins/pr-review/workflows/pr-review-by-openhands.yml +51 -0
  81. package/plugins/pr-review/workflows/pr-review-evaluation.yml +85 -0
  82. package/plugins/qa-changes/.plugin/plugin.json +11 -0
  83. package/plugins/qa-changes/README.md +185 -0
  84. package/plugins/qa-changes/action.yml +181 -0
  85. package/plugins/qa-changes/scripts/agent_script.py +406 -0
  86. package/plugins/qa-changes/scripts/evaluate_qa_changes.py +385 -0
  87. package/plugins/qa-changes/scripts/prompt.py +174 -0
  88. package/plugins/qa-changes/workflows/qa-changes-by-openhands.yml +50 -0
  89. package/plugins/qa-changes/workflows/qa-changes-evaluation.yml +85 -0
  90. package/plugins/release-notes/.plugin/plugin.json +19 -0
  91. package/plugins/release-notes/README.md +283 -0
  92. package/plugins/release-notes/SKILL.md +83 -0
  93. package/plugins/release-notes/action.yml +117 -0
  94. package/plugins/release-notes/commands/release-notes.md +8 -0
  95. package/plugins/release-notes/scripts/agent_script.py +292 -0
  96. package/plugins/release-notes/scripts/generate_release_notes.py +733 -0
  97. package/plugins/release-notes/scripts/prompt.py +90 -0
  98. package/plugins/release-notes/scripts/validate_release_notes.py +328 -0
  99. package/plugins/release-notes/workflows/release-notes.yml +76 -0
  100. package/plugins/vulnerability-remediation/.plugin/plugin.json +19 -0
  101. package/plugins/vulnerability-remediation/README.md +217 -0
  102. package/plugins/vulnerability-remediation/action.yml +187 -0
  103. package/plugins/vulnerability-remediation/scripts/scan_and_remediate.py +561 -0
  104. package/plugins/vulnerability-remediation/workflows/vulnerability-scan.yml +87 -0
  105. package/pyproject.toml +12 -0
  106. package/release-please-config.json +16 -0
  107. package/scripts/sync_extensions.py +494 -0
  108. package/scripts/sync_openhands_sdk_skill.py +264 -0
  109. package/skills/README.md +159 -0
  110. package/skills/add-javadoc/.plugin/plugin.json +18 -0
  111. package/skills/add-javadoc/README.md +40 -0
  112. package/skills/add-javadoc/SKILL.md +35 -0
  113. package/skills/add-javadoc/references/example.md +32 -0
  114. package/skills/add-skill/.plugin/plugin.json +18 -0
  115. package/skills/add-skill/README.md +67 -0
  116. package/skills/add-skill/SKILL.md +47 -0
  117. package/skills/add-skill/scripts/fetch_skill.py +259 -0
  118. package/skills/agent-creator/.plugin/plugin.json +20 -0
  119. package/skills/agent-creator/README.md +104 -0
  120. package/skills/agent-creator/SKILL.md +190 -0
  121. package/skills/agent-creator/commands/agent-creator.md +8 -0
  122. package/skills/agent-creator/references/fallback.md +117 -0
  123. package/skills/agent-memory/.plugin/plugin.json +18 -0
  124. package/skills/agent-memory/README.md +35 -0
  125. package/skills/agent-memory/SKILL.md +30 -0
  126. package/skills/agent-memory/commands/remember.md +8 -0
  127. package/skills/agent-sdk-builder/.plugin/plugin.json +18 -0
  128. package/skills/agent-sdk-builder/README.md +40 -0
  129. package/skills/agent-sdk-builder/SKILL.md +37 -0
  130. package/skills/agent-sdk-builder/commands/agent-builder.md +8 -0
  131. package/skills/azure-devops/.plugin/plugin.json +18 -0
  132. package/skills/azure-devops/README.md +55 -0
  133. package/skills/azure-devops/SKILL.md +50 -0
  134. package/skills/bitbucket/.plugin/plugin.json +17 -0
  135. package/skills/bitbucket/README.md +50 -0
  136. package/skills/bitbucket/SKILL.md +45 -0
  137. package/skills/code-review/.plugin/plugin.json +19 -0
  138. package/skills/code-review/README.md +18 -0
  139. package/skills/code-review/SKILL.md +208 -0
  140. package/skills/code-review/commands/codereview-roasted.md +8 -0
  141. package/skills/code-review/commands/codereview.md +8 -0
  142. package/skills/code-review/references/risk-evaluation.md +41 -0
  143. package/skills/code-review/references/supply-chain-security.md +31 -0
  144. package/skills/code-simplifier/.plugin/plugin.json +21 -0
  145. package/skills/code-simplifier/README.md +30 -0
  146. package/skills/code-simplifier/SKILL.md +91 -0
  147. package/skills/code-simplifier/commands/simplify.md +8 -0
  148. package/skills/code-simplifier/references/code-quality-review.md +86 -0
  149. package/skills/code-simplifier/references/code-reuse-review.md +63 -0
  150. package/skills/code-simplifier/references/efficiency-review.md +81 -0
  151. package/skills/datadog/.plugin/plugin.json +19 -0
  152. package/skills/datadog/README.md +100 -0
  153. package/skills/datadog/SKILL.md +95 -0
  154. package/skills/deno/.plugin/plugin.json +18 -0
  155. package/skills/deno/README.md +5 -0
  156. package/skills/deno/SKILL.md +99 -0
  157. package/skills/deno/references/README.md +6 -0
  158. package/skills/discord/.plugin/plugin.json +18 -0
  159. package/skills/discord/README.md +31 -0
  160. package/skills/discord/SKILL.md +109 -0
  161. package/skills/discord/__init__.py +0 -0
  162. package/skills/discord/references/REFERENCE.md +78 -0
  163. package/skills/discord/scripts/__init__.py +0 -0
  164. package/skills/discord/scripts/_http.py +127 -0
  165. package/skills/discord/scripts/post_webhook.py +106 -0
  166. package/skills/discord/scripts/send_message.py +102 -0
  167. package/skills/docker/.plugin/plugin.json +17 -0
  168. package/skills/docker/README.md +34 -0
  169. package/skills/docker/SKILL.md +29 -0
  170. package/skills/evidence-based-citations/.plugin/plugin.json +20 -0
  171. package/skills/evidence-based-citations/README.md +31 -0
  172. package/skills/evidence-based-citations/SKILL.md +59 -0
  173. package/skills/flarglebargle/.plugin/plugin.json +16 -0
  174. package/skills/flarglebargle/README.md +14 -0
  175. package/skills/flarglebargle/SKILL.md +9 -0
  176. package/skills/frontend-design/.plugin/plugin.json +21 -0
  177. package/skills/frontend-design/LICENSE.txt +177 -0
  178. package/skills/frontend-design/README.md +42 -0
  179. package/skills/frontend-design/SKILL.md +42 -0
  180. package/skills/github/.plugin/plugin.json +19 -0
  181. package/skills/github/README.md +42 -0
  182. package/skills/github/SKILL.md +106 -0
  183. package/skills/github-pr-review/.plugin/plugin.json +18 -0
  184. package/skills/github-pr-review/README.md +145 -0
  185. package/skills/github-pr-review/SKILL.md +148 -0
  186. package/skills/github-pr-review/commands/github-pr-review.md +8 -0
  187. package/skills/github-pr-reviewer/.plugin/plugin.json +20 -0
  188. package/skills/github-pr-reviewer/README.md +34 -0
  189. package/skills/github-pr-reviewer/SKILL.md +89 -0
  190. package/skills/github-pr-reviewer/commands/pr-reviewer:setup.md +8 -0
  191. package/skills/github-repo-monitor/.plugin/plugin.json +22 -0
  192. package/skills/github-repo-monitor/README.md +70 -0
  193. package/skills/github-repo-monitor/SKILL.md +316 -0
  194. package/skills/github-repo-monitor/commands/github-monitor:poll.md +8 -0
  195. package/skills/github-repo-monitor/references/github-api.md +241 -0
  196. package/skills/github-repo-monitor/references/state-schema.md +160 -0
  197. package/skills/github-repo-monitor/scripts/main.py +915 -0
  198. package/skills/github-repo-monitor/tests/test_main.py +400 -0
  199. package/skills/gitlab/.plugin/plugin.json +17 -0
  200. package/skills/gitlab/README.md +37 -0
  201. package/skills/gitlab/SKILL.md +32 -0
  202. package/skills/incident-retrospective/.plugin/plugin.json +21 -0
  203. package/skills/incident-retrospective/README.md +34 -0
  204. package/skills/incident-retrospective/SKILL.md +98 -0
  205. package/skills/incident-retrospective/commands/incident-retro:setup.md +8 -0
  206. package/skills/iterate/.plugin/plugin.json +13 -0
  207. package/skills/iterate/README.md +25 -0
  208. package/skills/iterate/SKILL.md +399 -0
  209. package/skills/iterate/commands/babysit.md +8 -0
  210. package/skills/iterate/commands/iterate.md +8 -0
  211. package/skills/iterate/commands/verify.md +8 -0
  212. package/skills/iterate/references/heuristics.md +58 -0
  213. package/skills/iterate/references/verification.md +96 -0
  214. package/skills/jupyter/.plugin/plugin.json +18 -0
  215. package/skills/jupyter/README.md +55 -0
  216. package/skills/jupyter/SKILL.md +50 -0
  217. package/skills/kubernetes/.plugin/plugin.json +18 -0
  218. package/skills/kubernetes/README.md +53 -0
  219. package/skills/kubernetes/SKILL.md +48 -0
  220. package/skills/learn-from-code-review/.plugin/plugin.json +19 -0
  221. package/skills/learn-from-code-review/README.md +64 -0
  222. package/skills/learn-from-code-review/SKILL.md +186 -0
  223. package/skills/learn-from-code-review/commands/learn-from-reviews.md +8 -0
  224. package/skills/linear/.plugin/plugin.json +19 -0
  225. package/skills/linear/README.md +58 -0
  226. package/skills/linear/SKILL.md +213 -0
  227. package/skills/linear-triage/.plugin/plugin.json +21 -0
  228. package/skills/linear-triage/README.md +34 -0
  229. package/skills/linear-triage/SKILL.md +91 -0
  230. package/skills/linear-triage/commands/linear-triage:setup.md +8 -0
  231. package/skills/notion/.plugin/plugin.json +17 -0
  232. package/skills/notion/README.md +114 -0
  233. package/skills/notion/SKILL.md +109 -0
  234. package/skills/npm/.plugin/plugin.json +17 -0
  235. package/skills/npm/README.md +14 -0
  236. package/skills/npm/SKILL.md +9 -0
  237. package/skills/openhands-api/.plugin/plugin.json +22 -0
  238. package/skills/openhands-api/README.md +48 -0
  239. package/skills/openhands-api/SKILL.md +399 -0
  240. package/skills/openhands-api/references/README.md +33 -0
  241. package/skills/openhands-api/references/TROUBLESHOOTING.md +81 -0
  242. package/skills/openhands-api/references/example_prompt.md +12 -0
  243. package/skills/openhands-api/scripts/openhands_api.py +606 -0
  244. package/skills/openhands-api/scripts/openhands_api.ts +252 -0
  245. package/skills/openhands-automation/.plugin/plugin.json +19 -0
  246. package/skills/openhands-automation/README.md +89 -0
  247. package/skills/openhands-automation/SKILL.md +875 -0
  248. package/skills/openhands-automation/commands/automation:create.md +8 -0
  249. package/skills/openhands-automation/references/ab-testing.md +185 -0
  250. package/skills/openhands-automation/references/custom-automation.md +644 -0
  251. package/skills/openhands-sdk/.plugin/plugin.json +20 -0
  252. package/skills/openhands-sdk/README.md +22 -0
  253. package/skills/openhands-sdk/SKILL.md +229 -0
  254. package/skills/openhands-sdk/commands/sdk.md +8 -0
  255. package/skills/pdflatex/.plugin/plugin.json +18 -0
  256. package/skills/pdflatex/README.md +39 -0
  257. package/skills/pdflatex/SKILL.md +34 -0
  258. package/skills/prd/.plugin/plugin.json +19 -0
  259. package/skills/prd/README.md +28 -0
  260. package/skills/prd/SKILL.md +237 -0
  261. package/skills/prd/commands/prd.md +8 -0
  262. package/skills/qa-changes/README.md +18 -0
  263. package/skills/qa-changes/SKILL.md +229 -0
  264. package/skills/qa-changes/commands/qa-changes.md +8 -0
  265. package/skills/release-notes/README.md +24 -0
  266. package/skills/release-notes/SKILL.md +19 -0
  267. package/skills/release-notes/commands/release-notes.md +8 -0
  268. package/skills/research-brief/.plugin/plugin.json +20 -0
  269. package/skills/research-brief/README.md +34 -0
  270. package/skills/research-brief/SKILL.md +99 -0
  271. package/skills/research-brief/commands/research-brief:setup.md +8 -0
  272. package/skills/security/.plugin/plugin.json +18 -0
  273. package/skills/security/README.md +38 -0
  274. package/skills/security/SKILL.md +33 -0
  275. package/skills/skill-creator/.plugin/plugin.json +17 -0
  276. package/skills/skill-creator/LICENSE.txt +202 -0
  277. package/skills/skill-creator/README.md +182 -0
  278. package/skills/skill-creator/SKILL.md +545 -0
  279. package/skills/skill-creator/references/output-patterns.md +82 -0
  280. package/skills/skill-creator/references/workflows.md +28 -0
  281. package/skills/skill-creator/scripts/init_skill.py +303 -0
  282. package/skills/skill-creator/scripts/quick_validate.py +95 -0
  283. package/skills/slack-channel-monitor/.plugin/plugin.json +21 -0
  284. package/skills/slack-channel-monitor/README.md +91 -0
  285. package/skills/slack-channel-monitor/SKILL.md +276 -0
  286. package/skills/slack-channel-monitor/commands/slack-monitor:poll.md +8 -0
  287. package/skills/slack-channel-monitor/references/slack-api.md +207 -0
  288. package/skills/slack-channel-monitor/references/state-schema.md +180 -0
  289. package/skills/slack-channel-monitor/scripts/main.py +962 -0
  290. package/skills/slack-standup-digest/.plugin/plugin.json +21 -0
  291. package/skills/slack-standup-digest/README.md +34 -0
  292. package/skills/slack-standup-digest/SKILL.md +92 -0
  293. package/skills/slack-standup-digest/commands/standup-digest:setup.md +8 -0
  294. package/skills/spark-version-upgrade/.plugin/plugin.json +20 -0
  295. package/skills/spark-version-upgrade/README.md +54 -0
  296. package/skills/spark-version-upgrade/SKILL.md +233 -0
  297. package/skills/ssh/.plugin/plugin.json +18 -0
  298. package/skills/ssh/README.md +140 -0
  299. package/skills/ssh/SKILL.md +135 -0
  300. package/skills/swift-linux/.plugin/plugin.json +17 -0
  301. package/skills/swift-linux/README.md +86 -0
  302. package/skills/swift-linux/SKILL.md +81 -0
  303. package/skills/theme-factory/.plugin/plugin.json +19 -0
  304. package/skills/theme-factory/LICENSE.txt +202 -0
  305. package/skills/theme-factory/README.md +58 -0
  306. package/skills/theme-factory/SKILL.md +59 -0
  307. package/skills/theme-factory/theme-showcase.pdf +0 -0
  308. package/skills/theme-factory/themes/arctic-frost.md +19 -0
  309. package/skills/theme-factory/themes/botanical-garden.md +19 -0
  310. package/skills/theme-factory/themes/desert-rose.md +19 -0
  311. package/skills/theme-factory/themes/forest-canopy.md +19 -0
  312. package/skills/theme-factory/themes/golden-hour.md +19 -0
  313. package/skills/theme-factory/themes/midnight-galaxy.md +19 -0
  314. package/skills/theme-factory/themes/modern-minimalist.md +19 -0
  315. package/skills/theme-factory/themes/ocean-depths.md +19 -0
  316. package/skills/theme-factory/themes/sunset-boulevard.md +19 -0
  317. package/skills/theme-factory/themes/tech-innovation.md +19 -0
  318. package/skills/uv/.plugin/plugin.json +18 -0
  319. package/skills/uv/README.md +5 -0
  320. package/skills/uv/SKILL.md +95 -0
  321. package/skills/uv/references/README.md +5 -0
  322. package/skills/vercel/.plugin/plugin.json +18 -0
  323. package/skills/vercel/README.md +108 -0
  324. package/skills/vercel/SKILL.md +103 -0
  325. package/tests/test_add_skill_installs_to_agents_dir.py +42 -0
  326. package/tests/test_catalogs.py +109 -0
  327. package/tests/test_code_review_risk_evaluation.py +94 -0
  328. package/tests/test_issue_duplicate_checker.py +240 -0
  329. package/tests/test_openhands_api_python.py +152 -0
  330. package/tests/test_plugin_manifest.py +83 -0
  331. package/tests/test_pr_review_diff_payload.py +202 -0
  332. package/tests/test_pr_review_feedback.py +263 -0
  333. package/tests/test_pr_review_prompt.py +152 -0
  334. package/tests/test_pr_review_review_context.py +253 -0
  335. package/tests/test_qa_changes.py +232 -0
  336. package/tests/test_qa_changes_evaluation.py +259 -0
  337. package/tests/test_release_notes_generator.py +990 -0
  338. package/tests/test_sdk_loading.py +150 -0
  339. package/tests/test_skill_plugin_loading.py +149 -0
  340. package/tests/test_skills_have_readme.py +66 -0
  341. package/tests/test_sync_extensions.py +292 -0
  342. package/tests/test_workflow_sync.py +46 -0
  343. package/utils/analysis/README.md +7 -0
  344. package/utils/analysis/laminar_signals/README.md +211 -0
  345. package/utils/analysis/laminar_signals/analyze.py +780 -0
  346. package/utils/analysis/laminar_signals/templates/default.j2 +49 -0
  347. 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()