claude-dev-env 1.38.1 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/CLAUDE.md +10 -36
  2. package/_shared/pr-loop/audit-reply-template.md +147 -0
  3. package/_shared/pr-loop/fix-protocol.md +25 -4
  4. package/_shared/pr-loop/gh-payloads.md +37 -50
  5. package/_shared/pr-loop/scripts/code_rules_gate.py +0 -60
  6. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +189 -0
  7. package/_shared/pr-loop/scripts/post_audit_thread.py +947 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -19
  9. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +923 -0
  10. package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +127 -0
  11. package/_shared/pr-loop/state-schema.md +1 -1
  12. package/agents/clean-coder.md +2 -2
  13. package/bin/install.mjs +6 -7
  14. package/bin/install.test.mjs +8 -0
  15. package/commands/doc-gist.md +16 -0
  16. package/commands/plan.md +0 -2
  17. package/commands/review-plan.md +1 -1
  18. package/docs/CODE_RULES.md +122 -2
  19. package/hooks/blocking/bot_mention_comment_blocker.py +75 -0
  20. package/hooks/blocking/code_rules_enforcer.py +1143 -129
  21. package/hooks/blocking/convergence_gate_blocker.py +130 -0
  22. package/hooks/blocking/destructive_command_blocker.py +74 -0
  23. package/hooks/blocking/gh_body_arg_blocker.py +30 -0
  24. package/hooks/blocking/md_to_html_blocker.py +119 -0
  25. package/hooks/blocking/test_bot_mention_comment_blocker.py +131 -0
  26. package/hooks/blocking/test_code_rules_enforcer.py +21 -0
  27. package/hooks/blocking/test_code_rules_enforcer_any_exempt_files.py +70 -0
  28. package/hooks/blocking/test_code_rules_enforcer_any_imports_and_cast.py +92 -0
  29. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +143 -0
  30. package/hooks/blocking/test_code_rules_enforcer_banned_prefixes.py +152 -0
  31. package/hooks/blocking/test_code_rules_enforcer_bare_except.py +120 -0
  32. package/hooks/blocking/test_code_rules_enforcer_boundary_types.py +175 -0
  33. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
  34. package/hooks/blocking/test_code_rules_enforcer_collection_prefix.py +50 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_format.py +255 -0
  36. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +130 -0
  37. package/hooks/blocking/test_code_rules_enforcer_stub_implementations.py +141 -0
  38. package/hooks/blocking/test_code_rules_enforcer_test_branching.py +143 -0
  39. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +169 -0
  40. package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +99 -0
  41. package/hooks/blocking/test_code_rules_enforcer_typed_dict_pairs.py +141 -0
  42. package/hooks/blocking/test_convergence_gate_blocker.py +63 -0
  43. package/hooks/blocking/test_destructive_command_blocker.py +146 -0
  44. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +102 -0
  45. package/hooks/blocking/test_gh_body_arg_blocker.py +45 -0
  46. package/hooks/blocking/test_md_to_html_blocker.py +317 -0
  47. package/hooks/config/any_type_config.py +7 -0
  48. package/hooks/config/banned_identifiers_constants.py +11 -0
  49. package/hooks/config/blocking_check_limits.py +38 -0
  50. package/hooks/config/bot_mention_comment_blocker_constants.py +20 -0
  51. package/hooks/config/code_rules_enforcer_constants.py +53 -0
  52. package/hooks/config/convergence_branch_constants.py +9 -0
  53. package/hooks/config/doc_gist_auto_publish_constants.py +18 -0
  54. package/hooks/config/html_companion_constants.py +20 -0
  55. package/hooks/config/inline_tuple_string_magic_constants.py +22 -0
  56. package/hooks/config/test_banned_identifiers_constants.py +17 -0
  57. package/hooks/hooks.json +28 -20
  58. package/hooks/pyproject.toml +69 -0
  59. package/hooks/validators/mypy_integration.py +47 -1
  60. package/hooks/validators/run_all_validators.py +3 -3
  61. package/hooks/validators/test_mypy_integration.py +50 -1
  62. package/hooks/workflow/doc_gist_auto_publish.py +144 -0
  63. package/hooks/workflow/md_to_html_companion.py +365 -0
  64. package/hooks/workflow/test_doc_gist_auto_publish.py +117 -0
  65. package/hooks/workflow/test_md_to_html_companion.py +452 -0
  66. package/package.json +1 -1
  67. package/rules/gh-body-file.md +2 -0
  68. package/scripts/Install-SweepEmptyDirs.ps1 +111 -0
  69. package/scripts/check.ps1 +106 -0
  70. package/scripts/config/timing.py +11 -0
  71. package/scripts/sweep_empty_dirs.py +138 -0
  72. package/scripts/sync_to_cursor/rules.py +1 -1
  73. package/scripts/test_sweep_empty_dirs.py +183 -0
  74. package/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +323 -0
  75. package/skills/_shared/pr-loop/scripts/_cli_utils.py +22 -0
  76. package/skills/_shared/pr-loop/scripts/_path_resolver.py +165 -0
  77. package/skills/_shared/pr-loop/scripts/_xml_utils.py +20 -0
  78. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +182 -0
  79. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +185 -0
  80. package/skills/_shared/pr-loop/scripts/config/__init__.py +0 -0
  81. package/skills/_shared/pr-loop/scripts/config/path_resolver_constants.py +78 -0
  82. package/skills/_shared/pr-loop/scripts/init_loop_state.py +135 -0
  83. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +175 -0
  84. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +182 -0
  85. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +206 -0
  86. package/skills/bugteam/CONSTRAINTS.md +21 -22
  87. package/skills/bugteam/EXAMPLES.md +3 -3
  88. package/skills/bugteam/PROMPTS.md +227 -67
  89. package/skills/bugteam/SKILL.md +114 -455
  90. package/skills/bugteam/reference/README.md +1 -1
  91. package/skills/bugteam/reference/audit-and-teammates.md +112 -39
  92. package/skills/bugteam/reference/audit-contract.md +4 -22
  93. package/skills/bugteam/reference/copilot-gap-analysis.md +8 -5
  94. package/skills/bugteam/reference/design-rationale.md +2 -2
  95. package/skills/bugteam/reference/github-pr-reviews.md +50 -57
  96. package/skills/bugteam/reference/obstacles/audit-assign-ids.md +13 -0
  97. package/skills/bugteam/reference/obstacles/audit-capture-excerpts.md +13 -0
  98. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +13 -0
  99. package/skills/bugteam/reference/obstacles/audit-write-xml.md +13 -0
  100. package/skills/bugteam/reference/obstacles/fix-append-summary.md +13 -0
  101. package/skills/bugteam/reference/obstacles/fix-apply-fixes.md +13 -0
  102. package/skills/bugteam/reference/obstacles/fix-git-add-commit.md +13 -0
  103. package/skills/bugteam/reference/obstacles/fix-git-push.md +13 -0
  104. package/skills/bugteam/reference/obstacles/fix-post-reply.md +13 -0
  105. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +13 -0
  106. package/skills/bugteam/reference/obstacles/fix-py-compile.md +13 -0
  107. package/skills/bugteam/reference/obstacles/fix-read-files.md +13 -0
  108. package/skills/bugteam/reference/obstacles/fix-resolve-thread.md +13 -0
  109. package/skills/bugteam/reference/obstacles/fix-test-suite.md +13 -0
  110. package/skills/bugteam/reference/obstacles/fix-violation-count.md +13 -0
  111. package/skills/bugteam/reference/obstacles/fix-write-xml.md +13 -0
  112. package/skills/bugteam/reference/team-setup.md +106 -9
  113. package/skills/bugteam/reference/teardown-publish-permissions.md +39 -8
  114. package/skills/bugteam/scripts/README.md +60 -0
  115. package/skills/bugteam/scripts/_claude_permissions_common.py +358 -0
  116. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +976 -0
  117. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +375 -0
  118. package/skills/bugteam/scripts/bugteam_preflight.py +294 -0
  119. package/skills/bugteam/scripts/config/bugteam_code_rules_gate_constants.py +25 -0
  120. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +26 -0
  121. package/skills/bugteam/scripts/config/bugteam_preflight_constants.py +35 -0
  122. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +20 -0
  123. package/skills/bugteam/scripts/config/probe_code_rules_enforcer_check_constants.py +12 -0
  124. package/skills/bugteam/scripts/config/windows_safe_rmtree_constants.py +7 -0
  125. package/skills/bugteam/scripts/grant_project_claude_permissions.py +175 -0
  126. package/skills/bugteam/scripts/probe_code_rules_enforcer_check.py +107 -0
  127. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +220 -0
  128. package/skills/bugteam/scripts/test__claude_permissions_common.py +112 -0
  129. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +400 -0
  130. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +384 -0
  131. package/skills/bugteam/scripts/test_bugteam_preflight.py +268 -0
  132. package/skills/bugteam/scripts/test_claude_permissions_common.py +195 -0
  133. package/skills/bugteam/scripts/test_grant_project_claude_permissions.py +55 -0
  134. package/skills/bugteam/scripts/test_probe_code_rules_enforcer_check.py +76 -0
  135. package/skills/bugteam/scripts/test_revoke_project_claude_permissions.py +55 -0
  136. package/skills/bugteam/scripts/test_windows_safe_rmtree.py +108 -0
  137. package/skills/bugteam/scripts/windows_safe_rmtree.py +100 -0
  138. package/skills/bugteam/test_skill_additions.py +1 -11
  139. package/skills/code/SKILL.md +176 -0
  140. package/skills/doc-gist/SKILL.md +99 -0
  141. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +453 -0
  142. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +515 -0
  143. package/skills/doc-gist/references/examples/03-code-review-pr.html +638 -0
  144. package/skills/doc-gist/references/examples/04-code-understanding.html +491 -0
  145. package/skills/doc-gist/references/examples/05-design-system.html +629 -0
  146. package/skills/doc-gist/references/examples/06-component-variants.html +605 -0
  147. package/skills/doc-gist/references/examples/07-prototype-animation.html +455 -0
  148. package/skills/doc-gist/references/examples/08-prototype-interaction.html +396 -0
  149. package/skills/doc-gist/references/examples/09-slide-deck.html +592 -0
  150. package/skills/doc-gist/references/examples/10-svg-illustrations.html +492 -0
  151. package/skills/doc-gist/references/examples/11-status-report.html +528 -0
  152. package/skills/doc-gist/references/examples/12-incident-report.html +596 -0
  153. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +395 -0
  154. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +381 -0
  155. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +368 -0
  156. package/skills/doc-gist/references/examples/16-implementation-plan.html +702 -0
  157. package/skills/doc-gist/references/examples/17-pr-writeup.html +595 -0
  158. package/skills/doc-gist/references/examples/18-editor-triage-board.html +573 -0
  159. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +663 -0
  160. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +722 -0
  161. package/skills/doc-gist/references/examples/README.md +5 -0
  162. package/skills/doc-gist/scripts/config/__init__.py +0 -0
  163. package/skills/doc-gist/scripts/config/gist_upload_constants.py +16 -0
  164. package/skills/doc-gist/scripts/gist_upload.py +177 -0
  165. package/skills/doc-gist/scripts/test_gist_upload.py +51 -0
  166. package/skills/findbugs/SKILL.md +68 -2
  167. package/skills/monitor-open-prs/SKILL.md +13 -32
  168. package/skills/monitor-open-prs/test_skill_contract.py +0 -11
  169. package/skills/pr-consistency-audit/SKILL.md +112 -0
  170. package/skills/pr-consistency-audit/reference/detection-rules.md +96 -0
  171. package/skills/pr-consistency-audit/reference/illustrations.md +78 -0
  172. package/skills/pr-converge/SKILL.md +227 -23
  173. package/skills/pr-converge/config/__init__.py +0 -0
  174. package/skills/pr-converge/config/constants.py +62 -0
  175. package/skills/pr-converge/reference/convergence-gates.md +138 -44
  176. package/skills/pr-converge/reference/examples.md +43 -11
  177. package/skills/pr-converge/reference/fix-protocol.md +6 -5
  178. package/skills/pr-converge/reference/ground-rules.md +5 -3
  179. package/skills/pr-converge/reference/multi-pr-orchestration.md +44 -19
  180. package/skills/pr-converge/reference/obstacles/fix-post-replies.md +13 -0
  181. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +13 -0
  182. package/skills/pr-converge/reference/obstacles/fix-push.md +13 -0
  183. package/skills/pr-converge/reference/obstacles/fix-read-filelines.md +13 -0
  184. package/skills/pr-converge/reference/obstacles/fix-reset-state.md +13 -0
  185. package/skills/pr-converge/reference/obstacles/fix-resolve-threads.md +13 -0
  186. package/skills/pr-converge/reference/obstacles/fix-spawn-clean-coder.md +13 -0
  187. package/skills/pr-converge/reference/obstacles/fix-stage-commit.md +13 -0
  188. package/skills/pr-converge/reference/obstacles/fix-trigger-bugbot.md +13 -0
  189. package/skills/pr-converge/reference/obstacles/fix-write-test.md +13 -0
  190. package/skills/pr-converge/reference/per-tick.md +90 -31
  191. package/skills/pr-converge/reference/state-schema.md +22 -1
  192. package/skills/pr-converge/reference/stop-conditions.md +9 -7
  193. package/skills/pr-converge/scripts/README.md +34 -46
  194. package/skills/pr-converge/scripts/check_bugbot_ci.py +174 -0
  195. package/skills/pr-converge/scripts/check_convergence.py +497 -0
  196. package/skills/pr-converge/scripts/check_pending_reviews.py +154 -0
  197. package/skills/pr-converge/scripts/config/pr_converge_constants.py +118 -0
  198. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +134 -0
  199. package/skills/pr-converge/scripts/post_fix_reply.py +168 -0
  200. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -12
  201. package/skills/qbug/SKILL.md +132 -27
  202. package/skills/session-log/SKILL.md +216 -114
  203. package/skills/session-tidy/SKILL.md +1 -1
  204. package/skills/skill-builder/SKILL.md +138 -56
  205. package/skills/skill-builder/references/delegation-map.md +72 -113
  206. package/skills/skill-builder/references/progressive-disclosure.md +122 -0
  207. package/skills/skill-builder/references/self-audit-checklist.md +92 -0
  208. package/skills/skill-builder/references/skill-types.md +228 -0
  209. package/skills/skill-builder/references/thariq-x-post-skills.json +33 -0
  210. package/skills/skill-builder/templates/gap-analysis.md +15 -8
  211. package/skills/skill-builder/workflows/improve-skill.md +86 -57
  212. package/skills/skill-builder/workflows/new-skill.md +80 -168
  213. package/skills/skill-builder/workflows/polish-skill.md +78 -54
  214. package/skills/structure-prompt/SKILL.md +50 -0
  215. package/skills/structure-prompt/reference/adversarial-tuning.md +62 -0
  216. package/skills/structure-prompt/reference/block-classification.md +27 -0
  217. package/skills/structure-prompt/reference/canonical-case.md +48 -0
  218. package/skills/structure-prompt/reference/citation-depth.md +70 -0
  219. package/skills/structure-prompt/reference/cleanup.md +33 -0
  220. package/skills/structure-prompt/reference/constraints.md +33 -0
  221. package/skills/structure-prompt/reference/directives.md +37 -0
  222. package/skills/structure-prompt/reference/examples.md +72 -0
  223. package/skills/structure-prompt/reference/instantiation.md +51 -0
  224. package/skills/structure-prompt/reference/output-contract.md +72 -0
  225. package/skills/structure-prompt/reference/per-category.md +23 -0
  226. package/skills/structure-prompt/reference/persona.md +38 -0
  227. package/skills/structure-prompt/reference/research.md +33 -0
  228. package/skills/structure-prompt/reference/structure.md +28 -0
  229. package/agents/code-standards-agent.md +0 -93
  230. package/agents/groq-coder.md +0 -113
  231. package/agents/plan-executor.md +0 -226
  232. package/agents/project-docs-analyzer.md +0 -53
  233. package/agents/project-structure-organizer-agent.md +0 -72
  234. package/agents/skill-to-agent-converter.md +0 -370
  235. package/agents/skill-writer-agent.md +0 -470
  236. package/agents/user-docs-writer.md +0 -67
  237. package/agents/workflow-visual-documenter.md +0 -82
  238. package/commands/readability-review.md +0 -20
  239. package/hooks/mypy.ini +0 -2
  240. package/hooks/notification/attention_needed_notify.py +0 -71
  241. package/hooks/notification/claude_notification_handler.py +0 -67
  242. package/hooks/notification/notification_utils.py +0 -267
  243. package/hooks/notification/subagent_complete_notify.py +0 -381
  244. package/hooks/notification/test_attention_needed_notify.py +0 -47
  245. package/hooks/notification/test_claude_notification_handler.py +0 -54
  246. package/hooks/notification/test_notification_utils.py +0 -91
  247. package/hooks/notification/test_subagent_complete_notify.py +0 -79
  248. package/scripts/config/groq_bugteam_config.py +0 -230
  249. package/scripts/config/test_groq_bugteam_config.py +0 -83
  250. package/scripts/config/test_spec_implementer_prompt.py +0 -32
  251. package/scripts/groq_bugteam.README.md +0 -131
  252. package/scripts/groq_bugteam.py +0 -647
  253. package/scripts/groq_bugteam_dotenv.py +0 -40
  254. package/scripts/groq_bugteam_spec.py +0 -226
  255. package/scripts/test_groq_bugteam.py +0 -529
  256. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +0 -426
  257. package/scripts/test_groq_bugteam_dotenv.py +0 -66
  258. package/scripts/test_groq_bugteam_spec.py +0 -338
  259. package/skills/bugteam/SKILL_EVALS.md +0 -309
  260. package/skills/dream/SKILL.md +0 -118
  261. package/skills/ingest/SKILL.md +0 -40
  262. package/skills/npm-creator/SKILL.md +0 -187
  263. package/skills/readability-review/SKILL.md +0 -127
  264. package/skills/resume-review/SKILL.md +0 -261
  265. package/skills/rule-audit/SKILL.md +0 -307
  266. package/skills/rule-creator/SKILL.md +0 -150
  267. package/skills/searching-obsidian-vault/SKILL.md +0 -131
  268. package/skills/skill-writer/REFERENCE.md +0 -284
  269. package/skills/skill-writer/SKILL.md +0 -222
  270. package/skills/tdd-team/SKILL.md +0 -128
