@ikon85/agent-workflow-kit 0.27.1 → 0.29.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/ask-matt/SKILL.md +2 -2
- package/.agents/skills/board-to-waves/SKILL.md +13 -3
- 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 +12 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.agents/skills/retro/SKILL.md +24 -1
- package/.agents/skills/scale-check/SKILL.md +2 -2
- package/.agents/skills/setup-workflow/SKILL.md +44 -1
- package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
- package/.agents/skills/tdd/SKILL.md +68 -14
- package/.agents/skills/to-issues/SKILL.md +104 -29
- package/.agents/skills/to-prd/SKILL.md +13 -3
- package/.agents/skills/to-waves/SKILL.md +16 -4
- package/.agents/skills/verify-spike/SKILL.md +1 -1
- package/.agents/skills/wrapup/SKILL.md +14 -3
- package/.claude/hooks/drift-guard.py +5 -2
- package/.claude/hooks/kit-origin-edit-hint.py +64 -0
- package/.claude/skills/ask-matt/SKILL.md +2 -2
- package/.claude/skills/board-to-waves/SKILL.md +13 -3
- package/.claude/skills/codex-build/SKILL.md +69 -23
- package/.claude/skills/codex-review/SKILL.md +47 -37
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-me-codex/SKILL.md +45 -34
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
- package/.claude/skills/kit-update/SKILL.md +12 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
- package/.claude/skills/retro/SKILL.md +23 -0
- package/.claude/skills/scale-check/SKILL.md +2 -2
- package/.claude/skills/setup-workflow/SKILL.md +44 -1
- package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/tdd/SKILL.md +68 -14
- package/.claude/skills/to-issues/SKILL.md +104 -29
- package/.claude/skills/to-prd/SKILL.md +13 -3
- package/.claude/skills/to-waves/SKILL.md +16 -4
- package/.claude/skills/verify-spike/SKILL.md +1 -1
- package/.claude/skills/wrapup/SKILL.md +14 -3
- package/README.md +70 -7
- package/agent-workflow-kit.package.json +99 -43
- package/docs/adr/0001-consumer-divergence-policy.md +49 -0
- package/docs/agents/wave-anchor-template.md +1 -1
- package/docs/research/wave-152-consumer-acceptance.md +98 -0
- package/package.json +1 -1
- package/scripts/board-sync.py +187 -12
- package/scripts/build-kit.test.mjs +42 -1
- package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
- package/scripts/codex-exec.sh +566 -0
- package/scripts/codex-exec.test.mjs +815 -0
- package/scripts/codex_proc.py +602 -0
- package/scripts/find-by-marker.py +68 -0
- package/scripts/marker_lib.py +88 -0
- package/scripts/pr-body-check.py +34 -6
- package/scripts/pr_body_e2e.py +83 -0
- package/scripts/render-anchor.py +111 -0
- package/scripts/test_board_sync.py +208 -0
- package/scripts/test_census_backstop.py +56 -3
- package/scripts/test_marker_lib.py +154 -0
- package/scripts/test_orchestrate_wave_contract.py +116 -0
- package/scripts/test_pr_body_check.py +245 -0
- package/scripts/test_render_anchor.py +267 -0
- package/scripts/test_retro_wrapup_contract.py +117 -0
- package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
- package/scripts/test_tdd_contract.py +78 -0
- package/src/cli.mjs +44 -8
- package/src/commands/diff.mjs +5 -2
- package/src/commands/init.mjs +12 -0
- package/src/commands/own.mjs +16 -0
- package/src/commands/uninstall.mjs +8 -1
- package/src/commands/update.mjs +37 -9
- package/src/lib/bundle.mjs +14 -0
- package/src/lib/consumerPath.mjs +30 -0
- package/src/lib/manifest.mjs +18 -0
- package/src/lib/ownedDiff.mjs +88 -0
- package/src/lib/updateCandidate.mjs +14 -0
- package/src/lib/updateReconcile.mjs +45 -6
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared exact-value HTML marker lookup for issue identity."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MARKER_KINDS = frozenset({
|
|
12
|
+
"prd-source-id",
|
|
13
|
+
"program-leaf-source",
|
|
14
|
+
"program-stub-source",
|
|
15
|
+
"wave-stub-source",
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnknownMarkerKind(ValueError):
|
|
20
|
+
"""Raised when a caller requests a marker outside the public grammar."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def marker_value(body: str, kind: str) -> str | None:
|
|
24
|
+
"""Return one allowlisted marker value from a body, trimmed exactly once."""
|
|
25
|
+
if kind not in MARKER_KINDS:
|
|
26
|
+
raise UnknownMarkerKind(f"unknown marker kind: {kind}")
|
|
27
|
+
match = re.search(rf"<!--\s*{re.escape(kind)}:\s*([^>]+?)\s*-->", body)
|
|
28
|
+
return match.group(1).strip() if match else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def first_marker(body: str, kinds: tuple[str, ...]) -> tuple[str, str] | None:
|
|
32
|
+
"""Return the first body-ordered marker among validated allowlisted kinds."""
|
|
33
|
+
unknown = next((kind for kind in kinds if kind not in MARKER_KINDS), None)
|
|
34
|
+
if unknown:
|
|
35
|
+
raise UnknownMarkerKind(f"unknown marker kind: {unknown}")
|
|
36
|
+
alternatives = "|".join(re.escape(kind) for kind in kinds)
|
|
37
|
+
match = re.search(rf"<!--\s*({alternatives}):\s*([^>]+?)\s*-->", body)
|
|
38
|
+
return (match.group(1), match.group(2).strip()) if match else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _issues_from_pages(raw: str) -> list[dict]:
|
|
42
|
+
pages = json.loads(raw)
|
|
43
|
+
if not pages:
|
|
44
|
+
return []
|
|
45
|
+
if isinstance(pages[0], dict):
|
|
46
|
+
return pages
|
|
47
|
+
return [issue for page in pages for issue in page]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def marker_verdict(issues: list[dict]) -> str:
|
|
51
|
+
"""Map exact identity matches onto the four-way consumer decision."""
|
|
52
|
+
if not issues:
|
|
53
|
+
return "create"
|
|
54
|
+
if len(issues) > 1:
|
|
55
|
+
return "STOP"
|
|
56
|
+
return "update" if issues[0]["state"] == "open" else "user-decision"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_by_marker(repo: str, kind: str, slug: str,
|
|
60
|
+
gh: Callable[[list[str]], str]) -> dict:
|
|
61
|
+
"""Scan every issue state and return exact marker matches plus a verdict."""
|
|
62
|
+
marker_value("", kind) # validate before making a network call
|
|
63
|
+
raw = gh([
|
|
64
|
+
"api", "--paginate", "--slurp", "--method", "GET",
|
|
65
|
+
f"repos/{repo}/issues", "-f", "state=all", "-f", "per_page=100",
|
|
66
|
+
])
|
|
67
|
+
matches = [
|
|
68
|
+
{"number": issue["number"], "state": issue["state"]}
|
|
69
|
+
for issue in _issues_from_pages(raw)
|
|
70
|
+
if "pull_request" not in issue
|
|
71
|
+
and marker_value(issue.get("body") or "", kind) == slug
|
|
72
|
+
]
|
|
73
|
+
return {
|
|
74
|
+
"count": len(matches),
|
|
75
|
+
"issues": matches,
|
|
76
|
+
"verdict": marker_verdict(matches),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def reconcile_after_create(repo: str, kind: str, slug: str,
|
|
81
|
+
created_number: int,
|
|
82
|
+
gh: Callable[[list[str]], str]) -> dict:
|
|
83
|
+
"""Re-scan after create and fail closed unless that open issue is unique."""
|
|
84
|
+
result = find_by_marker(repo, kind, slug, gh)
|
|
85
|
+
expected = [{"number": created_number, "state": "open"}]
|
|
86
|
+
if result["issues"] != expected:
|
|
87
|
+
result["verdict"] = "STOP"
|
|
88
|
+
return result
|
package/scripts/pr-body-check.py
CHANGED
|
@@ -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
|
|
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
|
|
21
|
-
`**Retro:**` line. It does NOT parse or validate
|
|
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
|
-
|
|
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,111 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pure rendering for a lean Tier-2 anchor and its full PRD archive."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
from marker_lib import marker_value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PLAN_REVISION_RE = re.compile(
|
|
16
|
+
r"^\s*\*\*plan_revision:\*\*\s*(r\d+)\s*$"
|
|
17
|
+
)
|
|
18
|
+
PLAN_REVISION_LOOKALIKE_RE = re.compile(r"^\s*\*\*plan_revision:\*\*.*$")
|
|
19
|
+
HTML_MARKER_RE = re.compile(
|
|
20
|
+
r"^\s*<!--\s*(prd-source-id|prd-content-fp|prd):\s*([^>\r\n]+?)\s*-->\s*$"
|
|
21
|
+
)
|
|
22
|
+
HTML_COMMENT_RE = re.compile(r"^\s*<!--.*-->\s*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RenderedDocuments(NamedTuple):
|
|
26
|
+
"""The two byte-stable documents produced by one render."""
|
|
27
|
+
|
|
28
|
+
anchor_body: str
|
|
29
|
+
archive_body: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _canonical_marker(line: str) -> tuple[str, str] | None:
|
|
33
|
+
revision = PLAN_REVISION_RE.fullmatch(line)
|
|
34
|
+
if revision:
|
|
35
|
+
return ("plan_revision", revision.group(1))
|
|
36
|
+
marker = HTML_MARKER_RE.fullmatch(line)
|
|
37
|
+
if not marker:
|
|
38
|
+
return None
|
|
39
|
+
kind = marker.group(1)
|
|
40
|
+
if kind == "prd-source-id":
|
|
41
|
+
value = marker_value(line, kind)
|
|
42
|
+
return (kind, value) if value is not None else None
|
|
43
|
+
return (kind, "")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _strip_head_markers(source: str) -> tuple[list[str], str]:
|
|
47
|
+
revisions: list[str] = []
|
|
48
|
+
end = 0
|
|
49
|
+
kept: list[str] = []
|
|
50
|
+
for line in source.splitlines(keepends=True):
|
|
51
|
+
bare = line.rstrip("\r\n")
|
|
52
|
+
marker = _canonical_marker(bare)
|
|
53
|
+
if (
|
|
54
|
+
not bare.strip()
|
|
55
|
+
or marker
|
|
56
|
+
or HTML_COMMENT_RE.fullmatch(bare)
|
|
57
|
+
or PLAN_REVISION_LOOKALIKE_RE.fullmatch(bare)
|
|
58
|
+
):
|
|
59
|
+
end += len(line)
|
|
60
|
+
if marker:
|
|
61
|
+
if marker[0] == "plan_revision":
|
|
62
|
+
revisions.append(marker[1])
|
|
63
|
+
else:
|
|
64
|
+
kept.append(line)
|
|
65
|
+
continue
|
|
66
|
+
break
|
|
67
|
+
if not any(line.strip() for line in kept):
|
|
68
|
+
kept = []
|
|
69
|
+
while kept and not kept[0].strip():
|
|
70
|
+
kept.pop(0)
|
|
71
|
+
return revisions, "".join(kept) + source[end:]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_documents(anchor_template: str, prd_body: str) -> RenderedDocuments:
|
|
75
|
+
"""Return the filled anchor template and marker-free PRD archive."""
|
|
76
|
+
revisions, archive_source = _strip_head_markers(prd_body)
|
|
77
|
+
if len(revisions) != 1:
|
|
78
|
+
raise ValueError("source PRD head must have exactly one canonical plan_revision")
|
|
79
|
+
revision = revisions[0]
|
|
80
|
+
archive_header = (
|
|
81
|
+
f"📄 Full PRD (archive, {revision}) — "
|
|
82
|
+
"the body carries navigation/decisions only\n\n"
|
|
83
|
+
)
|
|
84
|
+
return RenderedDocuments(anchor_template, archive_header + archive_source)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
88
|
+
parser = argparse.ArgumentParser(
|
|
89
|
+
description="Render one filled Tier-2 anchor or its marker-free PRD archive."
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument("--template", type=Path, required=True)
|
|
92
|
+
parser.add_argument("--prd", type=Path, required=True)
|
|
93
|
+
parser.add_argument("--document", choices=("anchor", "archive"), required=True)
|
|
94
|
+
return parser.parse_args(argv)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv: list[str] | None = None) -> int:
|
|
98
|
+
args = _parse_args(argv)
|
|
99
|
+
rendered = render_documents(
|
|
100
|
+
args.template.read_bytes().decode("utf-8"),
|
|
101
|
+
args.prd.read_bytes().decode("utf-8"),
|
|
102
|
+
)
|
|
103
|
+
output = (
|
|
104
|
+
rendered.anchor_body if args.document == "anchor" else rendered.archive_body
|
|
105
|
+
)
|
|
106
|
+
sys.stdout.buffer.write(output.encode("utf-8"))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
raise SystemExit(main())
|
|
@@ -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)
|