claude-dev-env 1.93.0 → 1.94.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 (220) hide show
  1. package/_shared/pr-loop/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/audit-contract.md +1 -1
  3. package/_shared/pr-loop/gh-payloads.md +3 -3
  4. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  5. package/_shared/pr-loop/scripts/README.md +2 -2
  6. package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
  7. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  8. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  9. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -69
  10. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  11. package/_shared/pr-loop/state-schema.md +3 -22
  12. package/bin/CLAUDE.md +1 -1
  13. package/bin/install.mjs +2 -7
  14. package/bin/install.test.mjs +10 -4
  15. package/hooks/blocking/CLAUDE.md +2 -0
  16. package/hooks/blocking/pii_commit_command.py +398 -0
  17. package/hooks/blocking/pii_payload_scan.py +133 -0
  18. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  19. package/hooks/blocking/pii_scanner.py +26 -1
  20. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
  21. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  22. package/hooks/blocking/test_pii_scanner.py +27 -2
  23. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  24. package/hooks/hooks_constants/CLAUDE.md +2 -2
  25. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  26. package/hooks/hooks_constants/local_identity.py +65 -15
  27. package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
  28. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  29. package/hooks/hooks_constants/test_local_identity.py +68 -0
  30. package/package.json +1 -1
  31. package/scripts/CLAUDE.md +10 -1
  32. package/scripts/Get-SessionAccount.ps1 +210 -0
  33. package/scripts/tests/CLAUDE.md +15 -1
  34. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  35. package/skills/CLAUDE.md +3 -25
  36. package/skills/anthropic-plan/SKILL.md +1 -13
  37. package/skills/autoconverge/CLAUDE.md +5 -2
  38. package/skills/autoconverge/SKILL.md +112 -387
  39. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  40. package/skills/autoconverge/reference/closing-report.md +9 -2
  41. package/skills/autoconverge/reference/convergence.md +26 -22
  42. package/skills/autoconverge/reference/headless-safety.md +44 -0
  43. package/skills/autoconverge/reference/multi-pr.md +88 -0
  44. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  45. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  46. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  47. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  49. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  50. package/skills/autoconverge/workflow/converge.mjs +284 -94
  51. package/skills/bugteam/CLAUDE.md +1 -1
  52. package/skills/bugteam/CONSTRAINTS.md +3 -3
  53. package/skills/bugteam/PROMPTS.md +8 -19
  54. package/skills/bugteam/SKILL.md +13 -14
  55. package/skills/bugteam/reference/CLAUDE.md +1 -1
  56. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  57. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  58. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  59. package/skills/bugteam/reference/team-setup.md +11 -11
  60. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  61. package/skills/bugteam/scripts/README.md +20 -8
  62. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  63. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  64. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  65. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  66. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  67. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  68. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  69. package/skills/copilot-finding-triage/SKILL.md +80 -28
  70. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  71. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  72. package/skills/everything-search/SKILL.md +1 -2
  73. package/skills/grokify/SKILL.md +58 -0
  74. package/skills/grokify/templates/handoff-template.md +92 -0
  75. package/skills/orchestrator/SKILL.md +12 -8
  76. package/skills/orchestrator-refresh/SKILL.md +3 -3
  77. package/skills/pr-converge/SKILL.md +132 -233
  78. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  79. package/skills/pr-converge/reference/examples.md +37 -25
  80. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  81. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  82. package/skills/pr-converge/reference/per-tick.md +64 -41
  83. package/skills/pr-converge/reference/state-schema.md +27 -2
  84. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  85. package/skills/pr-converge/scripts/README.md +4 -3
  86. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  87. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  88. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  89. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  90. package/skills/pr-converge/scripts/conftest.py +8 -0
  91. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  92. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  93. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  94. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  95. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  96. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  97. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  98. package/skills/privacy-hygiene/SKILL.md +8 -7
  99. package/skills/reviewer-gates/SKILL.md +26 -8
  100. package/skills/session-log/SKILL.md +11 -12
  101. package/skills/session-tidy/SKILL.md +40 -30
  102. package/skills/skill-builder/CLAUDE.md +13 -10
  103. package/skills/skill-builder/SKILL.md +31 -31
  104. package/skills/skill-builder/references/CLAUDE.md +10 -6
  105. package/skills/skill-builder/references/delegation-map.md +13 -4
  106. package/skills/skill-builder/references/description-field.md +113 -0
  107. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  108. package/skills/skill-builder/references/skill-modularity.md +113 -0
  109. package/skills/skill-builder/references/skill-types.md +4 -0
  110. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  111. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  112. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  113. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  114. package/skills/skill-builder/workflows/new-skill.md +34 -10
  115. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  116. package/skills/usage-pause/SKILL.md +10 -14
  117. package/skills/bdd-protocol/CLAUDE.md +0 -26
  118. package/skills/bdd-protocol/SKILL.md +0 -30
  119. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  120. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  121. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  122. package/skills/bg-agent/CLAUDE.md +0 -17
  123. package/skills/bg-agent/SKILL.md +0 -69
  124. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  125. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  126. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  127. package/skills/caveman/CLAUDE.md +0 -15
  128. package/skills/caveman/SKILL.md +0 -38
  129. package/skills/code/CLAUDE.md +0 -17
  130. package/skills/code/SKILL.md +0 -178
  131. package/skills/deep-research/CLAUDE.md +0 -17
  132. package/skills/deep-research/SKILL.md +0 -80
  133. package/skills/findbugs/CLAUDE.md +0 -20
  134. package/skills/findbugs/SKILL.md +0 -241
  135. package/skills/fixbugs/CLAUDE.md +0 -19
  136. package/skills/fixbugs/SKILL.md +0 -142
  137. package/skills/gh-paginate/CLAUDE.md +0 -18
  138. package/skills/gh-paginate/SKILL.md +0 -84
  139. package/skills/gotcha/CLAUDE.md +0 -33
  140. package/skills/gotcha/SKILL.md +0 -73
  141. package/skills/implement/CLAUDE.md +0 -27
  142. package/skills/implement/SKILL.md +0 -66
  143. package/skills/implement/scripts/CLAUDE.md +0 -22
  144. package/skills/implement/scripts/append_note.py +0 -133
  145. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  146. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  147. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  148. package/skills/implement/scripts/test_append_note.py +0 -191
  149. package/skills/log-audit/CLAUDE.md +0 -20
  150. package/skills/log-audit/SKILL.md +0 -66
  151. package/skills/log-audit/reference/CLAUDE.md +0 -9
  152. package/skills/log-audit/reference/charter.md +0 -52
  153. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  154. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  155. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  156. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  157. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  158. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  159. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  160. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  161. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  162. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  163. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  164. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  165. package/skills/logifix/CLAUDE.md +0 -36
  166. package/skills/logifix/SKILL.md +0 -69
  167. package/skills/logifix/scripts/CLAUDE.md +0 -16
  168. package/skills/logifix/scripts/logifix.ps1 +0 -205
  169. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  170. package/skills/monitor-open-prs/SKILL.md +0 -88
  171. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  172. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  173. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  174. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  175. package/skills/post-audit-findings/SKILL.md +0 -80
  176. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  177. package/skills/pr-consistency-audit/SKILL.md +0 -116
  178. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  179. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  180. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  181. package/skills/pr-fix-protocol/SKILL.md +0 -65
  182. package/skills/pr-review-responder/CLAUDE.md +0 -35
  183. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  184. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  185. package/skills/pr-review-responder/README.md +0 -168
  186. package/skills/pr-review-responder/SKILL.md +0 -203
  187. package/skills/pr-review-responder/TESTING.md +0 -350
  188. package/skills/pr-scope-resolve/SKILL.md +0 -45
  189. package/skills/pre-compact/CLAUDE.md +0 -24
  190. package/skills/pre-compact/SKILL.md +0 -134
  191. package/skills/qbug/CLAUDE.md +0 -40
  192. package/skills/qbug/SKILL.md +0 -387
  193. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  194. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  195. package/skills/refine/CLAUDE.md +0 -44
  196. package/skills/refine/SKILL.md +0 -262
  197. package/skills/refine/templates/CLAUDE.md +0 -17
  198. package/skills/refine/templates/implementation-notes-template.html +0 -56
  199. package/skills/refine/templates/plan-template.md +0 -60
  200. package/skills/research-mode/CLAUDE.md +0 -35
  201. package/skills/research-mode/SKILL.md +0 -53
  202. package/skills/structure-prompt/CLAUDE.md +0 -42
  203. package/skills/structure-prompt/SKILL.md +0 -44
  204. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  205. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  206. package/skills/structure-prompt/reference/block-classification.md +0 -27
  207. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  208. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  209. package/skills/structure-prompt/reference/cleanup.md +0 -33
  210. package/skills/structure-prompt/reference/constraints.md +0 -33
  211. package/skills/structure-prompt/reference/directives.md +0 -37
  212. package/skills/structure-prompt/reference/examples.md +0 -72
  213. package/skills/structure-prompt/reference/instantiation.md +0 -51
  214. package/skills/structure-prompt/reference/output-contract.md +0 -72
  215. package/skills/structure-prompt/reference/per-category.md +0 -23
  216. package/skills/structure-prompt/reference/persona.md +0 -38
  217. package/skills/structure-prompt/reference/research.md +0 -33
  218. package/skills/structure-prompt/reference/structure.md +0 -28
  219. package/skills/verified-build/CLAUDE.md +0 -33
  220. package/skills/verified-build/SKILL.md +0 -33
