@ikon85/agent-workflow-kit 0.27.1 → 0.28.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.
Files changed (57) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +2 -2
  2. package/.agents/skills/grill-me/SKILL.md +1 -1
  3. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  4. package/.agents/skills/kit-update/SKILL.md +12 -0
  5. package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
  6. package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
  7. package/.agents/skills/retro/SKILL.md +24 -1
  8. package/.agents/skills/scale-check/SKILL.md +2 -2
  9. package/.agents/skills/setup-workflow/SKILL.md +44 -1
  10. package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
  11. package/.agents/skills/tdd/SKILL.md +68 -14
  12. package/.agents/skills/to-issues/SKILL.md +2 -2
  13. package/.agents/skills/verify-spike/SKILL.md +1 -1
  14. package/.agents/skills/wrapup/SKILL.md +14 -2
  15. package/.claude/hooks/drift-guard.py +5 -2
  16. package/.claude/hooks/kit-origin-edit-hint.py +64 -0
  17. package/.claude/skills/ask-matt/SKILL.md +2 -2
  18. package/.claude/skills/grill-me/SKILL.md +1 -1
  19. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  20. package/.claude/skills/kit-update/SKILL.md +12 -0
  21. package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
  22. package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
  23. package/.claude/skills/retro/SKILL.md +23 -0
  24. package/.claude/skills/scale-check/SKILL.md +2 -2
  25. package/.claude/skills/setup-workflow/SKILL.md +44 -1
  26. package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
  27. package/.claude/skills/skill-manifest.json +1 -1
  28. package/.claude/skills/tdd/SKILL.md +68 -14
  29. package/.claude/skills/to-issues/SKILL.md +2 -2
  30. package/.claude/skills/verify-spike/SKILL.md +1 -1
  31. package/.claude/skills/wrapup/SKILL.md +14 -2
  32. package/README.md +47 -7
  33. package/agent-workflow-kit.package.json +49 -33
  34. package/docs/adr/0001-consumer-divergence-policy.md +49 -0
  35. package/docs/agents/wave-anchor-template.md +1 -1
  36. package/package.json +1 -1
  37. package/scripts/board-sync.py +184 -5
  38. package/scripts/pr-body-check.py +34 -6
  39. package/scripts/pr_body_e2e.py +83 -0
  40. package/scripts/test_board_sync.py +208 -0
  41. package/scripts/test_census_backstop.py +56 -3
  42. package/scripts/test_orchestrate_wave_contract.py +116 -0
  43. package/scripts/test_pr_body_check.py +245 -0
  44. package/scripts/test_retro_wrapup_contract.py +111 -0
  45. package/scripts/test_tdd_contract.py +78 -0
  46. package/src/cli.mjs +44 -8
  47. package/src/commands/diff.mjs +5 -2
  48. package/src/commands/init.mjs +12 -0
  49. package/src/commands/own.mjs +16 -0
  50. package/src/commands/uninstall.mjs +8 -1
  51. package/src/commands/update.mjs +37 -9
  52. package/src/lib/bundle.mjs +4 -0
  53. package/src/lib/consumerPath.mjs +30 -0
  54. package/src/lib/manifest.mjs +18 -0
  55. package/src/lib/ownedDiff.mjs +88 -0
  56. package/src/lib/updateCandidate.mjs +14 -0
  57. package/src/lib/updateReconcile.mjs +45 -6
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """board-sync.py — single GitHub Projects-v2 board-sync / sub-issue helper.
3
3
 
4
- Encapsulates the five board mechanics the planning skills used to inline as bare
4
+ Encapsulates the board mechanics the planning skills used to inline as bare
5
5
  `gh` snippets (board-to-waves, to-prd, to-issues):
6
6
 
7
7
  1. GraphQL-Link — native parent↔child sub-issue link (`link`)
@@ -9,6 +9,10 @@ Encapsulates the five board mechanics the planning skills used to inline as bare
9
9
  3. preview-header — `GraphQL-Features: sub_issues` on every sub-issue call
10
10
  4. Wave-Stempel — stamp the Wave (number) field (`--wave` on `create`/`add`)
11
11
  5. board-sync — add an issue to the board + set Status/Wave/Cluster/Path fields
12
+ 6. next-wave — rate-efficient title search with a bounded full-scan fallback
13
+ 7. promotion guard — reject a Wave number already held by another anchor
14
+ 8. item-of — targeted item/field lookup without scanning the whole board
15
+ 9. archive-done — dry-run-first, bounded-batch archival of active Done items
12
16
 
13
17
  Board-specific values (field IDs, status names, labels) are NOT inlined here —
14
18
  board_config reads them from the `board-sync:profile` block in
@@ -95,6 +99,8 @@ def _status_roles_or_hint(context: str) -> dict:
95
99
  return STATUS_ROLES
96
100
  PROJECT_ITEM_LIST_LIMIT = 2000
97
101
  GH_TIMEOUT_SECONDS = 15 # a hanging gh prompt must not block a session indefinitely
102
+ PROJECT_ITEM_LIST_TIMEOUT_SECONDS = 60 # full-board reads are slower, but still bounded
103
+ ARCHIVE_DONE_BATCH_SIZE = 30
98
104
 
99
105
  # Sub-issues GraphQL API is behind a preview feature gate per account.
100
106
  SUB_ISSUES_HEADER = "GraphQL-Features: sub_issues"
@@ -127,11 +133,13 @@ class GhError(RuntimeError):
127
133
  # --- subprocess seam (the only thing tests monkeypatch) ----------------------
128
134
  def _gh(args: list[str]) -> str:
129
135
  """Run `gh <args>`, return stdout. Raise GhError on non-zero exit or timeout."""
136
+ timeout = (PROJECT_ITEM_LIST_TIMEOUT_SECONDS
137
+ if args[:2] == ["project", "item-list"] else GH_TIMEOUT_SECONDS)
130
138
  try:
131
139
  result = subprocess.run(["gh", *args], capture_output=True, text=True,
132
- timeout=GH_TIMEOUT_SECONDS)
140
+ timeout=timeout)
133
141
  except subprocess.TimeoutExpired as exc:
134
- raise GhError(f"gh {' '.join(args)} timed out after {GH_TIMEOUT_SECONDS}s") from exc
142
+ raise GhError(f"gh {' '.join(args)} timed out after {timeout}s") from exc
135
143
  if result.returncode != 0:
136
144
  # `gh api graphql` exits non-zero when the response body carries GraphQL
137
145
  # `errors` even alongside a partial `data` (stamp-batch's per-alias
@@ -422,6 +430,10 @@ WAVE_TITLE_PREFIX = wave_title_prefix(_CFG)
422
430
  _WAVE_PREFIX_RE = re.compile(
423
431
  rf"^\s*{re.escape(WAVE_TITLE_PREFIX)}\s+\d+\s*[—–-]\s*", re.IGNORECASE
424
432
  )
433
+ _WAVE_NUMBER_RE = re.compile(rf"^{re.escape(WAVE_TITLE_PREFIX)}\s+(\d+)\b")
434
+ _ANCHOR_TITLE_RE = re.compile(
435
+ rf"^{re.escape(WAVE_TITLE_PREFIX)}\s+(\d+)\s+—\s+"
436
+ )
425
437
  # Leading conventional-commit token (`fix:`, `feat(ui):`, `chore!:`) — anchors
426
438
  # don't carry these, so strip it when present. Matches only a lowercase token,
427
439
  # so prose titles like "Supabase-Residuen entfernen: …" are left intact.
@@ -443,6 +455,26 @@ def wave_title(current: str, wave: int) -> str:
443
455
  return f"{WAVE_TITLE_PREFIX} {wave} — {thema}"
444
456
 
445
457
 
458
+ def compute_next_wave_from_search(payload: dict) -> Optional[int]:
459
+ """Return max wave-title number + 1, or None so callers can full-scan."""
460
+ numbers = []
461
+ for item in payload.get("items", []):
462
+ match = _WAVE_NUMBER_RE.match(item.get("title") or "")
463
+ if match:
464
+ numbers.append(int(match.group(1)))
465
+ return max(numbers) + 1 if numbers else None
466
+
467
+
468
+ def wave_collision_guard(payload: dict, wave: int, issue: int) -> Optional[str]:
469
+ """Reject a wave number carried by another anchor; allow replay and leaves."""
470
+ for item in payload.get("items", []):
471
+ match = _ANCHOR_TITLE_RE.match(item.get("title") or "")
472
+ if match and int(match.group(1)) == wave and item.get("number") != issue:
473
+ return (f"Wave {wave} is already taken by #{item.get('number')} "
474
+ f"({item.get('title')!r}); re-read `next-wave` and retry")
475
+ return None
476
+
477
+
446
478
  def title_edit_args(issue: int, title: str) -> list[str]:
447
479
  """The `gh issue edit` argv that sets an issue's title."""
