diff --git a/backend/routers/operational.py b/backend/routers/operational.py index 8607c55..5a76009 100644 --- a/backend/routers/operational.py +++ b/backend/routers/operational.py @@ -14,6 +14,7 @@ from services import backlog as backlog_service from services import blockers as blocker_service from services import decisions as decision_service from services import evidence as evidence_service +from services import recurring as recurring_service from services.audit import log_audit from tenant_context import TenantContext @@ -74,6 +75,21 @@ class BacklogProposalBody(BaseModel): roadmap_item_id: Optional[str] = None +class BacklogConvertBody(BaseModel): + work_cycle_id: Optional[str] = None + assign_active_sprint: bool = True + assigned_actor_ids: list[str] = Field(default_factory=list) + + +class RecurringStatusPatch(BaseModel): + status: Literal["active", "paused", "ended"] + + +class ActionWorkCyclePatch(BaseModel): + work_cycle_id: Optional[str] = None + clear_work_cycle: bool = False + + @router.get("/me") def operational_me(ctx: TenantContext = Depends(require_operational_capability("kairo.action.read"))): return _ok( @@ -309,3 +325,105 @@ def operational_backlog_proposal( raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc return _ok(ctx, item) + + +@router.post("/backlog/{backlog_item_id}/convert-to-action", status_code=201) +def operational_convert_backlog( + backlog_item_id: str, + body: BacklogConvertBody, + ctx: TenantContext = Depends(require_operational_capability("kairo.backlog.manage")), +): + assigned = body.assigned_actor_ids + if not assigned and ctx.actor_id: + assigned = [ctx.actor_id] + try: + result = backlog_service.convert_backlog_to_action( + tenant_id=ctx.tenant_id, + backlog_item_id=backlog_item_id, + user_id=ctx.user_id, + assigned_actor_ids=assigned, + work_cycle_id=body.work_cycle_id, + assign_active_sprint=body.assign_active_sprint, + ) + except ValueError as exc: + detail = str(exc) + if detail == "Backlog-Item nicht gefunden": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + + log_audit( + "operational.backlog.convert", + user_id=ctx.user_id or None, + tenant_id=ctx.tenant_id, + details={ + "backlog_item_id": backlog_item_id, + "action_id": result.get("action", {}).get("id"), + "work_cycle_id": body.work_cycle_id, + "actor_id": ctx.actor_id, + }, + ) + return _ok(ctx, result) + + +@router.patch("/recurring/{recurring_id}") +def operational_patch_recurring( + recurring_id: str, + body: RecurringStatusPatch, + ctx: TenantContext = Depends(require_operational_capability("kairo.recurring.manage")), +): + try: + item = recurring_service.update_recurring_element( + tenant_id=ctx.tenant_id, + recurring_id=recurring_id, + user_id=ctx.user_id, + status=body.status, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden") + + log_audit( + "operational.recurring.status", + user_id=ctx.user_id or None, + tenant_id=ctx.tenant_id, + details={ + "recurring_id": recurring_id, + "status": body.status, + "actor_id": ctx.actor_id, + }, + ) + return _ok(ctx, item) + + +@router.patch("/actions/{action_id}/work-cycle") +def operational_patch_action_work_cycle( + action_id: str, + body: ActionWorkCyclePatch, + ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")), +): + try: + item = action_service.update_action( + tenant_id=ctx.tenant_id, + action_id=action_id, + user_id=ctx.user_id or None, + work_cycle_id=body.work_cycle_id, + clear_work_cycle=body.clear_work_cycle, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not item: + raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden") + + log_audit( + "operational.action.work_cycle", + user_id=ctx.user_id or None, + tenant_id=ctx.tenant_id, + details={ + "action_id": action_id, + "work_cycle_id": body.work_cycle_id, + "clear_work_cycle": body.clear_work_cycle, + "actor_id": ctx.actor_id, + }, + ) + return _ok(ctx, item) diff --git a/backend/tests/test_ap17b_operational_api.py b/backend/tests/test_ap17b_operational_api.py new file mode 100644 index 0000000..5e0f5a8 --- /dev/null +++ b/backend/tests/test_ap17b_operational_api.py @@ -0,0 +1,136 @@ +"""AP1.7b — Operational API Pflege-Parität (Backlog-Commit, Recurring, Sprint-Zuweisung).""" + +from __future__ import annotations + +from services import actors as actor_service +from services.actor_service_tokens import create_service_token +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def _agent_token(admin, *, capabilities=None): + agent = actor_service.create_actor( + tenant_id=admin["tenant_id"], + actor_type="agent", + name="Ops Agent", + ) + caps = capabilities or [ + "kairo.action.read", + "kairo.action.manage", + "kairo.backlog.manage", + "kairo.recurring.manage", + "kairo.initiative.read", + ] + created = create_service_token( + tenant_id=admin["tenant_id"], + actor_id=agent["id"], + label="AP17b", + created_by_user_id=admin["id"], + capabilities=caps, + ) + return created["token"], agent["id"] + + +def test_operational_convert_backlog_to_sprint(client): + admin = provision_user_in_tenant(tenant_role="admin") + user_token = _login(client, admin) + actor_token, _ = _agent_token(admin) + + created = _create_initiative( + client, + user_token, + title="Product Ops", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + cycle = client.post( + f"/api/initiatives/{initiative_id}/work-cycles", + json={"title": "Sprint Ops", "status": "active"}, + headers=_auth(user_token), + ) + assert cycle.status_code == 201 + cycle_id = cycle.json()["id"] + + backlog = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Via Agent", "status": "accepted"}, + headers=_auth(user_token), + ) + backlog_id = backlog.json()["id"] + + converted = client.post( + f"/api/operational/backlog/{backlog_id}/convert-to-action", + json={"work_cycle_id": cycle_id, "assign_active_sprint": False}, + headers={"X-Actor-Token": actor_token}, + ) + assert converted.status_code == 201 + body = converted.json() + assert body["ok"] is True + assert body["data"]["action"]["work_cycle_id"] == cycle_id + + +def test_operational_patch_recurring_status(client): + admin = provision_user_in_tenant(tenant_role="admin") + user_token = _login(client, admin) + actor_token, _ = _agent_token(admin) + + created = _create_initiative( + client, + user_token, + title="Spagat Ops", + archetype_key="initiative.maturity_journey", + apply_starter_kit=True, + ) + initiative_id = created.json()["id"] + + recurring = client.get( + f"/api/initiatives/{initiative_id}/recurring", + headers=_auth(user_token), + ).json() + assert recurring + recurring_id = recurring[0]["id"] + + patched = client.patch( + f"/api/operational/recurring/{recurring_id}", + json={"status": "paused"}, + headers={"X-Actor-Token": actor_token}, + ) + assert patched.status_code == 200 + assert patched.json()["data"]["status"] == "paused" + + +def test_operational_assign_action_work_cycle(client): + admin = provision_user_in_tenant(tenant_role="admin") + user_token = _login(client, admin) + actor_token, _ = _agent_token(admin) + + created = _create_initiative( + client, + user_token, + title="Sprint Assign", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + cycle = client.post( + f"/api/initiatives/{initiative_id}/work-cycles", + json={"title": "Sprint B", "status": "planned"}, + headers=_auth(user_token), + ) + cycle_id = cycle.json()["id"] + + action = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Loose AP", "status": "open"}, + headers=_auth(user_token), + ) + action_id = action.json()["id"] + + assigned = client.patch( + f"/api/operational/actions/{action_id}/work-cycle", + json={"work_cycle_id": cycle_id}, + headers={"X-Actor-Token": actor_token}, + ) + assert assigned.status_code == 200 + assert assigned.json()["data"]["work_cycle_id"] == cycle_id diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 00e3f59..432b1c1 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -235,7 +235,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1 | AP1.9d | Work-Composition + Drill-down ◐→✓ (2026-07-27) | | AP2.2c–e | Referenz-Archetypen End-to-End ◐→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ | -| AP1.7b | Op-API Parität | +| AP1.7b | Op-API Parität ◐→✓ (2026-07-27) | --- diff --git a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md index e8cd9a3..32a5132 100644 --- a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md +++ b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md @@ -83,7 +83,7 @@ Phase 3 AP2.2b A2 Linear End-to-End ✓ AP2.2c A1 Reifegrad + AP2.0e ✓ AP2.2d B2b Product + B3 Sprint ✓ AP2.2e B2a Programm (optional vor AP2.1) -Phase 4 AP1.7b Operational API — Pflege-Parität +Phase 4 AP1.7b Operational API — Pflege-Parität ✓ Phase 5 AP2.1 Validation Report v0.3 (alle Stufe-A-Szenarien) Phase 6 AP2.2f Stufe B: A3, B1, D1 Phase 7 Schicht 4: Gitea-Webhook, MCP (nach AP2.1 Go)