The mechanism named in canon/standards/work-record-types_v0.1.md:
"Promotion is a first-class transition... manual transcription of an
intake item into other kinds is a process defect." This is what AWQ-010
needed and didn't have -- a human/agent had to notice, transcribe, and
re-register it by hand. One call now does what that manual pass did.
scripts/promote_intake.py: intake.routed -> workplan | task | decision |
engagement.
- workplan: new ADR-001 file at workplans/{ID}-{slug}.md, registered
against the hub (repo+topic resolution, POST /workplans, frontmatter
id write-back)
- task: appended as a ```task``` block to an existing --workplan-file,
registered via POST /tasks, reuses the existing
_inject_task_id_into_block writeback helper
- decision: appended as a ```yaml``` block with a fresh
{PREFIX}-DEC-{YYYY}-{NNN} id to --target-file, registered the same
way C-32 registers decisions (reuses _inject_yaml_block_field)
- engagement: appended as a ```yaml``` block with a fresh
{PREFIX}-ENG-{YYYY}-{NNN} id -- file-only, no hub entity exists yet
(same honest deferral as C-32), reported not silently skipped
In every case the intake is closed with outcome=promoted and
promoted_to=<new canonical id>; the new record carries an
origin: "intake:<id>" back-link.
Wired as `statehub promote-intake <intake-id> --to <kind> --repo-slug
<slug> --repo-path <path> --domain <domain> [--target-file ...]
[--workplan-file ...]`, matching the CLI shape named in the workplan text.
17 tests: pure helpers (_slugify, _next_number, _append_yaml_block,
frontmatter injection) offline; full promote_intake() flow with the hub
API mocked.
Live-verified against the real running API/DB and a real repo
(binky-control), not just mocks -- and the live proof caught a real bug:
the first workplan-promotion run silently produced a false success (the
intake was closed outcome=promoted, but /workplans/ actually 422'd on a
missing repo_id that the code never resolved, so no workstream was ever
created). Fixed to resolve repo_id via /repos/{slug} and to raise loudly
on registration failure instead of writing a half-registered file
silently; locked in as two regression tests. Re-verified clean:
workplan promotion (CLI direct + through `statehub promote-intake`
itself) and decision promotion both proven end-to-end against the live
hub, with all scratch artefacts (files + hub rows) cleaned up afterward.
No regressions: full state-hub suite (271 tests across
test_promote_intake, test_intake, test_work_record_registration,
test_work_record_check, test_routers_core, test_consistency_check,
test_consistency_sweep, test_mcp_smoke, test_mcp_write_tools) green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
238 lines
10 KiB
Python
238 lines
10 KiB
Python
"""Tests for the promotion transition (CUST-WP-0061-T03,
|
|
scripts/promote_intake.py): intake.routed -> workplan | task | decision |
|
|
engagement.
|
|
|
|
Pure file-manipulation logic (_slugify, _next_number, _append_yaml_block,
|
|
_inject_field_in_frontmatter) is tested offline. The full promote_intake()
|
|
flow is tested with the hub API layer mocked (monkeypatched _api_get/
|
|
_api_post) so these run without a live server; the real end-to-end proof
|
|
(actual DB writes) is documented in CUST-WP-0061's progress notes, mirroring
|
|
the live-verification discipline used for T01/T02.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
|
|
|
import promote_intake as pi # noqa: E402
|
|
|
|
|
|
class TestSlugify:
|
|
def test_lowercases_and_hyphenates(self):
|
|
assert pi._slugify("Qonto MCP integration: bank account") == "qonto-mcp-integration-bank-account"
|
|
|
|
def test_truncates_long_titles(self):
|
|
long_title = "a " * 100
|
|
assert len(pi._slugify(long_title)) <= 60
|
|
|
|
def test_empty_title_gets_placeholder(self):
|
|
assert pi._slugify("!!!") == "untitled"
|
|
|
|
|
|
class TestNextNumber:
|
|
def test_finds_max_and_increments(self, tmp_path):
|
|
(tmp_path / "a.md").write_text("id: CUST-WP-0005\nid: CUST-WP-0012\n")
|
|
(tmp_path / "b.md").write_text("id: CUST-WP-0003\n")
|
|
import re
|
|
n = pi._next_number(tmp_path, re.compile(r"CUST-WP-(\d+)"))
|
|
assert n == 13
|
|
|
|
def test_empty_repo_starts_at_one(self, tmp_path):
|
|
import re
|
|
n = pi._next_number(tmp_path, re.compile(r"CUST-WP-(\d+)"))
|
|
assert n == 1
|
|
|
|
def test_skips_skip_dirs(self, tmp_path):
|
|
import re
|
|
skipped = tmp_path / ".git"
|
|
skipped.mkdir()
|
|
(skipped / "a.md").write_text("id: CUST-WP-9999\n")
|
|
n = pi._next_number(tmp_path, re.compile(r"CUST-WP-(\d+)"))
|
|
assert n == 1
|
|
|
|
|
|
class TestAppendYamlBlock:
|
|
def test_creates_new_file(self, tmp_path):
|
|
target = tmp_path / "DecisionQueue.md"
|
|
pi._append_yaml_block(target, {"id": "X-DEC-2026-001", "title": "test"}, origin_intake_id="abc-123")
|
|
text = target.read_text()
|
|
assert "id: \"X-DEC-2026-001\"" in text
|
|
assert 'origin: "intake:abc-123"' in text
|
|
|
|
def test_appends_to_existing_file(self, tmp_path):
|
|
target = tmp_path / "DecisionQueue.md"
|
|
target.write_text("# Decision Queue\n\nSome existing content.\n")
|
|
pi._append_yaml_block(target, {"id": "X-DEC-2026-002", "title": "second"}, origin_intake_id="def-456")
|
|
text = target.read_text()
|
|
assert "Some existing content." in text
|
|
assert "X-DEC-2026-002" in text
|
|
|
|
|
|
class TestInjectFieldInFrontmatter:
|
|
def test_injects_field_into_frontmatter(self, tmp_path):
|
|
f = tmp_path / "wp.md"
|
|
f.write_text("---\nid: CUST-WP-0099\ntitle: x\n---\n\nBody.\n")
|
|
pi._inject_field_in_frontmatter(f, "state_hub_workstream_id", "019f0000-0000-7000-8000-000000000000")
|
|
text = f.read_text()
|
|
assert 'state_hub_workstream_id: "019f0000-0000-7000-8000-000000000000"' in text
|
|
assert "Body." in text
|
|
|
|
|
|
class TestPromoteIntakeMocked:
|
|
"""Full promote_intake() flow with the hub API mocked."""
|
|
|
|
def _fake_routed_intake(self, **overrides):
|
|
base = {
|
|
"id": "019f0000-0000-7000-8000-000000000001",
|
|
"title": "Qonto MCP mailing — actionable",
|
|
"status": "routed",
|
|
"lane": "green",
|
|
"description": "found in mail triage",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
def test_rejects_intake_not_routed(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(pi, "_api_get", lambda *a, **k: self._fake_routed_intake(status="open"))
|
|
with pytest.raises(pi.PromotionError, match="not 'routed'"):
|
|
pi.promote_intake("http://x", "019f0000", "workplan", tmp_path, "testrepo", "infotech")
|
|
|
|
def test_promote_to_workplan_writes_file_and_closes_intake(self, tmp_path, monkeypatch):
|
|
calls = []
|
|
|
|
def fake_get(api_base, path, *a, **k):
|
|
if path.startswith("/intakes/"):
|
|
return self._fake_routed_intake()
|
|
if path == "/topics":
|
|
return [{"id": "019ftopic", "domain_slug": "infotech"}]
|
|
if path == "/repos/testrepo":
|
|
return {"id": "019frepo", "slug": "testrepo"}
|
|
return None
|
|
|
|
def fake_post(api_base, path, body):
|
|
calls.append((path, body))
|
|
if path == "/workplans":
|
|
return {"id": "019fworkplan", "title": body["title"]}
|
|
if path.startswith("/intakes/") and path.endswith("/close"):
|
|
return {"id": "019f0000", "status": "closed", "outcome": "promoted"}
|
|
return {"id": "019fgeneric"}
|
|
|
|
monkeypatch.setattr(pi, "_api_get", fake_get)
|
|
monkeypatch.setattr(pi, "_api_post", fake_post)
|
|
|
|
result = pi.promote_intake(
|
|
"http://x", "019f0000-0000-7000-8000-000000000001", "workplan",
|
|
tmp_path, "testrepo", "infotech",
|
|
)
|
|
|
|
assert result["to_kind"] == "workplan"
|
|
new_file = tmp_path / result["location"]
|
|
assert new_file.is_file()
|
|
text = new_file.read_text()
|
|
assert "Qonto MCP mailing" in text
|
|
assert 'state_hub_workstream_id: "019fworkplan"' in text
|
|
assert "intake:019f0000-0000-7000-8000-000000000001" in text
|
|
|
|
close_calls = [c for c in calls if c[0].endswith("/close")]
|
|
assert len(close_calls) == 1
|
|
assert close_calls[0][1]["outcome"] == "promoted"
|
|
assert close_calls[0][1]["promoted_to"] == result["canonical_id"]
|
|
|
|
def test_promote_to_workplan_raises_loudly_when_repo_lookup_fails(self, tmp_path, monkeypatch):
|
|
"""Regression: the live proof against binky-control (2026-07-21) found
|
|
/workplans/ requires repo_id, which promote_to_workplan originally
|
|
never resolved -- the POST 422'd and the failure was silently
|
|
swallowed (file written, no state_hub_workstream_id, no error).
|
|
Must raise, not write a half-registered file silently."""
|
|
def fake_get(api_base, path, *a, **k):
|
|
if path.startswith("/intakes/"):
|
|
return self._fake_routed_intake()
|
|
if path == "/topics":
|
|
return [{"id": "019ftopic", "domain_slug": "infotech"}]
|
|
if path == "/repos/testrepo":
|
|
return {"_error": "404: not found"}
|
|
return None
|
|
|
|
monkeypatch.setattr(pi, "_api_get", fake_get)
|
|
monkeypatch.setattr(pi, "_api_post", lambda *a, **k: {"id": "x"})
|
|
|
|
with pytest.raises(pi.PromotionError, match="could not look up repo"):
|
|
pi.promote_intake("http://x", "019f0000", "workplan", tmp_path, "testrepo", "infotech")
|
|
|
|
def test_promote_to_workplan_raises_loudly_when_workstream_post_fails(self, tmp_path, monkeypatch):
|
|
def fake_get(api_base, path, *a, **k):
|
|
if path.startswith("/intakes/"):
|
|
return self._fake_routed_intake()
|
|
if path == "/topics":
|
|
return [{"id": "019ftopic", "domain_slug": "infotech"}]
|
|
if path == "/repos/testrepo":
|
|
return {"id": "019frepo"}
|
|
return None
|
|
|
|
def fake_post(api_base, path, body):
|
|
if path == "/workplans":
|
|
return {"_error": "422: repo_id required"}
|
|
return {"id": "x"}
|
|
|
|
monkeypatch.setattr(pi, "_api_get", fake_get)
|
|
monkeypatch.setattr(pi, "_api_post", fake_post)
|
|
|
|
with pytest.raises(pi.PromotionError, match="workstream registration failed"):
|
|
pi.promote_intake("http://x", "019f0000", "workplan", tmp_path, "testrepo", "infotech")
|
|
|
|
def test_promote_to_workplan_refuses_existing_file(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(pi, "_api_get", lambda *a, **k: self._fake_routed_intake())
|
|
wp_dir = tmp_path / "workplans"
|
|
wp_dir.mkdir()
|
|
# pre-create the file the promotion would try to write
|
|
prefix = pi.infer_wp_prefix(tmp_path, "testrepo")
|
|
(wp_dir / f"{prefix}-0001-qonto-mcp-mailing-actionable.md").write_text("already here")
|
|
|
|
def fake_post(api_base, path, body):
|
|
return {"id": "x"}
|
|
|
|
monkeypatch.setattr(pi, "_api_post", fake_post)
|
|
with pytest.raises(pi.PromotionError, match="already exists"):
|
|
pi.promote_intake("http://x", "019f0000", "workplan", tmp_path, "testrepo", "infotech")
|
|
|
|
def test_promote_to_decision_requires_target_file(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(pi, "_api_get", lambda *a, **k: self._fake_routed_intake())
|
|
with pytest.raises(pi.PromotionError, match="--target-file"):
|
|
pi.promote_intake("http://x", "019f0000", "decision", tmp_path, "testrepo", "infotech")
|
|
|
|
def test_promote_to_task_requires_workplan_file(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(pi, "_api_get", lambda *a, **k: self._fake_routed_intake())
|
|
with pytest.raises(pi.PromotionError, match="--workplan-file"):
|
|
pi.promote_intake("http://x", "019f0000", "task", tmp_path, "testrepo", "infotech")
|
|
|
|
def test_promote_to_engagement_is_file_only_no_hub_call(self, tmp_path, monkeypatch):
|
|
post_calls = []
|
|
|
|
def fake_get(api_base, path, *a, **k):
|
|
return self._fake_routed_intake()
|
|
|
|
def fake_post(api_base, path, body):
|
|
post_calls.append(path)
|
|
if path.startswith("/intakes/") and path.endswith("/close"):
|
|
return {"id": "x", "status": "closed"}
|
|
return {"id": "x"}
|
|
|
|
monkeypatch.setattr(pi, "_api_get", fake_get)
|
|
monkeypatch.setattr(pi, "_api_post", fake_post)
|
|
|
|
result = pi.promote_intake(
|
|
"http://x", "019f0000", "engagement", tmp_path, "testrepo", "infotech",
|
|
target_file="OfficeHourQueue.md",
|
|
)
|
|
assert result["to_kind"] == "engagement"
|
|
target = tmp_path / "OfficeHourQueue.md"
|
|
assert target.is_file()
|
|
assert "queued" in target.read_text()
|
|
# only the intake-close call should have hit the API — no
|
|
# engagement-creation endpoint exists yet
|
|
non_close_calls = [c for c in post_calls if not c.endswith("/close")]
|
|
assert non_close_calls == []
|