448
480
  return ["issue", "edit", str(issue), "--repo", REPO, "--title", title]
@@ -511,6 +543,43 @@ def _field_value(issue: int, field_id: str) -> Optional[dict]:
511
543
  return extract_field_value(data, PROJECT_NODE_ID, field_id)
512
544
 
513
545
 
546
+ def extract_item_overview(data: dict, project_node_id: str, field_ids: dict) -> Optional[dict]:
547
+ """Extract one issue's item id and configured field values for this project."""
548
+ issue = (data.get("data") or {}).get("repository", {}).get("issue") or {}
549
+ for node in (issue.get("projectItems") or {}).get("nodes") or []:
550
+ if (node.get("project") or {}).get("id") != project_node_id:
551
+ continue
552
+ by_field = {(value.get("field") or {}).get("id"): value
553
+ for value in (node.get("fieldValues") or {}).get("nodes") or []}
554
+ overview = {"itemId": node.get("id")}
555
+ for name, field_id in field_ids.items():
556
+ value = by_field.get(field_id)
557
+ if value is None:
558
+ overview[name] = None
559
+ elif "number" in value:
560
+ overview[name] = value["number"]
561
+ elif "name" in value:
562
+ overview[name] = value["name"]
563
+ else:
564
+ overview[name] = value.get("text")
565
+ return overview
566
+ return None
567
+
568
+
569
+ def cmd_item_of(args) -> int:
570
+ data = _gh_json(["api", "graphql", "-f", f"query={FIELD_VALUE_QUERY}",
571
+ "-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
572
+ "-F", f"num={args.issue}"])
573
+ overview = extract_item_overview(data, PROJECT_NODE_ID, {
574
+ "wave": WAVE_FIELD_ID, "status": STATUS_FIELD_ID, "cluster": CLUSTER_FIELD_ID,
575
+ })
576
+ if overview is None:
577
+ print("NOT-ON-BOARD")
578
+ return 1
579
+ print(json.dumps(overview, ensure_ascii=False))
580
+ return 0
581
+
582
+
514
583
  def _write_issue_body(issue: int, new_body: str) -> None:
