@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.
Files changed (126) hide show
  1. package/.agents/skills/grill-me/SKILL.md +3 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +3 -1
  3. package/.agents/skills/kit-update/SKILL.md +9 -11
  4. package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
  5. package/.agents/skills/setup-workflow/SKILL.md +20 -18
  6. package/.agents/skills/setup-workflow/board-sync.md +4 -1
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
  9. package/.agents/skills/to-issues/SKILL.md +43 -0
  10. package/.agents/skills/wrapup/SKILL.md +98 -54
  11. package/.claude/skills/grill-me/SKILL.md +3 -1
  12. package/.claude/skills/grill-me-codex/SKILL.md +3 -1
  13. package/.claude/skills/grill-with-docs/SKILL.md +3 -1
  14. package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
  15. package/.claude/skills/kit-update/SKILL.md +9 -11
  16. package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
  17. package/.claude/skills/setup-workflow/SKILL.md +20 -18
  18. package/.claude/skills/setup-workflow/board-sync.md +4 -1
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
  20. package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
  21. package/.claude/skills/to-issues/SKILL.md +43 -0
  22. package/.claude/skills/wrapup/SKILL.md +98 -54
  23. package/README.md +249 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -12
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +26 -9
  29. package/scripts/worktree-lifecycle/README.md +57 -160
  30. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  31. package/scripts/worktree-lifecycle/classify.py +480 -0
  32. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  33. package/scripts/worktree-lifecycle/core.py +68 -1429
  34. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  35. package/scripts/worktree-lifecycle/profile.py +46 -29
  36. package/scripts/worktree-lifecycle/setup.py +7 -19
  37. package/scripts/wrapup-land.py +1292 -545
  38. package/src/consumer-migrations.json +1 -16
  39. package/src/lib/bundle.mjs +31 -1
  40. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  41. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  42. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  43. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  44. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  45. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  46. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  47. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  48. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  49. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  50. package/docs/agents/board-sync.md +0 -74
  51. package/docs/agents/code-review.md +0 -14
  52. package/docs/agents/workflow-capabilities.json +0 -28
  53. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  54. package/docs/research/benchlm-routing-source.md +0 -198
  55. package/docs/research/consumer-owned-protocol-files.md +0 -238
  56. package/docs/research/frontend-agent-benchmarks.md +0 -282
  57. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  58. package/docs/research/provider-neutral-agent-routing.md +0 -207
  59. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  60. package/docs/research/wave-43-script-hook-census.md +0 -128
  61. package/scripts/build-kit.mjs +0 -117
  62. package/scripts/build-kit.test.mjs +0 -207
  63. package/scripts/census/census.test.mjs +0 -252
  64. package/scripts/census/state.test.mjs +0 -15
  65. package/scripts/census/transaction.test.mjs +0 -103
  66. package/scripts/census-contract.test.mjs +0 -227
  67. package/scripts/check-kit-staleness.mjs +0 -45
  68. package/scripts/check-kit-staleness.test.mjs +0 -19
  69. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  70. package/scripts/codex-exec.test.mjs +0 -871
  71. package/scripts/grill-census-wiring-guard.mjs +0 -87
  72. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  73. package/scripts/kit-release.test.mjs +0 -273
  74. package/scripts/kit-update-pr.test.mjs +0 -151
  75. package/scripts/lib/audit-refs.mjs +0 -146
  76. package/scripts/lib/scrub.mjs +0 -113
  77. package/scripts/lib/scrub.test.mjs +0 -187
  78. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  79. package/scripts/portability_profile_scan.py +0 -53
  80. package/scripts/release-delta-guard.test.mjs +0 -174
  81. package/scripts/release-parity.test.mjs +0 -90
  82. package/scripts/release-state.test.mjs +0 -360
  83. package/scripts/test_anchor_table.py +0 -69
  84. package/scripts/test_board_bootstrap.py +0 -348
  85. package/scripts/test_board_sync.py +0 -208
  86. package/scripts/test_board_sync_create_idempotency.py +0 -117
  87. package/scripts/test_board_sync_wave_title.py +0 -120
  88. package/scripts/test_census_backstop.py +0 -712
  89. package/scripts/test_census_forward_contract.py +0 -91
  90. package/scripts/test_census_update_contract.test.mjs +0 -454
  91. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  92. package/scripts/test_dist_kit_smoke.py +0 -220
  93. package/scripts/test_drift_guard_diagnostics.py +0 -295
  94. package/scripts/test_issue_claim_contract.py +0 -169
  95. package/scripts/test_kit_docs_language_census.py +0 -178
  96. package/scripts/test_marker_lib.py +0 -162
  97. package/scripts/test_orchestrate_wave_contract.py +0 -381
  98. package/scripts/test_pr_body_check.py +0 -264
  99. package/scripts/test_profile_globs.py +0 -280
  100. package/scripts/test_program_planning_contract.py +0 -133
  101. package/scripts/test_release_authorization_contract.py +0 -101
  102. package/scripts/test_render_anchor.py +0 -267
  103. package/scripts/test_retro_wrapup_contract.py +0 -153
  104. package/scripts/test_skill_code_review_seed.py +0 -89
  105. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  106. package/scripts/test_skill_frontmatter_lint.py +0 -234
  107. package/scripts/test_skill_gh_lint.py +0 -124
  108. package/scripts/test_skill_language_census.py +0 -254
  109. package/scripts/test_skill_optional_readiness.py +0 -171
  110. package/scripts/test_skill_portability_lint.py +0 -866
  111. package/scripts/test_skill_precommit_template.py +0 -30
  112. package/scripts/test_skill_publish_audit.py +0 -358
  113. package/scripts/test_skill_readiness_contract.py +0 -264
  114. package/scripts/test_skill_readiness_preflight.py +0 -180
  115. package/scripts/test_skill_required_readiness.py +0 -233
  116. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  117. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  118. package/scripts/test_skill_stale_name_lint.py +0 -126
  119. package/scripts/test_skill_surface_refs.py +0 -300
  120. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  121. package/scripts/test_tdd_contract.py +0 -78
  122. package/scripts/test_worktree_ignore_seed.py +0 -320
  123. package/scripts/test_worktree_setup_base_guard.py +0 -140
  124. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  125. package/scripts/test_wrapup_land.py +0 -428
  126. 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)