feat(steering): Backlog-Vokabular aus Methoden-Vertrag (ADP P3)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 3m56s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 3m56s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
backlog_epic_hierarchy als Steuerungselement, effektive steering_elements im Operating Context, Backend-Guards und UI ohne Slice-Ifs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f33135b87f
commit
a3b14d9c00
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
95
backend/steering/backlog_vocabulary.py
Normal file
95
backend/steering/backlog_vocabulary.py
Normal file
|
|
@ -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)
|
||||
36
backend/steering/effective_contract.py
Normal file
36
backend/steering/effective_contract.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
77
backend/tests/test_ap22g_backlog_vocabulary.py
Normal file
77
backend/tests/test_ap22g_backlog_vocabulary.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
49
backend/tests/test_backlog_vocabulary_unit.py
Normal file
49
backend/tests/test_backlog_vocabulary_unit.py
Normal file
|
|
@ -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"] == []
|
||||
34
backend/tests/test_effective_contract_unit.py
Normal file
34
backend/tests/test_effective_contract_unit.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
|
||||
|
|
@ -86,7 +86,7 @@ export function BacklogItemForm({
|
|||
>
|
||||
{itemKinds.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{BACKLOG_ITEM_KIND_LABELS[kind]}
|
||||
{vocabulary.labels[kind] || kind}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{sprintPlanningEnabled && canManage && !showSprintPlanning && (
|
||||
{sprintCommitEnabled && canManage && !showSprintPlanning && (
|
||||
<div className="backlog-sprint-setup card-list-item">
|
||||
<p className="backlog-sprint-setup__title">
|
||||
<strong>Sprint-Planung:</strong> Lege zuerst einen Sprint an.
|
||||
|
|
@ -332,9 +345,9 @@ export function BacklogSection({
|
|||
disabled={!canManage}
|
||||
>
|
||||
<strong>{item.title}</strong>
|
||||
{item.item_kind && item.item_kind !== 'story' && (
|
||||
{item.item_kind && item.item_kind !== defaultKind && (
|
||||
<span className="badge badge--kind muted">
|
||||
{BACKLOG_ITEM_KIND_LABELS[item.item_kind] || item.item_kind}
|
||||
{backlogKindLabel(vocabulary, item.item_kind)}
|
||||
</span>
|
||||
)}
|
||||
{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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
50
frontend/src/utils/resolveBacklogVocabulary.js
Normal file
50
frontend/src/utils/resolveBacklogVocabulary.js
Normal file
|
|
@ -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<BacklogVocabulary> | 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<BacklogVocabulary> | 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
|
||||
}
|
||||
32
frontend/src/utils/resolveBacklogVocabulary.test.js
Normal file
32
frontend/src/utils/resolveBacklogVocabulary.test.js
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user