515
584
  """Write an issue body via a temp file + `gh issue edit --body-file` — the
516
585
  one shared write mechanic for every body-rewriting command in this file."""
@@ -534,13 +603,101 @@ def _add_and_stamp(url, wave, status, cluster, spec_path, plan_path, dry_run) ->
534
603
 
535
604
 
536
605
  # --- commands ----------------------------------------------------------------
537
- def cmd_next_wave(_args) -> int:
606
+ def cmd_next_wave(args) -> int:
607
+ if not getattr(args, "scan", False):
608
+ payload = _gh_json(["api", "-X", "GET", "search/issues",
609
+ "-f", f'q=repo:{REPO} is:issue in:title "{WAVE_TITLE_PREFIX}"',
610
+ "-f", "per_page=100", "-f", "sort=created", "-f", "order=desc"])
611
+ next_wave = compute_next_wave_from_search(payload)
612
+ if next_wave is not None:
613
+ print(next_wave)
614
+ return 0
615
+ print(f"hint: title search found no '{WAVE_TITLE_PREFIX} <N>' issue; "
616
+ "falling back to the full board scan", file=sys.stderr)
538
617
  items = _gh_json(["project", "item-list", str(PROJECT_NUMBER), "--owner", PROJECT_OWNER,
539
618
  "--limit", str(PROJECT_ITEM_LIST_LIMIT), "--format", "json"]).get("items", [])
540
619
  print(compute_next_wave(items))
541
620
  return 0
542
621
 
543
622
 
623
+ def extract_done_items(items: list[dict], done_status: str) -> list[dict]:
624
+ return [item for item in items
625
+ if item.get("id") and item.get("status") == done_status
626
+ and not item.get("archived", False) and not item.get("isArchived", False)]
627
+
628
+
629
+ def build_archive_mutation(item_ids: list[str]) -> str:
630
+ calls = []
631
+ for index, item_id in enumerate(item_ids):
632
+ calls.append(
633
+ f"a{index}: archiveProjectV2Item(input: "
634
+ f"{{projectId: {json.dumps(PROJECT_NODE_ID)}, itemId: {json.dumps(item_id)}}}) "
635
+ "{ item { id } }"
636
+ )
637
+ return "mutation { " + " ".join(calls) + " }"
638
+
639
+
640
+ def parse_archive_response(response: dict, item_ids: list[str]) -> tuple[list[str], list[tuple[str, str]]]:
641
+ data = response.get("data") or {}
642
+ errors = {error["path"][0]: error.get("message", "unknown GraphQL error")
643
+ for error in response.get("errors") or [] if error.get("path")}
644
+ global_errors = [error.get("message", "unknown GraphQL error")
645
+ for error in response.get("errors") or [] if not error.get("path")]
646
+ fallback = "; ".join(global_errors) or "no successful response"
647
+ succeeded, failed = [], []
648
+ for index, item_id in enumerate(item_ids):
649
+ alias = f"a{index}"
650
+ if data.get(alias) and data[alias].get("item"):
651
+ succeeded.append(item_id)
652
+ else:
653
+ failed.append((item_id, errors.get(alias, fallback)))
654
+ return succeeded, failed
655
+
656
+
657
+ def cmd_archive_done(args) -> int:
658
+ done_status = resolve_status_role("done", _status_roles_or_hint("archive-done"))
659
+ payload = _gh_json(["project", "item-list", str(PROJECT_NUMBER),
660
+ "--owner", PROJECT_OWNER, "--limit", str(PROJECT_ITEM_LIST_LIMIT),
661
+ "--format", "json", "--jq",
662
+ "{items: [.items[] | {id, status, archived, isArchived}], "
663
+ "totalCount: .totalCount}"])
664
+ items = payload.get("items", [])
665
+ total_count = payload.get("totalCount", len(items))
666
+ if total_count > len(items):
667
+ raise ValueError(f"project item-list returned {len(items)} of {total_count} items; "
668
+ "refusing a partial archive")
669
+ done_items = extract_done_items(items, done_status)
670
+ print(f"archive-done: {len(done_items)} of {total_count} active items have status {done_status}")
671
+ if not done_items:
672
+ print("archive-done: nothing to archive (idempotent)")
673
+ return 0
674
+ chunks = [done_items[start:start + ARCHIVE_DONE_BATCH_SIZE]
675
+ for start in range(0, len(done_items), ARCHIVE_DONE_BATCH_SIZE)]
676
+ if not args.apply:
677
+ for chunk in chunks:
678
+ _print_dry(["api", "graphql", "-f",
679
+ f"query={build_archive_mutation([item['id'] for item in chunk])}"])
680
+ print("archive-done: dry-run only; pass --apply to archive")
681
+ return 0
682
+ succeeded, failed = [], []
683
+ for chunk in chunks:
684
+ item_ids = [item["id"] for item in chunk]
685
+ query = build_archive_mutation(item_ids)
686
+ try:
687
+ output = _gh(["api", "graphql", "-f", f"query={query}"])
688
+ except GhError as exc:
689
+ output = getattr(exc, "stdout", "") or ""
690
+ if not output:
691
+ raise
692
+ batch_succeeded, batch_failed = parse_archive_response(json.loads(output), item_ids)
693
+ succeeded += batch_succeeded
694
+ failed += batch_failed
695
+ print(f"archive-done: {len(succeeded)} of {len(done_items)} Done items archived")
696
+ for item_id, message in failed:
697
+ print(f" ERROR {item_id}: {message}")
698
+ return 1 if failed else 0
699
+
700
+
544
701
  def cmd_parent_of(args) -> int:
