feat(AP1.7b): Op-API fuer Backlog-Commit, Recurring-Status und Sprint-Zuweisung
Some checks failed
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Failing after 4m37s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 18:06:46 +02:00
parent 252b3f05f2
commit 69c516dba4
4 changed files with 256 additions and 2 deletions

View File

@ -14,6 +14,7 @@ from services import backlog as backlog_service
from services import blockers as blocker_service from services import blockers as blocker_service
from services import decisions as decision_service from services import decisions as decision_service
from services import evidence as evidence_service from services import evidence as evidence_service
from services import recurring as recurring_service
from services.audit import log_audit from services.audit import log_audit
from tenant_context import TenantContext from tenant_context import TenantContext
@ -74,6 +75,21 @@ class BacklogProposalBody(BaseModel):
roadmap_item_id: Optional[str] = None 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") @router.get("/me")
def operational_me(ctx: TenantContext = Depends(require_operational_capability("kairo.action.read"))): def operational_me(ctx: TenantContext = Depends(require_operational_capability("kairo.action.read"))):
return _ok( return _ok(
@ -309,3 +325,105 @@ def operational_backlog_proposal(
raise HTTPException(status_code=404, detail=detail) from exc raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item) 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)

View File

@ -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

View File

@ -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) | | AP1.9d | Work-Composition + Drill-down ◐→✓ (2026-07-27) |
| AP2.2ce | Referenz-Archetypen End-to-End ◐→✓ | | AP2.2ce | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.1 | MVP-Abnahfe ✗→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ |
| AP1.7b | Op-API Parität | | AP1.7b | Op-API Parität ◐→✓ (2026-07-27) |
--- ---

View File

@ -83,7 +83,7 @@ Phase 3 AP2.2b A2 Linear End-to-End ✓
AP2.2c A1 Reifegrad + AP2.0e ✓ AP2.2c A1 Reifegrad + AP2.0e ✓
AP2.2d B2b Product + B3 Sprint ✓ AP2.2d B2b Product + B3 Sprint ✓
AP2.2e B2a Programm (optional vor AP2.1) 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 5 AP2.1 Validation Report v0.3 (alle Stufe-A-Szenarien)
Phase 6 AP2.2f Stufe B: A3, B1, D1 Phase 6 AP2.2f Stufe B: A3, B1, D1
Phase 7 Schicht 4: Gitea-Webhook, MCP (nach AP2.1 Go) Phase 7 Schicht 4: Gitea-Webhook, MCP (nach AP2.1 Go)