@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,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":"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"}
|