545
702
  parent = _parent_of(args.issue)
546
703
  print(parent if parent is not None else "FREI")
@@ -793,6 +950,13 @@ def cmd_promote(args) -> int:
793
950
  if mismatch:
794
951
  print(f"error: {mismatch}", file=sys.stderr)
795
952
  return 1
953
+ search = _gh_json(["api", "-X", "GET", "search/issues",
954
+ "-f", f'q=repo:{REPO} is:issue in:title "{WAVE_TITLE_PREFIX} {args.wave}"',
955
+ "-f", "per_page=100"])
956
+ collision = wave_collision_guard(search, args.wave, args.issue)
957
+ if collision:
958
+ print(f"error: {collision}", file=sys.stderr)
959
+ return 1
796
960
  done: list[str] = []
797
961
  try:
798
962
  strip = type_labels_to_strip(labels)
@@ -1089,7 +1253,22 @@ def build_parser() -> argparse.ArgumentParser:
1089
1253
  p = argparse.ArgumentParser(prog="board-sync.py", description=__doc__.splitlines()[0])
1090
1254
  sub = p.add_subparsers(dest="command", required=True)
1091
1255
 
1092
- sub.add_parser("next-wave", help="print the next monotone wave number").set_defaults(func=cmd_next_wave)
1256
+ nw = sub.add_parser("next-wave", help="print the next monotone wave number "
1257
+ "(cheap title search; --scan reads the full board)")
1258
+ nw.add_argument("--scan", action="store_true")
1259
+ nw.set_defaults(func=cmd_next_wave)
1260
+
1261
+ archive = sub.add_parser(
1262
+ "archive-done",
1263
+ help="preview archival of active Done items; --apply performs bounded batches",
1264
+ )
1265
+ archive.add_argument("--apply", action="store_true",
1266
+ help="perform archival (the default is a non-mutating dry-run)")
1267
+ archive.set_defaults(func=cmd_archive_done)
1268
+
1269
+ item = sub.add_parser("item-of", help="targeted item id and field lookup for one issue")
1270
+ item.add_argument("--issue", type=int, required=True)
1271
+ item.set_defaults(func=cmd_item_of)
1093
1272
 
1094
1273
  po = sub.add_parser("parent-of", help="print an issue's parent number or FREI")
1095
1274
  po.add_argument("issue", type=int)
@@ -4,7 +4,7 @@ pr-body-check.py — mechanical guard for the PR-body conventions that were unti
4
4
  now prose-only.
5
5
 
6
6
  Called by `wrapup` Step 0c AFTER the PR has been created/reused, BEFORE the
7
- merge gate. Turns three instruction-only rules into a check that actually fires:
7
+ merge gate. Turns four instruction-only rules into a check that actually fires:
8
8
 
9
9
  1. Anker-Slice (Issue HAS a parent) → body MUST contain `Part of #<parent>`
10
10
  and MUST NOT contain a close-keyword on the **parent anchor** number
