@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,1093 @@
|
|
|
1
|
+
"""Continuous sync of GitHub tracker issues for cycle docs.
|
|
2
|
+
|
|
3
|
+
Renamed in Cycle 326 (was `backfill_cycle_issues.py`). The cycle 323
|
|
4
|
+
one-shot backfill became continuous: instead of a hardcoded list of
|
|
5
|
+
cycle IDs, this script discovers cycle docs by glob and reconciles the
|
|
6
|
+
tracker-issue state on every run.
|
|
7
|
+
|
|
8
|
+
Pipeline (per spec § Architecture):
|
|
9
|
+
|
|
10
|
+
1. Glob `docs/roadmap/cycles/cycle*.md` (exclude `archive/`).
|
|
11
|
+
2. Parse cycle ID from each FILENAME (not frontmatter — filename is
|
|
12
|
+
authoritative; guards against YAML float parsing of `cycle: 205.10`
|
|
13
|
+
→ `205.1` data-loss).
|
|
14
|
+
3. Group by cycle ID. When multiple docs share an ID, pick the
|
|
15
|
+
canonical one in priority order:
|
|
16
|
+
(1) filter out `status: superseded` OR docs with a non-null
|
|
17
|
+
`supersededBy:` / `superseded_by:` frontmatter (normalize
|
|
18
|
+
both spellings — both appear in the corpus)
|
|
19
|
+
(2) prefer `status: in-progress` over draft/completed/other
|
|
20
|
+
(3) longest filename (most descriptive slug)
|
|
21
|
+
(4) alphabetical tiebreaker
|
|
22
|
+
Non-canonical docs become `[collision]` log lines (NOT independent
|
|
23
|
+
trackers).
|
|
24
|
+
4. For each canonical cycle doc, resolve parent cycle:
|
|
25
|
+
(a) explicit `parent_cycle:` frontmatter wins
|
|
26
|
+
(b) otherwise, infer parent = cycle ID minus the last dot segment
|
|
27
|
+
(e.g. `322.7.1` → `322.7`, `318.4` → `318`, `100` → null)
|
|
28
|
+
Label = `type:sub-cycle` if parent resolved, else `type:cycle`.
|
|
29
|
+
5. Lookup existing same-title tracker (any label, any state). Three
|
|
30
|
+
outcomes:
|
|
31
|
+
- Found, correctly labeled → reuse, re-add to project (idempotent)
|
|
32
|
+
- Found, wrong label → ADOPT: add the missing label, wire
|
|
33
|
+
parent if applicable, add to project,
|
|
34
|
+
set fields. Issue body + title are
|
|
35
|
+
NOT changed.
|
|
36
|
+
- Not found → CREATE: new issue with the correct
|
|
37
|
+
label + body from doc, add to
|
|
38
|
+
project, wire parent.
|
|
39
|
+
6. Sub-issue parent link via GraphQL `addSubIssue` — ONLY when parent
|
|
40
|
+
resolved AND parent's cycle doc exists on disk. Orphan-parent
|
|
41
|
+
fail-soft: when parent has no doc (4 cases in corpus today: 177,
|
|
42
|
+
203, 307, 309), skip the mutation and emit `[warn] parent <id> has
|
|
43
|
+
no cycle doc; sub-issue link skipped for <child-id>`.
|
|
44
|
+
7. Add to project #2 on momentiq-ai (idempotent).
|
|
45
|
+
8. Set custom fields from frontmatter: `Wave`, `Priority`, `Cycle Doc`,
|
|
46
|
+
`Item Type` (Cycle / Sub-Cycle). `Cycle Doc` field value is derived
|
|
47
|
+
from the doc PATH (the doc IS the cycle_doc artifact), not from a
|
|
48
|
+
frontmatter key.
|
|
49
|
+
|
|
50
|
+
Modes:
|
|
51
|
+
(default) Full sync — create or adopt trackers, wire parents,
|
|
52
|
+
populate fields. Idempotent.
|
|
53
|
+
--issues-only Skip the project-field population pass (Phase C);
|
|
54
|
+
useful when project fields are intentionally not yet
|
|
55
|
+
defined or the operator wants a fast tracker-only run.
|
|
56
|
+
--dry-run Hermetic: print intended ops with NO gh calls.
|
|
57
|
+
|
|
58
|
+
Spec: docs/superpowers/specs/2026-05-17-gh-project-continuous-sync.md
|
|
59
|
+
"""
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import argparse
|
|
63
|
+
import json
|
|
64
|
+
import re
|
|
65
|
+
import subprocess
|
|
66
|
+
import sys
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import Optional
|
|
69
|
+
|
|
70
|
+
import yaml
|
|
71
|
+
|
|
72
|
+
import os
|
|
73
|
+
|
|
74
|
+
REPO = "momentiq-ai/sage3c"
|
|
75
|
+
PROJECT_OWNER = "momentiq-ai"
|
|
76
|
+
PROJECT_NUMBER = 2
|
|
77
|
+
|
|
78
|
+
CYCLES_DIR_DEFAULT = Path("docs/roadmap/cycles")
|
|
79
|
+
|
|
80
|
+
# Token strategy (per spec § Commit 3, Strategy A):
|
|
81
|
+
# - GH_TOKEN (or GITHUB_TOKEN) — used for repo issue mutations
|
|
82
|
+
# (gh issue create/edit/list, addSubIssue on issue node IDs).
|
|
83
|
+
# The workflow's `permissions: issues: write` block grants this.
|
|
84
|
+
# - PROJECT_TOKEN — classic PAT with `project` scope. Used ONLY for
|
|
85
|
+
# project-v2 mutations (addProjectV2ItemById, gh project item-add /
|
|
86
|
+
# item-edit, updateProjectV2ItemFieldValue) which the workflow-level
|
|
87
|
+
# GITHUB_TOKEN cannot do for org-level Projects v2.
|
|
88
|
+
# Resolved at module import time so subprocess.run can pass them down.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _project_env() -> dict:
|
|
92
|
+
"""Return an env dict that overrides GH_TOKEN with PROJECT_TOKEN for
|
|
93
|
+
org-level Projects v2 calls. Falls back to the existing GH_TOKEN if
|
|
94
|
+
PROJECT_TOKEN isn't set (e.g., local invocation where the PAT is
|
|
95
|
+
already exported as GH_TOKEN). Always returns a fresh dict — callers
|
|
96
|
+
use `subprocess.run(..., env=...)`."""
|
|
97
|
+
env = os.environ.copy()
|
|
98
|
+
project_token = os.environ.get("PROJECT_TOKEN")
|
|
99
|
+
if project_token:
|
|
100
|
+
env["GH_TOKEN"] = project_token
|
|
101
|
+
# Also unset GITHUB_TOKEN if present so gh doesn't prefer it.
|
|
102
|
+
env.pop("GITHUB_TOKEN", None)
|
|
103
|
+
return env
|
|
104
|
+
|
|
105
|
+
# Filename → cycle ID parser.
|
|
106
|
+
# Accepts:
|
|
107
|
+
# cycle318.4-foo.md → "318.4"
|
|
108
|
+
# cycle318.4.md → "318.4"
|
|
109
|
+
# cycle100-bar.md → "100"
|
|
110
|
+
# cycle100.md → "100"
|
|
111
|
+
# cycle10_meta_learn.md → "10" (legacy underscore separator)
|
|
112
|
+
# cycle308-2-foo.md → "308.2" (legacy dash-replaces-dot variant)
|
|
113
|
+
# cycle308-3-foo.md → "308.3"
|
|
114
|
+
# Rejects:
|
|
115
|
+
# cycle.md, cycleN.NN.md where NN parses as YAML float (filename only;
|
|
116
|
+
# frontmatter is never the cycle-ID source — see § Decisions).
|
|
117
|
+
#
|
|
118
|
+
# Order matters: try dot-form first (and ONLY dot-form for any filename
|
|
119
|
+
# already containing `.`), then dash-replaces-dot for filenames using the
|
|
120
|
+
# legacy `cycleN-N-...` form, then single-segment cycle docs.
|
|
121
|
+
#
|
|
122
|
+
# The dash-replaces-dot pattern captures *any* digit count in both
|
|
123
|
+
# segments because the legacy corpus uses single-digit sub-cycles only
|
|
124
|
+
# (308-2, 308-3) but we don't rely on that — the dot-form regex always
|
|
125
|
+
# wins first, so a filename like `cycle318.4-foo.md` is canonicalized
|
|
126
|
+
# to `318.4` via the dot-form pattern before the dash-pattern is
|
|
127
|
+
# considered. Filenames like `cycle318-4-foo.md` (no dot anywhere) are
|
|
128
|
+
# only produced by the legacy convention, where they too should map
|
|
129
|
+
# to `318.4`. The two readings are intentionally congruent.
|
|
130
|
+
CYCLE_ID_FILENAME_RES = (
|
|
131
|
+
re.compile(r"^cycle(\d+(?:\.\d+)+)(?:[-_].+)?\.md$"), # 318.4-foo / 318.4.md
|
|
132
|
+
re.compile(r"^cycle(\d+)-(\d+)(?:-.+)?\.md$"), # 308-2-foo → 308.2
|
|
133
|
+
re.compile(r"^cycle(\d+)(?:[-_].+)?\.md$"), # 100-foo / 10_meta / 100.md
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# `supersededBy` / `superseded_by` are BOTH valid spellings in the corpus.
|
|
137
|
+
SUPERSEDED_BY_KEYS = ("supersededBy", "superseded_by")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
141
|
+
# Pure data-layer functions (no gh CLI calls)
|
|
142
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_cycle_id_from_filename(filename: str) -> Optional[str]:
|
|
146
|
+
"""Extract a cycle ID from a doc filename.
|
|
147
|
+
|
|
148
|
+
Returns the canonical dotted form (`318.4`, `100`) or None if the
|
|
149
|
+
name doesn't look like a cycle doc.
|
|
150
|
+
"""
|
|
151
|
+
for pat in CYCLE_ID_FILENAME_RES:
|
|
152
|
+
m = pat.match(filename)
|
|
153
|
+
if m:
|
|
154
|
+
if pat.groups == 2: # dash-replaces-dot (308-2-foo)
|
|
155
|
+
return f"{m.group(1)}.{m.group(2)}"
|
|
156
|
+
if len(m.groups()) == 2: # dash-replaces-dot
|
|
157
|
+
return f"{m.group(1)}.{m.group(2)}"
|
|
158
|
+
return m.group(1)
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def discover_cycle_docs(
|
|
163
|
+
cycles_dir: Optional[Path] = None, root: Optional[Path] = None
|
|
164
|
+
) -> dict[str, list[Path]]:
|
|
165
|
+
"""Find every cycle doc under cycles_dir and group by cycle ID.
|
|
166
|
+
|
|
167
|
+
Returns: { cycle_id: [path1, path2, ...] }. Multiple paths per ID
|
|
168
|
+
indicate a duplicate-cycle-ID collision; the caller picks the
|
|
169
|
+
canonical one via `select_canonical_doc`.
|
|
170
|
+
|
|
171
|
+
Excludes `archive/` subdirectories.
|
|
172
|
+
"""
|
|
173
|
+
root = root or Path.cwd()
|
|
174
|
+
cycles_dir = cycles_dir or (root / CYCLES_DIR_DEFAULT)
|
|
175
|
+
if not cycles_dir.is_absolute():
|
|
176
|
+
cycles_dir = (root / cycles_dir).resolve()
|
|
177
|
+
grouped: dict[str, list[Path]] = {}
|
|
178
|
+
for path in sorted(cycles_dir.glob("cycle*.md")):
|
|
179
|
+
# Defensive: skip anything under an archive/ subtree even though
|
|
180
|
+
# glob() with no `**` shouldn't recurse.
|
|
181
|
+
if "archive" in path.parts:
|
|
182
|
+
continue
|
|
183
|
+
cycle_id = parse_cycle_id_from_filename(path.name)
|
|
184
|
+
if cycle_id is None:
|
|
185
|
+
continue
|
|
186
|
+
grouped.setdefault(cycle_id, []).append(path)
|
|
187
|
+
return grouped
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_frontmatter(doc_path: Path) -> dict:
|
|
191
|
+
"""Extract YAML frontmatter from a cycle doc.
|
|
192
|
+
|
|
193
|
+
Returns {} if the file has no frontmatter block. yaml.safe_load
|
|
194
|
+
handles YAML floats safely IF the frontmatter doesn't use them
|
|
195
|
+
in cycle-id-shaped fields — we don't rely on frontmatter for cycle
|
|
196
|
+
IDs at all (see parse_cycle_id_from_filename) which sidesteps the
|
|
197
|
+
`cycle: 205.10` → 205.1 trap.
|
|
198
|
+
"""
|
|
199
|
+
text = doc_path.read_text(encoding="utf-8")
|
|
200
|
+
if not text.startswith("---"):
|
|
201
|
+
return {}
|
|
202
|
+
end = text.find("---", 3)
|
|
203
|
+
if end < 0:
|
|
204
|
+
return {}
|
|
205
|
+
try:
|
|
206
|
+
return yaml.safe_load(text[3:end]) or {}
|
|
207
|
+
except yaml.YAMLError:
|
|
208
|
+
return {}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def is_superseded(frontmatter: dict) -> bool:
|
|
212
|
+
"""Return True if the doc is marked superseded by frontmatter.
|
|
213
|
+
|
|
214
|
+
Accepts both spellings of the supersession key. Treats:
|
|
215
|
+
- status: superseded → True
|
|
216
|
+
- supersededBy: <anything truthy and not null/empty> → True
|
|
217
|
+
- superseded_by: <anything truthy and not null/empty> → True
|
|
218
|
+
"""
|
|
219
|
+
status = (frontmatter.get("status") or "").strip().strip("\"'").lower()
|
|
220
|
+
if status == "superseded":
|
|
221
|
+
return True
|
|
222
|
+
for key in SUPERSEDED_BY_KEYS:
|
|
223
|
+
val = frontmatter.get(key)
|
|
224
|
+
if val is None:
|
|
225
|
+
continue
|
|
226
|
+
# YAML can yield empty string or "null" — treat as not set.
|
|
227
|
+
if isinstance(val, str):
|
|
228
|
+
normalized = val.strip().strip("\"'").lower()
|
|
229
|
+
if normalized in ("", "null", "none"):
|
|
230
|
+
continue
|
|
231
|
+
return True
|
|
232
|
+
# Any non-string truthy value (int, dict, list) counts as "set".
|
|
233
|
+
if val:
|
|
234
|
+
return True
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def select_canonical_doc(cycle_id: str, docs: list[Path]) -> tuple[Path, list[Path]]:
|
|
239
|
+
"""Pick the canonical doc for a cycle ID, returning (canonical, collisions).
|
|
240
|
+
|
|
241
|
+
Priority order (per spec § Decisions):
|
|
242
|
+
(1) filter out superseded docs (status: superseded OR non-null
|
|
243
|
+
supersededBy:/superseded_by:)
|
|
244
|
+
(2) prefer status: in-progress over draft/completed/other
|
|
245
|
+
(3) longest filename
|
|
246
|
+
(4) alphabetical tiebreaker
|
|
247
|
+
|
|
248
|
+
`collisions` is the list of all OTHER (non-canonical) docs for this
|
|
249
|
+
cycle ID; the caller logs them as `[collision]` entries. If the
|
|
250
|
+
input list has length 1, collisions = [].
|
|
251
|
+
"""
|
|
252
|
+
if len(docs) == 1:
|
|
253
|
+
return docs[0], []
|
|
254
|
+
|
|
255
|
+
annotated = []
|
|
256
|
+
for p in docs:
|
|
257
|
+
fm = parse_frontmatter(p)
|
|
258
|
+
annotated.append({
|
|
259
|
+
"path": p,
|
|
260
|
+
"frontmatter": fm,
|
|
261
|
+
"superseded": is_superseded(fm),
|
|
262
|
+
"status": (fm.get("status") or "").strip().strip("\"'").lower(),
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
# Step 1: filter out superseded.
|
|
266
|
+
survivors = [a for a in annotated if not a["superseded"]]
|
|
267
|
+
if not survivors:
|
|
268
|
+
# All are superseded — fall back to the full set so the cycle
|
|
269
|
+
# still gets a tracker. The first one wins via the remaining
|
|
270
|
+
# tiebreakers; the warning lives in the caller's log.
|
|
271
|
+
survivors = annotated
|
|
272
|
+
|
|
273
|
+
# Step 2: prefer in-progress over draft/completed/other.
|
|
274
|
+
in_progress = [a for a in survivors if a["status"] == "in-progress"]
|
|
275
|
+
if in_progress:
|
|
276
|
+
survivors = in_progress
|
|
277
|
+
|
|
278
|
+
# Step 3 & 4: longest filename, alphabetical tiebreaker.
|
|
279
|
+
# max() with a tuple key — longer filename first, then alphabetical
|
|
280
|
+
# by name (sorted-ascending → max takes the last alphabetically).
|
|
281
|
+
# To make alphabetical PICK the first alphabetically (deterministic
|
|
282
|
+
# in test fixtures), we negate via tuple of (length, -ord(name)).
|
|
283
|
+
# Simpler: sort by (-len(name), name) and take the first.
|
|
284
|
+
survivors.sort(key=lambda a: (-len(a["path"].name), a["path"].name))
|
|
285
|
+
canonical = survivors[0]["path"]
|
|
286
|
+
collisions = [p for p in docs if p != canonical]
|
|
287
|
+
return canonical, collisions
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def resolve_parent(cycle_id: str, frontmatter: dict) -> Optional[str]:
|
|
291
|
+
"""Resolve the parent cycle ID for a doc.
|
|
292
|
+
|
|
293
|
+
Priority:
|
|
294
|
+
1. Explicit `parent_cycle:` frontmatter (non-null) wins.
|
|
295
|
+
2. Otherwise, infer parent = cycle ID minus the last dot segment
|
|
296
|
+
(e.g. `322.7.1` → `322.7`, `318.4` → `318`, `100` → None).
|
|
297
|
+
|
|
298
|
+
Returns None for top-level cycles.
|
|
299
|
+
"""
|
|
300
|
+
raw = frontmatter.get("parent_cycle")
|
|
301
|
+
if raw is not None:
|
|
302
|
+
# Could be int, float (BAD if YAML float), or str. Normalize to str.
|
|
303
|
+
if isinstance(raw, (int, float)):
|
|
304
|
+
return str(raw)
|
|
305
|
+
if isinstance(raw, str):
|
|
306
|
+
normalized = raw.strip().strip("\"'").lower()
|
|
307
|
+
if normalized not in ("", "null", "none"):
|
|
308
|
+
return raw.strip().strip("\"'")
|
|
309
|
+
# Falls through to inference if frontmatter is empty/null/none.
|
|
310
|
+
|
|
311
|
+
# Inference: drop the last dot segment.
|
|
312
|
+
if "." in cycle_id:
|
|
313
|
+
return cycle_id.rsplit(".", 1)[0]
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def repo_relative_path(path: Path, root: Optional[Path] = None) -> Path:
|
|
318
|
+
"""Return a path suitable for GitHub blob URLs and project fields."""
|
|
319
|
+
root = (root or Path.cwd()).resolve()
|
|
320
|
+
resolved = path.resolve()
|
|
321
|
+
try:
|
|
322
|
+
return resolved.relative_to(root)
|
|
323
|
+
except ValueError:
|
|
324
|
+
raise ValueError(f"{resolved} is not under repository root {root}") from None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def build_issue_body(cycle_id: str, doc_path: Path, frontmatter: dict) -> str:
|
|
328
|
+
"""Issue body: link to doc + key frontmatter fields. Absolute URL because
|
|
329
|
+
issue bodies do not resolve repo-relative links the way in-repo markdown does."""
|
|
330
|
+
rel_doc_path = repo_relative_path(doc_path).as_posix()
|
|
331
|
+
doc_url = f"https://github.com/{REPO}/blob/main/{rel_doc_path}"
|
|
332
|
+
return (
|
|
333
|
+
f"Tracking issue for [{doc_path.name}]({doc_url}).\n\n"
|
|
334
|
+
f"**Status**: {frontmatter.get('status', 'unknown')}\n"
|
|
335
|
+
f"**Wave**: {frontmatter.get('wave', 'n/a')}\n"
|
|
336
|
+
f"**Priority**: {frontmatter.get('priority', 'n/a')}\n\n"
|
|
337
|
+
f"This issue is the project-item tracker for Cycle {cycle_id}.\n"
|
|
338
|
+
f"The cycle doc is the source of truth for design; this issue is the trackable item.\n"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def plan_operations(
|
|
343
|
+
grouped_docs: dict[str, list[Path]], parent_doc_exists_lookup: dict[str, bool]
|
|
344
|
+
) -> tuple[list[dict], list[dict]]:
|
|
345
|
+
"""Build the list of operations from grouped docs.
|
|
346
|
+
|
|
347
|
+
Returns (ops, collisions):
|
|
348
|
+
ops: one entry per canonical cycle ID, with title/body/label/
|
|
349
|
+
parent_cycle/parent_doc_exists/doc_path fields.
|
|
350
|
+
collisions: log entries for non-canonical duplicate docs.
|
|
351
|
+
|
|
352
|
+
`parent_doc_exists_lookup`: a {cycle_id → bool} map indicating whether
|
|
353
|
+
a cycle doc exists for that ID. Built from `grouped_docs.keys()`.
|
|
354
|
+
"""
|
|
355
|
+
ops: list[dict] = []
|
|
356
|
+
collisions: list[dict] = []
|
|
357
|
+
for cycle_id in sorted(grouped_docs.keys(), key=_cycle_id_sort_key):
|
|
358
|
+
docs = grouped_docs[cycle_id]
|
|
359
|
+
canonical, others = select_canonical_doc(cycle_id, docs)
|
|
360
|
+
if others:
|
|
361
|
+
collisions.append({
|
|
362
|
+
"cycle_id": cycle_id,
|
|
363
|
+
"canonical": canonical,
|
|
364
|
+
"others": others,
|
|
365
|
+
})
|
|
366
|
+
frontmatter = parse_frontmatter(canonical)
|
|
367
|
+
raw_title = frontmatter.get("title", f"Cycle {cycle_id}")
|
|
368
|
+
if raw_title.startswith("Cycle"):
|
|
369
|
+
title = raw_title
|
|
370
|
+
else:
|
|
371
|
+
title = f"Cycle {cycle_id}: {raw_title}"
|
|
372
|
+
parent_id = resolve_parent(cycle_id, frontmatter)
|
|
373
|
+
parent_doc_exists = bool(parent_id) and parent_doc_exists_lookup.get(parent_id, False)
|
|
374
|
+
label = "type:sub-cycle" if parent_id else "type:cycle"
|
|
375
|
+
ops.append({
|
|
376
|
+
"cycle_id": cycle_id,
|
|
377
|
+
"title": title,
|
|
378
|
+
"body": build_issue_body(cycle_id, canonical, frontmatter),
|
|
379
|
+
"label": label,
|
|
380
|
+
"parent_cycle": parent_id,
|
|
381
|
+
"parent_doc_exists": parent_doc_exists,
|
|
382
|
+
"doc_path": canonical,
|
|
383
|
+
"frontmatter": frontmatter,
|
|
384
|
+
})
|
|
385
|
+
return ops, collisions
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _cycle_id_sort_key(cycle_id: str) -> tuple:
|
|
389
|
+
"""Sort cycle IDs as semver-ish tuples (3 < 3.1 < 3.10 < 4)."""
|
|
390
|
+
return tuple(int(seg) if seg.isdigit() else seg for seg in cycle_id.split("."))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
394
|
+
# gh CLI shell-out helpers
|
|
395
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def gh_create_issue(title: str, body: str, label: str) -> int:
|
|
399
|
+
"""Create a repo issue; return its number parsed from the URL gh prints."""
|
|
400
|
+
result = subprocess.run(
|
|
401
|
+
["gh", "issue", "create",
|
|
402
|
+
"--repo", REPO,
|
|
403
|
+
"--title", title,
|
|
404
|
+
"--body", body,
|
|
405
|
+
"--label", label],
|
|
406
|
+
capture_output=True, text=True, check=True,
|
|
407
|
+
)
|
|
408
|
+
url = result.stdout.strip()
|
|
409
|
+
return int(url.rsplit("/", 1)[-1])
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# Bulk-fetch page size for `gh issue list --search` (issue #1998).
|
|
413
|
+
# Set high enough that a single call covers the entire corpus today
|
|
414
|
+
# (~300 trackers) with headroom. If the corpus ever grows past this
|
|
415
|
+
# limit, `gh_fetch_all_cycle_trackers()` raises rather than silently
|
|
416
|
+
# returning a truncated set that would cause Phase A to mass-duplicate.
|
|
417
|
+
_BULK_FETCH_LIMIT = 5000
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def gh_fetch_all_cycle_trackers() -> list[dict]:
|
|
421
|
+
"""Bulk-fetch every existing cycle-tracker issue in ONE search call.
|
|
422
|
+
|
|
423
|
+
Returns a list of `{"number", "title", "labels": [{"name": …}, …]}` dicts.
|
|
424
|
+
|
|
425
|
+
Why bulk-fetch? (issue #1998)
|
|
426
|
+
Per-cycle `gh issue list --search` calls (~300+ per run) hammer
|
|
427
|
+
GitHub's search API. The search endpoint has a stricter quota
|
|
428
|
+
(~30 requests/min) than the core API and triggers secondary
|
|
429
|
+
abuse-rate-limiting once exceeded. Secondary limits surface as
|
|
430
|
+
`HTTP 401: Bad credentials` (not 403 / 429) on the graphql
|
|
431
|
+
endpoint — which historically led to a misdiagnosis as token
|
|
432
|
+
expiration. The same token both succeeded and 401'd within a
|
|
433
|
+
single run, which is the secondary-rate-limit signature.
|
|
434
|
+
|
|
435
|
+
Replacing N per-cycle searches with ONE bulk fetch + in-memory
|
|
436
|
+
lookup eliminates the rate-limit class of failure entirely.
|
|
437
|
+
|
|
438
|
+
Bulk-fetch safety (Codex P1 review on PR for #1998):
|
|
439
|
+
`gh issue list --limit N` is a MAX, not pagination — it pages
|
|
440
|
+
internally up to N then stops. If the corpus exceeds N silently,
|
|
441
|
+
the index drops older trackers, lookups return None, and Phase A
|
|
442
|
+
takes the CREATE path → mass-duplicates. Defence: fetch with
|
|
443
|
+
headroom (5000 vs current ~300 corpus) AND raise if we hit the
|
|
444
|
+
limit exactly. The caller's `execute()` already treats a fetch
|
|
445
|
+
failure as fail-loud (rc=1, no CREATE fallback) so a raise here
|
|
446
|
+
means no duplicates.
|
|
447
|
+
|
|
448
|
+
Uses the default GH_TOKEN env (workflow GITHUB_TOKEN with
|
|
449
|
+
`issues: write`); does NOT use PROJECT_TOKEN.
|
|
450
|
+
"""
|
|
451
|
+
result = subprocess.run(
|
|
452
|
+
["gh", "issue", "list",
|
|
453
|
+
"--repo", REPO,
|
|
454
|
+
"--state", "all",
|
|
455
|
+
"--search", 'in:title "Cycle "',
|
|
456
|
+
"--json", "number,title,labels",
|
|
457
|
+
"--limit", str(_BULK_FETCH_LIMIT)],
|
|
458
|
+
capture_output=True, text=True, check=True,
|
|
459
|
+
)
|
|
460
|
+
items = json.loads(result.stdout)
|
|
461
|
+
if len(items) >= _BULK_FETCH_LIMIT:
|
|
462
|
+
# We may have truncated — refuse to proceed rather than mass-
|
|
463
|
+
# duplicate via Phase A CREATE on the missing tail.
|
|
464
|
+
raise RuntimeError(
|
|
465
|
+
f"gh_fetch_all_cycle_trackers: returned {len(items)} items "
|
|
466
|
+
f"which equals the limit {_BULK_FETCH_LIMIT}. The corpus has "
|
|
467
|
+
f"likely outgrown the bulk-fetch ceiling. Raise _BULK_FETCH_LIMIT "
|
|
468
|
+
f"or replace with proper pagination via gh api search/issues "
|
|
469
|
+
f"--paginate before re-running the sync."
|
|
470
|
+
)
|
|
471
|
+
return items
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def build_tracker_index(items: list[dict]) -> dict[str, tuple[int, str, list[str]]]:
|
|
475
|
+
"""Build a `{cycle_id → (number, title, labels)}` index from bulk-fetch output.
|
|
476
|
+
|
|
477
|
+
Matches the same title shapes that `gh_find_existing_tracker` used to
|
|
478
|
+
match per-cycle:
|
|
479
|
+
"Cycle <id>" (exact, no suffix — no-frontmatter docs)
|
|
480
|
+
"Cycle <id>: <suffix>" (canonical)
|
|
481
|
+
"Cycle <id> <suffix>" (space-followed legacy form)
|
|
482
|
+
|
|
483
|
+
Anchoring: `Cycle 318: Foo` does NOT match a lookup for `318.1`. The
|
|
484
|
+
cycle ID is parsed off the title text *after* the literal "Cycle "
|
|
485
|
+
prefix using a regex that captures up to the next delimiter (`:`,
|
|
486
|
+
space, or end-of-string).
|
|
487
|
+
|
|
488
|
+
On duplicate cycle IDs in the issue corpus (two trackers with the
|
|
489
|
+
same cycle ID — should not happen, but tolerate it), the FIRST
|
|
490
|
+
occurrence wins. Same behaviour as the pre-bulk-fetch code, which
|
|
491
|
+
returned the first match in the search-result order.
|
|
492
|
+
"""
|
|
493
|
+
# Parse the cycle ID out of "Cycle <id>" or "Cycle <id>: …" or "Cycle <id> …".
|
|
494
|
+
# Use a positive look-around for the delimiter so we don't consume it.
|
|
495
|
+
# The id pattern allows dotted forms (1.2.3) — same shape parse_cycle_id_from_filename
|
|
496
|
+
# accepts, but read from title rather than filename.
|
|
497
|
+
title_id_re = re.compile(r"^Cycle (\d+(?:\.\d+)*)(?=$|:|\s)")
|
|
498
|
+
index: dict[str, tuple[int, str, list[str]]] = {}
|
|
499
|
+
for item in items:
|
|
500
|
+
title = item.get("title", "") or ""
|
|
501
|
+
m = title_id_re.match(title)
|
|
502
|
+
if not m:
|
|
503
|
+
continue
|
|
504
|
+
cycle_id = m.group(1)
|
|
505
|
+
if cycle_id in index:
|
|
506
|
+
continue # first occurrence wins (matches pre-bulk-fetch behaviour)
|
|
507
|
+
label_names = [lbl["name"] for lbl in item.get("labels", []) or []]
|
|
508
|
+
index[cycle_id] = (item["number"], title, label_names)
|
|
509
|
+
return index
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def gh_find_existing_tracker(
|
|
513
|
+
cycle_id: str,
|
|
514
|
+
index: Optional[dict[str, tuple[int, str, list[str]]]] = None,
|
|
515
|
+
) -> Optional[tuple[int, str, list[str]]]:
|
|
516
|
+
"""Return (number, title, labels) for an existing tracker matching the cycle ID, regardless of label.
|
|
517
|
+
|
|
518
|
+
Matches on exact title (no suffix) OR exact title prefix:
|
|
519
|
+
"Cycle <id>" (whole title — produced when frontmatter
|
|
520
|
+
lacks `title:`; default is f"Cycle {id}")
|
|
521
|
+
"Cycle <id>: " (canonical title)
|
|
522
|
+
"Cycle <id> " (space-followed legacy form)
|
|
523
|
+
(anchored — `Cycle 318` does NOT match `Cycle 318.1` lookups by
|
|
524
|
+
design; we check exact-equality and prefix-with-delimiter only).
|
|
525
|
+
Returns the first match (any state, any label).
|
|
526
|
+
|
|
527
|
+
This is the adoption hook: cycles like 324.3 (#1414) and 330.2 (#1539)
|
|
528
|
+
have manually-created trackers with the wrong labels. We find them
|
|
529
|
+
regardless of label so we can relabel rather than duplicate-create.
|
|
530
|
+
|
|
531
|
+
The exact-equality case is load-bearing for idempotency on the
|
|
532
|
+
no-frontmatter path: cycle83 has no frontmatter, so plan_operations
|
|
533
|
+
produces title `"Cycle 83"` exactly; if this lookup didn't handle
|
|
534
|
+
that, the first sync would create the issue, the second sync would
|
|
535
|
+
fail to find it (prefix-mismatch), and create a duplicate.
|
|
536
|
+
|
|
537
|
+
Issue #1998 (2026-05): prefer the `index` kwarg over the per-cycle
|
|
538
|
+
fallback. `execute()` calls `gh_fetch_all_cycle_trackers()` once at
|
|
539
|
+
the top, then passes the resulting index here for in-memory lookups,
|
|
540
|
+
eliminating the per-cycle search-API hits that triggered secondary
|
|
541
|
+
rate-limiting (surfacing as HTTP 401). The legacy per-cycle search
|
|
542
|
+
branch is preserved as a fallback for callers that don't pass an
|
|
543
|
+
index — useful for ad-hoc CLI use and to keep the original test
|
|
544
|
+
coverage meaningful.
|
|
545
|
+
"""
|
|
546
|
+
if index is not None:
|
|
547
|
+
return index.get(cycle_id)
|
|
548
|
+
|
|
549
|
+
# Fallback: legacy per-cycle search. Kept for ad-hoc use and existing
|
|
550
|
+
# test coverage. NOT used by execute() any more.
|
|
551
|
+
# Search by title prefix; gh's --search uses GitHub's issue-search
|
|
552
|
+
# tokenizer which doesn't honor exact phrase quoting reliably, so
|
|
553
|
+
# post-filter the results.
|
|
554
|
+
search_title = f"Cycle {cycle_id}"
|
|
555
|
+
result = subprocess.run(
|
|
556
|
+
["gh", "issue", "list",
|
|
557
|
+
"--repo", REPO,
|
|
558
|
+
"--state", "all",
|
|
559
|
+
"--search", f'in:title "{search_title}"',
|
|
560
|
+
"--json", "number,title,labels",
|
|
561
|
+
"--limit", "100"],
|
|
562
|
+
capture_output=True, text=True, check=True,
|
|
563
|
+
)
|
|
564
|
+
items = json.loads(result.stdout)
|
|
565
|
+
exact = f"Cycle {cycle_id}"
|
|
566
|
+
prefix_colon = f"Cycle {cycle_id}: "
|
|
567
|
+
prefix_space = f"Cycle {cycle_id} "
|
|
568
|
+
for item in items:
|
|
569
|
+
title = item.get("title", "")
|
|
570
|
+
if (title == exact
|
|
571
|
+
or title.startswith(prefix_colon)
|
|
572
|
+
or title.startswith(prefix_space)):
|
|
573
|
+
label_names = [lbl["name"] for lbl in item.get("labels", []) or []]
|
|
574
|
+
return (item["number"], title, label_names)
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def gh_add_label_to_issue(issue_number: int, label: str) -> None:
|
|
579
|
+
"""Add a label to an existing issue. Idempotent (gh issue edit
|
|
580
|
+
silently accepts already-present labels)."""
|
|
581
|
+
subprocess.run(
|
|
582
|
+
["gh", "issue", "edit", str(issue_number),
|
|
583
|
+
"--repo", REPO,
|
|
584
|
+
"--add-label", label],
|
|
585
|
+
check=True, capture_output=True,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def gh_node_id_for_issue(issue_number: int) -> str:
|
|
590
|
+
result = subprocess.run(
|
|
591
|
+
["gh", "api",
|
|
592
|
+
f"repos/{REPO}/issues/{issue_number}",
|
|
593
|
+
"--jq", ".node_id"],
|
|
594
|
+
capture_output=True, text=True, check=True,
|
|
595
|
+
)
|
|
596
|
+
return result.stdout.strip()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def gh_add_to_project(issue_number: int) -> None:
|
|
600
|
+
"""Add an issue to project #2 on momentiq-ai. Idempotent.
|
|
601
|
+
|
|
602
|
+
Uses PROJECT_TOKEN (org-level Projects v2 mutation).
|
|
603
|
+
"""
|
|
604
|
+
subprocess.run(
|
|
605
|
+
["gh", "project", "item-add", str(PROJECT_NUMBER),
|
|
606
|
+
"--owner", PROJECT_OWNER,
|
|
607
|
+
"--url", f"https://github.com/{REPO}/issues/{issue_number}"],
|
|
608
|
+
check=True, capture_output=True, env=_project_env(),
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def gh_link_sub_issue(parent_issue_number: int, child_issue_number: int) -> None:
|
|
613
|
+
"""Wire a sub-issue parent/child relationship via GraphQL."""
|
|
614
|
+
parent_node = gh_node_id_for_issue(parent_issue_number)
|
|
615
|
+
child_node = gh_node_id_for_issue(child_issue_number)
|
|
616
|
+
mutation = (
|
|
617
|
+
"mutation($parent: ID!, $child: ID!) { "
|
|
618
|
+
"addSubIssue(input: {issueId: $parent, subIssueId: $child}) { "
|
|
619
|
+
" issue { id } subIssue { id } "
|
|
620
|
+
"} }"
|
|
621
|
+
)
|
|
622
|
+
subprocess.run(
|
|
623
|
+
["gh", "api", "graphql",
|
|
624
|
+
"-f", f"query={mutation}",
|
|
625
|
+
"-f", f"parent={parent_node}",
|
|
626
|
+
"-f", f"child={child_node}"],
|
|
627
|
+
capture_output=True, text=True, check=True,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def gh_get_sub_issue_parent_number(child_issue_number: int) -> Optional[int]:
|
|
632
|
+
"""Return the parent issue number for a child sub-issue, or None.
|
|
633
|
+
|
|
634
|
+
Used to verify idempotency of `addSubIssue` failures (Codex P2 review
|
|
635
|
+
on PR for #1998): GitHub returns the same conjunctive error message
|
|
636
|
+
("Issue may not contain duplicate sub-issues and Sub issue may only
|
|
637
|
+
have one parent") whether the child is already linked to the SAME
|
|
638
|
+
parent we want (true idempotent success) or to a DIFFERENT parent
|
|
639
|
+
(real error — would silently masquerade as success otherwise).
|
|
640
|
+
|
|
641
|
+
Querying the parent lets us distinguish: same parent number → skip,
|
|
642
|
+
different parent number → real failure.
|
|
643
|
+
|
|
644
|
+
GraphQL: `Issue` has a `parent` field (the parent sub-issue), returning
|
|
645
|
+
the parent Issue node. The `number` is the issue number we compare.
|
|
646
|
+
Returns None if the child has no parent.
|
|
647
|
+
|
|
648
|
+
Uses the default GH_TOKEN env (workflow GITHUB_TOKEN with
|
|
649
|
+
`issues: read`); does NOT use PROJECT_TOKEN.
|
|
650
|
+
"""
|
|
651
|
+
query = (
|
|
652
|
+
"query($owner: String!, $repo: String!, $number: Int!) { "
|
|
653
|
+
"repository(owner: $owner, name: $repo) { "
|
|
654
|
+
" issue(number: $number) { parent { number } } "
|
|
655
|
+
"} }"
|
|
656
|
+
)
|
|
657
|
+
owner, _, repo = REPO.partition("/")
|
|
658
|
+
result = subprocess.run(
|
|
659
|
+
["gh", "api", "graphql",
|
|
660
|
+
"-f", f"query={query}",
|
|
661
|
+
"-f", f"owner={owner}",
|
|
662
|
+
"-f", f"repo={repo}",
|
|
663
|
+
"-F", f"number={child_issue_number}"],
|
|
664
|
+
capture_output=True, text=True, check=True,
|
|
665
|
+
)
|
|
666
|
+
data = json.loads(result.stdout)
|
|
667
|
+
parent = (
|
|
668
|
+
data.get("data", {})
|
|
669
|
+
.get("repository", {})
|
|
670
|
+
.get("issue", {})
|
|
671
|
+
.get("parent")
|
|
672
|
+
)
|
|
673
|
+
if not parent:
|
|
674
|
+
return None
|
|
675
|
+
parent_num = parent.get("number")
|
|
676
|
+
return int(parent_num) if parent_num is not None else None
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def is_already_linked_subissue_error(stderr: str) -> bool:
|
|
680
|
+
"""Return True only for explicit addSubIssue idempotency errors.
|
|
681
|
+
|
|
682
|
+
These are the GitHub API responses we want to treat as success because
|
|
683
|
+
the desired post-condition (parent ↔ child link) is already satisfied:
|
|
684
|
+
|
|
685
|
+
- "already linked" / "already exists" / "already a sub-issue" / etc.
|
|
686
|
+
— older / hypothetical wording, kept defensively.
|
|
687
|
+
- "Issue may not contain duplicate sub-issues" / "Sub issue may only
|
|
688
|
+
have one parent" — the EXACT GraphQL error strings GitHub returns
|
|
689
|
+
today (2026-05) when addSubIssue is called against an already-wired
|
|
690
|
+
relationship. Without these, the script counted ~24 cycles as
|
|
691
|
+
failures on every run even though their links were correct
|
|
692
|
+
(idempotency bug #1999, masked by #1998 until the rate-limit fix).
|
|
693
|
+
"""
|
|
694
|
+
normalized = " ".join(stderr.lower().split())
|
|
695
|
+
already_linked_phrases = (
|
|
696
|
+
"already linked",
|
|
697
|
+
"already exists",
|
|
698
|
+
"already exist",
|
|
699
|
+
"already a sub-issue",
|
|
700
|
+
"already a subissue",
|
|
701
|
+
"already added as a sub-issue",
|
|
702
|
+
"already added as a subissue",
|
|
703
|
+
"already has a parent",
|
|
704
|
+
# Real-corpus addSubIssue idempotency errors (issue #1999). These
|
|
705
|
+
# are GH-specific phrases bound to addSubIssue's stderr — chosen
|
|
706
|
+
# narrow enough that they don't false-positive on unrelated
|
|
707
|
+
# "parent issue may only have one parent" wording from other
|
|
708
|
+
# surfaces (e.g., a future validation error against an orphan).
|
|
709
|
+
# The first phrase includes "issue" to anchor it; the second
|
|
710
|
+
# includes "sub issue" (GitHub's two-word spelling).
|
|
711
|
+
"issue may not contain duplicate sub-issues",
|
|
712
|
+
"sub issue may only have one parent",
|
|
713
|
+
)
|
|
714
|
+
return any(phrase in normalized for phrase in already_linked_phrases)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def get_project_metadata() -> dict:
|
|
718
|
+
"""Fetch project node ID, item IDs by issue number, and field metadata.
|
|
719
|
+
|
|
720
|
+
Uses PROJECT_TOKEN for all calls (org-level Projects v2 read).
|
|
721
|
+
|
|
722
|
+
Returns:
|
|
723
|
+
{
|
|
724
|
+
"project_id": "PVT_...",
|
|
725
|
+
"field_ids": {field_name: field_id},
|
|
726
|
+
"field_options": {field_name: {option_name: option_id}},
|
|
727
|
+
"item_ids": {issue_number: item_id},
|
|
728
|
+
}
|
|
729
|
+
"""
|
|
730
|
+
proj_env = _project_env()
|
|
731
|
+
fields_result = subprocess.run(
|
|
732
|
+
["gh", "project", "field-list", str(PROJECT_NUMBER),
|
|
733
|
+
"--owner", PROJECT_OWNER, "--format", "json"],
|
|
734
|
+
capture_output=True, text=True, check=True, env=proj_env,
|
|
735
|
+
)
|
|
736
|
+
fields_data = json.loads(fields_result.stdout)
|
|
737
|
+
|
|
738
|
+
field_ids = {}
|
|
739
|
+
field_options: dict[str, dict[str, str]] = {}
|
|
740
|
+
for f in fields_data["fields"]:
|
|
741
|
+
field_ids[f["name"]] = f["id"]
|
|
742
|
+
if "options" in f:
|
|
743
|
+
field_options[f["name"]] = {opt["name"]: opt["id"] for opt in f["options"]}
|
|
744
|
+
|
|
745
|
+
items_result = subprocess.run(
|
|
746
|
+
["gh", "project", "item-list", str(PROJECT_NUMBER),
|
|
747
|
+
"--owner", PROJECT_OWNER, "--format", "json", "--limit", "1000"],
|
|
748
|
+
capture_output=True, text=True, check=True, env=proj_env,
|
|
749
|
+
)
|
|
750
|
+
items_data = json.loads(items_result.stdout)
|
|
751
|
+
|
|
752
|
+
item_ids = {}
|
|
753
|
+
for item in items_data["items"]:
|
|
754
|
+
if item.get("content", {}).get("number"):
|
|
755
|
+
item_ids[item["content"]["number"]] = item["id"]
|
|
756
|
+
|
|
757
|
+
# Resolve project_id via repositoryOwner GraphQL
|
|
758
|
+
pid_result = subprocess.run(
|
|
759
|
+
["gh", "api", "graphql",
|
|
760
|
+
"-f", f"query=query{{ organization(login:\"{PROJECT_OWNER}\"){{ projectV2(number:{PROJECT_NUMBER}){{ id }} }} }}"],
|
|
761
|
+
capture_output=True, text=True, check=True, env=proj_env,
|
|
762
|
+
)
|
|
763
|
+
pid_data = json.loads(pid_result.stdout)
|
|
764
|
+
project_id = pid_data["data"]["organization"]["projectV2"]["id"]
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
"project_id": project_id,
|
|
768
|
+
"field_ids": field_ids,
|
|
769
|
+
"field_options": field_options,
|
|
770
|
+
"item_ids": item_ids,
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def gh_set_field_single_select(project_id: str, item_id: str, field_id: str,
|
|
775
|
+
option_id: str) -> None:
|
|
776
|
+
subprocess.run(
|
|
777
|
+
["gh", "project", "item-edit",
|
|
778
|
+
"--project-id", project_id,
|
|
779
|
+
"--id", item_id,
|
|
780
|
+
"--field-id", field_id,
|
|
781
|
+
"--single-select-option-id", option_id],
|
|
782
|
+
check=True, capture_output=True, env=_project_env(),
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def gh_set_field_text(project_id: str, item_id: str, field_id: str,
|
|
787
|
+
text: str) -> None:
|
|
788
|
+
subprocess.run(
|
|
789
|
+
["gh", "project", "item-edit",
|
|
790
|
+
"--project-id", project_id,
|
|
791
|
+
"--id", item_id,
|
|
792
|
+
"--field-id", field_id,
|
|
793
|
+
"--text", text],
|
|
794
|
+
check=True, capture_output=True, env=_project_env(),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def populate_fields_for_cycle(op: dict, issue_number: int, meta: dict) -> bool:
|
|
799
|
+
"""Set Item Type / Wave / Priority / Cycle Doc on the tracker issue's project item.
|
|
800
|
+
|
|
801
|
+
All values come from the doc's frontmatter (read once by plan_operations).
|
|
802
|
+
`Cycle Doc` is derived from the doc PATH, not from a frontmatter key.
|
|
803
|
+
`Sister Cycle` / `Manifesto refs` are not populated (no frontmatter source);
|
|
804
|
+
leave them at whatever value an operator set manually.
|
|
805
|
+
"""
|
|
806
|
+
cycle_id = op["cycle_id"]
|
|
807
|
+
frontmatter = op["frontmatter"]
|
|
808
|
+
item_id = meta["item_ids"].get(issue_number)
|
|
809
|
+
if not item_id:
|
|
810
|
+
print(f"[fields ERROR] #{issue_number} ({cycle_id}) — not on project; skip")
|
|
811
|
+
return False
|
|
812
|
+
project_id = meta["project_id"]
|
|
813
|
+
field_ids = meta["field_ids"]
|
|
814
|
+
field_options = meta["field_options"]
|
|
815
|
+
|
|
816
|
+
item_type = "Sub-Cycle" if op["parent_cycle"] else "Cycle"
|
|
817
|
+
type_opt = field_options.get("Item Type", {}).get(item_type)
|
|
818
|
+
if type_opt and "Item Type" in field_ids:
|
|
819
|
+
gh_set_field_single_select(project_id, item_id, field_ids["Item Type"], type_opt)
|
|
820
|
+
|
|
821
|
+
wave = frontmatter.get("wave")
|
|
822
|
+
if wave is not None and "Wave" in field_ids:
|
|
823
|
+
wave_opt = field_options.get("Wave", {}).get(str(wave))
|
|
824
|
+
if wave_opt:
|
|
825
|
+
gh_set_field_single_select(project_id, item_id, field_ids["Wave"], wave_opt)
|
|
826
|
+
|
|
827
|
+
priority = frontmatter.get("priority")
|
|
828
|
+
if priority and "Priority" in field_ids:
|
|
829
|
+
# Frontmatter can be lowercase ("high", "medium", "low") or title-case
|
|
830
|
+
# ("High", "Medium", "Low"). Project options are title-case.
|
|
831
|
+
prio_normalized = str(priority).strip().strip("\"'").title()
|
|
832
|
+
prio_opt = field_options.get("Priority", {}).get(prio_normalized)
|
|
833
|
+
if prio_opt:
|
|
834
|
+
gh_set_field_single_select(project_id, item_id, field_ids["Priority"], prio_opt)
|
|
835
|
+
|
|
836
|
+
if "Cycle Doc" in field_ids:
|
|
837
|
+
rel_doc_path = repo_relative_path(op["doc_path"]).as_posix()
|
|
838
|
+
doc_url = f"https://github.com/{REPO}/blob/main/{rel_doc_path}"
|
|
839
|
+
gh_set_field_text(project_id, item_id, field_ids["Cycle Doc"], doc_url)
|
|
840
|
+
|
|
841
|
+
print(f"[fields] #{issue_number} ({cycle_id}) — Item Type={item_type}, "
|
|
842
|
+
f"Wave={wave}, Priority={priority}")
|
|
843
|
+
return True
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
847
|
+
# Orchestration
|
|
848
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def execute(
|
|
852
|
+
ops: list[dict],
|
|
853
|
+
collisions: list[dict],
|
|
854
|
+
dry_run: bool = False,
|
|
855
|
+
issues_only: bool = False,
|
|
856
|
+
) -> int:
|
|
857
|
+
"""Execute the plan and return an exit code (0 = success, 1 = failures).
|
|
858
|
+
|
|
859
|
+
Phases:
|
|
860
|
+
A. Per-cycle: find-or-create tracker, adopt if mislabeled, add to project.
|
|
861
|
+
B. Sub-issue parent links (skipped for orphan parents with a [warn]).
|
|
862
|
+
C. Custom-field population (skipped in --issues-only mode).
|
|
863
|
+
"""
|
|
864
|
+
cycle_to_issue: dict[str, int] = {}
|
|
865
|
+
failures: list[tuple[str, str]] = []
|
|
866
|
+
|
|
867
|
+
# Log collisions up front so operators see them before any creates.
|
|
868
|
+
for c in collisions:
|
|
869
|
+
canonical_name = c["canonical"].name
|
|
870
|
+
other_names = ", ".join(p.name for p in c["others"])
|
|
871
|
+
print(
|
|
872
|
+
f"[collision] cycle {c['cycle_id']}: {len(c['others']) + 1} docs share this id; "
|
|
873
|
+
f"canonical={canonical_name}; others=[{other_names}]"
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
# Bulk-fetch every cycle-tracker issue in ONE search call up front
|
|
877
|
+
# (issue #1998 fix). Pre-bulk-fetch, every cycle did its own
|
|
878
|
+
# `gh issue list --search` — ~300 hits/run against the search API,
|
|
879
|
+
# ~18% returned `HTTP 401: Bad credentials` from secondary rate
|
|
880
|
+
# limiting. One bulk call has no rate-limit pressure.
|
|
881
|
+
tracker_index: Optional[dict[str, tuple[int, str, list[str]]]] = None
|
|
882
|
+
if not dry_run:
|
|
883
|
+
try:
|
|
884
|
+
tracker_index = build_tracker_index(gh_fetch_all_cycle_trackers())
|
|
885
|
+
print(f"[bulk-fetch] indexed {len(tracker_index)} existing cycle tracker(s)")
|
|
886
|
+
except (subprocess.CalledProcessError, RuntimeError) as e:
|
|
887
|
+
# If the one bulk call fails OR returns a truncated set, we
|
|
888
|
+
# cannot proceed — every cycle would CREATE-duplicate. Fail
|
|
889
|
+
# loud and exit non-zero. This is explicitly NOT a per-cycle
|
|
890
|
+
# fallback to the search API.
|
|
891
|
+
err_msg = e.stderr.strip() if isinstance(e, subprocess.CalledProcessError) and e.stderr else str(e)
|
|
892
|
+
print(f"[error] bulk tracker fetch failed: {err_msg}")
|
|
893
|
+
failures.append(("*", f"bulk tracker fetch failed: {err_msg}"))
|
|
894
|
+
return 1
|
|
895
|
+
|
|
896
|
+
# Phase A — per-cycle find / create / adopt
|
|
897
|
+
for op in ops:
|
|
898
|
+
cycle_id = op["cycle_id"]
|
|
899
|
+
title = op["title"]
|
|
900
|
+
if dry_run:
|
|
901
|
+
existing_hint = "?" # hermetic — don't call gh
|
|
902
|
+
print(f"[dry-run] cycle {cycle_id}: would create-or-adopt — title='{title}' label={op['label']}")
|
|
903
|
+
if op["parent_cycle"]:
|
|
904
|
+
if op["parent_doc_exists"]:
|
|
905
|
+
print(f"[dry-run] cycle {cycle_id}: would link to parent {op['parent_cycle']}")
|
|
906
|
+
else:
|
|
907
|
+
print(
|
|
908
|
+
f"[warn] parent {op['parent_cycle']} has no cycle doc; "
|
|
909
|
+
f"sub-issue link skipped for {cycle_id}"
|
|
910
|
+
)
|
|
911
|
+
continue
|
|
912
|
+
|
|
913
|
+
try:
|
|
914
|
+
existing = gh_find_existing_tracker(cycle_id, index=tracker_index)
|
|
915
|
+
except subprocess.CalledProcessError as e:
|
|
916
|
+
failures.append((cycle_id, f"tracker lookup failed: {(e.stderr or '').strip()}"))
|
|
917
|
+
print(f"[error] cycle {cycle_id}: tracker lookup failed: {(e.stderr or '').strip()}")
|
|
918
|
+
continue
|
|
919
|
+
|
|
920
|
+
if existing:
|
|
921
|
+
issue_num, existing_title, existing_labels = existing
|
|
922
|
+
has_correct_label = op["label"] in existing_labels
|
|
923
|
+
if has_correct_label:
|
|
924
|
+
print(f"[skip] cycle {cycle_id}: #{issue_num} '{existing_title}' already correctly labeled ({op['label']})")
|
|
925
|
+
else:
|
|
926
|
+
# ADOPT: add the missing type:cycle/type:sub-cycle label.
|
|
927
|
+
try:
|
|
928
|
+
gh_add_label_to_issue(issue_num, op["label"])
|
|
929
|
+
print(
|
|
930
|
+
f"[adopt] cycle {cycle_id}: #{issue_num} '{existing_title}' "
|
|
931
|
+
f"— added label {op['label']} "
|
|
932
|
+
f"(was: {existing_labels or 'no labels'})"
|
|
933
|
+
)
|
|
934
|
+
except subprocess.CalledProcessError as e:
|
|
935
|
+
failures.append((cycle_id, f"label-add failed: {(e.stderr or '').strip()}"))
|
|
936
|
+
print(f"[error] cycle {cycle_id}: label-add failed: {(e.stderr or '').strip()}")
|
|
937
|
+
continue
|
|
938
|
+
cycle_to_issue[cycle_id] = issue_num
|
|
939
|
+
try:
|
|
940
|
+
gh_add_to_project(issue_num)
|
|
941
|
+
except subprocess.CalledProcessError as e:
|
|
942
|
+
# Adding to project is idempotent at the GraphQL layer but
|
|
943
|
+
# gh project item-add returns nonzero on dup sometimes — don't fail
|
|
944
|
+
# the whole sync over it.
|
|
945
|
+
msg = (e.stderr or '').strip().lower()
|
|
946
|
+
if "already" not in msg:
|
|
947
|
+
print(f"[warn] cycle {cycle_id}: project add failed (non-fatal): {(e.stderr or '').strip()}")
|
|
948
|
+
continue
|
|
949
|
+
|
|
950
|
+
# CREATE
|
|
951
|
+
try:
|
|
952
|
+
issue_num = gh_create_issue(title=title, body=op["body"], label=op["label"])
|
|
953
|
+
cycle_to_issue[cycle_id] = issue_num
|
|
954
|
+
print(f"[create] cycle {cycle_id}: #{issue_num} '{title}' (label={op['label']})")
|
|
955
|
+
gh_add_to_project(issue_num)
|
|
956
|
+
except subprocess.CalledProcessError as e:
|
|
957
|
+
failures.append((cycle_id, f"create failed: {(e.stderr or '').strip()}"))
|
|
958
|
+
print(f"[error] cycle {cycle_id}: create failed: {(e.stderr or '').strip()}")
|
|
959
|
+
continue
|
|
960
|
+
|
|
961
|
+
# Phase B — sub-issue parent links
|
|
962
|
+
for op in ops:
|
|
963
|
+
cycle_id = op["cycle_id"]
|
|
964
|
+
parent_id = op["parent_cycle"]
|
|
965
|
+
if not parent_id:
|
|
966
|
+
continue
|
|
967
|
+
|
|
968
|
+
# Orphan-parent fail-soft: skip the link silently apart from a warn line.
|
|
969
|
+
if not op["parent_doc_exists"]:
|
|
970
|
+
if dry_run:
|
|
971
|
+
# Already printed during Phase A dry-run; avoid duplication.
|
|
972
|
+
continue
|
|
973
|
+
print(
|
|
974
|
+
f"[warn] parent {parent_id} has no cycle doc; "
|
|
975
|
+
f"sub-issue link skipped for {cycle_id}"
|
|
976
|
+
)
|
|
977
|
+
continue
|
|
978
|
+
|
|
979
|
+
if dry_run:
|
|
980
|
+
continue # printed in Phase A
|
|
981
|
+
|
|
982
|
+
child_num = cycle_to_issue.get(cycle_id)
|
|
983
|
+
parent_num = cycle_to_issue.get(parent_id)
|
|
984
|
+
if child_num is None or parent_num is None:
|
|
985
|
+
print(
|
|
986
|
+
f"[warn] cycle {cycle_id}: cannot link to parent {parent_id} — "
|
|
987
|
+
f"missing tracker issue (child#={child_num}, parent#={parent_num})"
|
|
988
|
+
)
|
|
989
|
+
continue
|
|
990
|
+
|
|
991
|
+
try:
|
|
992
|
+
gh_link_sub_issue(parent_num, child_num)
|
|
993
|
+
print(f"[link] #{child_num} (cycle {cycle_id}) → parent #{parent_num} (cycle {parent_id})")
|
|
994
|
+
except subprocess.CalledProcessError as e:
|
|
995
|
+
stderr = e.stderr or ""
|
|
996
|
+
if is_already_linked_subissue_error(stderr):
|
|
997
|
+
# Codex P2 review on PR for #1998: GitHub returns the same
|
|
998
|
+
# conjunctive error ("Issue may not contain duplicate
|
|
999
|
+
# sub-issues and Sub issue may only have one parent")
|
|
1000
|
+
# whether the child is already linked to the SAME parent
|
|
1001
|
+
# we want (true idempotency) or to a DIFFERENT parent (real
|
|
1002
|
+
# error — wiring drift). Verify the current parent matches
|
|
1003
|
+
# before downgrading to skip-link.
|
|
1004
|
+
try:
|
|
1005
|
+
current_parent_num = gh_get_sub_issue_parent_number(child_num)
|
|
1006
|
+
except subprocess.CalledProcessError as verify_err:
|
|
1007
|
+
# If the verification call itself fails, treat as a
|
|
1008
|
+
# real error (fail-safe — don't paper over).
|
|
1009
|
+
print(
|
|
1010
|
+
f"[error-link] cycle {cycle_id} → cycle {parent_id}: "
|
|
1011
|
+
f"addSubIssue reported already-linked; parent verification "
|
|
1012
|
+
f"failed: {(verify_err.stderr or '').strip()}"
|
|
1013
|
+
)
|
|
1014
|
+
failures.append((
|
|
1015
|
+
cycle_id,
|
|
1016
|
+
f"sub-issue link failed (parent verification unavailable): {stderr.strip()}",
|
|
1017
|
+
))
|
|
1018
|
+
continue
|
|
1019
|
+
if current_parent_num == parent_num:
|
|
1020
|
+
print(
|
|
1021
|
+
f"[skip-link] cycle {cycle_id} → cycle {parent_id} "
|
|
1022
|
+
f"(already linked to intended parent #{parent_num})"
|
|
1023
|
+
)
|
|
1024
|
+
else:
|
|
1025
|
+
print(
|
|
1026
|
+
f"[error-link] cycle {cycle_id} → cycle {parent_id}: "
|
|
1027
|
+
f"child #{child_num} is linked to a DIFFERENT parent "
|
|
1028
|
+
f"#{current_parent_num} (intended #{parent_num}); "
|
|
1029
|
+
f"manual repair required"
|
|
1030
|
+
)
|
|
1031
|
+
failures.append((
|
|
1032
|
+
cycle_id,
|
|
1033
|
+
f"sub-issue link drift: child #{child_num} linked to "
|
|
1034
|
+
f"parent #{current_parent_num}, intended #{parent_num}",
|
|
1035
|
+
))
|
|
1036
|
+
else:
|
|
1037
|
+
print(f"[error-link] cycle {cycle_id} → cycle {parent_id}: {stderr.strip()}")
|
|
1038
|
+
failures.append((cycle_id, f"sub-issue link failed: {stderr.strip()}"))
|
|
1039
|
+
|
|
1040
|
+
# Phase C — custom-field population
|
|
1041
|
+
if not issues_only and not dry_run:
|
|
1042
|
+
if not cycle_to_issue:
|
|
1043
|
+
print("[fields] no trackers resolved; skipping field population")
|
|
1044
|
+
else:
|
|
1045
|
+
try:
|
|
1046
|
+
meta = get_project_metadata()
|
|
1047
|
+
except subprocess.CalledProcessError as e:
|
|
1048
|
+
print(f"[error] project metadata fetch failed: {(e.stderr or '').strip()}")
|
|
1049
|
+
failures.append(("*", f"project metadata fetch failed: {(e.stderr or '').strip()}"))
|
|
1050
|
+
meta = None
|
|
1051
|
+
|
|
1052
|
+
if meta:
|
|
1053
|
+
for op in ops:
|
|
1054
|
+
cycle_id = op["cycle_id"]
|
|
1055
|
+
issue_num = cycle_to_issue.get(cycle_id)
|
|
1056
|
+
if not issue_num:
|
|
1057
|
+
continue
|
|
1058
|
+
try:
|
|
1059
|
+
if not populate_fields_for_cycle(op, issue_num, meta):
|
|
1060
|
+
failures.append((cycle_id, f"field population failed for #{issue_num}"))
|
|
1061
|
+
except subprocess.CalledProcessError as e:
|
|
1062
|
+
print(f"[error] cycle {cycle_id}: field set failed: {(e.stderr or '').strip()}")
|
|
1063
|
+
failures.append((cycle_id, f"field set failed: {(e.stderr or '').strip()}"))
|
|
1064
|
+
|
|
1065
|
+
if failures:
|
|
1066
|
+
print(f"\n[summary] {len(failures)} failure(s):")
|
|
1067
|
+
for cycle, reason in failures:
|
|
1068
|
+
print(f" - cycle {cycle}: {reason}")
|
|
1069
|
+
return 1
|
|
1070
|
+
return 0
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def main() -> int:
|
|
1074
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1075
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
1076
|
+
help="Hermetic: print planned ops without invoking gh.")
|
|
1077
|
+
parser.add_argument("--issues-only", action="store_true",
|
|
1078
|
+
help="Skip Phase C (project field population); only "
|
|
1079
|
+
"ensure trackers exist, are correctly labeled, "
|
|
1080
|
+
"and wired into sub-issue trees.")
|
|
1081
|
+
parser.add_argument("--cycles-dir", type=Path, default=None,
|
|
1082
|
+
help="Cycle docs directory (defaults to "
|
|
1083
|
+
"docs/roadmap/cycles relative to cwd).")
|
|
1084
|
+
args = parser.parse_args()
|
|
1085
|
+
|
|
1086
|
+
grouped = discover_cycle_docs(cycles_dir=args.cycles_dir)
|
|
1087
|
+
parent_doc_exists_lookup = {cid: True for cid in grouped.keys()}
|
|
1088
|
+
ops, collisions = plan_operations(grouped, parent_doc_exists_lookup)
|
|
1089
|
+
return execute(ops, collisions, dry_run=args.dry_run, issues_only=args.issues_only)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
if __name__ == "__main__":
|
|
1093
|
+
sys.exit(main())
|