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
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:
parent
252b3f05f2
commit
69c516dba4
|
|
@ -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)
|
||||||
|
|
|
||||||
136
backend/tests/test_ap17b_operational_api.py
Normal file
136
backend/tests/test_ap17b_operational_api.py
Normal 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
|
||||||
|
|
@ -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.2c–e | Referenz-Archetypen End-to-End ◐→✓ |
|
| AP2.2c–e | 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user