claude-dev-env 1.38.1 → 1.39.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 (270) hide show
  1. package/CLAUDE.md +10 -36
  2. package/_shared/pr-loop/audit-reply-template.md +147 -0
  3. package/_shared/pr-loop/fix-protocol.md +25 -4
  4. package/_shared/pr-loop/gh-payloads.md +37 -50
  5. package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
  6. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +189 -0
  7. package/_shared/pr-loop/scripts/post_audit_thread.py +947 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
  9. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +923 -0
  10. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
  11. package/_shared/pr-loop/state-schema.md +1 -1
  12. package/agents/clean-coder.md +2 -2
  13. package/bin/install.mjs +6 -7
  14. package/bin/install.test.mjs +8 -0
  15. package/commands/doc-gist.md +16 -0
  16. package/commands/plan.md +0 -2
  17. package/commands/review-plan.md +1 -1
  18. package/docs/CODE_RULES.md +122 -2
  19. package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
  20. package/hooks/blocking/code_rules_enforcer.py +1143 -129
  21. package/hooks/blocking/convergence_gate_blocker.py +130 -0
  22. package/hooks/blocking/destructive_command_blocker.py +74 -0
  23. package/hooks/blocking/gh_body_arg_blocker.py +30 -0
  24. package/hooks/blocking/md_to_html_blocker.py +119 -0
  25. package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
  26. package/hooks/blocking/test_code_rules_enforcer.py +21 -0
  27. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
  28. package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
  29. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
  30. package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
  31. package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
  32. package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
  33. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  34. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
  36. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
  37. package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
  38. package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
  39. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
  40. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
  41. package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
  42. package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
  43. package/hooks/blocking/test_destructive_command_blocker.py +146 -0
  44. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
  45. package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
  46. package/hooks/blocking/test_md_to_html_blocker.py +317 -0
  47. package/hooks/config/any_type_config.py +7 -0
  48. package/hooks/config/banned_identifiers_constants.py +11 -0
  49. package/hooks/config/blocking_check_limits.py +38 -0
  50. package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
  51. package/hooks/config/code_rules_enforcer_constants.py +53 -0
  52. package/hooks/config/convergence_branch_constants.py +9 -0
  53. package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
  54. package/hooks/config/html_companion_constants.py +20 -0
  55. package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
  56. package/hooks/config/test_banned_identifiers_constants.py +17 -0
  57. package/hooks/hooks.json +28 -20
  58. package/hooks/pyproject.toml +69 -0
  59. package/hooks/validators/mypy_integration.py +47 -1
  60. package/hooks/validators/run_all_validators.py +3 -3
  61. package/hooks/validators/test_mypy_integration.py +50 -1
  62. package/hooks/workflow/doc_gist_auto_publish.py +144 -0
  63. package/hooks/workflow/md_to_html_companion.py +365 -0
  64. package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
  65. package/hooks/workflow/test_md_to_html_companion.py +452 -0
  66. package/package.json +1 -1
  67. package/rules/gh-body-file.md +2 -0
  68. package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
  69. package/scripts/check.ps1 +106 -0
  70. package/scripts/config/timing.py +11 -0
  71. package/scripts/sweep_empty_dirs.py +138 -0
  72. package/scripts/sync_to_cursor/rules.py +1 -1
  73. package/scripts/test_sweep_empty_dirs.py +183 -0
  74. package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
  75. package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
  76. package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
  77. package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
  78. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
  79. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
  80. package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
  81. package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
  82. package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
  83. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
  84. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
  85. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
  86. package/skills/bugteam/CONSTRAINTS.md +21 -22
  87. package/skills/bugteam/EXAMPLES.md +3 -3
  88. package/skills/bugteam/PROMPTS.md +227 -67
  89. package/skills/bugteam/SKILL.md +114 -455
  90. package/skills/bugteam/reference/README.md +1 -1
  91. package/skills/bugteam/reference/audit-and-teammates.md +112 -39
  92. package/skills/bugteam/reference/audit-contract.md +4 -22
  93. package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
  94. package/skills/bugteam/reference/design-rationale.md +2 -2
  95. package/skills/bugteam/reference/github-pr-reviews.md +50 -57
  96. package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
  97. package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
  98. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
  99. package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
  100. package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
  101. package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
  102. package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
  103. package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
  104. package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
  105. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
  106. package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
  107. package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
  108. package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
  109. package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
  110. package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
  111. package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
  112. package/skills/bugteam/reference/team-setup.md +106 -9
  113. package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
  114. package/skills/bugteam/scripts/README.md +60 -0
  115. package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
  116. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
  117. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
  118. package/skills/bugteam/scripts/bugteam_preflight.py +294 -0
  119. package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
  120. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
  121. package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
  122. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
  123. package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
  124. package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
  125. package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
  126. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
  127. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
  128. package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
  129. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
  130. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
  131. package/skills/bugteam/scripts/test_bugteam_preflight.py +268 -0
  132. package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
  133. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
  134. package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
  135. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
  136. package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
  137. package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
  138. package/skills/bugteam/test_skill_additions.py +1 -11
  139. package/skills/code/SKILL.md +176 -0
  140. package/skills/doc-gist/SKILL.md +99 -0
  141. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
  142. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
  143. package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
  144. package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
  145. package/skills/doc-gist/references/examples/05-design-system.html +629 -0
  146. package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
  147. package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
  148. package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
  149. package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
  150. package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
  151. package/skills/doc-gist/references/examples/11-status-report.html +528 -0
  152. package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
  153. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
  154. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
  155. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
  156. package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
  157. package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
  158. package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
  159. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
  160. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
  161. package/skills/doc-gist/references/examples/README.md +5 -0
  162. package/skills/doc-gist/scripts/config/__init__.py +0 -0
  163. package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
  164. package/skills/doc-gist/scripts/gist_upload.py +177 -0
  165. package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
  166. package/skills/findbugs/SKILL.md +68 -2
  167. package/skills/monitor-open-prs/SKILL.md +13 -32
  168. package/skills/monitor-open-prs/test_skill_contract.py +0 -11
  169. package/skills/pr-consistency-audit/SKILL.md +112 -0
  170. package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
  171. package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
  172. package/skills/pr-converge/SKILL.md +227 -23
  173. package/skills/pr-converge/config/__init__.py +0 -0
  174. package/skills/pr-converge/config/constants.py +62 -0
  175. package/skills/pr-converge/reference/convergence-gates.md +138 -44
  176. package/skills/pr-converge/reference/examples.md +43 -11
  177. package/skills/pr-converge/reference/fix-protocol.md +6 -5
  178. package/skills/pr-converge/reference/ground-rules.md +5 -3
  179. package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
  180. package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
  181. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
  182. package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
  183. package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
  184. package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
  185. package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
  186. package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
  187. package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
  188. package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
  189. package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
  190. package/skills/pr-converge/reference/per-tick.md +90 -31
  191. package/skills/pr-converge/reference/state-schema.md +22 -1
  192. package/skills/pr-converge/reference/stop-conditions.md +9 -7
  193. package/skills/pr-converge/scripts/README.md +34 -46
  194. package/skills/pr-converge/scripts/check_bugbot_ci.py +174 -0
  195. package/skills/pr-converge/scripts/check_convergence.py +497 -0
  196. package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
  197. package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
  198. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
  199. package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
  200. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
  201. package/skills/qbug/SKILL.md +132 -27
  202. package/skills/session-log/SKILL.md +216 -114
  203. package/skills/session-tidy/SKILL.md +1 -1
  204. package/skills/skill-builder/SKILL.md +138 -56
  205. package/skills/skill-builder/references/delegation-map.md +72 -113
  206. package/skills/skill-builder/references/progressive-disclosure.md +122 -0
  207. package/skills/skill-builder/references/self-audit-checklist.md +92 -0
  208. package/skills/skill-builder/references/skill-types.md +228 -0
  209. package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
  210. package/skills/skill-builder/templates/gap-analysis.md +15 -8
  211. package/skills/skill-builder/workflows/improve-skill.md +86 -57
  212. package/skills/skill-builder/workflows/new-skill.md +80 -168
  213. package/skills/skill-builder/workflows/polish-skill.md +78 -54
  214. package/skills/structure-prompt/SKILL.md +50 -0
  215. package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
  216. package/skills/structure-prompt/reference/block-classification.md +27 -0
  217. package/skills/structure-prompt/reference/canonical-case.md +48 -0
  218. package/skills/structure-prompt/reference/citation-depth.md +70 -0
  219. package/skills/structure-prompt/reference/cleanup.md +33 -0
  220. package/skills/structure-prompt/reference/constraints.md +33 -0
  221. package/skills/structure-prompt/reference/directives.md +37 -0
  222. package/skills/structure-prompt/reference/examples.md +72 -0
  223. package/skills/structure-prompt/reference/instantiation.md +51 -0
  224. package/skills/structure-prompt/reference/output-contract.md +72 -0
  225. package/skills/structure-prompt/reference/per-category.md +23 -0
  226. package/skills/structure-prompt/reference/persona.md +38 -0
  227. package/skills/structure-prompt/reference/research.md +33 -0
  228. package/skills/structure-prompt/reference/structure.md +28 -0
  229. package/agents/code-standards-agent.md +0 -93
  230. package/agents/groq-coder.md +0 -113
  231. package/agents/plan-executor.md +0 -226
  232. package/agents/project-docs-analyzer.md +0 -53
  233. package/agents/project-structure-organizer-agent.md +0 -72
  234. package/agents/skill-to-agent-converter.md +0 -370
  235. package/agents/skill-writer-agent.md +0 -470
  236. package/agents/user-docs-writer.md +0 -67
  237. package/agents/workflow-visual-documenter.md +0 -82
  238. package/commands/readability-review.md +0 -20
  239. package/hooks/mypy.ini +0 -2
  240. package/hooks/notification/attention_needed_notify.py +0 -71
  241. package/hooks/notification/claude_notification_handler.py +0 -67
  242. package/hooks/notification/notification_utils.py +0 -267
  243. package/hooks/notification/subagent_complete_notify.py +0 -381
  244. package/hooks/notification/test_attention_needed_notify.py +0 -47
  245. package/hooks/notification/test_claude_notification_handler.py +0 -54
  246. package/hooks/notification/test_notification_utils.py +0 -91
  247. package/hooks/notification/test_subagent_complete_notify.py +0 -79
  248. package/scripts/config/groq_bugteam_config.py +0 -230
  249. package/scripts/config/test_groq_bugteam_config.py +0 -83
  250. package/scripts/config/test_spec_implementer_prompt.py +0 -32
  251. package/scripts/groq_bugteam.README.md +0 -131
  252. package/scripts/groq_bugteam.py +0 -647
  253. package/scripts/groq_bugteam_dotenv.py +0 -40
  254. package/scripts/groq_bugteam_spec.py +0 -226
  255. package/scripts/test_groq_bugteam.py +0 -529
  256. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
  257. package/scripts/test_groq_bugteam_dotenv.py +0 -66
  258. package/scripts/test_groq_bugteam_spec.py +0 -338
  259. package/skills/bugteam/SKILL_EVALS.md +0 -309
  260. package/skills/dream/SKILL.md +0 -118
  261. package/skills/ingest/SKILL.md +0 -40
  262. package/skills/npm-creator/SKILL.md +0 -187
  263. package/skills/readability-review/SKILL.md +0 -127
  264. package/skills/resume-review/SKILL.md +0 -261
  265. package/skills/rule-audit/SKILL.md +0 -307
  266. package/skills/rule-creator/SKILL.md +0 -150
  267. package/skills/searching-obsidian-vault/SKILL.md +0 -131
  268. package/skills/skill-writer/REFERENCE.md +0 -284
  269. package/skills/skill-writer/SKILL.md +0 -222
  270. package/skills/tdd-team/SKILL.md +0 -128