@@ -1,107 +1,87 @@
1
- """Verify all convergence pre-conditions for a PR before marking ready.
2
-
3
- Usage:
4
- python scripts/check_convergence.py --owner <O> --repo <R> --pr-number <N>
5
- [--bugbot-down] [--copilot-down]
6
-
7
- The bugbot check-run gate is bypassed when either ``--bugbot-down`` is
8
- passed OR the ``CLAUDE_REVIEWS_DISABLED`` environment variable lists the
9
- ``bugbot`` token, so a Bugbot opt-out closes the gate without the flag.
10
-
11
- The Copilot review gate and the pending-requested-reviews gate are bypassed
12
- when either ``--copilot-down`` is passed OR the ``CLAUDE_REVIEWS_DISABLED``
13
- environment variable lists the ``copilot`` token, so a Copilot outage or
14
- quota exhaustion closes the broader convergence gate on the remaining
15
- signals without the flag.
16
-
17
- Exit codes:
18
- 0 all pre-conditions met
19
- 1 — one or more conditions not met (FAIL lines printed to stdout)
20
- 2 — gh CLI error
1
+ """Verify every convergence pre-condition for a PR before marking it ready.
2
+
3
+ ::
4
+
5
+ check_convergence.py --owner O --repo R --pr-number N [flags]
6
+ exit 0 ok: every gate passed — safe to mark ready
7
+ exit 1 flag: a gate failed (FAIL lines print to stdout)
8
+ exit 2 flag: gh CLI error
9
+
10
+ Each bypass flag closes one reviewer gate when that reviewer is unavailable.
11
+ ``--bugbot-down`` skips the bugbot check-run gate. ``--copilot-down`` skips
12
+ the Copilot review and pending-review gates. ``--bugteam-post-blocked`` skips
13
+ the bugteam CLEAN-review gate.
14
+
15
+ Each flag also honors its own reviews-disabled token: "bugbot", "copilot",
16
+ or "bugteam". A caller that cannot pass the flag reaches the same bypass by
17
+ exporting the token. The mark-ready blocker hook re-runs this script with no
18
+ flags, so its convergence re-check reads the bypass from the exported token
19
+ instead.
21
20
  """
22
21
 
23
22
  from __future__ import annotations
24
23
 
25
24
  import argparse
26
- import json
27
- import re
28
- import subprocess
29
25
  import sys
30
- from pathlib import Path
31
-
32
- _pr_converge_dir = Path(__file__).absolute().parent.parent
33
- if str(_pr_converge_dir) not in sys.path:
34
- sys.path.insert(0, str(_pr_converge_dir))
35
-
26
+ from typing import NamedTuple
27
+
28
+ import _pr_converge_path_setup # noqa: F401
29
+ from check_convergence_gates import (
30
+ _check_bot_review,
31
+ _check_bugbot,
32
+ _check_bugbot_not_dirty,
33
+ _flatten_paginated_reviews,
34
+ _get_mergeable,
35
+ _get_pr_head_sha,
36
+ _gh_api_paginated,
37
+ _short_sha,
38
+ )
39
+ from check_convergence_thread_gates import (
40
+ _check_no_pending_reviews,
41
+ _count_unresolved_bot_threads,
42
+ )
36
43
  from pr_converge_skill_constants.constants import (
44
+ ALL_COPILOT_CLEAN_REVIEW_STATES,
37
45
  ALL_COPILOT_DIRTY_REVIEW_STATES,
38
- ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS,
39
- BUGBOT_CHECK_RUN_NAME_SUBSTRING,
40
- BUGBOT_DIRTY_BODY_REGEX,
41
46
  BUGTEAM_LEGACY_CLEAN_TOKEN,
42
47
  BUGTEAM_LEGACY_HEADER_PREFIX,
43
48
  BUGTEAM_NEW_CLEAN_LABEL,
44
49
  BUGTEAM_NEW_HEADER_PREFIX,
45
- CHECK_RUNS_PER_PAGE,
46
- CLAUDE_LOGIN_FILTER_SUBSTRING,
47
- ALL_COPILOT_CLEAN_REVIEW_STATES,
48
50
  COPILOT_LOGIN_FILTER_SUBSTRING,
49
- COPILOT_REVIEWER_LOGIN,
50
- CURSOR_LOGIN_FILTER_SUBSTRING,
51
- EXIT_CODE_GH_ERROR,
52
- GH_CHECK_RUNS_PATH_TEMPLATE,
53
- GH_PR_OBJECT_PATH_TEMPLATE,
54
- GH_REQUESTED_REVIEWERS_PATH_TEMPLATE,
55
51
  GH_REVIEWS_PATH_TEMPLATE,
56
- GRAPHQL_REVIEW_THREADS_PAGE_SIZE,
57
52
  REVIEWS_PER_PAGE,
58
- UNRESOLVED_THREAD_DETAIL_MAX,
59
- )
60
-
61
- _shared_pr_loop_scripts_dir = (
62
- Path(__file__).absolute().parents[3] / "_shared" / "pr-loop" / "scripts"
63
53
  )
64
- if str(_shared_pr_loop_scripts_dir) not in sys.path:
65
- sys.path.insert(0, str(_shared_pr_loop_scripts_dir))
66
-
67
54
  from reviews_disabled import (
68
55
  is_bugbot_disabled_via_env,
56
+ is_bugteam_disabled_via_env,
69
57
  is_copilot_disabled_via_env,
70
58
  )
71
59
 
60
+ JsonObject = dict[str, object]
61
+ GateCondition = tuple[str, tuple[bool, str]]
72
62
 
73
- def _is_bugteam_review(review_body: str) -> bool:
74
- """Return True when a review body opens with a bugteam audit header.
75
63
 
