@ikon85/agent-workflow-kit 0.43.0 → 0.44.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 +3 -1
- package/.agents/skills/grill-with-docs/SKILL.md +3 -1
- package/.agents/skills/kit-update/SKILL.md +9 -11
- package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
- package/.agents/skills/setup-workflow/SKILL.md +20 -18
- package/.agents/skills/setup-workflow/board-sync.md +4 -1
- package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.agents/skills/to-issues/SKILL.md +43 -0
- package/.agents/skills/wrapup/SKILL.md +98 -54
- package/.claude/skills/grill-me/SKILL.md +3 -1
- package/.claude/skills/grill-me-codex/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
- package/.claude/skills/kit-update/SKILL.md +9 -11
- package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
- package/.claude/skills/setup-workflow/SKILL.md +20 -18
- package/.claude/skills/setup-workflow/board-sync.md +4 -1
- package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.claude/skills/to-issues/SKILL.md +43 -0
- package/.claude/skills/wrapup/SKILL.md +98 -54
- package/README.md +249 -7
- package/agent-workflow-kit.package.json +42 -42
- package/package.json +16 -12
- package/scripts/marker_lib.py +3 -0
- package/scripts/profile_globs.py +26 -45
- package/scripts/release-delta-guard.mjs +26 -9
- package/scripts/worktree-lifecycle/README.md +57 -160
- package/scripts/worktree-lifecycle/capabilities.json +2 -3
- package/scripts/worktree-lifecycle/classify.py +480 -0
- package/scripts/worktree-lifecycle/cleanup.py +9 -29
- package/scripts/worktree-lifecycle/core.py +68 -1429
- package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
- package/scripts/worktree-lifecycle/profile.py +46 -29
- package/scripts/worktree-lifecycle/setup.py +7 -19
- package/scripts/wrapup-land.py +1292 -545
- package/src/consumer-migrations.json +1 -16
- package/src/lib/bundle.mjs +31 -1
- package/docs/adr/0001-consumer-divergence-policy.md +0 -53
- package/docs/adr/0002-capability-gated-orchestration.md +0 -70
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
- package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
- package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
- package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
- package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
- package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
- package/docs/agents/board-sync.md +0 -74
- package/docs/agents/code-review.md +0 -14
- package/docs/agents/workflow-capabilities.json +0 -28
- package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
- package/docs/research/benchlm-routing-source.md +0 -198
- package/docs/research/consumer-owned-protocol-files.md +0 -238
- package/docs/research/frontend-agent-benchmarks.md +0 -282
- package/docs/research/model-effort-routing-benchmarks.md +0 -261
- package/docs/research/provider-neutral-agent-routing.md +0 -207
- package/docs/research/wave-152-consumer-acceptance.md +0 -98
- package/docs/research/wave-43-script-hook-census.md +0 -128
- package/scripts/build-kit.mjs +0 -117
- package/scripts/build-kit.test.mjs +0 -207
- package/scripts/census/census.test.mjs +0 -252
- package/scripts/census/state.test.mjs +0 -15
- package/scripts/census/transaction.test.mjs +0 -103
- package/scripts/census-contract.test.mjs +0 -227
- package/scripts/check-kit-staleness.mjs +0 -45
- package/scripts/check-kit-staleness.test.mjs +0 -19
- package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
- package/scripts/codex-exec.test.mjs +0 -871
- package/scripts/grill-census-wiring-guard.mjs +0 -87
- package/scripts/grill-census-wiring-guard.test.mjs +0 -107
- package/scripts/kit-release.test.mjs +0 -273
- package/scripts/kit-update-pr.test.mjs +0 -151
- package/scripts/lib/audit-refs.mjs +0 -146
- package/scripts/lib/scrub.mjs +0 -113
- package/scripts/lib/scrub.test.mjs +0 -187
- package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
- package/scripts/portability_profile_scan.py +0 -53
- package/scripts/release-delta-guard.test.mjs +0 -174
- package/scripts/release-parity.test.mjs +0 -90
- package/scripts/release-state.test.mjs +0 -360
- package/scripts/test_anchor_table.py +0 -69
- package/scripts/test_board_bootstrap.py +0 -348
- package/scripts/test_board_sync.py +0 -208
- package/scripts/test_board_sync_create_idempotency.py +0 -117
- package/scripts/test_board_sync_wave_title.py +0 -120
- package/scripts/test_census_backstop.py +0 -712
- package/scripts/test_census_forward_contract.py +0 -91
- package/scripts/test_census_update_contract.test.mjs +0 -454
- package/scripts/test_codex_adapter_sync_contract.py +0 -169
- package/scripts/test_dist_kit_smoke.py +0 -220
- package/scripts/test_drift_guard_diagnostics.py +0 -295
- package/scripts/test_issue_claim_contract.py +0 -169
- package/scripts/test_kit_docs_language_census.py +0 -178
- package/scripts/test_marker_lib.py +0 -162
- package/scripts/test_orchestrate_wave_contract.py +0 -381
- package/scripts/test_pr_body_check.py +0 -264
- package/scripts/test_profile_globs.py +0 -280
- package/scripts/test_program_planning_contract.py +0 -133
- package/scripts/test_release_authorization_contract.py +0 -101
- package/scripts/test_render_anchor.py +0 -267
- package/scripts/test_retro_wrapup_contract.py +0 -153
- package/scripts/test_skill_code_review_seed.py +0 -89
- package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
- package/scripts/test_skill_frontmatter_lint.py +0 -234
- package/scripts/test_skill_gh_lint.py +0 -124
- package/scripts/test_skill_language_census.py +0 -254
- package/scripts/test_skill_optional_readiness.py +0 -171
- package/scripts/test_skill_portability_lint.py +0 -866
- package/scripts/test_skill_precommit_template.py +0 -30
- package/scripts/test_skill_publish_audit.py +0 -358
- package/scripts/test_skill_readiness_contract.py +0 -264
- package/scripts/test_skill_readiness_preflight.py +0 -180
- package/scripts/test_skill_required_readiness.py +0 -233
- package/scripts/test_skill_selfcontainment_lint.py +0 -255
- package/scripts/test_skill_setup_workflow_seeds.py +0 -691
- package/scripts/test_skill_stale_name_lint.py +0 -126
- package/scripts/test_skill_surface_refs.py +0 -300
- package/scripts/test_skill_trailing_artifact_lint.py +0 -71
- package/scripts/test_tdd_contract.py +0 -78
- package/scripts/test_worktree_ignore_seed.py +0 -320
- package/scripts/test_worktree_setup_base_guard.py +0 -140
- package/scripts/test_worktree_wrapup_contract.py +0 -1737
- package/scripts/test_wrapup_land.py +0 -428
- package/scripts/worktree-lifecycle/session.py +0 -1857
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Focused regression tests for pure anchor-table status decisions."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import unittest
|
|
7
|
-
|
|
8
|
-
from scripts.anchor_table import refresh_status_cell, status_token_from_board
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
ROLES = {
|
|
12
|
-
"inProgress": "In Progress",
|
|
13
|
-
"review": "In Review",
|
|
14
|
-
"done": "Done",
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class AnchorTableStatusTest(unittest.TestCase):
|
|
19
|
-
def test_closed_done_issue_without_pr_is_done(self):
|
|
20
|
-
entry = {"state": "closed", "status": "Done", "prs": []}
|
|
21
|
-
|
|
22
|
-
self.assertEqual(status_token_from_board(entry, ROLES), "✅")
|
|
23
|
-
|
|
24
|
-
def test_open_issue_without_pr_is_never_done(self):
|
|
25
|
-
cases = (
|
|
26
|
-
({"state": "open", "status": "Done", "prs": []}, "⬜"),
|
|
27
|
-
({"state": "open", "status": "In Progress", "prs": []}, "🔄"),
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
for entry, expected in cases:
|
|
31
|
-
with self.subTest(status=entry["status"]):
|
|
32
|
-
self.assertEqual(status_token_from_board(entry, ROLES), expected)
|
|
33
|
-
|
|
34
|
-
def test_merged_pr_keeps_numbered_done_token(self):
|
|
35
|
-
entry = {
|
|
36
|
-
"state": "closed",
|
|
37
|
-
"status": "Done",
|
|
38
|
-
"prs": [{"state": "MERGED", "number": 203}],
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
self.assertEqual(status_token_from_board(entry, ROLES), "✅ #203")
|
|
42
|
-
|
|
43
|
-
def test_refresh_never_regresses_existing_progress(self):
|
|
44
|
-
open_unstarted = {"state": "open", "status": "To Do", "prs": []}
|
|
45
|
-
|
|
46
|
-
self.assertEqual(
|
|
47
|
-
refresh_status_cell("✅ spike proven", open_unstarted, ROLES),
|
|
48
|
-
"✅ spike proven",
|
|
49
|
-
)
|
|
50
|
-
self.assertEqual(
|
|
51
|
-
refresh_status_cell("🔄 spike running", open_unstarted, ROLES),
|
|
52
|
-
"🔄 spike running",
|
|
53
|
-
)
|
|
54
|
-
self.assertEqual(
|
|
55
|
-
refresh_status_cell("✅ #199", open_unstarted, ROLES),
|
|
56
|
-
"✅ #199",
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
def test_refresh_advances_to_prless_done(self):
|
|
60
|
-
closed_done = {"state": "closed", "status": "Done", "prs": []}
|
|
61
|
-
|
|
62
|
-
self.assertEqual(
|
|
63
|
-
refresh_status_cell("🔄 spike running", closed_done, ROLES),
|
|
64
|
-
"✅",
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if __name__ == "__main__":
|
|
69
|
-
unittest.main()
|
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Spec for `board_bootstrap.py` — offered board creation for /setup-workflow (#24).
|
|
3
|
-
|
|
4
|
-
Section D of `setup-workflow` used to refuse board creation outright. It now
|
|
5
|
-
OFFERS it, and the mechanical part (create → read back → validate → write the
|
|
6
|
-
profile) belongs to a deterministic helper rather than to prose.
|
|
7
|
-
|
|
8
|
-
Every path is exercised against a FAKE `gh` seam — this suite never touches the
|
|
9
|
-
live GitHub API and never creates a project:
|
|
10
|
-
|
|
11
|
-
* approval — the full sequence, ending in a profile `board_config` loads.
|
|
12
|
-
* missing scope — refused before the first write.
|
|
13
|
-
* decline — no invocation at all (the prose contract lives in
|
|
14
|
-
`test_skill_setup_workflow_seeds.py`).
|
|
15
|
-
* failure — a failed create, a missing field on readback, or a status
|
|
16
|
-
option a role maps to but the board lacks: no profile file.
|
|
17
|
-
|
|
18
|
-
Run: python3 scripts/test_board_bootstrap.py
|
|
19
|
-
"""
|
|
20
|
-
from __future__ import annotations
|
|
21
|
-
|
|
22
|
-
import io
|
|
23
|
-
import json
|
|
24
|
-
import unittest
|
|
25
|
-
from contextlib import redirect_stderr, redirect_stdout
|
|
26
|
-
from pathlib import Path
|
|
27
|
-
from tempfile import TemporaryDirectory
|
|
28
|
-
|
|
29
|
-
import board_bootstrap as bb
|
|
30
|
-
import board_config
|
|
31
|
-
|
|
32
|
-
REPO = Path(__file__).resolve().parent.parent
|
|
33
|
-
SEED = REPO / ".claude/skills/setup-workflow/board-sync.md"
|
|
34
|
-
|
|
35
|
-
PROJECT_NODE = "PVT_fakeproject"
|
|
36
|
-
PROJECT_NUMBER = 7
|
|
37
|
-
OWNER = "octo"
|
|
38
|
-
REPO_SLUG = "octo/widgets"
|
|
39
|
-
|
|
40
|
-
AUTH_TEMPLATE = """github.com
|
|
41
|
-
- Active account: true
|
|
42
|
-
- Token scopes: {scopes}
|
|
43
|
-
"""
|
|
44
|
-
|
|
45
|
-
WORKFLOW_FIELD_NAMES = ("Wave", "Cluster", "Spec-Path", "Plan-Path")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def seed_config(path=SEED):
|
|
49
|
-
return board_config.load_board_config(path)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def role_names(cfg=None):
|
|
53
|
-
return bb.status_option_names(cfg or seed_config())
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def field_list_payload(status_options=None, workflow=WORKFLOW_FIELD_NAMES):
|
|
57
|
-
"""A `gh project field-list --format json` payload in the shape the real CLI
|
|
58
|
-
emits (verified against a live board on 2026-07-26)."""
|
|
59
|
-
status_options = role_names() if status_options is None else status_options
|
|
60
|
-
fields = [{"id": "PVTF_fakeTitle", "name": "Title", "type": "ProjectV2Field"}]
|
|
61
|
-
fields.append({
|
|
62
|
-
"id": "PVTSSF_fakeStatus",
|
|
63
|
-
"name": "Status",
|
|
64
|
-
"type": "ProjectV2SingleSelectField",
|
|
65
|
-
"options": [{"id": f"opt{i}", "name": name} for i, name in enumerate(status_options)],
|
|
66
|
-
})
|
|
67
|
-
for name in workflow:
|
|
68
|
-
fields.append({"id": f"PVTF_fake{name.replace('-', '')}", "name": name,
|
|
69
|
-
"type": "ProjectV2Field"})
|
|
70
|
-
return {"fields": fields, "totalCount": len(fields)}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
class FakeGh:
|
|
74
|
-
"""Records every `gh` argv and answers from scripted payloads."""
|
|
75
|
-
|
|
76
|
-
def __init__(self, *, scopes="'gist', 'project', 'repo'", field_list=None,
|
|
77
|
-
fail_on_field=None, fail_on_create=False):
|
|
78
|
-
self.scopes = scopes
|
|
79
|
-
self.field_list = field_list_payload() if field_list is None else field_list
|
|
80
|
-
self.fail_on_field = fail_on_field
|
|
81
|
-
self.fail_on_create = fail_on_create
|
|
82
|
-
self.calls: list[list[str]] = []
|
|
83
|
-
|
|
84
|
-
@property
|
|
85
|
-
def write_calls(self):
|
|
86
|
-
return [c for c in self.calls if c[:2] in (["project", "create"],
|
|
87
|
-
["project", "field-create"])]
|
|
88
|
-
|
|
89
|
-
def _flag(self, args, flag):
|
|
90
|
-
return args[args.index(flag) + 1]
|
|
91
|
-
|
|
92
|
-
def __call__(self, args):
|
|
93
|
-
args = list(args)
|
|
94
|
-
self.calls.append(args)
|
|
95
|
-
head = args[:2]
|
|
96
|
-
if head == ["auth", "status"]:
|
|
97
|
-
return AUTH_TEMPLATE.format(scopes=self.scopes)
|
|
98
|
-
if head == ["project", "create"]:
|
|
99
|
-
if self.fail_on_create:
|
|
100
|
-
raise bb.BootstrapError("`gh project create` failed: HTTP 500")
|
|
101
|
-
return json.dumps({"id": PROJECT_NODE, "number": PROJECT_NUMBER,
|
|
102
|
-
"title": self._flag(args, "--title"),
|
|
103
|
-
"url": f"https://github.com/users/{OWNER}/projects/{PROJECT_NUMBER}"})
|
|
104
|
-
if head == ["project", "field-create"]:
|
|
105
|
-
name = self._flag(args, "--name")
|
|
106
|
-
if name == self.fail_on_field:
|
|
107
|
-
raise bb.BootstrapError(f"`gh project field-create` failed: {name} rejected")
|
|
108
|
-
return json.dumps({"id": f"PVTF_new{name}", "name": name})
|
|
109
|
-
if head == ["project", "field-list"]:
|
|
110
|
-
return json.dumps(self.field_list)
|
|
111
|
-
raise AssertionError(f"unexpected gh call: {' '.join(args)}")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def run_create(fake, out_path, *, seed=SEED, extra=()):
|
|
115
|
-
"""Invoke the CLI with `fake` patched in; return (exit_code, stdout, stderr)."""
|
|
116
|
-
argv = ["create", "--owner", OWNER, "--repo", REPO_SLUG, "--title", "Widgets Workflow",
|
|
117
|
-
"--seed", str(seed), "--out", str(out_path), *extra]
|
|
118
|
-
original = bb._gh
|
|
119
|
-
bb._gh = fake
|
|
120
|
-
out, err = io.StringIO(), io.StringIO()
|
|
121
|
-
try:
|
|
122
|
-
with redirect_stdout(out), redirect_stderr(err):
|
|
123
|
-
code = bb.main(argv)
|
|
124
|
-
finally:
|
|
125
|
-
bb._gh = original
|
|
126
|
-
return code, out.getvalue(), err.getvalue()
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
class ScopePreflight(unittest.TestCase):
|
|
130
|
-
def test_project_scope_present_is_no_finding(self):
|
|
131
|
-
self.assertEqual(bb.missing_scopes(AUTH_TEMPLATE.format(scopes="'repo', 'project'")), [])
|
|
132
|
-
|
|
133
|
-
def test_project_scope_absent_is_reported(self):
|
|
134
|
-
self.assertEqual(bb.missing_scopes(AUTH_TEMPLATE.format(scopes="'repo', 'gist'")),
|
|
135
|
-
["project"])
|
|
136
|
-
|
|
137
|
-
def test_missing_scope_refuses_before_any_write(self):
|
|
138
|
-
fake = FakeGh(scopes="'repo', 'gist'")
|
|
139
|
-
with TemporaryDirectory() as tmp:
|
|
140
|
-
out = Path(tmp) / "board-sync.md"
|
|
141
|
-
code, _, err = run_create(fake, out)
|
|
142
|
-
self.assertEqual(code, bb.EXIT_MISSING_SCOPE)
|
|
143
|
-
self.assertEqual(fake.write_calls, [], "no board may be created without the scope")
|
|
144
|
-
self.assertIn("gh auth refresh -s project,read:project", err)
|
|
145
|
-
self.assertFalse(out.exists(), "a scope failure must not write a profile")
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
class StatusOptionsComeFromTheProfile(unittest.TestCase):
|
|
149
|
-
def test_option_names_are_the_seeded_role_names_in_role_order(self):
|
|
150
|
-
cfg = seed_config()
|
|
151
|
-
roles = board_config.status_roles(cfg)
|
|
152
|
-
self.assertEqual(role_names(cfg),
|
|
153
|
-
[roles[key] for key in board_config.STATUS_ROLE_KEYS if key in roles])
|
|
154
|
-
|
|
155
|
-
def test_a_non_english_roles_map_yields_its_own_option_names(self):
|
|
156
|
-
cfg = seed_config()
|
|
157
|
-
cfg["fields"]["status"]["roles"] = {"spec": "Spécification", "inProgress": "En cours",
|
|
158
|
-
"done": "Terminé"}
|
|
159
|
-
self.assertEqual(bb.status_option_names(cfg), ["Spécification", "En cours", "Terminé"])
|
|
160
|
-
|
|
161
|
-
def test_an_empty_roles_map_is_a_hard_error_not_an_english_default(self):
|
|
162
|
-
cfg = seed_config()
|
|
163
|
-
cfg["fields"]["status"]["roles"] = {}
|
|
164
|
-
with self.assertRaises(bb.BootstrapError):
|
|
165
|
-
bb.status_option_names(cfg)
|
|
166
|
-
|
|
167
|
-
def test_a_comma_in_an_option_name_is_refused(self):
|
|
168
|
-
cfg = seed_config()
|
|
169
|
-
cfg["fields"]["status"]["roles"] = {"spec": "Spec, draft"}
|
|
170
|
-
with self.assertRaises(bb.BootstrapError):
|
|
171
|
-
bb.status_option_names(cfg)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
class ApprovalPath(unittest.TestCase):
|
|
175
|
-
def setUp(self):
|
|
176
|
-
self.tmp = TemporaryDirectory()
|
|
177
|
-
self.addCleanup(self.tmp.cleanup)
|
|
178
|
-
self.out = Path(self.tmp.name) / "board-sync.md"
|
|
179
|
-
self.fake = FakeGh()
|
|
180
|
-
self.code, self.stdout, self.stderr = run_create(self.fake, self.out)
|
|
181
|
-
|
|
182
|
-
def test_exit_is_clean(self):
|
|
183
|
-
self.assertEqual(self.code, 0, self.stderr)
|
|
184
|
-
|
|
185
|
-
def test_the_sequence_is_create_then_fields_then_readback(self):
|
|
186
|
-
shape = [tuple(c[:2]) for c in self.fake.calls]
|
|
187
|
-
self.assertEqual(shape, [
|
|
188
|
-
("auth", "status"),
|
|
189
|
-
("project", "create"),
|
|
190
|
-
*[("project", "field-create")] * (1 + len(WORKFLOW_FIELD_NAMES)),
|
|
191
|
-
("project", "field-list"),
|
|
192
|
-
])
|
|
193
|
-
|
|
194
|
-
def test_status_is_created_with_its_options_in_one_call(self):
|
|
195
|
-
call = next(c for c in self.fake.calls
|
|
196
|
-
if c[:2] == ["project", "field-create"] and "Status" in c)
|
|
197
|
-
self.assertIn("--single-select-options", call)
|
|
198
|
-
self.assertEqual(call[call.index("--single-select-options") + 1],
|
|
199
|
-
",".join(role_names()))
|
|
200
|
-
self.assertEqual(call[call.index("--data-type") + 1], "SINGLE_SELECT")
|
|
201
|
-
|
|
202
|
-
def test_every_workflow_field_is_created(self):
|
|
203
|
-
created = {c[c.index("--name") + 1] for c in self.fake.calls
|
|
204
|
-
if c[:2] == ["project", "field-create"]}
|
|
205
|
-
self.assertEqual(created, {"Status", *WORKFLOW_FIELD_NAMES})
|
|
206
|
-
|
|
207
|
-
def test_the_written_file_carries_the_filled_sentinel(self):
|
|
208
|
-
first = self.out.read_text(encoding="utf-8").splitlines()[0]
|
|
209
|
-
self.assertEqual(first, "<!-- setup-workflow: state=filled; mode=github-projects-v2 -->")
|
|
210
|
-
|
|
211
|
-
def test_board_config_loads_the_written_profile(self):
|
|
212
|
-
cfg = board_config.load_board_config(self.out) # raises ConfigError on any gap
|
|
213
|
-
self.assertEqual(cfg["repo"], REPO_SLUG)
|
|
214
|
-
self.assertEqual(cfg["project"], {"number": PROJECT_NUMBER, "owner": OWNER,
|
|
215
|
-
"nodeId": PROJECT_NODE})
|
|
216
|
-
|
|
217
|
-
def test_ids_come_from_the_readback_not_from_the_create_calls(self):
|
|
218
|
-
cfg = board_config.load_board_config(self.out)
|
|
219
|
-
by_name = {f["name"]: f for f in self.fake.field_list["fields"]}
|
|
220
|
-
self.assertEqual(cfg["fields"]["status"]["id"], by_name["Status"]["id"])
|
|
221
|
-
self.assertEqual(cfg["fields"]["wave"], by_name["Wave"]["id"])
|
|
222
|
-
self.assertEqual(cfg["fields"]["cluster"], by_name["Cluster"]["id"])
|
|
223
|
-
self.assertEqual(cfg["fields"]["specPath"], by_name["Spec-Path"]["id"])
|
|
224
|
-
self.assertEqual(cfg["fields"]["planPath"], by_name["Plan-Path"]["id"])
|
|
225
|
-
self.assertEqual(cfg["fields"]["status"]["options"],
|
|
226
|
-
{o["name"]: o["id"] for o in by_name["Status"]["options"]})
|
|
227
|
-
|
|
228
|
-
def test_every_status_role_resolves_to_a_real_option(self):
|
|
229
|
-
cfg = board_config.load_board_config(self.out)
|
|
230
|
-
options = cfg["fields"]["status"]["options"]
|
|
231
|
-
roles = board_config.status_roles(cfg)
|
|
232
|
-
self.assertTrue(roles)
|
|
233
|
-
for role, name in roles.items():
|
|
234
|
-
self.assertIn(name, options, f"role {role} maps to an option the board lacks")
|
|
235
|
-
|
|
236
|
-
def test_no_placeholder_survives_in_the_profile(self):
|
|
237
|
-
raw = self.out.read_text(encoding="utf-8")
|
|
238
|
-
block = raw.split("<!-- board-sync:profile -->", 1)[1].split("```")[1]
|
|
239
|
-
self.assertNotIn("<fill", block)
|
|
240
|
-
self.assertNotIn("<owner>", block)
|
|
241
|
-
|
|
242
|
-
def test_the_optional_phase_placeholder_is_not_claimed(self):
|
|
243
|
-
cfg = board_config.load_board_config(self.out)
|
|
244
|
-
self.assertIsNone(board_config.phase_field_id(cfg),
|
|
245
|
-
"an uncreated Phase field must not appear in the profile")
|
|
246
|
-
|
|
247
|
-
def test_the_seed_conventions_survive(self):
|
|
248
|
-
cfg = board_config.load_board_config(self.out)
|
|
249
|
-
seed = seed_config()
|
|
250
|
-
for key in ("labels", "branchPrefixes", "prMarkers", "headings", "titles", "wrapup"):
|
|
251
|
-
self.assertEqual(cfg.get(key), seed.get(key), key)
|
|
252
|
-
|
|
253
|
-
def test_the_documentation_body_survives(self):
|
|
254
|
-
self.assertIn("## Board profile", self.out.read_text(encoding="utf-8"))
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
class FailurePathsWriteNoProfile(unittest.TestCase):
|
|
258
|
-
def _run(self, fake, **kwargs):
|
|
259
|
-
with TemporaryDirectory() as tmp:
|
|
260
|
-
out = Path(tmp) / "board-sync.md"
|
|
261
|
-
code, _, err = run_create(fake, out, **kwargs)
|
|
262
|
-
return code, err, out.exists()
|
|
263
|
-
|
|
264
|
-
def test_failed_project_create_writes_nothing(self):
|
|
265
|
-
code, err, exists = self._run(FakeGh(fail_on_create=True))
|
|
266
|
-
self.assertEqual(code, bb.EXIT_FAILURE)
|
|
267
|
-
self.assertFalse(exists)
|
|
268
|
-
self.assertIn("gh project create", err)
|
|
269
|
-
|
|
270
|
-
def test_failed_field_create_writes_nothing_and_names_the_project(self):
|
|
271
|
-
fake = FakeGh(fail_on_field="Spec-Path")
|
|
272
|
-
code, err, exists = self._run(fake)
|
|
273
|
-
self.assertEqual(code, bb.EXIT_FAILURE)
|
|
274
|
-
self.assertFalse(exists, "a partial board must never produce a profile")
|
|
275
|
-
self.assertIn(str(PROJECT_NUMBER), err, "the half-created project must be named")
|
|
276
|
-
|
|
277
|
-
def test_a_field_missing_on_readback_fails_validation(self):
|
|
278
|
-
fake = FakeGh(field_list=field_list_payload(workflow=("Wave", "Cluster", "Spec-Path")))
|
|
279
|
-
code, err, exists = self._run(fake)
|
|
280
|
-
self.assertEqual(code, bb.EXIT_FAILURE)
|
|
281
|
-
self.assertFalse(exists)
|
|
282
|
-
self.assertIn("Plan-Path", err)
|
|
283
|
-
|
|
284
|
-
def test_a_status_option_missing_on_readback_fails_validation(self):
|
|
285
|
-
fake = FakeGh(field_list=field_list_payload(status_options=role_names()[:-1]))
|
|
286
|
-
code, err, exists = self._run(fake)
|
|
287
|
-
self.assertEqual(code, bb.EXIT_FAILURE)
|
|
288
|
-
self.assertFalse(exists)
|
|
289
|
-
self.assertIn("role", err)
|
|
290
|
-
|
|
291
|
-
def test_an_absent_status_field_fails_validation(self):
|
|
292
|
-
payload = field_list_payload()
|
|
293
|
-
payload["fields"] = [f for f in payload["fields"] if f["name"] != "Status"]
|
|
294
|
-
code, err, exists = self._run(FakeGh(field_list=payload))
|
|
295
|
-
self.assertEqual(code, bb.EXIT_FAILURE)
|
|
296
|
-
self.assertFalse(exists)
|
|
297
|
-
self.assertIn("Status", err)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
class DestinationGuard(unittest.TestCase):
|
|
301
|
-
def test_decision_table(self):
|
|
302
|
-
self.assertEqual(bb.destination_action(None), "create")
|
|
303
|
-
self.assertEqual(bb.destination_action(
|
|
304
|
-
"<!-- setup-workflow: state=stub; mode=github-projects-v2 -->"), "fill")
|
|
305
|
-
self.assertEqual(bb.destination_action(
|
|
306
|
-
"<!-- setup-workflow: state=filled; mode=github-projects-v2 -->"), "refuse")
|
|
307
|
-
self.assertEqual(bb.destination_action(
|
|
308
|
-
"<!-- setup-workflow: state=not-applicable; mode=none -->"), "refuse")
|
|
309
|
-
self.assertEqual(bb.destination_action("# Board sync"), "refuse")
|
|
310
|
-
|
|
311
|
-
def test_a_filled_profile_is_never_overwritten_and_nothing_is_created(self):
|
|
312
|
-
fake = FakeGh()
|
|
313
|
-
with TemporaryDirectory() as tmp:
|
|
314
|
-
out = Path(tmp) / "board-sync.md"
|
|
315
|
-
out.write_text("<!-- setup-workflow: state=filled; mode=github-projects-v2 -->\n# x\n",
|
|
316
|
-
encoding="utf-8")
|
|
317
|
-
code, _, err = run_create(fake, out)
|
|
318
|
-
self.assertEqual(code, bb.EXIT_REFUSED)
|
|
319
|
-
self.assertEqual(fake.calls, [], "the destination is checked before any gh call")
|
|
320
|
-
self.assertIn("state=filled", err)
|
|
321
|
-
self.assertTrue(out.read_text(encoding="utf-8").endswith("# x\n"))
|
|
322
|
-
|
|
323
|
-
def test_a_stub_destination_is_filled(self):
|
|
324
|
-
fake = FakeGh()
|
|
325
|
-
with TemporaryDirectory() as tmp:
|
|
326
|
-
out = Path(tmp) / "board-sync.md"
|
|
327
|
-
out.write_text("<!-- setup-workflow: state=stub; mode=github-projects-v2 -->\nold\n",
|
|
328
|
-
encoding="utf-8")
|
|
329
|
-
code, _, err = run_create(fake, out)
|
|
330
|
-
self.assertEqual(code, 0, err)
|
|
331
|
-
self.assertEqual(board_config.load_board_config(out)["repo"], REPO_SLUG)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
class DryRun(unittest.TestCase):
|
|
335
|
-
def test_dry_run_creates_nothing(self):
|
|
336
|
-
fake = FakeGh()
|
|
337
|
-
with TemporaryDirectory() as tmp:
|
|
338
|
-
out = Path(tmp) / "board-sync.md"
|
|
339
|
-
code, stdout, err = run_create(fake, out, extra=("--dry-run",))
|
|
340
|
-
self.assertEqual(code, 0, err)
|
|
341
|
-
self.assertEqual(fake.write_calls, [])
|
|
342
|
-
self.assertFalse(out.exists())
|
|
343
|
-
self.assertIn("gh project create", stdout)
|
|
344
|
-
self.assertIn(",".join(role_names()), stdout)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if __name__ == "__main__":
|
|
348
|
-
unittest.main(verbosity=2)
|
|
@@ -1,208 +0,0 @@
|
|
|
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)
|