@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,990 @@
1
+ """Tests for the release notes generator plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ # Add the plugin scripts directory to the path
12
+ plugin_dir = Path(__file__).parent.parent / "plugins" / "release-notes" / "scripts"
13
+ sys.path.insert(0, str(plugin_dir))
14
+
15
+ from generate_release_notes import (
16
+ CATEGORIES,
17
+ Change,
18
+ Contributor,
19
+ ReleaseNotes,
20
+ _dedupe_changes,
21
+ _process_commit,
22
+ _process_contributors,
23
+ categorize_change,
24
+ get_commits_between_tags,
25
+ is_new_contributor,
26
+ )
27
+ from prompt import format_prompt
28
+ from validate_release_notes import (
29
+ ReleaseNotesValidationError,
30
+ append_reference_coverage_appendix,
31
+ format_coverage_summary,
32
+ missing_references,
33
+ validate_release_notes_markdown,
34
+ )
35
+
36
+
37
+ class TestChange:
38
+ """Tests for the Change dataclass."""
39
+
40
+ def test_to_markdown_basic(self):
41
+ """Test basic markdown formatting."""
42
+ change = Change(
43
+ message="Add new feature",
44
+ sha="abc123",
45
+ author="testuser",
46
+ )
47
+ result = change.to_markdown("owner/repo")
48
+ assert result == "- Add new feature @testuser"
49
+
50
+ def test_to_markdown_with_pr(self):
51
+ """Test markdown formatting with PR number."""
52
+ change = Change(
53
+ message="Resolve memory leak",
54
+ sha="abc123",
55
+ author="testuser",
56
+ pr_number=42,
57
+ )
58
+ result = change.to_markdown("owner/repo")
59
+ assert result == "- Resolve memory leak (#42) @testuser"
60
+
61
+ def test_to_markdown_strips_conventional_commit_prefix(self):
62
+ """Test that conventional commit prefixes are stripped."""
63
+ change = Change(
64
+ message="feat: Add new feature",
65
+ sha="abc123",
66
+ author="testuser",
67
+ pr_number=42,
68
+ )
69
+ result = change.to_markdown("owner/repo")
70
+ assert "feat:" not in result
71
+ assert "Add new feature" in result
72
+
73
+ def test_to_markdown_strips_scoped_prefix(self):
74
+ """Test that scoped conventional commit prefixes are stripped."""
75
+ change = Change(
76
+ message="fix(api): Resolve timeout issue",
77
+ sha="abc123",
78
+ author="testuser",
79
+ )
80
+ result = change.to_markdown("owner/repo")
81
+ assert "fix(api):" not in result
82
+ assert "Resolve timeout issue" in result
83
+
84
+ def test_to_markdown_capitalizes_first_letter(self):
85
+ """Test that the first letter is capitalized."""
86
+ change = Change(
87
+ message="fix: lower case message",
88
+ sha="abc123",
89
+ author="testuser",
90
+ )
91
+ result = change.to_markdown("owner/repo")
92
+ assert "Lower case message" in result
93
+
94
+
95
+ class TestCategorizeChange:
96
+ """Tests for the categorize_change function."""
97
+
98
+ def test_categorize_feat_prefix(self):
99
+ """Test categorization of feat: prefix."""
100
+ change = Change(message="feat: Add new API", sha="abc", author="user")
101
+ assert categorize_change(change) == "features"
102
+
103
+ def test_categorize_feature_prefix(self):
104
+ """Test categorization of feature: prefix."""
105
+ change = Change(message="feature: Add new API", sha="abc", author="user")
106
+ assert categorize_change(change) == "features"
107
+
108
+ def test_categorize_fix_prefix(self):
109
+ """Test categorization of fix: prefix."""
110
+ change = Change(message="fix: Resolve crash", sha="abc", author="user")
111
+ assert categorize_change(change) == "fixes"
112
+
113
+ def test_categorize_docs_prefix(self):
114
+ """Test categorization of docs: prefix."""
115
+ change = Change(message="docs: Update README", sha="abc", author="user")
116
+ assert categorize_change(change) == "docs"
117
+
118
+ def test_categorize_chore_prefix(self):
119
+ """Test categorization of chore: prefix."""
120
+ change = Change(message="chore: Update dependencies", sha="abc", author="user")
121
+ assert categorize_change(change) == "internal"
122
+
123
+ def test_categorize_ci_prefix(self):
124
+ """Test categorization of ci: prefix."""
125
+ change = Change(message="ci: Add GitHub Actions", sha="abc", author="user")
126
+ assert categorize_change(change) == "internal"
127
+
128
+ def test_categorize_breaking_prefix(self):
129
+ """Test categorization of BREAKING: prefix."""
130
+ change = Change(message="BREAKING: Remove deprecated API", sha="abc", author="user")
131
+ assert categorize_change(change) == "breaking"
132
+
133
+ def test_categorize_by_label_enhancement(self):
134
+ """Test categorization by enhancement label."""
135
+ change = Change(
136
+ message="Add feature",
137
+ sha="abc",
138
+ author="user",
139
+ pr_labels=["enhancement"],
140
+ )
141
+ assert categorize_change(change) == "features"
142
+
143
+ def test_categorize_by_label_bug(self):
144
+ """Test categorization by bug label."""
145
+ change = Change(
146
+ message="Fix something",
147
+ sha="abc",
148
+ author="user",
149
+ pr_labels=["bug"],
150
+ )
151
+ assert categorize_change(change) == "fixes"
152
+
153
+ def test_categorize_by_label_breaking_change(self):
154
+ """Test categorization by breaking-change label."""
155
+ change = Change(
156
+ message="Change API",
157
+ sha="abc",
158
+ author="user",
159
+ pr_labels=["breaking-change"],
160
+ )
161
+ assert categorize_change(change) == "breaking"
162
+
163
+ def test_categorize_uncategorized(self):
164
+ """Test uncategorized changes fall to other."""
165
+ change = Change(message="Random change", sha="abc", author="user")
166
+ assert categorize_change(change) == "other"
167
+
168
+ def test_commit_prefix_takes_precedence_over_label(self):
169
+ """Test that commit prefix categorization takes precedence."""
170
+ change = Change(
171
+ message="feat: Add feature",
172
+ sha="abc",
173
+ author="user",
174
+ pr_labels=["bug"], # Conflicting label
175
+ )
176
+ # Should be categorized as feature based on prefix
177
+ assert categorize_change(change) == "features"
178
+
179
+ def test_keyword_categorizes_docs(self):
180
+ """Test docs keyword fallback categorization."""
181
+ change = Change(
182
+ message="Update documentation with new examples",
183
+ sha="abc",
184
+ author="user",
185
+ )
186
+ assert categorize_change(change) == "docs"
187
+
188
+ def test_keyword_categorizes_internal_before_feature(self):
189
+ """Test internal keyword fallback takes precedence over generic feature verbs."""
190
+ change = Change(
191
+ message="Add extensive typing to controller directory",
192
+ sha="abc",
193
+ author="user",
194
+ )
195
+ assert categorize_change(change) == "internal"
196
+
197
+ def test_keyword_categorizes_fixes(self):
198
+ """Test fix keyword fallback categorization."""
199
+ change = Change(
200
+ message="Resolve timeout error in session reconnect",
201
+ sha="abc",
202
+ author="user",
203
+ )
204
+ assert categorize_change(change) == "fixes"
205
+
206
+ def test_keyword_categorizes_features(self):
207
+ """Test feature keyword fallback categorization."""
208
+ change = Change(
209
+ message="Add VS Code tab alongside the terminal",
210
+ sha="abc",
211
+ author="user",
212
+ )
213
+ assert categorize_change(change) == "features"
214
+
215
+
216
+ class TestReleaseNotes:
217
+ """Tests for the ReleaseNotes dataclass."""
218
+
219
+ def test_to_markdown_basic(self):
220
+ """Test basic release notes generation."""
221
+ notes = ReleaseNotes(
222
+ tag="v1.0.0",
223
+ previous_tag="v0.9.0",
224
+ date="2026-03-06",
225
+ repo_name="owner/repo",
226
+ changes={
227
+ "features": [
228
+ Change(message="Add feature", sha="abc", author="user1", pr_number=1)
229
+ ],
230
+ "fixes": [
231
+ Change(message="Fix bug", sha="def", author="user2", pr_number=2)
232
+ ],
233
+ },
234
+ )
235
+ markdown = notes.to_markdown()
236
+
237
+ assert "## [v1.0.0] - 2026-03-06" in markdown
238
+ assert "### ✨ New Features" in markdown
239
+ assert "### 🐛 Bug Fixes" in markdown
240
+ assert "Add feature (#1) @user1" in markdown
241
+ assert "(#2) @user2" in markdown # Fix bug becomes Bug due to prefix stripping
242
+ assert "compare/v0.9.0...v1.0.0" in markdown
243
+
244
+ def test_to_markdown_with_breaking_changes(self):
245
+ """Test release notes with breaking changes."""
246
+ notes = ReleaseNotes(
247
+ tag="v2.0.0",
248
+ previous_tag="v1.0.0",
249
+ date="2026-03-06",
250
+ repo_name="owner/repo",
251
+ changes={
252
+ "breaking": [
253
+ Change(message="Remove API", sha="abc", author="user", pr_number=1)
254
+ ],
255
+ },
256
+ )
257
+ markdown = notes.to_markdown()
258
+
259
+ assert "### ⚠️ Breaking Changes" in markdown
260
+ assert "Remove API" in markdown
261
+
262
+ def test_to_markdown_with_new_contributors(self):
263
+ """Test release notes with new contributors."""
264
+ notes = ReleaseNotes(
265
+ tag="v1.0.0",
266
+ previous_tag="v0.9.0",
267
+ date="2026-03-06",
268
+ repo_name="owner/repo",
269
+ changes={},
270
+ new_contributors=[
271
+ Contributor(username="newuser", first_pr=42, is_new=True),
272
+ ],
273
+ )
274
+ markdown = notes.to_markdown()
275
+
276
+ assert "### 👥 New Contributors" in markdown
277
+ assert "@newuser made their first contribution in #42" in markdown
278
+
279
+ def test_to_markdown_internal_excluded_by_default(self):
280
+ """Test that internal changes are excluded by default."""
281
+ notes = ReleaseNotes(
282
+ tag="v1.0.0",
283
+ previous_tag="v0.9.0",
284
+ date="2026-03-06",
285
+ repo_name="owner/repo",
286
+ changes={
287
+ "internal": [
288
+ Change(message="Update CI", sha="abc", author="user", pr_number=1)
289
+ ],
290
+ },
291
+ )
292
+ markdown = notes.to_markdown(include_internal=False)
293
+
294
+ assert "Internal" not in markdown
295
+
296
+ def test_to_markdown_internal_included_when_requested(self):
297
+ """Test that internal changes are included when requested."""
298
+ notes = ReleaseNotes(
299
+ tag="v1.0.0",
300
+ previous_tag="v0.9.0",
301
+ date="2026-03-06",
302
+ repo_name="owner/repo",
303
+ changes={
304
+ "internal": [
305
+ Change(message="Update CI", sha="abc", author="user", pr_number=1)
306
+ ],
307
+ },
308
+ )
309
+ markdown = notes.to_markdown(include_internal=True)
310
+
311
+ assert "### 🏗️ Internal/Infrastructure" in markdown
312
+ assert "Update CI" in markdown
313
+
314
+ def test_to_markdown_omits_other_changes(self):
315
+ """Test that uncategorized changes are omitted for a more concise summary."""
316
+ notes = ReleaseNotes(
317
+ tag="v1.0.0",
318
+ previous_tag="v0.9.0",
319
+ date="2026-03-06",
320
+ repo_name="owner/repo",
321
+ changes={
322
+ "other": [
323
+ Change(message="Random internal cleanup", sha="abc", author="user")
324
+ ],
325
+ },
326
+ )
327
+ markdown = notes.to_markdown()
328
+
329
+ assert "Other Changes" not in markdown
330
+ assert "Random internal cleanup" not in markdown
331
+
332
+
333
+ class TestProcessingHelpers:
334
+ """Tests for processing helpers."""
335
+
336
+ def test_dedupe_changes_collapses_multiple_commits_from_same_pr(self):
337
+ """Test that only one entry is kept per PR."""
338
+ changes = [
339
+ Change(message="First commit", sha="abc", author="user", pr_number=10),
340
+ Change(message="Second commit", sha="def", author="user", pr_number=10),
341
+ Change(message="Standalone commit", sha="ghi", author="user"),
342
+ ]
343
+
344
+ deduped = _dedupe_changes(changes)
345
+
346
+ assert [change.pr_number for change in deduped] == [10, None]
347
+ assert [change.sha for change in deduped] == ["abc", "ghi"]
348
+
349
+ @patch("generate_release_notes.get_pr_for_commit")
350
+ def test_process_commit_prefers_pr_title_and_author(self, mock_get_pr_for_commit):
351
+ """Test that PR metadata is preferred for user-facing release note entries."""
352
+ mock_get_pr_for_commit.return_value = {
353
+ "number": 42,
354
+ "title": "Add settings page",
355
+ "body": "Adds a new settings page for managing preferences.",
356
+ "html_url": "https://github.com/owner/repo/pull/42",
357
+ "labels": [{"name": "enhancement"}],
358
+ "user": {"login": "pr-author", "type": "User"},
359
+ "created_at": "2026-03-05T12:00:00Z",
360
+ "merged_at": "2026-03-06T09:30:00Z",
361
+ }
362
+ commit = {
363
+ "sha": "abc123",
364
+ "commit": {"message": "feat: Low-level implementation detail\n\nMore text"},
365
+ "author": {"login": "commit-author"},
366
+ }
367
+
368
+ change = _process_commit(commit, "owner/repo", "token")
369
+
370
+ assert change is not None
371
+ assert change.message == "Add settings page"
372
+ assert change.author == "pr-author"
373
+ assert change.pr_number == 42
374
+ assert change.pr_labels == ["enhancement"]
375
+ assert change.body == "Adds a new settings page for managing preferences."
376
+ assert change.url == "https://github.com/owner/repo/pull/42"
377
+ assert change.author_type == "User"
378
+ assert change.pr_created_at == "2026-03-05T12:00:00Z"
379
+ assert change.pr_merged_at == "2026-03-06T09:30:00Z"
380
+
381
+ @patch("generate_release_notes.github_api_request")
382
+ def test_get_commits_between_tags_warns_on_truncation(self, mock_github_api_request, capsys):
383
+ """Test that compare API truncation is surfaced to users."""
384
+ mock_github_api_request.return_value = {
385
+ "total_commits": 300,
386
+ "commits": [{"sha": "abc"}],
387
+ }
388
+
389
+ commits = get_commits_between_tags("owner/repo", "v1.0.0", "v1.1.0", "token")
390
+
391
+ assert commits == [{"sha": "abc"}]
392
+ assert "truncated the commit list" in capsys.readouterr().err
393
+
394
+ @patch("generate_release_notes._search_merged_pull_requests_by_author")
395
+ def test_is_new_contributor_rejects_prior_merged_pr(self, mock_search):
396
+ """A contributor is not new if they already merged a PR earlier."""
397
+ mock_search.return_value = [
398
+ {"number": 41, "closed_at": "2026-03-01T10:00:00Z"},
399
+ {"number": 42, "closed_at": "2026-03-06T09:30:00Z"},
400
+ ]
401
+
402
+ assert not is_new_contributor(
403
+ "pr-author",
404
+ "owner/repo",
405
+ "2026-03-06T09:30:00Z",
406
+ "token",
407
+ current_pr_number=42,
408
+ author_type="User",
409
+ )
410
+
411
+ @patch("generate_release_notes._search_merged_pull_requests_by_author")
412
+ def test_is_new_contributor_accepts_first_merged_pr(self, mock_search):
413
+ """A contributor is new when the only merged PR found is the current one."""
414
+ mock_search.return_value = [{"number": 42, "closed_at": "2026-03-06T09:30:00Z"}]
415
+
416
+ assert is_new_contributor(
417
+ "pr-author",
418
+ "owner/repo",
419
+ "2026-03-06T09:30:00Z",
420
+ "token",
421
+ current_pr_number=42,
422
+ author_type="User",
423
+ )
424
+
425
+ @patch("generate_release_notes._search_merged_pull_requests_by_author")
426
+ def test_is_new_contributor_ignores_bot_accounts(self, mock_search):
427
+ """Bot-authored PRs should never appear in new contributors."""
428
+ assert not is_new_contributor(
429
+ "dependabot[bot]",
430
+ "owner/repo",
431
+ "2026-03-06T09:30:00Z",
432
+ "token",
433
+ current_pr_number=42,
434
+ author_type="Bot",
435
+ )
436
+ mock_search.assert_not_called()
437
+
438
+ @patch("generate_release_notes.is_new_contributor")
439
+ def test_process_contributors_uses_earliest_release_pr_per_author(self, mock_is_new):
440
+ """Contributor detection should use the author's earliest PR in the release."""
441
+ mock_is_new.side_effect = lambda author, *_args, **_kwargs: author == "alice"
442
+ changes = [
443
+ Change(
444
+ message="Later PR",
445
+ sha="bbb2222",
446
+ author="alice",
447
+ pr_number=43,
448
+ author_type="User",
449
+ pr_created_at="2026-03-10T12:00:00Z",
450
+ pr_merged_at="2026-03-11T12:00:00Z",
451
+ ),
452
+ Change(
453
+ message="Earlier PR",
454
+ sha="aaa1111",
455
+ author="alice",
456
+ pr_number=42,
457
+ author_type="User",
458
+ pr_created_at="2026-03-05T12:00:00Z",
459
+ pr_merged_at="2026-03-06T09:30:00Z",
460
+ ),
461
+ Change(
462
+ message="Bot PR",
463
+ sha="ccc3333",
464
+ author="dependabot[bot]",
465
+ pr_number=44,
466
+ author_type="Bot",
467
+ pr_created_at="2026-03-07T12:00:00Z",
468
+ pr_merged_at="2026-03-08T12:00:00Z",
469
+ ),
470
+ ]
471
+
472
+ contributors, new_contributors = _process_contributors(changes, "owner/repo", "token")
473
+
474
+ assert [contributor.username for contributor in contributors] == ["alice", "dependabot[bot]"]
475
+ assert [contributor.first_pr for contributor in contributors] == [42, 44]
476
+ assert [contributor.username for contributor in new_contributors] == ["alice"]
477
+ mock_is_new.assert_any_call(
478
+ "alice",
479
+ "owner/repo",
480
+ "2026-03-06T09:30:00Z",
481
+ "token",
482
+ current_pr_number=42,
483
+ author_type="User",
484
+ )
485
+ mock_is_new.assert_any_call(
486
+ "dependabot[bot]",
487
+ "owner/repo",
488
+ "2026-03-08T12:00:00Z",
489
+ "token",
490
+ current_pr_number=44,
491
+ author_type="Bot",
492
+ )
493
+
494
+
495
+ class TestPrompt:
496
+ """Tests for the release-notes agent prompt."""
497
+
498
+ def test_format_prompt_includes_editorial_instructions(self):
499
+ """Test that the prompt tells the agent to make editorial judgments."""
500
+ prompt = format_prompt(
501
+ repo_name="owner/repo",
502
+ tag="v1.2.0",
503
+ previous_tag="v1.1.0",
504
+ date="2026-03-07",
505
+ commit_count=12,
506
+ include_internal=False,
507
+ output_format="release",
508
+ full_changelog_url="https://github.com/owner/repo/compare/v1.1.0...v1.2.0",
509
+ change_candidates="- Ref: #42\n Title: Add dark mode",
510
+ new_contributors="- @new-user made their first contribution in #42",
511
+ )
512
+
513
+ assert "Write official release notes for `owner/repo` tag `v1.2.0`." in prompt
514
+ assert "decide which PRs are important enough to mention" in prompt
515
+ assert "group related PRs into a single bullet" in prompt
516
+ assert "aggressively compress the notes into a shorter set of higher-signal bullets" in prompt
517
+ assert "if a section would have more than 5 bullets" in prompt
518
+ assert "omit trivial, repetitive, or low-signal changes" in prompt
519
+ assert "prefer end-user impact over implementation detail" in prompt
520
+ assert "prioritize public APIs, user-visible capabilities, security fixes" in prompt
521
+ assert "treat toolkit-maintainer or contributor-facing changes as secondary" in prompt
522
+ assert "should stay in the small/internal appendix unless they are unusually significant" in prompt
523
+ assert "public API additions still belong in `### ✨ New Features`" in prompt
524
+ assert "omit prompt wording, benchmark plumbing, workflow maintenance" in prompt
525
+ assert "start with a short, conversational 1-2 sentence overview" in prompt
526
+ assert "optional top-level highlight bullets (maximum 3)" in prompt
527
+ assert "every change bullet must end with explicit references" in prompt
528
+ assert "format PR references as `(#123) @username`" in prompt
529
+ assert "include every new contributor listed below" in prompt
530
+ assert "Current tag: v1.2.0" in prompt
531
+ assert "Full changelog URL:" in prompt
532
+
533
+
534
+ class TestValidation:
535
+ """Tests for release notes attribution validation."""
536
+
537
+ def test_validate_release_notes_accepts_grouped_prs_with_authors(self):
538
+ """Grouped bullets are allowed when every PR and author is listed."""
539
+ notes = ReleaseNotes(
540
+ tag="v1.2.0",
541
+ previous_tag="v1.1.0",
542
+ date="2026-03-07",
543
+ repo_name="owner/repo",
544
+ changes={
545
+ "features": [
546
+ Change(message="Add dark mode", sha="abc1234", author="alice", pr_number=42),
547
+ Change(message="Add theme presets", sha="def5678", author="bob", pr_number=43),
548
+ ]
549
+ },
550
+ )
551
+ markdown = """## [v1.2.0] - 2026-03-07
552
+
553
+ ### ✨ New Features
554
+ - Add appearance improvements (#42) @alice, (#43) @bob
555
+
556
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
557
+ """
558
+
559
+ summary = validate_release_notes_markdown(markdown, notes)
560
+
561
+ assert summary.bullet_count == 1
562
+ assert summary.referenced_prs == [42, 43]
563
+ assert summary.referenced_authors == ["alice", "bob"]
564
+
565
+ def test_validate_release_notes_rejects_missing_author(self):
566
+ """Each referenced PR must include the matching author handle."""
567
+ notes = ReleaseNotes(
568
+ tag="v1.2.0",
569
+ previous_tag="v1.1.0",
570
+ date="2026-03-07",
571
+ repo_name="owner/repo",
572
+ changes={
573
+ "fixes": [
574
+ Change(message="Fix reconnect bug", sha="abc1234", author="alice", pr_number=42),
575
+ ]
576
+ },
577
+ )
578
+ markdown = """## [v1.2.0] - 2026-03-07
579
+
580
+ ### 🐛 Bug Fixes
581
+ - Fix reconnect bug (#42)
582
+
583
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
584
+ """
585
+
586
+ with pytest.raises(ReleaseNotesValidationError, match=r"missing @alice"):
587
+ validate_release_notes_markdown(markdown, notes)
588
+
589
+ def test_validate_release_notes_rejects_missing_refs(self):
590
+ """Each change bullet must contain explicit references."""
591
+ notes = ReleaseNotes(
592
+ tag="v1.2.0",
593
+ previous_tag="v1.1.0",
594
+ date="2026-03-07",
595
+ repo_name="owner/repo",
596
+ changes={
597
+ "features": [
598
+ Change(message="Add dark mode", sha="abc1234", author="alice", pr_number=42),
599
+ ]
600
+ },
601
+ )
602
+ markdown = """## [v1.2.0] - 2026-03-07
603
+
604
+ ### ✨ New Features
605
+ - Add dark mode @alice
606
+
607
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
608
+ """
609
+
610
+ with pytest.raises(
611
+ ReleaseNotesValidationError,
612
+ match=r"Bullet missing explicit PR/commit references",
613
+ ):
614
+ validate_release_notes_markdown(markdown, notes)
615
+
616
+ def test_validate_release_notes_accepts_standalone_commit_refs(self):
617
+ """Standalone commits can be referenced by short SHA plus author."""
618
+ notes = ReleaseNotes(
619
+ tag="v1.2.0",
620
+ previous_tag="v1.1.0",
621
+ date="2026-03-07",
622
+ repo_name="owner/repo",
623
+ changes={
624
+ "docs": [
625
+ Change(message="Update docs", sha="abc1234fedcba", author="alice"),
626
+ ]
627
+ },
628
+ )
629
+ markdown = """## [v1.2.0] - 2026-03-07
630
+
631
+ ### 📚 Documentation
632
+ - Update docs (abc1234) @alice
633
+
634
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
635
+ """
636
+
637
+ summary = validate_release_notes_markdown(markdown, notes)
638
+
639
+ assert summary.referenced_commits == ["abc1234"]
640
+ assert summary.referenced_authors == ["alice"]
641
+
642
+ def test_validate_release_notes_accepts_accurate_new_contributor_section(self):
643
+ """The validator should allow the exact expected new contributor entry."""
644
+ notes = ReleaseNotes(
645
+ tag="v1.2.0",
646
+ previous_tag="v1.1.0",
647
+ date="2026-03-07",
648
+ repo_name="owner/repo",
649
+ changes={
650
+ "fixes": [
651
+ Change(message="Fix reconnect bug", sha="abc1234", author="alice", pr_number=42),
652
+ ]
653
+ },
654
+ new_contributors=[Contributor(username="alice", first_pr=42, is_new=True)],
655
+ )
656
+ markdown = """## [v1.2.0] - 2026-03-07
657
+
658
+ ### 🐛 Bug Fixes
659
+ - Fix reconnect bug (#42) @alice
660
+
661
+ ### 👥 New Contributors
662
+ - @alice made their first contribution in #42
663
+
664
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
665
+ """
666
+
667
+ summary = validate_release_notes_markdown(markdown, notes)
668
+
669
+ assert summary.referenced_prs == [42]
670
+ assert summary.referenced_authors == ["alice"]
671
+
672
+ def test_validate_release_notes_rejects_inaccurate_new_contributor_entry(self):
673
+ """The validator should reject incorrect or hallucinated first-contribution bullets."""
674
+ notes = ReleaseNotes(
675
+ tag="v1.2.0",
676
+ previous_tag="v1.1.0",
677
+ date="2026-03-07",
678
+ repo_name="owner/repo",
679
+ changes={
680
+ "fixes": [
681
+ Change(message="Fix reconnect bug", sha="abc1234", author="alice", pr_number=42),
682
+ ]
683
+ },
684
+ new_contributors=[Contributor(username="alice", first_pr=42, is_new=True)],
685
+ )
686
+ markdown = """## [v1.2.0] - 2026-03-07
687
+
688
+ ### 🐛 Bug Fixes
689
+ - Fix reconnect bug (#42) @alice
690
+
691
+ ### 👥 New Contributors
692
+ - @alice made their first contribution in #99
693
+
694
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
695
+ """
696
+
697
+ with pytest.raises(
698
+ ReleaseNotesValidationError,
699
+ match=r"must reference #42",
700
+ ):
701
+ validate_release_notes_markdown(markdown, notes)
702
+
703
+
704
+ def test_validate_release_notes_rejects_missing_new_contributor_section(self):
705
+ """Expected new contributors must appear in the dedicated section."""
706
+ notes = ReleaseNotes(
707
+ tag="v1.2.0",
708
+ previous_tag="v1.1.0",
709
+ date="2026-03-07",
710
+ repo_name="owner/repo",
711
+ changes={
712
+ "fixes": [
713
+ Change(message="Fix reconnect bug", sha="abc1234", author="alice", pr_number=42),
714
+ ]
715
+ },
716
+ new_contributors=[Contributor(username="alice", first_pr=42, is_new=True)],
717
+ )
718
+ markdown = """## [v1.2.0] - 2026-03-07
719
+
720
+ ### 🐛 Bug Fixes
721
+ - Fix reconnect bug (#42) @alice
722
+
723
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
724
+ """
725
+
726
+ with pytest.raises(
727
+ ReleaseNotesValidationError,
728
+ match=r"missing new contributor coverage for: @alice",
729
+ ):
730
+ validate_release_notes_markdown(markdown, notes)
731
+
732
+ def test_format_coverage_summary_lists_prs_and_authors(self):
733
+ """Coverage summary output is suitable for logs and evidence."""
734
+ summary = format_coverage_summary(
735
+ validate_release_notes_markdown(
736
+ """## [v1.2.0] - 2026-03-07
737
+
738
+ ### ✨ New Features
739
+ - Add dark mode (#42) @alice, (#43) @bob
740
+
741
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
742
+ """,
743
+ ReleaseNotes(
744
+ tag="v1.2.0",
745
+ previous_tag="v1.1.0",
746
+ date="2026-03-07",
747
+ repo_name="owner/repo",
748
+ changes={
749
+ "features": [
750
+ Change(
751
+ message="Add dark mode",
752
+ sha="abc1234",
753
+ author="alice",
754
+ pr_number=42,
755
+ ),
756
+ Change(
757
+ message="Add theme presets",
758
+ sha="def5678",
759
+ author="bob",
760
+ pr_number=43,
761
+ ),
762
+ ]
763
+ },
764
+ ),
765
+ )
766
+ )
767
+
768
+ assert "PRs referenced: #42, #43" in summary
769
+ assert "Authors referenced: @alice, @bob" in summary
770
+
771
+ def test_validate_release_notes_allows_agent_to_recategorize_other_candidates(self):
772
+ """The validator should allow refs for candidates the agent re-categorizes."""
773
+ notes = ReleaseNotes(
774
+ tag="v1.2.0",
775
+ previous_tag="v1.1.0",
776
+ date="2026-03-07",
777
+ repo_name="owner/repo",
778
+ changes={
779
+ "other": [
780
+ Change(message="Export public API", sha="abc1234", author="alice", pr_number=42),
781
+ ]
782
+ },
783
+ )
784
+ markdown = """## [v1.2.0] - 2026-03-07
785
+
786
+ ### ✨ New Features
787
+ - Export public API (#42) @alice
788
+
789
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
790
+ """
791
+
792
+ summary = validate_release_notes_markdown(markdown, notes)
793
+
794
+ assert summary.referenced_prs == [42]
795
+ assert summary.referenced_authors == ["alice"]
796
+
797
+ def test_validate_release_notes_rejects_missing_pr_coverage(self):
798
+ """Validation fails if any release-range PR is omitted entirely."""
799
+ notes = ReleaseNotes(
800
+ tag="v1.2.0",
801
+ previous_tag="v1.1.0",
802
+ date="2026-03-07",
803
+ repo_name="owner/repo",
804
+ changes={
805
+ "features": [
806
+ Change(message="Add dark mode", sha="abc1234", author="alice", pr_number=42),
807
+ Change(message="Add theme presets", sha="def5678", author="bob", pr_number=43),
808
+ ]
809
+ },
810
+ )
811
+ markdown = """## [v1.2.0] - 2026-03-07
812
+
813
+ ### ✨ New Features
814
+ - Add dark mode (#42) @alice
815
+
816
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
817
+ """
818
+
819
+ with pytest.raises(
820
+ ReleaseNotesValidationError,
821
+ match=r"missing PR/commit coverage for: #43",
822
+ ):
823
+ validate_release_notes_markdown(markdown, notes)
824
+
825
+ assert missing_references(markdown, notes) == ["#43"]
826
+
827
+ def test_append_reference_coverage_appendix_adds_missing_refs(self):
828
+ """A deterministic appendix covers PRs the agent chose not to mention."""
829
+ notes = ReleaseNotes(
830
+ tag="v1.2.0",
831
+ previous_tag="v1.1.0",
832
+ date="2026-03-07",
833
+ repo_name="owner/repo",
834
+ changes={
835
+ "features": [
836
+ Change(message="Add dark mode", sha="abc1234", author="alice", pr_number=42),
837
+ ],
838
+ "internal": [
839
+ Change(message="Update CI", sha="def5678", author="bob", pr_number=43),
840
+ Change(message="Refactor workflow", sha="fedcba9", author="bob", pr_number=44),
841
+ ],
842
+ },
843
+ )
844
+ markdown = """## [v1.2.0] - 2026-03-07
845
+
846
+ This release focuses on polish and delivery improvements.
847
+
848
+ ### ✨ New Features
849
+ - Add dark mode (#42) @alice
850
+
851
+ **Full Changelog**: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
852
+ """
853
+
854
+ augmented = append_reference_coverage_appendix(markdown, notes)
855
+
856
+ assert "### 🔎 Small Fixes/Internal Changes" in augmented
857
+ assert "- @bob: #43, #44" in augmented
858
+ assert augmented.count("@bob") == 1
859
+ summary = validate_release_notes_markdown(augmented, notes)
860
+ assert summary.referenced_prs == [42, 43, 44]
861
+
862
+
863
+ class TestCategories:
864
+ """Tests for the CATEGORIES constant."""
865
+
866
+ def test_all_categories_have_required_fields(self):
867
+ """Test that all categories have the required fields."""
868
+ required_fields = ["emoji", "title", "commit_patterns", "labels"]
869
+ for category, info in CATEGORIES.items():
870
+ for field in required_fields:
871
+ assert field in info, f"Category {category} missing {field}"
872
+
873
+ def test_breaking_category_exists(self):
874
+ """Test that the breaking category exists."""
875
+ assert "breaking" in CATEGORIES
876
+ assert CATEGORIES["breaking"]["emoji"] == "⚠️"
877
+
878
+ def test_features_category_exists(self):
879
+ """Test that the features category exists."""
880
+ assert "features" in CATEGORIES
881
+ assert CATEGORIES["features"]["emoji"] == "✨"
882
+
883
+ def test_fixes_category_exists(self):
884
+ """Test that the fixes category exists."""
885
+ assert "fixes" in CATEGORIES
886
+ assert CATEGORIES["fixes"]["emoji"] == "🐛"
887
+
888
+ def test_docs_category_exists(self):
889
+ """Test that the docs category exists."""
890
+ assert "docs" in CATEGORIES
891
+ assert CATEGORIES["docs"]["emoji"] == "📚"
892
+
893
+ def test_internal_category_exists(self):
894
+ """Test that the internal category exists."""
895
+ assert "internal" in CATEGORIES
896
+ assert CATEGORIES["internal"]["emoji"] == "🏗️"
897
+
898
+
899
+ class TestPluginStructure:
900
+ """Tests for the plugin directory structure."""
901
+
902
+ def test_plugin_directory_exists(self):
903
+ """Test that the plugin directory exists."""
904
+ plugin_dir = Path(__file__).parent.parent / "plugins" / "release-notes"
905
+ assert plugin_dir.is_dir()
906
+
907
+ def test_skill_md_exists(self):
908
+ """Test that SKILL.md exists."""
909
+ skill_md = Path(__file__).parent.parent / "plugins" / "release-notes" / "SKILL.md"
910
+ assert skill_md.is_file()
911
+
912
+ def test_readme_exists(self):
913
+ """Test that README.md exists."""
914
+ readme = Path(__file__).parent.parent / "plugins" / "release-notes" / "README.md"
915
+ assert readme.is_file()
916
+
917
+ def test_action_yml_exists(self):
918
+ """Test that action.yml exists."""
919
+ action = Path(__file__).parent.parent / "plugins" / "release-notes" / "action.yml"
920
+ assert action.is_file()
921
+
922
+ def test_script_exists(self):
923
+ """Test that the generator script exists."""
924
+ script = (
925
+ Path(__file__).parent.parent
926
+ / "plugins"
927
+ / "release-notes"
928
+ / "scripts"
929
+ / "generate_release_notes.py"
930
+ )
931
+ assert script.is_file()
932
+
933
+ def test_workflow_exists(self):
934
+ """Test that the workflow file exists."""
935
+ workflow = (
936
+ Path(__file__).parent.parent
937
+ / "plugins"
938
+ / "release-notes"
939
+ / "workflows"
940
+ / "release-notes.yml"
941
+ )
942
+ assert workflow.is_file()
943
+
944
+ def test_validator_script_exists(self):
945
+ """Test that the attribution validator script exists."""
946
+ validator = (
947
+ Path(__file__).parent.parent
948
+ / "plugins"
949
+ / "release-notes"
950
+ / "scripts"
951
+ / "validate_release_notes.py"
952
+ )
953
+ assert validator.is_file()
954
+
955
+ def test_agent_script_exists(self):
956
+ """Test that the agent orchestration script exists."""
957
+ script = (
958
+ Path(__file__).parent.parent
959
+ / "plugins"
960
+ / "release-notes"
961
+ / "scripts"
962
+ / "agent_script.py"
963
+ )
964
+ assert script.is_file()
965
+
966
+ def test_prompt_script_exists(self):
967
+ """Test that the prompt template exists."""
968
+ prompt = (
969
+ Path(__file__).parent.parent
970
+ / "plugins"
971
+ / "release-notes"
972
+ / "scripts"
973
+ / "prompt.py"
974
+ )
975
+ assert prompt.is_file()
976
+
977
+ def test_skills_symlink_exists(self):
978
+ """Test that the skills symlink exists."""
979
+ symlink = (
980
+ Path(__file__).parent.parent
981
+ / "plugins"
982
+ / "release-notes"
983
+ / "skills"
984
+ / "release-notes"
985
+ )
986
+ assert symlink.exists()
987
+
988
+
989
+ if __name__ == "__main__":
990
+ pytest.main([__file__, "-v"])