76
- Args:
77
- review_body: Full body text of a PR review.
64
+ class GateContext(NamedTuple):
65
+ """The PR coordinates and per-reviewer bypass flags one gate run evaluates."""
78
66
 
79
- Returns:
80
- True when the body opens with either the new audit-template header
81
- prefix or the legacy bugteam loop header prefix; False otherwise.
82
- Used to identify bugteam audit reviews by body content rather than
83
- by the posting user's GitHub login (the underlying ``gh`` token is
84
- typically the PR-owner or reviewer identity, not ``claude[bot]``).
85
- """
86
- return (
87
- review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX)
88
- or review_body.startswith(BUGTEAM_LEGACY_HEADER_PREFIX)
89
- )
67
+ owner: str
68
+ repo: str
69
+ number: int
70
+ head_sha: str
71
+ is_bugbot_down: bool
72
+ is_copilot_down: bool
73
+ is_bugteam_post_blocked: bool
90
74
 
91
75
 
92
- def _is_clean_bugteam_review(review_body: str) -> bool:
93
- """Return True when a bugteam audit review body declares a clean pass.
76
+ def _is_bugteam_review(review_body: str) -> bool:
77
+ """Return True when a review body opens with a bugteam audit header prefix."""
78
+ return review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX) or review_body.startswith(
79
+ BUGTEAM_LEGACY_HEADER_PREFIX
80
+ )
94
81
 
95
- Args:
96
- review_body: Body text of a review that has already satisfied
97
- :func:`_is_bugteam_review`.
98
82
 
