diff --git a/backend/migrations/026_sprint_planning_kinds.sql b/backend/migrations/026_sprint_planning_kinds.sql new file mode 100644 index 0000000..815b949 --- /dev/null +++ b/backend/migrations/026_sprint_planning_kinds.sql @@ -0,0 +1,26 @@ +-- AP2.2e: Sprint-Planung — Item-Arten, Feature-Referenz, Unplan-Support + +ALTER TABLE actions DROP CONSTRAINT IF EXISTS actions_action_kind_check; +ALTER TABLE actions + ADD CONSTRAINT actions_action_kind_check + CHECK (action_kind IN ('delivery', 'planning', 'review', 'bug', 'issue')); + +ALTER TABLE actions + ADD COLUMN IF NOT EXISTS parent_action_id UUID NULL + REFERENCES actions (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_actions_parent + ON actions (tenant_id, initiative_id, parent_action_id) + WHERE parent_action_id IS NOT NULL; + +ALTER TABLE backlog_items + ADD COLUMN IF NOT EXISTS item_kind VARCHAR(32) NOT NULL DEFAULT 'story' + CHECK (item_kind IN ('story', 'bug', 'issue')); + +ALTER TABLE backlog_items + ADD COLUMN IF NOT EXISTS parent_action_id UUID NULL + REFERENCES actions (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_backlog_parent_action + ON backlog_items (tenant_id, initiative_id, parent_action_id) + WHERE parent_action_id IS NOT NULL; diff --git a/backend/routers/actions.py b/backend/routers/actions.py index 9fcd984..0342777 100644 --- a/backend/routers/actions.py +++ b/backend/routers/actions.py @@ -30,9 +30,11 @@ class ActionUpdateRequest(BaseModel): roadmap_item_id: Optional[str] = None clear_roadmap_item: bool = False sort_order: Optional[int] = None - action_kind: Optional[Literal["delivery", "planning", "review"]] = None + action_kind: Optional[Literal["delivery", "planning", "review", "bug", "issue"]] = None work_cycle_id: Optional[str] = None clear_work_cycle: bool = False + parent_action_id: Optional[str] = None + clear_parent_action: bool = False class TaskCreateRequest(BaseModel): @@ -143,6 +145,8 @@ def update_action( clear_work_cycle=body.clear_work_cycle, sort_order=body.sort_order, action_kind=body.action_kind, + parent_action_id=body.parent_action_id, + clear_parent_action=body.clear_parent_action, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -171,6 +175,24 @@ def set_action_assignments( return item +@router.post("/{action_id}/unplan-to-backlog", status_code=200) +def unplan_action_to_backlog( + action_id: str, + ctx: TenantContext = Depends(require_capability("kairo.action.manage")), +): + try: + return action_service.unplan_action_to_backlog( + tenant_id=ctx.tenant_id, + action_id=action_id, + user_id=ctx.user_id, + ) + except ValueError as exc: + detail = str(exc) + if detail == "Arbeitspaket nicht gefunden": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + + @router.delete("/{action_id}", status_code=204) def delete_action( action_id: str, diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py index 0d14adc..13ada14 100644 --- a/backend/routers/backlog.py +++ b/backend/routers/backlog.py @@ -20,6 +20,8 @@ class BacklogCreateRequest(BaseModel): priority: Literal["low", "normal", "high"] = "normal" roadmap_item_id: Optional[str] = None sort_order: Optional[int] = None + item_kind: Literal["story", "bug", "issue"] = "story" + parent_action_id: Optional[str] = None class BacklogUpdateRequest(BaseModel): @@ -30,6 +32,9 @@ class BacklogUpdateRequest(BaseModel): roadmap_item_id: Optional[str] = None clear_roadmap_item: bool = False sort_order: Optional[int] = None + item_kind: Optional[Literal["story", "bug", "issue"]] = None + parent_action_id: Optional[str] = None + clear_parent_action: bool = False class BacklogConvertRequest(BaseModel): @@ -69,6 +74,9 @@ def update_backlog_item( roadmap_item_id=body.roadmap_item_id, clear_roadmap_item=body.clear_roadmap_item, sort_order=body.sort_order, + item_kind=body.item_kind, + parent_action_id=body.parent_action_id, + clear_parent_action=body.clear_parent_action, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 86386da..ca59954 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -70,7 +70,8 @@ class ActionCreateRequest(BaseModel): work_cycle_id: Optional[str] = None assigned_actor_ids: list[str] = Field(default_factory=list) sort_order: int = 0 - action_kind: Literal["delivery", "planning", "review"] = "delivery" + action_kind: Literal["delivery", "planning", "review", "bug", "issue"] = "delivery" + parent_action_id: Optional[str] = None class BlockerCreateRequest(BaseModel): @@ -87,6 +88,8 @@ class BacklogCreateRequest(BaseModel): status: Literal["new", "triaged", "accepted", "rejected"] = "new" priority: Literal["low", "normal", "high"] = "normal" roadmap_item_id: Optional[str] = None + item_kind: Literal["story", "bug", "issue"] = "story" + parent_action_id: Optional[str] = None class MilestoneCreateRequest(BaseModel): @@ -339,6 +342,7 @@ def create_initiative_action( assigned_actor_ids=body.assigned_actor_ids, sort_order=body.sort_order, action_kind=body.action_kind, + parent_action_id=body.parent_action_id, user_id=ctx.user_id, ) except ValueError as exc: @@ -418,6 +422,8 @@ def create_initiative_backlog_item( status=body.status, priority=body.priority, roadmap_item_id=body.roadmap_item_id, + item_kind=body.item_kind, + parent_action_id=body.parent_action_id, user_id=ctx.user_id, ) except ValueError as exc: diff --git a/backend/services/actions.py b/backend/services/actions.py index e54c8f5..4e36dd0 100644 --- a/backend/services/actions.py +++ b/backend/services/actions.py @@ -26,15 +26,25 @@ OPEN_ACTION_STATUSES = frozenset( _ACTION_COLUMNS = """ id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description, - status, priority, due_at, sort_order, action_kind, created_at, updated_at + status, priority, due_at, sort_order, action_kind, parent_action_id, created_at, updated_at """ -ACTION_KINDS = frozenset({"delivery", "planning", "review"}) +ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue"}) +UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"}) def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "work_cycle_id", "owner_actor_id"): + for key in ( + "id", + "tenant_id", + "initiative_id", + "project_id", + "roadmap_item_id", + "work_cycle_id", + "parent_action_id", + "owner_actor_id", + ): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): @@ -46,6 +56,42 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: return result +def _validate_parent_action_in_initiative( + *, + cur, + tenant_id: str, + initiative_id: str, + parent_action_id: Optional[str], + action_id: Optional[str] = None, +) -> None: + if not parent_action_id: + return + if action_id and parent_action_id == action_id: + raise ValueError("Arbeitspaket kann nicht auf sich selbst verweisen") + cur.execute( + """ + SELECT action_kind + FROM actions + WHERE id = %s AND tenant_id = %s AND initiative_id = %s + """, + (parent_action_id, tenant_id, initiative_id), + ) + row = cur.fetchone() + if not row: + raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden") + action_kind = row["action_kind"] if isinstance(row, dict) else row[0] + if action_kind in ("bug", "issue"): + raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue") + + +def _action_kind_to_item_kind(action_kind: str) -> str: + if action_kind == "bug": + return "bug" + if action_kind == "issue": + return "issue" + return "story" + + def _validate_action_kind(action_kind: str) -> None: if action_kind not in ACTION_KINDS: raise ValueError(f"Ungültiger action_kind: {action_kind}") @@ -149,6 +195,7 @@ def create_action( assigned_actor_ids: Optional[list[str]] = None, sort_order: int = 0, action_kind: str = "delivery", + parent_action_id: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() @@ -183,13 +230,20 @@ def create_action( work_cycle_id=work_cycle_id, cur=cur, ) + _validate_parent_action_in_initiative( + cur=cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + parent_action_id=parent_action_id, + ) cur.execute( f""" INSERT INTO actions ( tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, - title, description, status, priority, due_at, sort_order, action_kind + title, description, status, priority, due_at, sort_order, action_kind, + parent_action_id ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_ACTION_COLUMNS} """, ( @@ -205,6 +259,7 @@ def create_action( due_at, sort_order, action_kind, + parent_action_id, ), ) row = _serialize_row(dict(cur.fetchone())) @@ -299,6 +354,8 @@ def update_action( clear_work_cycle: bool = False, sort_order: Optional[int] = None, action_kind: Optional[str] = None, + parent_action_id: Optional[str] = None, + clear_parent_action: bool = False, ) -> Optional[dict[str, Any]]: existing = get_action(tenant_id=tenant_id, action_id=action_id) if not existing: @@ -373,6 +430,11 @@ def update_action( _validate_action_kind(action_kind) updates.append("action_kind = %s") params.append(action_kind) + if clear_parent_action: + updates.append("parent_action_id = NULL") + elif parent_action_id is not None: + updates.append("parent_action_id = %s") + params.append(parent_action_id) if not updates: return existing @@ -383,6 +445,14 @@ def update_action( conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: + if parent_action_id is not None and not clear_parent_action: + _validate_parent_action_in_initiative( + cur=cur, + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + parent_action_id=parent_action_id, + action_id=action_id, + ) cur.execute( f""" UPDATE actions @@ -533,3 +603,132 @@ def list_open_actions_for_actor( finally: conn.close() return _attach_assignments(actions, tenant_id=tenant_id) + + +def unplan_action_to_backlog( + *, + tenant_id: str, + action_id: str, + user_id: Optional[str] = None, +) -> dict[str, Any]: + """Entfernt ein Arbeitspaket aus dem Sprint und stellt es im Eingang wieder her.""" + existing = get_action(tenant_id=tenant_id, action_id=action_id) + if not existing: + raise ValueError("Arbeitspaket nicht gefunden") + if existing["status"] not in UNPLAN_ALLOWED_STATUSES: + raise ValueError( + "Nur offene oder bereite Arbeitspakete können zurück in den Eingang" + ) + + initiative_id = existing["initiative_id"] + item_kind = _action_kind_to_item_kind(existing.get("action_kind") or "delivery") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id + FROM backlog_items + WHERE tenant_id = %s AND converted_action_id = %s + """, + (tenant_id, action_id), + ) + linked = cur.fetchone() + + if linked: + backlog_id = str(linked["id"]) + cur.execute( + """ + UPDATE backlog_items + SET status = 'accepted', + converted_action_id = NULL, + title = %s, + description = %s, + priority = %s, + roadmap_item_id = %s, + item_kind = %s, + parent_action_id = %s, + updated_at = NOW() + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, initiative_id, title, description, status, + priority, roadmap_item_id, converted_action_id, sort_order, + item_kind, parent_action_id, created_at, updated_at + """, + ( + existing["title"], + existing.get("description") or "", + existing["priority"], + existing.get("roadmap_item_id"), + item_kind, + existing.get("parent_action_id"), + backlog_id, + tenant_id, + ), + ) + backlog_row = _serialize_row(dict(cur.fetchone())) + else: + cur.execute( + """ + SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order + FROM backlog_items + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + next_order = int(cur.fetchone()["next_order"]) + cur.execute( + """ + INSERT INTO backlog_items ( + tenant_id, initiative_id, title, description, status, priority, + roadmap_item_id, sort_order, item_kind, parent_action_id + ) + VALUES (%s, %s, %s, %s, 'accepted', %s, %s, %s, %s, %s) + RETURNING id, tenant_id, initiative_id, title, description, status, + priority, roadmap_item_id, converted_action_id, sort_order, + item_kind, parent_action_id, created_at, updated_at + """, + ( + tenant_id, + initiative_id, + existing["title"], + existing.get("description") or "", + existing["priority"], + existing.get("roadmap_item_id"), + next_order, + item_kind, + existing.get("parent_action_id"), + ), + ) + backlog_row = _serialize_row(dict(cur.fetchone())) + + cur.execute( + """ + UPDATE actions + SET status = 'discarded', + work_cycle_id = NULL, + updated_at = NOW() + WHERE id = %s AND tenant_id = %s + RETURNING id, tenant_id, initiative_id, project_id, roadmap_item_id, + work_cycle_id, title, description, status, priority, due_at, + sort_order, action_kind, parent_action_id, created_at, updated_at + """, + (action_id, tenant_id), + ) + action_row = _serialize_row(dict(cur.fetchone())) + conn.commit() + finally: + conn.close() + + action_result = get_action(tenant_id=tenant_id, action_id=action_id) + log_audit( + "action.unplanned_to_backlog", + user_id=user_id, + tenant_id=tenant_id, + details={ + "action_id": action_id, + "backlog_item_id": backlog_row["id"], + "initiative_id": initiative_id, + }, + ) + return {"backlog_item": backlog_row, "action": action_result or action_row} diff --git a/backend/services/backlog.py b/backend/services/backlog.py index d509e4b..eb8bc71 100644 --- a/backend/services/backlog.py +++ b/backend/services/backlog.py @@ -13,18 +13,28 @@ from services.initiatives import PRIORITIES, get_initiative from services.plan_ist import validate_roadmap_item_in_initiative BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"] +BacklogItemKind = Literal["story", "bug", "issue"] BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"}) +BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue"}) _BACKLOG_COLUMNS = """ id, tenant_id, initiative_id, title, description, status, - priority, roadmap_item_id, converted_action_id, sort_order, created_at, updated_at + priority, roadmap_item_id, converted_action_id, sort_order, item_kind, + parent_action_id, created_at, updated_at """ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "initiative_id", "converted_action_id", "roadmap_item_id"): + for key in ( + "id", + "tenant_id", + "initiative_id", + "converted_action_id", + "roadmap_item_id", + "parent_action_id", + ): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): @@ -44,6 +54,44 @@ def _validate_priority(priority: str) -> None: raise ValueError(f"Ungültige Priorität: {priority}") +def _validate_item_kind(item_kind: str) -> None: + if item_kind not in BACKLOG_ITEM_KINDS: + raise ValueError(f"Ungültiger Backlog-Typ: {item_kind}") + + +def _item_kind_to_action_kind(item_kind: str) -> str: + if item_kind == "bug": + return "bug" + if item_kind == "issue": + return "issue" + return "delivery" + + +def _validate_parent_action_in_initiative( + *, + cur, + tenant_id: str, + initiative_id: str, + parent_action_id: Optional[str], +) -> None: + if not parent_action_id: + return + cur.execute( + """ + SELECT action_kind, parent_action_id + FROM actions + WHERE id = %s AND tenant_id = %s AND initiative_id = %s + """, + (parent_action_id, tenant_id, initiative_id), + ) + row = cur.fetchone() + if not row: + raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden") + action_kind = row["action_kind"] if isinstance(row, dict) else row[0] + if action_kind in ("bug", "issue"): + raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue") + + def create_backlog_item( *, tenant_id: str, @@ -54,6 +102,8 @@ def create_backlog_item( priority: str = "normal", roadmap_item_id: Optional[str] = None, sort_order: Optional[int] = None, + item_kind: BacklogItemKind = "story", + parent_action_id: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() @@ -61,6 +111,7 @@ def create_backlog_item( raise ValueError("Titel ist erforderlich") _validate_status(status) _validate_priority(priority) + _validate_item_kind(item_kind) if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") @@ -73,6 +124,12 @@ def create_backlog_item( initiative_id=initiative_id, roadmap_item_id=roadmap_item_id, ) + _validate_parent_action_in_initiative( + cur=cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + parent_action_id=parent_action_id, + ) cur.execute( """ SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order @@ -86,9 +143,9 @@ def create_backlog_item( f""" INSERT INTO backlog_items ( tenant_id, initiative_id, title, description, status, priority, - roadmap_item_id, sort_order + roadmap_item_id, sort_order, item_kind, parent_action_id ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_BACKLOG_COLUMNS} """, ( @@ -100,6 +157,8 @@ def create_backlog_item( priority, roadmap_item_id, sort_order if sort_order is not None else next_order, + item_kind, + parent_action_id, ), ) row = _serialize_row(dict(cur.fetchone())) @@ -167,6 +226,9 @@ def update_backlog_item( roadmap_item_id: Optional[str] = None, clear_roadmap_item: bool = False, sort_order: Optional[int] = None, + item_kind: Optional[BacklogItemKind] = None, + parent_action_id: Optional[str] = None, + clear_parent_action: bool = False, ) -> Optional[dict[str, Any]]: existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) if not existing: @@ -214,6 +276,15 @@ def update_backlog_item( if sort_order is not None: updates.append("sort_order = %s") params.append(sort_order) + if item_kind is not None: + _validate_item_kind(item_kind) + updates.append("item_kind = %s") + params.append(item_kind) + if clear_parent_action: + updates.append("parent_action_id = NULL") + elif parent_action_id is not None: + updates.append("parent_action_id = %s") + params.append(parent_action_id) if not updates: return existing @@ -224,6 +295,13 @@ def update_backlog_item( conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: + if parent_action_id is not None and not clear_parent_action: + _validate_parent_action_in_initiative( + cur=cur, + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + parent_action_id=parent_action_id, + ) cur.execute( f""" UPDATE backlog_items @@ -316,6 +394,7 @@ def convert_backlog_to_action( if active: resolved_cycle_id = active["id"] + item_kind = existing.get("item_kind") or "story" action = create_action( tenant_id=tenant_id, initiative_id=existing["initiative_id"], @@ -325,6 +404,8 @@ def convert_backlog_to_action( roadmap_item_id=existing.get("roadmap_item_id"), work_cycle_id=resolved_cycle_id, assigned_actor_ids=assigned_actor_ids or [], + action_kind=_item_kind_to_action_kind(item_kind), + parent_action_id=existing.get("parent_action_id"), user_id=user_id, ) diff --git a/backend/services/work_cycle.py b/backend/services/work_cycle.py index 1a65248..197e90e 100644 --- a/backend/services/work_cycle.py +++ b/backend/services/work_cycle.py @@ -213,6 +213,12 @@ def complete_work_cycle( conn.close() result = _serialize_cycle({**dict(row), "initiative_id": initiative_id}) + open_count = count_actions_in_work_cycle( + tenant_id=tenant_id, + initiative_id=initiative_id, + work_cycle_id=work_cycle_id, + ) + result["open_action_count_at_completion"] = open_count log_audit( "work_cycle.completed", user_id=user_id, diff --git a/backend/tests/test_ap22e_sprint_planning_lifecycle.py b/backend/tests/test_ap22e_sprint_planning_lifecycle.py new file mode 100644 index 0000000..d414b2b --- /dev/null +++ b/backend/tests/test_ap22e_sprint_planning_lifecycle.py @@ -0,0 +1,172 @@ +"""AP2.2e — Unplan, Bug/Issue mit Feature-Referenz, Sprint-Lifecycle.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_unplan_action_restores_backlog(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Sprint Planung E2E", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + cycle = client.post( + f"/api/initiatives/{initiative_id}/work-cycles", + json={"title": "Sprint 1", "status": "planned"}, + headers=_auth(token), + ) + assert cycle.status_code == 201 + cycle_id = cycle.json()["id"] + + backlog = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Story A", "status": "accepted"}, + headers=_auth(token), + ) + assert backlog.status_code == 201 + backlog_id = backlog.json()["id"] + + converted = client.post( + f"/api/backlog/{backlog_id}/convert-to-action", + json={"work_cycle_id": cycle_id, "assign_active_sprint": False}, + headers=_auth(token), + ) + assert converted.status_code == 201 + action_id = converted.json()["action"]["id"] + assert converted.json()["backlog_item"]["status"] == "converted" + + unplan = client.post( + f"/api/actions/{action_id}/unplan-to-backlog", + headers=_auth(token), + ) + assert unplan.status_code == 200 + body = unplan.json() + assert body["backlog_item"]["status"] == "accepted" + assert body["backlog_item"]["id"] == backlog_id + assert body["backlog_item"]["converted_action_id"] is None + assert body["action"]["status"] == "discarded" + assert body["action"]["work_cycle_id"] is None + + +def test_unplan_rejects_in_progress(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Unplan Guard", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + action = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Laufendes AP", "status": "in_progress"}, + headers=_auth(token), + ) + assert action.status_code == 201 + action_id = action.json()["id"] + + unplan = client.post( + f"/api/actions/{action_id}/unplan-to-backlog", + headers=_auth(token), + ) + assert unplan.status_code == 400 + + +def test_backlog_bug_with_feature_reference(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Bug Ref", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + cycle = client.post( + f"/api/initiatives/{initiative_id}/work-cycles", + json={"title": "Sprint 1", "status": "planned"}, + headers=_auth(token), + ) + assert cycle.status_code == 201 + cycle_id = cycle.json()["id"] + + feature = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Login Feature", "action_kind": "delivery"}, + headers=_auth(token), + ) + assert feature.status_code == 201 + feature_id = feature.json()["id"] + + bug = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={ + "title": "Login-Button kaputt", + "status": "accepted", + "item_kind": "bug", + "parent_action_id": feature_id, + }, + headers=_auth(token), + ) + assert bug.status_code == 201 + assert bug.json()["item_kind"] == "bug" + assert bug.json()["parent_action_id"] == feature_id + + converted = client.post( + f"/api/backlog/{bug.json()['id']}/convert-to-action", + json={"work_cycle_id": cycle_id, "assign_active_sprint": False}, + headers=_auth(token), + ) + assert converted.status_code == 201 + action = converted.json()["action"] + assert action["action_kind"] == "bug" + assert action["parent_action_id"] == feature_id + assert action["work_cycle_id"] == cycle_id + + +def test_complete_sprint_reports_open_actions(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Complete Stats", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + cycle = client.post( + f"/api/initiatives/{initiative_id}/work-cycles", + json={"title": "Sprint Active", "status": "active"}, + headers=_auth(token), + ) + assert cycle.status_code == 201 + cycle_id = cycle.json()["id"] + + client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Offen", "work_cycle_id": cycle_id, "status": "open"}, + headers=_auth(token), + ) + + completed = client.post( + f"/api/initiatives/{initiative_id}/work-cycles/{cycle_id}/complete", + headers=_auth(token), + ) + assert completed.status_code == 200 + assert completed.json()["status"] == "reached" + assert completed.json()["open_action_count_at_completion"] == 1 diff --git a/frontend/src/api/actions.js b/frontend/src/api/actions.js index fba1f9d..8a7fac8 100644 --- a/frontend/src/api/actions.js +++ b/frontend/src/api/actions.js @@ -22,6 +22,10 @@ export function deleteAction(id) { return apiFetch(`/api/actions/${id}`, { method: 'DELETE' }) } +export function unplanActionToBacklog(id) { + return apiFetch(`/api/actions/${id}/unplan-to-backlog`, { method: 'POST' }) +} + export function listMyOpenActions() { return apiFetch('/api/actions/me/open') } diff --git a/frontend/src/components/ActionHubCard.jsx b/frontend/src/components/ActionHubCard.jsx index f36ef95..fb64059 100644 --- a/frontend/src/components/ActionHubCard.jsx +++ b/frontend/src/components/ActionHubCard.jsx @@ -1,9 +1,10 @@ import { Link } from 'react-router-dom' -import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js' +import { ACTION_STATUSES, ACTION_STATUS_LABELS, ACTION_KIND_LABELS } from '../constants/status.js' import { StatusBadge } from './StatusBadge.jsx' import { PriorityBadge } from './PriorityBadge.jsx' import { ActionForm } from './ActionForm.jsx' import { gateTitleById } from './GateSelect.jsx' +import { actionTitleById } from '../utils/actionReferences.js' function formatDue(iso) { if (!iso) return null @@ -43,6 +44,7 @@ export function ActionHubCard({ onSubmitEdit, onQuickStatus, onCreateBlocker, + onUnplanToBacklog, canManage, canManageBlocker, formBusy, @@ -56,10 +58,15 @@ export function ActionHubCard({ roadmapItems = [], workCycles = [], activeWorkCycle = null, + allActions = [], detailMode = false, }) { const due = formatDue(action.due_at) const isBlocked = action.status === 'blocked' || context?.has_open_blocker + const canUnplan = + typeof onUnplanToBacklog === 'function' && + action.work_cycle_id && + ['open', 'ready'].includes(action.status) if (editing) { return ( @@ -94,6 +101,11 @@ export function ActionHubCard({