@@ -16,9 +16,12 @@ merge gate. Turns three instruction-only rules into a check that actually fires:
16
16
  auto-close never fires, wrapup Step 5b lesson).
17
17
  3. `**Retro:**`-Pflichtzeile present in one of the Slice-7 forms
18
18
  (`gefahren …` | `übersprungen …`), with a space after the marker.
19
+ 4. Exactly one valid `E2E-NA: <reason>` trailer in the immutable pull-request
20
+ range requires active `E2E: n/a — <reason>` PR-body evidence.
19
21
 
20
- Scope: this script checks ONLY the closes/Part-of anchor rule and the
21
- `**Retro:**` line. It does NOT parse or validate `annahme-drift` markers
22
+ Scope: this script checks ONLY the closes/Part-of anchor rule, the
23
+ `**Retro:**` line, and E2E exemption evidence. It does NOT parse or validate
24
+ `annahme-drift` markers
22
25
  (those are prose-/judgment-driven in wrapup Step 0c + 5e, deliberately not
23
26
  mechanised — R2-F6). The annahme-drift block therefore runs BEFORE this
24
27
  check in wrapup Step 0c so the body the script sees is final.
@@ -34,7 +37,7 @@ thin shell. NOT a hook — `wrapup` invokes it (Design).
34
37
 
35
38
  Usage:
36
39
  pr-body-check.py [--branch <name>] [--issue <n>] [--parent <n>|FREI]
37
- [--body-file <path>]
40
+ [--body-file <path>] [--base-sha <sha>] [--head-sha <sha>]
38
41
  All flags optional; unset → derived from the current branch + live PR via gh.
39
42
 
40
43
  Audit log: .claude/logs/pr-body-check.log
@@ -47,6 +50,11 @@ from pathlib import Path
47
50
 
48
51
  sys.path.insert(0, str(Path(__file__).resolve().parent))
49
52
  from board_config import ConfigError, load_board_config # noqa: E402
53
+ from pr_body_e2e import ( # noqa: E402
54
+ check_e2e_na_line,
55
+ fetch_has_e2e_na_trailer,
56
+ fetch_pr_range,
57
+ )
50
58
 
51
59
  try:
52
60
  _CFG = load_board_config()
@@ -122,7 +130,8 @@ def _part_of(body: str, n: int) -> bool:
122
130
  rf"\b{PART_OF_RE_SRC}(?::\s*|\s+)#0*{n}(?!\d)", body or "", re.IGNORECASE))
123
131
 
124
132
 
