@deftai/directive-content 0.58.0 → 0.60.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/.githooks/pre-push +10 -9
- package/Taskfile.yml +57 -67
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/rules/rules-pack-0.1.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/relocate.yml +18 -48
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -2
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2551
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
|
@@ -1,1004 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""pr_merge_readiness.py -- Pre-merge Greptile-body verdict gate (#796 follow-up).
|
|
3
|
-
|
|
4
|
-
Verifies that a pull request's Greptile review state, parsed from the rolling
|
|
5
|
-
summary **comment body** (not the GitHub CheckRun status), satisfies the
|
|
6
|
-
``skills/deft-directive-review-cycle/SKILL.md`` Phase 2 Step 6 exit condition
|
|
7
|
-
AND the ``skills/deft-directive-swarm/SKILL.md`` Phase 5 -> 6 merge-readiness
|
|
8
|
-
checklist before any ``gh pr merge`` call.
|
|
9
|
-
|
|
10
|
-
Background
|
|
11
|
-
----------
|
|
12
|
-
The GitHub CheckRun named ``Greptile Review`` reports SUCCESS when the bot
|
|
13
|
-
finishes its review pass, irrespective of confidence score or P0 / P1
|
|
14
|
-
findings in the comment body. A swarm orchestrator that gates merges on the
|
|
15
|
-
CheckRun alone can start a merge cascade on a PR that Greptile has flagged
|
|
16
|
-
as unready (e.g. ``Confidence: 3/5`` with one P1 finding). The errored-state
|
|
17
|
-
guard at ``skills/deft-directive-swarm/SKILL.md`` Phase 6 Step 1 (#526)
|
|
18
|
-
covers the NEUTRAL CheckRun case but not the symmetric SUCCESS-with-findings
|
|
19
|
-
blind spot. This script is the structural gap-closer.
|
|
20
|
-
|
|
21
|
-
What it checks
|
|
22
|
-
--------------
|
|
23
|
-
1. The current PR HEAD SHA equals the SHA Greptile recorded as
|
|
24
|
-
``Last reviewed commit:`` (markdown-link form per
|
|
25
|
-
``templates/swarm-greptile-poller-prompt.md``).
|
|
26
|
-
2. The Greptile rolling-summary comment body is NOT the errored sentinel
|
|
27
|
-
``Greptile encountered an error while reviewing this PR`` (#526).
|
|
28
|
-
3. The body's ``Confidence Score: X / 5`` is ``> 3``.
|
|
29
|
-
4. The body's P0 / P1 finding counts (via HTML severity badges, with a
|
|
30
|
-
structured-section heading fallback) are both zero. P2 findings are
|
|
31
|
-
non-blocking style suggestions per
|
|
32
|
-
``skills/deft-directive-review-cycle/SKILL.md`` Phase 2 Step 6 and do
|
|
33
|
-
NOT gate the loop.
|
|
34
|
-
|
|
35
|
-
Layered fallback chain (#1368)
|
|
36
|
-
------------------------------
|
|
37
|
-
Long-running monitors that polled ``pr_merge_readiness.py --json`` saw
|
|
38
|
-
``head: None`` for ~15+ minutes during the #1166 swarm cascade because
|
|
39
|
-
the primary ``gh api ... --jq ...`` capture path occasionally returned
|
|
40
|
-
empty / malformed stdout under the Grok Build harness on Windows. The
|
|
41
|
-
#1366 ``_safe_subprocess.run_text`` helper closes the
|
|
42
|
-
``Thread-3 (_readerthread) UnicodeDecodeError`` root cause; #1368 adds a
|
|
43
|
-
layered fallback so a *single* gh failure on the primary path no longer
|
|
44
|
-
blinds the dependent monitor. Every response carries a ``via``
|
|
45
|
-
discriminator so callers can detect degraded mode:
|
|
46
|
-
|
|
47
|
-
- ``via: "primary"`` -- canonical Greptile rolling-summary parse path
|
|
48
|
-
- ``via: "fallback1"`` -- gh api REST + manual Python-side comment parse
|
|
49
|
-
(no ``--jq``, so a jq decode hiccup on the primary cannot mask the
|
|
50
|
-
comment list). Same gate evaluation as the primary; CLEAN verdicts are
|
|
51
|
-
authoritative.
|
|
52
|
-
- ``via: "fallback2"`` -- coarse PR-view + check-run signal. Reports
|
|
53
|
-
``state``, ``head_sha``, and a flattened check-run summary so callers
|
|
54
|
-
know the *PR* state even when no Greptile rolling-summary comment is
|
|
55
|
-
reachable. ! Never produces a CLEAN verdict -- always merge-blocked
|
|
56
|
-
with the failure ``"fallback2 is a coarse signal, not a CLEAN verdict"``.
|
|
57
|
-
Use for monitor heartbeat only; merge cascade MUST continue waiting
|
|
58
|
-
for a primary or fallback1 CLEAN.
|
|
59
|
-
- ``via: "error"`` -- every layer failed externally. Response
|
|
60
|
-
carries ``error`` (one-line summary) + ``partial_data`` (whatever was
|
|
61
|
-
observable across the cascade attempts) so the monitor can step
|
|
62
|
-
forward instead of going blind.
|
|
63
|
-
|
|
64
|
-
Usage
|
|
65
|
-
-----
|
|
66
|
-
uv run python scripts/pr_merge_readiness.py <pr-number> [--repo OWNER/REPO]
|
|
67
|
-
uv run python scripts/pr_merge_readiness.py 652 --json
|
|
68
|
-
|
|
69
|
-
Exit codes
|
|
70
|
-
----------
|
|
71
|
-
0 -- merge-ready (all gates pass; via primary or fallback1)
|
|
72
|
-
1 -- merge-blocked (one or more gates failed; OR fallback2 reached;
|
|
73
|
-
see structured failure list in --json output)
|
|
74
|
-
2 -- external / config error (every layer failed; gh missing,
|
|
75
|
-
total gh failure, ...; --json output still emits a structured
|
|
76
|
-
envelope with via="error")
|
|
77
|
-
|
|
78
|
-
Pure stdlib + ``gh`` CLI; no third-party deps.
|
|
79
|
-
"""
|
|
80
|
-
|
|
81
|
-
from __future__ import annotations
|
|
82
|
-
|
|
83
|
-
import argparse
|
|
84
|
-
import json
|
|
85
|
-
import re
|
|
86
|
-
import subprocess
|
|
87
|
-
import sys
|
|
88
|
-
from dataclasses import asdict, dataclass, field
|
|
89
|
-
from pathlib import Path
|
|
90
|
-
|
|
91
|
-
# Make sibling scripts importable both when run as __main__ and when imported by tests.
|
|
92
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
93
|
-
|
|
94
|
-
try:
|
|
95
|
-
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
96
|
-
reconfigure_stdio()
|
|
97
|
-
except ImportError:
|
|
98
|
-
# _stdio_utf8 is optional; some test contexts load this module directly.
|
|
99
|
-
pass
|
|
100
|
-
|
|
101
|
-
# UTF-8-safe subprocess capture (#1366). Greptile rolling-summary bodies
|
|
102
|
-
# frequently include non-cp1252 glyphs (smart quotes, em dashes, arrows);
|
|
103
|
-
# under the default ``text=True`` decode path on Windows + Grok Build,
|
|
104
|
-
# that crashes Python's internal reader thread with UnicodeDecodeError and
|
|
105
|
-
# leaves the caller with empty / malformed stdout. ``_safe_subprocess.run_text``
|
|
106
|
-
# forces encoding="utf-8", errors="replace" so any undecodable byte is
|
|
107
|
-
# substituted with U+FFFD rather than aborting the read.
|
|
108
|
-
from _safe_subprocess import run_text # noqa: E402
|
|
109
|
-
|
|
110
|
-
# ---- Exit codes -------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
EXIT_OK = 0
|
|
113
|
-
EXIT_MERGE_BLOCKED = 1
|
|
114
|
-
EXIT_EXTERNAL_ERROR = 2
|
|
115
|
-
|
|
116
|
-
# ---- Greptile body parsing --------------------------------------------------
|
|
117
|
-
|
|
118
|
-
# Greptile's bot login -- used to identify the rolling-summary comment among
|
|
119
|
-
# all PR issue comments. The login is stable across reviews; the comment is
|
|
120
|
-
# edited in place rather than re-created.
|
|
121
|
-
_GREPTILE_LOGIN = "greptile-apps[bot]"
|
|
122
|
-
|
|
123
|
-
# Errored sentinel from #526. Exact-string match per the swarm SKILL.
|
|
124
|
-
_GREPTILE_ERRORED_SENTINEL = "Greptile encountered an error while reviewing this PR"
|
|
125
|
-
|
|
126
|
-
# `Last reviewed commit:` -- markdown-link form. The hand-authored variant
|
|
127
|
-
# `Last reviewed commit:\s*[0-9a-f]+` will NEVER match Greptile's actual
|
|
128
|
-
# output (Agent D, post-#721 swarm; #727 Bug 1). The regex below mirrors the
|
|
129
|
-
# canonical encoding in templates/swarm-greptile-poller-prompt.md.
|
|
130
|
-
_LAST_REVIEWED_RE = re.compile(
|
|
131
|
-
r"Last reviewed commit:\s*\[[^\]]*\]\(https?://github\.com/[^/]+/[^/]+/commit/(?P<sha>[0-9a-f]{7,40})",
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
# Confidence Score parse. Tolerant of whitespace around the slash.
|
|
135
|
-
_CONFIDENCE_RE = re.compile(r"Confidence Score:\s*(?P<score>\d+)\s*/\s*5", re.IGNORECASE)
|
|
136
|
-
|
|
137
|
-
# P0 / P1 badge markers. These appear ONLY on actual findings, not in
|
|
138
|
-
# summary text or clean-summary phrasing like "No P0 or P1 issues found"
|
|
139
|
-
# (which contains the literal P0 / P1 tokens and would false-positive a
|
|
140
|
-
# raw substring scan). See templates/swarm-greptile-poller-prompt.md
|
|
141
|
-
# detection block (a) -- this is the "preferred" approach.
|
|
142
|
-
_P0_BADGE = '<img alt="P0"'
|
|
143
|
-
_P1_BADGE = '<img alt="P1"'
|
|
144
|
-
|
|
145
|
-
# Structured-section heading fallback (approach (b)). Used when no badges
|
|
146
|
-
# are present (some Greptile review templates render headings without
|
|
147
|
-
# badges). The heading captures `### P0 findings (N)` and similar.
|
|
148
|
-
_SECTION_RE = re.compile(
|
|
149
|
-
r"###\s+(?P<sev>P[012])\s+findings\s*\((?P<count>\d+)\)",
|
|
150
|
-
re.IGNORECASE,
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
# Informal-clean prose signals (#1543). Greptile sometimes posts a separate
|
|
154
|
-
# human-readable "all resolved / diff is clean" comment without the canonical
|
|
155
|
-
# rolling-summary fields. These patterns distinguish that state from a
|
|
156
|
-
# review that is still writing or a malformed partial summary.
|
|
157
|
-
_INFORMAL_CLEAN_SIGNAL_RE = re.compile(
|
|
158
|
-
r"(?:"
|
|
159
|
-
r"diff is clean|"
|
|
160
|
-
r"(?:prior |previously flagged )?issues? (?:are )?now resolved|"
|
|
161
|
-
r"all prior issues resolved|"
|
|
162
|
-
r"no new issues(?: to flag)?|"
|
|
163
|
-
r"looks solid|"
|
|
164
|
-
r"good to proceed"
|
|
165
|
-
r")",
|
|
166
|
-
re.IGNORECASE,
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
_INFORMAL_CLEAN_STATE = "informal-clean missing-canonical-fields"
|
|
170
|
-
|
|
171
|
-
_INFORMAL_CLEAN_DIAGNOSTIC = (
|
|
172
|
-
f"Greptile {_INFORMAL_CLEAN_STATE} state (#1543): the latest Greptile bot "
|
|
173
|
-
"comment says the diff is clean / prior issues are resolved, but omits the "
|
|
174
|
-
"canonical rolling-summary fields `Last reviewed commit:` and "
|
|
175
|
-
"`Confidence Score: X/5` that merge gates require. Prose alone cannot "
|
|
176
|
-
"prove review currency or confidence. Recovery: (1) comment "
|
|
177
|
-
"`@greptileai review` on the PR to retrigger a canonical rolling summary, "
|
|
178
|
-
"(2) wait for Greptile to edit its primary summary comment with both "
|
|
179
|
-
"canonical fields on the current HEAD, or (3) document an explicit "
|
|
180
|
-
"operator override per skills/deft-directive-swarm/SKILL.md Phase 6 "
|
|
181
|
-
"Step 1. Do NOT keep polling -- this is not 'review still writing'."
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
@dataclass
|
|
186
|
-
class GreptileVerdict:
|
|
187
|
-
"""Structured parse of the Greptile rolling-summary comment body."""
|
|
188
|
-
found: bool # was a Greptile comment present at all
|
|
189
|
-
errored: bool # body == errored sentinel (#526)
|
|
190
|
-
last_reviewed_sha: str | None
|
|
191
|
-
confidence: int | None
|
|
192
|
-
p0_count: int
|
|
193
|
-
p1_count: int
|
|
194
|
-
p2_count: int
|
|
195
|
-
informal_clean: bool = False # clean prose but missing canonical fields (#1543)
|
|
196
|
-
raw_body_excerpt: str = "" # first ~200 chars for debugging
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def is_informal_clean_missing_canonical_fields(
|
|
200
|
-
verdict: GreptileVerdict, body: str,
|
|
201
|
-
) -> bool:
|
|
202
|
-
"""Return True when Greptile posted informal clean prose without canonical fields.
|
|
203
|
-
|
|
204
|
-
Mirrors the detection contract in
|
|
205
|
-
``templates/swarm-greptile-poller-prompt.md`` and
|
|
206
|
-
``skills/deft-directive-review-cycle/SKILL.md`` (#1543).
|
|
207
|
-
"""
|
|
208
|
-
if not verdict.found or verdict.errored:
|
|
209
|
-
return False
|
|
210
|
-
if verdict.last_reviewed_sha is not None or verdict.confidence is not None:
|
|
211
|
-
return False
|
|
212
|
-
if verdict.p0_count > 0 or verdict.p1_count > 0:
|
|
213
|
-
return False
|
|
214
|
-
return _INFORMAL_CLEAN_SIGNAL_RE.search(body) is not None
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def parse_greptile_body(body: str) -> GreptileVerdict:
|
|
218
|
-
"""Parse a Greptile rolling-summary comment body into a structured verdict.
|
|
219
|
-
|
|
220
|
-
Mirrors the per-poll detection block in
|
|
221
|
-
``templates/swarm-greptile-poller-prompt.md`` so this script and the
|
|
222
|
-
poller agree on the same interpretation of any given comment.
|
|
223
|
-
|
|
224
|
-
The whitespace-aware ``not body.strip()`` guard accounts for ``gh api
|
|
225
|
-
--jq`` raw-output behaviour (Greptile review P2 #1, PR #797): in raw
|
|
226
|
-
mode jq emits a trailing newline for every output value, including
|
|
227
|
-
the empty-string fallback ``// ""``. With ``--paginate`` jq runs
|
|
228
|
-
per-page, so a no-comment PR with N pages of issue comments produces
|
|
229
|
-
``"\\n" * N``. A bare ``not body`` guard treats that as truthy and
|
|
230
|
-
falls through to the SHA / confidence parsers, producing the less
|
|
231
|
-
useful "Could not parse ..." diagnostics instead of the intended
|
|
232
|
-
"No Greptile rolling-summary comment found" message. Stripping first
|
|
233
|
-
routes the empty-jq case through the right diagnostic.
|
|
234
|
-
"""
|
|
235
|
-
if not body or not body.strip():
|
|
236
|
-
return GreptileVerdict(
|
|
237
|
-
found=False,
|
|
238
|
-
errored=False,
|
|
239
|
-
last_reviewed_sha=None,
|
|
240
|
-
confidence=None,
|
|
241
|
-
p0_count=0,
|
|
242
|
-
p1_count=0,
|
|
243
|
-
p2_count=0,
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
errored = body.strip().startswith(_GREPTILE_ERRORED_SENTINEL)
|
|
247
|
-
|
|
248
|
-
# Take the LAST `Last reviewed commit:` match, not the first. Greptile
|
|
249
|
-
# may quote suggestion code (test fixtures, prior comment text) that
|
|
250
|
-
# contains the same `Last reviewed commit: [x](.../commit/<sha>)`
|
|
251
|
-
# pattern -- those quotes appear earlier in the body. The actual
|
|
252
|
-
# ground-truth SHA Greptile records lives in the trailing `<sub>` block
|
|
253
|
-
# ("Reviews (N): Last reviewed commit: [...](.../commit/<sha>) | ...").
|
|
254
|
-
# Self-dogfood on PR #797 surfaced this: my own test fixtures were
|
|
255
|
-
# quoted in Greptile's P2 #3 suggestion and the parser picked their
|
|
256
|
-
# `bbbbbbb` SHA over the real HEAD.
|
|
257
|
-
sha_matches = list(_LAST_REVIEWED_RE.finditer(body))
|
|
258
|
-
last_reviewed_sha = sha_matches[-1].group("sha") if sha_matches else None
|
|
259
|
-
|
|
260
|
-
conf_match = _CONFIDENCE_RE.search(body)
|
|
261
|
-
confidence = int(conf_match.group("score")) if conf_match else None
|
|
262
|
-
|
|
263
|
-
# Badge-count first (preferred -- robust by construction).
|
|
264
|
-
p0_count = body.count(_P0_BADGE)
|
|
265
|
-
p1_count = body.count(_P1_BADGE)
|
|
266
|
-
p2_count = body.count('<img alt="P2"')
|
|
267
|
-
|
|
268
|
-
# Structured-section fallback -- only consulted when the body lacks
|
|
269
|
-
# the rich-format `<details>` collapsible. Greptile's modern review
|
|
270
|
-
# format ALWAYS uses HTML severity badges (`<img alt="P0" ...>`) and
|
|
271
|
-
# wraps findings in `<details><summary>...</summary>...</details>`
|
|
272
|
-
# collapsibles. When the body contains `<details>`, the badge counts
|
|
273
|
-
# are authoritative -- a `### P1 findings (N)` heading appearing in
|
|
274
|
-
# such a body is almost certainly Greptile QUOTING reviewer-suggested
|
|
275
|
-
# code (test fixtures, prior P2 suggestions) rather than an actual
|
|
276
|
-
# finding-section heading. The PR #797 self-dogfood surfaced this:
|
|
277
|
-
# Greptile's clean review of HEAD `85c0b1d` quoted the new
|
|
278
|
-
# `test_mixed_format_p2_badge_with_p1_section_heading` test fixture,
|
|
279
|
-
# which contains the literal `### P1 findings (1)` string -- and the
|
|
280
|
-
# naive fallback false-positived a P1 count.
|
|
281
|
-
#
|
|
282
|
-
# Heuristic: the legacy heading-only format never used `<details>`,
|
|
283
|
-
# so its absence is the trigger for the fallback. This keeps the
|
|
284
|
-
# fallback for hypothetical legacy bodies without sacrificing
|
|
285
|
-
# correctness on the modern format. Badge-count primary remains the
|
|
286
|
-
# source of truth for any body Greptile actually emits today.
|
|
287
|
-
has_details_format = "<details>" in body
|
|
288
|
-
if not has_details_format and p0_count == 0 and p1_count == 0:
|
|
289
|
-
for match in _SECTION_RE.finditer(body):
|
|
290
|
-
sev = match.group("sev").upper()
|
|
291
|
-
count = int(match.group("count"))
|
|
292
|
-
if sev == "P0":
|
|
293
|
-
p0_count = count
|
|
294
|
-
elif sev == "P1":
|
|
295
|
-
p1_count = count
|
|
296
|
-
elif sev == "P2" and p2_count == 0:
|
|
297
|
-
# Only override P2 from heading if the badge pass found none
|
|
298
|
-
# -- preserves badge-source-of-truth when both surfaces emit.
|
|
299
|
-
p2_count = count
|
|
300
|
-
|
|
301
|
-
verdict = GreptileVerdict(
|
|
302
|
-
found=True,
|
|
303
|
-
errored=errored,
|
|
304
|
-
last_reviewed_sha=last_reviewed_sha,
|
|
305
|
-
confidence=confidence,
|
|
306
|
-
p0_count=p0_count,
|
|
307
|
-
p1_count=p1_count,
|
|
308
|
-
p2_count=p2_count,
|
|
309
|
-
raw_body_excerpt=body[:200],
|
|
310
|
-
)
|
|
311
|
-
if is_informal_clean_missing_canonical_fields(verdict, body):
|
|
312
|
-
verdict.informal_clean = True
|
|
313
|
-
return verdict
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
# ---- gh wrappers ------------------------------------------------------------
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
def _run_gh(cmd: list[str]) -> tuple[int, str, str]:
|
|
320
|
-
"""Run a gh subcommand and return (returncode, stdout, stderr).
|
|
321
|
-
|
|
322
|
-
Routes through ``_safe_subprocess.run_text`` so the captured stdout /
|
|
323
|
-
stderr are decoded as UTF-8 with ``errors="replace"`` (#1366). The
|
|
324
|
-
default ``text=True`` binding decodes via the host codepage on
|
|
325
|
-
Windows + Grok Build, which crashes Python's internal reader thread
|
|
326
|
-
with ``UnicodeDecodeError`` whenever the Greptile rolling-summary
|
|
327
|
-
body contains non-cp1252 bytes -- the exact failure mode behind the
|
|
328
|
-
``head: None`` symptom on the #1166 swarm monitor.
|
|
329
|
-
|
|
330
|
-
Returns (-1, "", message) on FileNotFoundError / TimeoutExpired so the
|
|
331
|
-
caller can map either to EXIT_EXTERNAL_ERROR uniformly.
|
|
332
|
-
"""
|
|
333
|
-
try:
|
|
334
|
-
result = run_text(cmd, timeout=60)
|
|
335
|
-
except FileNotFoundError:
|
|
336
|
-
return -1, "", "gh CLI not found. Install GitHub CLI."
|
|
337
|
-
except subprocess.TimeoutExpired:
|
|
338
|
-
return -1, "", f"gh CLI timed out: {' '.join(cmd)}"
|
|
339
|
-
return result.returncode, result.stdout, result.stderr
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def fetch_pr_head_sha(pr_number: int, repo: str | None) -> str | None:
|
|
343
|
-
"""Return the PR's current HEAD ref SHA, or None on error."""
|
|
344
|
-
cmd = ["gh", "pr", "view", str(pr_number), "--json", "headRefOid", "--jq", ".headRefOid"]
|
|
345
|
-
if repo:
|
|
346
|
-
cmd.extend(["--repo", repo])
|
|
347
|
-
rc, out, err = _run_gh(cmd)
|
|
348
|
-
if rc != 0:
|
|
349
|
-
print(
|
|
350
|
-
f"Error: gh failed fetching PR #{pr_number} headRefOid: {err.strip()}",
|
|
351
|
-
file=sys.stderr,
|
|
352
|
-
)
|
|
353
|
-
return None
|
|
354
|
-
sha = out.strip()
|
|
355
|
-
return sha or None
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
def fetch_greptile_comment_body(pr_number: int, repo: str | None) -> str | None:
|
|
359
|
-
"""Return the body of the Greptile rolling-summary comment, or "" if no
|
|
360
|
-
Greptile comment is present, or None on external error.
|
|
361
|
-
|
|
362
|
-
Greptile edits its summary comment in place rather than creating a new
|
|
363
|
-
one each review pass, so we filter by the bot login.
|
|
364
|
-
"""
|
|
365
|
-
if not repo:
|
|
366
|
-
# Resolve repo from current checkout if the caller did not pass it.
|
|
367
|
-
rc, out, err = _run_gh(
|
|
368
|
-
["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]
|
|
369
|
-
)
|
|
370
|
-
if rc != 0:
|
|
371
|
-
print(
|
|
372
|
-
f"Error: could not resolve --repo from cwd: {err.strip()}",
|
|
373
|
-
file=sys.stderr,
|
|
374
|
-
)
|
|
375
|
-
return None
|
|
376
|
-
repo = out.strip()
|
|
377
|
-
if not repo:
|
|
378
|
-
print(
|
|
379
|
-
"Error: empty repo from gh repo view (specify --repo OWNER/REPO).",
|
|
380
|
-
file=sys.stderr,
|
|
381
|
-
)
|
|
382
|
-
return None
|
|
383
|
-
|
|
384
|
-
cmd = [
|
|
385
|
-
"gh", "api",
|
|
386
|
-
f"repos/{repo}/issues/{pr_number}/comments",
|
|
387
|
-
"--paginate",
|
|
388
|
-
"--jq", f'[.[] | select(.user.login == "{_GREPTILE_LOGIN}")] | last | .body // ""',
|
|
389
|
-
]
|
|
390
|
-
rc, out, err = _run_gh(cmd)
|
|
391
|
-
if rc != 0:
|
|
392
|
-
print(
|
|
393
|
-
f"Error: gh failed fetching comments for PR #{pr_number}: {err.strip()}",
|
|
394
|
-
file=sys.stderr,
|
|
395
|
-
)
|
|
396
|
-
return None
|
|
397
|
-
return out # may be empty string when no Greptile comment exists yet
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
# ---- Gate evaluation --------------------------------------------------------
|
|
401
|
-
|
|
402
|
-
# Layered-fallback discriminator values (#1368). Always emitted on every
|
|
403
|
-
# response so a long-running monitor can detect degraded mode without
|
|
404
|
-
# inspecting the failure list.
|
|
405
|
-
VIA_PRIMARY = "primary"
|
|
406
|
-
VIA_FALLBACK1 = "fallback1"
|
|
407
|
-
VIA_FALLBACK2 = "fallback2"
|
|
408
|
-
VIA_ERROR = "error"
|
|
409
|
-
|
|
410
|
-
# Sentinel failure prepended to every fallback2 verdict so a monitor that
|
|
411
|
-
# only inspects ``failures`` cannot accidentally treat the coarse signal as
|
|
412
|
-
# CLEAN. The merge cascade MUST keep waiting for a primary/fallback1 CLEAN.
|
|
413
|
-
_FALLBACK2_NOT_CLEAN_MSG = (
|
|
414
|
-
"fallback2 is a coarse signal, not a CLEAN verdict -- the Greptile "
|
|
415
|
-
"rolling-summary comment was not reachable on either the primary or "
|
|
416
|
-
"fallback1 path. PR state / check-runs reported below as a heartbeat "
|
|
417
|
-
"only; do NOT merge on this verdict alone (#1368)."
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
@dataclass
|
|
422
|
-
class GateResult:
|
|
423
|
-
"""Aggregate result of all merge-readiness gates.
|
|
424
|
-
|
|
425
|
-
The ``via`` discriminator (#1368) lets monitors detect which layer of
|
|
426
|
-
the fallback chain produced this result. ``partial_data`` carries
|
|
427
|
-
fallback-specific observations (PR state, check-run summary, raw error
|
|
428
|
-
messages from each attempted layer) so a monitor stepping forward on a
|
|
429
|
-
degraded response still has actionable context.
|
|
430
|
-
"""
|
|
431
|
-
pr_number: int
|
|
432
|
-
repo: str | None
|
|
433
|
-
head_sha: str | None
|
|
434
|
-
verdict: GreptileVerdict
|
|
435
|
-
failures: list[str] = field(default_factory=list)
|
|
436
|
-
via: str = VIA_PRIMARY
|
|
437
|
-
partial_data: dict = field(default_factory=dict)
|
|
438
|
-
error: str | None = None
|
|
439
|
-
|
|
440
|
-
@property
|
|
441
|
-
def merge_ready(self) -> bool:
|
|
442
|
-
# fallback2 + error paths carry sentinel failures so merge_ready is
|
|
443
|
-
# already False by construction; this property collapses to the
|
|
444
|
-
# documented "no failures" check.
|
|
445
|
-
return not self.failures
|
|
446
|
-
|
|
447
|
-
def to_dict(self) -> dict:
|
|
448
|
-
payload: dict = {
|
|
449
|
-
"pr_number": self.pr_number,
|
|
450
|
-
"repo": self.repo,
|
|
451
|
-
"head_sha": self.head_sha,
|
|
452
|
-
"verdict": asdict(self.verdict),
|
|
453
|
-
"failures": list(self.failures),
|
|
454
|
-
"merge_ready": self.merge_ready,
|
|
455
|
-
"via": self.via,
|
|
456
|
-
}
|
|
457
|
-
if self.partial_data:
|
|
458
|
-
payload["partial_data"] = dict(self.partial_data)
|
|
459
|
-
if self.error is not None:
|
|
460
|
-
payload["error"] = self.error
|
|
461
|
-
return payload
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
def evaluate_gates(pr_number: int, head_sha: str | None, verdict: GreptileVerdict) -> list[str]:
|
|
465
|
-
"""Return a list of failure messages (empty list == merge-ready)."""
|
|
466
|
-
failures: list[str] = []
|
|
467
|
-
|
|
468
|
-
if not verdict.found:
|
|
469
|
-
failures.append(
|
|
470
|
-
"No Greptile rolling-summary comment found on the PR. "
|
|
471
|
-
"Either Greptile has not posted yet, or the bot login filter is wrong. "
|
|
472
|
-
"Wait for the review to land before merging (see #796 late-bot-review re-check)."
|
|
473
|
-
)
|
|
474
|
-
return failures # remaining gates are meaningless without a body
|
|
475
|
-
|
|
476
|
-
if verdict.errored:
|
|
477
|
-
failures.append(
|
|
478
|
-
"Greptile review is in the ERRORED state on the current HEAD (#526). "
|
|
479
|
-
"Retry via @greptileai or escalate per "
|
|
480
|
-
"skills/deft-directive-swarm/SKILL.md Phase 6 Step 1."
|
|
481
|
-
)
|
|
482
|
-
|
|
483
|
-
if verdict.informal_clean:
|
|
484
|
-
failures.append(_INFORMAL_CLEAN_DIAGNOSTIC)
|
|
485
|
-
return failures
|
|
486
|
-
|
|
487
|
-
if verdict.last_reviewed_sha is None:
|
|
488
|
-
failures.append(
|
|
489
|
-
"Could not parse `Last reviewed commit:` from Greptile body. "
|
|
490
|
-
"The comment may be malformed or Greptile may still be writing it -- re-fetch."
|
|
491
|
-
)
|
|
492
|
-
elif head_sha and not (
|
|
493
|
-
head_sha.startswith(verdict.last_reviewed_sha)
|
|
494
|
-
or verdict.last_reviewed_sha.startswith(head_sha)
|
|
495
|
-
):
|
|
496
|
-
failures.append(
|
|
497
|
-
f"Greptile last reviewed {verdict.last_reviewed_sha} but PR HEAD is {head_sha}. "
|
|
498
|
-
"Review is stale -- wait for Greptile to re-review the latest commit."
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
if verdict.confidence is None:
|
|
502
|
-
failures.append(
|
|
503
|
-
"Could not parse `Confidence Score: X/5` from Greptile body. "
|
|
504
|
-
"Confidence is a required exit-condition input per "
|
|
505
|
-
"skills/deft-directive-review-cycle/SKILL.md Phase 2 Step 6."
|
|
506
|
-
)
|
|
507
|
-
elif verdict.confidence <= 3:
|
|
508
|
-
failures.append(
|
|
509
|
-
f"Greptile confidence is {verdict.confidence}/5; exit condition requires > 3. "
|
|
510
|
-
"Address remaining findings or push clarifying changes."
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
if verdict.p0_count > 0 or verdict.p1_count > 0:
|
|
514
|
-
failures.append(
|
|
515
|
-
f"Greptile reports {verdict.p0_count} P0 and {verdict.p1_count} P1 findings "
|
|
516
|
-
"on the current HEAD. All P0 / P1 findings MUST be addressed before merge "
|
|
517
|
-
"(P2 findings are non-blocking)."
|
|
518
|
-
)
|
|
519
|
-
|
|
520
|
-
return failures
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
# ---- CLI --------------------------------------------------------------------
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
527
|
-
parser = argparse.ArgumentParser(
|
|
528
|
-
prog="pr_merge_readiness",
|
|
529
|
-
description=(
|
|
530
|
-
"Pre-merge Greptile-body verdict gate. Exits non-zero if the PR's "
|
|
531
|
-
"Greptile rolling-summary comment fails any of: HEAD-SHA match, "
|
|
532
|
-
"errored sentinel, confidence > 3, no P0/P1 findings."
|
|
533
|
-
),
|
|
534
|
-
)
|
|
535
|
-
parser.add_argument("pr_number", type=int, help="Pull request number to check.")
|
|
536
|
-
parser.add_argument(
|
|
537
|
-
"--repo", default=None, metavar="OWNER/REPO",
|
|
538
|
-
help="Repository in OWNER/REPO form. Defaults to the current checkout's remote.",
|
|
539
|
-
)
|
|
540
|
-
parser.add_argument(
|
|
541
|
-
"--json", dest="emit_json", action="store_true",
|
|
542
|
-
help="Emit the gate result as a single JSON object on stdout (still respects exit code).",
|
|
543
|
-
)
|
|
544
|
-
return parser
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
# ---- Layered fallback chain (#1368) -----------------------------------------
|
|
548
|
-
#
|
|
549
|
-
# The primary path (existing #796 logic) calls ``gh api ... --jq ...`` to
|
|
550
|
-
# pull the Greptile rolling-summary comment body. When jq is invoked on
|
|
551
|
-
# the Grok Build harness and the gh stdout pipe carries non-cp1252 bytes,
|
|
552
|
-
# the helper-thread decode is now safe (#1366), but the jq filter itself
|
|
553
|
-
# can still emit empty output on a transient gh failure (rate-limit, 5xx,
|
|
554
|
-
# pagination boundary). Fallback1 routes around that by fetching the raw
|
|
555
|
-
# ``/issues/<N>/comments`` REST endpoint and parsing the comment list in
|
|
556
|
-
# Python so a jq glitch on the primary cannot blind the monitor.
|
|
557
|
-
#
|
|
558
|
-
# Fallback2 is the coarse last-resort signal: it asks for the PR's own
|
|
559
|
-
# state + check-runs via REST so we can at least report ``state``,
|
|
560
|
-
# ``head_sha``, and a flattened check summary even when no Greptile
|
|
561
|
-
# rolling-summary comment is reachable. It is NEVER CLEAN; the merge
|
|
562
|
-
# cascade MUST continue waiting on a primary/fallback1 verdict.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
def _empty_verdict() -> GreptileVerdict:
|
|
566
|
-
"""Return the canonical not-found Greptile verdict for fallback paths."""
|
|
567
|
-
return GreptileVerdict(
|
|
568
|
-
found=False,
|
|
569
|
-
errored=False,
|
|
570
|
-
last_reviewed_sha=None,
|
|
571
|
-
confidence=None,
|
|
572
|
-
p0_count=0,
|
|
573
|
-
p1_count=0,
|
|
574
|
-
p2_count=0,
|
|
575
|
-
)
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
def _resolve_repo(repo: str | None) -> tuple[str | None, str]:
|
|
579
|
-
"""Resolve --repo (or detect from cwd). Returns (repo, error_msg)."""
|
|
580
|
-
if repo:
|
|
581
|
-
return repo, ""
|
|
582
|
-
rc, out, err = _run_gh(
|
|
583
|
-
["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]
|
|
584
|
-
)
|
|
585
|
-
if rc != 0:
|
|
586
|
-
return None, f"could not resolve --repo from cwd: {err.strip()}"
|
|
587
|
-
resolved = out.strip()
|
|
588
|
-
if not resolved:
|
|
589
|
-
return None, "empty repo from gh repo view (specify --repo OWNER/REPO)"
|
|
590
|
-
return resolved, ""
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
def _compute_primary(
|
|
594
|
-
pr_number: int, repo: str | None,
|
|
595
|
-
) -> tuple[GateResult | None, dict]:
|
|
596
|
-
"""Run the primary path; return (result, partial_data_on_failure).
|
|
597
|
-
|
|
598
|
-
Returns (GateResult, {}) on success (gh calls all returned 0, body
|
|
599
|
-
parsed); returns (None, partial_data) when an external/gh failure
|
|
600
|
-
prevents the primary path from producing a verdict at all.
|
|
601
|
-
|
|
602
|
-
A merge-blocked verdict with a parsed body is still a successful
|
|
603
|
-
primary -- only external failures (head_sha unreachable, comment
|
|
604
|
-
fetch failed) demote to fallback1.
|
|
605
|
-
"""
|
|
606
|
-
partial: dict = {}
|
|
607
|
-
|
|
608
|
-
head_sha = fetch_pr_head_sha(pr_number, repo)
|
|
609
|
-
if head_sha is None:
|
|
610
|
-
partial["primary_error"] = "gh pr view headRefOid returned non-zero"
|
|
611
|
-
return None, partial
|
|
612
|
-
partial["head_sha"] = head_sha
|
|
613
|
-
|
|
614
|
-
body = fetch_greptile_comment_body(pr_number, repo)
|
|
615
|
-
if body is None:
|
|
616
|
-
partial["primary_error"] = (
|
|
617
|
-
"gh api /issues/<N>/comments --jq returned non-zero"
|
|
618
|
-
)
|
|
619
|
-
return None, partial
|
|
620
|
-
|
|
621
|
-
verdict = parse_greptile_body(body)
|
|
622
|
-
failures = evaluate_gates(pr_number, head_sha, verdict)
|
|
623
|
-
return (
|
|
624
|
-
GateResult(
|
|
625
|
-
pr_number=pr_number,
|
|
626
|
-
repo=repo,
|
|
627
|
-
head_sha=head_sha,
|
|
628
|
-
verdict=verdict,
|
|
629
|
-
failures=failures,
|
|
630
|
-
via=VIA_PRIMARY,
|
|
631
|
-
),
|
|
632
|
-
partial,
|
|
633
|
-
)
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
def _fetch_greptile_body_rest(
|
|
637
|
-
pr_number: int, repo: str,
|
|
638
|
-
) -> tuple[str | None, str]:
|
|
639
|
-
"""Fallback1 helper: fetch issue comments via REST, parse Python-side.
|
|
640
|
-
|
|
641
|
-
Unlike the primary, this does NOT invoke ``--jq``; a jq decode hiccup
|
|
642
|
-
on the primary cannot mask the comment list here. Returns (body, err)
|
|
643
|
-
where ``body == ""`` means "no Greptile comment exists yet" and
|
|
644
|
-
``body is None`` means an external/gh failure prevented retrieval.
|
|
645
|
-
"""
|
|
646
|
-
cmd = [
|
|
647
|
-
"gh", "api",
|
|
648
|
-
f"repos/{repo}/issues/{pr_number}/comments",
|
|
649
|
-
"--paginate",
|
|
650
|
-
]
|
|
651
|
-
rc, out, err = _run_gh(cmd)
|
|
652
|
-
if rc != 0:
|
|
653
|
-
return None, f"gh api /issues/{pr_number}/comments failed: {err.strip()}"
|
|
654
|
-
if not out.strip():
|
|
655
|
-
return "", ""
|
|
656
|
-
# ``gh api --paginate`` concatenates pages as separate JSON arrays
|
|
657
|
-
# back-to-back without delimiters. Parse forgivingly with raw_decode
|
|
658
|
-
# so a multi-page response collapses to one combined comment list.
|
|
659
|
-
decoder = json.JSONDecoder()
|
|
660
|
-
comments: list = []
|
|
661
|
-
idx = 0
|
|
662
|
-
text = out.strip()
|
|
663
|
-
while idx < len(text):
|
|
664
|
-
# Skip whitespace between concatenated arrays.
|
|
665
|
-
while idx < len(text) and text[idx].isspace():
|
|
666
|
-
idx += 1
|
|
667
|
-
if idx >= len(text):
|
|
668
|
-
break
|
|
669
|
-
try:
|
|
670
|
-
obj, end = decoder.raw_decode(text, idx)
|
|
671
|
-
except json.JSONDecodeError as exc:
|
|
672
|
-
return None, f"could not parse REST comments JSON: {exc}"
|
|
673
|
-
if isinstance(obj, list):
|
|
674
|
-
comments.extend(obj)
|
|
675
|
-
elif isinstance(obj, dict):
|
|
676
|
-
comments.append(obj)
|
|
677
|
-
idx = end
|
|
678
|
-
|
|
679
|
-
greptile_bodies = [
|
|
680
|
-
c.get("body", "")
|
|
681
|
-
for c in comments
|
|
682
|
-
if isinstance(c, dict)
|
|
683
|
-
and isinstance(c.get("user"), dict)
|
|
684
|
-
and c["user"].get("login") == _GREPTILE_LOGIN
|
|
685
|
-
]
|
|
686
|
-
if not greptile_bodies:
|
|
687
|
-
return "", ""
|
|
688
|
-
return greptile_bodies[-1] or "", ""
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
def _fetch_pr_head_sha_rest(
|
|
692
|
-
pr_number: int, repo: str,
|
|
693
|
-
) -> tuple[str | None, str]:
|
|
694
|
-
"""Fallback1/2 helper: fetch PR head SHA via REST (no jq)."""
|
|
695
|
-
rc, out, err = _run_gh(
|
|
696
|
-
["gh", "api", f"repos/{repo}/pulls/{pr_number}"],
|
|
697
|
-
)
|
|
698
|
-
if rc != 0:
|
|
699
|
-
return None, f"gh api /pulls/{pr_number} failed: {err.strip()}"
|
|
700
|
-
if not out.strip():
|
|
701
|
-
return None, "empty body from gh api /pulls/<N>"
|
|
702
|
-
try:
|
|
703
|
-
payload = json.loads(out)
|
|
704
|
-
except json.JSONDecodeError as exc:
|
|
705
|
-
return None, f"could not parse PR JSON: {exc}"
|
|
706
|
-
if not isinstance(payload, dict):
|
|
707
|
-
return None, "unexpected PR JSON shape (not a dict)"
|
|
708
|
-
head = payload.get("head")
|
|
709
|
-
if isinstance(head, dict):
|
|
710
|
-
sha = head.get("sha")
|
|
711
|
-
if isinstance(sha, str) and sha:
|
|
712
|
-
return sha, ""
|
|
713
|
-
return None, "PR JSON missing head.sha"
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
def _compute_fallback1(
|
|
717
|
-
pr_number: int, repo: str | None, primary_partial: dict,
|
|
718
|
-
) -> tuple[GateResult | None, dict]:
|
|
719
|
-
"""Fallback 1: gh api REST + Python-side comment parse (no --jq)."""
|
|
720
|
-
partial: dict = dict(primary_partial)
|
|
721
|
-
|
|
722
|
-
resolved_repo, repo_err = _resolve_repo(repo)
|
|
723
|
-
if resolved_repo is None:
|
|
724
|
-
partial["fallback1_error"] = repo_err
|
|
725
|
-
return None, partial
|
|
726
|
-
|
|
727
|
-
# Prefer the cached primary head SHA if we got one before the comment
|
|
728
|
-
# fetch failed; otherwise re-fetch via REST.
|
|
729
|
-
head_sha = partial.get("head_sha")
|
|
730
|
-
if not head_sha:
|
|
731
|
-
head_sha, head_err = _fetch_pr_head_sha_rest(pr_number, resolved_repo)
|
|
732
|
-
if head_sha is None:
|
|
733
|
-
partial["fallback1_error"] = head_err
|
|
734
|
-
return None, partial
|
|
735
|
-
partial["head_sha"] = head_sha
|
|
736
|
-
|
|
737
|
-
body, body_err = _fetch_greptile_body_rest(pr_number, resolved_repo)
|
|
738
|
-
if body is None:
|
|
739
|
-
partial["fallback1_error"] = body_err
|
|
740
|
-
return None, partial
|
|
741
|
-
|
|
742
|
-
verdict = parse_greptile_body(body)
|
|
743
|
-
failures = evaluate_gates(pr_number, head_sha, verdict)
|
|
744
|
-
return (
|
|
745
|
-
GateResult(
|
|
746
|
-
pr_number=pr_number,
|
|
747
|
-
repo=resolved_repo,
|
|
748
|
-
head_sha=head_sha,
|
|
749
|
-
verdict=verdict,
|
|
750
|
-
failures=failures,
|
|
751
|
-
via=VIA_FALLBACK1,
|
|
752
|
-
partial_data={
|
|
753
|
-
k: v for k, v in partial.items()
|
|
754
|
-
if k not in ("head_sha",) # head_sha is a first-class field
|
|
755
|
-
},
|
|
756
|
-
),
|
|
757
|
-
partial,
|
|
758
|
-
)
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
def _fetch_check_runs_rest(
|
|
762
|
-
sha: str, repo: str,
|
|
763
|
-
) -> tuple[dict | None, str]:
|
|
764
|
-
"""Fallback2 helper: flatten check-runs for the given commit."""
|
|
765
|
-
rc, out, err = _run_gh(
|
|
766
|
-
["gh", "api", f"repos/{repo}/commits/{sha}/check-runs"],
|
|
767
|
-
)
|
|
768
|
-
if rc != 0:
|
|
769
|
-
return None, f"gh api /commits/<sha>/check-runs failed: {err.strip()}"
|
|
770
|
-
if not out.strip():
|
|
771
|
-
return None, "empty body from gh api /commits/<sha>/check-runs"
|
|
772
|
-
try:
|
|
773
|
-
payload = json.loads(out)
|
|
774
|
-
except json.JSONDecodeError as exc:
|
|
775
|
-
return None, f"could not parse check-runs JSON: {exc}"
|
|
776
|
-
if not isinstance(payload, dict):
|
|
777
|
-
return None, "unexpected check-runs JSON shape (not a dict)"
|
|
778
|
-
runs = payload.get("check_runs")
|
|
779
|
-
if not isinstance(runs, list):
|
|
780
|
-
return None, "check-runs JSON missing check_runs list"
|
|
781
|
-
summary = {
|
|
782
|
-
"total": len(runs),
|
|
783
|
-
"by_status": {},
|
|
784
|
-
"by_conclusion": {},
|
|
785
|
-
"greptile_review": None,
|
|
786
|
-
}
|
|
787
|
-
for run in runs:
|
|
788
|
-
if not isinstance(run, dict):
|
|
789
|
-
continue
|
|
790
|
-
status = run.get("status") or "unknown"
|
|
791
|
-
conclusion = run.get("conclusion") or "none"
|
|
792
|
-
summary["by_status"][status] = summary["by_status"].get(status, 0) + 1
|
|
793
|
-
summary["by_conclusion"][conclusion] = (
|
|
794
|
-
summary["by_conclusion"].get(conclusion, 0) + 1
|
|
795
|
-
)
|
|
796
|
-
if run.get("name") == "Greptile Review":
|
|
797
|
-
summary["greptile_review"] = {
|
|
798
|
-
"status": status,
|
|
799
|
-
"conclusion": conclusion,
|
|
800
|
-
}
|
|
801
|
-
return summary, ""
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def _compute_fallback2(
|
|
805
|
-
pr_number: int, repo: str | None, prior_partial: dict,
|
|
806
|
-
) -> tuple[GateResult | None, dict]:
|
|
807
|
-
"""Fallback 2: coarse PR-view + check-run signal. NEVER CLEAN."""
|
|
808
|
-
partial: dict = dict(prior_partial)
|
|
809
|
-
|
|
810
|
-
resolved_repo, repo_err = _resolve_repo(repo)
|
|
811
|
-
if resolved_repo is None:
|
|
812
|
-
partial["fallback2_error"] = repo_err
|
|
813
|
-
return None, partial
|
|
814
|
-
|
|
815
|
-
# Hit /pulls/<N> directly so we capture state, mergeable, and head SHA
|
|
816
|
-
# in one REST call. This is the structural last-resort observation.
|
|
817
|
-
rc, out, err = _run_gh(
|
|
818
|
-
["gh", "api", f"repos/{resolved_repo}/pulls/{pr_number}"],
|
|
819
|
-
)
|
|
820
|
-
if rc != 0:
|
|
821
|
-
partial["fallback2_error"] = (
|
|
822
|
-
f"gh api /pulls/{pr_number} failed: {err.strip()}"
|
|
823
|
-
)
|
|
824
|
-
return None, partial
|
|
825
|
-
|
|
826
|
-
try:
|
|
827
|
-
pr_payload = json.loads(out) if out.strip() else None
|
|
828
|
-
except json.JSONDecodeError as exc:
|
|
829
|
-
partial["fallback2_error"] = f"could not parse PR JSON: {exc}"
|
|
830
|
-
return None, partial
|
|
831
|
-
|
|
832
|
-
if not isinstance(pr_payload, dict):
|
|
833
|
-
partial["fallback2_error"] = "unexpected PR JSON shape (not a dict)"
|
|
834
|
-
return None, partial
|
|
835
|
-
|
|
836
|
-
state = pr_payload.get("state")
|
|
837
|
-
merged = bool(pr_payload.get("merged"))
|
|
838
|
-
mergeable = pr_payload.get("mergeable")
|
|
839
|
-
mergeable_state = pr_payload.get("mergeable_state")
|
|
840
|
-
head_block = pr_payload.get("head")
|
|
841
|
-
head_sha = None
|
|
842
|
-
if isinstance(head_block, dict):
|
|
843
|
-
candidate = head_block.get("sha")
|
|
844
|
-
if isinstance(candidate, str) and candidate:
|
|
845
|
-
head_sha = candidate
|
|
846
|
-
if head_sha is None and partial.get("head_sha"):
|
|
847
|
-
head_sha = partial["head_sha"]
|
|
848
|
-
|
|
849
|
-
# Check-runs are best-effort -- a missing endpoint must not down-rank
|
|
850
|
-
# this layer to error, because the PR state/headSHA alone is still a
|
|
851
|
-
# useful heartbeat for the monitor.
|
|
852
|
-
check_summary: dict | None = None
|
|
853
|
-
if head_sha:
|
|
854
|
-
check_summary, check_err = _fetch_check_runs_rest(head_sha, resolved_repo)
|
|
855
|
-
if check_summary is None and check_err:
|
|
856
|
-
partial["fallback2_check_runs_error"] = check_err
|
|
857
|
-
|
|
858
|
-
fallback_partial = {
|
|
859
|
-
"pr_state": state,
|
|
860
|
-
"merged": merged,
|
|
861
|
-
"mergeable": mergeable,
|
|
862
|
-
"mergeable_state": mergeable_state,
|
|
863
|
-
"check_runs": check_summary,
|
|
864
|
-
}
|
|
865
|
-
# Carry forward the earlier layer error context so a monitor inspecting
|
|
866
|
-
# the response sees both "why did we degrade?" and "what did the coarse
|
|
867
|
-
# layer see?" in one envelope.
|
|
868
|
-
for key in (
|
|
869
|
-
"primary_error",
|
|
870
|
-
"fallback1_error",
|
|
871
|
-
"fallback2_check_runs_error",
|
|
872
|
-
):
|
|
873
|
-
if key in partial:
|
|
874
|
-
fallback_partial[key] = partial[key]
|
|
875
|
-
|
|
876
|
-
failures = [_FALLBACK2_NOT_CLEAN_MSG]
|
|
877
|
-
return (
|
|
878
|
-
GateResult(
|
|
879
|
-
pr_number=pr_number,
|
|
880
|
-
repo=resolved_repo,
|
|
881
|
-
head_sha=head_sha,
|
|
882
|
-
verdict=_empty_verdict(),
|
|
883
|
-
failures=failures,
|
|
884
|
-
via=VIA_FALLBACK2,
|
|
885
|
-
partial_data=fallback_partial,
|
|
886
|
-
),
|
|
887
|
-
partial,
|
|
888
|
-
)
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
def _error_result(
|
|
892
|
-
pr_number: int, repo: str | None, partial: dict,
|
|
893
|
-
) -> GateResult:
|
|
894
|
-
"""Build the structured-error envelope when every layer failed."""
|
|
895
|
-
# Compose a one-line error string from whichever layer-level errors
|
|
896
|
-
# accumulated through the cascade.
|
|
897
|
-
pieces = []
|
|
898
|
-
for key in ("primary_error", "fallback1_error", "fallback2_error"):
|
|
899
|
-
if key in partial:
|
|
900
|
-
pieces.append(f"{key}={partial[key]}")
|
|
901
|
-
error = (
|
|
902
|
-
"; ".join(pieces)
|
|
903
|
-
if pieces
|
|
904
|
-
else "every fallback layer failed without a reportable error"
|
|
905
|
-
)
|
|
906
|
-
return GateResult(
|
|
907
|
-
pr_number=pr_number,
|
|
908
|
-
repo=repo,
|
|
909
|
-
head_sha=partial.get("head_sha"),
|
|
910
|
-
verdict=_empty_verdict(),
|
|
911
|
-
failures=[
|
|
912
|
-
"pr_merge_readiness external error -- every fallback layer "
|
|
913
|
-
"failed; see partial_data for diagnostic detail (#1368)."
|
|
914
|
-
],
|
|
915
|
-
via=VIA_ERROR,
|
|
916
|
-
partial_data=dict(partial),
|
|
917
|
-
error=error,
|
|
918
|
-
)
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
def compute_gate_result(pr_number: int, repo: str | None) -> GateResult:
|
|
922
|
-
"""Run the primary->fallback1->fallback2 cascade and return a result.
|
|
923
|
-
|
|
924
|
-
The result ALWAYS carries a ``via`` discriminator. ``via="error"``
|
|
925
|
-
means every layer failed; the monitor MUST treat that as merge-blocked
|
|
926
|
-
rather than CLEAN, but the response still carries ``partial_data`` so
|
|
927
|
-
the monitor can step forward without going blind.
|
|
928
|
-
"""
|
|
929
|
-
result, partial = _compute_primary(pr_number, repo)
|
|
930
|
-
if result is not None:
|
|
931
|
-
return result
|
|
932
|
-
|
|
933
|
-
result, partial = _compute_fallback1(pr_number, repo, partial)
|
|
934
|
-
if result is not None:
|
|
935
|
-
return result
|
|
936
|
-
|
|
937
|
-
result, partial = _compute_fallback2(pr_number, repo, partial)
|
|
938
|
-
if result is not None:
|
|
939
|
-
return result
|
|
940
|
-
|
|
941
|
-
return _error_result(pr_number, repo, partial)
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
def _print_human(result: GateResult) -> None:
|
|
945
|
-
"""Print the merge-readiness check result in human-readable form."""
|
|
946
|
-
print(f"PR #{result.pr_number} merge-readiness check (via={result.via})")
|
|
947
|
-
print(f" HEAD SHA: {result.head_sha or '<unknown>'}")
|
|
948
|
-
print(
|
|
949
|
-
f" Greptile reviewed: "
|
|
950
|
-
f"{result.verdict.last_reviewed_sha or '<not parsed>'}"
|
|
951
|
-
)
|
|
952
|
-
confidence_str = (
|
|
953
|
-
str(result.verdict.confidence)
|
|
954
|
-
if result.verdict.confidence is not None
|
|
955
|
-
else "<not parsed>"
|
|
956
|
-
)
|
|
957
|
-
print(f" Confidence: {confidence_str}/5")
|
|
958
|
-
print(
|
|
959
|
-
f" Findings: P0={result.verdict.p0_count} "
|
|
960
|
-
f"P1={result.verdict.p1_count} P2={result.verdict.p2_count}"
|
|
961
|
-
)
|
|
962
|
-
print(f" Errored sentinel: {result.verdict.errored}")
|
|
963
|
-
if result.via == VIA_FALLBACK2 and result.partial_data:
|
|
964
|
-
print(" Fallback2 signal:")
|
|
965
|
-
for key in ("pr_state", "merged", "mergeable", "mergeable_state"):
|
|
966
|
-
if key in result.partial_data:
|
|
967
|
-
print(f" {key}: {result.partial_data[key]}")
|
|
968
|
-
check_runs = result.partial_data.get("check_runs")
|
|
969
|
-
if isinstance(check_runs, dict):
|
|
970
|
-
greptile = check_runs.get("greptile_review")
|
|
971
|
-
if greptile:
|
|
972
|
-
print(f" Greptile Review check: {greptile}")
|
|
973
|
-
if result.merge_ready:
|
|
974
|
-
print("\nResult: MERGE-READY")
|
|
975
|
-
else:
|
|
976
|
-
label = "MERGE-BLOCKED" if result.via != VIA_ERROR else "EXTERNAL-ERROR"
|
|
977
|
-
print(f"\nResult: {label}")
|
|
978
|
-
for i, fail in enumerate(result.failures, 1):
|
|
979
|
-
print(f" [{i}] {fail}")
|
|
980
|
-
if result.error:
|
|
981
|
-
print(f"\nUnderlying error: {result.error}")
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
def _exit_code_for(result: GateResult) -> int:
|
|
985
|
-
if result.via == VIA_ERROR:
|
|
986
|
-
return EXIT_EXTERNAL_ERROR
|
|
987
|
-
return EXIT_OK if result.merge_ready else EXIT_MERGE_BLOCKED
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
def main(argv: list[str] | None = None) -> int:
|
|
991
|
-
args = _build_parser().parse_args(argv)
|
|
992
|
-
|
|
993
|
-
result = compute_gate_result(args.pr_number, args.repo)
|
|
994
|
-
|
|
995
|
-
if args.emit_json:
|
|
996
|
-
print(json.dumps(result.to_dict(), indent=2))
|
|
997
|
-
else:
|
|
998
|
-
_print_human(result)
|
|
999
|
-
|
|
1000
|
-
return _exit_code_for(result)
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
if __name__ == "__main__":
|
|
1004
|
-
sys.exit(main())
|