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