diff --git a/backend/migrations/028_backlog_epic_hierarchy.sql b/backend/migrations/028_backlog_epic_hierarchy.sql new file mode 100644 index 0000000..1ba43ba --- /dev/null +++ b/backend/migrations/028_backlog_epic_hierarchy.sql @@ -0,0 +1,14 @@ +-- AP2.2f / ADP Backlog Epic-Hierarchie — parent_backlog_id + item_kind epic + +ALTER TABLE backlog_items + ADD COLUMN IF NOT EXISTS parent_backlog_id UUID NULL + REFERENCES backlog_items (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_backlog_parent_backlog + ON backlog_items (tenant_id, initiative_id, parent_backlog_id) + WHERE parent_backlog_id IS NOT NULL; + +ALTER TABLE backlog_items DROP CONSTRAINT IF EXISTS backlog_items_item_kind_check; +ALTER TABLE backlog_items + ADD CONSTRAINT backlog_items_item_kind_check + CHECK (item_kind IN ('story', 'bug', 'issue', 'epic')); diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py index 13ada14..28d018e 100644 --- a/backend/routers/backlog.py +++ b/backend/routers/backlog.py @@ -20,8 +20,9 @@ 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" + item_kind: Literal["story", "bug", "issue", "epic"] = "story" parent_action_id: Optional[str] = None + parent_backlog_id: Optional[str] = None class BacklogUpdateRequest(BaseModel): @@ -32,9 +33,11 @@ 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 + item_kind: Optional[Literal["story", "bug", "issue", "epic"]] = None parent_action_id: Optional[str] = None clear_parent_action: bool = False + parent_backlog_id: Optional[str] = None + clear_parent_backlog: bool = False class BacklogConvertRequest(BaseModel): @@ -77,6 +80,8 @@ def update_backlog_item( item_kind=body.item_kind, parent_action_id=body.parent_action_id, clear_parent_action=body.clear_parent_action, + parent_backlog_id=body.parent_backlog_id, + clear_parent_backlog=body.clear_parent_backlog, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -90,11 +95,15 @@ def delete_backlog_item( backlog_item_id: str, ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")), ): - if not backlog_service.delete_backlog_item( - tenant_id=ctx.tenant_id, - backlog_item_id=backlog_item_id, - user_id=ctx.user_id, - ): + try: + deleted = backlog_service.delete_backlog_item( + tenant_id=ctx.tenant_id, + backlog_item_id=backlog_item_id, + user_id=ctx.user_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not deleted: raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden") diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 985218b..1852741 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -89,8 +89,9 @@ 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" + item_kind: Literal["story", "bug", "issue", "epic"] = "story" parent_action_id: Optional[str] = None + parent_backlog_id: Optional[str] = None class MilestoneCreateRequest(BaseModel): @@ -438,6 +439,7 @@ def create_initiative_backlog_item( roadmap_item_id=body.roadmap_item_id, item_kind=body.item_kind, parent_action_id=body.parent_action_id, + parent_backlog_id=body.parent_backlog_id, user_id=ctx.user_id, ) except ValueError as exc: diff --git a/backend/services/backlog.py b/backend/services/backlog.py index eb8bc71..5d2f7b8 100644 --- a/backend/services/backlog.py +++ b/backend/services/backlog.py @@ -13,15 +13,16 @@ 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"] +BacklogItemKind = Literal["story", "bug", "issue", "epic"] BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"}) -BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue"}) +BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue", "epic"}) +CONVERTIBLE_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, item_kind, - parent_action_id, created_at, updated_at + parent_action_id, parent_backlog_id, created_at, updated_at """ @@ -34,6 +35,7 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: "converted_action_id", "roadmap_item_id", "parent_action_id", + "parent_backlog_id", ): if result.get(key): result[key] = str(result[key]) @@ -92,6 +94,48 @@ def _validate_parent_action_in_initiative( raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue") +def _validate_backlog_hierarchy( + *, + cur, + tenant_id: str, + initiative_id: str, + item_kind: str, + parent_backlog_id: Optional[str], + parent_action_id: Optional[str], + backlog_item_id: Optional[str] = None, +) -> None: + if item_kind == "epic": + if parent_backlog_id: + raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden") + if parent_action_id: + raise ValueError("Epic kann keinem Arbeitspaket zugeordnet werden") + return + + if not parent_backlog_id: + return + + if backlog_item_id and parent_backlog_id == backlog_item_id: + raise ValueError("Item kann nicht sich selbst als Epic referenzieren") + + cur.execute( + """ + SELECT id, item_kind, initiative_id + FROM backlog_items + WHERE id = %s AND tenant_id = %s + """, + (parent_backlog_id, tenant_id), + ) + parent = cur.fetchone() + if not parent: + raise ValueError("Epic-Referenz nicht gefunden") + parent_initiative = parent["initiative_id"] if isinstance(parent, dict) else parent[2] + parent_kind = parent["item_kind"] if isinstance(parent, dict) else parent[1] + if str(parent_initiative) != initiative_id: + raise ValueError("Epic gehört zu einem anderen Vorhaben") + if parent_kind != "epic": + raise ValueError("Übergeordnetes Item muss ein Epic sein") + + def create_backlog_item( *, tenant_id: str, @@ -104,6 +148,7 @@ def create_backlog_item( sort_order: Optional[int] = None, item_kind: BacklogItemKind = "story", parent_action_id: Optional[str] = None, + parent_backlog_id: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() @@ -130,6 +175,14 @@ def create_backlog_item( initiative_id=initiative_id, parent_action_id=parent_action_id, ) + _validate_backlog_hierarchy( + cur=cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + item_kind=item_kind, + parent_backlog_id=parent_backlog_id, + parent_action_id=parent_action_id, + ) cur.execute( """ SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order @@ -143,9 +196,9 @@ def create_backlog_item( f""" INSERT INTO backlog_items ( tenant_id, initiative_id, title, description, status, priority, - roadmap_item_id, sort_order, item_kind, parent_action_id + roadmap_item_id, sort_order, item_kind, parent_action_id, parent_backlog_id ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_BACKLOG_COLUMNS} """, ( @@ -159,6 +212,7 @@ def create_backlog_item( sort_order if sort_order is not None else next_order, item_kind, parent_action_id, + parent_backlog_id, ), ) row = _serialize_row(dict(cur.fetchone())) @@ -229,6 +283,8 @@ def update_backlog_item( item_kind: Optional[BacklogItemKind] = None, parent_action_id: Optional[str] = None, clear_parent_action: bool = False, + parent_backlog_id: Optional[str] = None, + clear_parent_backlog: bool = False, ) -> Optional[dict[str, Any]]: existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) if not existing: @@ -285,16 +341,46 @@ def update_backlog_item( elif parent_action_id is not None: updates.append("parent_action_id = %s") params.append(parent_action_id) + if clear_parent_backlog: + updates.append("parent_backlog_id = NULL") + elif parent_backlog_id is not None: + updates.append("parent_backlog_id = %s") + params.append(parent_backlog_id) if not updates: return existing + effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story" + effective_parent_backlog = ( + None + if clear_parent_backlog + else parent_backlog_id + if parent_backlog_id is not None + else existing.get("parent_backlog_id") + ) + effective_parent_action = ( + None + if clear_parent_action + else parent_action_id + if parent_action_id is not None + else existing.get("parent_action_id") + ) + updates.append("updated_at = NOW()") params.extend([backlog_item_id, tenant_id]) conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: + _validate_backlog_hierarchy( + cur=cur, + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + item_kind=effective_kind, + parent_backlog_id=effective_parent_backlog, + parent_action_id=effective_parent_action, + backlog_item_id=backlog_item_id, + ) if parent_action_id is not None and not clear_parent_action: _validate_parent_action_in_initiative( cur=cur, @@ -348,6 +434,18 @@ def delete_backlog_item( conn = get_connection() try: with conn.cursor() as cur: + cur.execute( + """ + SELECT id FROM backlog_items + WHERE parent_backlog_id = %s AND tenant_id = %s AND status != 'converted' + LIMIT 1 + """, + (backlog_item_id, tenant_id), + ) + if cur.fetchone(): + raise ValueError( + "Epic mit untergeordneten Items kann nicht gelöscht werden" + ) cur.execute( "DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id", (backlog_item_id, tenant_id), @@ -384,6 +482,10 @@ def convert_backlog_to_action( if existing["status"] not in ("accepted", "triaged", "new"): raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden") + item_kind = existing.get("item_kind") or "story" + if item_kind not in CONVERTIBLE_ITEM_KINDS: + raise ValueError("Epic kann nicht direkt in ein Arbeitspaket umgewandelt werden") + resolved_cycle_id = work_cycle_id if not resolved_cycle_id and assign_active_sprint: from services.work_cycle import get_active_work_cycle @@ -394,7 +496,6 @@ 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"], diff --git a/backend/tests/test_ap22f_backlog_epic_hierarchy.py b/backend/tests/test_ap22f_backlog_epic_hierarchy.py new file mode 100644 index 0000000..fb83523 --- /dev/null +++ b/backend/tests/test_ap22f_backlog_epic_hierarchy.py @@ -0,0 +1,161 @@ +"""AP2.2f — Backlog Epic-Hierarchie (parent_backlog_id + item_kind epic).""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def _create_product_initiative(client, token, title="Epic Product"): + created = _create_initiative( + client, + token, + title=title, + archetype_key="initiative.product", + ) + assert created.status_code == 201 + return created.json()["id"] + + +def test_create_epic_and_story_under_epic(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_product_initiative(client, token) + + epic = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Checkout Epic", "item_kind": "epic", "status": "accepted"}, + headers=_auth(token), + ) + assert epic.status_code == 201 + epic_id = epic.json()["id"] + assert epic.json()["item_kind"] == "epic" + assert epic.json().get("parent_backlog_id") is None + + story = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={ + "title": "Login Story", + "item_kind": "story", + "parent_backlog_id": epic_id, + "status": "accepted", + }, + headers=_auth(token), + ) + assert story.status_code == 201 + assert story.json()["parent_backlog_id"] == epic_id + + listed = client.get( + f"/api/initiatives/{initiative_id}/backlog", + headers=_auth(token), + ) + assert listed.status_code == 200 + by_id = {item["id"]: item for item in listed.json()} + assert by_id[story.json()["id"]]["parent_backlog_id"] == epic_id + + +def test_epic_cannot_convert_to_action(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_product_initiative(client, token, title="Epic Convert Block") + + epic = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Big Epic", "item_kind": "epic", "status": "accepted"}, + headers=_auth(token), + ) + epic_id = epic.json()["id"] + + converted = client.post( + f"/api/backlog/{epic_id}/convert-to-action", + json={}, + headers=_auth(token), + ) + assert converted.status_code == 400 + assert "Epic" in converted.json()["detail"] + + +def test_story_under_epic_converts_to_action(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_product_initiative(client, token, title="Story Convert") + + epic = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Epic", "item_kind": "epic", "status": "accepted"}, + headers=_auth(token), + ) + epic_id = epic.json()["id"] + + story = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={ + "title": "Story", + "item_kind": "story", + "parent_backlog_id": epic_id, + "status": "accepted", + }, + headers=_auth(token), + ) + story_id = story.json()["id"] + + converted = client.post( + f"/api/backlog/{story_id}/convert-to-action", + json={}, + headers=_auth(token), + ) + assert converted.status_code == 201 + assert converted.json()["action"]["title"] == "Story" + + +def test_cannot_delete_epic_with_children(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_product_initiative(client, token, title="Epic Delete Guard") + + epic = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Epic", "item_kind": "epic", "status": "accepted"}, + headers=_auth(token), + ) + epic_id = epic.json()["id"] + + client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={ + "title": "Child Story", + "item_kind": "story", + "parent_backlog_id": epic_id, + "status": "new", + }, + headers=_auth(token), + ) + + deleted = client.delete(f"/api/backlog/{epic_id}", headers=_auth(token)) + assert deleted.status_code == 400 + assert "untergeordneten" in deleted.json()["detail"] + + +def test_epic_cannot_have_parent_backlog(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_product_initiative(client, token, title="Epic Parent Guard") + + epic_a = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Epic A", "item_kind": "epic", "status": "accepted"}, + headers=_auth(token), + ) + epic_a_id = epic_a.json()["id"] + + epic_b = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={ + "title": "Epic B", + "item_kind": "epic", + "parent_backlog_id": epic_a_id, + "status": "accepted", + }, + headers=_auth(token), + ) + assert epic_b.status_code == 400 diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 2b4f9bf..6ef9519 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | `actions.sort_order` | ◐ | Migration 023 | | BacklogItem | ✓ | Gate-Zuordnung AP1.6, `sort_order` | | BacklogItem kinds (MVP) | ◐ | `story`/`bug`/`issue` AP2.2e — global, nicht profilgebunden | -| Backlog Epic-Hierarchie | ✗ | ADP P2: `epic` + `parent_backlog_id` — dokumentiert 2026-07-26 | +| Backlog Epic-Hierarchie | ◐ | AP2.2f: `epic` + `parent_backlog_id`, Baum-UI, Convert-Guard — Roll-up (P4) offen | | Dual Commit (Direct AP) | ◐ | ADP P1: Plan→Arbeit im Product-Outline (2026-07-26) | | Profil-Vokabular Backlog | ✗ | ADP P3: Continuous vs. Agile labels via Operating Context | | Blocker | ✓ | `action_id` optional | diff --git a/frontend/src/components/BacklogItemForm.jsx b/frontend/src/components/BacklogItemForm.jsx index d87d96c..d0e7528 100644 --- a/frontend/src/components/BacklogItemForm.jsx +++ b/frontend/src/components/BacklogItemForm.jsx @@ -1,45 +1,59 @@ +import { useMemo } from 'react' import { BACKLOG_STATUSES, BACKLOG_STATUS_LABELS, BACKLOG_ITEM_KINDS, + BACKLOG_ITEM_KINDS_WITH_EPIC, BACKLOG_ITEM_KIND_LABELS, PRIORITIES, PRIORITY_LABELS, } from '../constants/status.js' import { GateSelect } from './GateSelect.jsx' import { FeatureParentSelect } from './FeatureParentSelect.jsx' +import { EpicParentSelect } from './EpicParentSelect.jsx' export function BacklogItemForm({ initial = {}, roadmapItems = [], featureActions = [], + epicItems = [], + epicHierarchyEnabled = false, onSubmit, onCancel, busy = false, submitLabel = 'Speichern', allowStatus = true, }) { + const itemKinds = epicHierarchyEnabled ? BACKLOG_ITEM_KINDS_WITH_EPIC : BACKLOG_ITEM_KINDS + const initialKind = initial.item_kind || 'story' + const isEpic = initialKind === 'epic' + async function handleSubmit(e) { e.preventDefault() const form = e.target const gateValue = form.roadmap_item_id?.value ?? '' const parentValue = form.parent_action_id?.value ?? '' + const epicValue = form.parent_backlog_id?.value ?? '' + const itemKind = form.item_kind?.value || 'story' await onSubmit({ title: form.title.value.trim(), description: form.description.value, status: form.status?.value, priority: form.priority.value, - item_kind: form.item_kind?.value || 'story', + item_kind: itemKind, roadmap_item_id: gateValue || undefined, clear_roadmap_item: gateValue === '', - parent_action_id: parentValue || undefined, - clear_parent_action: parentValue === '', + parent_action_id: itemKind === 'epic' ? undefined : parentValue || undefined, + clear_parent_action: itemKind === 'epic' ? true : parentValue === '', + parent_backlog_id: itemKind === 'epic' ? undefined : epicValue || undefined, + clear_parent_backlog: itemKind === 'epic' ? true : epicValue === '', }) } const statusLocked = initial.status === 'converted' const showFeatureRef = - (initial.item_kind || 'story') !== 'story' || featureActions.length > 0 + !isEpic && + ((initial.item_kind || 'story') !== 'story' || featureActions.length > 0) return (
@@ -67,10 +81,10 @@ export function BacklogItemForm({ Typ {item.description}

)} + {item.parent_backlog_id && ( +

+ Epic: {epicTitleById.get(item.parent_backlog_id) || '…'} +

+ )} {item.parent_action_id && (

Feature: {actionTitleById(featureActions, item.parent_action_id) || '…'} @@ -334,8 +361,8 @@ export function BacklogSection({ {reorderable && !isDesktop && ( 0} - canMoveDown={index < sortedItems.length - 1} + canMoveUp={flatIndex > 0} + canMoveDown={flatIndex < sortedItems.length - 1} onMove={(direction) => handleMove(item.id, direction)} busy={busy} /> @@ -344,7 +371,7 @@ export function BacklogSection({ {canManage && item.status !== 'converted' && ( <> - {COMMITTABLE_STATUSES.has(item.status) && ( + {COMMITTABLE_STATUSES.has(item.status) && !isEpic && (