@@ -0,0 +1,923 @@
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
+ GH_TOKEN_ENV_VAR_NAME,
47
+ CLI_FLAG_COMMIT,
48
+ CLI_FLAG_FINDINGS_JSON,
49
+ CLI_FLAG_OWNER,
50
+ CLI_FLAG_PR_NUMBER,
51
+ CLI_FLAG_REPO,
52
+ CLI_FLAG_SKILL,
53
+ CLI_FLAG_STATE,
54
+ EXIT_CODE_RETRY_EXHAUSTED,
55
+ INLINE_COMMENT_SIDE_RIGHT,
56
+ JSON_FIELD_DESCRIPTION,
57
+ JSON_FIELD_FIX_SUMMARY,
58
+ JSON_FIELD_LINE,
59
+ JSON_FIELD_PATH,
60
+ JSON_FIELD_SEVERITY,
61
+ JSON_FIELD_SIDE,
62
+ MAX_RETRY_ATTEMPTS,
63
+ SEVERITY_TAG_P0,
64
+ SEVERITY_TAG_P1,
65
+ SEVERITY_TAG_P2,
66
+ SINGLE_REVIEW_API_PATH_TEMPLATE,
67
+ SINGLE_REVIEW_COMMENTS_API_PATH_TEMPLATE,
68
+ SKILL_BUGTEAM,
69
+ STATE_CLEAN,
70
+ STATE_DIRTY,
71
+ )
72
+ from post_audit_thread import build_reviews_endpoint_url # noqa: E402
73
+
74
+ LIVE_TEST_OWNER = "JonEcho"
75
+ LIVE_TEST_REPO = "tests"
76
+ LIVE_TEST_BRANCH_PREFIX = "pr-loop-test"
77
+ LIVE_TEST_PR_TITLE = "TEST: post_audit_thread smoke test (auto-closed)"
78
+ LIVE_TEST_PR_BODY = (
79
+ "Throwaway PR for post_audit_thread.py live smoke tests. "
80
+ "Auto-created by `test_post_audit_thread.py`; closed in `tearDownClass`."
81
+ )
82
+ LIVE_TEST_BASE_BRANCH = "main"
83
+ LIVE_TEST_FIXTURE_FILENAME = "post-audit-thread-fixture.md"
84
+ LIVE_TEST_FIXTURE_CONTENT = (
85
+ "# Throwaway test fixture\n\n"
86
+ "Created by `test_post_audit_thread.py` to satisfy GitHub's "
87
+ "non-empty PR-diff requirement. Deleted when the PR closes.\n"
88
+ )
89
+ LIVE_TEST_FIXTURE_LINE_FOR_FINDING_ONE = 1
90
+ LIVE_TEST_FIXTURE_LINE_FOR_FINDING_TWO = 2
91
+ LIVE_TEST_FIXTURE_LINE_FOR_FINDING_THREE = 3
92
+
93
+ SCRIPT_PATH = SCRIPT_DIRECTORY / "post_audit_thread.py"
94
+ REPO_FULL_NAME = f"{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}"
95
+
96
+ LIVE_TEST_AUDIT_ACCOUNT_NAME = "jl-cmd"
97
+
98
+ GH_EVENT_APPROVED = "APPROVED"
99
+ GH_EVENT_CHANGES_REQUESTED = "CHANGES_REQUESTED"
100
+
101
+ UUID_SUFFIX_LENGTH = 8
102
+
103
+ REVIEW_URL_ID_DELIMITER = "#pullrequestreview-"
104
+
105
+
106
+ def _strip_read_only_and_retry(
107
+ removal_function: Any, target_path: str, *_exc_info: Any
108
+ ) -> None:
109
+ try:
110
+ os.chmod(target_path, stat.S_IWRITE)
111
+ removal_function(target_path)
112
+ except OSError:
113
+ pass
114
+
115
+
116
+ def force_remove_directory(target_path: Path) -> None:
117
+ if not target_path.exists():
118
+ return
119
+ handler_kwargs: dict[str, Any]
120
+ if sys.version_info >= (3, 12):
121
+ handler_kwargs = {"onexc": _strip_read_only_and_retry}
122
+ else:
123
+ handler_kwargs = {"onerror": _strip_read_only_and_retry}
124
+ try:
125
+ shutil.rmtree(str(target_path), **handler_kwargs)
126
+ except OSError as removal_error:
127
+ sys.stderr.write(
128
+ f"force_remove_directory: could not remove {target_path}: {removal_error}\n"
129
+ )
130
+
131
+
132
+ def resolve_gh_auth_token() -> str:
133
+ completion = subprocess.run(
134
+ list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS),
135
+ capture_output=True,
136
+ text=True,
137
+ encoding="utf-8",
138
+ check=False,
139
+ )
140
+ if completion.returncode != 0:
141
+ raise AssertionError(
142
+ f"`gh auth token` failed: rc={completion.returncode} "
143
+ f"stderr={completion.stderr.strip()} — live tests require gh to "
144
+ f"be authenticated against github.com"
145
+ )
146
+ token_text = completion.stdout.strip()
147
+ if not token_text:
148
+ raise AssertionError(
149
+ "`gh auth token` returned empty output — not authenticated"
150
+ )
151
+ return token_text
152
+
153
+
154
+ def resolve_audit_account_token(account_name: str) -> str:
155
+ completion = subprocess.run(
156
+ list(ALL_GH_AUTH_TOKEN_COMMAND_PARTS) + ["--user", account_name],
157
+ capture_output=True,
158
+ text=True,
159
+ encoding="utf-8",
160
+ check=False,
161
+ )
162
+ if completion.returncode != 0:
163
+ raise AssertionError(
164
+ f"`gh auth token --user {account_name}` failed — the audit-side "
165
+ f"account must be authenticated separately from the PR author so "
166
+ f"GitHub allows APPROVE / REQUEST_CHANGES on the throwaway PR. "
167
+ f"rc={completion.returncode} stderr={completion.stderr.strip()}"
168
+ )
169
+ token_text = completion.stdout.strip()
170
+ if not token_text:
171
+ raise AssertionError(
172
+ f"`gh auth token --user {account_name}` returned empty output"
173
+ )
174
+ return token_text
175
+
176
+
177
+ def gh_api_object_json(api_path: str) -> dict[str, Any]:
178
+ completion = subprocess.run(
179
+ ["gh", "api", api_path],
180
+ capture_output=True,
181
+ text=True,
182
+ encoding="utf-8",
183
+ check=True,
184
+ )
185
+ parsed_object: Any = json.loads(completion.stdout)
186
+ if not isinstance(parsed_object, dict):
187
+ raise AssertionError(
188
+ f"unexpected gh api object shape: {type(parsed_object).__name__}"
189
+ )
190
+ return parsed_object
191
+
192
+
193
+ def review_id_from_html_url(html_url: str) -> int:
194
+ suffix_parts = html_url.rsplit(REVIEW_URL_ID_DELIMITER, 1)
195
+ if len(suffix_parts) != 2:
196
+ raise AssertionError(
197
+ f"html_url {html_url!r} missing {REVIEW_URL_ID_DELIMITER!r} suffix"
198
+ )
199
+ return int(suffix_parts[1])
200
+
201
+
202
+ def gh_api_paginated_json(api_path: str) -> list[dict[str, Any]]:
203
+ completion = subprocess.run(
204
+ ["gh", "api", api_path, "--paginate", "--slurp"],
205
+ capture_output=True,
206
+ text=True,
207
+ encoding="utf-8",
208
+ check=True,
209
+ )
210
+ parsed: Any = json.loads(completion.stdout)
211
+ if not isinstance(parsed, list):
212
+ raise AssertionError(
213
+ f"unexpected gh api response shape: {type(parsed).__name__}"
214
+ )
215
+ flattened: list[dict[str, Any]] = []
216
+ for each_page in parsed:
217
+ if isinstance(each_page, list):
218
+ for each_item in each_page:
219
+ if isinstance(each_item, dict):
220
+ flattened.append(each_item)
221
+ elif isinstance(each_page, dict):
222
+ flattened.append(each_page)
223
+ return flattened
224
+
225
+
226
+ def write_pr_body_temporary_file(body_text: str) -> Path:
227
+ handle, body_path_str = tempfile.mkstemp(suffix=".md", prefix="post-audit-pr-body-")
228
+ os.close(handle)
229
+ body_path = Path(body_path_str)
230
+ body_path.write_text(body_text, encoding="utf-8")
231
+ return body_path
232
+
233
+
234
+ def create_throwaway_pr(
235
+ clone_directory: Path,
236
+ branch_name: str,
237
+ ) -> tuple[int, str]:
238
+ subprocess.run(
239
+ [
240
+ "gh",
241
+ "repo",
242
+ "clone",
243
+ REPO_FULL_NAME,
244
+ str(clone_directory),
245
+ "--",
246
+ "--branch",
247
+ LIVE_TEST_BASE_BRANCH,
248
+ "--single-branch",
249
+ "--depth",
250
+ "1",
251
+ ],
252
+ check=True,
253
+ capture_output=True,
254
+ text=True,
255
+ )
256
+ subprocess.run(
257
+ [
258
+ "git",
259
+ "-C",
260
+ str(clone_directory),
261
+ "config",
262
+ "--local",
263
+ "core.hooksPath",
264
+ str(clone_directory / ".git" / "hooks"),
265
+ ],
266
+ check=True,
267
+ capture_output=True,
268
+ text=True,
269
+ )
270
+ subprocess.run(
271
+ ["git", "-C", str(clone_directory), "checkout", "-b", branch_name],
272
+ check=True,
273
+ capture_output=True,
274
+ text=True,
275
+ )
276
+ fixture_path = clone_directory / LIVE_TEST_FIXTURE_FILENAME
277
+ fixture_path.write_text(LIVE_TEST_FIXTURE_CONTENT, encoding="utf-8")
278
+ subprocess.run(
279
+ ["git", "-C", str(clone_directory), "add", LIVE_TEST_FIXTURE_FILENAME],
280
+ check=True,
281
+ capture_output=True,
282
+ text=True,
283
+ )
284
+ subprocess.run(
285
+ [
286
+ "git",
287
+ "-C",
288
+ str(clone_directory),
289
+ "commit",
290
+ "-m",
291
+ "test: post_audit_thread.py live smoke fixture",
292
+ ],
293
+ check=True,
294
+ capture_output=True,
295
+ text=True,
296
+ )
297
+ head_sha_completion = subprocess.run(
298
+ ["git", "-C", str(clone_directory), "rev-parse", "HEAD"],
299
+ capture_output=True,
300
+ text=True,
301
+ encoding="utf-8",
302
+ check=True,
303
+ )
304
+ head_sha = head_sha_completion.stdout.strip()
305
+ subprocess.run(
306
+ ["git", "-C", str(clone_directory), "push", "-u", "origin", branch_name],
307
+ check=True,
308
+ capture_output=True,
309
+ text=True,
310
+ )
311
+ body_path = write_pr_body_temporary_file(LIVE_TEST_PR_BODY)
312
+ try:
313
+ create_completion = subprocess.run(
314
+ [
315
+ "gh",
316
+ "pr",
317
+ "create",
318
+ "--draft",
319
+ "--head",
320
+ branch_name,
321
+ "--base",
322
+ LIVE_TEST_BASE_BRANCH,
323
+ "--title",
324
+ LIVE_TEST_PR_TITLE,
325
+ "--body-file",
326
+ str(body_path),
327
+ "--repo",
328
+ REPO_FULL_NAME,
329
+ ],
330
+ capture_output=True,
331
+ text=True,
332
+ encoding="utf-8",
333
+ check=True,
334
+ cwd=str(clone_directory),
335
+ )
336
+ finally:
337
+ try:
338
+ body_path.unlink()
339
+ except OSError:
340
+ pass
341
+ pr_url = create_completion.stdout.strip().splitlines()[-1]
342
+ parsed_pr_url = urllib.parse.urlparse(pr_url)
343
+ pr_number = int(parsed_pr_url.path.rsplit("/", 1)[-1])
344
+ return pr_number, head_sha
345
+
346
+
347
+ def close_throwaway_pr(pr_number: int) -> None:
348
+ subprocess.run(
349
+ [
350
+ "gh",
351
+ "pr",
352
+ "close",
353
+ str(pr_number),
354
+ "--delete-branch",
355
+ "--repo",
356
+ REPO_FULL_NAME,
357
+ ],
358
+ capture_output=True,
359
+ text=True,
360
+ check=False,
361
+ )
362
+
363
+
364
+ def remove_local_clone(clone_directory: Path) -> None:
365
+ force_remove_directory(clone_directory)
366
+
367
+
368
+ def best_effort_delete_remote_branch(branch_name: str) -> None:
369
+ try:
370
+ subprocess.run(
371
+ [
372
+ "gh",
373
+ "api",
374
+ "--method",
375
+ "DELETE",
376
+ f"repos/{LIVE_TEST_OWNER}/{LIVE_TEST_REPO}/git/refs/heads/{branch_name}",
377
+ ],
378
+ capture_output=True,
379
+ text=True,
380
+ check=False,
381
+ )
382
+ except OSError as deletion_error:
383
+ sys.stderr.write(
384
+ f"best_effort_delete_remote_branch: could not delete "
385
+ f"{branch_name}: {deletion_error}\n"
386
+ )
387
+
388
+
389
+ def write_findings_json(findings_payload: list[dict[str, Any]]) -> Path:
390
+ handle, findings_path_str = tempfile.mkstemp(
391
+ suffix=".json", prefix="post-audit-findings-"
392
+ )
393
+ os.close(handle)
394
+ findings_path = Path(findings_path_str)
395
+ findings_path.write_text(json.dumps(findings_payload), encoding="utf-8")
396
+ return findings_path
397
+
398
+
399
+ def invoke_post_audit_thread_script(
400
+ pr_number: int,
401
+ head_sha: str,
402
+ state_argument: str,
403
+ findings_json_path: Path,
404
+ audit_token: str,
405
+ ) -> subprocess.CompletedProcess[str]:
406
+ child_environment = dict(os.environ)
407
+ child_environment[GH_TOKEN_ENV_VAR_NAME] = audit_token
408
+ return subprocess.run(
409
+ [
410
+ sys.executable,
411
+ str(SCRIPT_PATH),
412
+ CLI_FLAG_SKILL,
413
+ SKILL_BUGTEAM,
414
+ CLI_FLAG_OWNER,
415
+ LIVE_TEST_OWNER,
416
+ CLI_FLAG_REPO,
417
+ LIVE_TEST_REPO,
418
+ CLI_FLAG_PR_NUMBER,
419
+ str(pr_number),
420
+ CLI_FLAG_COMMIT,
421
+ head_sha,
422
+ CLI_FLAG_STATE,
423
+ state_argument,
424
+ CLI_FLAG_FINDINGS_JSON,
425
+ str(findings_json_path),
426
+ ],
427
+ capture_output=True,
428
+ text=True,
429
+ encoding="utf-8",
430
+ check=False,
431
+ env=child_environment,
432
+ )
433
+
434
+
435
+ STUB_SERVER_HOST = "127.0.0.1"
436
+ STUB_SERVER_PORT_DYNAMIC = 0
437
+ STUB_RESPONSE_HEADER_CONTENT_TYPE = "Content-Type"
438
+ STUB_RESPONSE_HEADER_CONTENT_LENGTH = "Content-Length"
439
+ STUB_RESPONSE_CONTENT_TYPE_VALUE = "application/json"
440
+ STUB_HTTP_STATUS_BAD_GATEWAY = 502
441
+ STUB_HTTP_STATUS_OK = 200
442
+ STUB_502_RESPONSE_BODY_BYTES = json.dumps(
443
+ {"message": "stub server: simulated transient 502 for retry test"}
444
+ ).encode("utf-8")
445
+ STUB_200_RESPONSE_BODY_BYTES = json.dumps(
446
+ {
447
+ "html_url": (
448
+ "https://github.com/stub-host/stub-repo/pull/0#pullrequestreview-1"
449
+ )
450
+ }
451
+ ).encode("utf-8")
452
+ STUB_SERVER_SHUTDOWN_JOIN_TIMEOUT_SECONDS = 5.0
453
+
454
+ FAILURE_COUNT_FOR_RETRY_SUCCESS = 1
455
+ TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS = 2
456
+ FAILURE_COUNT_FOR_RETRY_EXHAUSTION = MAX_RETRY_ATTEMPTS + 1
457
+ TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION = MAX_RETRY_ATTEMPTS + 1
458
+
459
+ BACKOFF_TIMING_EPSILON_SECONDS = 0.1
460
+ TOTAL_BACKOFF_SECONDS = sum(ALL_RETRY_BACKOFF_SECONDS)
461
+
462
+ EXPECTED_RETRY_SUCCESS_ELAPSED_LOWER_BOUND_SECONDS = (
463
+ ALL_RETRY_BACKOFF_SECONDS[0] - BACKOFF_TIMING_EPSILON_SECONDS
464
+ )
465
+ EXPECTED_RETRY_EXHAUSTION_ELAPSED_LOWER_BOUND_SECONDS = (
466
+ TOTAL_BACKOFF_SECONDS - BACKOFF_TIMING_EPSILON_SECONDS
467
+ )
468
+
469
+ EXIT_CODE_SUCCESS = 0
470
+
471
+ LAUNCHER_SOURCE_CODE = textwrap.dedent(
472
+ """
473
+ import sys
474
+ sys.path.insert(0, sys.argv[1])
475
+ import post_audit_thread
476
+ post_audit_thread.GITHUB_API_BASE_URL = sys.argv[2]
477
+ sys.exit(post_audit_thread.main(sys.argv[3:]))
478
+ """
479
+ ).strip()
480
+
481
+
482
+ class _StubReviewsServer(http.server.HTTPServer):
483
+ """HTTP server that records POST count and serves canned 502/200 responses."""
484
+
485
+ request_count: int = 0
486
+ failure_count: int = 0
487
+ recorded_request_path: str = ""
488
+
489
+
490
+ class _StubReviewsHandler(http.server.BaseHTTPRequestHandler):
491
+ """Returns 502 for the first ``failure_count`` POSTs, then 200 thereafter.
492
+
493
+ State lives on the owning :class:`_StubReviewsServer` instance so the
494
+ test can inspect the final request count and the path of the last
495
+ received POST after the script exits.
496
+ """
497
+
498
+ def do_POST(self) -> None:
499
+ owning_server = self.server
500
+ owning_server.request_count += 1
501
+ owning_server.recorded_request_path = self.path
502
+ if owning_server.request_count <= owning_server.failure_count:
503
+ response_status = STUB_HTTP_STATUS_BAD_GATEWAY
504
+ response_body_bytes = STUB_502_RESPONSE_BODY_BYTES
505
+ else:
506
+ response_status = STUB_HTTP_STATUS_OK
507
+ response_body_bytes = STUB_200_RESPONSE_BODY_BYTES
508
+ self.send_response(response_status)
509
+ self.send_header(
510
+ STUB_RESPONSE_HEADER_CONTENT_TYPE, STUB_RESPONSE_CONTENT_TYPE_VALUE
511
+ )
512
+ self.send_header(
513
+ STUB_RESPONSE_HEADER_CONTENT_LENGTH, str(len(response_body_bytes))
514
+ )
515
+ self.end_headers()
516
+ self.wfile.write(response_body_bytes)
517
+
518
+ def log_message(self, format: str, *args: Any) -> None:
519
+ return
520
+
521
+
522
+ def spawn_stub_reviews_server(
523
+ failure_count: int,
524
+ ) -> tuple[_StubReviewsServer, threading.Thread]:
525
+ """Start a localhost stub server returning leading 502s then 200s.
526
+
527
+ Args:
528
+ failure_count: Number of leading POSTs the stub responds to with
529
+ 502. Subsequent POSTs receive a synthetic 200 carrying a fake
530
+ ``html_url``. Set above the script's total retry budget to
531
+ force the retry-exhaustion path end-to-end.
532
+
533
+ Returns:
534
+ Tuple of the stub server (bound to a random port on
535
+ ``127.0.0.1``) and its serving thread.
536
+ """
537
+ stub_server = _StubReviewsServer(
538
+ (STUB_SERVER_HOST, STUB_SERVER_PORT_DYNAMIC), _StubReviewsHandler
539
+ )
540
+ stub_server.request_count = 0
541
+ stub_server.failure_count = failure_count
542
+ stub_server.recorded_request_path = ""
543
+ stub_thread = threading.Thread(target=stub_server.serve_forever, daemon=True)
544
+ stub_thread.start()
545
+ return stub_server, stub_thread
546
+
547
+
548
+ def shutdown_stub_reviews_server(
549
+ stub_server: _StubReviewsServer,
550
+ stub_thread: threading.Thread,
551
+ ) -> None:
552
+ """Stop the stub server and join its serving thread.
553
+
554
+ Args:
555
+ stub_server: Server returned by :func:`spawn_stub_reviews_server`.
556
+ stub_thread: Serving thread returned by
557
+ :func:`spawn_stub_reviews_server`.
558
+ """
559
+ stub_server.shutdown()
560
+ stub_server.server_close()
561
+ stub_thread.join(timeout=STUB_SERVER_SHUTDOWN_JOIN_TIMEOUT_SECONDS)
562
+
563
+
564
+ def stub_reviews_server_base_url(stub_server: _StubReviewsServer) -> str:
565
+ """Return ``http://host:port`` for a bound stub server.
566
+
567
+ Args:
568
+ stub_server: Server returned by :func:`spawn_stub_reviews_server`.
569
+
570
+ Returns:
571
+ Base URL suitable for assigning to
572
+ ``post_audit_thread.GITHUB_API_BASE_URL`` inside the launcher
573
+ subprocess so the script targets the stub instead of api.github.com.
574
+ """
575
+ host_address, bound_port = stub_server.server_address[:2]
576
+ return f"http://{host_address}:{bound_port}"
577
+
578
+
579
+ def invoke_post_audit_thread_with_url_override(
580
+ pr_number: int,
581
+ head_sha: str,
582
+ state_argument: str,
583
+ findings_json_path: Path,
584
+ audit_token: str,
585
+ overridden_base_url: str,
586
+ ) -> subprocess.CompletedProcess[str]:
587
+ """Subprocess-invoke the script with ``GITHUB_API_BASE_URL`` redirected.
588
+
589
+ The subprocess runs a short launcher (``LAUNCHER_SOURCE_CODE``) that
590
+ imports ``post_audit_thread`` as a module, rebinds its
591
+ ``GITHUB_API_BASE_URL`` attribute to ``overridden_base_url``, then
592
+ delegates to ``main()``. Lets the retry tests point the script at the
593
+ local stub server without modifying production source.
594
+
595
+ Args:
596
+ pr_number: Throwaway PR number created by ``setUpClass``.
597
+ head_sha: HEAD SHA the script attaches the review to.
598
+ state_argument: ``CLEAN`` or ``DIRTY``.
599
+ findings_json_path: Path to the (empty-list) findings JSON.
600
+ audit_token: Token assigned to ``GH_TOKEN`` in the child env.
601
+ overridden_base_url: Base URL handed to the launcher (the local
602
+ stub server URL).
603
+
604
+ Returns:
605
+ Completed subprocess result with ``returncode``, ``stdout``, and
606
+ ``stderr`` for the test to inspect.
607
+ """
608
+ child_environment = dict(os.environ)
609
+ child_environment[GH_TOKEN_ENV_VAR_NAME] = audit_token
610
+ return subprocess.run(
611
+ [
612
+ sys.executable,
613
+ "-c",
614
+ LAUNCHER_SOURCE_CODE,
615
+ str(SCRIPT_DIRECTORY),
616
+ overridden_base_url,
617
+ CLI_FLAG_SKILL,
618
+ SKILL_BUGTEAM,
619
+ CLI_FLAG_OWNER,
620
+ LIVE_TEST_OWNER,
621
+ CLI_FLAG_REPO,
622
+ LIVE_TEST_REPO,
623
+ CLI_FLAG_PR_NUMBER,
624
+ str(pr_number),
625
+ CLI_FLAG_COMMIT,
626
+ head_sha,
627
+ CLI_FLAG_STATE,
628
+ state_argument,
629
+ CLI_FLAG_FINDINGS_JSON,
630
+ str(findings_json_path),
631
+ ],
632
+ capture_output=True,
633
+ text=True,
634
+ encoding="utf-8",
635
+ check=False,
636
+ env=child_environment,
637
+ )
638
+
639
+
640
+ class LivePostAuditThreadTests(unittest.TestCase):
641
+ """Live smoke tests for post_audit_thread.py against JonEcho/tests.
642
+
643
+ Every test in this class reuses a single throwaway draft PR created
644
+ in :meth:`setUpClass` and closed in :meth:`tearDownClass`. The CLEAN
645
+ and DIRTY tests post real reviews against that PR; the retry tests
646
+ redirect the script's HTTP layer to a localhost stub server so the
647
+ retry loop runs deterministically without touching api.github.com,
648
+ while still consuming the shared PR's number and HEAD SHA.
649
+ """
650
+
651
+ audit_account_token: str
652
+ local_clone_directory: Path
653
+ branch_name: str
654
+ pr_number: int
655
+ head_sha: str
656
+
657
+ @classmethod
658
+ def setUpClass(cls) -> None:
659
+ resolve_gh_auth_token()
660
+ cls.audit_account_token = resolve_audit_account_token(
661
+ LIVE_TEST_AUDIT_ACCOUNT_NAME
662
+ )
663
+ unique_suffix = uuid.uuid4().hex[:UUID_SUFFIX_LENGTH]
664
+ cls.branch_name = f"{LIVE_TEST_BRANCH_PREFIX}/{unique_suffix}"
665
+ cls.local_clone_directory = Path(
666
+ tempfile.mkdtemp(prefix=f"post-audit-thread-test-{unique_suffix}-")
667
+ )
668
+ cls.pr_number = 0
669
+ cls.head_sha = ""
670
+ try:
671
+ cls.pr_number, cls.head_sha = create_throwaway_pr(
672
+ cls.local_clone_directory,
673
+ cls.branch_name,
674
+ )
675
+ except Exception:
676
+ try:
677
+ remove_local_clone(cls.local_clone_directory)
678
+ finally:
679
+ best_effort_delete_remote_branch(cls.branch_name)
680
+ raise
681
+
682
+ @classmethod
683
+ def tearDownClass(cls) -> None:
684
+ try:
685
+ if cls.pr_number > 0:
686
+ close_throwaway_pr(cls.pr_number)
687
+ finally:
688
+ try:
689
+ remove_local_clone(cls.local_clone_directory)
690
+ finally:
691
+ best_effort_delete_remote_branch(cls.branch_name)
692
+
693
+ def _assert_review_state_for_url(
694
+ self, html_url: str, expected_state: str
695
+ ) -> dict[str, Any]:
696
+ review_id = review_id_from_html_url(html_url)
697
+ single_review_api_path = SINGLE_REVIEW_API_PATH_TEMPLATE.format(
698
+ owner=LIVE_TEST_OWNER,
699
+ repo=LIVE_TEST_REPO,
700
+ pr_number=self.pr_number,
701
+ review_id=review_id,
702
+ )
703
+ single_review = gh_api_object_json(single_review_api_path)
704
+ self.assertEqual(
705
+ single_review.get("state"),
706
+ expected_state,
707
+ f"unexpected review state for {html_url!r}: {single_review!r}",
708
+ )
709
+ return single_review
710
+
711
+ def _fetch_comments_for_review(self, html_url: str) -> list[dict[str, Any]]:
712
+ review_id = review_id_from_html_url(html_url)
713
+ review_comments_api_path = SINGLE_REVIEW_COMMENTS_API_PATH_TEMPLATE.format(
714
+ owner=LIVE_TEST_OWNER,
715
+ repo=LIVE_TEST_REPO,
716
+ pr_number=self.pr_number,
717
+ review_id=review_id,
718
+ )
719
+ return gh_api_paginated_json(review_comments_api_path)
720
+
721
+ def _run_script_capturing_html_url(
722
+ self,
723
+ state_argument: str,
724
+ findings_payload: list[dict[str, Any]],
725
+ ) -> str:
726
+ findings_path = write_findings_json(findings_payload)
727
+ try:
728
+ completion = invoke_post_audit_thread_script(
729
+ pr_number=self.pr_number,
730
+ head_sha=self.head_sha,
731
+ state_argument=state_argument,
732
+ findings_json_path=findings_path,
733
+ audit_token=self.audit_account_token,
734
+ )
735
+ finally:
736
+ try:
737
+ findings_path.unlink()
738
+ except OSError:
739
+ pass
740
+ self.assertEqual(
741
+ completion.returncode,
742
+ 0,
743
+ f"script exited {completion.returncode}; stdout={completion.stdout!r} "
744
+ f"stderr={completion.stderr!r}",
745
+ )
746
+ emitted_html_url = completion.stdout.strip().splitlines()[-1]
747
+ self.assertTrue(
748
+ emitted_html_url.startswith("https://github.com/"),
749
+ f"expected an html_url on stdout, got {emitted_html_url!r}",
750
+ )
751
+ return emitted_html_url
752
+
753
+ def test_clean_state_posts_approved_review_with_empty_comments(self) -> None:
754
+ emitted_html_url = self._run_script_capturing_html_url(
755
+ state_argument=STATE_CLEAN, findings_payload=[]
756
+ )
757
+ self._assert_review_state_for_url(emitted_html_url, GH_EVENT_APPROVED)
758
+ review_comments = self._fetch_comments_for_review(emitted_html_url)
759
+ self.assertEqual(
760
+ len(review_comments),
761
+ 0,
762
+ f"CLEAN state should produce zero inline comments on this review; "
763
+ f"saw {len(review_comments)}: {review_comments!r}",
764
+ )
765
+
766
+ def test_dirty_state_with_three_findings_posts_changes_requested_with_three_inline_threads(
767
+ self,
768
+ ) -> None:
769
+ findings_payload: list[dict[str, Any]] = [
770
+ {
771
+ JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
772
+ JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_ONE,
773
+ JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
774
+ JSON_FIELD_SEVERITY: SEVERITY_TAG_P0,
775
+ JSON_FIELD_DESCRIPTION: "Smoke finding one (heading line).",
776
+ JSON_FIELD_FIX_SUMMARY: "Trim the leading marker (smoke fix one).",
777
+ },
778
+ {
779
+ JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
780
+ JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_TWO,
781
+ JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
782
+ JSON_FIELD_SEVERITY: SEVERITY_TAG_P1,
783
+ JSON_FIELD_DESCRIPTION: "Smoke finding two (blank-line anchor).",
784
+ JSON_FIELD_FIX_SUMMARY: "Collapse the blank separator (smoke fix two).",
785
+ },
786
+ {
787
+ JSON_FIELD_PATH: LIVE_TEST_FIXTURE_FILENAME,
788
+ JSON_FIELD_LINE: LIVE_TEST_FIXTURE_LINE_FOR_FINDING_THREE,
789
+ JSON_FIELD_SIDE: INLINE_COMMENT_SIDE_RIGHT,
790
+ JSON_FIELD_SEVERITY: SEVERITY_TAG_P2,
791
+ JSON_FIELD_DESCRIPTION: "Smoke finding three (body-line anchor).",
792
+ JSON_FIELD_FIX_SUMMARY: "Tighten the description (smoke fix three).",
793
+ },
794
+ ]
795
+ emitted_html_url = self._run_script_capturing_html_url(
796
+ state_argument=STATE_DIRTY, findings_payload=findings_payload
797
+ )
798
+ self._assert_review_state_for_url(
799
+ emitted_html_url, GH_EVENT_CHANGES_REQUESTED
800
+ )
801
+ review_comments = self._fetch_comments_for_review(emitted_html_url)
802
+ self.assertEqual(
803
+ len(review_comments),
804
+ len(findings_payload),
805
+ f"DIRTY state should produce one inline comment per finding on "
806
+ f"this review; expected {len(findings_payload)} got "
807
+ f"{len(review_comments)}: {review_comments!r}",
808
+ )
809
+
810
+ def _expected_reviews_request_path(self) -> str:
811
+ full_endpoint_url = build_reviews_endpoint_url(
812
+ LIVE_TEST_OWNER, LIVE_TEST_REPO, self.pr_number
813
+ )
814
+ parsed_endpoint = urllib.parse.urlparse(full_endpoint_url)
815
+ return parsed_endpoint.path
816
+
817
+ def _run_retry_simulation_and_measure_elapsed(
818
+ self,
819
+ failure_count: int,
820
+ ) -> tuple[subprocess.CompletedProcess[str], _StubReviewsServer, float]:
821
+ findings_path = write_findings_json([])
822
+ try:
823
+ stub_server, stub_thread = spawn_stub_reviews_server(
824
+ failure_count=failure_count
825
+ )
826
+ try:
827
+ overridden_base_url = stub_reviews_server_base_url(stub_server)
828
+ start_time = time.perf_counter()
829
+ completion = invoke_post_audit_thread_with_url_override(
830
+ pr_number=self.pr_number,
831
+ head_sha=self.head_sha,
832
+ state_argument=STATE_CLEAN,
833
+ findings_json_path=findings_path,
834
+ audit_token=self.audit_account_token,
835
+ overridden_base_url=overridden_base_url,
836
+ )
837
+ elapsed_seconds = time.perf_counter() - start_time
838
+ finally:
839
+ shutdown_stub_reviews_server(stub_server, stub_thread)
840
+ finally:
841
+ try:
842
+ findings_path.unlink()
843
+ except OSError:
844
+ pass
845
+ return completion, stub_server, elapsed_seconds
846
+
847
+ def test_retry_succeeds_after_one_transient_502_response(self) -> None:
848
+ completion, stub_server, elapsed_seconds = (
849
+ self._run_retry_simulation_and_measure_elapsed(
850
+ failure_count=FAILURE_COUNT_FOR_RETRY_SUCCESS
851
+ )
852
+ )
853
+ self.assertEqual(
854
+ completion.returncode,
855
+ EXIT_CODE_SUCCESS,
856
+ f"retry-success: expected exit {EXIT_CODE_SUCCESS}; got "
857
+ f"{completion.returncode}; stdout={completion.stdout!r} "
858
+ f"stderr={completion.stderr!r}",
859
+ )
860
+ self.assertEqual(
861
+ stub_server.request_count,
862
+ TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS,
863
+ f"retry-success: stub should have received exactly "
864
+ f"{TOTAL_REQUEST_COUNT_FOR_RETRY_SUCCESS} POSTs (one 502 + "
865
+ f"one 200); got {stub_server.request_count}",
866
+ )
867
+ expected_request_path = self._expected_reviews_request_path()
868
+ self.assertEqual(
869
+ stub_server.recorded_request_path,
870
+ expected_request_path,
871
+ f"retry-success: stub received POST at "
872
+ f"{stub_server.recorded_request_path!r}; expected "
873
+ f"{expected_request_path!r} per build_reviews_endpoint_url",
874
+ )
875
+ self.assertGreaterEqual(
876
+ elapsed_seconds,
877
+ EXPECTED_RETRY_SUCCESS_ELAPSED_LOWER_BOUND_SECONDS,
878
+ f"retry-success should observe at least the first 1s backoff; "
879
+ f"elapsed={elapsed_seconds:.2f}s",
880
+ )
881
+
882
+ def test_retry_exhausts_and_exits_two_after_four_consecutive_502_responses(
883
+ self,
884
+ ) -> None:
885
+ completion, stub_server, elapsed_seconds = (
886
+ self._run_retry_simulation_and_measure_elapsed(
887
+ failure_count=FAILURE_COUNT_FOR_RETRY_EXHAUSTION
888
+ )
889
+ )
890
+ self.assertEqual(
891
+ completion.returncode,
892
+ EXIT_CODE_RETRY_EXHAUSTED,
893
+ f"retry-exhaustion: expected exit "
894
+ f"{EXIT_CODE_RETRY_EXHAUSTED}; got "
895
+ f"{completion.returncode}; stdout={completion.stdout!r} "
896
+ f"stderr={completion.stderr!r}",
897
+ )
898
+ self.assertEqual(
899
+ stub_server.request_count,
900
+ TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION,
901
+ f"retry-exhaustion: stub should have received exactly "
902
+ f"{TOTAL_REQUEST_COUNT_FOR_RETRY_EXHAUSTION} POSTs (one "
903
+ f"initial plus three retries); got "
904
+ f"{stub_server.request_count}",
905
+ )
906
+ expected_request_path = self._expected_reviews_request_path()
907
+ self.assertEqual(
908
+ stub_server.recorded_request_path,
909
+ expected_request_path,
910
+ f"retry-exhaustion: stub received POST at "
911
+ f"{stub_server.recorded_request_path!r}; expected "
912
+ f"{expected_request_path!r} per build_reviews_endpoint_url",
913
+ )
914
+ self.assertGreaterEqual(
915
+ elapsed_seconds,
916
+ EXPECTED_RETRY_EXHAUSTION_ELAPSED_LOWER_BOUND_SECONDS,
917
+ f"retry-exhaustion should observe ~21s of backoff "
918
+ f"(1s + 4s + 16s); elapsed={elapsed_seconds:.2f}s",
919
+ )
920
+
921
+
922
+ if __name__ == "__main__":
923
+ unittest.main()