99
- Returns:
100
- True when the new-shape body's first line carries the clean state
101
- label, or the legacy-shape body ends with the legacy clean token.
102
- False for any other shape, including dirty audit reviews and
103
- bodies that do not match the bugteam header signature.
104
- """
83
+ def _is_clean_bugteam_review(review_body: str) -> bool:
84
+ """Return True when a bugteam audit review body declares a clean pass."""
105
85
  if review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX):
106
86
  first_line = review_body.splitlines()[0]
107
87
  return BUGTEAM_NEW_CLEAN_LABEL in first_line
@@ -110,417 +90,45 @@ def _is_clean_bugteam_review(review_body: str) -> bool:
110
90
  return False
111
91
 
112
92
 
113
- def _check_bugteam_clean(
114
- *, owner: str, repo: str, number: int, head_sha: str
115
- ) -> tuple[bool, str]:
116
- endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
117
- returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
118
- if returncode != 0:
119
- return False, f"gh api error: {stdout}"
120
- try:
121
- raw_output = json.loads(stdout)
122
- except json.JSONDecodeError:
123
- return False, "gh api response not valid JSON"
124
- if not isinstance(raw_output, list):
125
- return False, "unexpected gh api response shape (expected list)"
126
- all_pages = [p for p in raw_output if isinstance(p, list)]
127
- all_flat: list[dict[str, object]] = [
128
- each_entry
129
- for page in all_pages
130
- for each_entry in page
131
- if isinstance(each_entry, dict)
132
- ]
133
- all_flat.sort(
134
- key=lambda each_review: str(each_review.get("submitted_at", "")),
135
- reverse=True,
136
- )
137
- for each_review in all_flat:
138
- body = each_review.get("body", "")
139
- if not isinstance(body, str):
140
- continue
141
- if not _is_bugteam_review(body):
142
- continue
143
- commit_id = each_review.get("commit_id", "")
144
- if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
145
- continue
146
- review_id = each_review.get("id", "?")
147
- short_commit = commit_id[:7]
148
- if _is_clean_bugteam_review(body):
149
- return (
150
- True,
151
- f"review #{review_id}, clean bugteam audit, commit: {short_commit}",
152
- )
153
- return (
154
- False,
155
- f"review #{review_id}, dirty bugteam audit, commit: {short_commit}",
156
- )
157
- return False, f"no bugteam review found on {head_sha[:7]}"
158
-
159
-
160
- def _gh_api(endpoint_path: str) -> tuple[int, str]:
161
- completed_process = subprocess.run(
162
- ["gh", "api", endpoint_path],
163
- capture_output=True,
164
- text=True,
165
- encoding="utf-8",
166
- errors="replace",
167
- check=False,
168
- )
169
- return completed_process.returncode, completed_process.stdout
93
+ def _bugteam_review_detail(review: JsonObject, head_sha: str) -> tuple[bool, str] | None:
94
+ """Return the pass flag and detail for a bugteam review on HEAD, or None."""
95
+ body = review.get("body", "")
96
+ if not isinstance(body, str) or not _is_bugteam_review(body):
97
+ return None
98
+ commit_id = review.get("commit_id", "")
99
+ if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
100
+ return None
101
+ review_id = review.get("id", "?")
102
+ short_commit = _short_sha(commit_id)
103
+ if _is_clean_bugteam_review(body):
104
+ return True, f"review {review_id}, clean bugteam audit, commit {short_commit}"
105
+ return False, f"review {review_id}, dirty bugteam audit, commit {short_commit}"
170
106
 
171
107
 
172
- def _gh_api_paginated(endpoint_path: str) -> tuple[int, str]:
173
- completed_process = subprocess.run(
174
- ["gh", "api", endpoint_path, "--paginate", "--slurp"],
175
- capture_output=True,
176
- text=True,
177
- encoding="utf-8",
178
- errors="replace",
179
- check=False,
180
- )
181
- return completed_process.returncode, completed_process.stdout
182
-
183
-
184
- def _get_pr_head_sha(*, owner: str, repo: str, number: int) -> str:
185
- endpoint = GH_PR_OBJECT_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
186
- returncode, stdout = _gh_api(endpoint)
187
- if returncode != 0:
188
- print(f"gh api error fetching PR object: {stdout}", file=sys.stderr)
189
- raise SystemExit(EXIT_CODE_GH_ERROR)
190
- pr_object = json.loads(stdout)
191
- head_sha: object = pr_object.get("head", {}).get("sha")
192
- if not isinstance(head_sha, str):
193
- raise SystemExit(EXIT_CODE_GH_ERROR)
194
- return head_sha
195
-
196
-
197
- def _get_mergeable(*, owner: str, repo: str, number: int) -> tuple[bool, str]:
198
- endpoint = GH_PR_OBJECT_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
199
- returncode, stdout = _gh_api(endpoint)
200
- if returncode != 0:
201
- return False, f"gh api error: {stdout}"
202
- pr_object = json.loads(stdout)
203
- mergeable: object = pr_object.get("mergeable")
204
- mergeable_state: object = pr_object.get("mergeable_state", "unknown")
205
- state_str = str(mergeable_state)
206
- if mergeable is True and state_str == "clean":
207
- return True, "clean"
208
- return False, state_str
209
-
210
-
211
- def _check_bugbot(*, owner: str, repo: str, sha: str) -> tuple[bool, str]:
212
- endpoint = GH_CHECK_RUNS_PATH_TEMPLATE.format(owner=owner, repo=repo, sha=sha)
213
- returncode, stdout = _gh_api(f"{endpoint}?per_page={CHECK_RUNS_PER_PAGE}")
214
- if returncode != 0:
215
- return False, f"gh api error: {stdout}"
216
- try:
217
- response_body = json.loads(stdout)
218
- except json.JSONDecodeError:
219
- return False, "gh api response not valid JSON"
220
- check_runs: list[dict[str, object]] = []
221
- if isinstance(response_body, dict):
222
- raw_runs = response_body.get("check_runs")
223
- if isinstance(raw_runs, list):
224
- check_runs = [r for r in raw_runs if isinstance(r, dict)]
225
- for check_entry in check_runs:
226
- each_name = check_entry.get("name", "")
227
- if not isinstance(each_name, str):
228
- continue
229
- if BUGBOT_CHECK_RUN_NAME_SUBSTRING.lower() not in each_name.lower():
230
- continue
231
- conclusion = check_entry.get("conclusion", "")
232
- if conclusion in ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS:
233
- check_id = check_entry.get("id", "?")
234
- detail_url = check_entry.get("html_url", "")
235
- details_suffix = f" ({detail_url})" if detail_url else ""
236
- return True, f"check run #{check_id}, conclusion: {conclusion}{details_suffix}"
237
- return False, f"check run conclusion is '{conclusion}', expected {ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS}"
238
- return False, "no bugbot check run found"
239
-
240
-
241
- def _check_bugbot_not_dirty(*, owner: str, repo: str, number: int, head_sha: str) -> tuple[bool, str]:
242
- endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
243
- returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
244
- if returncode != 0:
245
- return True, "bugbot reviews unavailable (non-fatal)"
246
- try:
247
- raw_output = json.loads(stdout)
248
- except json.JSONDecodeError:
249
- return True, "bugbot reviews not valid JSON (non-fatal)"
250
- if not isinstance(raw_output, list):
251
- return True, "no reviews"
252
- all_pages = [p for p in raw_output if isinstance(p, list)]
253
- all_flat: list[dict[str, object]] = [
254
- each_entry
255
- for page in all_pages
256
- for each_entry in page
257
- if isinstance(each_entry, dict)
258
- ]
259
- all_flat.sort(
260
- key=lambda each_review: str(each_review.get("submitted_at", "")),
261
- reverse=True,
262
- )
263
- dirty_pattern = re.compile(BUGBOT_DIRTY_BODY_REGEX, re.IGNORECASE)
264
- for each_review in all_flat:
265
- user_obj = each_review.get("user")
266
- if not isinstance(user_obj, dict):
267
- continue
268
- login = user_obj.get("login", "")
269
- if not isinstance(login, str):
270
- continue
271
- if CURSOR_LOGIN_FILTER_SUBSTRING not in login.lower():
272
- continue
273
- commit_id = each_review.get("commit_id", "")
274
- if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
275
- continue
276
- body = each_review.get("body", "")
277
- if isinstance(body, str) and dirty_pattern.search(body):
278
- return False, "bugbot review body reports findings"
279
- return True, "clean"
280
- return True, "no bugbot review at HEAD"
281
-
282
-
283
- def _check_bot_review(
284
- *,
285
- owner: str,
286
- repo: str,
287
- number: int,
288
- head_sha: str,
289
- login_substring: str,
290
- clean_states: tuple[str, ...],
291
- dirty_states: tuple[str, ...],
292
- label: str,
293
- ) -> tuple[bool, str]:
108
+ def _check_bugteam_clean(*, owner: str, repo: str, number: int, head_sha: str) -> tuple[bool, str]:
109
+ """Return whether the newest bugteam audit review on HEAD declares a clean pass."""
294
110
  endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
295
111
  returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
296
112
  if returncode != 0:
297
113
  return False, f"gh api error: {stdout}"
298
- try:
299
- raw_output = json.loads(stdout)
300
- except json.JSONDecodeError:
301
- return False, "gh api response not valid JSON"
302
- if not isinstance(raw_output, list):
303
- return False, f"no {label} review found"
304
- all_pages = [p for p in raw_output if isinstance(p, list)]
305
- all_flat = [
306
- each_entry
307
- for page in all_pages
308
- for each_entry in page
309
- if isinstance(each_entry, dict)
310
- ]
311
- all_flat.sort(
312
- key=lambda each_review: str(each_review.get("submitted_at", "")),
313
- reverse=True,
314
- )
114
+ all_flat = _flatten_paginated_reviews(stdout)
115
+ if all_flat is None:
116
+ return False, "unexpected gh api response shape (expected list)"
315
117
  for each_review in all_flat:
316
- user_obj = each_review.get("user")
317
- if not isinstance(user_obj, dict):
318
- continue
319
- login = user_obj.get("login", "")
320
- if not isinstance(login, str):
321
- continue
322
- if login_substring not in login.lower():
323
- continue
324
- commit_id = each_review.get("commit_id", "")
325
- review_state = each_review.get("state", "")
326
- if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
327
- continue
328
- if review_state in clean_states:
329
- review_id = each_review.get("id", "?")
330
- return (
331
- True,
332
- f"review #{review_id}, state: {review_state}, commit: {commit_id[:7]}",
333
- )
334
- if review_state in dirty_states:
335
- return (
336
- False,
337
- f"review state is '{review_state}' (dirty), commit: {commit_id[:7]}",
338
- )
339
- return False, f"review state is '{review_state}', commit: {commit_id[:7]}"
340
- return False, f"no {label} review found on {head_sha[:7]}"
341
-
342
-
343
- def _gh_graphql(query: str, variables: dict[str, object]) -> tuple[int, str]:
344
- args: list[str] = ["gh", "api", "graphql", "-f", f"query={query}"]
345
- for each_key, each_value in variables.items():
346
- if each_value is None:
347
- continue
348
- if isinstance(each_value, int):
349
- args.extend(["-F", f"{each_key}={each_value}"])
350
- else:
351
- args.extend(["-f", f"{each_key}={each_value}"])
352
- completed_process = subprocess.run(
353
- args,
354
- capture_output=True,
355
- text=True,
356
- encoding="utf-8",
357
- errors="replace",
358
- check=False,
359
- )
360
- return completed_process.returncode, completed_process.stdout
361
-
362
-
363
- def _count_unresolved_bot_threads(
364
- *, owner: str, repo: str, number: int
365
- ) -> tuple[bool, str]:
366
- query = """
367
- query($owner: String!, $repo: String!, $number: Int!, $first: Int!, $cursor: String) {
368
- repository(owner: $owner, name: $repo) {
369
- pullRequest(number: $number) {
370
- reviewThreads(first: $first, after: $cursor) {
371
- nodes {
372
- isResolved
373
- isOutdated
374
- path
375
- comments(first: 1) {
376
- nodes {
377
- author { login }
378
- }
379
- }
380
- }
381
- pageInfo {
382
- hasNextPage
383
- endCursor
384
- }
385
- }
386
- }
387
- }
388
- }
389
- """
390
- bot_logins = (
391
- CURSOR_LOGIN_FILTER_SUBSTRING,
392
- CLAUDE_LOGIN_FILTER_SUBSTRING,
393
- COPILOT_LOGIN_FILTER_SUBSTRING,
394
- )
395
- unresolved: list[dict[str, object]] = []
396
- cursor: str | None = None
397
-
398
- while True:
399
- variables: dict[str, object] = {
400
- "owner": owner,
401
- "repo": repo,
402
- "number": number,
403
- "first": GRAPHQL_REVIEW_THREADS_PAGE_SIZE,
404
- "cursor": cursor,
405
- }
406
- returncode, stdout = _gh_graphql(query, variables)
407
- if returncode != 0:
408
- return False, f"gh api graphql error: {stdout}"
409
- try:
410
- response_body = json.loads(stdout)
411
- except json.JSONDecodeError:
412
- return False, "gh api graphql response not valid JSON"
413
- response_data = response_body.get("data", {})
414
- repository = response_data.get("repository", {}) if isinstance(response_data, dict) else {}
415
- pull_request = repository.get("pullRequest", {}) if isinstance(repository, dict) else {}
416
- threads = pull_request.get("reviewThreads", {}) if isinstance(pull_request, dict) else {}
417
- if not isinstance(threads, dict):
418
- return False, "unexpected GraphQL response shape"
419
- nodes = threads.get("nodes", [])
420
- if isinstance(nodes, list):
421
- for each_thread in nodes:
422
- if not isinstance(each_thread, dict):
423
- continue
424
- if each_thread.get("isResolved") is True:
425
- continue
426
- if each_thread.get("isOutdated") is True:
427
- continue
428
- comments_wrapper = each_thread.get("comments", {})
429
- if not isinstance(comments_wrapper, dict):
430
- continue
431
- comments_nodes = comments_wrapper.get("nodes", [])
432
- if not isinstance(comments_nodes, list) or not comments_nodes:
433
- continue
434
- first_comment = comments_nodes[0]
435
- if not isinstance(first_comment, dict):
436
- continue
437
- author_wrapper = first_comment.get("author")
438
- if not isinstance(author_wrapper, dict):
439
- continue
440
- login = author_wrapper.get("login", "")
441
- if not isinstance(login, str):
442
- continue
443
- is_bot = any(bot in login.lower() for bot in bot_logins)
444
- if not is_bot:
445
- continue
446
- unresolved.append(each_thread)
447
- page_info = threads.get("pageInfo", {})
448
- if not isinstance(page_info, dict) or not page_info.get("hasNextPage"):
449
- break
450
- next_cursor = page_info.get("endCursor")
451
- if isinstance(next_cursor, str):
452
- cursor = next_cursor
453
- else:
454
- break
455
-
456
- if not unresolved:
457
- return True, "0 unresolved"
458
- details_parts: list[str] = []
459
- for each_thread in unresolved[:UNRESOLVED_THREAD_DETAIL_MAX]:
460
- thread_path = each_thread.get("path", "?")
461
- details_parts.append(str(thread_path))
462
- detail_text = "; ".join(details_parts)
463
- if len(unresolved) > UNRESOLVED_THREAD_DETAIL_MAX:
464
- detail_text += f" ... and {len(unresolved) - UNRESOLVED_THREAD_DETAIL_MAX} more"
465
- return False, f"{len(unresolved)} unresolved ({detail_text})"
466
-
467
-
468
- def _check_no_pending_reviews(
469
- *, owner: str, repo: str, number: int
470
- ) -> tuple[bool, str]:
471
- endpoint = GH_REQUESTED_REVIEWERS_PATH_TEMPLATE.format(
472
- owner=owner, repo=repo, number=number
473
- )
474
- returncode, stdout = _gh_api(endpoint)
475
- if returncode != 0:
476
- return False, f"gh api error: {stdout}"
477
- try:
478
- response_body = json.loads(stdout)
479
- except json.JSONDecodeError:
480
- return True, "no pending (empty response)"
481
- if isinstance(response_body, dict):
482
- users = response_body.get("users", [])
483
- elif isinstance(response_body, list):
484
- users = response_body
485
- else:
486
- return True, "no pending (unexpected format)"
487
- if not isinstance(users, list):
488
- return True, "no pending"
489
- copilot_pending = []
490
- for each_user in users:
491
- if not isinstance(each_user, dict):
492
- continue
493
- login = each_user.get("login", "")
494
- if isinstance(login, str) and COPILOT_REVIEWER_LOGIN.lower() in login.lower():
495
- copilot_pending.append(login)
496
- if copilot_pending:
497
- return False, f"pending: {', '.join(copilot_pending)}"
498
- return True, "no pending reviewers"
499
-
500
-
501
- def _bugbot_conditions(
502
- *, owner: str, repo: str, number: int, head_sha: str, is_bugbot_down: bool
503
- ) -> list[tuple[str, tuple[bool, str]]]:
504
- """Build the Bugbot gate conditions, bypassed when Bugbot is down.
118
+ detail = _bugteam_review_detail(each_review, head_sha)
119
+ if detail is not None:
120
+ return detail
121
+ return False, f"no bugteam review found on {_short_sha(head_sha)}"
505
122
 
