diff --git a/api/services/repo_manager_dual_run.py b/api/services/repo_manager_dual_run.py index 2d2e8ce..49b9bed 100644 --- a/api/services/repo_manager_dual_run.py +++ b/api/services/repo_manager_dual_run.py @@ -249,6 +249,166 @@ def rm_update_task_status( return result +def rm_update_workplan( + *, + repo_path: str | Path, + workplan_id: str, + operation: str = "update", + title: str | None = None, + goal: str | None = None, + status: str | None = None, + owner: str | None = None, + domain: str | None = None, + topic_slug: str | None = None, + repo_slug: str | None = None, + reason: str = "state-hub dual-run", + push: bool | None = None, + correlation_id: str | None = None, + confirm_archive: bool = False, +) -> dict[str, Any]: + """Delegate a file-backed workplan mutation to Repo Manager. + + ``operation=archive`` is the recoverable counterpart of State Hub's + DELETE workplan route; it never erases repository history. + """ + correlation_id = correlation_id or str(uuid.uuid4()) + if operation not in {"create", "update", "archive"}: + return { + "status": "rejected", + "correlation_id": correlation_id, + "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, + } + if push is None: + push = writeback_push_enabled() + cli_operation = "delete" if operation == "archive" else operation + args = [ + "workplan", + cli_operation, + "--path", + str(repo_path), + "--workplan-id", + str(workplan_id), + "--reason", + reason, + "--correlation-id", + correlation_id, + "--idempotency-key", + f"sh-dual-workplan-{operation}-{workplan_id}-{correlation_id}", + ] + for flag, value in ( + ("--title", title), + ("--goal", goal), + ("--status", status), + ("--owner", owner), + ("--domain", domain), + ("--topic-slug", topic_slug), + ("--slug", repo_slug), + ): + if value is not None: + args.extend([flag, value]) + if operation == "archive" and confirm_archive: + args.append("--confirm") + if push: + args.append("--push") + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + "correlation_id": correlation_id, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + return result + + +def rm_update_register_entry( + *, + repo_path: str | Path, + kind: str, + entry_id: str, + operation: str = "put", + title: str | None = None, + status: str | None = None, + data: dict[str, Any] | None = None, + note: str | None = None, + author: str | None = None, + repo_slug: str | None = None, + reason: str = "state-hub retirement adapter", + push: bool | None = None, + correlation_id: str | None = None, +) -> dict[str, Any]: + """Delegate register create/update/defer/note to Repo Manager.""" + correlation_id = correlation_id or str(uuid.uuid4()) + if operation not in {"put", "defer", "note"}: + return { + "status": "rejected", + "correlation_id": correlation_id, + "error": {"code": "validation_error", "message": f"invalid operation {operation!r}"}, + } + if push is None: + push = writeback_push_enabled() + args = [ + "register", + operation, + "--path", + str(repo_path), + "--kind", + kind, + "--entry-id", + entry_id, + "--reason", + reason, + "--correlation-id", + correlation_id, + "--idempotency-key", + f"sh-dual-register-{operation}-{kind}-{entry_id}-{correlation_id}", + ] + for flag, value in ( + ("--title", title), + ("--status", status), + ("--note", note), + ("--author", author), + ("--slug", repo_slug), + ): + if value is not None: + args.extend([flag, value]) + if data is not None: + args.extend(["--data-json", json.dumps(data, separators=(",", ":"))]) + if push: + args.append("--push") + + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + "correlation_id": correlation_id, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + return result + + def rm_scaffold( *, repo_path: str | Path, diff --git a/tests/test_repo_manager_workplan_adapter.py b/tests/test_repo_manager_workplan_adapter.py new file mode 100644 index 0000000..5accfb3 --- /dev/null +++ b/tests/test_repo_manager_workplan_adapter.py @@ -0,0 +1,95 @@ +"""Compatibility adapter coverage for RMGR-WP-0008-T01.""" + +from api.services import repo_manager_dual_run as adapter + + +def test_rm_update_workplan_builds_governed_cli_command(monkeypatch): + seen = {} + + def fake_run(args, *, timeout=120): + seen["args"] = args + return 0, '{"status":"applied","evidence":{"git_sha":"abc"}}', "" + + monkeypatch.setattr(adapter, "run_rmgr", fake_run) + result = adapter.rm_update_workplan( + repo_path="/repos/demo", + workplan_id="DEMO-WP-0001", + operation="update", + title="Renamed", + status="active", + repo_slug="demo", + correlation_id="00000000-0000-4000-8000-000000000001", + push=True, + ) + + assert result["status"] == "applied" + assert seen["args"][:5] == [ + "workplan", + "update", + "--path", + "/repos/demo", + "--workplan-id", + ] + assert "--title" in seen["args"] + assert "--status" in seen["args"] + assert "--push" in seen["args"] + + +def test_rm_update_workplan_maps_delete_to_confirmed_archive(monkeypatch): + seen = {} + + def fake_run(args, *, timeout=120): + seen["args"] = args + return 0, '{"status":"applied"}', "" + + monkeypatch.setattr(adapter, "run_rmgr", fake_run) + result = adapter.rm_update_workplan( + repo_path="/repos/demo", + workplan_id="DEMO-WP-0001", + operation="archive", + confirm_archive=True, + push=False, + ) + + assert result["status"] == "applied" + assert seen["args"][0:2] == ["workplan", "delete"] + assert "--confirm" in seen["args"] + assert "--push" not in seen["args"] + + +def test_rm_update_workplan_rejects_unknown_operation_without_invocation(monkeypatch): + def fail_run(*args, **kwargs): + raise AssertionError("rmgr must not run") + + monkeypatch.setattr(adapter, "run_rmgr", fail_run) + result = adapter.rm_update_workplan( + repo_path="/repos/demo", + workplan_id="DEMO-WP-0001", + operation="erase", + ) + assert result["status"] == "rejected" + assert result["error"]["code"] == "validation_error" + + +def test_rm_update_register_entry_builds_shared_spine_command(monkeypatch): + seen = {} + + def fake_run(args, *, timeout=120): + seen["args"] = args + return 0, '{"status":"applied"}', "" + + monkeypatch.setattr(adapter, "run_rmgr", fake_run) + result = adapter.rm_update_register_entry( + repo_path="/repos/demo", + kind="technical-debt", + entry_id="TD-001", + operation="put", + title="Debt", + data={"severity": "high"}, + push=False, + ) + + assert result["status"] == "applied" + assert seen["args"][0:2] == ["register", "put"] + assert "technical-debt" in seen["args"] + assert '{"severity":"high"}' in seen["args"]