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,1116 @@
1
+ """Live smoke tests for post_audit_thread.py.
2
+
3
+ Runs against the real GitHub repo ``JonEcho/tests``. The class opens a
4
+ single throwaway draft PR in ``setUpClass`` and reuses it across every
5
+ test in the class; ``tearDownClass`` closes the PR with
6
+ ``--delete-branch``. The CLEAN and DIRTY tests post real reviews against
7
+ the shared PR. The retry tests stub the GitHub endpoint with a localhost
8
+ HTTP server so the four-attempt retry loop runs deterministically without
9
+ contacting api.github.com, but still reference the shared PR's number and
10
+ HEAD SHA so the request URL is exercised end-to-end. Authentication uses
11
+ ``gh auth token`` — empty token fails loudly per spec.
12
+
13
+ Test files are exempt from the no-comment, magic-value, banned-identifier,
14
+ and constants-location enforcer rules.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import http.server
20
+ import json
21
+ import os
22
+ import shutil
23
+ import stat
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import textwrap
28
+ import threading
29
+ import time
30
+ import unittest
31
+ import urllib.parse
32
+ import uuid
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ THIS_FILE_DIRECTORY = Path(__file__).resolve().parent
37
+ SCRIPT_DIRECTORY = THIS_FILE_DIRECTORY.parent
38
+
39
+ sys.modules.pop("config", None)
40
+ if str(SCRIPT_DIRECTORY) not in sys.path:
41
+ sys.path.insert(0, str(SCRIPT_DIRECTORY))
42
+
43
+ from config.post_audit_thread_constants import ( # noqa: E402
44
+ ALL_GH_AUTH_TOKEN_COMMAND_PARTS,
45
+ ALL_RETRY_BACKOFF_SECONDS,
46
+ BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME,
47
+ GH_TOKEN_ENV_VAR_NAME,
48
+ GITHUB_TOKEN_ENV_VAR_NAME,
49
+ CLI_FLAG_COMMIT,
50
+ CLI_FLAG_FINDINGS_JSON,
51
+ CLI_FLAG_OWNER,
52
+ CLI_FLAG_PR_NUMBER,
53
+ CLI_FLAG_REPO,
54
+ CLI_FLAG_SKILL,
55
+ CLI_FLAG_STATE,
56
+ EXIT_CODE_RETRY_EXHAUSTED,
57
+ INLINE_COMMENT_SIDE_RIGHT,
58
+ JSON_FIELD_DESCRIPTION,
59
+ JSON_FIELD_FIX_SUMMARY,
60
+ JSON_FIELD_LINE,
61
+ JSON_FIELD_PATH,
62
+ JSON_FIELD_SEVERITY,
63
+ JSON_FIELD_SIDE,
64
+ MAX_RETRY_ATTEMPTS,
65
+ SEVERITY_TAG_P0,
66
+ SEVERITY_TAG_P1,
67
+ SEVERITY_TAG_P2,
68
+ SINGLE_REVIEW_API_PATH_TEMPLATE,
69
+ SINGLE_REVIEW_COMMENTS_API_PATH_TEMPLATE,
70
+ SKILL_BUGTEAM,
71
+ STATE_CLEAN,
72
+ STATE_DIRTY,
73
+ )
74
+ from post_audit_thread import ( # noqa: E402
75
+ UserInputError,
76
+ build_reviews_endpoint_url,
77
+ fetch_gh_token_for_account,
78
+ list_authenticated_gh_account_logins,
79
+ query_active_gh_user_login,
80
+ query_pull_request_author_login,
81
+ resolve_reviewer_token,
82
+ )
83
+
84
+ LIVE_TEST_OWNER = "JonEcho"
85
+ LIVE_TEST_REPO = "tests"
86
+ LIVE_TEST_BRANCH_PREFIX = "pr-loop-test"
87
+ LIVE_TEST_PR_TITLE = "TEST: post_audit_thread smoke test (auto-closed)"
88
+ LIVE_TEST_PR_BODY = (
89
+ "Throwaway PR for post_audit_thread.py live smoke tests. "
90
+ "Auto-created by `test_post_audit_thread.py`; closed in `tearDownClass`."
91
+ )
92
+ LIVE_TEST_BASE_BRANCH = "main"
93
+ LIVE_TEST_FIXTURE_FILENAME = "post-audit-thread-fixture.md"
94
+ LIVE_TEST_FIXTURE_CONTENT = (
95
+ "# Throwaway test fixture\n\n"
96
+ "Created by `test_post_audit_thread.py` to satisfy GitHub's "
97
+ "non-empty PR-diff requirement. Deleted when the PR closes.\n"
98
+ )
99
+ LIVE_TEST_FIXTURE_LINE_FOR_FINDING_ONE = 1
100
+ LIVE_TEST_FIXTURE_LINE_FOR_FINDING_TWO = 2
101
+ LIVE_TEST_FIXTURE_LINE_FOR_FINDING_THREE = 3
102
+
103
+ SCRIPT_PATH = SCRIPT_DIRECTORY / "post_audit_thread.py"
104
+ REPO_FULL_NAME = f"{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}"
105
+
106
+ LIVE_TEST_AUDIT_ACCOUNT_NAME = "jl-cmd"
107
+
108
+ GH_EVENT_APPROVED = "APPROVED"
109
+ GH_EVENT_CHANGES_REQUESTED = "CHANGES_REQUESTED"
110
+
111
+ UUID_SUFFIX_LENGTH = 8
112
+
113
+ REVIEW_URL_ID_DELIMITER = "#pullrequestreview-"
114
+
115
+
116
+ def _strip_read_only_and_retry(
117
+ removal_function: Any, target_path: str, *_exc_info: Any
118
+ ) -> None:
119
+ try:
120
+ os.chmod(target_path, stat.S_IWRITE)
121
+ removal_function(target_path)
122
+ except OSError:
123
+ pass
124
+
125
+
126
+ def force_remove_directory(target_path: Path) -> None:
127
+ if not target_path.exists():
128
+ return
129
+ handler_kwargs: dict[str, Any]
130
+ if sys.version_info >= (3, 12):
131
+ handler_kwargs = {"onexc": _strip_read_only_and_retry}
132
+ else:
133
+ handler_kwargs = {"onerror": _strip_read_only_and_retry}
134
+ try:
135
+ shutil.rmtree(str(target_path), **handler_kwargs)
136
+ except OSError as removal_error:
137
+ sys.stderr.write(
138
+ f"force_remove_directory: could not remove {target_path}: {removal_error}\n"
139
+ )
140
+
141
+
142
+ def resolve_gh_auth_token() -> str:
143
+ completion = subprocess.run(
144
+ list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS),
145
+ capture_output=True,
146
+ text=True,
147
+ encoding="utf-8",
148
+ check=False,
149
+ )
150
+ if completion.returncode != 0:
151
+ raise AssertionError(
152
+ f"`gh auth token` failed: rc={completion.returncode} "
153
+ f"stderr={completion.stderr.strip()} — live tests require gh to "
154
+ f"be authenticated against github.com"
155
+ )
156
+ token_text = completion.stdout.strip()
157
+ if not token_text:
158
+ raise AssertionError(
159
+ "`gh auth token` returned empty output — not authenticated"
160
+ )
161
+ return token_text
162
+
163
+
164
+ def resolve_audit_account_token(account_name: str) -> str:
165
+ completion = subprocess.run(
166
+ list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS) + ["--user", account_name],
167
+ capture_output=True,
168
+ text=True,
169
+ encoding="utf-8",
170
+ check=False,
171
+ )
172
+ if completion.returncode != 0:
173
+ raise AssertionError(
174
+ f"`gh auth token --user {account_name}` failed — the audit-side "
175
+ f"account must be authenticated separately from the PR author so "
176
+ f"GitHub allows APPROVE / REQUEST_CHANGES on the throwaway PR. "
177
+ f"rc={completion.returncode} stderr={completion.stderr.strip()}"
178
+ )
179
+ token_text = completion.stdout.strip()
180
+ if not token_text:
181
+ raise AssertionError(
182
+ f"`gh auth token --user {account_name}` returned empty output"
183
+ )
184
+ return token_text
185
+
186
+
187
+ def gh_api_object_json(api_path: str) -> dict[str, Any]:
188
+ completion = subprocess.run(
189
+ ["gh", "api", api_path],
190
+ capture_output=True,
191
+ text=True,
192
+ encoding="utf-8",
193
+ check=True,
194
+ )
195
+ parsed_object: Any = json.loads(completion.stdout)
196
+ if not isinstance(parsed_object, dict):
197
+ raise AssertionError(
198
+ f"unexpected gh api object shape: {type(parsed_object).__name__}"
199
+ )
200
+ return parsed_object
201
+
202
+
203
+ def review_id_from_html_url(html_url: str) -> int:
204
+ suffix_parts = html_url.rsplit(REVIEW_URL_ID_DELIMITER, 1)
205
+ if len(suffix_parts) != 2:
206
+ raise AssertionError(
207
+ f"html_url {html_url!r} missing {REVIEW_URL_ID_DELIMITER!r} suffix"
208
+ )
209
+ return int(suffix_parts[1])
210
+
211
+
212
+ def gh_api_paginated_json(api_path: str) -> list[dict[str, Any]]:
213
+ completion = subprocess.run(
214
+ ["gh", "api", api_path, "--paginate", "--slurp"],
215
+ capture_output=True,
216
+ text=True,
217
+ encoding="utf-8",
218
+ check=True,
219
+ )
220
+ parsed: Any = json.loads(completion.stdout)
221
+ if not isinstance(parsed, list):
222
+ raise AssertionError(
223
+ f"unexpected gh api response shape: {type(parsed).__name__}"
224
+ )
225
+ flattened: list[dict[str, Any]] = []
226
+ for each_page in parsed:
227
+ if isinstance(each_page, list):
228
+ for each_item in each_page:
229
+ if isinstance(each_item, dict):
230
+ flattened.append(each_item)
231
+ elif isinstance(each_page, dict):
232
+ flattened.append(each_page)
233
+ return flattened
234
+
235
+
236
+ def write_pr_body_temporary_file(body_text: str) -> Path:
237
+ handle, body_path_str = tempfile.mkstemp(suffix=".md", prefix="post-audit-pr-body-")
238
+ os.close(handle)
239
+ body_path = Path(body_path_str)
240
+ body_path.write_text(body_text, encoding="utf-8")
241
+ return body_path
242
+
243
+
244
+ def create_throwaway_pr(
245
+ clone_directory: Path,
246
+ branch_name: str,
247
+ ) -> tuple[int, str]:
248
+ subprocess.run(
249
+ [
250
+ "gh",
251
+ "repo",
252
+ "clone",
253
+ REPO_FULL_NAME,
254
+ str(clone_directory),
255
+ "--",
256
+ "--branch",
257
+ LIVE_TEST_BASE_BRANCH,
258
+ "--single-branch",
259
+ "--depth",
260
+ "1",
261
+ ],
262
+ check=True,
263
+ capture_output=True,
264
+ text=True,
265
+ )
266
+ subprocess.run(
267
+ [
268
+ "git",
269
+ "-C",
270
+ str(clone_directory),
271
+ "config",
272
+ "--local",
273
+ "core.hooksPath",
274
+ str(clone_directory / ".git" / "hooks"),
275
+ ],
276
+ check=True,
277
+ capture_output=True,
278
+ text=True,
279
+ )
280
+ subprocess.run(
281
+ ["git", "-C", str(clone_directory), "checkout", "-b", branch_name],
282
+ check=True,
283
+ capture_output=True,
284
+ text=True,
285
+ )
286
+ fixture_path = clone_directory / LIVE_TEST_FIXTURE_FILENAME
287
+ fixture_path.write_text(LIVE_TEST_FIXTURE_CONTENT, encoding="utf-8")
288
+ subprocess.run(
289
+ ["git", "-C", str(clone_directory), "add", LIVE_TEST_FIXTURE_FILENAME],
290
+ check=True,
291
+ capture_output=True,
292
+ text=True,
293
+ )
294
+ subprocess.run(
295
+ [
296
+ "git",
297
+ "-C",
298
+ str(clone_directory),
299
+ "commit",
300
+ "-m",
301
+ "test: post_audit_thread.py live smoke fixture",
302
+ ],
303
+ check=True,
304
+ capture_output=True,
305
+ text=True,
306
+ )
307
+ head_sha_completion = subprocess.run(
308
+ ["git", "-C", str(clone_directory), "rev-parse", "HEAD"],
309
+ capture_output=True,
310
+ text=True,
311
+ encoding="utf-8",
312
+ check=True,
313
+ )
314
+ head_sha = head_sha_completion.stdout.strip()
315
+ subprocess.run(
316
+ ["git", "-C", str(clone_directory), "push", "-u", "origin", branch_name],
317
+ check=True,
318
+ capture_output=True,
319
+ text=True,
320
+ )
321
+ body_path = write_pr_body_temporary_file(LIVE_TEST_PR_BODY)
322
+ try:
323
+ create_completion = subprocess.run(
324
+ [
325
+ "gh",
326
+ "pr",
327
+ "create",
328
+ "--draft",
329
+ "--head",
330
+ branch_name,
331
+ "--base",
332
+ LIVE_TEST_BASE_BRANCH,
333
+ "--title",
334
+ LIVE_TEST_PR_TITLE,
335
+ "--body-file",
336
+ str(body_path),
337
+ "--repo",
338
+ REPO_FULL_NAME,
339
+ ],
340
+ capture_output=True,
341
+ text=True,
342
+ encoding="utf-8",
343
+ check=True,
344
+ cwd=str(clone_directory),
345
+ )
346
+ finally:
347
+ try:
348
+ body_path.unlink()
349
+ except OSError:
350
+ pass
351
+ pr_url = create_completion.stdout.strip().splitlines()[-1]
352
+ parsed_pr_url = urllib.parse.urlparse(pr_url)
353
+ pr_number = int(parsed_pr_url.path.rsplit("/", 1)[-1])
354
+ return pr_number, head_sha
355
+
356
+
357
+ def close_throwaway_pr(pr_number: int) -> None:
358
+ subprocess.run(
359
+ [
360
+ "gh",
361
+ "pr",
362
+ "close",
363
+ str(pr_number),
364
+ "--delete-branch",
365
+ "--repo",
366
+ REPO_FULL_NAME,
367
+ ],
368
+ capture_output=True,
369
+ text=True,
370
+ check=False,
371
+ )
372
+
373
+
374
+ def remove_local_clone(clone_directory: Path) -> None:
375
+ force_remove_directory(clone_directory)
376
+
377
+
378
+ def best_effort_delete_remote_branch(branch_name: str) -> None:
379
+ try:
380
+ subprocess.run(
381
+ [
382
+ "gh",
383
+ "api",
384
+ "--method",
385
+ "DELETE",
386
+ f"repos/{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}/git/refs/heads/{branch_name}",
387
+ ],
388
+ capture_output=True,
389
+ text=True,
390
+ check=False,
391
+ )
392
+ except OSError as deletion_error:
393
+ sys.stderr.write(
394
+ f"best_effort_delete_remote_branch: could not delete "
395
+ f"{branch_name}: {deletion_error}\n"
396
+ )
397
+
398
+
399
+ def write_findings_json(findings_payload: list[dict[str, Any]]) -> Path:
400
+ handle, findings_path_str = tempfile.mkstemp(
401
+ suffix=".json", prefix="post-audit-findings-"
402
+ )
403
+ os.close(handle)
404
+ findings_path = Path(findings_path_str)
405
+ findings_path.write_text(json.dumps(findings_payload), encoding="utf-8")
406
+ return findings_path
407
+
408
+
409
+ def invoke_post_audit_thread_script(
410
+ pr_number: int,
411
+ head_sha: str,
412
+ state_argument: str,
413
+ findings_json_path: Path,
414
+ audit_token: str,
415
+ ) -> subprocess.CompletedProcess[str]:
416
+ child_environment = dict(os.environ)
417
+ child_environment[GH_TOKEN_ENV_VAR_NAME] = audit_token
418
+ return subprocess.run(
419
+ [
420
+ sys.executable,
421
+ str(SCRIPT_PATH),
422
+ CLI_FLAG_SKILL,
423
+ SKILL_BUGTEAM,
424
+ CLI_FLAG_OWNER,
425
+ LIVE_TEST_OWNER,
426
+ CLI_FLAG_REPO,
427
+ LIVE_TEST_REPO,
428
+ CLI_FLAG_PR_NUMBER,
429
+ str(pr_number),
430
+ CLI_FLAG_COMMIT,
431
+ head_sha,
432
+ CLI_FLAG_STATE,
433
+ state_argument,
434
+ CLI_FLAG_FINDINGS_JSON,
435
+ str(findings_json_path),
436
+ ],
437
+ capture_output=True,
438
+ text=True,
439
+ encoding="utf-8",
440
+ check=False,
441
+ env=child_environment,
442
+ )
443
+
444
+
445
+ STUB_SERVER_HOST = "127.0.0.1"
446
+ STUB_SERVER_PORT_DYNAMIC = 0
447
+ STUB_RESPONSE_HEADER_CONTENT_TYPE = "Content-Type"
448
+ STUB_RESPONSE_HEADER_CONTENT_LENGTH = "Content-Length"
449
+ STUB_RESPONSE_CONTENT_TYPE_VALUE = "application/json"
450
+ STUB_HTTP_STATUS_BAD_GATEWAY = 502
451
+ STUB_HTTP_STATUS_OK = 200
452
+ STUB_502_RESPONSE_BODY_BYTES = json.dumps(
453
+ {"message": "stub server: simulated transient 502 for retry test"}
454
+ ).encode("utf-8")
455
+ STUB_200_RESPONSE_BODY_BYTES = json.dumps(
456
+ {
457
+ "html_url": (
458
+ "https://github.com/stub-host/stub-repo/pull/0#pullrequestreview-1"
459
+ )
460
+ }
461
+ ).encode("utf-8")
462
+ STUB_SERVER_SHUTDOWN_JOIN_TIMEOUT_SECONDS = 5.0
463
+
464
+ FAILURE_COUNT_FOR_RETRY_SUCCESS = 1
465
+ TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS = 2
466
+ FAILURE_COUNT_FOR_RETRY_EXHAUSTION = MAX_RETRY_ATTEMPTS + 1
467
+ TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION = MAX_RETRY_ATTEMPTS + 1
468
+
469
+ BACKOFF_TIMING_EPSILON_SECONDS = 0.1
470
+ TOTAL_BACKOFF_SECONDS = sum(ALL_RETRY_BACKOFF_SECONDS)
471
+
472
+ EXPECTED_RETRY_SUCCESS_ELAPSED_LOWER_BOUND_SECONDS = (
473
+ ALL_RETRY_BACKOFF_SECONDS[0] - BACKOFF_TIMING_EPSILON_SECONDS
474
+ )
475
+ EXPECTED_RETRY_EXHAUSTION_ELAPSED_LOWER_BOUND_SECONDS = (
476
+ TOTAL_BACKOFF_SECONDS - BACKOFF_TIMING_EPSILON_SECONDS
477
+ )
478
+
479
+ EXIT_CODE_SUCCESS = 0
480
+
481
+ LAUNCHER_SOURCE_CODE = textwrap.dedent(
482
+ """
483
+ import sys
484
+ sys.path.insert(0, sys.argv[1])
485
+ import post_audit_thread
486
+ post_audit_thread.GITHUB_API_BASE_URL = sys.argv[2]
487
+ sys.exit(post_audit_thread.main(sys.argv[3:]))
488
+ """
489
+ ).strip()
490
+
491
+
492
+ class _StubReviewsServer(http.server.HTTPServer):
493
+ """HTTP server that records POST count and serves canned 502/200 responses."""
494
+
495
+ request_count: int = 0
496
+ failure_count: int = 0
497
+ recorded_request_path: str = ""
498
+
499
+
500
+ class _StubReviewsHandler(http.server.BaseHTTPRequestHandler):
501
+ """Returns 502 for the first ``failure_count`` POSTs, then 200 thereafter.
502
+
503
+ State lives on the owning :class:`_StubReviewsServer` instance so the
504
+ test can inspect the final request count and the path of the last
505
+ received POST after the script exits.
506
+ """
507
+
508
+ def do_POST(self) -> None:
509
+ owning_server = self.server
510
+ owning_server.request_count += 1
511
+ owning_server.recorded_request_path = self.path
512
+ if owning_server.request_count <= owning_server.failure_count:
513
+ response_status = STUB_HTTP_STATUS_BAD_GATEWAY
514
+ response_body_bytes = STUB_502_RESPONSE_BODY_BYTES
515
+ else:
516
+ response_status = STUB_HTTP_STATUS_OK
517
+ response_body_bytes = STUB_200_RESPONSE_BODY_BYTES
518
+ self.send_response(response_status)
519
+ self.send_header(
520
+ STUB_RESPONSE_HEADER_CONTENT_TYPE, STUB_RESPONSE_CONTENT_TYPE_VALUE
521
+ )
522
+ self.send_header(
523
+ STUB_RESPONSE_HEADER_CONTENT_LENGTH, str(len(response_body_bytes))
524
+ )
525
+ self.end_headers()
526
+ self.wfile.write(response_body_bytes)
527
+
528
+ def log_message(self, format: str, *args: Any) -> None:
529
+ return
530
+
531
+
532
+ def spawn_stub_reviews_server(
533
+ failure_count: int,
534
+ ) -> tuple[_StubReviewsServer, threading.Thread]:
535
+ """Start a localhost stub server returning leading 502s then 200s.
536
+
537
+ Args:
538
+ failure_count: Number of leading POSTs the stub responds to with
539
+ 502. Subsequent POSTs receive a synthetic 200 carrying a fake
540
+ ``html_url``. Set above the script's total retry budget to
541
+ force the retry-exhaustion path end-to-end.
542
+
543
+ Returns:
544
+ Tuple of the stub server (bound to a random port on
545
+ ``127.0.0.1``) and its serving thread.
546
+ """
547
+ stub_server = _StubReviewsServer(
548
+ (STUB_SERVER_HOST, STUB_SERVER_PORT_DYNAMIC), _StubReviewsHandler
549
+ )
550
+ stub_server.request_count = 0
551
+ stub_server.failure_count = failure_count
552
+ stub_server.recorded_request_path = ""
553
+ stub_thread = threading.Thread(target=stub_server.serve_forever, daemon=True)
554
+ stub_thread.start()
555
+ return stub_server, stub_thread
556
+
557
+
558
+ def shutdown_stub_reviews_server(
559
+ stub_server: _StubReviewsServer,
560
+ stub_thread: threading.Thread,
561
+ ) -> None:
562
+ """Stop the stub server and join its serving thread.
563
+
564
+ Args:
565
+ stub_server: Server returned by :func:`spawn_stub_reviews_server`.
566
+ stub_thread: Serving thread returned by
567
+ :func:`spawn_stub_reviews_server`.
568
+ """
569
+ stub_server.shutdown()
570
+ stub_server.server_close()
571
+ stub_thread.join(timeout=STUB_SERVER_SHUTDOWN_JOIN_TIMEOUT_SECONDS)
572
+
573
+
574
+ def stub_reviews_server_base_url(stub_server: _StubReviewsServer) -> str:
575
+ """Return ``http://host:port`` for a bound stub server.
576
+
577
+ Args:
578
+ stub_server: Server returned by :func:`spawn_stub_reviews_server`.
579
+
580
+ Returns:
581
+ Base URL suitable for assigning to
582
+ ``post_audit_thread.GITHUB_API_BASE_URL`` inside the launcher
583
+ subprocess so the script targets the stub instead of api.github.com.
584
+ """
585
+ host_address, bound_port = stub_server.server_address[:2]
586
+ return f"http://{host_address}:{bound_port}"
587
+
588
+
589
+ def invoke_post_audit_thread_with_url_override(
590
+ pr_number: int,
591
+ head_sha: str,
592
+ state_argument: str,
593
+ findings_json_path: Path,
594
+ audit_token: str,
595
+ overridden_base_url: str,
596
+ ) -> subprocess.CompletedProcess[str]:
597
+ """Subprocess-invoke the script with ``GITHUB_API_BASE_URL`` redirected.
598
+
599
+ The subprocess runs a short launcher (``LAUNCHER_SOURCE_CODE``) that
600
+ imports ``post_audit_thread`` as a module, rebinds its
601
+ ``GITHUB_API_BASE_URL`` attribute to ``overridden_base_url``, then
602
+ delegates to ``main()``. Lets the retry tests point the script at the
603
+ local stub server without modifying production source.
604
+
605
+ Args:
606
+ pr_number: Throwaway PR number created by ``setUpClass``.
607
+ head_sha: HEAD SHA the script attaches the review to.
608
+ state_argument: ``CLEAN`` or ``DIRTY``.
609
+ findings_json_path: Path to the (empty-list) findings JSON.
610
+ audit_token: Token assigned to ``GH_TOKEN`` in the child env.
611
+ overridden_base_url: Base URL handed to the launcher (the local
612
+ stub server URL).
613
+
614
+ Returns:
615
+ Completed subprocess result with ``returncode``, ``stdout``, and
616
+ ``stderr`` for the test to inspect.
617
+ """
618
+ child_environment = dict(os.environ)
619
+ child_environment[GH_TOKEN_ENV_VAR_NAME] = audit_token
620
+ return subprocess.run(
621
+ [
622
+ sys.executable,
623
+ "-c",
624
+ LAUNCHER_SOURCE_CODE,
625
+ str(SCRIPT_DIRECTORY),
626
+ overridden_base_url,
627
+ CLI_FLAG_SKILL,
628
+ SKILL_BUGTEAM,
629
+ CLI_FLAG_OWNER,
630
+ LIVE_TEST_OWNER,
631
+ CLI_FLAG_REPO,
632
+ LIVE_TEST_REPO,
633
+ CLI_FLAG_PR_NUMBER,
634
+ str(pr_number),
635
+ CLI_FLAG_COMMIT,
636
+ head_sha,
637
+ CLI_FLAG_STATE,
638
+ state_argument,
639
+ CLI_FLAG_FINDINGS_JSON,
640
+ str(findings_json_path),
641
+ ],
642
+ capture_output=True,
643
+ text=True,
644
+ encoding="utf-8",
645
+ check=False,
646
+ env=child_environment,
647
+ )
648
+
649
+
650
+ class LivePostAuditThreadTests(unittest.TestCase):
651
+ """Live smoke tests for post_audit_thread.py against JonEcho/tests.
652
+
653
+ Every test in this class reuses a single throwaway draft PR created
654
+ in :meth:`setUpClass` and closed in :meth:`tearDownClass`. The CLEAN
655
+ and DIRTY tests post real reviews against that PR; the retry tests
656
+ redirect the script's HTTP layer to a localhost stub server so the
657
+ retry loop runs deterministically without touching api.github.com,
658
+ while still consuming the shared PR's number and HEAD SHA.
659
+ """
660
+
661
+ audit_account_token: str
662
+ local_clone_directory: Path
663
+ branch_name: str
664
+ pr_number: int
665
+ head_sha: str
666
+
667
+ @classmethod
668
+ def setUpClass(cls) -> None:
669
+ resolve_gh_auth_token()
670
+ cls.audit_account_token = resolve_audit_account_token(
671
+ LIVE_TEST_AUDIT_ACCOUNT_NAME
672
+ )
673
+ unique_suffix = uuid.uuid4().hex[:UUID_SUFFIX_LENGTH]
674
+ cls.branch_name = f"{LIVE_TEST_BRANCH_PREFIX}/{unique_suffix}"
675
+ cls.local_clone_directory = Path(
676
+ tempfile.mkdtemp(prefix=f"post-audit-thread-test-{unique_suffix}-")
677
+ )
678
+ cls.pr_number = 0
679
+ cls.head_sha = ""
680
+ try:
681
+ cls.pr_number, cls.head_sha = create_throwaway_pr(
682
+ cls.local_clone_directory,
683
+ cls.branch_name,
684
+ )
685
+ except Exception:
686
+ try:
687
+ remove_local_clone(cls.local_clone_directory)
688
+ finally:
689
+ best_effort_delete_remote_branch(cls.branch_name)
690
+ raise
691
+
692
+ @classmethod
693
+ def tearDownClass(cls) -> None:
694
+ try:
695
+ if cls.pr_number > 0:
696
+ close_throwaway_pr(cls.pr_number)
697
+ finally:
698
+ try:
699
+ remove_local_clone(cls.local_clone_directory)
700
+ finally:
701
+ best_effort_delete_remote_branch(cls.branch_name)
702
+
703
+ def _assert_review_state_for_url(
704
+ self, html_url: str, expected_state: str
705
+ ) -> dict[str, Any]:
706
+ review_id = review_id_from_html_url(html_url)
707
+ single_review_api_path = SINGLE_REVIEW_API_PATH_TEMPLATE.format(
708
+ owner=LIVE_TEST_OWNER,
709
+ repo=LIVE_TEST_REPO,
710
+ pr_number=self.pr_number,
711
+ review_id=review_id,
712
+ )
713
+ single_review = gh_api_object_json(single_review_api_path)
714
+ self.assertEqual(
715
+ single_review.get("state"),
716
+ expected_state,
717
+ f"unexpected review state for {html_url!r}: {single_review!r}",
718
+ )
719
+ return single_review
720
+
721
+ def _fetch_comments_for_review(self, html_url: str) -> list[dict[str, Any]]:
722
+ review_id = review_id_from_html_url(html_url)
723
+ review_comments_api_path = SINGLE_REVIEW_COMMENTS_API_PATH_TEMPLATE.format(
724
+ owner=LIVE_TEST_OWNER,
725
+ repo=LIVE_TEST_REPO,
726
+ pr_number=self.pr_number,
727
+ review_id=review_id,
728
+ )
729
+ return gh_api_paginated_json(review_comments_api_path)
730
+
731
+ def _run_script_capturing_html_url(
732
+ self,
733
+ state_argument: str,
734
+ findings_payload: list[dict[str, Any]],
735
+ ) -> str:
736
+ findings_path = write_findings_json(findings_payload)
737
+ try:
738
+ completion = invoke_post_audit_thread_script(
739
+ pr_number=self.pr_number,
740
+ head_sha=self.head_sha,
741
+ state_argument=state_argument,
742
+ findings_json_path=findings_path,
743
+ audit_token=self.audit_account_token,
744
+ )
745
+ finally:
746
+ try:
747
+ findings_path.unlink()
748
+ except OSError:
749
+ pass
750
+ self.assertEqual(
751
+ completion.returncode,
752
+ 0,
753
+ f"script exited {completion.returncode}; stdout={completion.stdout!r} "
754
+ f"stderr={completion.stderr!r}",
755
+ )
756
+ emitted_html_url = completion.stdout.strip().splitlines()[-1]
757
+ self.assertTrue(
758
+ emitted_html_url.startswith("https://github.com/"),
759
+ f"expected an html_url on stdout, got {emitted_html_url!r}",
760
+ )
761
+ return emitted_html_url
762
+
763
+ def test_clean_state_posts_approved_review_with_empty_comments(self) -> None:
764
+ emitted_html_url = self._run_script_capturing_html_url(
765
+ state_argument=STATE_CLEAN, findings_payload=[]
766
+ )
767
+ self._assert_review_state_for_url(emitted_html_url, GH_EVENT_APPROVED)
768
+ review_comments = self._fetch_comments_for_review(emitted_html_url)
769
+ self.assertEqual(
770
+ len(review_comments),
771
+ 0,
772
+ f"CLEAN state should produce zero inline comments on this review; "
773
+ f"saw {len(review_comments)}: {review_comments!r}",
774
+ )
775
+
776
+ def test_dirty_state_with_three_findings_posts_changes_requested_with_three_inline_threads(
777
+ self,
778
+ ) -> None:
779
+ findings_payload: list[dict[str, Any]] = [
780
+ {
781
+ JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
782
+ JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_ONE,
783
+ JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
784
+ JSON_FIELD_SEVERITY: SEVERITY_TAG_P0,
785
+ JSON_FIELD_DESCRIPTION: "Smoke finding one (heading line).",
786
+ JSON_FIELD_FIX_SUMMARY: "Trim the leading marker (smoke fix one).",
787
+ },
788
+ {
789
+ JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
790
+ JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_TWO,
791
+ JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
792
+ JSON_FIELD_SEVERITY: SEVERITY_TAG_P1,
793
+ JSON_FIELD_DESCRIPTION: "Smoke finding two (blank-line anchor).",
794
+ JSON_FIELD_FIX_SUMMARY: "Collapse the blank separator (smoke fix two).",
795
+ },
796
+ {
797
+ JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
798
+ JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_THREE,
799
+ JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
800
+ JSON_FIELD_SEVERITY: SEVERITY_TAG_P2,
801
+ JSON_FIELD_DESCRIPTION: "Smoke finding three (body-line anchor).",
802
+ JSON_FIELD_FIX_SUMMARY: "Tighten the description (smoke fix three).",
803
+ },
804
+ ]
805
+ emitted_html_url = self._run_script_capturing_html_url(
806
+ state_argument=STATE_DIRTY, findings_payload=findings_payload
807
+ )
808
+ self._assert_review_state_for_url(
809
+ emitted_html_url, GH_EVENT_CHANGES_REQUESTED
810
+ )
811
+ review_comments = self._fetch_comments_for_review(emitted_html_url)
812
+ self.assertEqual(
813
+ len(review_comments),
814
+ len(findings_payload),
815
+ f"DIRTY state should produce one inline comment per finding on "
816
+ f"this review; expected {len(findings_payload)} got "
817
+ f"{len(review_comments)}: {review_comments!r}",
818
+ )
819
+
820
+ def _expected_reviews_request_path(self) -> str:
821
+ full_endpoint_url = build_reviews_endpoint_url(
822
+ LIVE_TEST_OWNER, LIVE_TEST_REPO, self.pr_number
823
+ )
824
+ parsed_endpoint = urllib.parse.urlparse(full_endpoint_url)
825
+ return parsed_endpoint.path
826
+
827
+ def _run_retry_simulation_and_measure_elapsed(
828
+ self,
829
+ failure_count: int,
830
+ ) -> tuple[subprocess.CompletedProcess[str], _StubReviewsServer, float]:
831
+ findings_path = write_findings_json([])
832
+ try:
833
+ stub_server, stub_thread = spawn_stub_reviews_server(
834
+ failure_count=failure_count
835
+ )
836
+ try:
837
+ overridden_base_url = stub_reviews_server_base_url(stub_server)
838
+ start_time = time.perf_counter()
839
+ completion = invoke_post_audit_thread_with_url_override(
840
+ pr_number=self.pr_number,
841
+ head_sha=self.head_sha,
842
+ state_argument=STATE_CLEAN,
843
+ findings_json_path=findings_path,
844
+ audit_token=self.audit_account_token,
845
+ overridden_base_url=overridden_base_url,
846
+ )
847
+ elapsed_seconds = time.perf_counter() - start_time
848
+ finally:
849
+ shutdown_stub_reviews_server(stub_server, stub_thread)
850
+ finally:
851
+ try:
852
+ findings_path.unlink()
853
+ except OSError:
854
+ pass
855
+ return completion, stub_server, elapsed_seconds
856
+
857
+ def test_retry_succeeds_after_one_transient_502_response(self) -> None:
858
+ completion, stub_server, elapsed_seconds = (
859
+ self._run_retry_simulation_and_measure_elapsed(
860
+ failure_count=FAILURE_COUNT_FOR_RETRY_SUCCESS
861
+ )
862
+ )
863
+ self.assertEqual(
864
+ completion.returncode,
865
+ EXIT_CODE_SUCCESS,
866
+ f"retry-success: expected exit {EXIT_CODE_SUCCESS}; got "
867
+ f"{completion.returncode}; stdout={completion.stdout!r} "
868
+ f"stderr={completion.stderr!r}",
869
+ )
870
+ self.assertEqual(
871
+ stub_server.request_count,
872
+ TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS,
873
+ f"retry-success: stub should have received exactly "
874
+ f"{TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS} POSTs (one 502 + "
875
+ f"one 200); got {stub_server.request_count}",
876
+ )
877
+ expected_request_path = self._expected_reviews_request_path()
878
+ self.assertEqual(
879
+ stub_server.recorded_request_path,
880
+ expected_request_path,
881
+ f"retry-success: stub received POST at "
882
+ f"{stub_server.recorded_request_path!r}; expected "
883
+ f"{expected_request_path!r} per build_reviews_endpoint_url",
884
+ )
885
+ self.assertGreaterEqual(
886
+ elapsed_seconds,
887
+ EXPECTED_RETRY_SUCCESS_ELAPSED_LOWER_BOUND_SECONDS,
888
+ f"retry-success should observe at least the first 1s backoff; "
889
+ f"elapsed={elapsed_seconds:.2f}s",
890
+ )
891
+
892
+ def test_retry_exhausts_and_exits_two_after_four_consecutive_502_responses(
893
+ self,
894
+ ) -> None:
895
+ completion, stub_server, elapsed_seconds = (
896
+ self._run_retry_simulation_and_measure_elapsed(
897
+ failure_count=FAILURE_COUNT_FOR_RETRY_EXHAUSTION
898
+ )
899
+ )
900
+ self.assertEqual(
901
+ completion.returncode,
902
+ EXIT_CODE_RETRY_EXHAUSTED,
903
+ f"retry-exhaustion: expected exit "
904
+ f"{EXIT_CODE_RETRY_EXHAUSTED}; got "
905
+ f"{completion.returncode}; stdout={completion.stdout!r} "
906
+ f"stderr={completion.stderr!r}",
907
+ )
908
+ self.assertEqual(
909
+ stub_server.request_count,
910
+ TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION,
911
+ f"retry-exhaustion: stub should have received exactly "
912
+ f"{TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION} POSTs (one "
913
+ f"initial plus three retries); got "
914
+ f"{stub_server.request_count}",
915
+ )
916
+ expected_request_path = self._expected_reviews_request_path()
917
+ self.assertEqual(
918
+ stub_server.recorded_request_path,
919
+ expected_request_path,
920
+ f"retry-exhaustion: stub received POST at "
921
+ f"{stub_server.recorded_request_path!r}; expected "
922
+ f"{expected_request_path!r} per build_reviews_endpoint_url",
923
+ )
924
+ self.assertGreaterEqual(
925
+ elapsed_seconds,
926
+ EXPECTED_RETRY_EXHAUSTION_ELAPSED_LOWER_BOUND_SECONDS,
927
+ f"retry-exhaustion should observe ~21s of backoff "
928
+ f"(1s + 4s + 16s); elapsed={elapsed_seconds:.2f}s",
929
+ )
930
+
931
+ def _isolate_auth_env_vars(self) -> dict[str, str | None]:
932
+ all_managed_env_var_names = (
933
+ GH_TOKEN_ENV_VAR_NAME,
934
+ GITHUB_TOKEN_ENV_VAR_NAME,
935
+ BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME,
936
+ )
937
+ previous_env_state: dict[str, str | None] = {
938
+ each_name: os.environ.get(each_name)
939
+ for each_name in all_managed_env_var_names
940
+ }
941
+ for each_name in all_managed_env_var_names:
942
+ os.environ.pop(each_name, None)
943
+ return previous_env_state
944
+
945
+ def _restore_auth_env_vars(
946
+ self, previous_env_state: dict[str, str | None]
947
+ ) -> None:
948
+ for each_name, prior_value in previous_env_state.items():
949
+ if prior_value is None:
950
+ os.environ.pop(each_name, None)
951
+ else:
952
+ os.environ[each_name] = prior_value
953
+
954
+ def test_query_active_gh_user_login_matches_gh_api_user_login_field(self) -> None:
955
+ active_login = query_active_gh_user_login()
956
+ self.assertTrue(
957
+ active_login,
958
+ "query_active_gh_user_login() returned empty",
959
+ )
960
+ gh_api_user_response = gh_api_object_json("user")
961
+ self.assertEqual(active_login, gh_api_user_response.get("login"))
962
+
963
+ def test_query_pull_request_author_login_matches_throwaway_pr_author(self) -> None:
964
+ author_login = query_pull_request_author_login(
965
+ owner=LIVE_TEST_OWNER,
966
+ repo=LIVE_TEST_REPO,
967
+ pr_number=self.pr_number,
968
+ )
969
+ pr_detail_path = f"repos/{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}/pulls/{self.pr_number}"
970
+ pr_detail_object = gh_api_object_json(pr_detail_path)
971
+ user_field_object = pr_detail_object.get("user")
972
+ self.assertIsInstance(user_field_object, dict)
973
+ if isinstance(user_field_object, dict):
974
+ self.assertEqual(author_login, user_field_object.get("login"))
975
+
976
+ def test_list_authenticated_gh_account_logins_includes_active_and_audit_accounts(
977
+ self,
978
+ ) -> None:
979
+ all_logins = list_authenticated_gh_account_logins()
980
+ active_login = query_active_gh_user_login()
981
+ self.assertIn(active_login, all_logins)
982
+ self.assertIn(LIVE_TEST_AUDIT_ACCOUNT_NAME, all_logins)
983
+
984
+ def test_fetch_gh_token_for_account_returns_audit_account_cached_token(self) -> None:
985
+ fetched_token = fetch_gh_token_for_account(LIVE_TEST_AUDIT_ACCOUNT_NAME)
986
+ self.assertEqual(fetched_token, self.audit_account_token)
987
+
988
+ def test_resolve_reviewer_token_returns_env_var_when_gh_token_is_set(self) -> None:
989
+ sentinel_env_token = "sentinel-gh-token-from-env-var-precedence-test"
990
+ previous_env_state = self._isolate_auth_env_vars()
991
+ try:
992
+ os.environ[GH_TOKEN_ENV_VAR_NAME] = sentinel_env_token
993
+ returned_token = resolve_reviewer_token(
994
+ owner=LIVE_TEST_OWNER,
995
+ repo=LIVE_TEST_REPO,
996
+ pr_number=self.pr_number,
997
+ )
998
+ self.assertEqual(returned_token, sentinel_env_token)
999
+ finally:
1000
+ self._restore_auth_env_vars(previous_env_state)
1001
+
1002
+ def test_resolve_reviewer_token_toggles_to_alternate_token_on_self_pr(self) -> None:
1003
+ previous_env_state = self._isolate_auth_env_vars()
1004
+ try:
1005
+ returned_token = resolve_reviewer_token(
1006
+ owner=LIVE_TEST_OWNER,
1007
+ repo=LIVE_TEST_REPO,
1008
+ pr_number=self.pr_number,
1009
+ )
1010
+ active_login = query_active_gh_user_login()
1011
+ pr_author_login = query_pull_request_author_login(
1012
+ owner=LIVE_TEST_OWNER,
1013
+ repo=LIVE_TEST_REPO,
1014
+ pr_number=self.pr_number,
1015
+ )
1016
+ self.assertEqual(
1017
+ active_login.lower(),
1018
+ pr_author_login.lower(),
1019
+ "throwaway PR author must equal active gh account so the "
1020
+ "self-PR toggle branch is exercised",
1021
+ )
1022
+ all_alternates = [
1023
+ each_login
1024
+ for each_login in list_authenticated_gh_account_logins()
1025
+ if each_login.lower() != pr_author_login.lower()
1026
+ ]
1027
+ self.assertTrue(
1028
+ all_alternates,
1029
+ "test setup requires at least one alternate authenticated account",
1030
+ )
1031
+ expected_first_alternate_token = fetch_gh_token_for_account(
1032
+ all_alternates[0]
1033
+ )
1034
+ self.assertEqual(returned_token, expected_first_alternate_token)
1035
+ active_account_token = resolve_gh_auth_token()
1036
+ self.assertNotEqual(
1037
+ returned_token,
1038
+ active_account_token,
1039
+ "self-PR toggle must not return the active (author) token",
1040
+ )
1041
+ finally:
1042
+ self._restore_auth_env_vars(previous_env_state)
1043
+
1044
+ def test_resolve_reviewer_token_honors_bugteam_reviewer_account_pin(self) -> None:
1045
+ previous_env_state = self._isolate_auth_env_vars()
1046
+ try:
1047
+ pr_author_login = query_pull_request_author_login(
1048
+ owner=LIVE_TEST_OWNER,
1049
+ repo=LIVE_TEST_REPO,
1050
+ pr_number=self.pr_number,
1051
+ )
1052
+ all_alternates_excluding_pr_author = [
1053
+ each_login
1054
+ for each_login in list_authenticated_gh_account_logins()
1055
+ if each_login.lower() != pr_author_login.lower()
1056
+ ]
1057
+ self.assertTrue(
1058
+ all_alternates_excluding_pr_author,
1059
+ "test setup requires at least one authenticated account that "
1060
+ "is not the PR author so the pin has a valid target",
1061
+ )
1062
+ chosen_pin_login = all_alternates_excluding_pr_author[0]
1063
+ os.environ[BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME] = chosen_pin_login
1064
+ returned_token = resolve_reviewer_token(
1065
+ owner=LIVE_TEST_OWNER,
1066
+ repo=LIVE_TEST_REPO,
1067
+ pr_number=self.pr_number,
1068
+ )
1069
+ expected_pinned_token = fetch_gh_token_for_account(chosen_pin_login)
1070
+ self.assertEqual(returned_token, expected_pinned_token)
1071
+ finally:
1072
+ self._restore_auth_env_vars(previous_env_state)
1073
+
1074
+ def test_resolve_reviewer_token_error_excludes_pr_author_from_candidate_set(
1075
+ self,
1076
+ ) -> None:
1077
+ unauthenticated_account_name = "intentionally-not-authenticated-account-zzz"
1078
+ previous_env_state = self._isolate_auth_env_vars()
1079
+ try:
1080
+ os.environ[BUGTEAM_REVIEWER_ACCOUNT_ENV_VAR_NAME] = (
1081
+ unauthenticated_account_name
1082
+ )
1083
+ with self.assertRaises(UserInputError) as raised_context:
1084
+ resolve_reviewer_token(
1085
+ owner=LIVE_TEST_OWNER,
1086
+ repo=LIVE_TEST_REPO,
1087
+ pr_number=self.pr_number,
1088
+ )
1089
+ error_message_text = str(raised_context.exception)
1090
+ self.assertIn(unauthenticated_account_name, error_message_text)
1091
+ pr_author_login = query_pull_request_author_login(
1092
+ owner=LIVE_TEST_OWNER,
1093
+ repo=LIVE_TEST_REPO,
1094
+ pr_number=self.pr_number,
1095
+ )
1096
+ all_alternates_at_call_time = [
1097
+ each_login
1098
+ for each_login in list_authenticated_gh_account_logins()
1099
+ if each_login.lower() != pr_author_login.lower()
1100
+ ]
1101
+ self.assertIn(
1102
+ repr(all_alternates_at_call_time),
1103
+ error_message_text,
1104
+ "error must show the alternate-reviewer set actually searched",
1105
+ )
1106
+ self.assertNotIn(
1107
+ f"authenticated set [{repr(pr_author_login)}",
1108
+ error_message_text,
1109
+ "error must not show a set whose head is the excluded PR author",
1110
+ )
1111
+ finally:
1112
+ self._restore_auth_env_vars(previous_env_state)
1113
+
1114
+
1115
+ if __name__ == "__main__":
1116
+ unittest.main()