@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.
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,681 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ OPENHANDS_BASE_URL = os.environ.get("OPENHANDS_BASE_URL", "https://app.all-hands.dev")
18
+ REPOSITORY_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$")
19
+ GITHUB_API_BASE_URL = os.environ.get("GITHUB_API_BASE_URL", "https://api.github.com")
20
+ GITHUB_BASE_URL = os.environ.get("GITHUB_BASE_URL", "https://github.com")
21
+ FAILED_EXECUTION_STATUSES = {
22
+ "error",
23
+ "errored",
24
+ "failed",
25
+ "stopped",
26
+ }
27
+ SUCCESSFUL_TERMINAL_EXECUTION_STATUSES = {
28
+ "completed",
29
+ "finished",
30
+ }
31
+ TERMINAL_EXECUTION_STATUSES = (
32
+ FAILED_EXECUTION_STATUSES | SUCCESSFUL_TERMINAL_EXECUTION_STATUSES
33
+ )
34
+ EVENT_SEARCH_LIMIT = 1000
35
+ EVENT_SEARCH_LIMIT_HIT_MESSAGE = (
36
+ f"Event search returned at least {EVENT_SEARCH_LIMIT} events; results may be "
37
+ "incomplete"
38
+ )
39
+ OPENHANDS_DEBUG_KEYS = (
40
+ "id",
41
+ "status",
42
+ "app_conversation_id",
43
+ "execution_status",
44
+ "conversation_url",
45
+ "error",
46
+ "error_detail",
47
+ "detail",
48
+ "message",
49
+ )
50
+ OPENHANDS_SENSITIVE_KEYS = frozenset({"session_api_key"})
51
+
52
+
53
+ class HTTPError(RuntimeError):
54
+ def __init__(self, method: str, url: str, status_code: int, body: str) -> None:
55
+ self.method = method
56
+ self.url = url
57
+ self.status_code = status_code
58
+ self.body = body
59
+ super().__init__(f"{method} {url} failed with HTTP {status_code}: {body}")
60
+
61
+
62
+ def parse_args() -> argparse.Namespace:
63
+ parser = argparse.ArgumentParser(
64
+ description=(
65
+ "Start an OpenHands Cloud conversation that checks a GitHub issue "
66
+ "for duplicates."
67
+ )
68
+ )
69
+ parser.add_argument(
70
+ "--repository", required=True, help="Repository in owner/repo form"
71
+ )
72
+ parser.add_argument(
73
+ "--issue-number", required=True, type=int, help="Issue number to inspect"
74
+ )
75
+ parser.add_argument(
76
+ "--output",
77
+ default="duplicate-check-result.json",
78
+ help="Path where the JSON result should be written",
79
+ )
80
+ parser.add_argument(
81
+ "--poll-interval-seconds",
82
+ default=5,
83
+ type=int,
84
+ help="Polling interval while waiting for the conversation to finish",
85
+ )
86
+ parser.add_argument(
87
+ "--max-wait-seconds",
88
+ default=900,
89
+ type=int,
90
+ help=(
91
+ "Maximum time to wait per polling phase; if a start task must be awaited "
92
+ "first, the total runtime can approach twice this value"
93
+ ),
94
+ )
95
+ return parser.parse_args()
96
+
97
+
98
+ def github_headers() -> dict[str, str]:
99
+ github_token = os.environ.get("GITHUB_TOKEN")
100
+ if not github_token:
101
+ raise RuntimeError("GITHUB_TOKEN environment variable is required")
102
+ return {
103
+ "Authorization": f"Bearer {github_token}",
104
+ "Accept": "application/vnd.github+json",
105
+ "User-Agent": "openhands-issue-duplicate-check",
106
+ "X-GitHub-Api-Version": "2022-11-28",
107
+ }
108
+
109
+
110
+ def openhands_headers() -> dict[str, str]:
111
+ api_key = os.environ.get("OPENHANDS_API_KEY")
112
+ if not api_key:
113
+ raise RuntimeError("OPENHANDS_API_KEY environment variable is required")
114
+ return {
115
+ "Authorization": f"Bearer {api_key}",
116
+ "Content-Type": "application/json",
117
+ }
118
+
119
+
120
+ def request_json(
121
+ base_url: str,
122
+ path: str,
123
+ *,
124
+ method: str = "GET",
125
+ headers: dict[str, str] | None = None,
126
+ body: dict[str, Any] | None = None,
127
+ ) -> Any:
128
+ data = json.dumps(body).encode("utf-8") if body is not None else None
129
+ url = f"{base_url}{path}"
130
+ request = urllib.request.Request(
131
+ url,
132
+ data=data,
133
+ headers=headers or {},
134
+ method=method,
135
+ )
136
+ try:
137
+ with urllib.request.urlopen(request, timeout=60) as response:
138
+ return json.load(response)
139
+ except urllib.error.HTTPError as exc:
140
+ error_body = exc.read().decode("utf-8", errors="replace")
141
+ raise HTTPError(method, url, exc.code, error_body) from exc
142
+ except json.JSONDecodeError as exc:
143
+ raise RuntimeError(f"Failed to parse JSON from {method} {url}: {exc}") from exc
144
+ except urllib.error.URLError as exc:
145
+ raise RuntimeError(f"{method} {url} failed: {exc}") from exc
146
+
147
+
148
+ def fetch_issue(repository: str, issue_number: int) -> dict[str, Any]:
149
+ if not REPOSITORY_PATTERN.fullmatch(repository):
150
+ raise ValueError(f"Invalid repository format: {repository}")
151
+ return request_json(
152
+ GITHUB_API_BASE_URL,
153
+ f"/repos/{repository}/issues/{issue_number}",
154
+ headers=github_headers(),
155
+ )
156
+
157
+
158
+ def escape_json_text(value: str | None) -> str:
159
+ return json.dumps(value or "", ensure_ascii=False)
160
+
161
+
162
+ def build_prompt(repository: str, issue: dict[str, Any]) -> str:
163
+ issue_number = issue["number"]
164
+ issue_title = issue.get("title", "")
165
+ issue_body = issue.get("body") or ""
166
+ issue_url = issue.get("html_url", "")
167
+ issue_title_json = escape_json_text(issue_title)
168
+ issue_body_json = escape_json_text(issue_body)
169
+
170
+ return "\n".join(
171
+ [
172
+ "You are investigating whether a GitHub issue should be redirected "
173
+ "to an existing issue because it is either:",
174
+ "- an exact or near-exact duplicate, or",
175
+ "- so overlapping in scope that discussion or fix planning would "
176
+ "likely be better kept in one canonical issue.",
177
+ "",
178
+ "Be conservative about auto-close decisions, but do investigate "
179
+ "seriously before deciding.",
180
+ "",
181
+ f"Repository: {repository}",
182
+ f"New issue number: #{issue_number}",
183
+ f"New issue URL: {issue_url}",
184
+ f"New issue title (JSON-escaped string): {issue_title_json}",
185
+ f"New issue body (JSON-escaped string): {issue_body_json}",
186
+ "",
187
+ "Task:",
188
+ "1. Understand the core problem, user-facing outcome, likely root "
189
+ "cause, and requested fix or behavior.",
190
+ "2. Investigate this repository's open issues and issues closed "
191
+ "in the last 90 days for exact duplicates, near-duplicates, or "
192
+ "strong scope overlap.",
193
+ "3. Use multiple search approaches with diverse keywords and "
194
+ "phrasings rather than a single literal search.",
195
+ "4. Ignore pull requests.",
196
+ "5. Distinguish carefully between:",
197
+ " - duplicate: essentially the same report, request, or root cause",
198
+ " - overlapping-scope: not identical, but likely to fragment "
199
+ "discussion or produce competing fixes",
200
+ " - related-but-distinct: similar area, but should stay separate",
201
+ " - no-match: no strong candidate worth redirecting to",
202
+ "6. Inspect the strongest 1-3 candidates carefully. If needed, "
203
+ "inspect comments on the strongest candidates to disambiguate "
204
+ "false positives.",
205
+ "7. Do not post comments, do not modify files, and do not change "
206
+ "repository state.",
207
+ "8. Useful API shapes include:",
208
+ f" - GET {GITHUB_API_BASE_URL}/repos/{repository}/issues?state=open&per_page=100",
209
+ f" - GET {GITHUB_API_BASE_URL}/repos/"
210
+ f"{repository}/issues?state=closed&since=<ISO-8601 timestamp>&per_page=100",
211
+ f" - GET {GITHUB_API_BASE_URL}/search/issues?q=<query>",
212
+ f" - GET {GITHUB_API_BASE_URL}/repos/{repository}/issues/<number>/comments",
213
+ "9. Return exactly one JSON object and nothing else. Do not wrap "
214
+ "it in markdown fences.",
215
+ "",
216
+ "Return schema:",
217
+ "{",
218
+ f' "issue_number": {issue_number},',
219
+ ' "should_comment": true or false,',
220
+ ' "is_duplicate": true or false,',
221
+ ' "auto_close_candidate": true or false,',
222
+ ' "classification": "duplicate" | "overlapping-scope" | '
223
+ '"related-but-distinct" | "no-match",',
224
+ ' "confidence": "high" | "medium" | "low",',
225
+ ' "summary": "short explanation",',
226
+ ' "canonical_issue_number": 123 or null,',
227
+ ' "candidate_issues": [',
228
+ " {",
229
+ ' "number": 123,',
230
+ f' "url": "{GITHUB_BASE_URL}/{repository}/issues/123",',
231
+ ' "title": "issue title",',
232
+ ' "state": "open or closed",',
233
+ ' "closed_at": "ISO timestamp or null",',
234
+ ' "similarity_reason": "why it looks similar"',
235
+ " }",
236
+ " ]",
237
+ "}",
238
+ "",
239
+ "Rules:",
240
+ "- `should_comment` should be true only when redirecting the "
241
+ "author would likely help.",
242
+ "- `is_duplicate` should be true only for exact or near-exact duplicates.",
243
+ "- `auto_close_candidate` should be true only when:",
244
+ " - classification is `duplicate`",
245
+ " - confidence is `high`",
246
+ " - one canonical issue clearly stands out",
247
+ " - a maintainer would likely be comfortable closing this issue "
248
+ "after a waiting period",
249
+ "- For `overlapping-scope`, `auto_close_candidate` must be false.",
250
+ "- `candidate_issues` must contain at most 3 issues, sorted best-first.",
251
+ "- If no strong match exists, return `should_comment: false`, "
252
+ '`classification: "no-match"`, `canonical_issue_number: null`, '
253
+ "and an empty candidate list.",
254
+ "- Be especially careful not to collapse broad meta, tracking, "
255
+ "feedback, or umbrella issues with specific bug reports unless "
256
+ "the new issue clearly belongs in that exact thread.",
257
+ ]
258
+ )
259
+
260
+
261
+ def start_conversation(
262
+ prompt: str, repository: str, issue_number: int
263
+ ) -> dict[str, Any]:
264
+ body = {
265
+ "title": f"Issue duplicate check #{issue_number}",
266
+ "selected_repository": repository,
267
+ "initial_message": {
268
+ "content": [
269
+ {
270
+ "type": "text",
271
+ "text": prompt,
272
+ }
273
+ ]
274
+ },
275
+ }
276
+ return request_json(
277
+ OPENHANDS_BASE_URL,
278
+ "/api/v1/app-conversations",
279
+ method="POST",
280
+ headers=openhands_headers(),
281
+ body=body,
282
+ )
283
+
284
+
285
+ def extract_first_item(payload: Any) -> dict[str, Any] | None:
286
+ if isinstance(payload, list):
287
+ first_item = payload[0] if payload else None
288
+ return first_item if isinstance(first_item, dict) else None
289
+ if not isinstance(payload, dict):
290
+ return None
291
+
292
+ items = payload.get("items")
293
+ if isinstance(items, list):
294
+ first_item = items[0] if items else None
295
+ return first_item if isinstance(first_item, dict) else None
296
+ return payload
297
+
298
+
299
+ def summarize_openhands_item(item: dict[str, Any]) -> str:
300
+ summary = {}
301
+ for key in OPENHANDS_DEBUG_KEYS:
302
+ if key not in item:
303
+ continue
304
+ value = item[key]
305
+ if value in (None, "", [], {}):
306
+ continue
307
+ summary[key] = value
308
+
309
+ available_keys = sorted(
310
+ key
311
+ for key in item
312
+ if key not in summary and key not in OPENHANDS_SENSITIVE_KEYS
313
+ )
314
+ if available_keys:
315
+ summary["available_keys"] = available_keys
316
+ sensitive_keys_present = sorted(
317
+ key for key in item if key in OPENHANDS_SENSITIVE_KEYS
318
+ )
319
+ if sensitive_keys_present:
320
+ summary["sensitive_keys_present"] = sensitive_keys_present
321
+ return json.dumps(summary or {"available_keys": sorted(item)}, ensure_ascii=False)
322
+
323
+
324
+ def poll_start_task(
325
+ start_task_id: str, poll_interval_seconds: int, max_wait_seconds: int
326
+ ) -> dict[str, Any]:
327
+ deadline = time.time() + max_wait_seconds
328
+ while time.time() < deadline:
329
+ payload = request_json(
330
+ OPENHANDS_BASE_URL,
331
+ f"/api/v1/app-conversations/start-tasks?ids={urllib.parse.quote(start_task_id)}",
332
+ headers={"Authorization": openhands_headers()["Authorization"]},
333
+ )
334
+ item = extract_first_item(payload)
335
+ if item is None:
336
+ time.sleep(poll_interval_seconds)
337
+ continue
338
+ status = str(item.get("status") or "").lower()
339
+ if status == "ready" and item.get("app_conversation_id"):
340
+ return item
341
+ if status in {"error", "failed"}:
342
+ raise RuntimeError(
343
+ f"OpenHands start task failed: {summarize_openhands_item(item)}"
344
+ )
345
+ time.sleep(poll_interval_seconds)
346
+ raise TimeoutError(
347
+ f"Timed out waiting for start task {start_task_id} to become ready"
348
+ )
349
+
350
+
351
+ def poll_conversation(
352
+ app_conversation_id: str, poll_interval_seconds: int, max_wait_seconds: int
353
+ ) -> dict[str, Any]:
354
+ deadline = time.time() + max_wait_seconds
355
+ while time.time() < deadline:
356
+ payload = request_json(
357
+ OPENHANDS_BASE_URL,
358
+ f"/api/v1/app-conversations?ids={app_conversation_id}",
359
+ headers={"Authorization": openhands_headers()["Authorization"]},
360
+ )
361
+ item = extract_first_item(payload)
362
+ if item is None:
363
+ time.sleep(poll_interval_seconds)
364
+ continue
365
+ execution_status = str(item.get("execution_status", "")).lower()
366
+ if execution_status in FAILED_EXECUTION_STATUSES:
367
+ raise RuntimeError(
368
+ "OpenHands conversation ended with "
369
+ f"{execution_status}: {summarize_openhands_item(item)}"
370
+ )
371
+ if execution_status in SUCCESSFUL_TERMINAL_EXECUTION_STATUSES:
372
+ return item
373
+ time.sleep(poll_interval_seconds)
374
+ raise TimeoutError(
375
+ f"Timed out waiting for conversation {app_conversation_id} to finish running"
376
+ )
377
+
378
+
379
+ def validate_event_search_results(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
380
+ if len(events) >= EVENT_SEARCH_LIMIT:
381
+ raise RuntimeError(EVENT_SEARCH_LIMIT_HIT_MESSAGE)
382
+ return events
383
+
384
+
385
+ def fetch_app_server_events(app_conversation_id: str) -> list[dict[str, Any]]:
386
+ payload = request_json(
387
+ OPENHANDS_BASE_URL,
388
+ f"/api/v1/conversation/{app_conversation_id}/events/search?limit={EVENT_SEARCH_LIMIT}",
389
+ headers={"Authorization": openhands_headers()["Authorization"]},
390
+ )
391
+ if isinstance(payload, dict):
392
+ items = payload.get("items")
393
+ return validate_event_search_results(items) if isinstance(items, list) else []
394
+ if isinstance(payload, list):
395
+ return validate_event_search_results(payload)
396
+ return []
397
+
398
+
399
+ def fetch_agent_server_events(
400
+ app_conversation_id: str, agent_server_url: str, session_api_key: str
401
+ ) -> list[dict[str, Any]]:
402
+ payload = request_json(
403
+ agent_server_url,
404
+ f"/api/conversations/{urllib.parse.quote(app_conversation_id)}/events/search?limit={EVENT_SEARCH_LIMIT}",
405
+ headers={"X-Session-API-Key": session_api_key},
406
+ )
407
+ if isinstance(payload, dict):
408
+ items = payload.get("items")
409
+ return validate_event_search_results(items) if isinstance(items, list) else []
410
+ if isinstance(payload, list):
411
+ return validate_event_search_results(payload)
412
+ return []
413
+
414
+
415
+ def fetch_agent_server_final_response(
416
+ app_conversation_id: str, agent_server_url: str, session_api_key: str
417
+ ) -> str:
418
+ payload = request_json(
419
+ agent_server_url,
420
+ f"/api/conversations/{urllib.parse.quote(app_conversation_id)}/agent_final_response",
421
+ headers={"X-Session-API-Key": session_api_key},
422
+ )
423
+ if not isinstance(payload, dict):
424
+ return ""
425
+ return str(payload.get("response") or "").strip()
426
+
427
+
428
+ def extract_agent_server_url(conversation_url: str) -> str | None:
429
+ parsed = urllib.parse.urlparse(conversation_url)
430
+ if not parsed.scheme or not parsed.netloc:
431
+ return None
432
+ return f"{parsed.scheme}://{parsed.netloc}"
433
+
434
+
435
+ def extract_last_agent_text(events: list[dict[str, Any]]) -> str:
436
+ agent_events = [
437
+ event
438
+ for event in events
439
+ if event.get("kind") == "MessageEvent" and event.get("source") == "agent"
440
+ ]
441
+ if not agent_events:
442
+ raise RuntimeError(
443
+ "No assistant text message was found in the conversation events"
444
+ )
445
+
446
+ llm_message = agent_events[-1].get("llm_message")
447
+ if not isinstance(llm_message, dict):
448
+ raise RuntimeError("Last agent message has no llm_message field")
449
+ content = llm_message.get("content")
450
+ if not isinstance(content, list):
451
+ raise RuntimeError("Last agent message content is not a list")
452
+
453
+ text_parts: list[str] = []
454
+ for part in content:
455
+ if not isinstance(part, dict):
456
+ continue
457
+ if part.get("type") == "text" and part.get("text"):
458
+ text_parts.append(str(part["text"]))
459
+ if not text_parts:
460
+ raise RuntimeError("Last agent message contains no text content")
461
+ return "".join(text_parts).strip()
462
+
463
+
464
+ def parse_agent_json(text: str) -> dict[str, Any]:
465
+ cleaned = text.strip()
466
+ try:
467
+ return json.loads(cleaned)
468
+ except json.JSONDecodeError:
469
+ decoder = json.JSONDecoder()
470
+ for start, character in enumerate(cleaned):
471
+ if character != "{":
472
+ continue
473
+ try:
474
+ candidate, end = decoder.raw_decode(cleaned[start:])
475
+ except json.JSONDecodeError:
476
+ continue
477
+ trailing = cleaned[start + end :].strip()
478
+ if trailing not in {"", "```"}:
479
+ continue
480
+ if isinstance(candidate, dict):
481
+ return candidate
482
+ raise ValueError("No valid JSON object found in the agent response")
483
+
484
+
485
+ def as_bool(value: Any) -> bool:
486
+ if isinstance(value, bool):
487
+ return value
488
+ if isinstance(value, str):
489
+ return value.strip().lower() in {"true", "1", "yes"}
490
+ if isinstance(value, (int, float)):
491
+ return bool(value)
492
+ return False
493
+
494
+
495
+ def normalize_result(result: dict[str, Any]) -> dict[str, Any]:
496
+ normalized = dict(result)
497
+ normalized["should_comment"] = as_bool(normalized.get("should_comment"))
498
+ normalized["is_duplicate"] = as_bool(normalized.get("is_duplicate"))
499
+ normalized["auto_close_candidate"] = as_bool(normalized.get("auto_close_candidate"))
500
+
501
+ classification = str(normalized.get("classification") or "no-match").strip().lower()
502
+ if classification not in {
503
+ "duplicate",
504
+ "overlapping-scope",
505
+ "related-but-distinct",
506
+ "no-match",
507
+ }:
508
+ classification = "no-match"
509
+ normalized["classification"] = classification
510
+
511
+ confidence = str(normalized.get("confidence") or "low").strip().lower()
512
+ if confidence not in {"high", "medium", "low"}:
513
+ confidence = "low"
514
+ normalized["confidence"] = confidence
515
+
516
+ try:
517
+ canonical_issue_number = normalized.get("canonical_issue_number")
518
+ if canonical_issue_number in {None, ""}:
519
+ normalized["canonical_issue_number"] = None
520
+ else:
521
+ normalized["canonical_issue_number"] = int(str(canonical_issue_number))
522
+ except (TypeError, ValueError):
523
+ normalized["canonical_issue_number"] = None
524
+
525
+ candidate_issues = normalized.get("candidate_issues")
526
+ if not isinstance(candidate_issues, list):
527
+ candidate_issues = []
528
+ normalized["candidate_issues"] = candidate_issues[:3]
529
+ if not normalized["candidate_issues"]:
530
+ normalized["should_comment"] = False
531
+
532
+ if classification not in {"duplicate", "overlapping-scope"}:
533
+ normalized["should_comment"] = False
534
+ if classification != "duplicate":
535
+ normalized["is_duplicate"] = False
536
+ normalized["auto_close_candidate"] = False
537
+ if normalized["should_comment"] and confidence not in {"high", "medium"}:
538
+ normalized["should_comment"] = False
539
+ if normalized["auto_close_candidate"] and not normalized["should_comment"]:
540
+ normalized["auto_close_candidate"] = False
541
+ if normalized["auto_close_candidate"] and confidence != "high":
542
+ normalized["auto_close_candidate"] = False
543
+ if normalized["auto_close_candidate"] and not normalized["candidate_issues"]:
544
+ normalized["auto_close_candidate"] = False
545
+ if (
546
+ normalized["auto_close_candidate"]
547
+ and normalized["canonical_issue_number"] is None
548
+ ):
549
+ first_candidate = (
550
+ normalized["candidate_issues"][0] if normalized["candidate_issues"] else {}
551
+ )
552
+ candidate_number = first_candidate.get("number")
553
+ try:
554
+ if candidate_number is None:
555
+ raise ValueError("candidate number is missing")
556
+ normalized["canonical_issue_number"] = int(str(candidate_number))
557
+ except (TypeError, ValueError, AttributeError):
558
+ normalized["auto_close_candidate"] = False
559
+
560
+ normalized["summary"] = str(normalized.get("summary") or "").strip()
561
+ return normalized
562
+
563
+
564
+ def main() -> int:
565
+ args = parse_args()
566
+ issue = fetch_issue(args.repository, args.issue_number)
567
+ if issue.get("pull_request"):
568
+ raise RuntimeError(f"#{args.issue_number} is a pull request, not an issue")
569
+
570
+ prompt = build_prompt(args.repository, issue)
571
+ start_task = start_conversation(prompt, args.repository, args.issue_number)
572
+ app_conversation_id = start_task.get("app_conversation_id")
573
+ conversation_url = ""
574
+
575
+ if not app_conversation_id:
576
+ task_id = start_task.get("id")
577
+ if not task_id:
578
+ raise RuntimeError(
579
+ "Missing id in start task response: "
580
+ f"{summarize_openhands_item(start_task)}"
581
+ )
582
+ ready_task = poll_start_task(
583
+ task_id,
584
+ args.poll_interval_seconds,
585
+ args.max_wait_seconds,
586
+ )
587
+ app_conversation_id = ready_task.get("app_conversation_id")
588
+ if not app_conversation_id:
589
+ raise RuntimeError(
590
+ "Missing app_conversation_id in response: "
591
+ f"{summarize_openhands_item(ready_task)}"
592
+ )
593
+
594
+ conversation = poll_conversation(
595
+ app_conversation_id,
596
+ args.poll_interval_seconds,
597
+ args.max_wait_seconds,
598
+ )
599
+ conversation_url = (
600
+ conversation.get("conversation_url")
601
+ or f"{OPENHANDS_BASE_URL}/conversations/{app_conversation_id}"
602
+ )
603
+ session_api_key_value = conversation.get("session_api_key")
604
+ if session_api_key_value and not isinstance(session_api_key_value, str):
605
+ raise RuntimeError(
606
+ "session_api_key had unexpected type in the OpenHands conversation: "
607
+ f"{type(session_api_key_value).__name__}"
608
+ )
609
+ session_api_key = session_api_key_value or ""
610
+ agent_server_url = extract_agent_server_url(conversation_url)
611
+
612
+ agent_text = ""
613
+ if agent_server_url and session_api_key:
614
+ try:
615
+ agent_text = fetch_agent_server_final_response(
616
+ app_conversation_id,
617
+ agent_server_url,
618
+ session_api_key,
619
+ )
620
+ except RuntimeError:
621
+ agent_text = ""
622
+ if not agent_text:
623
+ events = fetch_app_server_events(app_conversation_id)
624
+ try:
625
+ agent_text = extract_last_agent_text(events)
626
+ except RuntimeError as exc:
627
+ if not session_api_key:
628
+ raise RuntimeError(
629
+ "App server events did not contain assistant text and "
630
+ "session_api_key was missing from the OpenHands conversation"
631
+ ) from exc
632
+ if not agent_server_url:
633
+ raise RuntimeError(
634
+ "App server events did not contain assistant text and cannot "
635
+ "extract agent server URL from conversation URL: "
636
+ f"{conversation_url}"
637
+ ) from exc
638
+ events = fetch_agent_server_events(
639
+ app_conversation_id,
640
+ agent_server_url,
641
+ session_api_key,
642
+ )
643
+ agent_text = extract_last_agent_text(events)
644
+ result = normalize_result(parse_agent_json(agent_text))
645
+
646
+ result["issue_number"] = args.issue_number
647
+ result["repository"] = args.repository
648
+ result["app_conversation_id"] = app_conversation_id
649
+ result["conversation_url"] = conversation_url
650
+ result["agent_response"] = agent_text
651
+
652
+ output_path = Path(args.output)
653
+ try:
654
+ output_path.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
655
+ except OSError as exc:
656
+ raise RuntimeError(f"Failed to write output to {output_path}: {exc}") from exc
657
+
658
+ print(
659
+ json.dumps(
660
+ {
661
+ "issue_number": result.get("issue_number"),
662
+ "should_comment": result.get("should_comment"),
663
+ "is_duplicate": result.get("is_duplicate"),
664
+ "auto_close_candidate": result.get("auto_close_candidate"),
665
+ "classification": result.get("classification"),
666
+ "confidence": result.get("confidence"),
667
+ "conversation_url": result.get("conversation_url"),
668
+ "output": str(output_path),
669
+ },
670
+ ensure_ascii=False,
671
+ )
672
+ )
673
+ return 0
674
+
675
+
676
+ if __name__ == "__main__":
677
+ try:
678
+ raise SystemExit(main())
679
+ except Exception as exc: # noqa: BLE001
680
+ print(f"error: {exc}", file=sys.stderr)
681
+ raise