506
- Args:
507
- owner: GitHub repository owner login.
508
- repo: GitHub repository name.
509
- number: Pull request number to inspect.
510
- head_sha: Current PR HEAD SHA the gates evaluate against.
511
- is_bugbot_down: When True, emit a single bypassed check-run
512
- condition and skip the review-body content gate entirely.
513
123
 
514
- Returns:
515
- The bugbot check-run condition, plus the review-body content
516
- condition when the check-run gate is present and passing.
517
- """
518
- if is_bugbot_down:
124
+ def _bugbot_conditions(context: GateContext) -> list[GateCondition]:
125
+ """Build the Bugbot gate conditions, bypassed when Bugbot is down."""
126
+ if context.is_bugbot_down:
519
127
  return [("bugbot_clean_at == current_head", (True, "bypassed (bugbot_down)"))]
520
- conditions: list[tuple[str, tuple[bool, str]]] = [
128
+ conditions: list[GateCondition] = [
521
129
  (
522
130
  "bugbot_clean_at == current_head",
523
- _check_bugbot(owner=owner, repo=repo, sha=head_sha),
131
+ _check_bugbot(owner=context.owner, repo=context.repo, sha=context.head_sha),
524
132
  )
525
133
  ]
526
134
  if conditions[-1][1][0]:
@@ -528,39 +136,36 @@ def _bugbot_conditions(
528
136
  (
529
137
  "bugbot review body clean",
530
138
  _check_bugbot_not_dirty(
531
- owner=owner, repo=repo, number=number, head_sha=head_sha
139
+ owner=context.owner, repo=context.repo, number=context.number, head_sha=context.head_sha
532
140
  ),
533
141
  )
534
142
  )
535
143
  return conditions
536
144
 
537
145
 
538
- def _copilot_review_condition(
539
- *, owner: str, repo: str, number: int, head_sha: str, is_copilot_down: bool
540
- ) -> tuple[str, tuple[bool, str]]:
541
- """Build the Copilot review gate condition, bypassed when Copilot is down.
146
+ def _bugteam_condition(context: GateContext) -> GateCondition:
147
+ """Build the bugteam CLEAN-review condition, skipped when the post was blocked."""
148
+ if context.is_bugteam_post_blocked:
149
+ return ("bugteam_clean_at == current_head", (True, "bypassed (bugteam_post_blocked)"))
150
+ return (
151
+ "bugteam_clean_at == current_head",
152
+ _check_bugteam_clean(
153
+ owner=context.owner, repo=context.repo, number=context.number, head_sha=context.head_sha
154
+ ),
155
+ )
542
156
 
