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.
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/audit-contract.md +1 -1
- package/_shared/pr-loop/gh-payloads.md +3 -3
- package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
- package/_shared/pr-loop/scripts/README.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +127 -13
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -69
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
- package/_shared/pr-loop/state-schema.md +3 -22
- package/bin/CLAUDE.md +1 -1
- package/bin/install.mjs +2 -7
- package/bin/install.test.mjs +10 -4
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/pii_commit_command.py +398 -0
- package/hooks/blocking/pii_payload_scan.py +133 -0
- package/hooks/blocking/pii_prevention_blocker.py +151 -449
- package/hooks/blocking/pii_scanner.py +26 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
- package/hooks/blocking/test_pii_scanner.py +27 -2
- package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
- package/hooks/hooks_constants/local_identity.py +65 -15
- package/hooks/hooks_constants/pii_prevention_constants.py +20 -8
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/test_local_identity.py +68 -0
- package/package.json +1 -1
- package/scripts/CLAUDE.md +10 -1
- package/scripts/Get-SessionAccount.ps1 +210 -0
- package/scripts/tests/CLAUDE.md +15 -1
- package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
- package/skills/CLAUDE.md +3 -25
- package/skills/anthropic-plan/SKILL.md +1 -13
- package/skills/autoconverge/CLAUDE.md +5 -2
- package/skills/autoconverge/SKILL.md +112 -387
- package/skills/autoconverge/reference/CLAUDE.md +4 -1
- package/skills/autoconverge/reference/closing-report.md +9 -2
- package/skills/autoconverge/reference/convergence.md +26 -22
- package/skills/autoconverge/reference/headless-safety.md +44 -0
- package/skills/autoconverge/reference/multi-pr.md +88 -0
- package/skills/autoconverge/reference/self-closing-loop.md +84 -0
- package/skills/autoconverge/reference/stop-conditions.md +21 -13
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
- package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +284 -94
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/CONSTRAINTS.md +3 -3
- package/skills/bugteam/PROMPTS.md +8 -19
- package/skills/bugteam/SKILL.md +13 -14
- package/skills/bugteam/reference/CLAUDE.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
- package/skills/bugteam/reference/github-pr-reviews.md +56 -38
- package/skills/bugteam/reference/team-setup.md +11 -11
- package/skills/bugteam/scripts/CLAUDE.md +6 -6
- package/skills/bugteam/scripts/README.md +20 -8
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
- package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
- package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
- package/skills/copilot-finding-triage/SKILL.md +80 -28
- package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
- package/skills/copilot-finding-triage/templates/notification.md +8 -3
- package/skills/everything-search/SKILL.md +1 -2
- package/skills/grokify/SKILL.md +58 -0
- package/skills/grokify/templates/handoff-template.md +92 -0
- package/skills/orchestrator/SKILL.md +12 -8
- package/skills/orchestrator-refresh/SKILL.md +3 -3
- package/skills/pr-converge/SKILL.md +132 -233
- package/skills/pr-converge/reference/convergence-gates.md +102 -66
- package/skills/pr-converge/reference/examples.md +37 -25
- package/skills/pr-converge/reference/fix-protocol.md +7 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
- package/skills/pr-converge/reference/per-tick.md +64 -41
- package/skills/pr-converge/reference/state-schema.md +27 -2
- package/skills/pr-converge/scripts/CLAUDE.md +8 -0
- package/skills/pr-converge/scripts/README.md +4 -3
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
- package/skills/pr-converge/scripts/check_convergence.py +189 -645
- package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
- package/skills/pr-converge/scripts/conftest.py +8 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
- package/skills/privacy-hygiene/SKILL.md +8 -7
- package/skills/reviewer-gates/SKILL.md +26 -8
- package/skills/session-log/SKILL.md +11 -12
- package/skills/session-tidy/SKILL.md +40 -30
- package/skills/skill-builder/CLAUDE.md +13 -10
- package/skills/skill-builder/SKILL.md +31 -31
- package/skills/skill-builder/references/CLAUDE.md +10 -6
- package/skills/skill-builder/references/delegation-map.md +13 -4
- package/skills/skill-builder/references/description-field.md +113 -0
- package/skills/skill-builder/references/self-audit-checklist.md +21 -4
- package/skills/skill-builder/references/skill-modularity.md +113 -0
- package/skills/skill-builder/references/skill-types.md +4 -0
- package/skills/skill-builder/templates/CLAUDE.md +4 -4
- package/skills/skill-builder/templates/gap-analysis.md +25 -1
- package/skills/skill-builder/workflows/CLAUDE.md +6 -4
- package/skills/skill-builder/workflows/improve-skill.md +17 -16
- package/skills/skill-builder/workflows/new-skill.md +34 -10
- package/skills/skill-builder/workflows/polish-skill.md +59 -43
- package/skills/usage-pause/SKILL.md +10 -14
- package/skills/bdd-protocol/CLAUDE.md +0 -26
- package/skills/bdd-protocol/SKILL.md +0 -30
- package/skills/bdd-protocol/references/CLAUDE.md +0 -10
- package/skills/bdd-protocol/references/anti-patterns.md +0 -26
- package/skills/bdd-protocol/references/example-mapping.md +0 -23
- package/skills/bg-agent/CLAUDE.md +0 -17
- package/skills/bg-agent/SKILL.md +0 -69
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
- package/skills/caveman/CLAUDE.md +0 -15
- package/skills/caveman/SKILL.md +0 -38
- package/skills/code/CLAUDE.md +0 -17
- package/skills/code/SKILL.md +0 -178
- package/skills/deep-research/CLAUDE.md +0 -17
- package/skills/deep-research/SKILL.md +0 -80
- package/skills/findbugs/CLAUDE.md +0 -20
- package/skills/findbugs/SKILL.md +0 -241
- package/skills/fixbugs/CLAUDE.md +0 -19
- package/skills/fixbugs/SKILL.md +0 -142
- package/skills/gh-paginate/CLAUDE.md +0 -18
- package/skills/gh-paginate/SKILL.md +0 -84
- package/skills/gotcha/CLAUDE.md +0 -33
- package/skills/gotcha/SKILL.md +0 -73
- package/skills/implement/CLAUDE.md +0 -27
- package/skills/implement/SKILL.md +0 -66
- package/skills/implement/scripts/CLAUDE.md +0 -22
- package/skills/implement/scripts/append_note.py +0 -133
- package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
- package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
- package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
- package/skills/implement/scripts/test_append_note.py +0 -191
- package/skills/log-audit/CLAUDE.md +0 -20
- package/skills/log-audit/SKILL.md +0 -66
- package/skills/log-audit/reference/CLAUDE.md +0 -9
- package/skills/log-audit/reference/charter.md +0 -52
- package/skills/log-audit/scripts/CLAUDE.md +0 -27
- package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
- package/skills/log-audit/scripts/collect_log_window.py +0 -199
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
- package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
- package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
- package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
- package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
- package/skills/logifix/CLAUDE.md +0 -36
- package/skills/logifix/SKILL.md +0 -69
- package/skills/logifix/scripts/CLAUDE.md +0 -16
- package/skills/logifix/scripts/logifix.ps1 +0 -205
- package/skills/monitor-open-prs/CLAUDE.md +0 -34
- package/skills/monitor-open-prs/SKILL.md +0 -88
- package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
- package/skills/monitor-open-prs/test_skill_contract.py +0 -27
- package/skills/post-audit-findings/SKILL.md +0 -80
- package/skills/pr-consistency-audit/CLAUDE.md +0 -34
- package/skills/pr-consistency-audit/SKILL.md +0 -116
- package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
- package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
- package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
- package/skills/pr-fix-protocol/SKILL.md +0 -65
- package/skills/pr-review-responder/CLAUDE.md +0 -35
- package/skills/pr-review-responder/EXAMPLES.md +0 -590
- package/skills/pr-review-responder/PRINCIPLES.md +0 -533
- package/skills/pr-review-responder/README.md +0 -168
- package/skills/pr-review-responder/SKILL.md +0 -203
- package/skills/pr-review-responder/TESTING.md +0 -350
- package/skills/pr-scope-resolve/SKILL.md +0 -45
- package/skills/pre-compact/CLAUDE.md +0 -24
- package/skills/pre-compact/SKILL.md +0 -134
- package/skills/qbug/CLAUDE.md +0 -40
- package/skills/qbug/SKILL.md +0 -387
- package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
- package/skills/refine/CLAUDE.md +0 -44
- package/skills/refine/SKILL.md +0 -262
- package/skills/refine/templates/CLAUDE.md +0 -17
- package/skills/refine/templates/implementation-notes-template.html +0 -56
- package/skills/refine/templates/plan-template.md +0 -60
- package/skills/research-mode/CLAUDE.md +0 -35
- package/skills/research-mode/SKILL.md +0 -53
- package/skills/structure-prompt/CLAUDE.md +0 -42
- package/skills/structure-prompt/SKILL.md +0 -44
- package/skills/structure-prompt/reference/CLAUDE.md +0 -28
- package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
- package/skills/structure-prompt/reference/block-classification.md +0 -27
- package/skills/structure-prompt/reference/canonical-case.md +0 -48
- package/skills/structure-prompt/reference/citation-depth.md +0 -70
- package/skills/structure-prompt/reference/cleanup.md +0 -33
- package/skills/structure-prompt/reference/constraints.md +0 -33
- package/skills/structure-prompt/reference/directives.md +0 -37
- package/skills/structure-prompt/reference/examples.md +0 -72
- package/skills/structure-prompt/reference/instantiation.md +0 -51
- package/skills/structure-prompt/reference/output-contract.md +0 -72
- package/skills/structure-prompt/reference/per-category.md +0 -23
- package/skills/structure-prompt/reference/persona.md +0 -38
- package/skills/structure-prompt/reference/research.md +0 -33
- package/skills/structure-prompt/reference/structure.md +0 -28
- package/skills/verified-build/CLAUDE.md +0 -33
- package/skills/verified-build/SKILL.md +0 -33
|
@@ -1,107 +1,87 @@
|
|
|
1
|
-
"""Verify
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
77
|
-
|
|
64
|
+
class GateContext(NamedTuple):
|
|
65
|
+
"""The PR coordinates and per-reviewer bypass flags one gate run evaluates."""
|
|
78
66
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
93
|
-
"""Return True when a
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
173
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
317
|
-
if not
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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[
|
|
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
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
553
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
|
597
|
-
"""
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
sys.stdout.write("\n")
|
|
614
217
|
if is_all_passed:
|
|
615
|
-
|
|
218
|
+
sys.stdout.write("All pre-conditions met — PR is ready to mark ready.\n")
|
|
616
219
|
else:
|
|
617
|
-
|
|
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:
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
|
712
|
-
"""
|
|
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
|
-
|
|
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
|
|
750
|
-
"""
|
|
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
|
-
|
|
290
|
+
all_argv: Argument list excluding the program name.
|
|
754
291
|
|
|
755
292
|
Returns:
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|