@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,569 @@
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 urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from datetime import UTC, datetime, timedelta
13
+ from typing import Any
14
+
15
+
16
+ GITHUB_API_BASE_URL = os.environ.get("GITHUB_API_BASE_URL", "https://api.github.com")
17
+ MAX_PAGES = 100
18
+ DUPLICATE_CANDIDATE_LABEL = "duplicate-candidate"
19
+ DUPLICATE_VETO_MARKER = "<!-- openhands-duplicate-veto -->"
20
+ AUTOMATION_BOT_LOGINS = {"all-hands-bot"}
21
+ REPOSITORY_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$")
22
+ DUPLICATE_MARKER_RE = re.compile(
23
+ r"<!-- openhands-duplicate-check canonical=(?P<canonical>\d+) "
24
+ r"auto-close=(?P<auto_close>true|false) -->"
25
+ )
26
+
27
+
28
+ class HTTPError(RuntimeError):
29
+ def __init__(self, method: str, path: str, status_code: int, body: str) -> None:
30
+ self.method = method
31
+ self.path = path
32
+ self.status_code = status_code
33
+ self.body = body
34
+ super().__init__(f"{method} {path} failed with HTTP {status_code}: {body}")
35
+
36
+
37
+ def parse_args() -> argparse.Namespace:
38
+ parser = argparse.ArgumentParser(
39
+ description="Auto-close issues previously flagged as duplicate candidates."
40
+ )
41
+ parser.add_argument("--repository", required=True)
42
+ parser.add_argument("--close-after-days", type=int, default=3)
43
+ parser.add_argument("--dry-run", action="store_true")
44
+ args = parser.parse_args()
45
+ if not REPOSITORY_PATTERN.fullmatch(args.repository):
46
+ parser.error(f"Invalid repository format: {args.repository}")
47
+ return args
48
+
49
+
50
+ def github_headers() -> dict[str, str]:
51
+ token = os.environ.get("GITHUB_TOKEN")
52
+ if not token:
53
+ raise RuntimeError("GITHUB_TOKEN environment variable is required")
54
+ return {
55
+ "Authorization": f"Bearer {token}",
56
+ "Accept": "application/vnd.github+json",
57
+ "User-Agent": "openhands-duplicate-auto-close",
58
+ "X-GitHub-Api-Version": "2022-11-28",
59
+ }
60
+
61
+
62
+ def request_json(
63
+ path: str,
64
+ *,
65
+ method: str = "GET",
66
+ body: dict[str, Any] | None = None,
67
+ ) -> Any:
68
+ request_body = None
69
+ headers = github_headers()
70
+ if body is not None:
71
+ request_body = json.dumps(body).encode("utf-8")
72
+ headers["Content-Type"] = "application/json"
73
+
74
+ request = urllib.request.Request(
75
+ f"{GITHUB_API_BASE_URL}{path}",
76
+ data=request_body,
77
+ headers=headers,
78
+ method=method,
79
+ )
80
+ try:
81
+ with urllib.request.urlopen(request, timeout=60) as response:
82
+ payload = response.read().decode("utf-8")
83
+ except urllib.error.HTTPError as exc:
84
+ error_body = exc.read().decode("utf-8", errors="replace")
85
+ raise HTTPError(method, path, exc.code, error_body) from exc
86
+ except urllib.error.URLError as exc:
87
+ raise RuntimeError(f"{method} {path} failed: {exc}") from exc
88
+
89
+ if not payload:
90
+ return None
91
+ try:
92
+ return json.loads(payload)
93
+ except json.JSONDecodeError as exc:
94
+ raise RuntimeError(f"Failed to parse JSON from {path}: {exc}") from exc
95
+
96
+
97
+ def parse_timestamp(value: str) -> datetime:
98
+ try:
99
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
100
+ except ValueError as exc:
101
+ raise ValueError(f"Failed to parse timestamp {value!r}: {exc}") from exc
102
+
103
+
104
+ def ensure_page_limit(page: int, resource_name: str) -> None:
105
+ if page > MAX_PAGES:
106
+ raise RuntimeError(f"Exceeded pagination limit while listing {resource_name}")
107
+
108
+
109
+ def list_open_issues(repository: str) -> list[dict[str, Any]]:
110
+ issues: list[dict[str, Any]] = []
111
+ page = 1
112
+ label_query = urllib.parse.quote(DUPLICATE_CANDIDATE_LABEL)
113
+ while True:
114
+ ensure_page_limit(page, f"open issues for {repository}")
115
+ payload = request_json(
116
+ f"/repos/{repository}/issues?state=open&labels={label_query}&per_page=100&page={page}"
117
+ )
118
+ if not isinstance(payload, list):
119
+ raise RuntimeError(
120
+ f"Expected list response while listing open issues for {repository}, "
121
+ f"got {type(payload).__name__}"
122
+ )
123
+ if not payload:
124
+ return issues
125
+ for issue in payload:
126
+ if issue.get("pull_request"):
127
+ continue
128
+ issues.append(issue)
129
+ page += 1
130
+
131
+
132
+ def list_issue_comments(repository: str, issue_number: int) -> list[dict[str, Any]]:
133
+ comments: list[dict[str, Any]] = []
134
+ page = 1
135
+ while True:
136
+ ensure_page_limit(page, f"comments for issue #{issue_number}")
137
+ payload = request_json(
138
+ f"/repos/{repository}/issues/{issue_number}/comments?per_page=100&page={page}"
139
+ )
140
+ if not isinstance(payload, list):
141
+ raise RuntimeError(
142
+ "Expected list response while listing comments for issue "
143
+ f"#{issue_number}, got {type(payload).__name__}"
144
+ )
145
+ if not payload:
146
+ return comments
147
+ comments.extend(payload)
148
+ page += 1
149
+
150
+
151
+ def list_comment_reactions(repository: str, comment_id: int) -> list[dict[str, Any]]:
152
+ reactions: list[dict[str, Any]] = []
153
+ page = 1
154
+ while True:
155
+ ensure_page_limit(page, f"reactions for comment {comment_id}")
156
+ payload = request_json(
157
+ f"/repos/{repository}/issues/comments/{comment_id}/reactions?per_page=100&page={page}"
158
+ )
159
+ if not isinstance(payload, list):
160
+ raise RuntimeError(
161
+ "Expected list response while listing reactions for comment "
162
+ f"{comment_id}, got {type(payload).__name__}"
163
+ )
164
+ if not payload:
165
+ return reactions
166
+ reactions.extend(payload)
167
+ page += 1
168
+
169
+
170
+ def extract_duplicate_metadata(comment_body: str) -> tuple[int | None, bool]:
171
+ match = DUPLICATE_MARKER_RE.search(comment_body)
172
+ if not match:
173
+ return None, False
174
+ return int(match.group("canonical")), match.group("auto_close") == "true"
175
+
176
+
177
+ def find_latest_auto_close_comment(
178
+ comments: list[dict[str, Any]],
179
+ ) -> tuple[dict[str, Any] | None, int | None]:
180
+ latest_comment: dict[str, Any] | None = None
181
+ latest_canonical_issue: int | None = None
182
+ latest_created_at: str | None = None
183
+ for comment in comments:
184
+ canonical_issue, auto_close = extract_duplicate_metadata(
185
+ comment.get("body") or ""
186
+ )
187
+ if canonical_issue is None or not auto_close:
188
+ continue
189
+ comment_created_at = comment.get("created_at")
190
+ if not isinstance(comment_created_at, str):
191
+ comment_created_at = None
192
+ if latest_comment is None:
193
+ latest_comment = comment
194
+ latest_canonical_issue = canonical_issue
195
+ latest_created_at = comment_created_at
196
+ continue
197
+ if comment_created_at is None:
198
+ continue
199
+ if latest_created_at is not None:
200
+ try:
201
+ if parse_timestamp(comment_created_at) < parse_timestamp(
202
+ latest_created_at
203
+ ):
204
+ continue
205
+ except ValueError:
206
+ continue
207
+ latest_comment = comment
208
+ latest_canonical_issue = canonical_issue
209
+ latest_created_at = comment_created_at
210
+ return latest_comment, latest_canonical_issue
211
+
212
+
213
+ def issue_has_label(issue: dict[str, Any], label_name: str) -> bool:
214
+ labels = issue.get("labels") or []
215
+ for label in labels:
216
+ if label == label_name:
217
+ return True
218
+ if isinstance(label, dict) and label.get("name") == label_name:
219
+ return True
220
+ return False
221
+
222
+
223
+ def user_id_from_item(item: dict[str, Any]) -> int | None:
224
+ user = item.get("user")
225
+ if not isinstance(user, dict):
226
+ return None
227
+ user_id = user.get("id")
228
+ return user_id if isinstance(user_id, int) else None
229
+
230
+
231
+ def has_reaction_from_user(
232
+ reactions: list[dict[str, Any]], user_id: int | None, content: str
233
+ ) -> bool:
234
+ if user_id is None:
235
+ return False
236
+ return any(
237
+ user_id_from_item(reaction) == user_id and reaction.get("content") == content
238
+ for reaction in reactions
239
+ )
240
+
241
+
242
+ def has_veto_note(comments: list[dict[str, Any]]) -> bool:
243
+ return any(
244
+ DUPLICATE_VETO_MARKER in (comment.get("body") or "") for comment in comments
245
+ )
246
+
247
+
248
+ def is_non_bot_comment(comment: dict[str, Any]) -> bool:
249
+ if user_id_from_item(comment) is None:
250
+ return False
251
+ user = comment.get("user")
252
+ if not isinstance(user, dict):
253
+ return False
254
+ login = user.get("login")
255
+ if not isinstance(login, str):
256
+ return False
257
+ login = login.lower()
258
+ return (
259
+ user.get("type") != "Bot"
260
+ and not login.endswith("[bot]")
261
+ and login not in AUTOMATION_BOT_LOGINS
262
+ )
263
+
264
+
265
+ def remove_candidate_label(
266
+ repository: str, issue_number: int, *, dry_run: bool
267
+ ) -> bool:
268
+ if dry_run:
269
+ return True
270
+ try:
271
+ request_json(
272
+ f"/repos/{repository}/issues/{issue_number}/labels/{DUPLICATE_CANDIDATE_LABEL}",
273
+ method="DELETE",
274
+ )
275
+ except HTTPError as exc:
276
+ if exc.status_code == 404:
277
+ return False
278
+ raise
279
+ return True
280
+
281
+
282
+ def post_veto_note(repository: str, issue_number: int, *, dry_run: bool) -> bool:
283
+ if dry_run:
284
+ return True
285
+ request_json(
286
+ f"/repos/{repository}/issues/{issue_number}/comments",
287
+ method="POST",
288
+ body={
289
+ "body": (
290
+ "Thanks — leaving this open and removing the "
291
+ f"{DUPLICATE_CANDIDATE_LABEL} label.\n\n"
292
+ f"{DUPLICATE_VETO_MARKER}\n"
293
+ "_This comment was created by an AI assistant "
294
+ "(OpenHands) on behalf of the repository maintainer._"
295
+ )
296
+ },
297
+ )
298
+ return True
299
+
300
+
301
+ def post_newer_activity_note(repository: str, issue_number: int, *, dry_run: bool) -> bool:
302
+ if dry_run:
303
+ return True
304
+ request_json(
305
+ f"/repos/{repository}/issues/{issue_number}/comments",
306
+ method="POST",
307
+ body={
308
+ "body": (
309
+ "Further activity was detected after the duplicate notice, so "
310
+ "this issue is being left open and the "
311
+ f"{DUPLICATE_CANDIDATE_LABEL} label is being removed.\n\n"
312
+ "_This comment was created by an AI assistant "
313
+ "(OpenHands) on behalf of the repository maintainer._"
314
+ )
315
+ },
316
+ )
317
+ return True
318
+
319
+
320
+ def fetch_issue(repository: str, issue_number: int) -> dict[str, Any] | None:
321
+ try:
322
+ payload = request_json(f"/repos/{repository}/issues/{issue_number}")
323
+ except HTTPError as exc:
324
+ if exc.status_code == 404:
325
+ return None
326
+ raise
327
+ if not isinstance(payload, dict):
328
+ raise RuntimeError(
329
+ f"Expected issue object while fetching #{issue_number}, "
330
+ f"got {type(payload).__name__}"
331
+ )
332
+ return payload
333
+
334
+
335
+ def is_open_canonical_issue(repository: str, issue_number: int) -> bool:
336
+ issue = fetch_issue(repository, issue_number)
337
+ if issue is None or issue.get("pull_request"):
338
+ return False
339
+ return issue.get("state") == "open" and not issue.get("locked")
340
+
341
+
342
+ def close_issue_as_duplicate(
343
+ repository: str,
344
+ issue_number: int,
345
+ canonical_issue_number: int,
346
+ *,
347
+ dry_run: bool,
348
+ ) -> None:
349
+ if dry_run:
350
+ return
351
+
352
+ request_json(
353
+ f"/repos/{repository}/issues/{issue_number}/comments",
354
+ method="POST",
355
+ body={
356
+ "body": (
357
+ "This issue is being closed as a duplicate of "
358
+ f"#{canonical_issue_number}.\n\n"
359
+ "If this is incorrect, please add a comment and it can be "
360
+ "reopened.\n\n"
361
+ "_This comment was created by an AI assistant "
362
+ "(OpenHands) on behalf of the repository maintainer._"
363
+ )
364
+ },
365
+ )
366
+ request_json(
367
+ f"/repos/{repository}/issues/{issue_number}",
368
+ method="PATCH",
369
+ body={"state": "closed", "state_reason": "duplicate"},
370
+ )
371
+ remove_candidate_label(repository, issue_number, dry_run=dry_run)
372
+
373
+
374
+ def keep_open_due_to_newer_comments(
375
+ repository: str,
376
+ issue: dict[str, Any],
377
+ issue_number: int,
378
+ *,
379
+ dry_run: bool,
380
+ ) -> dict[str, Any]:
381
+ label_removed = False
382
+ if issue_has_label(issue, DUPLICATE_CANDIDATE_LABEL):
383
+ label_removed = remove_candidate_label(
384
+ repository,
385
+ issue_number,
386
+ dry_run=dry_run,
387
+ )
388
+ activity_note_posted = post_newer_activity_note(
389
+ repository,
390
+ issue_number,
391
+ dry_run=dry_run,
392
+ )
393
+ return {
394
+ "issue_number": issue_number,
395
+ "action": "kept-open",
396
+ "reason": "newer-comment-after-duplicate-notice",
397
+ "label_removed": label_removed,
398
+ "activity_note_posted": activity_note_posted,
399
+ }
400
+
401
+
402
+ def main() -> int:
403
+ args = parse_args()
404
+ now = datetime.now(UTC)
405
+ cutoff = now - timedelta(days=args.close_after_days)
406
+
407
+ summary: list[dict[str, Any]] = []
408
+ for issue in list_open_issues(args.repository):
409
+ issue_number = issue.get("number")
410
+ if issue_number is None:
411
+ continue
412
+ try:
413
+ issue_number = int(issue_number)
414
+ except (TypeError, ValueError):
415
+ continue
416
+
417
+ try:
418
+ comments = list_issue_comments(args.repository, issue_number)
419
+ latest_comment, canonical_issue_number = find_latest_auto_close_comment(
420
+ comments
421
+ )
422
+ if latest_comment is None or canonical_issue_number is None:
423
+ continue
424
+
425
+ comment_created_at_str = latest_comment.get("created_at")
426
+ comment_id = latest_comment.get("id")
427
+ if not comment_created_at_str or comment_id is None:
428
+ continue
429
+ try:
430
+ comment_id = int(comment_id)
431
+ except (TypeError, ValueError):
432
+ continue
433
+ try:
434
+ comment_created_at = parse_timestamp(comment_created_at_str)
435
+ except ValueError as exc:
436
+ print(
437
+ "Warning: Skipping issue "
438
+ f"#{issue_number} due to invalid duplicate-comment timestamp: "
439
+ f"{exc}",
440
+ file=sys.stderr,
441
+ )
442
+ continue
443
+ if comment_created_at > cutoff:
444
+ continue
445
+
446
+ author_id = user_id_from_item(issue)
447
+ reactions = list_comment_reactions(args.repository, comment_id)
448
+ author_thumbs_down = has_reaction_from_user(reactions, author_id, "-1")
449
+ author_thumbs_up = has_reaction_from_user(reactions, author_id, "+1")
450
+ if author_thumbs_down:
451
+ label_removed = False
452
+ if issue_has_label(issue, DUPLICATE_CANDIDATE_LABEL):
453
+ label_removed = remove_candidate_label(
454
+ args.repository,
455
+ issue_number,
456
+ dry_run=args.dry_run,
457
+ )
458
+ veto_note_posted = False
459
+ if not has_veto_note(comments):
460
+ veto_note_posted = post_veto_note(
461
+ args.repository,
462
+ issue_number,
463
+ dry_run=args.dry_run,
464
+ )
465
+ summary.append(
466
+ {
467
+ "issue_number": issue_number,
468
+ "action": "kept-open",
469
+ "reason": "author-thumbed-down-duplicate-comment",
470
+ "label_removed": label_removed,
471
+ "veto_note_posted": veto_note_posted,
472
+ "author_thumbs_up": author_thumbs_up,
473
+ }
474
+ )
475
+ continue
476
+
477
+ newer_comments = []
478
+ for comment in comments:
479
+ created_at = comment.get("created_at")
480
+ if not created_at or not is_non_bot_comment(comment):
481
+ continue
482
+ try:
483
+ newer_comment_created_at = parse_timestamp(created_at)
484
+ except ValueError as exc:
485
+ print(
486
+ "Warning: Ignoring newer comment with invalid timestamp on "
487
+ f"issue #{issue_number}: {exc}",
488
+ file=sys.stderr,
489
+ )
490
+ continue
491
+ if newer_comment_created_at > comment_created_at:
492
+ newer_comments.append(comment)
493
+ if newer_comments:
494
+ summary.append(
495
+ keep_open_due_to_newer_comments(
496
+ args.repository,
497
+ issue,
498
+ issue_number,
499
+ dry_run=args.dry_run,
500
+ )
501
+ )
502
+ continue
503
+
504
+ if canonical_issue_number == issue_number:
505
+ summary.append(
506
+ {
507
+ "issue_number": issue_number,
508
+ "action": "kept-open",
509
+ "reason": "self-duplicate-marker",
510
+ "canonical_issue_number": canonical_issue_number,
511
+ }
512
+ )
513
+ continue
514
+
515
+ if not is_open_canonical_issue(args.repository, canonical_issue_number):
516
+ label_removed = False
517
+ if issue_has_label(issue, DUPLICATE_CANDIDATE_LABEL):
518
+ label_removed = remove_candidate_label(
519
+ args.repository,
520
+ issue_number,
521
+ dry_run=args.dry_run,
522
+ )
523
+ summary.append(
524
+ {
525
+ "issue_number": issue_number,
526
+ "action": "kept-open",
527
+ "reason": "canonical-issue-not-open",
528
+ "canonical_issue_number": canonical_issue_number,
529
+ "label_removed": label_removed,
530
+ }
531
+ )
532
+ continue
533
+
534
+ close_issue_as_duplicate(
535
+ args.repository,
536
+ issue_number,
537
+ canonical_issue_number,
538
+ dry_run=args.dry_run,
539
+ )
540
+ summary.append(
541
+ {
542
+ "issue_number": issue_number,
543
+ "action": "closed-as-duplicate"
544
+ if not args.dry_run
545
+ else "would-close-as-duplicate",
546
+ "canonical_issue_number": canonical_issue_number,
547
+ "author_thumbs_up": author_thumbs_up,
548
+ }
549
+ )
550
+ except RuntimeError as exc:
551
+ print(f"Error processing issue #{issue_number}: {exc}", file=sys.stderr)
552
+ summary.append(
553
+ {
554
+ "issue_number": issue_number,
555
+ "action": "failed",
556
+ "error": str(exc),
557
+ }
558
+ )
559
+
560
+ print(json.dumps({"repository": args.repository, "results": summary}, indent=2))
561
+ return 0
562
+
563
+
564
+ if __name__ == "__main__":
565
+ try:
566
+ raise SystemExit(main())
567
+ except Exception as exc: # noqa: BLE001
568
+ print(f"error: {exc}", file=sys.stderr)
569
+ raise