543
- Args:
544
- owner: GitHub repository owner login.
545
- repo: GitHub repository name.
546
- number: Pull request number to inspect.
547
- head_sha: Current PR HEAD SHA the gate evaluates against.
548
- is_copilot_down: When True, return a bypassed condition rather than
549
- demanding a Copilot review that an outage or quota exhaustion
550
- keeps from landing.
551
157
 
552
- Returns:
553
- The Copilot review gate condition for the current HEAD.
554
- """
555
- if is_copilot_down:
158
+ def _copilot_review_condition(context: GateContext) -> GateCondition:
159
+ """Build the Copilot review gate condition, bypassed when Copilot is down."""
160
+ if context.is_copilot_down:
556
161
  return ("copilot_clean_at == current_head", (True, "bypassed (copilot_down)"))
557
162
  return (
558
163
  "copilot_clean_at == current_head",
559
164
  _check_bot_review(
560
- owner=owner,
561
- repo=repo,
562
- number=number,
563
- head_sha=head_sha,
165
+ owner=context.owner,
166
+ repo=context.repo,
167
+ number=context.number,
168
+ head_sha=context.head_sha,
564
169
  login_substring=COPILOT_LOGIN_FILTER_SUBSTRING,
565
170
  clean_states=ALL_COPILOT_CLEAN_REVIEW_STATES,
566
171
  dirty_states=ALL_COPILOT_DIRTY_REVIEW_STATES,
@@ -569,213 +174,151 @@ def _copilot_review_condition(
569
174
  )
570
175
 
571
176
 
572
- def _pending_reviews_condition(
573
- *, owner: str, repo: str, number: int, is_copilot_down: bool
574
- ) -> tuple[str, tuple[bool, str]]:
575
- """Build the pending-requested-reviews condition, bypassed when Copilot is down.
576
-
577
- Args:
578
- owner: GitHub repository owner login.
579
- repo: GitHub repository name.
580
- number: Pull request number to inspect.
581
- is_copilot_down: When True, return a bypassed condition so a Copilot
582
- review request that will never land does not strand the gate.
583
-
584
- Returns:
585
- The pending-requested-reviews condition, which the gate checks for a
586
- still-pending Copilot reviewer.
587
- """
588
- if is_copilot_down:
177
+ def _pending_reviews_condition(context: GateContext) -> GateCondition:
178
+ """Build the pending-requested-reviews condition, bypassed when Copilot is down."""
179
+ if context.is_copilot_down:
589
180
  return ("no pending requested reviews", (True, "bypassed (copilot_down)"))
590
181
  return (
591
182
  "no pending requested reviews",
592
- _check_no_pending_reviews(owner=owner, repo=repo, number=number),
183
+ _check_no_pending_reviews(owner=context.owner, repo=context.repo, number=context.number),
593
184
  )
594
185
 
595
186
 
596
- def _print_conditions(all_conditions: list[tuple[str, tuple[bool, str]]]) -> int:
597
- """Print one PASS/FAIL line per condition and return the aggregate exit code.
187
+ def _state_conditions(context: GateContext) -> list[GateCondition]:
188
+ """Build the thread, mergeable, and pending-review conditions in gate order."""
189
+ return [
190
+ (
191
+ "zero unresolved bot threads",
192
+ _count_unresolved_bot_threads(owner=context.owner, repo=context.repo, number=context.number),
193
+ ),
194
+ ("PR is mergeable", _get_mergeable(owner=context.owner, repo=context.repo, number=context.number)),
195
+ _pending_reviews_condition(context),
196
+ ]
598
197
 
