@deftai/directive-content 0.55.2 → 0.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,1004 @@
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())