From e65838118cf1e99aa254323953faed21f969678f Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 13 Jul 2026 08:07:02 +0200 Subject: [PATCH] AP2.2e: Unplan, Bug/Issue-Referenz und Sprint-Lebenszyklus. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ermöglicht APs zurück in den Eingang zu schieben, Bugs/Issues mit Feature-Bezug zu planen und den Scrum-Sprint-Ablauf in der UI. Co-authored-by: Cursor --- .../migrations/026_sprint_planning_kinds.sql | 26 +++ backend/routers/actions.py | 24 +- backend/routers/backlog.py | 8 + backend/routers/initiatives.py | 8 +- backend/services/actions.py | 209 +++++++++++++++++- backend/services/backlog.py | 89 +++++++- backend/services/work_cycle.py | 6 + .../test_ap22e_sprint_planning_lifecycle.py | 172 ++++++++++++++ frontend/src/api/actions.js | 4 + frontend/src/components/ActionHubCard.jsx | 29 ++- frontend/src/components/BacklogItemForm.jsx | 33 +++ frontend/src/components/BacklogSection.jsx | 20 ++ .../src/components/FeatureParentSelect.jsx | 25 +++ .../src/components/InitiativeActionsHub.jsx | 3 + frontend/src/components/WorkCyclesPanel.jsx | 47 +++- frontend/src/constants/status.js | 16 ++ .../context/InitiativeOperationsContext.jsx | 42 +++- .../pages/initiative/InitiativeInboxPage.jsx | 2 + frontend/src/pages/modes/PlanSprintPage.jsx | 2 + frontend/src/utils/actionReferences.js | 23 ++ frontend/src/utils/actionReferences.test.js | 20 ++ 21 files changed, 794 insertions(+), 14 deletions(-) create mode 100644 backend/migrations/026_sprint_planning_kinds.sql create mode 100644 backend/tests/test_ap22e_sprint_planning_lifecycle.py create mode 100644 frontend/src/components/FeatureParentSelect.jsx create mode 100644 frontend/src/utils/actionReferences.js create mode 100644 frontend/src/utils/actionReferences.test.js 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({
+ {action.action_kind && action.action_kind !== 'delivery' && ( + + {ACTION_KIND_LABELS[action.action_kind] || action.action_kind} + + )}
{action.description && ( @@ -103,6 +115,11 @@ export function ActionHubCard({ {action.roadmap_item_id && ( Gate: {gateTitleById(roadmapItems, action.roadmap_item_id) || '…'} )} + {action.parent_action_id && ( + + Feature: {actionTitleById(allActions, action.parent_action_id) || '…'} + + )} {action.assigned_actor_ids?.length > 0 && ( {action.assigned_actor_ids.length} zugewiesen )} @@ -174,6 +191,16 @@ export function ActionHubCard({ Blocker melden )} + {canUnplan && ( + + )} diff --git a/frontend/src/constants/status.js b/frontend/src/constants/status.js index 8756611..0da1de9 100644 --- a/frontend/src/constants/status.js +++ b/frontend/src/constants/status.js @@ -61,6 +61,22 @@ export const BACKLOG_STATUS_LABELS = { converted: 'Umgewandelt', } +export const BACKLOG_ITEM_KINDS = ['story', 'bug', 'issue'] +export const BACKLOG_ITEM_KIND_LABELS = { + story: 'Story', + bug: 'Bug', + issue: 'Issue', +} + +export const ACTION_KINDS = ['delivery', 'planning', 'review', 'bug', 'issue'] +export const ACTION_KIND_LABELS = { + delivery: 'Lieferung', + planning: 'Planung', + review: 'Review', + bug: 'Bugfix', + issue: 'Issue', +} + export const MILESTONE_STATUS_LABELS = { planned: 'Geplant', active: 'Aktiv', diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index 21048b6..ae346c7 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -14,7 +14,7 @@ import { createInitiativeAction, updateInitiative, } from '../api/initiatives.js' -import { updateAction, setActionAssignments } from '../api/actions.js' +import { updateAction, setActionAssignments, unplanActionToBacklog } from '../api/actions.js' import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js' import { listInitiativeBlockers, @@ -84,6 +84,7 @@ import { useSession } from './SessionContext.jsx' import { useOptionalProgramScope } from './ProgramScopeContext.jsx' import { collectProjectSubtreeIds } from '../utils/projectTree.js' import { filterActionsForWorkCycle } from '../utils/workCycleActions.js' +import { listFeatureParentActions } from '../utils/actionReferences.js' const InitiativeOperationsContext = createContext(null) @@ -262,6 +263,29 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ } } + function applyUnplanResult(result) { + if (!result) return + const backlogItem = result.backlog_item + const action = result.action + if (backlogItem?.id) { + setBacklogItems((prev) => { + const exists = prev.some((item) => item.id === backlogItem.id) + if (exists) { + return prev.map((item) => (item.id === backlogItem.id ? backlogItem : item)) + } + return [...prev, backlogItem] + }) + } + if (action?.id) { + setActions((prev) => prev.map((a) => (a.id === action.id ? action : a))) + } + } + + const featureParentActions = useMemo( + () => listFeatureParentActions(actions), + [actions], + ) + const actionContextById = useMemo( () => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])), [steeringSnapshot] @@ -511,6 +535,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ } } + async function handleUnplanAction(action) { + if (!action?.id) return + setFormBusy(true) + setError(null) + try { + const result = await unplanActionToBacklog(action.id) + applyUnplanResult(result) + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + async function handleCreateWorkCycle(body) { setFormBusy(true) try { @@ -861,6 +899,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ initiativeId: id, initiative, actions, + featureParentActions, visibleActions, blockers, unlinkedBlockers, @@ -919,6 +958,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ handleBacklogStatus, handleConvertBacklog, handleBulkConvertBacklog, + handleUnplanAction, handleCreateWorkCycle, handleActivateWorkCycle, handleCompleteWorkCycle, diff --git a/frontend/src/pages/initiative/InitiativeInboxPage.jsx b/frontend/src/pages/initiative/InitiativeInboxPage.jsx index 0c98d31..cfa4484 100644 --- a/frontend/src/pages/initiative/InitiativeInboxPage.jsx +++ b/frontend/src/pages/initiative/InitiativeInboxPage.jsx @@ -8,6 +8,7 @@ export function InitiativeInboxPage() { initiativeId, backlogItems, roadmapItems, + featureParentActions, workCycles, activeWorkCycle, capabilities, @@ -39,6 +40,7 @@ export function InitiativeInboxPage() { items={backlogItems} initiativeId={initiativeId} roadmapItems={roadmapItems} + featureActions={featureParentActions} workCycles={workCycles} activeWorkCycle={activeWorkCycle} sprintPlanningEnabled={isProductArchetype} diff --git a/frontend/src/pages/modes/PlanSprintPage.jsx b/frontend/src/pages/modes/PlanSprintPage.jsx index 74bef4b..f834a8e 100644 --- a/frontend/src/pages/modes/PlanSprintPage.jsx +++ b/frontend/src/pages/modes/PlanSprintPage.jsx @@ -43,6 +43,7 @@ function PlanSprintInner() { handleUpdateAction, handleQuickStatus, handleCreateBlockerForAction, + handleUnplanAction, } = ops const actionCountByCycleId = useMemo( @@ -103,6 +104,7 @@ function PlanSprintInner() { onUpdateAction={handleUpdateAction} onQuickStatus={handleQuickStatus} onCreateBlockerForAction={handleCreateBlockerForAction} + onUnplanToBacklog={handleUnplanAction} canManage={capabilities.has('kairo.action.manage')} canManageBlocker={capabilities.has('kairo.blocker.manage')} formBusy={formBusy} diff --git a/frontend/src/utils/actionReferences.js b/frontend/src/utils/actionReferences.js new file mode 100644 index 0000000..7fd5ace --- /dev/null +++ b/frontend/src/utils/actionReferences.js @@ -0,0 +1,23 @@ +/** + * Feature-Arbeitspakete, auf die Bugs/Issues referenzieren können. + * @param {Array<{ id: string, title: string, action_kind?: string, parent_action_id?: string, status?: string }>} actions + */ +export function listFeatureParentActions(actions) { + return (actions || []).filter( + (action) => + action.status !== 'discarded' && + action.status !== 'done' && + !['bug', 'issue'].includes(action.action_kind) && + !action.parent_action_id, + ) +} + +/** + * @param {Array<{ id: string, title: string }>} actions + * @param {string | null | undefined} actionId + */ +export function actionTitleById(actions, actionId) { + if (!actionId) return '' + const match = (actions || []).find((action) => action.id === actionId) + return match?.title || '' +} diff --git a/frontend/src/utils/actionReferences.test.js b/frontend/src/utils/actionReferences.test.js new file mode 100644 index 0000000..6a27576 --- /dev/null +++ b/frontend/src/utils/actionReferences.test.js @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { listFeatureParentActions, actionTitleById } from './actionReferences.js' + +describe('actionReferences', () => { + const actions = [ + { id: 'f1', title: 'Login', action_kind: 'delivery', status: 'open' }, + { id: 'b1', title: 'Bug', action_kind: 'bug', status: 'open', parent_action_id: 'f1' }, + { id: 'd1', title: 'Done', action_kind: 'delivery', status: 'done' }, + ] + + it('listFeatureParentActions excludes bugs and done', () => { + const features = listFeatureParentActions(actions) + expect(features.map((a) => a.id)).toEqual(['f1']) + }) + + it('actionTitleById resolves title', () => { + expect(actionTitleById(actions, 'f1')).toBe('Login') + expect(actionTitleById(actions, 'missing')).toBe('') + }) +})