diff --git a/backend/services/backlog.py b/backend/services/backlog.py index 5d2f7b8..95f014b 100644 --- a/backend/services/backlog.py +++ b/backend/services/backlog.py @@ -10,6 +10,7 @@ from db import get_connection from services.actions import create_action from services.audit import log_audit from services.initiatives import PRIORITIES, get_initiative +from services.operating_context import get_operating_context from services.plan_ist import validate_roadmap_item_in_initiative BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"] @@ -61,6 +62,28 @@ def _validate_item_kind(item_kind: str) -> None: raise ValueError(f"Ungültiger Backlog-Typ: {item_kind}") +def _backlog_vocabulary_for_initiative(*, tenant_id: str, initiative_id: str) -> dict[str, Any]: + context = get_operating_context(tenant_id=tenant_id, initiative_id=initiative_id) + if not context: + raise ValueError("Initiative nicht gefunden") + return context.get("backlog_vocabulary") or {} + + +def _validate_item_kind_for_initiative( + *, tenant_id: str, initiative_id: str, item_kind: str +) -> dict[str, Any]: + _validate_item_kind(item_kind) + vocabulary = _backlog_vocabulary_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + allowed = vocabulary.get("kinds") or [] + if allowed and item_kind not in allowed: + raise ValueError( + f"Backlog-Typ '{item_kind}' ist für dieses Vorhaben nicht erlaubt" + ) + return vocabulary + + def _item_kind_to_action_kind(item_kind: str) -> str: if item_kind == "bug": return "bug" @@ -103,7 +126,11 @@ def _validate_backlog_hierarchy( parent_backlog_id: Optional[str], parent_action_id: Optional[str], backlog_item_id: Optional[str] = None, + epic_hierarchy: bool = True, ) -> None: + if item_kind == "epic" and not epic_hierarchy: + raise ValueError("Epic ist für dieses Vorhaben nicht verfügbar") + if item_kind == "epic": if parent_backlog_id: raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden") @@ -114,6 +141,9 @@ def _validate_backlog_hierarchy( if not parent_backlog_id: return + if not epic_hierarchy: + raise ValueError("Epic-Zuordnung ist für dieses Vorhaben nicht verfügbar") + if backlog_item_id and parent_backlog_id == backlog_item_id: raise ValueError("Item kann nicht sich selbst als Epic referenzieren") @@ -156,7 +186,10 @@ def create_backlog_item( raise ValueError("Titel ist erforderlich") _validate_status(status) _validate_priority(priority) - _validate_item_kind(item_kind) + vocabulary = _validate_item_kind_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id, item_kind=item_kind + ) + epic_hierarchy = bool((vocabulary.get("capabilities") or {}).get("epic_hierarchy")) if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") @@ -182,6 +215,7 @@ def create_backlog_item( item_kind=item_kind, parent_backlog_id=parent_backlog_id, parent_action_id=parent_action_id, + epic_hierarchy=epic_hierarchy, ) cur.execute( """ @@ -336,6 +370,15 @@ def update_backlog_item( _validate_item_kind(item_kind) updates.append("item_kind = %s") params.append(item_kind) + + effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story" + vocabulary = _validate_item_kind_for_initiative( + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + item_kind=effective_kind, + ) + epic_hierarchy = bool((vocabulary.get("capabilities") or {}).get("epic_hierarchy")) + if clear_parent_action: updates.append("parent_action_id = NULL") elif parent_action_id is not None: @@ -350,7 +393,6 @@ def update_backlog_item( 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 @@ -380,6 +422,7 @@ def update_backlog_item( parent_backlog_id=effective_parent_backlog, parent_action_id=effective_parent_action, backlog_item_id=backlog_item_id, + epic_hierarchy=epic_hierarchy, ) if parent_action_id is not None and not clear_parent_action: _validate_parent_action_in_initiative( diff --git a/backend/services/operating_context.py b/backend/services/operating_context.py index ede67fd..63494d6 100644 --- a/backend/services/operating_context.py +++ b/backend/services/operating_context.py @@ -15,6 +15,8 @@ from entity_archetypes.registry import ( from services.initiatives import get_initiative from services.steering_context import ensure_steering_context, get_steering_context from steering.graph.profiles import LIGHT, graph_profile_as_dict +from steering.backlog_vocabulary import resolve_backlog_vocabulary +from steering.effective_contract import resolve_effective_steering_elements from steering.methods.registry import ( get_method, list_compatible_methods, @@ -169,6 +171,25 @@ def get_operating_context( method_key=method_key, ) method_contract = _resolve_method_contract(method_key) + active_composition_modifier = ( + "agile_iteration" + if _resolve_active_agile_composition( + tenant_id=tenant_id, + initiative_id=initiative_id, + primary_method_key=method_key, + ) + else None + ) + effective_steering_elements = resolve_effective_steering_elements( + method_key, + data_slices=data_slices, + active_composition_modifier=active_composition_modifier, + ) + backlog_vocabulary = resolve_backlog_vocabulary( + data_slices=data_slices, + method_key=method_key, + steering_elements=effective_steering_elements, + ) return { "initiative_id": initiative_id, @@ -179,7 +200,8 @@ def get_operating_context( "om_capabilities": sorted(om_capabilities), "data_slices": data_slices, "method_capabilities": _method_capabilities(method_key), - "steering_elements": method_contract["steering_elements"], + "steering_elements": sorted(effective_steering_elements), + "primary_steering_elements": method_contract["steering_elements"], "ui_features": method_contract["ui_features"], "graph_profile": method_contract["graph_profile"], "compatible_methods": _compatible_methods_payload( @@ -191,15 +213,8 @@ def get_operating_context( method_to_dict(m, include_compatibility=False) for m in list_composable_modifiers(method_key) ], - "active_composition_modifier": ( - "agile_iteration" - if _resolve_active_agile_composition( - tenant_id=tenant_id, - initiative_id=initiative_id, - primary_method_key=method_key, - ) - else None - ), + "active_composition_modifier": active_composition_modifier, + "backlog_vocabulary": backlog_vocabulary, } diff --git a/backend/steering/backlog_vocabulary.py b/backend/steering/backlog_vocabulary.py new file mode 100644 index 0000000..567f561 --- /dev/null +++ b/backend/steering/backlog_vocabulary.py @@ -0,0 +1,95 @@ +"""Profilgebundenes Backlog-Vokabular — ADP P3 / AP2.4 Methoden-Vertrag.""" + +from __future__ import annotations + +from typing import Any + +_EMPTY: dict[str, Any] = { + "profile_key": "none", + "kinds": [], + "labels": {}, + "default_kind": "story", + "epic_hierarchy": False, + "convertible_kinds": [], + "capabilities": { + "sprint_commit": False, + "epic_hierarchy": False, + }, +} + +_AGILE_INTAKE: dict[str, Any] = { + "profile_key": "agile_intake", + "kinds": ["epic", "story", "bug", "issue"], + "labels": { + "epic": "Epic", + "story": "Story", + "bug": "Bug", + "issue": "Issue", + }, + "default_kind": "story", + "epic_hierarchy": True, + "convertible_kinds": ["story", "bug", "issue"], +} + +_CONTINUOUS_INTAKE: dict[str, Any] = { + "profile_key": "continuous_intake", + "kinds": ["story", "bug", "issue"], + "labels": { + "story": "Idee", + "bug": "Bug", + "issue": "Störung", + }, + "default_kind": "story", + "epic_hierarchy": False, + "convertible_kinds": ["story", "bug", "issue"], +} + +_STANDARD_INTAKE: dict[str, Any] = { + "profile_key": "standard_intake", + "kinds": ["story", "bug", "issue"], + "labels": { + "story": "Story", + "bug": "Bug", + "issue": "Issue", + }, + "default_kind": "story", + "epic_hierarchy": False, + "convertible_kinds": ["story", "bug", "issue"], +} + + +def _with_capabilities( + vocabulary: dict[str, Any], + *, + steering_elements: frozenset[str], +) -> dict[str, Any]: + result = dict(vocabulary) + sprint_commit = "work_cycle_scope" in steering_elements + epic_hierarchy = "backlog_epic_hierarchy" in steering_elements + result["epic_hierarchy"] = epic_hierarchy + result["capabilities"] = { + "sprint_commit": sprint_commit, + "epic_hierarchy": epic_hierarchy, + } + return result + + +def resolve_backlog_vocabulary( + *, + data_slices: list[str], + method_key: str, + steering_elements: frozenset[str], +) -> dict[str, Any]: + """Backlog-Typen/Labels aus effektivem Methoden-Vertrag — nicht aus Page-Ifs.""" + if "backlog" not in data_slices: + return dict(_EMPTY) + + if "backlog_epic_hierarchy" in steering_elements: + return _with_capabilities(dict(_AGILE_INTAKE), steering_elements=steering_elements) + + if method_key == "continuous_product": + return _with_capabilities( + dict(_CONTINUOUS_INTAKE), steering_elements=steering_elements + ) + + return _with_capabilities(dict(_STANDARD_INTAKE), steering_elements=steering_elements) diff --git a/backend/steering/effective_contract.py b/backend/steering/effective_contract.py new file mode 100644 index 0000000..4ccc84b --- /dev/null +++ b/backend/steering/effective_contract.py @@ -0,0 +1,36 @@ +"""Effective method contract — primary + composition modifier (AP2.4).""" + +from __future__ import annotations + +from steering.methods.registry import get_method + +PLAN_PHASE_MODIFIER_KEY = "agile_iteration" + + +def resolve_effective_steering_elements( + primary_method_key: str, + *, + data_slices: list[str], + active_composition_modifier: str | None, +) -> frozenset[str]: + """ + Primärmethode + aktiver Modifier; Plan-Phase: agile_iteration wenn work_cycles + Slice aktiv und Methode komponierbar (Epic-Planung vor aktivem Sprint). + """ + primary = get_method(primary_method_key) + elements: set[str] = set(primary.steering_elements if primary else []) + + modifier_keys: list[str] = [] + if active_composition_modifier: + modifier_keys.append(active_composition_modifier) + elif "work_cycles" in data_slices: + agile = get_method(PLAN_PHASE_MODIFIER_KEY) + if agile and primary_method_key in agile.composes_with: + modifier_keys.append(PLAN_PHASE_MODIFIER_KEY) + + for modifier_key in modifier_keys: + modifier = get_method(modifier_key) + if modifier: + elements.update(modifier.steering_elements) + + return frozenset(elements) diff --git a/backend/steering/elements/registry.py b/backend/steering/elements/registry.py index 58a0ab1..8e8add2 100644 --- a/backend/steering/elements/registry.py +++ b/backend/steering/elements/registry.py @@ -41,6 +41,12 @@ STEERING_ELEMENTS: dict[str, SteeringElementDefinition] = { description="Aktive work_cycle und Sprint-Backlog", modes=("work", "plan"), ), + "backlog_epic_hierarchy": SteeringElementDefinition( + key="backlog_epic_hierarchy", + label="Epic-Hierarchie", + description="Epic-Container und Story-Baum im Plan-Eingang (Agile-Modifier)", + modes=("plan",), + ), "recurring_rhythm": SteeringElementDefinition( key="recurring_rhythm", label="Rhythmen", diff --git a/backend/steering/methods/registrations/ap20_method_stubs.py b/backend/steering/methods/registrations/ap20_method_stubs.py index 88f073e..f681d1b 100644 --- a/backend/steering/methods/registrations/ap20_method_stubs.py +++ b/backend/steering/methods/registrations/ap20_method_stubs.py @@ -76,7 +76,8 @@ def register() -> None: "initiative.recurring_program", } ), - steering_elements=ELEM_NEXT | frozenset({"work_cycle_scope"}), + steering_elements=ELEM_NEXT + | frozenset({"work_cycle_scope", "backlog_epic_hierarchy"}), ui_features=frozenset({"steeringSnapshotOnWorkSprint"}), graph_profile=STRICT, method_role="modifier", diff --git a/backend/tests/test_ap22g_backlog_vocabulary.py b/backend/tests/test_ap22g_backlog_vocabulary.py new file mode 100644 index 0000000..886b167 --- /dev/null +++ b/backend/tests/test_ap22g_backlog_vocabulary.py @@ -0,0 +1,77 @@ +"""AP2.2g — Profilgebundenes Backlog-Vokabular (ADP P3).""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_operating_context_product_has_agile_backlog_vocabulary(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Product Vocab", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + res = client.get( + f"/api/initiatives/{initiative_id}/operating-context", + headers=_auth(token), + ) + assert res.status_code == 200 + vocab = res.json()["backlog_vocabulary"] + assert vocab["profile_key"] == "agile_intake" + assert "epic" in vocab["kinds"] + assert vocab["labels"]["story"] == "Story" + assert vocab["capabilities"]["epic_hierarchy"] is True + assert vocab["capabilities"]["sprint_commit"] is True + assert "backlog_epic_hierarchy" in res.json()["steering_elements"] + + +def test_operating_context_linear_has_standard_backlog_vocabulary(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Linear Vocab", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + res = client.get( + f"/api/initiatives/{initiative_id}/operating-context", + headers=_auth(token), + ) + vocab = res.json()["backlog_vocabulary"] + assert vocab["profile_key"] == "standard_intake" + assert vocab["kinds"] == ["story", "bug", "issue"] + assert "epic" not in vocab["kinds"] + assert vocab["capabilities"]["epic_hierarchy"] is False + assert "backlog_epic_hierarchy" not in res.json()["steering_elements"] + + +def test_linear_initiative_rejects_epic_backlog_item(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Linear Epic Block", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + epic = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Not allowed", "item_kind": "epic", "status": "new"}, + headers=_auth(token), + ) + assert epic.status_code == 400 + assert "nicht erlaubt" in epic.json()["detail"] diff --git a/backend/tests/test_ap23a_operating_context.py b/backend/tests/test_ap23a_operating_context.py index 4b3c922..fa873a4 100644 --- a/backend/tests/test_ap23a_operating_context.py +++ b/backend/tests/test_ap23a_operating_context.py @@ -30,6 +30,7 @@ def test_operating_context_product(client): assert body["initiative_id"] == initiative_id assert body["archetype_key"] == "initiative.product" assert body["method_key"] == "continuous_product" + assert "backlog_epic_hierarchy" in body["steering_elements"] assert body["ui_profile"]["planDefaultRoute"] == "/plan/inbox" assert body["ui_profile"]["workDefaultRoute"] == "/work/sprint" assert "work_cycles" in body["data_slices"] diff --git a/backend/tests/test_ap24_steering_elements.py b/backend/tests/test_ap24_steering_elements.py index 4b1dab7..565fc34 100644 --- a/backend/tests/test_ap24_steering_elements.py +++ b/backend/tests/test_ap24_steering_elements.py @@ -49,6 +49,7 @@ def test_agile_iteration_composes_with_matrix(): } ) assert "program_delivery" not in method.composes_with + assert "backlog_epic_hierarchy" in method.steering_elements def test_list_composable_modifiers_for_linear(): diff --git a/backend/tests/test_backlog_vocabulary_unit.py b/backend/tests/test_backlog_vocabulary_unit.py new file mode 100644 index 0000000..b7bbe33 --- /dev/null +++ b/backend/tests/test_backlog_vocabulary_unit.py @@ -0,0 +1,49 @@ +"""Unit tests for backlog vocabulary resolution.""" + +from __future__ import annotations + +from steering.backlog_vocabulary import resolve_backlog_vocabulary + + +def test_resolve_agile_from_steering_element(): + vocab = resolve_backlog_vocabulary( + data_slices=["backlog", "actions", "work_cycles"], + method_key="continuous_product", + steering_elements=frozenset( + {"work_cycle_scope", "backlog_epic_hierarchy", "next_action_primary"} + ), + ) + assert vocab["profile_key"] == "agile_intake" + assert vocab["capabilities"]["epic_hierarchy"] is True + assert vocab["capabilities"]["sprint_commit"] is True + assert "epic" in vocab["kinds"] + + +def test_resolve_continuous_without_epic_element(): + vocab = resolve_backlog_vocabulary( + data_slices=["backlog", "actions"], + method_key="continuous_product", + steering_elements=frozenset({"next_action_primary"}), + ) + assert vocab["profile_key"] == "continuous_intake" + assert vocab["labels"]["story"] == "Idee" + assert vocab["capabilities"]["epic_hierarchy"] is False + + +def test_resolve_standard_for_generic_backlog(): + vocab = resolve_backlog_vocabulary( + data_slices=["backlog", "actions"], + method_key="sequential_dependency", + steering_elements=frozenset({"critical_path", "next_action_primary"}), + ) + assert vocab["profile_key"] == "standard_intake" + assert vocab["labels"]["story"] == "Story" + + +def test_resolve_empty_without_backlog_slice(): + vocab = resolve_backlog_vocabulary( + data_slices=["actions", "roadmap"], + method_key="maturity_progression", + steering_elements=frozenset({"next_action_primary"}), + ) + assert vocab["kinds"] == [] diff --git a/backend/tests/test_effective_contract_unit.py b/backend/tests/test_effective_contract_unit.py new file mode 100644 index 0000000..971395a --- /dev/null +++ b/backend/tests/test_effective_contract_unit.py @@ -0,0 +1,34 @@ +"""Unit tests for effective method contract resolution.""" + +from __future__ import annotations + +from steering.effective_contract import resolve_effective_steering_elements + + +def test_product_plan_phase_includes_agile_backlog_elements(): + elements = resolve_effective_steering_elements( + "continuous_product", + data_slices=["backlog", "actions", "work_cycles"], + active_composition_modifier=None, + ) + assert "work_cycle_scope" in elements + assert "backlog_epic_hierarchy" in elements + + +def test_linear_without_work_cycles_excludes_agile_backlog(): + elements = resolve_effective_steering_elements( + "sequential_dependency", + data_slices=["backlog", "actions", "roadmap"], + active_composition_modifier=None, + ) + assert "critical_path" in elements + assert "backlog_epic_hierarchy" not in elements + + +def test_active_modifier_merges_agile_elements(): + elements = resolve_effective_steering_elements( + "sequential_dependency", + data_slices=["backlog", "actions"], + active_composition_modifier="agile_iteration", + ) + assert "backlog_epic_hierarchy" in elements diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 6ef9519..be49e1d 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -51,7 +51,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | BacklogItem kinds (MVP) | ◐ | `story`/`bug`/`issue` AP2.2e — global, nicht profilgebunden | | 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 | +| Profil-Vokabular Backlog | ◐ | AP2.2g: `backlog_vocabulary` + `backlog_epic_hierarchy` via effektive `steering_elements` | | Blocker | ✓ | `action_id` optional | | Milestone | ◐ | Legacy-Tabelle MVP-Brücke; RoadmapItem bevorzugt | | Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) | diff --git a/frontend/src/components/BacklogItemForm.jsx b/frontend/src/components/BacklogItemForm.jsx index d0e7528..ef7dab5 100644 --- a/frontend/src/components/BacklogItemForm.jsx +++ b/frontend/src/components/BacklogItemForm.jsx @@ -1,32 +1,31 @@ -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' +import { resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js' export function BacklogItemForm({ initial = {}, roadmapItems = [], featureActions = [], epicItems = [], - epicHierarchyEnabled = false, + backlogVocabulary = null, 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 vocabulary = resolveBacklogVocabulary(backlogVocabulary) + const itemKinds = vocabulary.kinds + const initialKind = initial.item_kind || vocabulary.default_kind || 'story' const isEpic = initialKind === 'epic' + const epicHierarchyEnabled = vocabulary.capabilities?.epic_hierarchy ?? vocabulary.epic_hierarchy async function handleSubmit(e) { e.preventDefault() @@ -34,7 +33,7 @@ export function BacklogItemForm({ 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' + const itemKind = form.item_kind?.value || vocabulary.default_kind || 'story' await onSubmit({ title: form.title.value.trim(), description: form.description.value, @@ -53,7 +52,8 @@ export function BacklogItemForm({ const statusLocked = initial.status === 'converted' const showFeatureRef = !isEpic && - ((initial.item_kind || 'story') !== 'story' || featureActions.length > 0) + ((initial.item_kind || vocabulary.default_kind || 'story') !== 'story' || + featureActions.length > 0) return (
@@ -86,7 +86,7 @@ export function BacklogItemForm({ > {itemKinds.map((kind) => ( ))} diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx index 36acd69..aa671ef 100644 --- a/frontend/src/components/BacklogSection.jsx +++ b/frontend/src/components/BacklogSection.jsx @@ -8,8 +8,10 @@ import { ReorderControls } from './ReorderControls.jsx' import { gateTitleById } from './GateSelect.jsx' import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js' import { buildBacklogDisplayRows, listEpicBacklogItems } from '../utils/backlogTree.js' +import { backlogKindLabel, resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js' import { useMinWidth } from '../hooks/useMinWidth.js' -import { BACKLOG_ITEM_KIND_LABELS } from '../constants/status.js' +import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx' +import { hasSteeringElement } from '../registry/steeringElementRegistry.js' import { actionTitleById } from '../utils/actionReferences.js' const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk']) @@ -24,7 +26,6 @@ export function BacklogSection({ activeWorkCycle = null, featureActions = [], initiativeId = '', - sprintPlanningEnabled = false, canManage, onCreate, onUpdate, @@ -36,6 +37,7 @@ export function BacklogSection({ sectionTitle = 'Product Backlog', sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.', }) { + const { operatingContext, steeringElements } = useInitiativeOperations() const [modalMode, setModalMode] = useState(null) const [dragItemId, setDragItemId] = useState('') const [dropTargetId, setDropTargetId] = useState('') @@ -44,12 +46,23 @@ export function BacklogSection({ const isDesktop = useMinWidth(1024) const canReorder = canManage && typeof onReorder === 'function' + const vocabulary = useMemo( + () => resolveBacklogVocabulary(operatingContext?.backlog_vocabulary), + [operatingContext?.backlog_vocabulary], + ) + const sprintCommitEnabled = useMemo( + () => + vocabulary.capabilities?.sprint_commit ?? + hasSteeringElement(steeringElements, 'work_cycle_scope'), + [vocabulary, steeringElements], + ) + const planableSprints = useMemo( () => workCycles.filter((cycle) => PLANNING_STATUSES.has(cycle.status)), [workCycles], ) - const showSprintPlanning = sprintPlanningEnabled && planableSprints.length > 0 + const showSprintPlanning = sprintCommitEnabled && planableSprints.length > 0 useEffect(() => { if (!showSprintPlanning || !initiativeId) { @@ -93,7 +106,7 @@ export function BacklogSection({ return map }, [epicItems]) - const epicHierarchyEnabled = sprintPlanningEnabled + const defaultKind = vocabulary.default_kind || 'story' function closeModal() { setModalMode(null) @@ -234,7 +247,7 @@ export function BacklogSection({ )} - {sprintPlanningEnabled && canManage && !showSprintPlanning && ( + {sprintCommitEnabled && canManage && !showSprintPlanning && (

Sprint-Planung: Lege zuerst einen Sprint an. @@ -332,9 +345,9 @@ export function BacklogSection({ disabled={!canManage} > {item.title} - {item.item_kind && item.item_kind !== 'story' && ( + {item.item_kind && item.item_kind !== defaultKind && ( - {BACKLOG_ITEM_KIND_LABELS[item.item_kind] || item.item_kind} + {backlogKindLabel(vocabulary, item.item_kind)} )} {item.description && ( @@ -421,7 +434,7 @@ export function BacklogSection({ roadmapItems={roadmapItems} featureActions={featureActions} epicItems={epicItems} - epicHierarchyEnabled={epicHierarchyEnabled} + backlogVocabulary={vocabulary} onSubmit={handleCreateSubmit} onCancel={closeModal} busy={busy} @@ -434,7 +447,7 @@ export function BacklogSection({ roadmapItems={roadmapItems} featureActions={featureActions} epicItems={epicItems.filter((epic) => epic.id !== modalMode.item.id)} - epicHierarchyEnabled={epicHierarchyEnabled} + backlogVocabulary={vocabulary} onSubmit={handleEditSubmit} onCancel={closeModal} busy={busy} diff --git a/frontend/src/pages/initiative/InitiativeInboxPage.jsx b/frontend/src/pages/initiative/InitiativeInboxPage.jsx index 32a1f9a..32fc6ef 100644 --- a/frontend/src/pages/initiative/InitiativeInboxPage.jsx +++ b/frontend/src/pages/initiative/InitiativeInboxPage.jsx @@ -7,7 +7,6 @@ export function InitiativeInboxPage() { initiative, initiativeId, backlogItems, - dataSlices, roadmapItems, featureParentActions, workCycles, @@ -42,7 +41,6 @@ export function InitiativeInboxPage() { featureActions={featureParentActions} workCycles={workCycles} activeWorkCycle={activeWorkCycle} - sprintPlanningEnabled={dataSlices.includes('work_cycles')} canManage={capabilities.has('kairo.backlog.manage')} onCreate={handleCreateBacklog} onUpdate={handleUpdateBacklog} diff --git a/frontend/src/registry/steeringElementRegistry.js b/frontend/src/registry/steeringElementRegistry.js index c8a0d85..0456557 100644 --- a/frontend/src/registry/steeringElementRegistry.js +++ b/frontend/src/registry/steeringElementRegistry.js @@ -13,6 +13,9 @@ export const STEERING_ELEMENT_UI = { nextActionTitle: 'Nächster Schritt aus der Queue', nextActionSubtitle: 'Pull-Empfehlung aus dem Eingang.', }, + backlog_epic_hierarchy: { + planInboxHint: 'Epic-Baum für Sprint-Planung und Refinement.', + }, } /** diff --git a/frontend/src/utils/resolveBacklogVocabulary.js b/frontend/src/utils/resolveBacklogVocabulary.js new file mode 100644 index 0000000..42188ee --- /dev/null +++ b/frontend/src/utils/resolveBacklogVocabulary.js @@ -0,0 +1,50 @@ +/** @typedef {import('../api/types.js').BacklogVocabulary} BacklogVocabulary */ + +export const DEFAULT_BACKLOG_VOCABULARY = { + profile_key: 'standard_intake', + kinds: ['story', 'bug', 'issue'], + labels: { + story: 'Story', + bug: 'Bug', + issue: 'Issue', + }, + default_kind: 'story', + epic_hierarchy: false, + convertible_kinds: ['story', 'bug', 'issue'], + capabilities: { + sprint_commit: false, + epic_hierarchy: false, + }, +} + +/** + * @param {Partial | null | undefined} vocabulary + * @returns {BacklogVocabulary} + */ +export function resolveBacklogVocabulary(vocabulary) { + if (!vocabulary?.kinds?.length) { + return { ...DEFAULT_BACKLOG_VOCABULARY } + } + return { + ...DEFAULT_BACKLOG_VOCABULARY, + ...vocabulary, + labels: { + ...DEFAULT_BACKLOG_VOCABULARY.labels, + ...(vocabulary.labels || {}), + }, + capabilities: { + ...DEFAULT_BACKLOG_VOCABULARY.capabilities, + ...(vocabulary.capabilities || {}), + }, + } +} + +/** + * @param {Partial | null | undefined} vocabulary + * @param {string | null | undefined} itemKind + */ +export function backlogKindLabel(vocabulary, itemKind) { + if (!itemKind) return '' + const resolved = resolveBacklogVocabulary(vocabulary) + return resolved.labels[itemKind] || itemKind +} diff --git a/frontend/src/utils/resolveBacklogVocabulary.test.js b/frontend/src/utils/resolveBacklogVocabulary.test.js new file mode 100644 index 0000000..3a27a54 --- /dev/null +++ b/frontend/src/utils/resolveBacklogVocabulary.test.js @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + backlogKindLabel, + DEFAULT_BACKLOG_VOCABULARY, + resolveBacklogVocabulary, +} from './resolveBacklogVocabulary.js' + +describe('resolveBacklogVocabulary', () => { + it('liefert Fallback bei leerem Input', () => { + expect(resolveBacklogVocabulary(null)).toEqual(DEFAULT_BACKLOG_VOCABULARY) + }) + + it('merged Continuous-Labels', () => { + const vocab = resolveBacklogVocabulary({ + profile_key: 'continuous_intake', + kinds: ['story', 'bug', 'issue'], + labels: { story: 'Idee', issue: 'Störung' }, + epic_hierarchy: false, + }) + expect(vocab.labels.story).toBe('Idee') + expect(vocab.labels.issue).toBe('Störung') + }) + + it('backlogKindLabel nutzt Profil-Label', () => { + expect( + backlogKindLabel( + { labels: { story: 'Idee' }, kinds: ['story'] }, + 'story', + ), + ).toBe('Idee') + }) +})