125
- def check_pr_body(body: str, issue: int, parent, is_anchor: bool = False):
133
+ def check_pr_body(body: str, issue: int, parent, is_anchor: bool = False,
134
+ has_e2e_na_trailer: bool = False):
126
135
  """Return a list of violation strings ([] = green).
127
136
 
128
137
  parent is the anchor issue number, or None for an atomar Leaf.
@@ -165,6 +174,7 @@ def check_pr_body(body: str, issue: int, parent, is_anchor: bool = False):
165
174
  "Pflichtzeile `**Retro:** gefahren — …` oder "
166
175
  "`**Retro:** übersprungen — <Grund>` fehlt.")
167
176
 
177
+ violations.extend(check_e2e_na_line(body, has_e2e_na_trailer))
168
178
  return violations
169
179
 
170
180
 
@@ -207,12 +217,23 @@ def fetch_is_anchor(number: int) -> bool:
207
217
  return "type:cluster" in out.splitlines()
208
218
 
209
219
 
220
+ def resolve_has_e2e_na(args, branch: str) -> bool:
221
+ """Explicit test overrides win; otherwise use immutable GitHub PR SHAs."""
222
+ if args.base_sha or args.head_sha:
223
+ base_sha, head_sha = args.base_sha, args.head_sha
224
+ else:
225
+ base_sha, head_sha = fetch_pr_range(branch)
226
+ return fetch_has_e2e_na_trailer(base_sha, head_sha)
227
+
228
+
210
229
  def main() -> int:
211
230
  ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
212
231
  ap.add_argument("--branch", help="default: current git branch")
213
232
  ap.add_argument("--issue", type=int, help="override branch-derived issue #")
214
233
  ap.add_argument("--parent", help="override parent (number or FREI)")
215
234
  ap.add_argument("--body-file", help="read PR body from file instead of gh")
235
+ ap.add_argument("--base-sha", help="override immutable PR base SHA")
236
+ ap.add_argument("--head-sha", help="override immutable PR head SHA")
216
237
  args = ap.parse_args()
217
238
 
218
239
  branch = args.branch or current_branch()
@@ -244,7 +265,14 @@ def main() -> int:
244
265
  parent = fetch_parent(issue)
245
266
 
246
267
  is_anchor = fetch_is_anchor(issue) if parent is None else False
247
- violations = check_pr_body(body, issue, parent, is_anchor=is_anchor)
268
+ has_e2e_na = resolve_has_e2e_na(args, branch)
269
+ violations = check_pr_body(
270
+ body,
271
+ issue,
272
+ parent,
273
+ is_anchor=is_anchor,
274
+ has_e2e_na_trailer=has_e2e_na,
275
+ )
248
276
  kind = ("Anker-Slice" if parent is not None
249
277
  else "Wave-PR" if is_anchor else "Leaf")
250
278
  log(f"branch={branch} issue={issue} parent={parent} kind={kind} "
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python3
2
+ """Couple a valid E2E-NA trailer to active PR-body evidence."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+
9
+
10
+ E2E_NA_LINE_RE = re.compile(
11
+ r"^E2E:\s*n/a\s*—\s*\S", re.IGNORECASE | re.MULTILINE
12
+ )
13
+
14
+
15
+ def _run(cmd: list[str], timeout=15):
16
+ try:
17
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
18
+ return result.returncode, result.stdout.strip()
19
+ except Exception:
20
+ return -1, ""
21
+
22
+
23
+ def check_e2e_na_line(body: str, has_e2e_na_trailer: bool) -> list[str]:
24
+ """Require non-empty body evidence only when a valid trailer exists."""
25
+ if not has_e2e_na_trailer or E2E_NA_LINE_RE.search(body or ""):
26
+ return []
27
+ return [
28
+ "E2E-NA trailer found in the pull-request range, but required "
29
+ "`E2E: n/a — <reason>` evidence is missing from the PR body."
30
+ ]
31
+
32
+
33
+ def _git(args: list[str], cwd=None) -> str:
34
+ result = subprocess.run(
35
+ ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=15
36
+ )
37
+ if result.returncode != 0:
38
+ raise RuntimeError(result.stderr.strip())
39
+ return result.stdout
40
+
41
+
42
+ def _collect_e2e_na_trailers(base_sha: str, head_sha: str, cwd=None) -> list[str]:
43
+ raw = _git(
44
+ [
45
+ "log",
46
+ f"{base_sha}..{head_sha}",
47
+ "--format=%x00%(trailers:key=E2E-NA,unfold)",
48
+ ],
49
+ cwd=cwd,
50
+ )
51
+ values = []
52
+ for chunk in raw.split("\x00"):
53
+ for line in chunk.splitlines():
54
+ if line.startswith("E2E-NA:"):
55
+ values.append(line.removeprefix("E2E-NA:").strip())
56
+ return values
57
+
58
+
59
+ def fetch_has_e2e_na_trailer(base_sha, head_sha, cwd=None) -> bool:
60
+ """Return true for exactly one non-empty trailer; unreadable ranges are false."""
61
+ if not base_sha or not head_sha:
62
+ return False
63
+ try:
64
+ values = _collect_e2e_na_trailers(base_sha, head_sha, cwd=cwd)
65
+ except Exception:
66
+ return False
67
+ return len(values) == 1 and bool(values[0].strip())
68
+
69
+
70
+ def fetch_pr_range(branch: str):
71
+ """Return GitHub's immutable PR base/head SHAs, or an unreadable range."""
72
+ rc, out = _run(
73
+ ["gh", "pr", "view", branch, "--json", "baseRefOid,headRefOid"]
74
+ )
75
+ if rc != 0 or not out:
76
+ return None, None
77
+ try:
78
+ data = json.loads(out)
79
+ except (json.JSONDecodeError, TypeError):
80
+ return None, None
81
+ if not isinstance(data, dict):
82
+ return None, None
83
+ return data.get("baseRefOid"), data.get("headRefOid")
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """Behavior tests for bounded board operations; the fake seam never calls GitHub."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import json
8
+ import subprocess
9
+ import unittest
10
+ from contextlib import redirect_stderr, redirect_stdout
11
+ from unittest.mock import patch
12
+
13
+ import importlib.util
14
+ from pathlib import Path
15
+
16
+
17
+ SPEC = importlib.util.spec_from_file_location(
18
+ "board_sync_operations_test", Path(__file__).with_name("board-sync.py"))
19
+ bs = importlib.util.module_from_spec(SPEC)
20
+ assert SPEC.loader is not None
21
+ SPEC.loader.exec_module(bs)
22
+
23
+
24
+ class FakeGh:
25
+ def __init__(self, responses=None, failures=None):
26
+ self.responses = responses or {}
27
+ self.failures = failures or {}
28
+ self.calls = []
29
+
30
+ def __call__(self, args):
31
+ self.calls.append(args)
32
+ joined = " ".join(args)
33
+ for needle, body in self.failures.items():
34
+ if needle in joined:
35
+ error = bs.GhError("injected failure")
36
+ error.stdout = body
37
+ raise error
38
+ for needle, body in self.responses.items():
39
+ if needle in joined:
40
+ return body
41
+ return ""
42
+
43
+
44
+ def run(fake, argv):
45
+ old = bs._gh
46
+ bs._gh = fake
47
+ out = io.StringIO()
48
+ try:
49
+ with redirect_stdout(out):
50
+ code = bs.main(argv)
51
+ finally:
52
+ bs._gh = old
53
+ return code, out.getvalue()
54
+
55
+
56
+ class BoundedCalls(unittest.TestCase):
57
+ def test_full_board_and_ordinary_calls_have_distinct_clear_timeouts(self):
58
+ with patch.object(bs.subprocess, "run", side_effect=subprocess.TimeoutExpired("gh", 1)) as call:
59
+ with self.assertRaisesRegex(bs.GhError, "15s"):
60
+ bs._gh(["issue", "view", "1"])
61
+ self.assertEqual(call.call_args.kwargs["timeout"], 15)
62
+ with self.assertRaisesRegex(bs.GhError, "60s"):
63
+ bs._gh(["project", "item-list", "3"])
64
+ self.assertEqual(call.call_args.kwargs["timeout"], 60)
65
+
66
+ def test_partial_create_prints_issue_and_replay_safe_repair(self):
67
+ fake = FakeGh(
68
+ {"issue list": "[]", "issue create": "https://github.com/x/y/issues/17\n"},
69
+ {"project item-add": ""},
70
+ )
71
+ with patch.object(bs, "REPO", "x/y"):
72
+ with self.subTest("created issue remains visible"):
73
+ from tempfile import NamedTemporaryFile
74
+ with NamedTemporaryFile("w") as body:
75
+ body.write("<!-- program-leaf-source: p/1 -->\n")
76
+ body.flush()
77
+ code, out = run(fake, ["create", "--title", "S", "--body-file", body.name])
78
+ self.assertEqual(code, 1)
79
+ self.assertIn("#17 https://github.com/x/y/issues/17", out)
80
+ self.assertIn("board-sync.py add --issue 17", out)
81
+ self.assertIn("idempotent", out)
82
+
83
+
84
+ class WaveLookup(unittest.TestCase):
85
+ def test_search_finds_next_wave_without_full_scan(self):
86
+ fake = FakeGh({"search/issues": json.dumps({"items": [
87
+ {"title": f"{bs.WAVE_TITLE_PREFIX} 7 — Anchor"},
88
+ {"title": f"{bs.WAVE_TITLE_PREFIX} 8 / Slice 1 — Leaf"},
89
+ ]})})
90
+ code, out = run(fake, ["next-wave"])
91
+ self.assertEqual((code, out.strip()), (0, "9"))
92
+ self.assertFalse(any("item-list" in " ".join(c) for c in fake.calls))
93
+
94
+ def test_empty_search_falls_back_and_scan_skips_search(self):
95
+ fake = FakeGh({"search/issues": '{"items":[]}', "item-list": '{"items":[{"wave":4}]}'})
96
+ with redirect_stderr(io.StringIO()):
97
+ self.assertEqual(run(fake, ["next-wave"])[1].strip(), "5")
98
+ scan = FakeGh({"item-list": '{"items":[{"wave":9}]}'})
99
+ self.assertEqual(run(scan, ["next-wave", "--scan"])[1].strip(), "10")
100
+ self.assertFalse(any("search/issues" in " ".join(c) for c in scan.calls))
101
+
102
+ def test_promotion_refuses_foreign_anchor_but_allows_same_issue(self):
103
+ foreign = {"items": [{"number": 22, "title": f"{bs.WAVE_TITLE_PREFIX} 7 — Other"}]}
104
+ message = bs.wave_collision_guard(foreign, 7, 21)
105
+ self.assertIn("#22", message)
106
+ self.assertIn("next-wave", message)
107
+ self.assertIsNone(bs.wave_collision_guard(foreign, 7, 22))
108
+ leaf = {"items": [{"number": 23, "title": f"{bs.WAVE_TITLE_PREFIX} 7 / Slice 1 — X"}]}
109
+ self.assertIsNone(bs.wave_collision_guard(leaf, 7, 21))
110
+
111
+ def test_promote_collision_fails_before_any_write(self):
112
+ fake = FakeGh({
113
+ "--json labels": "",
114
+ "--json body": "Draft PRD\n",
115
+ "graphql": '{"data":{"repository":{"issue":{"projectItems":{"nodes":[]}}}}}',
116
+ "search/issues": json.dumps({"items": [
117
+ {"number": 22, "title": f"{bs.WAVE_TITLE_PREFIX} 7 — Other"},
118
+ ]}),
119
+ })
120
+ error = io.StringIO()
121
+ with redirect_stderr(error):
122
+ code, _ = run(fake, ["promote", "--issue", "21", "--wave", "7"])
123
+ self.assertEqual(code, 1)
124
+ self.assertIn("#22", error.getvalue())
125
+ self.assertFalse(any(c[:2] == ["issue", "edit"] for c in fake.calls))
126
+
127
+
128
+ class TargetedLookup(unittest.TestCase):
129
+ @staticmethod
130
+ def payload(project):
131
+ return {"data": {"repository": {"issue": {"projectItems": {"nodes": [{
132
+ "id": "PVTI_x", "project": {"id": project}, "fieldValues": {"nodes": [
133
+ {"number": 7, "field": {"id": bs.WAVE_FIELD_ID}},
134
+ {"name": "Spec", "field": {"id": bs.STATUS_FIELD_ID}},
135
+ ]},
136
+ }]}}}}}
137
+
138
+ def test_item_of_returns_configured_fields(self):
139
+ fake = FakeGh({"graphql": json.dumps(self.payload(bs.PROJECT_NODE_ID))})
140
+ code, out = run(fake, ["item-of", "--issue", "21"])
141
+ self.assertEqual(code, 0)
142
+ self.assertEqual(json.loads(out), {"itemId": "PVTI_x", "wave": 7,
143
+ "status": "Spec", "cluster": None})
144
+
145
+ def test_item_of_fails_clearly_when_absent(self):
146
+ payload = {"data": {"repository": {"issue": {"projectItems": {"nodes": []}}}}}
147
+ code, out = run(FakeGh({"graphql": json.dumps(payload)}),
148
+ ["item-of", "--issue", "99"])
149
+ self.assertEqual((code, out.strip()), (1, "NOT-ON-BOARD"))
150
+
151
+
152
+ class ArchiveDone(unittest.TestCase):
153
+ def item_payload(self, count=1):
154
+ items = [{"id": f"D{i}", "status": bs.STATUS_ROLES["done"]} for i in range(count)]
155
+ items += [{"id": "OPEN", "status": bs.STATUS_ROLES["inProgress"]},
156
+ {"id": "OLD", "status": bs.STATUS_ROLES["done"], "archived": True}]
157
+ return json.dumps({"items": items, "totalCount": len(items)})
158
+
159
+ def test_archive_defaults_to_dry_run_and_selects_only_active_done(self):
160
+ fake = FakeGh({"item-list": self.item_payload()})
161
+ code, out = run(fake, ["archive-done"])
162
+ self.assertEqual(code, 0)
163
+ self.assertIn("D0", out)
164
+ self.assertNotIn("OPEN", out)
165
+ self.assertNotIn("OLD", out)
166
+ self.assertFalse(any("archiveProjectV2Item" in " ".join(c) for c in fake.calls))
167
+
168
+ def test_apply_batches_at_thirty(self):
169
+ class BatchGh(FakeGh):
170
+ def __call__(self, args):
171
+ self.calls.append(args)
172
+ if "item-list" in args:
173
+ return self.responses["item-list"]
174
+ count = " ".join(args).count("archiveProjectV2Item")
175
+ return json.dumps({"data": {f"a{i}": {"item": {"id": f"x{i}"}}
176
+ for i in range(count)}})
177
+ fake = BatchGh({"item-list": self.item_payload(31)})
178
+ code, out = run(fake, ["archive-done", "--apply"])
179
+ self.assertEqual(code, 0)
180
+ self.assertIn("31 of 31", out)
181
+ calls = [c for c in fake.calls if "archiveProjectV2Item" in " ".join(c)]
182
+ self.assertEqual([" ".join(c).count("archiveProjectV2Item") for c in calls], [30, 1])
183
+
184
+ def test_truncated_input_refuses_without_mutation(self):
185
+ fake = FakeGh({"item-list": '{"items":[{"id":"D","status":"Done"}],"totalCount":2}'})
186
+ err = io.StringIO()
187
+ with redirect_stderr(err):
188
+ code, _ = run(fake, ["archive-done", "--apply"])
189
+ self.assertEqual(code, 1)
190
+ self.assertIn("partial archive", err.getvalue())
191
+ self.assertFalse(any("archiveProjectV2Item" in " ".join(c) for c in fake.calls))
192
+
193
+ def test_apply_reports_partial_failure_and_empty_replay_is_idempotent(self):
194
+ body = json.dumps({"data": {"a0": {"item": {"id": "D0"}}, "a1": None},
195
+ "errors": [{"path": ["a1"], "message": "already archived"}]})
196
+ fake = FakeGh({"item-list": self.item_payload(2)}, {"archiveProjectV2Item": body})
197
+ code, out = run(fake, ["archive-done", "--apply"])
198
+ self.assertEqual(code, 1)
199
+ self.assertIn("1 of 2", out)
200
+ self.assertIn("D1: already archived", out)
201
+ empty = FakeGh({"item-list": json.dumps({"items": [], "totalCount": 0})})
202
+ code, out = run(empty, ["archive-done", "--apply"])
203
+ self.assertEqual(code, 0)
204
+ self.assertIn("nothing to archive", out)
205
+
206
+
207
+ if __name__ == "__main__":
208
+ unittest.main(verbosity=2)