@@ -0,0 +1,947 @@
1
+ """Post an audit review (APPROVE / REQUEST_CHANGES) to a draft PR.
2
+
3
+ Consumed by ``/bugteam``, ``/findbugs``, and ``/qbug`` at the end of every
4
+ audit invocation. Posts to ``/repos/{owner}/{repo}/pulls/{N}/reviews`` with
5
+ ``commit_id=<SHA>``, a formatted body, and inline ``comments[]`` derived
6
+ from a findings JSON file. CLEAN state ``→`` APPROVE with empty
7
+ ``comments[]``; DIRTY state ``→`` REQUEST_CHANGES with one inline comment
8
+ per finding so each becomes its own resolvable thread.
9
+
10
+ The body skeleton is read at runtime from ``audit-reply-template.md`` (the
11
+ canonical reference doc shipped in Phase 1) so the template stays the
12
+ single source of truth for the review-body shape.
13
+
14
+ Exit codes per spec:
15
+ - ``0`` on success (POSTs the new review's ``html_url`` to stdout)
16
+ - ``1`` on user error (bad CLI arguments, malformed findings JSON)
17
+ - ``2`` on retry exhaustion (four non-2xx responses — one initial attempt
18
+ plus three retries) — hard blocker
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import dataclasses
25
+ import json
26
+ import os
27
+ import subprocess
28
+ import sys
29
+ import time
30
+ import urllib.error
31
+ import urllib.request
32
+ from pathlib import Path
33
+ from typing import NoReturn
34
+
35
+ sys.modules.pop("config", None)
36
+ if str(Path(__file__).resolve().parent) not in sys.path:
37
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
38
+
39
+ from config.post_audit_thread_constants import (
40
+ ALL_GH_AUTH_TOKEN_COMMAND_PARTS,
41
+ ALL_GH_TOKEN_ENV_VAR_NAMES,
42
+ ALL_REQUIRED_FINDING_FIELDS,
43
+ ALL_RETRY_BACKOFF_SECONDS,
44
+ ALL_SUPPORTED_INLINE_COMMENT_SIDES,
45
+ ALL_SUPPORTED_SEVERITY_TAGS,
46
+ ALL_SUPPORTED_SKILLS,
47
+ ALL_SUPPORTED_STATES,
48
+ AUDIT_BODY_SKELETON_CLOSE_MARKER,
49
+ AUDIT_BODY_SKELETON_OPEN_MARKER,
50
+ CLI_FLAG_COMMIT,
51
+ CLI_FLAG_FINDINGS_JSON,
52
+ CLI_FLAG_OWNER,
53
+ CLI_FLAG_PR_NUMBER,
54
+ CLI_FLAG_REPO,
55
+ CLI_FLAG_SKILL,
56
+ CLI_FLAG_STATE,
57
+ DETAILS_BLOCK_BULLET_TEMPLATE,
58
+ DETAILS_BLOCK_FOOTER,
59
+ DETAILS_BLOCK_HEADER,
60
+ ERROR_RESPONSE_PREVIEW_CHARS,
61
+ EXIT_CODE_RETRY_EXHAUSTED,
62
+ EXIT_CODE_USER_ERROR,
63
+ GITHUB_API_ACCEPT_HEADER,
64
+ GITHUB_API_BASE_URL,
65
+ GITHUB_API_USER_AGENT,
66
+ GITHUB_API_VERSION_HEADER,
67
+ GITHUB_REVIEW_EVENT_APPROVE,
68
+ GITHUB_REVIEW_EVENT_REQUEST_CHANGES,
69
+ HEADING_FOR_CLEAN,
70
+ HEADING_FOR_DIRTY,
71
+ HTTP_AUTHORIZATION_BEARER_PREFIX,
72
+ HTTP_HEADER_ACCEPT,
73
+ HTTP_HEADER_AUTHORIZATION,
74
+ HTTP_HEADER_CONTENT_TYPE,
75
+ HTTP_HEADER_GITHUB_API_VERSION,
76
+ HTTP_HEADER_USER_AGENT,
77
+ HTTP_METHOD_POST,
78
+ HTTP_REQUEST_CONTENT_TYPE,
79
+ HTTP_REQUEST_TIMEOUT_SECONDS,
80
+ HTTP_STATUS_SUCCESS_RANGE_HIGH,
81
+ HTTP_STATUS_SUCCESS_RANGE_LOW,
82
+ INLINE_COMMENT_BODY_TEMPLATE,
83
+ INLINE_COMMENT_FIELD_BODY,
84
+ INLINE_COMMENT_FIELD_LINE,
85
+ INLINE_COMMENT_FIELD_PATH,
86
+ INLINE_COMMENT_FIELD_SIDE,
87
+ JSON_FIELD_DESCRIPTION,
88
+ JSON_FIELD_FIX_SUMMARY,
89
+ JSON_FIELD_LINE,
90
+ JSON_FIELD_PATH,
91
+ JSON_FIELD_SEVERITY,
92
+ JSON_FIELD_SIDE,
93
+ MAX_RETRY_ATTEMPTS,
94
+ PLACEHOLDER_DETAILS_BLOCK,
95
+ PLACEHOLDER_FINDINGS_COUNT,
96
+ PLACEHOLDER_HEADING,
97
+ PLACEHOLDER_P0_COUNT,
98
+ PLACEHOLDER_P1_COUNT,
99
+ PLACEHOLDER_P2_COUNT,
100
+ PLACEHOLDER_SKILL,
101
+ PLACEHOLDER_STATE_LABEL,
102
+ PLACEHOLDER_SUMMARY_PARAGRAPH,
103
+ REVIEW_REQUEST_FIELD_BODY,
104
+ REVIEW_REQUEST_FIELD_COMMENTS,
105
+ REVIEW_REQUEST_FIELD_COMMIT_ID,
106
+ REVIEW_REQUEST_FIELD_EVENT,
107
+ REVIEW_RESPONSE_FIELD_HTML_URL,
108
+ REVIEWS_API_PATH_TEMPLATE,
109
+ SEVERITY_TAG_P0,
110
+ SEVERITY_TAG_P1,
111
+ SEVERITY_TAG_P2,
112
+ SHORT_SHA_LENGTH,
113
+ STATE_CLEAN,
114
+ STATE_DIRTY,
115
+ STATE_LABEL_FOR_CLEAN,
116
+ STATE_LABEL_FOR_DIRTY,
117
+ SUMMARY_PARAGRAPH_CLEAN_TEMPLATE,
118
+ SUMMARY_PARAGRAPH_DIRTY_TEMPLATE,
119
+ TEMPLATE_FENCE_TOKEN,
120
+ template_path,
121
+ )
122
+
123
+
124
+ class UserInputError(ValueError):
125
+ """Raised on malformed CLI input or findings JSON.
126
+
127
+ Surfaces as exit code ``EXIT_CODE_USER_ERROR`` at the entry point.
128
+ """
129
+
130
+
131
+ class RetryExhaustedError(RuntimeError):
132
+ """Raised after four non-2xx responses from the reviews endpoint.
133
+
134
+ Four attempts = one initial attempt plus three retries. Surfaces as
135
+ exit code ``EXIT_CODE_RETRY_EXHAUSTED`` at the entry point.
136
+ """
137
+
138
+
139
+ @dataclasses.dataclass(frozen=True)
140
+ class AuditFinding:
141
+ """One row of the findings JSON file consumed by ``--findings-json``.
142
+
143
+ Mirrors the schema in spec lines 158-169. Frozen so callers cannot
144
+ mutate fields after parsing.
145
+ """
146
+
147
+ path: str
148
+ line: int
149
+ side: str
150
+ severity: str
151
+ description: str
152
+ fix_summary: str
153
+
154
+
155
+ @dataclasses.dataclass(frozen=True)
156
+ class PostedReview:
157
+ """Result of a successful POST to the reviews endpoint.
158
+
159
+ ``html_url`` is the field emitted to stdout per spec line 177;
160
+ ``raw_response_text`` and ``status_code`` are retained for tests and
161
+ logging.
162
+ """
163
+
164
+ html_url: str
165
+ raw_response_text: str
166
+ status_code: int
167
+
168
+
169
+ class _UserInputArgumentParser(argparse.ArgumentParser):
170
+ """ArgumentParser that raises :class:`UserInputError` on parse errors.
171
+
172
+ The stock ``argparse.ArgumentParser.error`` raises ``SystemExit(2)``,
173
+ which collides with ``EXIT_CODE_RETRY_EXHAUSTED``. Routing parse
174
+ failures through :class:`UserInputError` lets the entry point map
175
+ them to ``EXIT_CODE_USER_ERROR`` (exit 1) instead.
176
+ """
177
+
178
+ def error(self, message: str) -> NoReturn:
179
+ raise UserInputError(f"argument parsing failed: {message}")
180
+
181
+
182
+ def parse_command_line_arguments(all_arguments: list[str]) -> argparse.Namespace:
183
+ """Parse and validate the script's CLI surface.
184
+
185
+ Args:
186
+ all_arguments: ``sys.argv[1:]`` or an equivalent list of strings.
187
+
188
+ Returns:
189
+ Namespace with attributes ``skill``, ``owner``, ``repo``,
190
+ ``pr_number``, ``commit``, ``state``, ``findings_json``.
191
+
192
+ Raises:
193
+ UserInputError: unrecognized argument, missing required argument,
194
+ or value outside a declared ``choices`` set.
195
+ """
196
+ parser = _UserInputArgumentParser(
197
+ description=(
198
+ "Post an audit review to a draft PR. CLEAN state approves; "
199
+ "DIRTY state requests changes with one inline comment per finding."
200
+ ),
201
+ )
202
+ parser.add_argument(
203
+ CLI_FLAG_SKILL,
204
+ required=True,
205
+ choices=list(ALL_SUPPORTED_SKILLS),
206
+ help="Name of the calling audit skill.",
207
+ )
208
+ parser.add_argument(
209
+ CLI_FLAG_OWNER,
210
+ required=True,
211
+ help="Repository owner (e.g., jl-cmd).",
212
+ )
213
+ parser.add_argument(
214
+ CLI_FLAG_REPO,
215
+ required=True,
216
+ help="Repository name (e.g., claude-code-config).",
217
+ )
218
+ parser.add_argument(
219
+ CLI_FLAG_PR_NUMBER,
220
+ required=True,
221
+ type=int,
222
+ dest="pr_number",
223
+ help="Pull request number.",
224
+ )
225
+ parser.add_argument(
226
+ CLI_FLAG_COMMIT,
227
+ required=True,
228
+ help="Commit SHA the review attaches to (commit_id field).",
229
+ )
230
+ parser.add_argument(
231
+ CLI_FLAG_STATE,
232
+ required=True,
233
+ choices=list(ALL_SUPPORTED_STATES),
234
+ help="CLEAN approves; DIRTY requests changes.",
235
+ )
236
+ parser.add_argument(
237
+ CLI_FLAG_FINDINGS_JSON,
238
+ required=True,
239
+ type=Path,
240
+ dest="findings_json",
241
+ help="Path to the findings JSON file (empty list for CLEAN).",
242
+ )
243
+ return parser.parse_args(all_arguments)
244
+
245
+
246
+ def _require_string_field(
247
+ all_finding_fields: dict[str, object], field_name: str
248
+ ) -> str:
249
+ field_value = all_finding_fields.get(field_name)
250
+ if not isinstance(field_value, str):
251
+ raise UserInputError(
252
+ f"finding field {field_name!r} must be a string; "
253
+ f"got {type(field_value).__name__}"
254
+ )
255
+ return field_value
256
+
257
+
258
+ def _require_nonempty_string_field(
259
+ all_finding_fields: dict[str, object], field_name: str
260
+ ) -> str:
261
+ field_value = _require_string_field(all_finding_fields, field_name)
262
+ if not field_value:
263
+ raise UserInputError(
264
+ f"finding field {field_name!r} must be a non-empty string; got ''"
265
+ )
266
+ return field_value
267
+
268
+
269
+ def _require_int_field(
270
+ all_finding_fields: dict[str, object], field_name: str
271
+ ) -> int:
272
+ field_value = all_finding_fields.get(field_name)
273
+ if isinstance(field_value, bool) or not isinstance(field_value, int):
274
+ raise UserInputError(
275
+ f"finding field {field_name!r} must be an int; "
276
+ f"got {type(field_value).__name__}"
277
+ )
278
+ return field_value
279
+
280
+
281
+ def parse_findings_json_file(findings_json_path: Path) -> list[AuditFinding]:
282
+ """Parse and validate the findings JSON file.
283
+
284
+ Args:
285
+ findings_json_path: Path to a JSON file whose root is a list of
286
+ finding objects matching the schema in the unresolved-thread
287
+ spec.
288
+
289
+ Returns:
290
+ List of :class:`AuditFinding`. Empty list when the file contains
291
+ an empty JSON array (used on CLEAN state).
292
+
293
+ Raises:
294
+ UserInputError: file missing, not parseable, JSON root not a list,
295
+ entries not dicts, required fields missing or mistyped, path
296
+ empty, or line value below ``1`` (the GitHub reviews API
297
+ rejects ``line=0`` as unprocessable).
298
+ """
299
+ if not findings_json_path.is_file():
300
+ raise UserInputError(
301
+ f"findings-json path not found or not a file: {findings_json_path}"
302
+ )
303
+ findings_text = findings_json_path.read_text(encoding="utf-8")
304
+ try:
305
+ parsed_value: object = json.loads(findings_text)
306
+ except json.JSONDecodeError as decode_error:
307
+ raise UserInputError(
308
+ f"findings-json file is not parseable as JSON: {decode_error}"
309
+ ) from decode_error
310
+ if not isinstance(parsed_value, list):
311
+ raise UserInputError(
312
+ f"findings JSON root must be a list; got {type(parsed_value).__name__}"
313
+ )
314
+ parsed_findings: list[AuditFinding] = []
315
+ for each_entry in parsed_value:
316
+ if not isinstance(each_entry, dict):
317
+ raise UserInputError(
318
+ "every findings JSON entry must be an object; got "
319
+ f"{type(each_entry).__name__}"
320
+ )
321
+ all_entry_fields: dict[str, object] = each_entry
322
+ for each_required_field in ALL_REQUIRED_FINDING_FIELDS:
323
+ if each_required_field not in all_entry_fields:
324
+ raise UserInputError(
325
+ f"finding entry missing required field: {each_required_field!r}"
326
+ )
327
+ severity_value = _require_string_field(all_entry_fields, JSON_FIELD_SEVERITY)
328
+ if severity_value not in ALL_SUPPORTED_SEVERITY_TAGS:
329
+ raise UserInputError(
330
+ f"finding severity {severity_value!r} not in supported set "
331
+ f"{list(ALL_SUPPORTED_SEVERITY_TAGS)!r}"
332
+ )
333
+ side_value = _require_string_field(all_entry_fields, JSON_FIELD_SIDE)
334
+ if side_value not in ALL_SUPPORTED_INLINE_COMMENT_SIDES:
335
+ raise UserInputError(
336
+ f"finding side {side_value!r} not in supported set "
337
+ f"{list(ALL_SUPPORTED_INLINE_COMMENT_SIDES)!r}"
338
+ )
339
+ path_value = _require_nonempty_string_field(all_entry_fields, JSON_FIELD_PATH)
340
+ line_value = _require_int_field(all_entry_fields, JSON_FIELD_LINE)
341
+ if line_value < 1:
342
+ raise UserInputError(
343
+ f"finding field {JSON_FIELD_LINE!r} must be >= 1 (GitHub "
344
+ f"reviews API rejects line=0); got {line_value} for path "
345
+ f"{path_value!r}"
346
+ )
347
+ parsed_findings.append(
348
+ AuditFinding(
349
+ path=path_value,
350
+ line=line_value,
351
+ side=side_value,
352
+ severity=severity_value,
353
+ description=_require_string_field(all_entry_fields, JSON_FIELD_DESCRIPTION),
354
+ fix_summary=_require_string_field(all_entry_fields, JSON_FIELD_FIX_SUMMARY),
355
+ )
356
+ )
357
+ return parsed_findings
358
+
359
+
360
+ def extract_audit_body_skeleton(template_markdown_text: str) -> str:
361
+ """Pull the audit review body skeleton out of the Phase 1 template markdown.
362
+
363
+ Locates the explicit HTML comment markers
364
+ ``AUDIT_BODY_SKELETON_OPEN_MARKER`` and
365
+ ``AUDIT_BODY_SKELETON_CLOSE_MARKER`` in the template, then captures
366
+ the fenced block (delimited by the token in ``TEMPLATE_FENCE_TOKEN``)
367
+ sitting between them. The captured text contains the placeholders the
368
+ rest of this script substitutes. Anchoring on explicit markers — not on
369
+ heading text or "the next fence after a heading" — keeps the contract
370
+ stable across template edits that rename headings, insert new fences,
371
+ or change fence syntax.
372
+
373
+ Args:
374
+ template_markdown_text: Full text of ``audit-reply-template.md``.
375
+
376
+ Returns:
377
+ Text between the fence markers, with any leading or trailing
378
+ newlines stripped.
379
+
380
+ Raises:
381
+ RuntimeError: open or close marker missing, markers out of order,
382
+ or the marker-bounded region is not a paired fence block.
383
+ """
384
+ open_marker_index = template_markdown_text.find(AUDIT_BODY_SKELETON_OPEN_MARKER)
385
+ if open_marker_index < 0:
386
+ raise RuntimeError(
387
+ f"audit body skeleton open marker not found in template: "
388
+ f"{AUDIT_BODY_SKELETON_OPEN_MARKER!r}"
389
+ )
390
+ region_start = open_marker_index + len(AUDIT_BODY_SKELETON_OPEN_MARKER)
391
+ close_marker_index = template_markdown_text.find(
392
+ AUDIT_BODY_SKELETON_CLOSE_MARKER, region_start
393
+ )
394
+ if close_marker_index < 0:
395
+ raise RuntimeError(
396
+ f"audit body skeleton close marker not found after open marker: "
397
+ f"{AUDIT_BODY_SKELETON_CLOSE_MARKER!r}"
398
+ )
399
+ region_text = template_markdown_text[region_start:close_marker_index]
400
+ fence_open_index = region_text.find(TEMPLATE_FENCE_TOKEN)
401
+ if fence_open_index < 0:
402
+ raise RuntimeError(
403
+ "audit body skeleton marker region has no opening fence"
404
+ )
405
+ skeleton_start = fence_open_index + len(TEMPLATE_FENCE_TOKEN)
406
+ fence_close_index = region_text.find(TEMPLATE_FENCE_TOKEN, skeleton_start)
407
+ if fence_close_index < 0:
408
+ raise RuntimeError(
409
+ "audit body skeleton marker region has no closing fence"
410
+ )
411
+ return region_text[skeleton_start:fence_close_index].strip("\n")
412
+
413
+
414
+ def load_audit_body_skeleton() -> str:
415
+ """Read ``audit-reply-template.md`` and return the audit body skeleton.
416
+
417
+ Returns:
418
+ Skeleton text containing the placeholders the body formatter
419
+ substitutes. Reads from disk every call so a docs change picks
420
+ up without restarting the caller.
421
+
422
+ Raises:
423
+ RuntimeError: template file missing or malformed.
424
+ """
425
+ template_file_path = template_path()
426
+ if not template_file_path.is_file():
427
+ raise RuntimeError(f"audit-reply-template.md not found at {template_file_path}")
428
+ template_text = template_file_path.read_text(encoding="utf-8")
429
+ return extract_audit_body_skeleton(template_text)
430
+
431
+
432
+ def short_commit_sha(commit_sha: str) -> str:
433
+ """Return the short form of a Git SHA per ``SHORT_SHA_LENGTH``.
434
+
435
+ Args:
436
+ commit_sha: Full or already-short Git SHA.
437
+
438
+ Returns:
439
+ First ``SHORT_SHA_LENGTH`` characters of the input.
440
+ """
441
+ return commit_sha[:SHORT_SHA_LENGTH]
442
+
443
+
444
+ def skill_display_name(skill_argument: str) -> str:
445
+ """Return the title-cased display form of a skill name.
446
+
447
+ Args:
448
+ skill_argument: Lowercase skill identifier (``bugteam``,
449
+ ``findbugs``, ``qbug``).
450
+
451
+ Returns:
452
+ Title-cased form for embedding in the review body
453
+ (``Bugteam``, ``Findbugs``, ``Qbug``).
454
+ """
455
+ return skill_argument.title()
456
+
457
+
458
+ def severity_counts_by_tag(
459
+ all_findings: list[AuditFinding],
460
+ ) -> dict[str, int]:
461
+ """Tally findings by severity tag.
462
+
463
+ Args:
464
+ all_findings: Parsed findings list (empty on CLEAN state).
465
+
466
+ Returns:
467
+ Mapping with every key in ``ALL_SUPPORTED_SEVERITY_TAGS`` present,
468
+ even when its count is ``0``. Callers can index the result without
469
+ a ``KeyError``.
470
+ """
471
+ counts_by_tag: dict[str, int] = {
472
+ each_tag: 0 for each_tag in ALL_SUPPORTED_SEVERITY_TAGS
473
+ }
474
+ for each_finding in all_findings:
475
+ counts_by_tag[each_finding.severity] += 1
476
+ return counts_by_tag
477
+
478
+
479
+ def build_details_block(all_findings: list[AuditFinding]) -> str:
480
+ """Render the collapsed ``<details>`` block listing every finding.
481
+
482
+ Args:
483
+ all_findings: Non-empty list of findings (DIRTY state only).
484
+
485
+ Returns:
486
+ Multi-line markdown string wrapped in ``<details>`` / ``</details>``,
487
+ or an empty string when no findings were supplied (CLEAN state).
488
+ """
489
+ if not all_findings:
490
+ return ""
491
+ rendered_bullets = [
492
+ DETAILS_BLOCK_BULLET_TEMPLATE.format(
493
+ severity=each_finding.severity,
494
+ path=each_finding.path,
495
+ line=each_finding.line,
496
+ description=each_finding.description,
497
+ )
498
+ for each_finding in all_findings
499
+ ]
500
+ return (
501
+ DETAILS_BLOCK_HEADER + "\n" + "\n".join(rendered_bullets) + DETAILS_BLOCK_FOOTER
502
+ )
503
+
504
+
505
+ def fill_audit_body_skeleton(
506
+ skeleton_text: str,
507
+ skill_argument: str,
508
+ state_argument: str,
509
+ commit_sha: str,
510
+ all_findings: list[AuditFinding],
511
+ ) -> str:
512
+ """Substitute placeholders in the audit body skeleton with concrete values.
513
+
514
+ Args:
515
+ skeleton_text: Skeleton produced by :func:`load_audit_body_skeleton`.
516
+ skill_argument: One of ``ALL_SUPPORTED_SKILLS``.
517
+ state_argument: One of ``ALL_SUPPORTED_STATES``.
518
+ commit_sha: Full SHA of the commit the review attaches to.
519
+ all_findings: Parsed findings list.
520
+
521
+ Returns:
522
+ Markdown body string ready to send as the ``body`` field of the
523
+ reviews POST payload.
524
+ """
525
+ display_skill = skill_display_name(skill_argument)
526
+ short_commit = short_commit_sha(commit_sha)
527
+ counts_by_tag = severity_counts_by_tag(all_findings)
528
+ is_clean = state_argument == STATE_CLEAN
529
+ state_label = STATE_LABEL_FOR_CLEAN if is_clean else STATE_LABEL_FOR_DIRTY
530
+ heading_text = HEADING_FOR_CLEAN if is_clean else HEADING_FOR_DIRTY
531
+ summary_template = (
532
+ SUMMARY_PARAGRAPH_CLEAN_TEMPLATE
533
+ if is_clean
534
+ else SUMMARY_PARAGRAPH_DIRTY_TEMPLATE
535
+ )
536
+ summary_paragraph_text = summary_template.format(
537
+ skill_display=display_skill,
538
+ short_commit=short_commit,
539
+ findings_count=len(all_findings),
540
+ )
541
+ details_block_text = "" if is_clean else build_details_block(all_findings)
542
+ placeholder_replacements: list[tuple[str, str]] = [
543
+ (PLACEHOLDER_SKILL, display_skill),
544
+ (PLACEHOLDER_STATE_LABEL, state_label),
545
+ (PLACEHOLDER_HEADING, heading_text),
546
+ (PLACEHOLDER_SUMMARY_PARAGRAPH, summary_paragraph_text),
547
+ (PLACEHOLDER_FINDINGS_COUNT, str(len(all_findings))),
548
+ (PLACEHOLDER_P0_COUNT, str(counts_by_tag[SEVERITY_TAG_P0])),
549
+ (PLACEHOLDER_P1_COUNT, str(counts_by_tag[SEVERITY_TAG_P1])),
550
+ (PLACEHOLDER_P2_COUNT, str(counts_by_tag[SEVERITY_TAG_P2])),
551
+ (PLACEHOLDER_DETAILS_BLOCK, details_block_text),
552
+ ]
553
+ filled_text = skeleton_text
554
+ for each_placeholder, each_replacement in placeholder_replacements:
555
+ filled_text = filled_text.replace(each_placeholder, each_replacement)
556
+ return filled_text
557
+
558
+
559
+ def build_inline_comments_payload(
560
+ skill_argument: str,
561
+ all_findings: list[AuditFinding],
562
+ ) -> list[dict[str, object]]:
563
+ """Render the findings list as a GitHub reviews ``comments[]`` payload.
564
+
565
+ Args:
566
+ skill_argument: One of ``ALL_SUPPORTED_SKILLS``; embedded in each
567
+ comment's body text.
568
+ all_findings: Findings to render (empty list on CLEAN state).
569
+
570
+ Returns:
571
+ List of dictionaries matching the GitHub API shape for inline
572
+ review comments: ``path``, ``line``, ``side``, ``body``.
573
+ """
574
+ display_skill = skill_display_name(skill_argument)
575
+ rendered_comments: list[dict[str, object]] = []
576
+ for each_finding in all_findings:
577
+ comment_body_text = INLINE_COMMENT_BODY_TEMPLATE.format(
578
+ severity=each_finding.severity,
579
+ skill_display=display_skill,
580
+ description=each_finding.description,
581
+ fix_summary=each_finding.fix_summary,
582
+ )
583
+ rendered_comments.append(
584
+ {
585
+ INLINE_COMMENT_FIELD_PATH: each_finding.path,
586
+ INLINE_COMMENT_FIELD_LINE: each_finding.line,
587
+ INLINE_COMMENT_FIELD_SIDE: each_finding.side,
588
+ INLINE_COMMENT_FIELD_BODY: comment_body_text,
589
+ }
590
+ )
591
+ return rendered_comments
592
+
593
+
594
+ def review_event_for_state(state_argument: str) -> str:
595
+ """Return the GitHub API ``event`` string for an audit state.
596
+
597
+ Args:
598
+ state_argument: One of ``ALL_SUPPORTED_STATES``.
599
+
600
+ Returns:
601
+ ``APPROVE`` for CLEAN, ``REQUEST_CHANGES`` for DIRTY.
602
+
603
+ Raises:
604
+ UserInputError: state outside the supported set.
605
+ """
606
+ if state_argument == STATE_CLEAN:
607
+ return GITHUB_REVIEW_EVENT_APPROVE
608
+ if state_argument == STATE_DIRTY:
609
+ return GITHUB_REVIEW_EVENT_REQUEST_CHANGES
610
+ raise UserInputError(
611
+ f"state {state_argument!r} not in supported set {list(ALL_SUPPORTED_STATES)!r}"
612
+ )
613
+
614
+
615
+ def build_review_request_payload(
616
+ state_argument: str,
617
+ commit_sha: str,
618
+ review_body_text: str,
619
+ all_inline_comments: list[dict[str, object]],
620
+ ) -> dict[str, object]:
621
+ """Assemble the JSON payload sent to the reviews endpoint.
622
+
623
+ Args:
624
+ state_argument: One of ``ALL_SUPPORTED_STATES``.
625
+ commit_sha: SHA bound into ``commit_id``.
626
+ review_body_text: Already-formatted review body.
627
+ all_inline_comments: Output of :func:`build_inline_comments_payload`;
628
+ empty list on CLEAN state.
629
+
630
+ Returns:
631
+ Dictionary suitable for ``json.dumps`` and sending as the request
632
+ body to ``POST /repos/{owner}/{repo}/pulls/{N}/reviews``.
633
+ """
634
+ return {
635
+ REVIEW_REQUEST_FIELD_COMMIT_ID: commit_sha,
636
+ REVIEW_REQUEST_FIELD_BODY: review_body_text,
637
+ REVIEW_REQUEST_FIELD_EVENT: review_event_for_state(state_argument),
638
+ REVIEW_REQUEST_FIELD_COMMENTS: all_inline_comments,
639
+ }
640
+
641
+
642
+ def resolve_github_token() -> str:
643
+ """Return the GitHub token to authenticate the reviews POST with.
644
+
645
+ Precedence (first non-empty wins):
646
+ - ``GH_TOKEN`` env var
647
+ - ``GITHUB_TOKEN`` env var
648
+ - ``gh auth token`` (current active ``gh`` account)
649
+
650
+ Returns:
651
+ Token string, stripped of trailing whitespace.
652
+
653
+ Raises:
654
+ UserInputError: every source above failed or returned empty.
655
+ """
656
+ for each_env_var_name in ALL_GH_TOKEN_ENV_VAR_NAMES:
657
+ env_token_value = os.environ.get(each_env_var_name, "").strip()
658
+ if env_token_value:
659
+ return env_token_value
660
+ try:
661
+ completion = subprocess.run(
662
+ list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS),
663
+ capture_output=True,
664
+ text=True,
665
+ encoding="utf-8",
666
+ errors="replace",
667
+ check=False,
668
+ )
669
+ except FileNotFoundError as missing_gh_error:
670
+ raise UserInputError(
671
+ "`gh` CLI not installed or not on PATH; cannot resolve a GitHub "
672
+ "token"
673
+ ) from missing_gh_error
674
+ if completion.returncode != 0:
675
+ raise UserInputError(
676
+ f"`gh auth token` failed (exit {completion.returncode}): "
677
+ f"{completion.stderr.strip()}"
678
+ )
679
+ token_text = completion.stdout.strip()
680
+ if not token_text:
681
+ raise UserInputError("`gh auth token` returned empty output")
682
+ return token_text
683
+
684
+
685
+ def build_reviews_endpoint_url(owner: str, repo: str, pr_number: int) -> str:
686
+ """Compose the full reviews-endpoint URL for a PR.
687
+
688
+ Args:
689
+ owner: Repository owner.
690
+ repo: Repository name.
691
+ pr_number: Pull request number.
692
+
693
+ Returns:
694
+ Full URL string ready to pass to :class:`urllib.request.Request`.
695
+ """
696
+ api_path = REVIEWS_API_PATH_TEMPLATE.format(
697
+ owner=owner,
698
+ repo=repo,
699
+ pr_number=pr_number,
700
+ )
701
+ return f"{GITHUB_API_BASE_URL}{api_path}"
702
+
703
+
704
+ def _build_authenticated_request(
705
+ endpoint_url: str,
706
+ token: str,
707
+ all_request_fields: dict[str, object],
708
+ ) -> urllib.request.Request:
709
+ encoded_body = json.dumps(all_request_fields).encode("utf-8")
710
+ request_object = urllib.request.Request(
711
+ url=endpoint_url,
712
+ data=encoded_body,
713
+ method=HTTP_METHOD_POST,
714
+ )
715
+ request_object.add_header(
716
+ HTTP_HEADER_AUTHORIZATION, f"{HTTP_AUTHORIZATION_BEARER_PREFIX}{token}"
717
+ )
718
+ request_object.add_header(HTTP_HEADER_ACCEPT, GITHUB_API_ACCEPT_HEADER)
719
+ request_object.add_header(HTTP_HEADER_CONTENT_TYPE, HTTP_REQUEST_CONTENT_TYPE)
720
+ request_object.add_header(HTTP_HEADER_GITHUB_API_VERSION, GITHUB_API_VERSION_HEADER)
721
+ request_object.add_header(HTTP_HEADER_USER_AGENT, GITHUB_API_USER_AGENT)
722
+ return request_object
723
+
724
+
725
+ def execute_review_post_attempt(
726
+ endpoint_url: str,
727
+ token: str,
728
+ all_request_fields: dict[str, object],
729
+ ) -> tuple[int, str]:
730
+ """Make one HTTP POST to the reviews endpoint and return its outcome.
731
+
732
+ Args:
733
+ endpoint_url: Full URL produced by :func:`build_reviews_endpoint_url`.
734
+ token: GitHub token string.
735
+ all_request_fields: Payload produced by :func:`build_review_request_payload`.
736
+
737
+ Returns:
738
+ Tuple ``(status_code, response_body_text)``. ``status_code`` is the
739
+ HTTP status; ``response_body_text`` is the decoded response body.
740
+ Client- and server-error responses are returned through this path
741
+ (not raised) so the retry loop can decide whether to back off.
742
+
743
+ Raises:
744
+ urllib.error.URLError: transport-level failure (no DNS, no TCP).
745
+ """
746
+ request_object = _build_authenticated_request(endpoint_url, token, all_request_fields)
747
+ try:
748
+ with urllib.request.urlopen(
749
+ request_object, timeout=HTTP_REQUEST_TIMEOUT_SECONDS
750
+ ) as response_object:
751
+ response_body = response_object.read().decode("utf-8", errors="replace")
752
+ return response_object.status, response_body
753
+ except urllib.error.HTTPError as http_error:
754
+ error_body_text = http_error.read().decode("utf-8", errors="replace")
755
+ return http_error.code, error_body_text
756
+
757
+
758
+ def post_review_with_retries(
759
+ endpoint_url: str,
760
+ token: str,
761
+ all_request_fields: dict[str, object],
762
+ ) -> PostedReview:
763
+ """POST the review with retries on non-success outcomes.
764
+
765
+ Backoffs between attempts come from ``ALL_RETRY_BACKOFF_SECONDS``.
766
+ After every retry has failed, raise :class:`RetryExhaustedError` so
767
+ the entry point can exit with the retry-exhausted code.
768
+
769
+ Args:
770
+ endpoint_url: Full URL produced by :func:`build_reviews_endpoint_url`.
771
+ token: GitHub token string.
772
+ all_request_fields: Payload produced by :func:`build_review_request_payload`.
773
+
774
+ Returns:
775
+ :class:`PostedReview` carrying the response's ``html_url``, raw
776
+ body, and status code.
777
+
778
+ Raises:
779
+ RetryExhaustedError: every attempt across the four-attempt loop
780
+ (one initial attempt plus three retries) returned a non-2xx
781
+ response, raised a transport-level
782
+ :class:`urllib.error.URLError`, or produced a 2xx response
783
+ whose body could not be parsed for ``html_url``.
784
+ """
785
+ last_status_code: int = 0
786
+ last_response_text: str = ""
787
+ total_attempts = MAX_RETRY_ATTEMPTS + 1
788
+ for each_attempt_index in range(total_attempts):
789
+ try:
790
+ status_code, response_text = execute_review_post_attempt(
791
+ endpoint_url, token, all_request_fields
792
+ )
793
+ except urllib.error.URLError as transport_error:
794
+ status_code = 0
795
+ response_text = f"transport-level URLError: {transport_error.reason!r}"
796
+ last_status_code = status_code
797
+ last_response_text = response_text
798
+ is_success = (
799
+ HTTP_STATUS_SUCCESS_RANGE_LOW
800
+ <= status_code
801
+ < HTTP_STATUS_SUCCESS_RANGE_HIGH
802
+ )
803
+ if is_success:
804
+ try:
805
+ html_url_value = extract_html_url_field(response_text)
806
+ except RuntimeError as malformed_body_error:
807
+ raise RetryExhaustedError(
808
+ f"reviews POST returned {status_code} but the response body "
809
+ f"was unusable: {malformed_body_error}; "
810
+ f"body={response_text[:ERROR_RESPONSE_PREVIEW_CHARS]!r}"
811
+ ) from malformed_body_error
812
+ return PostedReview(
813
+ html_url=html_url_value,
814
+ raw_response_text=response_text,
815
+ status_code=status_code,
816
+ )
817
+ is_last_attempt = each_attempt_index == MAX_RETRY_ATTEMPTS
818
+ if is_last_attempt:
819
+ break
820
+ time.sleep(ALL_RETRY_BACKOFF_SECONDS[each_attempt_index])
821
+ raise RetryExhaustedError(
822
+ f"reviews POST failed after {total_attempts} attempts; "
823
+ f"last status={last_status_code}; "
824
+ f"last body={last_response_text[:ERROR_RESPONSE_PREVIEW_CHARS]!r}"
825
+ )
826
+
827
+
828
+ def extract_html_url_field(response_text: str) -> str:
829
+ """Pull the ``html_url`` field out of a successful reviews POST response.
830
+
831
+ Args:
832
+ response_text: Decoded response body.
833
+
834
+ Returns:
835
+ Value of the ``html_url`` field.
836
+
837
+ Raises:
838
+ RuntimeError: response is not JSON, root is not an object, or
839
+ ``html_url`` is missing or not a string.
840
+ """
841
+ try:
842
+ parsed_value: object = json.loads(response_text)
843
+ except json.JSONDecodeError as decode_error:
844
+ raise RuntimeError(
845
+ f"review response is not parseable as JSON: {decode_error}"
846
+ ) from decode_error
847
+ if not isinstance(parsed_value, dict):
848
+ raise RuntimeError(
849
+ f"review response root is not an object; got {type(parsed_value).__name__}"
850
+ )
851
+ typed_response: dict[str, object] = parsed_value
852
+ html_url_value = typed_response.get(REVIEW_RESPONSE_FIELD_HTML_URL)
853
+ if not isinstance(html_url_value, str):
854
+ raise RuntimeError(
855
+ f"review response missing string {REVIEW_RESPONSE_FIELD_HTML_URL!r}"
856
+ )
857
+ return html_url_value
858
+
859
+
860
+ def post_audit_review(parsed_arguments: argparse.Namespace) -> PostedReview:
861
+ """Top-level pipeline: load findings, build body, POST, return result.
862
+
863
+ Args:
864
+ parsed_arguments: Output of :func:`parse_command_line_arguments`.
865
+
866
+ Returns:
867
+ :class:`PostedReview` containing the new review's ``html_url``.
868
+
869
+ Raises:
870
+ UserInputError: bad CLI argument, malformed findings JSON, state
871
+ inconsistent with findings list (CLEAN+non-empty or
872
+ DIRTY+empty), missing ``gh`` CLI, ``gh auth token`` failure,
873
+ or ``audit-reply-template.md`` misconfigured (translated from
874
+ :class:`RuntimeError` at the boundary).
875
+ RetryExhaustedError: every retry failed against the reviews API.
876
+ """
877
+ all_findings = parse_findings_json_file(parsed_arguments.findings_json)
878
+ is_clean_state = parsed_arguments.state == STATE_CLEAN
879
+ if is_clean_state and all_findings:
880
+ raise UserInputError(
881
+ f"state {STATE_CLEAN} requires an empty findings list; got "
882
+ f"{len(all_findings)} finding(s)"
883
+ )
884
+ if not is_clean_state and not all_findings:
885
+ raise UserInputError(
886
+ f"state {STATE_DIRTY} requires at least one finding; got an "
887
+ f"empty findings list"
888
+ )
889
+ try:
890
+ skeleton_text = load_audit_body_skeleton()
891
+ except RuntimeError as template_error:
892
+ raise UserInputError(
893
+ f"audit-reply-template.md misconfigured: {template_error}"
894
+ ) from template_error
895
+ review_body_text = fill_audit_body_skeleton(
896
+ skeleton_text=skeleton_text,
897
+ skill_argument=parsed_arguments.skill,
898
+ state_argument=parsed_arguments.state,
899
+ commit_sha=parsed_arguments.commit,
900
+ all_findings=all_findings,
901
+ )
902
+ inline_comments_payload = (
903
+ []
904
+ if parsed_arguments.state == STATE_CLEAN
905
+ else build_inline_comments_payload(parsed_arguments.skill, all_findings)
906
+ )
907
+ all_request_fields = build_review_request_payload(
908
+ state_argument=parsed_arguments.state,
909
+ commit_sha=parsed_arguments.commit,
910
+ review_body_text=review_body_text,
911
+ all_inline_comments=inline_comments_payload,
912
+ )
913
+ endpoint_url = build_reviews_endpoint_url(
914
+ owner=parsed_arguments.owner,
915
+ repo=parsed_arguments.repo,
916
+ pr_number=parsed_arguments.pr_number,
917
+ )
918
+ token_text = resolve_github_token()
919
+ return post_review_with_retries(endpoint_url, token_text, all_request_fields)
920
+
921
+
922
+ def main(all_arguments: list[str]) -> int:
923
+ """Entry-point. Returns the process exit code.
924
+
925
+ Args:
926
+ all_arguments: ``sys.argv[1:]`` or equivalent.
927
+
928
+ Returns:
929
+ ``0`` on success (emits the new review's ``html_url`` to stdout),
930
+ ``EXIT_CODE_USER_ERROR`` on user input failure,
931
+ ``EXIT_CODE_RETRY_EXHAUSTED`` on retry exhaustion.
932
+ """
933
+ try:
934
+ parsed_arguments = parse_command_line_arguments(all_arguments)
935
+ posted_review = post_audit_review(parsed_arguments)
936
+ except UserInputError as user_error:
937
+ print(f"post_audit_thread: {user_error}", file=sys.stderr)
938
+ return EXIT_CODE_USER_ERROR
939
+ except RetryExhaustedError as retry_error:
940
+ print(f"post_audit_thread: {retry_error}", file=sys.stderr)
941
+ return EXIT_CODE_RETRY_EXHAUSTED
942
+ print(posted_review.html_url)
943
+ return 0
944
+
945
+
946
+ if __name__ == "__main__":
947
+ raise SystemExit(main(sys.argv[1:]))