@momentiq/dark-factory-cli 0.1.0-alpha.4
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/LICENSE +201 -0
- package/README.md +150 -0
- package/dist/adapters/_shared.d.ts +45 -0
- package/dist/adapters/_shared.d.ts.map +1 -0
- package/dist/adapters/_shared.js +200 -0
- package/dist/adapters/_shared.js.map +1 -0
- package/dist/adapters/codex-sdk.d.ts +279 -0
- package/dist/adapters/codex-sdk.d.ts.map +1 -0
- package/dist/adapters/codex-sdk.js +930 -0
- package/dist/adapters/codex-sdk.js.map +1 -0
- package/dist/adapters/critic-result-schema.d.ts +215 -0
- package/dist/adapters/critic-result-schema.d.ts.map +1 -0
- package/dist/adapters/critic-result-schema.js +153 -0
- package/dist/adapters/critic-result-schema.js.map +1 -0
- package/dist/adapters/critic.d.ts +62 -0
- package/dist/adapters/critic.d.ts.map +1 -0
- package/dist/adapters/critic.js +79 -0
- package/dist/adapters/critic.js.map +1 -0
- package/dist/adapters/cursor-sdk.d.ts +153 -0
- package/dist/adapters/cursor-sdk.d.ts.map +1 -0
- package/dist/adapters/cursor-sdk.js +818 -0
- package/dist/adapters/cursor-sdk.js.map +1 -0
- package/dist/adapters/gemini-sdk.d.ts +113 -0
- package/dist/adapters/gemini-sdk.d.ts.map +1 -0
- package/dist/adapters/gemini-sdk.js +532 -0
- package/dist/adapters/gemini-sdk.js.map +1 -0
- package/dist/adapters/grok-direct-sdk.d.ts +148 -0
- package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
- package/dist/adapters/grok-direct-sdk.js +694 -0
- package/dist/adapters/grok-direct-sdk.js.map +1 -0
- package/dist/branch-protection/audit_branch_protection.py +759 -0
- package/dist/branch-protection/index.d.ts +25 -0
- package/dist/branch-protection/index.d.ts.map +1 -0
- package/dist/branch-protection/index.js +70 -0
- package/dist/branch-protection/index.js.map +1 -0
- package/dist/branch-protection/spec-default.yaml +314 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +581 -0
- package/dist/cli.js.map +1 -0
- package/dist/cycle-doc-validator/index.d.ts +43 -0
- package/dist/cycle-doc-validator/index.d.ts.map +1 -0
- package/dist/cycle-doc-validator/index.js +69 -0
- package/dist/cycle-doc-validator/index.js.map +1 -0
- package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
- package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
- package/dist/cycle-tracker-sync/index.d.ts +20 -0
- package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
- package/dist/cycle-tracker-sync/index.js +71 -0
- package/dist/cycle-tracker-sync/index.js.map +1 -0
- package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
- package/dist/evidence/audit-trail.d.ts +59 -0
- package/dist/evidence/audit-trail.d.ts.map +1 -0
- package/dist/evidence/audit-trail.js +283 -0
- package/dist/evidence/audit-trail.js.map +1 -0
- package/dist/evidence/index.d.ts +4 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +29 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/per-sha.d.ts +13 -0
- package/dist/evidence/per-sha.d.ts.map +1 -0
- package/dist/evidence/per-sha.js +97 -0
- package/dist/evidence/per-sha.js.map +1 -0
- package/dist/evidence/quality-gates.d.ts +19 -0
- package/dist/evidence/quality-gates.d.ts.map +1 -0
- package/dist/evidence/quality-gates.js +212 -0
- package/dist/evidence/quality-gates.js.map +1 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +414 -0
- package/dist/git.js.map +1 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +99 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +42 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy/baseline.d.ts +15 -0
- package/dist/policy/baseline.d.ts.map +1 -0
- package/dist/policy/baseline.js +115 -0
- package/dist/policy/baseline.js.map +1 -0
- package/dist/policy/config.d.ts +64 -0
- package/dist/policy/config.d.ts.map +1 -0
- package/dist/policy/config.js +363 -0
- package/dist/policy/config.js.map +1 -0
- package/dist/policy/gate.d.ts +80 -0
- package/dist/policy/gate.d.ts.map +1 -0
- package/dist/policy/gate.js +1019 -0
- package/dist/policy/gate.js.map +1 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +14 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/merge-queue.d.ts +183 -0
- package/dist/policy/merge-queue.d.ts.map +1 -0
- package/dist/policy/merge-queue.js +310 -0
- package/dist/policy/merge-queue.js.map +1 -0
- package/dist/policy/profile.d.ts +98 -0
- package/dist/policy/profile.d.ts.map +1 -0
- package/dist/policy/profile.js +156 -0
- package/dist/policy/profile.js.map +1 -0
- package/dist/policy/tdd-classifier.d.ts +18 -0
- package/dist/policy/tdd-classifier.d.ts.map +1 -0
- package/dist/policy/tdd-classifier.js +79 -0
- package/dist/policy/tdd-classifier.js.map +1 -0
- package/dist/prompt.d.ts +13 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +175 -0
- package/dist/prompt.js.map +1 -0
- package/dist/report.d.ts +88 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +376 -0
- package/dist/report.js.map +1 -0
- package/dist/runner.d.ts +30 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +456 -0
- package/dist/runner.js.map +1 -0
- package/dist/security.d.ts +2 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +19 -0
- package/dist/security.js.map +1 -0
- package/dist/trusted-surface/rebind.d.ts +10 -0
- package/dist/trusted-surface/rebind.d.ts.map +1 -0
- package/dist/trusted-surface/rebind.js +154 -0
- package/dist/trusted-surface/rebind.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1260 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cycle 318.4 Component 2 — cycle-doc CI gate.
|
|
3
|
+
|
|
4
|
+
Enforces AI-Native Manifesto §10 (Spec-Driven Traceability) per the
|
|
5
|
+
trailer policy documented in ``CONTRIBUTING.md``: every PR has some
|
|
6
|
+
anchor in the project's tracking surface, with two paths:
|
|
7
|
+
|
|
8
|
+
* **Cycle-tracked product code** — ``Cycle: <N>`` references a cycle
|
|
9
|
+
doc in ``docs/roadmap/cycles/`` (the cycle must exist and not be
|
|
10
|
+
in a terminal status); ``Issue:`` OR ``ProjectItem:`` is also
|
|
11
|
+
required as a secondary anchor.
|
|
12
|
+
* **Non-cycle PRs** (drift fixes, dependabot bumps, doc tweaks,
|
|
13
|
+
hotfixes) — no ``Cycle:`` required; ``Issue:`` OR ``ProjectItem:``
|
|
14
|
+
alone is sufficient.
|
|
15
|
+
* **Bot/automation PRs** carrying an allowlisted label
|
|
16
|
+
(``dependencies`` / ``automated`` / ``autorelease: pending`` /
|
|
17
|
+
``autorelease: tagged``) bypass trailer enforcement entirely.
|
|
18
|
+
|
|
19
|
+
Without this gate, the existing PR-body substring check in
|
|
20
|
+
``.github/workflows/quality-gate.yml`` only catches that the strings
|
|
21
|
+
"cycle doc", "definition of done", etc. appear — it does not parse
|
|
22
|
+
trailers, does not verify the referenced cycle exists, and does not
|
|
23
|
+
distinguish plan PRs from code PRs.
|
|
24
|
+
|
|
25
|
+
Inputs (all environment variables; the workflow sets them):
|
|
26
|
+
|
|
27
|
+
PR_NUMBER — PR number being validated.
|
|
28
|
+
PR_TITLE — full PR title.
|
|
29
|
+
PR_BODY — full PR body (the description shown on GitHub).
|
|
30
|
+
PR_LABELS — JSON array of label objects, ``[{"name":"plan-pr"},...]``.
|
|
31
|
+
REPO — ``owner/name``.
|
|
32
|
+
GH_TOKEN — token with ``repo:read`` for the changed-files lookup.
|
|
33
|
+
|
|
34
|
+
Outputs:
|
|
35
|
+
|
|
36
|
+
exit 0 on success; exit 1 with an actionable stderr message on failure.
|
|
37
|
+
|
|
38
|
+
The check matrix (per Component 2 of cycle318.4-ci-fallback-and-auto-merge.md,
|
|
39
|
+
aligned with CONTRIBUTING.md "Trailers" section):
|
|
40
|
+
|
|
41
|
+
1. **Type detection.** Plan PR if title starts with ``docs(roadmap):``
|
|
42
|
+
or ``docs(<cycle>):``, OR PR has label ``plan-pr``, AND every
|
|
43
|
+
changed path is under ``docs/`` (or is the repo-root ``AGENTS.md``
|
|
44
|
+
/ ``CLAUDE.md``). The "docs only" check is broader than just
|
|
45
|
+
cycle-doc paths so a plan PR can include supporting ADR / engineering
|
|
46
|
+
docs in the same change without being misclassified as a code PR.
|
|
47
|
+
Otherwise code PR.
|
|
48
|
+
|
|
49
|
+
2. **Bot/automation exemption.** PRs with any of the labels
|
|
50
|
+
``dependencies`` / ``automated`` / ``autorelease: pending`` /
|
|
51
|
+
``autorelease: tagged`` short-circuit to PASS without trailer
|
|
52
|
+
checks. Matches CONTRIBUTING.md "Bot / automation PRs are exempted
|
|
53
|
+
by label allowlist".
|
|
54
|
+
|
|
55
|
+
3. **Trailer parsing.** Read PR body AND every commit message in the
|
|
56
|
+
PR; extract ``Cycle:``, ``Issue:``, ``ProjectItem:`` trailer values
|
|
57
|
+
per ``git-interpret-trailers`` semantics. Commit messages are
|
|
58
|
+
fetched via ``gh api repos/<repo>/pulls/<n>/commits`` and
|
|
59
|
+
concatenated to the body before parsing. GitHub auto-close keywords
|
|
60
|
+
(``Closes #N``, ``Fixes #N``, ``Resolves #N``) count as an
|
|
61
|
+
``Issue:`` trailer for the purpose of this gate.
|
|
62
|
+
|
|
63
|
+
4. **Code PR with Cycle: (product code).** When ``Cycle: <N>`` is
|
|
64
|
+
present, the cycle must resolve to
|
|
65
|
+
``docs/roadmap/cycles/cycle<N>*.md`` and status must not be
|
|
66
|
+
terminal (completed/complete/superseded/abandoned/absorbed). An
|
|
67
|
+
``Issue:`` OR ``ProjectItem:`` MUST also be present (one is
|
|
68
|
+
sufficient).
|
|
69
|
+
|
|
70
|
+
5. **Code PR without Cycle: (drift / hotfix / non-cycle).** When
|
|
71
|
+
no ``Cycle:`` is present, ``Issue:`` OR ``ProjectItem:`` MUST be
|
|
72
|
+
present. This is the non-cycle-PR path documented in
|
|
73
|
+
CONTRIBUTING.md (drift fixes, dependabot bumps, doc tweaks,
|
|
74
|
+
hotfixes).
|
|
75
|
+
|
|
76
|
+
6. **Plan PR rules.** ``Cycle: <N>`` MUST be present AND the diff
|
|
77
|
+
must include ``docs/roadmap/cycles/cycle<N>*.md`` (i.e., the PR
|
|
78
|
+
is creating or updating that cycle's plan).
|
|
79
|
+
|
|
80
|
+
7. **Status transitions.** If the same diff sets ``status: completed``
|
|
81
|
+
on a cycle's frontmatter, fail with a clear message — completion
|
|
82
|
+
belongs in a separate doc-update PR after the implementing code
|
|
83
|
+
PR has merged.
|
|
84
|
+
|
|
85
|
+
Failure messages name the file and the missing/conflicting field so
|
|
86
|
+
operators can fix without reading this script.
|
|
87
|
+
"""
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
import argparse
|
|
91
|
+
import base64
|
|
92
|
+
import json
|
|
93
|
+
import os
|
|
94
|
+
import re
|
|
95
|
+
import subprocess
|
|
96
|
+
import sys
|
|
97
|
+
from dataclasses import dataclass, field
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
from typing import Iterable
|
|
100
|
+
|
|
101
|
+
def _resolve_repo_root() -> Path:
|
|
102
|
+
"""Resolve the consumer repo root.
|
|
103
|
+
|
|
104
|
+
Phase C extraction (cycle 331.1): the same script ships inside
|
|
105
|
+
``@momentiq/dark-factory-cli`` and is invoked from arbitrary cwds
|
|
106
|
+
in consumer repos. Resolution order:
|
|
107
|
+
|
|
108
|
+
1. ``DF_REPO_ROOT`` env var — explicit override (the TS CLI wrapper
|
|
109
|
+
sets this when spawning).
|
|
110
|
+
2. ``git rev-parse --show-toplevel`` from the current cwd — the
|
|
111
|
+
standard "find the consumer repo" path for any in-repo invocation.
|
|
112
|
+
3. ``Path(__file__).resolve().parents[2]`` — legacy fallback that
|
|
113
|
+
matched sage3c's ``scripts/ci/`` layout. Kept so the original
|
|
114
|
+
pytest contract still works when the script is invoked in-tree.
|
|
115
|
+
"""
|
|
116
|
+
override = os.environ.get("DF_REPO_ROOT")
|
|
117
|
+
if override:
|
|
118
|
+
return Path(override).resolve()
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
122
|
+
check=True,
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
)
|
|
126
|
+
return Path(result.stdout.strip()).resolve()
|
|
127
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
128
|
+
return Path(__file__).resolve().parents[2]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
REPO_ROOT = _resolve_repo_root()
|
|
132
|
+
CYCLES_DIR = REPO_ROOT / "docs" / "roadmap" / "cycles"
|
|
133
|
+
CYCLE_DOC_GLOB = "docs/roadmap/cycles/cycle*.md"
|
|
134
|
+
|
|
135
|
+
# Trailers we recognize. ``git-interpret-trailers`` defines a trailer as
|
|
136
|
+
# ``Token: Value`` on its own line in the trailer block; we accept the
|
|
137
|
+
# same shape anywhere in the body text, since GitHub PR bodies don't have
|
|
138
|
+
# a strict trailer block.
|
|
139
|
+
TRAILER_RE = re.compile(r"^\s*(?P<key>[A-Za-z][A-Za-z0-9-]*):\s*(?P<value>.+?)\s*$", re.MULTILINE)
|
|
140
|
+
|
|
141
|
+
# GitHub auto-close keywords — treat as equivalent to ``Issue:`` trailers.
|
|
142
|
+
# Matches "Closes #123", "fixes momentiq-ai/sage3c#42", "Resolves #5", etc.
|
|
143
|
+
GH_AUTOCLOSE_RE = re.compile(
|
|
144
|
+
r"\b(?P<verb>close[sd]?|fix(?:es|ed)?|resolve[sd]?)\s+"
|
|
145
|
+
r"(?:(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+))?#(?P<num>\d+)\b",
|
|
146
|
+
re.IGNORECASE,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Plan-PR title patterns: ``docs(roadmap): ...`` or ``docs(<cycle>): ...``
|
|
150
|
+
# where <cycle> looks like a cycle number (118, 318.4, etc.).
|
|
151
|
+
PLAN_TITLE_RE = re.compile(
|
|
152
|
+
r"^docs\((?:roadmap|\d+(?:\.\d+)*)\):",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Bot/automation label allowlist per CONTRIBUTING.md "Trailers" section.
|
|
156
|
+
# PRs carrying any of these labels skip the trailer requirement.
|
|
157
|
+
BOT_EXEMPT_LABELS = frozenset(
|
|
158
|
+
{
|
|
159
|
+
"dependencies",
|
|
160
|
+
"automated",
|
|
161
|
+
"autorelease: pending",
|
|
162
|
+
"autorelease: tagged",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class Trailers:
|
|
169
|
+
"""Parsed trailer set with normalized presence flags."""
|
|
170
|
+
|
|
171
|
+
cycle: str | None = None
|
|
172
|
+
issue: str | None = None
|
|
173
|
+
project_item: str | None = None
|
|
174
|
+
raw: dict[str, list[str]] = field(default_factory=dict)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class CycleDoc:
|
|
179
|
+
path: Path
|
|
180
|
+
status: str | None
|
|
181
|
+
superseded_by: str | None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_trailers(text: str) -> Trailers:
|
|
185
|
+
"""Extract recognized trailers from a body of text.
|
|
186
|
+
|
|
187
|
+
Multiple values per key are preserved in ``raw`` so a downstream
|
|
188
|
+
consumer can debug accidentally-doubled trailers. The convenience
|
|
189
|
+
fields ``cycle`` / ``issue`` / ``project_item`` carry the LAST seen
|
|
190
|
+
value of each (the trailer-block tail is the canonical position).
|
|
191
|
+
"""
|
|
192
|
+
out = Trailers()
|
|
193
|
+
for match in TRAILER_RE.finditer(text):
|
|
194
|
+
key = match.group("key").lower()
|
|
195
|
+
value = match.group("value").strip()
|
|
196
|
+
out.raw.setdefault(key, []).append(value)
|
|
197
|
+
if key == "cycle":
|
|
198
|
+
out.cycle = value
|
|
199
|
+
elif key == "issue":
|
|
200
|
+
out.issue = value
|
|
201
|
+
elif key == "projectitem":
|
|
202
|
+
out.project_item = value
|
|
203
|
+
|
|
204
|
+
if out.issue is None:
|
|
205
|
+
# GitHub auto-close keywords substitute for an explicit Issue: trailer.
|
|
206
|
+
# We only need ONE match — the gate doesn't care which issue is linked.
|
|
207
|
+
autoclose = GH_AUTOCLOSE_RE.search(text)
|
|
208
|
+
if autoclose:
|
|
209
|
+
out.issue = f"#{autoclose.group('num')}"
|
|
210
|
+
out.raw.setdefault("_autoclose", []).append(autoclose.group(0))
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def normalize_cycle_id(raw: str) -> str | None:
|
|
215
|
+
"""Pull the cycle id out of a trailer value.
|
|
216
|
+
|
|
217
|
+
Accepts ``318.4``, ``318``, ``Cycle 318.4``, ``318.4-foo``. Rejects
|
|
218
|
+
obviously malformed input. The id is a dotted-decimal sequence of
|
|
219
|
+
1-4 segments (``318``, ``318.4``, ``313.1.e`` would NOT match — we
|
|
220
|
+
accept only digits + dots for the gate's normalized form, which is
|
|
221
|
+
what ``sync_cycle_trackers.py`` already standardizes on).
|
|
222
|
+
"""
|
|
223
|
+
if not raw:
|
|
224
|
+
return None
|
|
225
|
+
# Strip any leading word like "Cycle " or surrounding markdown.
|
|
226
|
+
candidate = raw.strip()
|
|
227
|
+
# Take just the leading cycle-id token; drop any trailing text after
|
|
228
|
+
# the first whitespace or punctuation that isn't ``.`` or a digit.
|
|
229
|
+
m = re.match(r"(\d+(?:\.\d+)*)", candidate)
|
|
230
|
+
if not m:
|
|
231
|
+
return None
|
|
232
|
+
return m.group(1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def find_cycle_doc(cycle_id: str) -> CycleDoc | None:
|
|
236
|
+
"""Locate ``docs/roadmap/cycles/cycle<id>*.md`` and read its status."""
|
|
237
|
+
matches = sorted(CYCLES_DIR.glob(f"cycle{cycle_id}-*.md")) + sorted(
|
|
238
|
+
CYCLES_DIR.glob(f"cycle{cycle_id}.md")
|
|
239
|
+
)
|
|
240
|
+
if not matches:
|
|
241
|
+
return None
|
|
242
|
+
# If multiple matches exist (legacy ``cycle318.md`` + new ``cycle318-foo.md``),
|
|
243
|
+
# prefer the one with the longer name (more descriptive slug).
|
|
244
|
+
path = max(matches, key=lambda p: len(p.name))
|
|
245
|
+
status, superseded_by = read_cycle_frontmatter(path)
|
|
246
|
+
return CycleDoc(path=path, status=status, superseded_by=superseded_by)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def read_cycle_frontmatter(path: Path) -> tuple[str | None, str | None]:
|
|
250
|
+
"""Read the ``status:`` and ``superseded_by:`` fields from frontmatter.
|
|
251
|
+
|
|
252
|
+
Frontmatter parsing is deliberately simple — we don't pull PyYAML
|
|
253
|
+
for this since the validator runs in CI on every PR and we want a
|
|
254
|
+
fast, dependency-light boot. Frontmatter is the first ``---`` block
|
|
255
|
+
at the top of the file; we look for ``status:`` and
|
|
256
|
+
``superseded_by:`` lines until the closing ``---``.
|
|
257
|
+
"""
|
|
258
|
+
status: str | None = None
|
|
259
|
+
superseded_by: str | None = None
|
|
260
|
+
try:
|
|
261
|
+
with path.open(encoding="utf-8") as fh:
|
|
262
|
+
inside = False
|
|
263
|
+
for line in fh:
|
|
264
|
+
if line.strip() == "---":
|
|
265
|
+
if not inside:
|
|
266
|
+
inside = True
|
|
267
|
+
continue
|
|
268
|
+
break # closing fence
|
|
269
|
+
if not inside:
|
|
270
|
+
continue
|
|
271
|
+
stripped = line.strip()
|
|
272
|
+
if stripped.startswith("status:"):
|
|
273
|
+
status = stripped[len("status:") :].strip().strip("\"'")
|
|
274
|
+
elif stripped.startswith("superseded_by:"):
|
|
275
|
+
superseded_by = stripped[len("superseded_by:") :].strip().strip("\"'")
|
|
276
|
+
except OSError:
|
|
277
|
+
return None, None
|
|
278
|
+
return status, superseded_by
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def read_cycle_frontmatter_from_text(text: str) -> tuple[str | None, str | None]:
|
|
282
|
+
"""Read the ``status:`` and ``superseded_by:`` fields from frontmatter text."""
|
|
283
|
+
status: str | None = None
|
|
284
|
+
superseded_by: str | None = None
|
|
285
|
+
inside = False
|
|
286
|
+
for line in text.splitlines():
|
|
287
|
+
if line.strip() == "---":
|
|
288
|
+
if not inside:
|
|
289
|
+
inside = True
|
|
290
|
+
continue
|
|
291
|
+
break
|
|
292
|
+
if not inside:
|
|
293
|
+
continue
|
|
294
|
+
stripped = line.strip()
|
|
295
|
+
if stripped.startswith("status:"):
|
|
296
|
+
status = stripped[len("status:") :].strip().strip("\"'")
|
|
297
|
+
elif stripped.startswith("superseded_by:"):
|
|
298
|
+
superseded_by = stripped[len("superseded_by:") :].strip().strip("\"'")
|
|
299
|
+
return status, superseded_by
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class BaseCycleDocFetchError(RuntimeError):
|
|
303
|
+
"""Raised when the base-ref cycle document cannot be read."""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _is_gh_not_found_error(exc: subprocess.CalledProcessError) -> bool:
|
|
307
|
+
"""Return True only for confirmed missing-content responses from gh."""
|
|
308
|
+
|
|
309
|
+
output = f"{exc.stderr or ''}\n{exc.stdout or ''}".lower()
|
|
310
|
+
return "404" in output or "not found" in output
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def base_cycle_doc(
|
|
314
|
+
repo: str,
|
|
315
|
+
cycle_id: str,
|
|
316
|
+
base_ref: str,
|
|
317
|
+
gh_token: str | None,
|
|
318
|
+
) -> CycleDoc | None:
|
|
319
|
+
"""Read the cited cycle doc from the protected base ref.
|
|
320
|
+
|
|
321
|
+
CI checks out the PR head, so a PR can edit the cited doc before
|
|
322
|
+
this validator reads it from disk. Use the GitHub contents API
|
|
323
|
+
against the immutable base SHA to catch cycles that were already
|
|
324
|
+
terminal before the PR.
|
|
325
|
+
"""
|
|
326
|
+
env = os.environ.copy()
|
|
327
|
+
if gh_token:
|
|
328
|
+
env["GH_TOKEN"] = gh_token
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
listing_result = subprocess.run(
|
|
332
|
+
[
|
|
333
|
+
"gh",
|
|
334
|
+
"api",
|
|
335
|
+
f"repos/{repo}/contents/docs/roadmap/cycles?ref={base_ref}",
|
|
336
|
+
],
|
|
337
|
+
check=True,
|
|
338
|
+
capture_output=True,
|
|
339
|
+
text=True,
|
|
340
|
+
env=env,
|
|
341
|
+
timeout=60,
|
|
342
|
+
)
|
|
343
|
+
except FileNotFoundError as exc:
|
|
344
|
+
raise BaseCycleDocFetchError(
|
|
345
|
+
"gh CLI not on PATH; cannot read cycle docs from base ref."
|
|
346
|
+
) from exc
|
|
347
|
+
except subprocess.CalledProcessError as exc:
|
|
348
|
+
raise BaseCycleDocFetchError(
|
|
349
|
+
f"gh api repos/{repo}/contents/docs/roadmap/cycles failed "
|
|
350
|
+
f"(exit {exc.returncode}): {exc.stderr.strip()}"
|
|
351
|
+
) from exc
|
|
352
|
+
except subprocess.TimeoutExpired as exc:
|
|
353
|
+
raise BaseCycleDocFetchError(
|
|
354
|
+
f"gh api repos/{repo}/contents/docs/roadmap/cycles timed out after {exc.timeout}s"
|
|
355
|
+
) from exc
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
entries = json.loads(listing_result.stdout)
|
|
359
|
+
except json.JSONDecodeError as exc:
|
|
360
|
+
raise BaseCycleDocFetchError(
|
|
361
|
+
"GitHub contents API returned malformed JSON for cycle docs listing."
|
|
362
|
+
) from exc
|
|
363
|
+
|
|
364
|
+
pattern = re.compile(rf"^cycle{re.escape(cycle_id)}(?:-.+)?\.md$")
|
|
365
|
+
candidates = [
|
|
366
|
+
entry
|
|
367
|
+
for entry in entries
|
|
368
|
+
if isinstance(entry, dict)
|
|
369
|
+
and entry.get("type") == "file"
|
|
370
|
+
and isinstance(entry.get("name"), str)
|
|
371
|
+
and pattern.match(entry["name"])
|
|
372
|
+
]
|
|
373
|
+
if not candidates:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
chosen = max(candidates, key=lambda entry: len(entry["name"]))
|
|
377
|
+
path = chosen.get("path")
|
|
378
|
+
if not isinstance(path, str) or not path:
|
|
379
|
+
raise BaseCycleDocFetchError(
|
|
380
|
+
f"GitHub contents API returned a cycle doc entry without a path for cycle {cycle_id}."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
content_result = subprocess.run(
|
|
385
|
+
[
|
|
386
|
+
"gh",
|
|
387
|
+
"api",
|
|
388
|
+
f"repos/{repo}/contents/{path}?ref={base_ref}",
|
|
389
|
+
"--jq",
|
|
390
|
+
".content",
|
|
391
|
+
],
|
|
392
|
+
check=True,
|
|
393
|
+
capture_output=True,
|
|
394
|
+
text=True,
|
|
395
|
+
env=env,
|
|
396
|
+
timeout=60,
|
|
397
|
+
)
|
|
398
|
+
except subprocess.CalledProcessError as exc:
|
|
399
|
+
raise BaseCycleDocFetchError(
|
|
400
|
+
f"gh api repos/{repo}/contents/{path} failed "
|
|
401
|
+
f"(exit {exc.returncode}): {exc.stderr.strip()}"
|
|
402
|
+
) from exc
|
|
403
|
+
except subprocess.TimeoutExpired as exc:
|
|
404
|
+
raise BaseCycleDocFetchError(
|
|
405
|
+
f"gh api repos/{repo}/contents/{path} timed out after {exc.timeout}s"
|
|
406
|
+
) from exc
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
decoded = base64.b64decode(content_result.stdout).decode("utf-8")
|
|
410
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
411
|
+
raise BaseCycleDocFetchError(
|
|
412
|
+
f"Could not decode base-ref cycle doc `{path}` from GitHub contents API."
|
|
413
|
+
) from exc
|
|
414
|
+
|
|
415
|
+
status, superseded_by = read_cycle_frontmatter_from_text(decoded)
|
|
416
|
+
return CycleDoc(path=REPO_ROOT / path, status=status, superseded_by=superseded_by)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class ChangedFilesFetchError(RuntimeError):
|
|
420
|
+
"""Raised when the PR-files API is unreachable.
|
|
421
|
+
|
|
422
|
+
Surfacing the failure (rather than returning an empty list) lets
|
|
423
|
+
the caller fail-closed: empty changed-files would push every PR
|
|
424
|
+
into the code-PR rule set (Issue/ProjectItem required), so a
|
|
425
|
+
transient gh outage could falsely block a legitimate plan PR.
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def pr_changed_files(repo: str, pr_number: int, gh_token: str | None) -> list[str]:
|
|
430
|
+
"""Fetch the PR's changed file list via the GitHub API.
|
|
431
|
+
|
|
432
|
+
Raises :class:`ChangedFilesFetchError` on any transport failure so
|
|
433
|
+
the caller can distinguish "no files changed" (legitimate) from
|
|
434
|
+
"could not enumerate files" (the gate must fail-closed rather than
|
|
435
|
+
silently mis-classify the PR as code-PR).
|
|
436
|
+
"""
|
|
437
|
+
env = os.environ.copy()
|
|
438
|
+
if gh_token:
|
|
439
|
+
env["GH_TOKEN"] = gh_token
|
|
440
|
+
args = [
|
|
441
|
+
"gh",
|
|
442
|
+
"api",
|
|
443
|
+
"--paginate",
|
|
444
|
+
f"repos/{repo}/pulls/{pr_number}/files",
|
|
445
|
+
"--jq",
|
|
446
|
+
".[].filename",
|
|
447
|
+
]
|
|
448
|
+
try:
|
|
449
|
+
result = subprocess.run(
|
|
450
|
+
args,
|
|
451
|
+
check=True,
|
|
452
|
+
capture_output=True,
|
|
453
|
+
text=True,
|
|
454
|
+
env=env,
|
|
455
|
+
timeout=60,
|
|
456
|
+
)
|
|
457
|
+
except FileNotFoundError as exc:
|
|
458
|
+
raise ChangedFilesFetchError(
|
|
459
|
+
"gh CLI not on PATH; cannot enumerate PR changed files."
|
|
460
|
+
) from exc
|
|
461
|
+
except subprocess.CalledProcessError as exc:
|
|
462
|
+
raise ChangedFilesFetchError(
|
|
463
|
+
f"gh api repos/{repo}/pulls/{pr_number}/files failed "
|
|
464
|
+
f"(exit {exc.returncode}): {exc.stderr.strip()}"
|
|
465
|
+
) from exc
|
|
466
|
+
except subprocess.TimeoutExpired as exc:
|
|
467
|
+
raise ChangedFilesFetchError(
|
|
468
|
+
f"gh api repos/{repo}/pulls/{pr_number}/files timed out after {exc.timeout}s"
|
|
469
|
+
) from exc
|
|
470
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class DiffFetchError(RuntimeError):
|
|
474
|
+
"""Raised when the PR-diff API is unreachable.
|
|
475
|
+
|
|
476
|
+
The status-completion guard reads the diff to detect PRs that flip
|
|
477
|
+
a cited cycle's frontmatter to ``status: completed``. Without the
|
|
478
|
+
diff, the guard fails open — exactly the failure mode the gate
|
|
479
|
+
exists to prevent. Surfacing the error lets ``main()`` fail-closed.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def pr_diff_patches(repo: str, pr_number: int, gh_token: str | None) -> str:
|
|
484
|
+
"""Fetch the full unified diff of the PR.
|
|
485
|
+
|
|
486
|
+
Raises :class:`DiffFetchError` on any transport failure so the
|
|
487
|
+
caller can fail-closed. A silently-empty return value would mean
|
|
488
|
+
the status-completion guard never fires — that's exactly the bypass
|
|
489
|
+
surface this cycle is meant to harden, so any inability to read the
|
|
490
|
+
diff must surface.
|
|
491
|
+
"""
|
|
492
|
+
env = os.environ.copy()
|
|
493
|
+
if gh_token:
|
|
494
|
+
env["GH_TOKEN"] = gh_token
|
|
495
|
+
args = [
|
|
496
|
+
"gh",
|
|
497
|
+
"pr",
|
|
498
|
+
"diff",
|
|
499
|
+
str(pr_number),
|
|
500
|
+
"--repo",
|
|
501
|
+
repo,
|
|
502
|
+
]
|
|
503
|
+
try:
|
|
504
|
+
result = subprocess.run(
|
|
505
|
+
args,
|
|
506
|
+
check=True,
|
|
507
|
+
capture_output=True,
|
|
508
|
+
text=True,
|
|
509
|
+
env=env,
|
|
510
|
+
timeout=60,
|
|
511
|
+
)
|
|
512
|
+
except FileNotFoundError as exc:
|
|
513
|
+
raise DiffFetchError(
|
|
514
|
+
"gh CLI not on PATH; cannot fetch PR diff for status-transition check."
|
|
515
|
+
) from exc
|
|
516
|
+
except subprocess.CalledProcessError as exc:
|
|
517
|
+
raise DiffFetchError(
|
|
518
|
+
f"gh pr diff {pr_number} --repo {repo} failed (exit {exc.returncode}): "
|
|
519
|
+
f"{exc.stderr.strip()}"
|
|
520
|
+
) from exc
|
|
521
|
+
except subprocess.TimeoutExpired as exc:
|
|
522
|
+
raise DiffFetchError(
|
|
523
|
+
f"gh pr diff {pr_number} --repo {repo} timed out after {exc.timeout}s"
|
|
524
|
+
) from exc
|
|
525
|
+
return result.stdout
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class CommitMessagesFetchError(RuntimeError):
|
|
529
|
+
"""Raised when the PR-commits API is unreachable.
|
|
530
|
+
|
|
531
|
+
Trailers on the last commit (the documented Sage workflow) live
|
|
532
|
+
here, not in the PR body. Silently degrading to "body-only" hides
|
|
533
|
+
half of the trailer surface and can produce false-red gate failures
|
|
534
|
+
for PRs that follow the documented commit-trailer convention.
|
|
535
|
+
Fail-closed and let the operator retry once gh is reachable.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def pr_commit_messages(repo: str, pr_number: int, gh_token: str | None) -> list[str]:
|
|
540
|
+
"""Fetch the commit messages for every commit in the PR.
|
|
541
|
+
|
|
542
|
+
Returns a list of commit messages in chronological order
|
|
543
|
+
(oldest → newest). Each message is preserved intact (including any
|
|
544
|
+
blank lines inside the body); the list-of-strings shape avoids the
|
|
545
|
+
delimiter-ambiguity that a single ``"\n\n"``-joined string would
|
|
546
|
+
have when commit bodies contain blank paragraphs of their own.
|
|
547
|
+
|
|
548
|
+
Trailers placed on the last commit (the documented Sage workflow
|
|
549
|
+
per CLAUDE.md "Commit Messages" section) are visible to the gate
|
|
550
|
+
alongside trailers in the PR body via :func:`build_trailer_input`.
|
|
551
|
+
|
|
552
|
+
Raises :class:`CommitMessagesFetchError` on transport failure so
|
|
553
|
+
the caller can fail-closed — mirroring the discipline applied to
|
|
554
|
+
:func:`pr_changed_files` and :func:`pr_diff_patches`. Silent
|
|
555
|
+
"body-only" degradation would mean a transient gh outage produces
|
|
556
|
+
false-red gate failures for PRs that follow the documented
|
|
557
|
+
last-commit-trailer convention.
|
|
558
|
+
"""
|
|
559
|
+
env = os.environ.copy()
|
|
560
|
+
if gh_token:
|
|
561
|
+
env["GH_TOKEN"] = gh_token
|
|
562
|
+
# Fetch structured JSON rather than --jq-flattening to a list of
|
|
563
|
+
# strings; the latter collapses commit messages whose body spans
|
|
564
|
+
# multiple lines and breaks trailer-block detection per git's own
|
|
565
|
+
# parsing semantics. We parse the JSON ourselves.
|
|
566
|
+
args = [
|
|
567
|
+
"gh",
|
|
568
|
+
"api",
|
|
569
|
+
"--paginate",
|
|
570
|
+
f"repos/{repo}/pulls/{pr_number}/commits",
|
|
571
|
+
]
|
|
572
|
+
try:
|
|
573
|
+
result = subprocess.run(
|
|
574
|
+
args,
|
|
575
|
+
check=True,
|
|
576
|
+
capture_output=True,
|
|
577
|
+
text=True,
|
|
578
|
+
env=env,
|
|
579
|
+
timeout=60,
|
|
580
|
+
)
|
|
581
|
+
except FileNotFoundError as exc:
|
|
582
|
+
raise CommitMessagesFetchError(
|
|
583
|
+
"gh CLI not on PATH; cannot fetch PR commit messages."
|
|
584
|
+
) from exc
|
|
585
|
+
except subprocess.CalledProcessError as exc:
|
|
586
|
+
raise CommitMessagesFetchError(
|
|
587
|
+
f"gh api repos/{repo}/pulls/{pr_number}/commits failed "
|
|
588
|
+
f"(exit {exc.returncode}): {exc.stderr.strip()}"
|
|
589
|
+
) from exc
|
|
590
|
+
except subprocess.TimeoutExpired as exc:
|
|
591
|
+
raise CommitMessagesFetchError(
|
|
592
|
+
f"gh api repos/{repo}/pulls/{pr_number}/commits timed out after {exc.timeout}s"
|
|
593
|
+
) from exc
|
|
594
|
+
return _parse_paginated_commits_to_list(result.stdout)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _parse_paginated_commits_to_list(raw: str) -> list[str]:
|
|
598
|
+
"""Extract commit messages from a `gh api --paginate` JSON stream as a list.
|
|
599
|
+
|
|
600
|
+
`gh api --paginate` concatenates each page's JSON array back-to-back
|
|
601
|
+
into a single stream — e.g., ``[{...},{...}][{...}]`` — without a
|
|
602
|
+
delimiter between arrays. The same pattern is parsed by
|
|
603
|
+
``scripts/ci/check_agentic_engineer_authorship.py:fetch_pr_commits``
|
|
604
|
+
via ``json.JSONDecoder().raw_decode``; this helper applies the same
|
|
605
|
+
streaming parse and returns commit messages as a list of strings,
|
|
606
|
+
preserving every commit body intact (including blank-line
|
|
607
|
+
paragraphs that would otherwise alias the inter-commit delimiter).
|
|
608
|
+
"""
|
|
609
|
+
raw = raw.strip()
|
|
610
|
+
if not raw:
|
|
611
|
+
return []
|
|
612
|
+
commits: list[str] = []
|
|
613
|
+
decoder = json.JSONDecoder()
|
|
614
|
+
idx = 0
|
|
615
|
+
while idx < len(raw):
|
|
616
|
+
try:
|
|
617
|
+
value, end = decoder.raw_decode(raw, idx)
|
|
618
|
+
except json.JSONDecodeError:
|
|
619
|
+
# Skip past one character and retry; defensive guard against
|
|
620
|
+
# malformed prefixes that aren't JSON. The reference parser
|
|
621
|
+
# in `check_agentic_engineer_authorship.py` does not retry,
|
|
622
|
+
# but it operates on tighter-controlled input.
|
|
623
|
+
idx += 1
|
|
624
|
+
continue
|
|
625
|
+
if isinstance(value, list):
|
|
626
|
+
for entry in value:
|
|
627
|
+
msg = (entry or {}).get("commit", {}).get("message")
|
|
628
|
+
if isinstance(msg, str) and msg.strip():
|
|
629
|
+
commits.append(msg.rstrip())
|
|
630
|
+
elif isinstance(value, dict):
|
|
631
|
+
# Single-commit case (rare). Defensive.
|
|
632
|
+
msg = value.get("commit", {}).get("message")
|
|
633
|
+
if isinstance(msg, str) and msg.strip():
|
|
634
|
+
commits.append(msg.rstrip())
|
|
635
|
+
idx = end
|
|
636
|
+
while idx < len(raw) and raw[idx].isspace():
|
|
637
|
+
idx += 1
|
|
638
|
+
return commits
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _parse_paginated_commit_messages(raw: str) -> str:
|
|
642
|
+
"""Backward-compatible wrapper that returns the list as a joined string.
|
|
643
|
+
|
|
644
|
+
Retained for the unit tests that were written before
|
|
645
|
+
:func:`_parse_paginated_commits_to_list` existed. Production code
|
|
646
|
+
uses the list-returning variant via :func:`pr_commit_messages`.
|
|
647
|
+
Joining with ``"\n\n"`` is lossy when commit bodies contain blank
|
|
648
|
+
paragraphs — DO NOT use this form for any precedence-sensitive
|
|
649
|
+
parsing path.
|
|
650
|
+
"""
|
|
651
|
+
return "\n\n".join(_parse_paginated_commits_to_list(raw))
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def is_plan_pr(
|
|
655
|
+
title: str,
|
|
656
|
+
labels: Iterable[str],
|
|
657
|
+
changed_files: Iterable[str],
|
|
658
|
+
) -> bool:
|
|
659
|
+
"""Plan PR detection per Component 2 rule 1.
|
|
660
|
+
|
|
661
|
+
A PR is a plan PR if BOTH conditions hold:
|
|
662
|
+
- title matches ``docs(roadmap):`` OR ``docs(<cycle>):``,
|
|
663
|
+
OR the PR has the label ``plan-pr``;
|
|
664
|
+
- AND every changed file is under ``docs/`` (no code paths). We
|
|
665
|
+
use the broader ``docs/`` rather than just cycle-doc paths so
|
|
666
|
+
that a plan PR can add supporting docs (ADR, engineering doc)
|
|
667
|
+
in the same change.
|
|
668
|
+
"""
|
|
669
|
+
label_match = any(label.strip().lower() == "plan-pr" for label in labels)
|
|
670
|
+
title_match = bool(PLAN_TITLE_RE.match(title or ""))
|
|
671
|
+
files = list(changed_files)
|
|
672
|
+
if not files:
|
|
673
|
+
return False
|
|
674
|
+
docs_only = all(p.startswith("docs/") or p == "AGENTS.md" or p == "CLAUDE.md" for p in files)
|
|
675
|
+
return (label_match or title_match) and docs_only
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def status_completion_in_diff(diff: str) -> list[str]:
|
|
679
|
+
"""Find cycle docs whose frontmatter is being set to ``status: completed`` in this PR.
|
|
680
|
+
|
|
681
|
+
Reads the unified diff and looks for added lines matching
|
|
682
|
+
``+status: completed`` inside a file under ``docs/roadmap/cycles/``.
|
|
683
|
+
Returns the list of cycle doc file paths affected. The caller uses
|
|
684
|
+
this to enforce: a PR that completes a cycle must not also be the
|
|
685
|
+
PR that implements it (status transitions go in their own PR).
|
|
686
|
+
"""
|
|
687
|
+
affected: list[str] = []
|
|
688
|
+
current_file: str | None = None
|
|
689
|
+
in_frontmatter = False
|
|
690
|
+
seen_completed_in_frontmatter = False
|
|
691
|
+
for line in diff.splitlines():
|
|
692
|
+
if line.startswith("+++ b/"):
|
|
693
|
+
# New file header. Flush prior state.
|
|
694
|
+
if (
|
|
695
|
+
seen_completed_in_frontmatter
|
|
696
|
+
and current_file
|
|
697
|
+
and current_file not in affected
|
|
698
|
+
):
|
|
699
|
+
affected.append(current_file)
|
|
700
|
+
current_file = line[len("+++ b/") :].strip()
|
|
701
|
+
in_frontmatter = False
|
|
702
|
+
seen_completed_in_frontmatter = False
|
|
703
|
+
continue
|
|
704
|
+
if not current_file or not current_file.startswith("docs/roadmap/cycles/"):
|
|
705
|
+
continue
|
|
706
|
+
# Naive frontmatter tracking inside the diff hunk: ``---`` delimiters
|
|
707
|
+
# toggle the flag. False positives are acceptable here — the goal
|
|
708
|
+
# is to flag obvious "added a status: completed line in a cycle
|
|
709
|
+
# doc" cases, not to perfectly parse YAML.
|
|
710
|
+
if line.startswith("+") and line[1:].strip() == "---":
|
|
711
|
+
in_frontmatter = not in_frontmatter
|
|
712
|
+
continue
|
|
713
|
+
if line.startswith(" ") and line.strip() == "---":
|
|
714
|
+
in_frontmatter = not in_frontmatter
|
|
715
|
+
continue
|
|
716
|
+
if (
|
|
717
|
+
line.startswith("+")
|
|
718
|
+
and in_frontmatter
|
|
719
|
+
and re.match(r"\+\s*status:\s*[\"']?completed[\"']?\s*$", line)
|
|
720
|
+
):
|
|
721
|
+
seen_completed_in_frontmatter = True
|
|
722
|
+
# Flush trailing file.
|
|
723
|
+
if seen_completed_in_frontmatter and current_file and current_file not in affected:
|
|
724
|
+
affected.append(current_file)
|
|
725
|
+
return affected
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
_TERMINAL_STATUSES = frozenset({"completed", "complete", "superseded", "abandoned", "absorbed"})
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def is_terminal_status(status: str | None) -> bool:
|
|
732
|
+
return (status or "").lower().strip() in _TERMINAL_STATUSES
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def cycle_docs_transitioned_to_terminal(
|
|
736
|
+
repo: str,
|
|
737
|
+
base_ref: str,
|
|
738
|
+
gh_token: str | None,
|
|
739
|
+
changed_files: Iterable[str],
|
|
740
|
+
) -> list[str]:
|
|
741
|
+
"""Find cycle docs whose status transitions to terminal in this PR.
|
|
742
|
+
|
|
743
|
+
Compares each modified cycle-doc file's status at the base ref vs the
|
|
744
|
+
PR checkout. Returns the list of repo-relative paths that flipped
|
|
745
|
+
from a non-terminal status (in-progress / draft / planned / …) to a
|
|
746
|
+
terminal one (completed / complete / superseded / abandoned /
|
|
747
|
+
absorbed).
|
|
748
|
+
|
|
749
|
+
Replaces the prior diff-context-based detection
|
|
750
|
+
(``status_completion_in_diff``), which missed transitions when
|
|
751
|
+
``gh pr diff`` omitted the opening ``---`` frontmatter fence from
|
|
752
|
+
the hunk context — a real failure mode flagged by Codex P2 review
|
|
753
|
+
on PR #1380. Reading the files directly via the GitHub contents API
|
|
754
|
+
(and locally for the PR-head version) is robust to diff truncation.
|
|
755
|
+
|
|
756
|
+
Raises :class:`BaseCycleDocFetchError` on transport failure; caller
|
|
757
|
+
decides whether to fail-closed.
|
|
758
|
+
"""
|
|
759
|
+
cycle_paths = [
|
|
760
|
+
p
|
|
761
|
+
for p in changed_files
|
|
762
|
+
if p.startswith("docs/roadmap/cycles/cycle") and p.endswith(".md")
|
|
763
|
+
]
|
|
764
|
+
if not cycle_paths:
|
|
765
|
+
return []
|
|
766
|
+
|
|
767
|
+
env = os.environ.copy()
|
|
768
|
+
if gh_token:
|
|
769
|
+
env["GH_TOKEN"] = gh_token
|
|
770
|
+
|
|
771
|
+
transitioned: list[str] = []
|
|
772
|
+
for path in cycle_paths:
|
|
773
|
+
# Head status: read from the PR checkout (already on disk).
|
|
774
|
+
head_path = REPO_ROOT / path
|
|
775
|
+
if not head_path.exists():
|
|
776
|
+
# File was deleted in the PR — not a transition-to-terminal.
|
|
777
|
+
continue
|
|
778
|
+
head_status, _ = read_cycle_frontmatter(head_path)
|
|
779
|
+
if not is_terminal_status(head_status):
|
|
780
|
+
continue # head is non-terminal; no transition to terminal.
|
|
781
|
+
|
|
782
|
+
# Base status: fetch via contents API at the immutable base SHA.
|
|
783
|
+
try:
|
|
784
|
+
result = subprocess.run(
|
|
785
|
+
[
|
|
786
|
+
"gh",
|
|
787
|
+
"api",
|
|
788
|
+
f"repos/{repo}/contents/{path}?ref={base_ref}",
|
|
789
|
+
"--jq",
|
|
790
|
+
".content",
|
|
791
|
+
],
|
|
792
|
+
check=True,
|
|
793
|
+
capture_output=True,
|
|
794
|
+
text=True,
|
|
795
|
+
env=env,
|
|
796
|
+
timeout=60,
|
|
797
|
+
)
|
|
798
|
+
except FileNotFoundError as exc:
|
|
799
|
+
raise BaseCycleDocFetchError(
|
|
800
|
+
"gh CLI not on PATH; cannot read base-ref cycle doc."
|
|
801
|
+
) from exc
|
|
802
|
+
except subprocess.CalledProcessError as exc:
|
|
803
|
+
# 404 means the file is new in the PR — treat as "no base
|
|
804
|
+
# status to compare against" rather than failure.
|
|
805
|
+
if _is_gh_not_found_error(exc):
|
|
806
|
+
# New file in PR; if head is terminal, that's a fresh
|
|
807
|
+
# cycle being created already terminal — almost certainly
|
|
808
|
+
# an error but not a transition the gate is meant to
|
|
809
|
+
# catch (the cycle-doc-existence checks above handle
|
|
810
|
+
# the "you can't cite a terminal cycle" path).
|
|
811
|
+
continue
|
|
812
|
+
raise BaseCycleDocFetchError(
|
|
813
|
+
f"gh api contents/{path}?ref={base_ref} failed "
|
|
814
|
+
f"(exit {exc.returncode}): {exc.stderr.strip()}"
|
|
815
|
+
) from exc
|
|
816
|
+
except subprocess.TimeoutExpired as exc:
|
|
817
|
+
raise BaseCycleDocFetchError(
|
|
818
|
+
f"gh api contents/{path}?ref={base_ref} timed out after {exc.timeout}s"
|
|
819
|
+
) from exc
|
|
820
|
+
|
|
821
|
+
try:
|
|
822
|
+
decoded = base64.b64decode(result.stdout.strip()).decode("utf-8")
|
|
823
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
824
|
+
# Codex P2 #3 follow-up: fail-closed on decode failure
|
|
825
|
+
# rather than silently skipping. A corrupt/unexpected
|
|
826
|
+
# contents-API response on a cited cycle doc must surface
|
|
827
|
+
# — silently dropping the file from transition detection
|
|
828
|
+
# would re-open the bypass surface this function exists
|
|
829
|
+
# to close. Mirrors the DiffFetchError / CommitMessages
|
|
830
|
+
# FetchError fail-closed pattern.
|
|
831
|
+
raise BaseCycleDocFetchError(
|
|
832
|
+
f"failed to decode contents-API payload for {path} at "
|
|
833
|
+
f"{base_ref}: {exc}"
|
|
834
|
+
) from exc
|
|
835
|
+
|
|
836
|
+
base_status: str | None = None
|
|
837
|
+
inside = False
|
|
838
|
+
for line in decoded.split("\n"):
|
|
839
|
+
stripped = line.strip()
|
|
840
|
+
if stripped == "---":
|
|
841
|
+
if not inside:
|
|
842
|
+
inside = True
|
|
843
|
+
continue
|
|
844
|
+
break
|
|
845
|
+
if not inside:
|
|
846
|
+
continue
|
|
847
|
+
if stripped.startswith("status:"):
|
|
848
|
+
base_status = stripped[len("status:") :].strip().strip("\"'")
|
|
849
|
+
break
|
|
850
|
+
|
|
851
|
+
if base_status is not None and not is_terminal_status(base_status):
|
|
852
|
+
transitioned.append(path)
|
|
853
|
+
|
|
854
|
+
return transitioned
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def build_trailer_input(pr_body: str, commits: list[str] | str) -> str:
|
|
858
|
+
"""Concatenate PR body and commits into the order trailers should be parsed in.
|
|
859
|
+
|
|
860
|
+
Precedence (lowest → highest, with the last-write-wins behavior of
|
|
861
|
+
``parse_trailers`` meaning later text overrides earlier):
|
|
862
|
+
|
|
863
|
+
1. Older commits first (the PR commit list is chronological;
|
|
864
|
+
:func:`pr_commit_messages` returns commits oldest → newest).
|
|
865
|
+
2. Tip commit (last commit) — the documented Sage convention
|
|
866
|
+
puts trailers here.
|
|
867
|
+
3. PR body LAST — authoritative; what the human / reviewer sees
|
|
868
|
+
on GitHub. A correct ``Cycle:`` in the body overrides any
|
|
869
|
+
stale value on an early commit (the bug this function fixes).
|
|
870
|
+
|
|
871
|
+
Accepts ``commits`` as either a list of commit-message strings
|
|
872
|
+
(preferred — preserves blank-line paragraphs inside individual
|
|
873
|
+
commit bodies) or a single ``"\n\n"``-joined string (legacy; ONLY
|
|
874
|
+
safe when no commit body contains a blank-line paragraph).
|
|
875
|
+
"""
|
|
876
|
+
# Normalize the input into a list of non-empty commit messages.
|
|
877
|
+
if isinstance(commits, str):
|
|
878
|
+
if not commits.strip():
|
|
879
|
+
return pr_body
|
|
880
|
+
# Legacy delimited-string path. Lossy on multi-paragraph
|
|
881
|
+
# commit bodies — production callers should pass a list.
|
|
882
|
+
commit_list = [c for c in commits.split("\n\n") if c.strip()]
|
|
883
|
+
else:
|
|
884
|
+
commit_list = [c for c in commits if c.strip()]
|
|
885
|
+
|
|
886
|
+
if not commit_list:
|
|
887
|
+
return pr_body
|
|
888
|
+
|
|
889
|
+
# Older commits come first in the returned order; the tip is the
|
|
890
|
+
# last entry. Concatenate older→tip→body so the body wins on
|
|
891
|
+
# last-write-wins parsing.
|
|
892
|
+
parts: list[str] = []
|
|
893
|
+
if len(commit_list) > 1:
|
|
894
|
+
parts.append("\n\n".join(commit_list[:-1]))
|
|
895
|
+
parts.append(commit_list[-1]) # tip commit
|
|
896
|
+
parts.append(pr_body)
|
|
897
|
+
return "\n\n".join(p for p in parts if p)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def is_bot_exempt(labels: Iterable[str]) -> bool:
|
|
901
|
+
"""Bot/automation label allowlist per CONTRIBUTING.md.
|
|
902
|
+
|
|
903
|
+
PRs with any of these labels skip trailer enforcement entirely.
|
|
904
|
+
"""
|
|
905
|
+
norm = {(lbl or "").strip().lower() for lbl in labels}
|
|
906
|
+
return any(lbl in norm for lbl in BOT_EXEMPT_LABELS)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def terminal_status_error(cycle_id: str, doc: CycleDoc, pr_type: str, source: str) -> str | None:
|
|
910
|
+
"""Return a terminal-status error for ``doc`` if its status blocks citation."""
|
|
911
|
+
status_norm = (doc.status or "").lower().strip()
|
|
912
|
+
path = doc.path.relative_to(REPO_ROOT)
|
|
913
|
+
source_suffix = f" in {source}" if source else ""
|
|
914
|
+
if status_norm in {"completed", "complete"}:
|
|
915
|
+
return (
|
|
916
|
+
f"[cycle-doc] FAIL ({pr_type}): cycle `{cycle_id}` is "
|
|
917
|
+
f"`status: {doc.status}` in `{path}`{source_suffix}; "
|
|
918
|
+
"cannot reference a completed cycle from a new PR. Open a "
|
|
919
|
+
"follow-up cycle for additional work."
|
|
920
|
+
)
|
|
921
|
+
if status_norm == "superseded":
|
|
922
|
+
suffix = (
|
|
923
|
+
f" (superseded by `{doc.superseded_by}`)"
|
|
924
|
+
if doc.superseded_by
|
|
925
|
+
else ""
|
|
926
|
+
)
|
|
927
|
+
return (
|
|
928
|
+
f"[cycle-doc] FAIL ({pr_type}): cycle `{cycle_id}` has "
|
|
929
|
+
f"`status: superseded`{suffix} in `{path}`{source_suffix}; "
|
|
930
|
+
"cannot reference a superseded cycle. Use the successor cycle's id."
|
|
931
|
+
)
|
|
932
|
+
if status_norm in {"abandoned", "absorbed"}:
|
|
933
|
+
return (
|
|
934
|
+
f"[cycle-doc] FAIL ({pr_type}): cycle `{cycle_id}` is "
|
|
935
|
+
f"`status: {doc.status}` in `{path}`{source_suffix}; cannot reference "
|
|
936
|
+
"a terminated cycle from a new PR."
|
|
937
|
+
)
|
|
938
|
+
return None
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def validate(
|
|
942
|
+
title: str,
|
|
943
|
+
body: str,
|
|
944
|
+
labels: Iterable[str],
|
|
945
|
+
changed_files: Iterable[str],
|
|
946
|
+
diff: str,
|
|
947
|
+
base_doc: CycleDoc | None = None,
|
|
948
|
+
terminal_transitions: Iterable[str] | None = None,
|
|
949
|
+
) -> list[str]:
|
|
950
|
+
"""Run all checks. Returns a list of error messages; empty means pass."""
|
|
951
|
+
errors: list[str] = []
|
|
952
|
+
|
|
953
|
+
labels_list = list(labels)
|
|
954
|
+
if is_bot_exempt(labels_list):
|
|
955
|
+
# Bot/automation PRs are exempted by label per CONTRIBUTING.md
|
|
956
|
+
# "Trailers" section. The exemption is rare and explicit (one
|
|
957
|
+
# of four named labels); the PR review reactor and dependabot
|
|
958
|
+
# carry these. Code-PR rules don't apply.
|
|
959
|
+
return errors
|
|
960
|
+
|
|
961
|
+
trailers = parse_trailers(body)
|
|
962
|
+
plan = is_plan_pr(title, labels_list, changed_files)
|
|
963
|
+
pr_type = "plan-pr" if plan else "code-pr"
|
|
964
|
+
|
|
965
|
+
cycle_id = normalize_cycle_id(trailers.cycle or "")
|
|
966
|
+
|
|
967
|
+
if plan:
|
|
968
|
+
# Plan PR: Cycle: is required. We also need the cycle doc in
|
|
969
|
+
# the diff (checked below after find_cycle_doc).
|
|
970
|
+
if not cycle_id:
|
|
971
|
+
errors.append(
|
|
972
|
+
"[cycle-doc] FAIL (plan-pr): missing required `Cycle:` trailer.\n"
|
|
973
|
+
" Plan PRs must cite the cycle they describe.\n"
|
|
974
|
+
" Add to PR description (or last commit message):\n"
|
|
975
|
+
" Cycle: <N>\n"
|
|
976
|
+
)
|
|
977
|
+
return errors
|
|
978
|
+
else:
|
|
979
|
+
# Code PR: either Cycle: is present (cycle-tracked product code,
|
|
980
|
+
# the dominant case) or Issue:/ProjectItem: is present (non-cycle
|
|
981
|
+
# PR — drift, hotfix, dependabot bump). Bot-exempt PRs already
|
|
982
|
+
# returned above.
|
|
983
|
+
if not cycle_id and not trailers.issue and not trailers.project_item:
|
|
984
|
+
errors.append(
|
|
985
|
+
"[cycle-doc] FAIL (code-pr): missing trailer.\n"
|
|
986
|
+
" Either:\n"
|
|
987
|
+
" Cycle: <N> (for product code tied to a cycle, "
|
|
988
|
+
"PLUS Issue:/ProjectItem: as a secondary anchor), or\n"
|
|
989
|
+
" Issue: #<N> (for drift / dependabot / hotfix "
|
|
990
|
+
"PRs without a cycle, per CONTRIBUTING.md), or\n"
|
|
991
|
+
" ProjectItem: <id>\n"
|
|
992
|
+
" GitHub auto-close keywords (`Closes #N`, `Fixes #N`, "
|
|
993
|
+
"`Resolves #N`) count as `Issue:`."
|
|
994
|
+
)
|
|
995
|
+
return errors
|
|
996
|
+
|
|
997
|
+
doc: CycleDoc | None = None
|
|
998
|
+
if cycle_id:
|
|
999
|
+
doc = find_cycle_doc(cycle_id)
|
|
1000
|
+
if doc is None:
|
|
1001
|
+
errors.append(
|
|
1002
|
+
f"[cycle-doc] FAIL ({pr_type}): cycle `{cycle_id}` not found in "
|
|
1003
|
+
f"`docs/roadmap/cycles/`. Either fix the trailer or open a plan "
|
|
1004
|
+
"PR that creates the cycle doc first."
|
|
1005
|
+
)
|
|
1006
|
+
return errors
|
|
1007
|
+
|
|
1008
|
+
# Terminal statuses that mean "do not cite this cycle from a new PR."
|
|
1009
|
+
# Spelling drift across older docs is reality: ``complete`` (no
|
|
1010
|
+
# ``-ed``) and ``Complete`` (capitalized) appear alongside the
|
|
1011
|
+
# canonical ``completed``. ``absorbed`` / ``abandoned`` are also
|
|
1012
|
+
# terminal. Treat all of these as block-cite.
|
|
1013
|
+
if error := terminal_status_error(cycle_id, doc, pr_type, "the PR checkout"):
|
|
1014
|
+
errors.append(error)
|
|
1015
|
+
|
|
1016
|
+
# The PR can edit the cited cycle doc itself. In CI we therefore
|
|
1017
|
+
# also validate the protected base-ref copy; otherwise a PR could
|
|
1018
|
+
# change `status: completed` back to `in-progress` and cite the
|
|
1019
|
+
# stale cycle in the same diff.
|
|
1020
|
+
if base_doc is not None:
|
|
1021
|
+
if error := terminal_status_error(cycle_id, base_doc, pr_type, "the base ref"):
|
|
1022
|
+
errors.append(error)
|
|
1023
|
+
|
|
1024
|
+
if plan:
|
|
1025
|
+
# Plan PR: the diff MUST include the cycle's own doc file. A plan
|
|
1026
|
+
# PR that doesn't touch its cycle's doc is mis-labelled.
|
|
1027
|
+
assert doc is not None # plan PR without cycle_id already returned
|
|
1028
|
+
cycle_paths = [
|
|
1029
|
+
f for f in changed_files if f.startswith("docs/roadmap/cycles/")
|
|
1030
|
+
]
|
|
1031
|
+
doc_relative = doc.path.relative_to(REPO_ROOT).as_posix()
|
|
1032
|
+
if doc_relative not in cycle_paths:
|
|
1033
|
+
errors.append(
|
|
1034
|
+
f"[cycle-doc] FAIL (plan-pr): plan PR cites `Cycle: {cycle_id}` "
|
|
1035
|
+
f"but does not modify `{doc_relative}`. Plan PRs must include "
|
|
1036
|
+
"the cycle doc they describe."
|
|
1037
|
+
)
|
|
1038
|
+
elif cycle_id and not trailers.issue and not trailers.project_item:
|
|
1039
|
+
# Code PR WITH Cycle but no Issue/ProjectItem: still required as
|
|
1040
|
+
# the secondary anchor (per CONTRIBUTING.md "For cycle PRs, both
|
|
1041
|
+
# Cycle: and Issue: (or ProjectItem:) are required").
|
|
1042
|
+
errors.append(
|
|
1043
|
+
"[cycle-doc] FAIL (code-pr): code PR cites a Cycle but no "
|
|
1044
|
+
"`Issue:` / `ProjectItem:` trailer. For cycle PRs both are "
|
|
1045
|
+
"required (CONTRIBUTING.md). GitHub auto-close keywords "
|
|
1046
|
+
"(`Closes #N`, `Fixes #N`, `Resolves #N`) count as `Issue:`."
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
if cycle_id and doc is not None:
|
|
1050
|
+
# Prefer the base-vs-head transition check (Codex P2 #3: avoids
|
|
1051
|
+
# diff-context misses when the frontmatter fence is outside the
|
|
1052
|
+
# hunk window). Fall back to the diff-based heuristic when no
|
|
1053
|
+
# transition set was supplied (in-memory tests, or when the
|
|
1054
|
+
# base-ref fetch was skipped).
|
|
1055
|
+
if terminal_transitions is not None:
|
|
1056
|
+
completed = list(terminal_transitions)
|
|
1057
|
+
else:
|
|
1058
|
+
completed = status_completion_in_diff(diff)
|
|
1059
|
+
if completed:
|
|
1060
|
+
# Same PR creates AND completes a cycle: that's a status-transition
|
|
1061
|
+
# PR that should be split from the implementation. Note: completing
|
|
1062
|
+
# a *different* cycle is fine (and common — a meta-PR can close
|
|
1063
|
+
# multiple cycles), so we only fail when the completed file
|
|
1064
|
+
# matches the PR's own cited cycle.
|
|
1065
|
+
doc_relative = doc.path.relative_to(REPO_ROOT).as_posix()
|
|
1066
|
+
if doc_relative in completed:
|
|
1067
|
+
errors.append(
|
|
1068
|
+
f"[cycle-doc] FAIL ({pr_type}): same PR sets "
|
|
1069
|
+
f"`status: completed` on `{doc_relative}` AND cites "
|
|
1070
|
+
f"`Cycle: {cycle_id}` as the in-flight cycle. Open a "
|
|
1071
|
+
"separate doc PR to flip status to completed after the "
|
|
1072
|
+
"implementing PR merges."
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
return errors
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1079
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1080
|
+
parser.add_argument(
|
|
1081
|
+
"--pr-number",
|
|
1082
|
+
type=int,
|
|
1083
|
+
default=int(os.environ.get("PR_NUMBER", "0") or 0),
|
|
1084
|
+
help="PR number (defaults to $PR_NUMBER).",
|
|
1085
|
+
)
|
|
1086
|
+
parser.add_argument(
|
|
1087
|
+
"--repo",
|
|
1088
|
+
default=os.environ.get("REPO", "momentiq-ai/sage3c"),
|
|
1089
|
+
help="Repo in owner/name form (defaults to $REPO).",
|
|
1090
|
+
)
|
|
1091
|
+
parser.add_argument(
|
|
1092
|
+
"--title",
|
|
1093
|
+
default=os.environ.get("PR_TITLE", ""),
|
|
1094
|
+
help="PR title (defaults to $PR_TITLE).",
|
|
1095
|
+
)
|
|
1096
|
+
parser.add_argument(
|
|
1097
|
+
"--body",
|
|
1098
|
+
default=os.environ.get("PR_BODY", ""),
|
|
1099
|
+
help="PR body (defaults to $PR_BODY).",
|
|
1100
|
+
)
|
|
1101
|
+
parser.add_argument(
|
|
1102
|
+
"--labels-json",
|
|
1103
|
+
default=os.environ.get("PR_LABELS", "[]"),
|
|
1104
|
+
help="JSON array of label objects (defaults to $PR_LABELS).",
|
|
1105
|
+
)
|
|
1106
|
+
parser.add_argument(
|
|
1107
|
+
"--gh-token",
|
|
1108
|
+
default=os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN"),
|
|
1109
|
+
)
|
|
1110
|
+
parser.add_argument(
|
|
1111
|
+
"--base-ref",
|
|
1112
|
+
default=os.environ.get("PR_BASE_SHA") or os.environ.get("PR_BASE_REF") or "",
|
|
1113
|
+
help="Base commit/ref for base-ref cycle-doc status checks.",
|
|
1114
|
+
)
|
|
1115
|
+
args = parser.parse_args(argv)
|
|
1116
|
+
|
|
1117
|
+
try:
|
|
1118
|
+
labels_raw = json.loads(args.labels_json or "[]")
|
|
1119
|
+
except json.JSONDecodeError:
|
|
1120
|
+
labels_raw = []
|
|
1121
|
+
labels = [
|
|
1122
|
+
(lbl.get("name") if isinstance(lbl, dict) else str(lbl))
|
|
1123
|
+
for lbl in labels_raw
|
|
1124
|
+
if lbl
|
|
1125
|
+
]
|
|
1126
|
+
labels = [label for label in labels if label]
|
|
1127
|
+
|
|
1128
|
+
if args.pr_number <= 0:
|
|
1129
|
+
print(
|
|
1130
|
+
"[cycle-doc] FAIL: PR_NUMBER missing or invalid. Set $PR_NUMBER or pass --pr-number.",
|
|
1131
|
+
file=sys.stderr,
|
|
1132
|
+
)
|
|
1133
|
+
return 1
|
|
1134
|
+
|
|
1135
|
+
try:
|
|
1136
|
+
changed_files = pr_changed_files(args.repo, args.pr_number, args.gh_token)
|
|
1137
|
+
except ChangedFilesFetchError as exc:
|
|
1138
|
+
print(
|
|
1139
|
+
f"[cycle-doc] FAIL: cannot enumerate PR changed files: {exc}\n"
|
|
1140
|
+
" Without the file list the gate cannot distinguish plan PR from "
|
|
1141
|
+
"code PR and would silently mis-classify. Failing closed.",
|
|
1142
|
+
file=sys.stderr,
|
|
1143
|
+
)
|
|
1144
|
+
return 1
|
|
1145
|
+
|
|
1146
|
+
try:
|
|
1147
|
+
diff = pr_diff_patches(args.repo, args.pr_number, args.gh_token)
|
|
1148
|
+
except DiffFetchError as exc:
|
|
1149
|
+
print(
|
|
1150
|
+
f"[cycle-doc] FAIL: cannot fetch PR diff: {exc}\n"
|
|
1151
|
+
" Without the diff the status-completion guard cannot fire and "
|
|
1152
|
+
"would silently allow a PR that both implements a cycle AND flips "
|
|
1153
|
+
"its status to completed. Failing closed.",
|
|
1154
|
+
file=sys.stderr,
|
|
1155
|
+
)
|
|
1156
|
+
return 1
|
|
1157
|
+
|
|
1158
|
+
try:
|
|
1159
|
+
commit_messages = pr_commit_messages(args.repo, args.pr_number, args.gh_token)
|
|
1160
|
+
except CommitMessagesFetchError as exc:
|
|
1161
|
+
print(
|
|
1162
|
+
f"[cycle-doc] FAIL: cannot fetch PR commit messages: {exc}\n"
|
|
1163
|
+
" Trailers placed on the last commit (the documented Sage "
|
|
1164
|
+
"workflow) would be invisible to the gate. Failing closed to "
|
|
1165
|
+
"avoid false-red rejection of PRs that follow the convention.",
|
|
1166
|
+
file=sys.stderr,
|
|
1167
|
+
)
|
|
1168
|
+
return 1
|
|
1169
|
+
|
|
1170
|
+
# Build the parse body with explicit trailer precedence.
|
|
1171
|
+
# build_trailer_input concatenates older-commits → tip-commit →
|
|
1172
|
+
# PR-body, so parse_trailers' last-write-wins ordering means:
|
|
1173
|
+
# 1. PR body wins on duplicate keys (textually last segment).
|
|
1174
|
+
# 2. Tip commit (last commit) overrides any older commits.
|
|
1175
|
+
# 3. Older commits are additive fallback (e.g., a `Closes #N`
|
|
1176
|
+
# left on an early commit by a tooling helper).
|
|
1177
|
+
body_for_parse = build_trailer_input(args.body, commit_messages)
|
|
1178
|
+
|
|
1179
|
+
base_doc_for_cited_cycle: CycleDoc | None = None
|
|
1180
|
+
cycle_id = normalize_cycle_id(parse_trailers(body_for_parse).cycle or "")
|
|
1181
|
+
if cycle_id:
|
|
1182
|
+
if not args.base_ref:
|
|
1183
|
+
print(
|
|
1184
|
+
"[cycle-doc] FAIL: PR_BASE_SHA / --base-ref missing.\n"
|
|
1185
|
+
" The gate must compare cited cycle-doc status against the "
|
|
1186
|
+
"protected base ref; reading only the PR checkout lets the PR "
|
|
1187
|
+
"rewrite terminal cycle status. Failing closed.",
|
|
1188
|
+
file=sys.stderr,
|
|
1189
|
+
)
|
|
1190
|
+
return 1
|
|
1191
|
+
try:
|
|
1192
|
+
base_doc_for_cited_cycle = base_cycle_doc(
|
|
1193
|
+
args.repo,
|
|
1194
|
+
cycle_id,
|
|
1195
|
+
args.base_ref,
|
|
1196
|
+
args.gh_token,
|
|
1197
|
+
)
|
|
1198
|
+
except BaseCycleDocFetchError as exc:
|
|
1199
|
+
print(
|
|
1200
|
+
f"[cycle-doc] FAIL: cannot read cited cycle doc from base ref: {exc}\n"
|
|
1201
|
+
" Without the base-ref copy, a PR could change a completed or "
|
|
1202
|
+
"superseded cycle back to in-progress and bypass the terminal-status "
|
|
1203
|
+
"guard. Failing closed.",
|
|
1204
|
+
file=sys.stderr,
|
|
1205
|
+
)
|
|
1206
|
+
return 1
|
|
1207
|
+
|
|
1208
|
+
# Codex P2 #3: detect terminal-status transitions via base-vs-head
|
|
1209
|
+
# status comparison rather than diff-context parsing. The diff path
|
|
1210
|
+
# missed transitions where the opening `---` fence fell outside the
|
|
1211
|
+
# default hunk context. Reading the files directly is robust to
|
|
1212
|
+
# truncation.
|
|
1213
|
+
transitions: list[str] = []
|
|
1214
|
+
if cycle_id and args.base_ref:
|
|
1215
|
+
try:
|
|
1216
|
+
transitions = cycle_docs_transitioned_to_terminal(
|
|
1217
|
+
args.repo,
|
|
1218
|
+
args.base_ref,
|
|
1219
|
+
args.gh_token,
|
|
1220
|
+
changed_files,
|
|
1221
|
+
)
|
|
1222
|
+
except BaseCycleDocFetchError as exc:
|
|
1223
|
+
print(
|
|
1224
|
+
f"[cycle-doc] FAIL: cannot read base-ref cycle docs for "
|
|
1225
|
+
f"terminal-transition check: {exc}\n"
|
|
1226
|
+
" Without base-ref reads the same-PR completion guard "
|
|
1227
|
+
"can be bypassed by a hunk that omits the frontmatter "
|
|
1228
|
+
"fence. Failing closed.",
|
|
1229
|
+
file=sys.stderr,
|
|
1230
|
+
)
|
|
1231
|
+
return 1
|
|
1232
|
+
|
|
1233
|
+
errors = validate(
|
|
1234
|
+
title=args.title,
|
|
1235
|
+
body=body_for_parse,
|
|
1236
|
+
labels=labels,
|
|
1237
|
+
changed_files=changed_files,
|
|
1238
|
+
diff=diff,
|
|
1239
|
+
base_doc=base_doc_for_cited_cycle,
|
|
1240
|
+
terminal_transitions=transitions,
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
if errors:
|
|
1244
|
+
for err in errors:
|
|
1245
|
+
print(err, file=sys.stderr)
|
|
1246
|
+
print(
|
|
1247
|
+
"\n[cycle-doc] See docs/roadmap/cycles/cycle318.4-ci-fallback-and-auto-merge.md "
|
|
1248
|
+
"(Component 2) for the gate specification.",
|
|
1249
|
+
file=sys.stderr,
|
|
1250
|
+
)
|
|
1251
|
+
return 1
|
|
1252
|
+
|
|
1253
|
+
print(
|
|
1254
|
+
f"[cycle-doc] OK: PR #{args.pr_number} satisfies cycle-doc trailer requirements."
|
|
1255
|
+
)
|
|
1256
|
+
return 0
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
if __name__ == "__main__":
|
|
1260
|
+
sys.exit(main())
|