@ikon85/agent-workflow-kit 0.38.0 → 0.40.0
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/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +33 -1
- package/.agents/skills/orchestrate-wave/SKILL.md +4 -4
- package/.agents/skills/setup-workflow/SKILL.md +84 -3
- package/.agents/skills/setup-workflow/board-sync.md +6 -2
- package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +54 -3
- package/.agents/skills/wrapup/SKILL.md +24 -0
- package/.claude/hooks/drift-guard.py +212 -21
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/kit-update/SKILL.md +33 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +4 -4
- package/.claude/skills/setup-workflow/SKILL.md +84 -3
- package/.claude/skills/setup-workflow/board-sync.md +6 -2
- package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +54 -3
- package/.claude/skills/wrapup/SKILL.md +24 -0
- package/README.md +62 -0
- package/agent-workflow-kit.package.json +57 -25
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +84 -0
- package/package.json +1 -1
- package/scripts/board_bootstrap.py +367 -0
- package/scripts/kit-update-pr.mjs +20 -7
- package/scripts/kit-update-pr.test.mjs +29 -0
- package/scripts/profile_globs.py +347 -0
- package/scripts/test_board_bootstrap.py +348 -0
- package/scripts/test_drift_guard_diagnostics.py +295 -0
- package/scripts/test_profile_globs.py +280 -0
- package/scripts/test_skill_setup_workflow_seeds.py +87 -0
- package/scripts/test_worktree_ignore_seed.py +320 -0
- package/scripts/test_worktree_wrapup_contract.py +588 -0
- package/scripts/workflow-advisories/core.py +29 -4
- package/scripts/worktree-lifecycle/README.md +53 -4
- package/scripts/worktree-lifecycle/core.py +211 -60
- package/scripts/worktree-lifecycle/ignore_seed.py +226 -0
- package/scripts/worktree-lifecycle/plan-artifacts.json +37 -0
- package/scripts/wrapup-land.py +179 -34
- package/src/cli.mjs +32 -1
- package/src/commands/update.mjs +30 -21
- package/src/consumer-migrations.json +19 -0
- package/src/lib/bundle.mjs +16 -0
- package/src/lib/consumerMigrations.mjs +161 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""board_bootstrap.py — provision a GitHub-Projects board for /setup-workflow.
|
|
3
|
+
|
|
4
|
+
`setup-workflow` Section D used to refuse creation and hand out manual
|
|
5
|
+
instructions. It now OFFERS creation, and this helper owns the mechanical part
|
|
6
|
+
so the outcome is deterministic rather than prose-driven:
|
|
7
|
+
|
|
8
|
+
gh project create # the project shell
|
|
9
|
+
gh project field-create … Status # single-select WITH its options
|
|
10
|
+
gh project field-create … Wave/Cluster/Spec-Path/Plan-Path
|
|
11
|
+
gh project field-list # discover the opaque ids
|
|
12
|
+
|
|
13
|
+
Four properties this file exists to guarantee:
|
|
14
|
+
|
|
15
|
+
* Nothing is created before the destination is writable and `gh auth status`
|
|
16
|
+
proves the write scope — a missing scope can never half-create a board.
|
|
17
|
+
* The profile is written from the READ-BACK, never from what the create calls
|
|
18
|
+
were asked to do. A field that did not survive creation cannot appear in it.
|
|
19
|
+
* A failure anywhere (create, readback, validation) writes NO profile file.
|
|
20
|
+
The caller falls back to the retryable stub path with the board it can see.
|
|
21
|
+
* The Status option NAMES come from the seed profile's `fields.status.roles`,
|
|
22
|
+
never from a literal in this file — a board in another language provisions
|
|
23
|
+
its own stage names.
|
|
24
|
+
|
|
25
|
+
The offer/decline decision itself is NOT here: minting a project is an outward
|
|
26
|
+
action, so the user gate stays with the skill and this helper only runs after an
|
|
27
|
+
explicit yes.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
python3 scripts/board_bootstrap.py preflight [--json]
|
|
31
|
+
python3 scripts/board_bootstrap.py create --owner <owner> --repo <owner>/<repo> \\
|
|
32
|
+
--title "<title>" --seed <seeded board-sync.md> --out docs/agents/board-sync.md \\
|
|
33
|
+
[--dry-run]
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import copy
|
|
39
|
+
import json
|
|
40
|
+
import re
|
|
41
|
+
import shlex
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
import board_config
|
|
47
|
+
|
|
48
|
+
# Field NAMES for a NEW board. These are creation-time conventions (the profile
|
|
49
|
+
# stores ids under fixed keys and never carries a field name), documented in the
|
|
50
|
+
# seeded board-sync.md field table. Discovery matches the readback by them.
|
|
51
|
+
STATUS_FIELD_NAME = "Status"
|
|
52
|
+
WORKFLOW_FIELDS = (
|
|
53
|
+
("wave", "Wave", "NUMBER"),
|
|
54
|
+
("cluster", "Cluster", "TEXT"),
|
|
55
|
+
("specPath", "Spec-Path", "TEXT"),
|
|
56
|
+
("planPath", "Plan-Path", "TEXT"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# `gh project create` + `field-create` need the write scope; the read scope is
|
|
60
|
+
# what every later board read uses, so the remedy asks for both at once.
|
|
61
|
+
REQUIRED_SCOPES = ("project",)
|
|
62
|
+
SCOPE_REMEDY = "gh auth refresh -s project,read:project"
|
|
63
|
+
|
|
64
|
+
SENTINEL = "<!-- setup-workflow: state=filled; mode=github-projects-v2 -->"
|
|
65
|
+
SENTINEL_RE = re.compile(
|
|
66
|
+
r"^<!--\s*setup-workflow:\s*state=(stub|filled|not-applicable)"
|
|
67
|
+
r"(?:;\s*mode=(github-projects-v2|none))?\s*-->\s*$")
|
|
68
|
+
PROFILE_MARKER_RE = re.compile(r"<!--\s*board-sync:profile\s*-->")
|
|
69
|
+
JSON_FENCE_RE = re.compile(r"(```json\s*\n)(.*?)(\n```)", re.DOTALL)
|
|
70
|
+
PLACEHOLDER_RE = re.compile(r"<[^<>\n]*>")
|
|
71
|
+
|
|
72
|
+
EXIT_FAILURE = 1
|
|
73
|
+
EXIT_REFUSED = 2
|
|
74
|
+
EXIT_MISSING_SCOPE = 3
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BootstrapError(RuntimeError):
|
|
78
|
+
"""Any condition that must abort before or instead of writing a profile."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- the gh seam (tests replace `_gh`) ---------------------------------------
|
|
82
|
+
def _gh(args: list[str]) -> str:
|
|
83
|
+
proc = subprocess.run(["gh", *args], capture_output=True, text=True)
|
|
84
|
+
if proc.returncode != 0:
|
|
85
|
+
detail = (proc.stderr or proc.stdout).strip()
|
|
86
|
+
raise BootstrapError(f"`gh {' '.join(args)}` failed: {detail}")
|
|
87
|
+
return proc.stdout
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _gh_json(args: list[str]):
|
|
91
|
+
raw = _gh(args)
|
|
92
|
+
try:
|
|
93
|
+
return json.loads(raw)
|
|
94
|
+
except json.JSONDecodeError as exc:
|
|
95
|
+
raise BootstrapError(f"`gh {' '.join(args)}` did not return JSON: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --- pure decisions ----------------------------------------------------------
|
|
99
|
+
def missing_scopes(auth_status_text: str, required=REQUIRED_SCOPES) -> list[str]:
|
|
100
|
+
"""The required OAuth scopes `gh auth status` does NOT report."""
|
|
101
|
+
granted: set[str] = set()
|
|
102
|
+
for line in auth_status_text.splitlines():
|
|
103
|
+
if "Token scopes:" in line:
|
|
104
|
+
granted |= {part.strip().strip("'\"")
|
|
105
|
+
for part in line.split(":", 1)[1].split(",")}
|
|
106
|
+
return [scope for scope in required if scope not in granted]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def status_option_names(cfg: dict) -> list[str]:
|
|
110
|
+
"""This board's Status option names, read from the seed profile's role map in
|
|
111
|
+
role order (extra consumer roles keep their own order at the end)."""
|
|
112
|
+
roles = board_config.status_roles(cfg)
|
|
113
|
+
ordered = list(board_config.STATUS_ROLE_KEYS)
|
|
114
|
+
ordered += [key for key in roles if key not in ordered]
|
|
115
|
+
names: list[str] = []
|
|
116
|
+
for key in ordered:
|
|
117
|
+
name = roles.get(key)
|
|
118
|
+
if name and name not in names:
|
|
119
|
+
names.append(name)
|
|
120
|
+
if not names:
|
|
121
|
+
raise BootstrapError(
|
|
122
|
+
"the seed profile carries no `fields.status.roles` — fill the role map "
|
|
123
|
+
"with this board's stage names before creating the board.")
|
|
124
|
+
commas = [name for name in names if "," in name]
|
|
125
|
+
if commas:
|
|
126
|
+
raise BootstrapError(
|
|
127
|
+
f"status option name(s) {commas} contain a comma; "
|
|
128
|
+
"`gh project field-create --single-select-options` is comma-separated. "
|
|
129
|
+
"Rename the role value, or create the Status field by hand.")
|
|
130
|
+
return names
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def destination_action(first_line: str | None) -> str:
|
|
134
|
+
"""`create` / `fill` / `refuse` for the destination profile, per the
|
|
135
|
+
setup-workflow idempotency contract: missing or `state=stub` may be written,
|
|
136
|
+
everything else (filled, not-applicable, legacy without sentinel) is owned by
|
|
137
|
+
the consumer and never overwritten."""
|
|
138
|
+
if first_line is None:
|
|
139
|
+
return "create"
|
|
140
|
+
match = SENTINEL_RE.match(first_line.strip())
|
|
141
|
+
if match:
|
|
142
|
+
return "fill" if match.group(1) == "stub" else "refuse"
|
|
143
|
+
return "refuse"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def index_fields(field_list) -> dict:
|
|
147
|
+
fields = field_list.get("fields") if isinstance(field_list, dict) else field_list
|
|
148
|
+
return {f["name"]: f for f in (fields or [])
|
|
149
|
+
if isinstance(f, dict) and f.get("name")}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_profile(seed_cfg: dict, *, repo: str, owner: str, number, node_id: str,
|
|
153
|
+
field_list) -> dict:
|
|
154
|
+
"""The consumer profile assembled from the seed's conventions plus the ids
|
|
155
|
+
read back off the board. Every id is best-effort: what is absent stays
|
|
156
|
+
absent so `validate_profile` can report it instead of a claim being made."""
|
|
157
|
+
by_name = index_fields(field_list)
|
|
158
|
+
profile = copy.deepcopy(seed_cfg)
|
|
159
|
+
profile["repo"] = repo
|
|
160
|
+
profile["project"] = {"number": number, "owner": owner, "nodeId": node_id}
|
|
161
|
+
|
|
162
|
+
status_field = by_name.get(STATUS_FIELD_NAME)
|
|
163
|
+
status = {"options": {}, "roles": copy.deepcopy(board_config.status_roles(seed_cfg))}
|
|
164
|
+
if status_field:
|
|
165
|
+
status["id"] = status_field.get("id")
|
|
166
|
+
status["options"] = {opt["name"]: opt["id"]
|
|
167
|
+
for opt in (status_field.get("options") or [])
|
|
168
|
+
if opt.get("name") and opt.get("id")}
|
|
169
|
+
# A fresh profile carries only what was created and read back — the optional
|
|
170
|
+
# `fields.phase` block stays out (the Program route is opt-in and manual).
|
|
171
|
+
fields = {"status": status}
|
|
172
|
+
for key, name, _ in WORKFLOW_FIELDS:
|
|
173
|
+
field = by_name.get(name)
|
|
174
|
+
if field and field.get("id"):
|
|
175
|
+
fields[key] = field["id"]
|
|
176
|
+
profile["fields"] = fields
|
|
177
|
+
return profile
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def validate_profile(profile: dict) -> list[str]:
|
|
181
|
+
"""Every reason this profile must not be written, as plain sentences."""
|
|
182
|
+
problems: list[str] = []
|
|
183
|
+
fields = profile.get("fields") or {}
|
|
184
|
+
status = fields.get("status") or {}
|
|
185
|
+
options = status.get("options") or {}
|
|
186
|
+
if not status.get("id"):
|
|
187
|
+
problems.append(f"the readback carries no `{STATUS_FIELD_NAME}` field")
|
|
188
|
+
if not options:
|
|
189
|
+
problems.append(f"the readback carries no `{STATUS_FIELD_NAME}` options")
|
|
190
|
+
for role, name in (status.get("roles") or {}).items():
|
|
191
|
+
if name not in options:
|
|
192
|
+
problems.append(
|
|
193
|
+
f"status role `{role}` maps to option {name!r}, which the board does not have")
|
|
194
|
+
for key, name, _ in WORKFLOW_FIELDS:
|
|
195
|
+
if not fields.get(key):
|
|
196
|
+
problems.append(f"the readback carries no `{name}` field")
|
|
197
|
+
project = profile.get("project") or {}
|
|
198
|
+
if not project.get("nodeId"):
|
|
199
|
+
problems.append("the project node id is missing")
|
|
200
|
+
if not project.get("number"):
|
|
201
|
+
problems.append("the project number is missing")
|
|
202
|
+
if not profile.get("repo"):
|
|
203
|
+
problems.append("the repository is missing")
|
|
204
|
+
leftovers = sorted(set(PLACEHOLDER_RE.findall(
|
|
205
|
+
json.dumps(profile, ensure_ascii=False))))
|
|
206
|
+
if leftovers:
|
|
207
|
+
problems.append(f"unresolved seed placeholder(s): {', '.join(leftovers)}")
|
|
208
|
+
return problems
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def render_document(seed_text: str, profile: dict) -> str:
|
|
212
|
+
"""The seeded board-sync.md with the filled sentinel and the profile block
|
|
213
|
+
replaced — the documentation body around it survives byte-for-byte."""
|
|
214
|
+
body = seed_text
|
|
215
|
+
first, _, rest = body.partition("\n")
|
|
216
|
+
if SENTINEL_RE.match(first.strip()):
|
|
217
|
+
body = rest
|
|
218
|
+
marker = PROFILE_MARKER_RE.search(body)
|
|
219
|
+
if not marker:
|
|
220
|
+
raise BootstrapError("the seed carries no `<!-- board-sync:profile -->` marker")
|
|
221
|
+
fence = JSON_FENCE_RE.search(body, marker.end())
|
|
222
|
+
if not fence:
|
|
223
|
+
raise BootstrapError("the seed's profile marker is not followed by a ```json block")
|
|
224
|
+
rendered = json.dumps(profile, indent=2, ensure_ascii=False)
|
|
225
|
+
return SENTINEL + "\n" + body[:fence.start(2)] + rendered + body[fence.end(2):]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --- the sequence ------------------------------------------------------------
|
|
229
|
+
def creation_plan(owner: str, title: str, status_options: list[str]) -> list[list[str]]:
|
|
230
|
+
"""The `gh` argv sequence, project-number placeholder `{number}` included —
|
|
231
|
+
also what `--dry-run` prints."""
|
|
232
|
+
plan = [["project", "create", "--owner", owner, "--title", title, "--format", "json"]]
|
|
233
|
+
plan.append(["project", "field-create", "{number}", "--owner", owner,
|
|
234
|
+
"--name", STATUS_FIELD_NAME, "--data-type", "SINGLE_SELECT",
|
|
235
|
+
"--single-select-options", ",".join(status_options), "--format", "json"])
|
|
236
|
+
for _, name, data_type in WORKFLOW_FIELDS:
|
|
237
|
+
plan.append(["project", "field-create", "{number}", "--owner", owner,
|
|
238
|
+
"--name", name, "--data-type", data_type, "--format", "json"])
|
|
239
|
+
plan.append(["project", "field-list", "{number}", "--owner", owner, "--format", "json"])
|
|
240
|
+
return plan
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def create_board(*, owner: str, repo: str, title: str, seed_cfg: dict) -> dict:
|
|
244
|
+
"""Create the board, read it back, and return the validated profile. Raises
|
|
245
|
+
`BootstrapError` — with the created project named — on any failure."""
|
|
246
|
+
options = status_option_names(seed_cfg)
|
|
247
|
+
created = _gh_json(["project", "create", "--owner", owner, "--title", title,
|
|
248
|
+
"--format", "json"])
|
|
249
|
+
number, node_id = created.get("number"), created.get("id")
|
|
250
|
+
if not number or not node_id:
|
|
251
|
+
raise BootstrapError(
|
|
252
|
+
f"`gh project create` returned no project number/id: {created!r}")
|
|
253
|
+
where = (f"project #{number} ({created.get('url') or owner}) was created; "
|
|
254
|
+
"finish or delete it before retrying")
|
|
255
|
+
try:
|
|
256
|
+
_gh(["project", "field-create", str(number), "--owner", owner,
|
|
257
|
+
"--name", STATUS_FIELD_NAME, "--data-type", "SINGLE_SELECT",
|
|
258
|
+
"--single-select-options", ",".join(options), "--format", "json"])
|
|
259
|
+
for _, name, data_type in WORKFLOW_FIELDS:
|
|
260
|
+
_gh(["project", "field-create", str(number), "--owner", owner,
|
|
261
|
+
"--name", name, "--data-type", data_type, "--format", "json"])
|
|
262
|
+
field_list = _gh_json(["project", "field-list", str(number), "--owner", owner,
|
|
263
|
+
"--format", "json"])
|
|
264
|
+
except BootstrapError as exc:
|
|
265
|
+
raise BootstrapError(f"{exc} — {where}") from exc
|
|
266
|
+
profile = build_profile(seed_cfg, repo=repo, owner=owner, number=number,
|
|
267
|
+
node_id=node_id, field_list=field_list)
|
|
268
|
+
problems = validate_profile(profile)
|
|
269
|
+
if problems:
|
|
270
|
+
raise BootstrapError(
|
|
271
|
+
"the board readback does not satisfy the workflow profile:\n - "
|
|
272
|
+
+ "\n - ".join(problems) + f"\n{where}")
|
|
273
|
+
return profile
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def write_profile(out: Path, seed_text: str, profile: dict) -> None:
|
|
277
|
+
"""Write the document, then re-read it through the real loader. A profile the
|
|
278
|
+
loader rejects is removed again — never left behind as a filled claim."""
|
|
279
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
out.write_text(render_document(seed_text, profile), encoding="utf-8")
|
|
281
|
+
try:
|
|
282
|
+
board_config.load_board_config(out)
|
|
283
|
+
except board_config.ConfigError as exc:
|
|
284
|
+
out.unlink(missing_ok=True)
|
|
285
|
+
raise BootstrapError(f"the written profile does not load: {exc}") from exc
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --- CLI ---------------------------------------------------------------------
|
|
289
|
+
def _first_line(path: Path):
|
|
290
|
+
if not path.exists():
|
|
291
|
+
return None
|
|
292
|
+
text = path.read_text(encoding="utf-8")
|
|
293
|
+
return text.splitlines()[0] if text.strip() else None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _preflight(args) -> int:
|
|
297
|
+
missing = missing_scopes(_gh(["auth", "status"]))
|
|
298
|
+
if args.json:
|
|
299
|
+
print(json.dumps({"missingScopes": missing, "remedy": SCOPE_REMEDY}))
|
|
300
|
+
elif missing:
|
|
301
|
+
print(f"missing scope(s): {', '.join(missing)} — run: {SCOPE_REMEDY}")
|
|
302
|
+
else:
|
|
303
|
+
print("scopes ok")
|
|
304
|
+
return EXIT_MISSING_SCOPE if missing else 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _create(args) -> int:
|
|
308
|
+
out = Path(args.out)
|
|
309
|
+
first_line = _first_line(out)
|
|
310
|
+
action = destination_action(first_line)
|
|
311
|
+
if action == "refuse":
|
|
312
|
+
observed = SENTINEL_RE.match((first_line or "").strip())
|
|
313
|
+
state = f"state={observed.group(1)}" if observed else "no setup-workflow sentinel"
|
|
314
|
+
print(f"{out} is consumer-owned ({state}) — refusing to overwrite it. "
|
|
315
|
+
"Only a missing file or `state=stub` may be filled.", file=sys.stderr)
|
|
316
|
+
return EXIT_REFUSED
|
|
317
|
+
|
|
318
|
+
seed_text = Path(args.seed).read_text(encoding="utf-8")
|
|
319
|
+
seed_cfg = board_config.load_board_config(args.seed)
|
|
320
|
+
|
|
321
|
+
missing = missing_scopes(_gh(["auth", "status"]))
|
|
322
|
+
if missing:
|
|
323
|
+
print(f"`gh` is missing the {', '.join(missing)} scope — no board was created. "
|
|
324
|
+
f"Run: {SCOPE_REMEDY}", file=sys.stderr)
|
|
325
|
+
return EXIT_MISSING_SCOPE
|
|
326
|
+
|
|
327
|
+
if args.dry_run:
|
|
328
|
+
for call in creation_plan(args.owner, args.title, status_option_names(seed_cfg)):
|
|
329
|
+
print(shlex.join(["gh", *call])) # copy-pasteable, not just readable
|
|
330
|
+
print(f"destination: {out} ({action})")
|
|
331
|
+
return 0
|
|
332
|
+
|
|
333
|
+
profile = create_board(owner=args.owner, repo=args.repo, title=args.title,
|
|
334
|
+
seed_cfg=seed_cfg)
|
|
335
|
+
write_profile(out, seed_text, profile)
|
|
336
|
+
print(f"board created: project #{profile['project']['number']} "
|
|
337
|
+
f"({profile['project']['owner']}) — profile written to {out}")
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main(argv=None) -> int:
|
|
342
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
343
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
344
|
+
|
|
345
|
+
pre = sub.add_parser("preflight", help="check the gh scopes board creation needs")
|
|
346
|
+
pre.add_argument("--json", action="store_true")
|
|
347
|
+
pre.set_defaults(func=_preflight)
|
|
348
|
+
|
|
349
|
+
create = sub.add_parser("create", help="create the board and write the filled profile")
|
|
350
|
+
create.add_argument("--owner", required=True)
|
|
351
|
+
create.add_argument("--repo", required=True, help="owner/repo of the consumer repository")
|
|
352
|
+
create.add_argument("--title", required=True)
|
|
353
|
+
create.add_argument("--seed", required=True, help="the seeded board-sync.md template")
|
|
354
|
+
create.add_argument("--out", required=True, help="destination, e.g. docs/agents/board-sync.md")
|
|
355
|
+
create.add_argument("--dry-run", action="store_true")
|
|
356
|
+
create.set_defaults(func=_create)
|
|
357
|
+
|
|
358
|
+
args = parser.parse_args(argv)
|
|
359
|
+
try:
|
|
360
|
+
return args.func(args)
|
|
361
|
+
except (BootstrapError, board_config.ConfigError) as exc:
|
|
362
|
+
print(str(exc), file=sys.stderr)
|
|
363
|
+
return EXIT_FAILURE
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
sys.exit(main())
|
|
@@ -42,17 +42,30 @@ export async function orchestrateUpdatePullRequest(options) {
|
|
|
42
42
|
return { status: 'created', branch };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
const AVAILABILITY_LABELS = [
|
|
46
|
+
'newly available:', 'newly degraded:', 'newly blocked:', 'still unresolved:',
|
|
47
|
+
];
|
|
48
|
+
const MIGRATION_LABELS = ['required migration:'];
|
|
49
|
+
|
|
50
|
+
function summarize(lines, labels) {
|
|
51
|
+
return lines
|
|
50
52
|
.filter((line) => labels.some((label) => line.includes(label)))
|
|
51
53
|
.map((line) => line.slice(Math.min(...labels.map((label) => {
|
|
52
54
|
const index = line.indexOf(label);
|
|
53
55
|
return index < 0 ? line.length : index;
|
|
54
|
-
}))))
|
|
55
|
-
|
|
56
|
+
}))))
|
|
57
|
+
.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pullRequestBody(stdout = '') {
|
|
61
|
+
const lines = stdout.replaceAll(/\x1b\[[0-9;]*m/g, '').split('\n').map((line) => line.trim());
|
|
62
|
+
return [
|
|
63
|
+
UPDATE_BODY,
|
|
64
|
+
'## Availability',
|
|
65
|
+
summarize(lines, AVAILABILITY_LABELS) || 'No readiness availability changes reported.',
|
|
66
|
+
'## Required migrations',
|
|
67
|
+
summarize(lines, MIGRATION_LABELS) || 'No required consumer migrations reported.',
|
|
68
|
+
].join('\n\n');
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
export function createSystemAdapters({
|
|
@@ -51,6 +51,35 @@ test('automated update pull requests carry the behavior availability summary', a
|
|
|
51
51
|
assert.match(created.body, /never merged automatically/);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test('automated update pull requests carry the required consumer migrations', async () => {
|
|
55
|
+
const h = harness({ update: {
|
|
56
|
+
exitCode: 0,
|
|
57
|
+
stdout: [
|
|
58
|
+
'newly available: none',
|
|
59
|
+
'required migration: wrapup-landing-artifact-policy · setup-workflow · '
|
|
60
|
+
+ 'docs/agents/workflow-capabilities.json · wrapup.landingGeneratedArtifactPatterns',
|
|
61
|
+
].join('\n'),
|
|
62
|
+
stderr: '',
|
|
63
|
+
} });
|
|
64
|
+
|
|
65
|
+
await orchestrateUpdatePullRequest(h.options);
|
|
66
|
+
|
|
67
|
+
const created = h.calls.find((call) => Array.isArray(call) && call[0] === 'create')[1];
|
|
68
|
+
assert.match(created.body, /## Required migrations/);
|
|
69
|
+
assert.match(created.body, /required migration: wrapup-landing-artifact-policy/);
|
|
70
|
+
assert.match(created.body, /docs\/agents\/workflow-capabilities\.json/);
|
|
71
|
+
assert.match(created.body, /wrapup\.landingGeneratedArtifactPatterns/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a pull request without pending migrations says so explicitly', async () => {
|
|
75
|
+
const h = harness({ update: { exitCode: 0, stdout: 'newly available: none', stderr: '' } });
|
|
76
|
+
|
|
77
|
+
await orchestrateUpdatePullRequest(h.options);
|
|
78
|
+
|
|
79
|
+
const created = h.calls.find((call) => Array.isArray(call) && call[0] === 'create')[1];
|
|
80
|
+
assert.match(created.body, /## Required migrations\n\nNo required consumer migrations reported\./);
|
|
81
|
+
});
|
|
82
|
+
|
|
54
83
|
test('a conflict produces a structured report without touching the stable branch', async () => {
|
|
55
84
|
const h = harness({ update: { exitCode: 2, stdout: 'conflicts: 1', stderr: '' } });
|
|
56
85
|
const report = await orchestrateUpdatePullRequest(h.options);
|