@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.
Files changed (132) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +150 -0
  3. package/dist/adapters/_shared.d.ts +45 -0
  4. package/dist/adapters/_shared.d.ts.map +1 -0
  5. package/dist/adapters/_shared.js +200 -0
  6. package/dist/adapters/_shared.js.map +1 -0
  7. package/dist/adapters/codex-sdk.d.ts +279 -0
  8. package/dist/adapters/codex-sdk.d.ts.map +1 -0
  9. package/dist/adapters/codex-sdk.js +930 -0
  10. package/dist/adapters/codex-sdk.js.map +1 -0
  11. package/dist/adapters/critic-result-schema.d.ts +215 -0
  12. package/dist/adapters/critic-result-schema.d.ts.map +1 -0
  13. package/dist/adapters/critic-result-schema.js +153 -0
  14. package/dist/adapters/critic-result-schema.js.map +1 -0
  15. package/dist/adapters/critic.d.ts +62 -0
  16. package/dist/adapters/critic.d.ts.map +1 -0
  17. package/dist/adapters/critic.js +79 -0
  18. package/dist/adapters/critic.js.map +1 -0
  19. package/dist/adapters/cursor-sdk.d.ts +153 -0
  20. package/dist/adapters/cursor-sdk.d.ts.map +1 -0
  21. package/dist/adapters/cursor-sdk.js +818 -0
  22. package/dist/adapters/cursor-sdk.js.map +1 -0
  23. package/dist/adapters/gemini-sdk.d.ts +113 -0
  24. package/dist/adapters/gemini-sdk.d.ts.map +1 -0
  25. package/dist/adapters/gemini-sdk.js +532 -0
  26. package/dist/adapters/gemini-sdk.js.map +1 -0
  27. package/dist/adapters/grok-direct-sdk.d.ts +148 -0
  28. package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
  29. package/dist/adapters/grok-direct-sdk.js +694 -0
  30. package/dist/adapters/grok-direct-sdk.js.map +1 -0
  31. package/dist/branch-protection/audit_branch_protection.py +759 -0
  32. package/dist/branch-protection/index.d.ts +25 -0
  33. package/dist/branch-protection/index.d.ts.map +1 -0
  34. package/dist/branch-protection/index.js +70 -0
  35. package/dist/branch-protection/index.js.map +1 -0
  36. package/dist/branch-protection/spec-default.yaml +314 -0
  37. package/dist/cli.d.ts +3 -0
  38. package/dist/cli.d.ts.map +1 -0
  39. package/dist/cli.js +581 -0
  40. package/dist/cli.js.map +1 -0
  41. package/dist/cycle-doc-validator/index.d.ts +43 -0
  42. package/dist/cycle-doc-validator/index.d.ts.map +1 -0
  43. package/dist/cycle-doc-validator/index.js +69 -0
  44. package/dist/cycle-doc-validator/index.js.map +1 -0
  45. package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
  46. package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
  47. package/dist/cycle-tracker-sync/index.d.ts +20 -0
  48. package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
  49. package/dist/cycle-tracker-sync/index.js +71 -0
  50. package/dist/cycle-tracker-sync/index.js.map +1 -0
  51. package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
  52. package/dist/evidence/audit-trail.d.ts +59 -0
  53. package/dist/evidence/audit-trail.d.ts.map +1 -0
  54. package/dist/evidence/audit-trail.js +283 -0
  55. package/dist/evidence/audit-trail.js.map +1 -0
  56. package/dist/evidence/index.d.ts +4 -0
  57. package/dist/evidence/index.d.ts.map +1 -0
  58. package/dist/evidence/index.js +29 -0
  59. package/dist/evidence/index.js.map +1 -0
  60. package/dist/evidence/per-sha.d.ts +13 -0
  61. package/dist/evidence/per-sha.d.ts.map +1 -0
  62. package/dist/evidence/per-sha.js +97 -0
  63. package/dist/evidence/per-sha.js.map +1 -0
  64. package/dist/evidence/quality-gates.d.ts +19 -0
  65. package/dist/evidence/quality-gates.d.ts.map +1 -0
  66. package/dist/evidence/quality-gates.js +212 -0
  67. package/dist/evidence/quality-gates.js.map +1 -0
  68. package/dist/git.d.ts +40 -0
  69. package/dist/git.d.ts.map +1 -0
  70. package/dist/git.js +414 -0
  71. package/dist/git.js.map +1 -0
  72. package/dist/glob.d.ts +4 -0
  73. package/dist/glob.d.ts.map +1 -0
  74. package/dist/glob.js +99 -0
  75. package/dist/glob.js.map +1 -0
  76. package/dist/index.d.ts +17 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +33 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/paths.d.ts +12 -0
  81. package/dist/paths.d.ts.map +1 -0
  82. package/dist/paths.js +42 -0
  83. package/dist/paths.js.map +1 -0
  84. package/dist/policy/baseline.d.ts +15 -0
  85. package/dist/policy/baseline.d.ts.map +1 -0
  86. package/dist/policy/baseline.js +115 -0
  87. package/dist/policy/baseline.js.map +1 -0
  88. package/dist/policy/config.d.ts +64 -0
  89. package/dist/policy/config.d.ts.map +1 -0
  90. package/dist/policy/config.js +363 -0
  91. package/dist/policy/config.js.map +1 -0
  92. package/dist/policy/gate.d.ts +80 -0
  93. package/dist/policy/gate.d.ts.map +1 -0
  94. package/dist/policy/gate.js +1019 -0
  95. package/dist/policy/gate.js.map +1 -0
  96. package/dist/policy/index.d.ts +7 -0
  97. package/dist/policy/index.d.ts.map +1 -0
  98. package/dist/policy/index.js +14 -0
  99. package/dist/policy/index.js.map +1 -0
  100. package/dist/policy/merge-queue.d.ts +183 -0
  101. package/dist/policy/merge-queue.d.ts.map +1 -0
  102. package/dist/policy/merge-queue.js +310 -0
  103. package/dist/policy/merge-queue.js.map +1 -0
  104. package/dist/policy/profile.d.ts +98 -0
  105. package/dist/policy/profile.d.ts.map +1 -0
  106. package/dist/policy/profile.js +156 -0
  107. package/dist/policy/profile.js.map +1 -0
  108. package/dist/policy/tdd-classifier.d.ts +18 -0
  109. package/dist/policy/tdd-classifier.d.ts.map +1 -0
  110. package/dist/policy/tdd-classifier.js +79 -0
  111. package/dist/policy/tdd-classifier.js.map +1 -0
  112. package/dist/prompt.d.ts +13 -0
  113. package/dist/prompt.d.ts.map +1 -0
  114. package/dist/prompt.js +175 -0
  115. package/dist/prompt.js.map +1 -0
  116. package/dist/report.d.ts +88 -0
  117. package/dist/report.d.ts.map +1 -0
  118. package/dist/report.js +376 -0
  119. package/dist/report.js.map +1 -0
  120. package/dist/runner.d.ts +30 -0
  121. package/dist/runner.d.ts.map +1 -0
  122. package/dist/runner.js +456 -0
  123. package/dist/runner.js.map +1 -0
  124. package/dist/security.d.ts +2 -0
  125. package/dist/security.d.ts.map +1 -0
  126. package/dist/security.js +19 -0
  127. package/dist/security.js.map +1 -0
  128. package/dist/trusted-surface/rebind.d.ts +10 -0
  129. package/dist/trusted-surface/rebind.d.ts.map +1 -0
  130. package/dist/trusted-surface/rebind.js +154 -0
  131. package/dist/trusted-surface/rebind.js.map +1 -0
  132. 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())