@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,384 @@
1
+ """Write PR↔cycle attribution into the GH Project's `Cycle Ref` text field.
2
+
3
+ Called from `.github/workflows/cycle-board.yml` for every PR event. Parses
4
+ the `Cycle: <N>` trailer from the PR body + commit messages (reusing the
5
+ regex from `validate_cycle_doc.py`), then writes that cycle ID to the
6
+ PR's project item via `updateProjectV2ItemFieldValue` with a TEXT input.
7
+
8
+ Failure model — fail-loud (per Cycle 326 spec § Commit 6):
9
+ - No `Cycle:` trailer found → exit 0 (not every PR is cycle-tracked;
10
+ dependabot bumps, drift fixes, etc. live without a Cycle: trailer).
11
+ - PROJECT_TOKEN missing/invalid → exit 1 with `::error::` annotation.
12
+ - `Cycle Ref` field doesn't exist on the project → exit 1, `::error::`.
13
+ - addProjectV2ItemById fails (the same drift mode cycle 323 closed
14
+ on the existing `Add PR to project board` step) → exit 1, `::error::`.
15
+ - updateProjectV2ItemFieldValue rejects the mutation → exit 1, `::error::`.
16
+
17
+ Structural independence from the existing `Add PR to project board` step:
18
+ this script does its OWN `addProjectV2ItemById` (idempotent — returns the
19
+ existing item if already added), so the workflow's `continue-on-error:
20
+ true` on the existing step doesn't suppress this one.
21
+
22
+ Inputs (env vars; workflow sets):
23
+ PR_NUMBER — the PR number being attributed.
24
+ PR_NODE_ID — the PR's node ID (from
25
+ `${{ github.event.pull_request.node_id }}` or via
26
+ a graphql lookup on workflow_run events — both
27
+ paths supported via the workflow YAML).
28
+ PR_BODY_FILE — path to a file containing the PR body text.
29
+ Preferred over PR_BODY because it sidesteps
30
+ heredoc-delimiter-injection on GITHUB_OUTPUT.
31
+ The workflow writes the API-fetched body to
32
+ ${RUNNER_TEMP}/pr-${PR_NUMBER}-body.txt and
33
+ passes the path.
34
+ PR_BODY — fallback: the PR body text as a string. Used
35
+ only when PR_BODY_FILE is unset or unreadable.
36
+ PROJECT_TOKEN — classic PAT with `project` scope.
37
+ PROJECT_OWNER — defaults to 'momentiq-ai'.
38
+ PROJECT_NUMBER — defaults to '2'.
39
+ REPO — defaults to 'momentiq-ai/sage3c'. Used to fetch
40
+ commit messages.
41
+
42
+ Outputs: prints what it did to stdout; emits `::error::` on failures.
43
+
44
+ Spec: docs/superpowers/specs/2026-05-17-gh-project-continuous-sync.md § Commit 6
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import os
50
+ import re
51
+ import subprocess
52
+ import sys
53
+ from typing import Optional
54
+
55
+
56
+ # ─────────────────────────────────────────────────────────────────────────
57
+ # Token-scope split (per Codex P2 review on Plan PR #1562)
58
+ # ─────────────────────────────────────────────────────────────────────────
59
+ #
60
+ # Two tokens are passed in by `cycle-board.yml`:
61
+ # GH_TOKEN — already swapped to PROJECT_TOKEN for project mutations
62
+ # (`secrets.PROJECT_TOKEN || secrets.GITHUB_TOKEN`).
63
+ # PROJECT_TOKEN is a classic PAT with `project` scope
64
+ # ONLY — it cannot read `repos/{repo}/pulls/{pr}/commits`.
65
+ # GITHUB_TOKEN_REPO — repo-scoped token, always present in CI as the
66
+ # default `secrets.GITHUB_TOKEN`. Used for PR/commits
67
+ # reads (issues/pulls API).
68
+ #
69
+ # The helpers below construct subprocess envs with the right token for
70
+ # each call site so we never write to projects with GITHUB_TOKEN and never
71
+ # read repo data with PROJECT_TOKEN.
72
+
73
+ def _repo_env() -> dict:
74
+ """Env for repo-scoped reads (pulls/commits API)."""
75
+ env = os.environ.copy()
76
+ repo_token = (
77
+ os.environ.get("GITHUB_TOKEN_REPO")
78
+ or os.environ.get("GITHUB_TOKEN")
79
+ )
80
+ if repo_token:
81
+ env["GH_TOKEN"] = repo_token
82
+ # Remove PROJECT_TOKEN so a misconfigured subprocess can't fall
83
+ # back to it.
84
+ env.pop("PROJECT_TOKEN", None)
85
+ return env
86
+
87
+
88
+ def _project_env() -> dict:
89
+ """Env for ProjectV2 mutations.
90
+
91
+ Prefers PROJECT_TOKEN explicitly so the `project` scope is in effect.
92
+ Falls back to GH_TOKEN if PROJECT_TOKEN is unset (single-token mode).
93
+ """
94
+ env = os.environ.copy()
95
+ project_token = os.environ.get("PROJECT_TOKEN")
96
+ if project_token:
97
+ env["GH_TOKEN"] = project_token
98
+ return env
99
+
100
+
101
+ # Trailer regex — same shape as validate_cycle_doc.py:TRAILER_RE.
102
+ _TRAILER_RE = re.compile(
103
+ r"^\s*(?P<key>[A-Za-z][A-Za-z0-9-]*):\s*(?P<value>.+?)\s*$",
104
+ re.MULTILINE,
105
+ )
106
+
107
+
108
+ def parse_cycle_trailer(text: str) -> Optional[str]:
109
+ """Return the LAST `Cycle:` trailer value in the text, normalized to
110
+ a dotted-decimal id (e.g. `318.4`), or None.
111
+
112
+ Last-write-wins matches validate_cycle_doc.py:parse_trailers.
113
+ Normalization strips a leading `Cycle ` word and any text after the
114
+ leading cycle-id token.
115
+ """
116
+ last_value: Optional[str] = None
117
+ for m in _TRAILER_RE.finditer(text or ""):
118
+ if m.group("key").lower() == "cycle":
119
+ last_value = m.group("value").strip()
120
+ if not last_value:
121
+ return None
122
+ # Normalize: strip leading "Cycle " word, accept only the leading
123
+ # dotted-decimal token.
124
+ candidate = re.sub(r"^[Cc]ycle\s+", "", last_value).strip()
125
+ m = re.match(r"(\d+(?:\.\d+)*)", candidate)
126
+ return m.group(1) if m else None
127
+
128
+
129
+ def fetch_commit_messages(repo: str, pr_number: int) -> str:
130
+ """Fetch PR commit messages concatenated with newlines. Used as the
131
+ body-extension input for parse_cycle_trailer.
132
+
133
+ Failure here is non-fatal: log a warning and return empty string,
134
+ falling back to PR body only. Trailer convention says the trailer
135
+ lives on the PR body or last commit — body-only is still a valid
136
+ surface.
137
+ """
138
+ try:
139
+ result = subprocess.run(
140
+ ["gh", "api", "--paginate",
141
+ f"repos/{repo}/pulls/{pr_number}/commits"],
142
+ capture_output=True, text=True, check=True, timeout=30,
143
+ env=_repo_env(),
144
+ )
145
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
146
+ msg = getattr(e, 'stderr', None) or str(e)
147
+ print(f"::warning::commit fetch failed for PR #{pr_number}: {msg}")
148
+ return ""
149
+ parts: list[str] = []
150
+ decoder = json.JSONDecoder()
151
+ raw = result.stdout
152
+ idx = 0
153
+ while idx < len(raw):
154
+ raw_slice = raw[idx:].lstrip()
155
+ if not raw_slice:
156
+ break
157
+ offset = len(raw) - len(raw_slice)
158
+ try:
159
+ val, end = decoder.raw_decode(raw, offset)
160
+ except json.JSONDecodeError:
161
+ break
162
+ if isinstance(val, list):
163
+ for entry in val:
164
+ m = (entry or {}).get("commit", {}).get("message")
165
+ if isinstance(m, str) and m.strip():
166
+ parts.append(m.rstrip())
167
+ idx = end
168
+ return "\n\n".join(parts)
169
+
170
+
171
+ def lookup_project_id(owner: str, number: int) -> Optional[str]:
172
+ """Resolve org/user → project number → project node id.
173
+
174
+ Uses `repositoryOwner` so both User and Organization owners resolve.
175
+ """
176
+ query = (
177
+ 'query($owner: String!, $number: Int!) { '
178
+ ' repositoryOwner(login: $owner) { '
179
+ ' ... on ProjectV2Owner { projectV2(number: $number) { id } } '
180
+ ' } '
181
+ '}'
182
+ )
183
+ try:
184
+ result = subprocess.run(
185
+ ["gh", "api", "graphql",
186
+ "-f", f"query={query}",
187
+ "-f", f"owner={owner}",
188
+ "-F", f"number={number}",
189
+ "--jq", ".data.repositoryOwner.projectV2.id"],
190
+ capture_output=True, text=True, check=True, timeout=30,
191
+ env=_project_env(),
192
+ )
193
+ except subprocess.CalledProcessError as e:
194
+ print(f"::error::project lookup failed: {(e.stderr or '').strip()}")
195
+ return None
196
+ pid = result.stdout.strip()
197
+ if not pid or pid == "null":
198
+ print(f"::error::project lookup returned empty id for {owner}/projects/{number}")
199
+ return None
200
+ return pid
201
+
202
+
203
+ def lookup_cycle_ref_field_id(project_id: str) -> Optional[str]:
204
+ """Get the field ID of the `Cycle Ref` custom text field.
205
+
206
+ Returns None and logs `::error::` if the field doesn't exist; the
207
+ operator pre-step (gh project field-create) is documented in the
208
+ Cycle 326 PR description.
209
+ """
210
+ query = (
211
+ 'query($projectId: ID!) { '
212
+ ' node(id: $projectId) { '
213
+ ' ... on ProjectV2 { '
214
+ ' field(name: "Cycle Ref") { ... on ProjectV2Field { id } } '
215
+ ' } '
216
+ ' } '
217
+ '}'
218
+ )
219
+ try:
220
+ result = subprocess.run(
221
+ ["gh", "api", "graphql",
222
+ "-f", f"query={query}",
223
+ "-f", f"projectId={project_id}",
224
+ "--jq", ".data.node.field.id"],
225
+ capture_output=True, text=True, check=True, timeout=30,
226
+ env=_project_env(),
227
+ )
228
+ except subprocess.CalledProcessError as e:
229
+ print(f"::error::Cycle Ref field lookup failed: {(e.stderr or '').strip()}. "
230
+ "Verify the field exists: gh project field-list 2 --owner momentiq-ai")
231
+ return None
232
+ fid = result.stdout.strip()
233
+ if not fid or fid == "null":
234
+ print("::error::Cycle Ref field not found on the project. "
235
+ "Create it: gh project field-create 2 --owner momentiq-ai --name 'Cycle Ref' --data-type TEXT")
236
+ return None
237
+ return fid
238
+
239
+
240
+ def ensure_item_id_for_pr(project_id: str, pr_node_id: str) -> Optional[str]:
241
+ """Idempotent project-item add. Returns the item ID.
242
+
243
+ Independent of the existing `Add PR to project board` workflow step
244
+ — same mutation but our own call (per spec § Commit 6: structural
245
+ independence prevents the existing step's continue-on-error: true
246
+ from suppressing this script).
247
+ """
248
+ mutation = (
249
+ 'mutation($projectId: ID!, $contentId: ID!) { '
250
+ ' addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { '
251
+ ' item { id } '
252
+ ' } '
253
+ '}'
254
+ )
255
+ try:
256
+ result = subprocess.run(
257
+ ["gh", "api", "graphql",
258
+ "-f", f"query={mutation}",
259
+ "-f", f"projectId={project_id}",
260
+ "-f", f"contentId={pr_node_id}",
261
+ "--jq", ".data.addProjectV2ItemById.item.id"],
262
+ capture_output=True, text=True, check=True, timeout=30,
263
+ env=_project_env(),
264
+ )
265
+ except subprocess.CalledProcessError as e:
266
+ print(f"::error::addProjectV2ItemById failed for PR node {pr_node_id}: "
267
+ f"{(e.stderr or '').strip()}. Verify PROJECT_TOKEN scope and Doppler sync (sage/dev:PROJECT_TOKEN).")
268
+ return None
269
+ item_id = result.stdout.strip()
270
+ if not item_id or item_id == "null":
271
+ print(f"::error::addProjectV2ItemById returned empty item id for PR node {pr_node_id}")
272
+ return None
273
+ return item_id
274
+
275
+
276
+ def write_cycle_ref(project_id: str, item_id: str, field_id: str, cycle_id: str) -> bool:
277
+ """Write the cycle id (e.g. '326') to the Cycle Ref text field."""
278
+ mutation = (
279
+ 'mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $text: String!) { '
280
+ ' updateProjectV2ItemFieldValue(input: { '
281
+ ' projectId: $projectId, '
282
+ ' itemId: $itemId, '
283
+ ' fieldId: $fieldId, '
284
+ ' value: { text: $text } '
285
+ ' }) { '
286
+ ' projectV2Item { id } '
287
+ ' } '
288
+ '}'
289
+ )
290
+ try:
291
+ subprocess.run(
292
+ ["gh", "api", "graphql",
293
+ "-f", f"query={mutation}",
294
+ "-f", f"projectId={project_id}",
295
+ "-f", f"itemId={item_id}",
296
+ "-f", f"fieldId={field_id}",
297
+ "-f", f"text={cycle_id}"],
298
+ capture_output=True, text=True, check=True, timeout=30,
299
+ env=_project_env(),
300
+ )
301
+ return True
302
+ except subprocess.CalledProcessError as e:
303
+ print(f"::error::Cycle Ref write failed: {(e.stderr or '').strip()}")
304
+ return False
305
+
306
+
307
+ def _load_pr_body() -> str:
308
+ """Resolve the PR body from PR_BODY_FILE (preferred) or PR_BODY.
309
+
310
+ PR_BODY_FILE is the safe path: the workflow writes the API-fetched
311
+ PR body to a temp file and passes the path, sidestepping
312
+ heredoc-delimiter-injection on GITHUB_OUTPUT.
313
+
314
+ PR_BODY is kept as a fallback for callers that still pass body text
315
+ inline (e.g. unit tests, manual invocations).
316
+ """
317
+ body_file = os.environ.get("PR_BODY_FILE", "").strip()
318
+ if body_file:
319
+ try:
320
+ with open(body_file, "r", encoding="utf-8") as f:
321
+ return f.read()
322
+ except OSError as e:
323
+ print(f"::warning::PR_BODY_FILE='{body_file}' unreadable ({e}); falling back to PR_BODY")
324
+ return os.environ.get("PR_BODY", "") or ""
325
+
326
+
327
+ def main() -> int:
328
+ pr_number_raw = os.environ.get("PR_NUMBER", "")
329
+ pr_node_id = os.environ.get("PR_NODE_ID", "")
330
+ pr_body = _load_pr_body()
331
+ repo = os.environ.get("REPO", "momentiq-ai/sage3c")
332
+ project_owner = os.environ.get("PROJECT_OWNER", "momentiq-ai")
333
+ try:
334
+ project_number = int(os.environ.get("PROJECT_NUMBER", "2"))
335
+ except ValueError:
336
+ print("::error::PROJECT_NUMBER must be an integer")
337
+ return 1
338
+
339
+ if not pr_number_raw:
340
+ print("::error::PR_NUMBER env var is empty")
341
+ return 1
342
+ try:
343
+ pr_number = int(pr_number_raw)
344
+ except ValueError:
345
+ print(f"::error::PR_NUMBER must be an integer, got '{pr_number_raw}'")
346
+ return 1
347
+
348
+ if not pr_node_id:
349
+ print("::error::PR_NODE_ID env var is empty")
350
+ return 1
351
+
352
+ # Parse Cycle: trailer from body + commits.
353
+ commit_text = fetch_commit_messages(repo, pr_number)
354
+ combined = "\n\n".join(p for p in (commit_text, pr_body) if p)
355
+ cycle_id = parse_cycle_trailer(combined)
356
+
357
+ if cycle_id is None:
358
+ # Not every PR is cycle-tracked — see validate_cycle_doc.py
359
+ # multi-anchor model. No trailer = no attribution needed.
360
+ print(f"[cycle-ref] PR #{pr_number}: no Cycle: trailer found; skipping attribution")
361
+ return 0
362
+
363
+ project_id = lookup_project_id(project_owner, project_number)
364
+ if not project_id:
365
+ return 1
366
+
367
+ field_id = lookup_cycle_ref_field_id(project_id)
368
+ if not field_id:
369
+ return 1
370
+
371
+ item_id = ensure_item_id_for_pr(project_id, pr_node_id)
372
+ if not item_id:
373
+ return 1
374
+
375
+ if not write_cycle_ref(project_id, item_id, field_id, cycle_id):
376
+ return 1
377
+
378
+ print(f"[cycle-ref] PR #{pr_number}: Cycle Ref set to '{cycle_id}' "
379
+ f"(item {item_id} on project {project_owner}/{project_number})")
380
+ return 0
381
+
382
+
383
+ if __name__ == "__main__":
384
+ sys.exit(main())
@@ -0,0 +1,20 @@
1
+ export interface SyncCycleTrackersOptions {
2
+ /** Extra argv passed to the Python script. */
3
+ readonly args?: ReadonlyArray<string>;
4
+ readonly cwd?: string;
5
+ readonly env?: NodeJS.ProcessEnv;
6
+ readonly inheritStdio?: boolean;
7
+ readonly python?: string;
8
+ }
9
+ export interface AttributePrCycleRefOptions extends SyncCycleTrackersOptions {
10
+ }
11
+ export interface PythonScriptResult {
12
+ readonly exitCode: number;
13
+ readonly stdout: string;
14
+ readonly stderr: string;
15
+ }
16
+ export declare function getSyncCycleTrackersScriptPath(): string;
17
+ export declare function getAttributePrCycleRefScriptPath(): string;
18
+ export declare function runSyncCycleTrackers(options?: SyncCycleTrackersOptions): Promise<PythonScriptResult>;
19
+ export declare function runAttributePrCycleRef(options?: AttributePrCycleRefOptions): Promise<PythonScriptResult>;
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cycle-tracker-sync/index.ts"],"names":[],"mappings":"AA0BA,MAAM,WAAW,wBAAwB;IACvC,8CAA8C;IAC9C,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACjC,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,0BAA2B,SAAQ,wBAAwB;CAAG;AAE/E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,8BAA8B,IAAI,MAAM,CAGvD;AAED,wBAAgB,gCAAgC,IAAI,MAAM,CAGzD;AAuCD,wBAAgB,oBAAoB,CAClC,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,kBAAkB,CAAC,CAE7B;AAED,wBAAgB,sBAAsB,CACpC,OAAO,GAAE,0BAA+B,GACvC,OAAO,CAAC,kBAAkB,CAAC,CAE7B"}
@@ -0,0 +1,71 @@
1
+ // Service #9 — Cycle Tracker Sync + PR Attribution
2
+ //
3
+ // Wraps two bundled Python scripts (Phase C extraction from sage3c per
4
+ // cycle 331.1):
5
+ //
6
+ // - `sync_cycle_trackers.py` — discovers cycle docs by glob and
7
+ // reconciles GitHub-tracker-issue state on every run. Used by the
8
+ // `cycle-tracker-sync` reusable workflow (push on cycle-doc paths +
9
+ // daily cron + manual dispatch).
10
+ // - `attribute_pr_cycle_ref.py` — parses the `Cycle: <N>` trailer from
11
+ // PR body + commit messages and writes the cycle ID into the PR's
12
+ // project item via `updateProjectV2ItemFieldValue` with a TEXT input
13
+ // (the `Cycle Ref` custom field). Used by the `cycle-board.yml`
14
+ // workflow on every PR event.
15
+ //
16
+ // Both scripts shell out to `gh` for GitHub API calls; require `gh`
17
+ // available on `PATH` + an authenticated session (PROJECT_TOKEN env var
18
+ // in CI, or a logged-in `gh auth status` locally).
19
+ //
20
+ // Design choice: subprocess wrap (Option A). Phase C-PORT follow-up
21
+ // tracks the pure-TS rewrite.
22
+ import { spawn } from "node:child_process";
23
+ import { dirname, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ export function getSyncCycleTrackersScriptPath() {
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ return resolve(here, "sync_cycle_trackers.py");
28
+ }
29
+ export function getAttributePrCycleRefScriptPath() {
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ return resolve(here, "attribute_pr_cycle_ref.py");
32
+ }
33
+ function spawnPython(scriptPath, options) {
34
+ const python = options.python ?? "python3";
35
+ const argv = [scriptPath, ...(options.args ?? [])];
36
+ const cwd = options.cwd ?? process.cwd();
37
+ const inherit = options.inheritStdio !== false;
38
+ const env = { ...process.env, ...(options.env ?? {}) };
39
+ return new Promise((resolvePromise, rejectPromise) => {
40
+ const child = spawn(python, argv, {
41
+ cwd,
42
+ env,
43
+ stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
44
+ });
45
+ let stdout = "";
46
+ let stderr = "";
47
+ if (!inherit) {
48
+ child.stdout?.on("data", (chunk) => {
49
+ stdout += chunk.toString("utf8");
50
+ });
51
+ child.stderr?.on("data", (chunk) => {
52
+ stderr += chunk.toString("utf8");
53
+ });
54
+ }
55
+ child.on("error", (err) => rejectPromise(err));
56
+ child.on("close", (code) => {
57
+ resolvePromise({
58
+ exitCode: code === null ? -1 : code,
59
+ stdout,
60
+ stderr,
61
+ });
62
+ });
63
+ });
64
+ }
65
+ export function runSyncCycleTrackers(options = {}) {
66
+ return spawnPython(getSyncCycleTrackersScriptPath(), options);
67
+ }
68
+ export function runAttributePrCycleRef(options = {}) {
69
+ return spawnPython(getAttributePrCycleRefScriptPath(), options);
70
+ }
71
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cycle-tracker-sync/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,EAAE;AACF,uEAAuE;AACvE,gBAAgB;AAChB,EAAE;AACF,mEAAmE;AACnE,sEAAsE;AACtE,wEAAwE;AACxE,qCAAqC;AACrC,yEAAyE;AACzE,sEAAsE;AACtE,yEAAyE;AACzE,oEAAoE;AACpE,kCAAkC;AAClC,EAAE;AACF,oEAAoE;AACpE,wEAAwE;AACxE,mDAAmD;AACnD,EAAE;AACF,oEAAoE;AACpE,8BAA8B;AAE9B,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAmBzC,MAAM,UAAU,8BAA8B;IAC5C,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,OAAO,OAAO,CAAC,IAAI,EAAE,wBAAwB,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,gCAAgC;IAC9C,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,OAAO,OAAO,CAAC,IAAI,EAAE,2BAA2B,CAAC,CAAC;AACpD,CAAC;AAED,SAAS,WAAW,CAClB,UAAkB,EAClB,OAAiC;IAEjC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,SAAS,CAAC;IAC3C,MAAM,IAAI,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC;IACnD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,KAAK,KAAK,CAAC;IAC/C,MAAM,GAAG,GAAsB,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,IAAI,EAAE,CAAC,EAAE,CAAC;IAE1E,OAAO,IAAI,OAAO,CAAqB,CAAC,cAAc,EAAE,aAAa,EAAE,EAAE;QACvE,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE;YAChC,GAAG;YACH,GAAG;YACH,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;SACxD,CAAC,CAAC;QACH,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;gBACzC,MAAM,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YACnC,CAAC,CAAC,CAAC;YACH,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;gBACzC,MAAM,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YACnC,CAAC,CAAC,CAAC;QACL,CAAC;QACD,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC;QAC/C,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YACzB,cAAc,CAAC;gBACb,QAAQ,EAAE,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI;gBACnC,MAAM;gBACN,MAAM;aACP,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,UAAoC,EAAE;IAEtC,OAAO,WAAW,CAAC,8BAA8B,EAAE,EAAE,OAAO,CAAC,CAAC;AAChE,CAAC;AAED,MAAM,UAAU,sBAAsB,CACpC,UAAsC,EAAE;IAExC,OAAO,WAAW,CAAC,gCAAgC,EAAE,EAAE,OAAO,CAAC,CAAC;AAClE,CAAC"}