599
- Args:
600
- all_conditions: Ordered (label, (passed, detail)) gate results.
601
198
 
602
- Returns:
603
- ``0`` when every condition passed, ``1`` when at least one failed.
604
- """
199
+ def _build_all_conditions(context: GateContext) -> list[GateCondition]:
200
+ """Build the ordered convergence conditions the checker prints and grades."""
201
+ conditions = list(_bugbot_conditions(context))
202
+ conditions.append(_bugteam_condition(context))
203
+ conditions.append(_copilot_review_condition(context))
204
+ conditions.extend(_state_conditions(context))
205
+ return conditions
206
+
207
+
208
+ def _print_conditions(all_conditions: list[GateCondition]) -> int:
209
+ """Write one PASS/FAIL line per condition and return the aggregate exit code."""
605
210
  is_all_passed = True
606
- for each_index, (each_label, (each_passed, each_detail)) in enumerate(
607
- all_conditions, start=1
608
- ):
211
+ for each_index, (each_label, (each_passed, each_detail)) in enumerate(all_conditions, start=1):
609
212
  status = "PASS" if each_passed else "FAIL"
610
- print(f"{each_index}. {each_label}: {status} — {each_detail}")
213
+ sys.stdout.write(f"{each_index}. {each_label}: {status} — {each_detail}\n")
611
214
  if not each_passed:
612
215
  is_all_passed = False
613
- print()
216
+ sys.stdout.write("\n")
614
217
  if is_all_passed:
615
- print("All pre-conditions met — PR is ready to mark ready.")
218
+ sys.stdout.write("All pre-conditions met — PR is ready to mark ready.\n")
616
219
  else:
617
- print("One or more pre-conditions not met — do not mark ready.")
220
+ sys.stdout.write("One or more pre-conditions not met — do not mark ready.\n")
618
221
  return 0 if is_all_passed else 1
619
222
 
620
223
 
224
+ def _evaluate_convergence(context: GateContext) -> int:
225
+ """Write the HEAD line then the per-condition PASS/FAIL lines for a gate run."""
226
+ sys.stdout.write(f"HEAD: {_short_sha(context.head_sha)}\n\n")
227
+ return _print_conditions(_build_all_conditions(context))
228
+
229
+
621
230
  def check_all(
622
- *,
623
231
  owner: str,
624
232
  repo: str,
625
233
  number: int,
626
234
  is_bugbot_down: bool,
627
235
  is_copilot_down: bool,
236
+ is_bugteam_post_blocked: bool = False,
628
237
  ) -> int:
629
238
  """Run every convergence gate and print one PASS/FAIL line per condition.
630
-
631
239
  Args:
632
240
  owner: GitHub repository owner login.
633
241
  repo: GitHub repository name.
634
242
  number: Pull request number to inspect.
635
- is_bugbot_down: When True, bypass both the Cursor Bugbot check-run
636
- presence gate and the bugbot review-body content gate. The
637
- check-run gate appears in the condition list with a
638
- ``bypassed (bugbot_down)`` note; the review-body gate is
639
- omitted entirely. Callers pass True when the lead has
640
- declared Cursor Bugbot unreachable on the current HEAD so the
641
- broader convergence gate can still close on the remaining
642
- signals.
643
- is_copilot_down: When True, bypass both the Copilot review gate and
644
- the pending-requested-reviews gate, each shown with a
645
- ``bypassed (copilot_down)`` note. Callers pass True when Copilot
646
- is down or out of quota on the current HEAD so the broader
647
- convergence gate can still close on the remaining signals; the
648
- bypassed pending gate keeps a Copilot review request that will
649
- never land from stranding the gate.
650
-
243
+ is_bugbot_down: True bypasses the bugbot check-run and review-body gates.
244
+ is_copilot_down: True bypasses the Copilot review and pending gates.
245
+ is_bugteam_post_blocked: True skips the bugteam CLEAN-review gate.
651
246
  Returns:
652
- ``0`` when every gate reports PASS, ``1`` when at least one gate
653
- reports FAIL. Per-gate ``gh api`` transport failures surface as
654
- gate FAIL lines in the printed output and contribute to the ``1``
655
- exit code.
656
-
657
- Raises:
658
- SystemExit: Propagated by the initial ``_get_pr_head_sha`` call
659
- with ``EXIT_CODE_GH_ERROR`` when the PR-head-SHA fetch fails
660
- before any gate runs. The function does not catch this
661
- exception; the caller is responsible for converting it into
662
- an exit code.
247
+ 0 when every gate passes, 1 when at least one gate fails.
663
248
  """
664
249
  head_sha = _get_pr_head_sha(owner=owner, repo=repo, number=number)
665
- print(f"HEAD: {head_sha[:7]}\n")
666
-
667
- conditions: list[tuple[str, tuple[bool, str]]] = []
668
- conditions.extend(
669
- _bugbot_conditions(
670
- owner=owner,
671
- repo=repo,
672
- number=number,
673
- head_sha=head_sha,
674
- is_bugbot_down=is_bugbot_down,
675
- )
250
+ context = GateContext(
251
+ owner=owner,
252
+ repo=repo,
253
+ number=number,
254
+ head_sha=head_sha,
255
+ is_bugbot_down=is_bugbot_down,
256
+ is_copilot_down=is_copilot_down,
257
+ is_bugteam_post_blocked=is_bugteam_post_blocked,
676
258
  )
677
- conditions.append(
678
- (
679
- "bugteam_clean_at == current_head",
680
- _check_bugteam_clean(
681
- owner=owner, repo=repo, number=number, head_sha=head_sha
682
- ),
683
- )
684
- )
685
- conditions.append(
686
- _copilot_review_condition(
687
- owner=owner,
688
- repo=repo,
689
- number=number,
690
- head_sha=head_sha,
691
- is_copilot_down=is_copilot_down,
692
- )
693
- )
694
- conditions.append(
695
- (
696
- "zero unresolved bot threads",
697
- _count_unresolved_bot_threads(owner=owner, repo=repo, number=number),
698
- )
699
- )
700
- conditions.append(
701
- ("PR is mergeable", _get_mergeable(owner=owner, repo=repo, number=number))
702
- )
703
- conditions.append(
704
- _pending_reviews_condition(
705
- owner=owner, repo=repo, number=number, is_copilot_down=is_copilot_down
706
- )
707
- )
708
- return _print_conditions(conditions)
259
+ return _evaluate_convergence(context)
709
260
 
710
261
 
711
- def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
712
- """Parse command-line arguments for the convergence checker.
713
-
714
- Args:
715
- all_argv: Argument list excluding the program name, typically
716
- ``sys.argv[1:]``.
717
-
718
- Returns:
719
- Namespace exposing ``owner``, ``repo``, ``pr_number``,
720
- ``bugbot_down``, and ``copilot_down`` attributes. ``bugbot_down``
721
- and ``copilot_down`` default to False so the base hook contract
722
- (``--owner X --repo Y --pr-number N``) picks up the full gate set.
723
- """
262
+ def _build_argument_parser() -> argparse.ArgumentParser:
263
+ """Build the command-line argument parser for the convergence checker."""
724
264
  parser = argparse.ArgumentParser(description=__doc__)
725
265
  parser.add_argument("--owner", required=True, help="GitHub repository owner")
726
266
  parser.add_argument("--repo", required=True, help="GitHub repository name")
727
- parser.add_argument(
728
- "--pr-number", required=True, type=int, help="Pull request number"
729
- )
267
+ parser.add_argument("--pr-number", required=True, type=int, help="Pull request number")
730
268
  parser.add_argument(
731
269
  "--bugbot-down",
732
270
  action="store_true",
733
- help=(
734
- "Bypass the bugbot check-run gate (gate 1) when the lead has "
735
- "declared Cursor Bugbot unreachable on the current HEAD."
736
- ),
271
+ help="Bypass the bugbot check-run gate when Cursor Bugbot is unreachable on HEAD.",
737
272
  )
738
273
  parser.add_argument(
739
274
  "--copilot-down",
740
275
  action="store_true",
741
- help=(
742
- "Bypass the Copilot review gate and the pending-requested-reviews "
743
- "gate when Copilot is down or out of quota on the current HEAD."
744
- ),
276
+ help="Bypass the Copilot review and pending-review gates when Copilot is down on HEAD.",
745
277
  )
746
- return parser.parse_args(all_argv)
278
+ parser.add_argument(
279
+ "--bugteam-post-blocked",
280
+ action="store_true",
281
+ help="Skip the bugteam CLEAN-review gate when the environment refused the CLEAN post on HEAD.",
282
+ )
283
+ return parser
747
284
 
748
285
 
749
- def _resolve_bugbot_down(bugbot_down_flag: bool) -> bool:
750
- """Combine the explicit flag with the env availability gate for Bugbot.
286
+ def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
287
+ """Parse command-line arguments for the convergence checker.
751
288
 
752
289
  Args:
753
- bugbot_down_flag: Value of the ``--bugbot-down`` CLI flag.
290
+ all_argv: Argument list excluding the program name.
754
291
 
755
292
  Returns:
756
- True when the flag is set, or when Bugbot is disabled for the run:
757
- off by default unless ``CLAUDE_REVIEWS_ENABLED`` lists ``bugbot``, and
758
- always when ``CLAUDE_REVIEWS_DISABLED`` lists ``bugbot``. The env gate
759
- bypasses the bugbot gates even when the caller omits the flag.
293
+ Namespace exposing owner, repo, pr_number, bugbot_down, copilot_down,
294
+ and bugteam_post_blocked attributes.
760
295
  """
761
- return bugbot_down_flag or is_bugbot_disabled_via_env()
296
+ return _build_argument_parser().parse_args(all_argv)
297
+
298
+
299
+ def _resolve_bugbot_down(is_bugbot_down_flag: bool) -> bool:
300
+ """Combine the --bugbot-down flag with the env availability gate for Bugbot."""
301
+ return is_bugbot_down_flag or is_bugbot_disabled_via_env()
762
302
 
763
303
 
764
304
  def _resolve_copilot_down(is_copilot_down_flag: bool) -> bool:
765
- """Combine the explicit flag with the CLAUDE_REVIEWS_DISABLED env opt-out.
305
+ """Combine the --copilot-down flag with the CLAUDE_REVIEWS_DISABLED env opt-out."""
306
+ return is_copilot_down_flag or is_copilot_disabled_via_env()
766
307
 
767
- Args:
768
- is_copilot_down_flag: Value of the ``--copilot-down`` CLI flag.
769
308
 
770
- Returns:
771
- True when the flag is set OR ``CLAUDE_REVIEWS_DISABLED`` lists the
772
- ``copilot`` token, so a Copilot outage signalled through the env var
773
- bypasses the Copilot gates even when the caller omits the flag. The
774
- mark-ready blocker hook re-runs this script without the flag, so the
775
- env token is the only channel a genuine Copilot outage has to pass
776
- that independent gate.
309
+ def _resolve_bugteam_post_blocked(is_bugteam_post_blocked_flag: bool) -> bool:
310
+ """Combine the --bugteam-post-blocked flag with the bugteam env opt-out.
311
+
312
+ ::
313
+
314
+ flag True, env unset -> True (caller passed the flag)
315
+ flag False, reviews-disabled lists bugteam -> True (exported token)
316
+ flag False, env unset -> False (gate runs)
317
+
318
+ The mark-ready blocker hook re-runs this script with no flags, so the env
319
+ fallback lets an exported bugteam token carry the bypass into that re-check.
777
320
  """
778
- return is_copilot_down_flag or is_copilot_disabled_via_env()
321
+ return is_bugteam_post_blocked_flag or is_bugteam_disabled_via_env()
779
322
 
780
323
 
781
324
  def main(all_arguments: list[str]) -> int:
@@ -785,7 +328,7 @@ def main(all_arguments: list[str]) -> int:
785
328
  all_arguments: Argument list excluding the program name.
786
329
 
787
330
  Returns:
788
- ``0`` on full convergence, ``1`` on one or more gate failures.
331
+ 0 on full convergence, 1 on one or more gate failures.
789
332
  """
790
333
  arguments = parse_arguments(all_arguments)
791
334
  return check_all(
@@ -794,6 +337,7 @@ def main(all_arguments: list[str]) -> int:
794
337
  number=getattr(arguments, "pr_number"),
795
338
  is_bugbot_down=_resolve_bugbot_down(arguments.bugbot_down),
796
339
  is_copilot_down=_resolve_copilot_down(arguments.copilot_down),
340
+ is_bugteam_post_blocked=_resolve_bugteam_post_blocked(arguments.bugteam_post_blocked),
797
341
  )
798
342
 
799
343