From f33135b87f560632967ed0d65a494fb9721f8bfd Mon Sep 17 00:00:00 2001
From: Lars
Date: Mon, 27 Jul 2026 09:22:20 +0200
Subject: [PATCH] feat(backlog): Epic-Hierarchie mit parent_backlog_id (ADP P2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Epics als Backlog-Container mit Baum-UI, Convert-Guard und pytest-Abdeckung — Roll-up bleibt für P4 offen.
Co-authored-by: Cursor
---
.../migrations/028_backlog_epic_hierarchy.sql | 14 ++
backend/routers/backlog.py | 23 ++-
backend/routers/initiatives.py | 4 +-
backend/services/backlog.py | 113 +++++++++++-
.../test_ap22f_backlog_epic_hierarchy.py | 161 ++++++++++++++++++
.../Kairo_Implementation_Truth_Table_v0.1.md | 2 +-
frontend/src/components/BacklogItemForm.jsx | 33 +++-
frontend/src/components/BacklogSection.jsx | 43 ++++-
frontend/src/components/EpicParentSelect.jsx | 24 +++
frontend/src/constants/status.js | 2 +
frontend/src/utils/backlogTree.js | 53 ++++++
frontend/src/utils/backlogTree.test.js | 28 +++
12 files changed, 473 insertions(+), 27 deletions(-)
create mode 100644 backend/migrations/028_backlog_epic_hierarchy.sql
create mode 100644 backend/tests/test_ap22f_backlog_epic_hierarchy.py
create mode 100644 frontend/src/components/EpicParentSelect.jsx
create mode 100644 frontend/src/utils/backlogTree.js
create mode 100644 frontend/src/utils/backlogTree.test.js
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 (
)}
+ {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 && (