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.actions import create_action
|
||||||
from services.audit import log_audit
|
from services.audit import log_audit
|
||||||
from services.initiatives import PRIORITIES, get_initiative
|
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
|
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||||
|
|
||||||
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
|
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}")
|
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:
|
def _item_kind_to_action_kind(item_kind: str) -> str:
|
||||||
if item_kind == "bug":
|
if item_kind == "bug":
|
||||||
return "bug"
|
return "bug"
|
||||||
|
|
@ -103,7 +126,11 @@ def _validate_backlog_hierarchy(
|
||||||
parent_backlog_id: Optional[str],
|
parent_backlog_id: Optional[str],
|
||||||
parent_action_id: Optional[str],
|
parent_action_id: Optional[str],
|
||||||
backlog_item_id: Optional[str] = None,
|
backlog_item_id: Optional[str] = None,
|
||||||
|
epic_hierarchy: bool = True,
|
||||||
) -> None:
|
) -> 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 item_kind == "epic":
|
||||||
if parent_backlog_id:
|
if parent_backlog_id:
|
||||||
raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden")
|
raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden")
|
||||||
|
|
@ -114,6 +141,9 @@ def _validate_backlog_hierarchy(
|
||||||
if not parent_backlog_id:
|
if not parent_backlog_id:
|
||||||
return
|
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:
|
if backlog_item_id and parent_backlog_id == backlog_item_id:
|
||||||
raise ValueError("Item kann nicht sich selbst als Epic referenzieren")
|
raise ValueError("Item kann nicht sich selbst als Epic referenzieren")
|
||||||
|
|
||||||
|
|
@ -156,7 +186,10 @@ def create_backlog_item(
|
||||||
raise ValueError("Titel ist erforderlich")
|
raise ValueError("Titel ist erforderlich")
|
||||||
_validate_status(status)
|
_validate_status(status)
|
||||||
_validate_priority(priority)
|
_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):
|
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||||
raise ValueError("Initiative nicht gefunden")
|
raise ValueError("Initiative nicht gefunden")
|
||||||
|
|
||||||
|
|
@ -182,6 +215,7 @@ def create_backlog_item(
|
||||||
item_kind=item_kind,
|
item_kind=item_kind,
|
||||||
parent_backlog_id=parent_backlog_id,
|
parent_backlog_id=parent_backlog_id,
|
||||||
parent_action_id=parent_action_id,
|
parent_action_id=parent_action_id,
|
||||||
|
epic_hierarchy=epic_hierarchy,
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -336,6 +370,15 @@ def update_backlog_item(
|
||||||
_validate_item_kind(item_kind)
|
_validate_item_kind(item_kind)
|
||||||
updates.append("item_kind = %s")
|
updates.append("item_kind = %s")
|
||||||
params.append(item_kind)
|
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:
|
if clear_parent_action:
|
||||||
updates.append("parent_action_id = NULL")
|
updates.append("parent_action_id = NULL")
|
||||||
elif parent_action_id is not None:
|
elif parent_action_id is not None:
|
||||||
|
|
@ -350,7 +393,6 @@ def update_backlog_item(
|
||||||
if not updates:
|
if not updates:
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story"
|
|
||||||
effective_parent_backlog = (
|
effective_parent_backlog = (
|
||||||
None
|
None
|
||||||
if clear_parent_backlog
|
if clear_parent_backlog
|
||||||
|
|
@ -380,6 +422,7 @@ def update_backlog_item(
|
||||||
parent_backlog_id=effective_parent_backlog,
|
parent_backlog_id=effective_parent_backlog,
|
||||||
parent_action_id=effective_parent_action,
|
parent_action_id=effective_parent_action,
|
||||||
backlog_item_id=backlog_item_id,
|
backlog_item_id=backlog_item_id,
|
||||||
|
epic_hierarchy=epic_hierarchy,
|
||||||
)
|
)
|
||||||
if parent_action_id is not None and not clear_parent_action:
|
if parent_action_id is not None and not clear_parent_action:
|
||||||
_validate_parent_action_in_initiative(
|
_validate_parent_action_in_initiative(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ from entity_archetypes.registry import (
|
||||||
from services.initiatives import get_initiative
|
from services.initiatives import get_initiative
|
||||||
from services.steering_context import ensure_steering_context, get_steering_context
|
from services.steering_context import ensure_steering_context, get_steering_context
|
||||||
from steering.graph.profiles import LIGHT, graph_profile_as_dict
|
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 (
|
from steering.methods.registry import (
|
||||||
get_method,
|
get_method,
|
||||||
list_compatible_methods,
|
list_compatible_methods,
|
||||||
|
|
@ -169,6 +171,25 @@ def get_operating_context(
|
||||||
method_key=method_key,
|
method_key=method_key,
|
||||||
)
|
)
|
||||||
method_contract = _resolve_method_contract(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 {
|
return {
|
||||||
"initiative_id": initiative_id,
|
"initiative_id": initiative_id,
|
||||||
|
|
@ -179,7 +200,8 @@ def get_operating_context(
|
||||||
"om_capabilities": sorted(om_capabilities),
|
"om_capabilities": sorted(om_capabilities),
|
||||||
"data_slices": data_slices,
|
"data_slices": data_slices,
|
||||||
"method_capabilities": _method_capabilities(method_key),
|
"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"],
|
"ui_features": method_contract["ui_features"],
|
||||||
"graph_profile": method_contract["graph_profile"],
|
"graph_profile": method_contract["graph_profile"],
|
||||||
"compatible_methods": _compatible_methods_payload(
|
"compatible_methods": _compatible_methods_payload(
|
||||||
|
|
@ -191,15 +213,8 @@ def get_operating_context(
|
||||||
method_to_dict(m, include_compatibility=False)
|
method_to_dict(m, include_compatibility=False)
|
||||||
for m in list_composable_modifiers(method_key)
|
for m in list_composable_modifiers(method_key)
|
||||||
],
|
],
|
||||||
"active_composition_modifier": (
|
"active_composition_modifier": active_composition_modifier,
|
||||||
"agile_iteration"
|
"backlog_vocabulary": backlog_vocabulary,
|
||||||
if _resolve_active_agile_composition(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
initiative_id=initiative_id,
|
|
||||||
primary_method_key=method_key,
|
|
||||||
)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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",
|
description="Aktive work_cycle und Sprint-Backlog",
|
||||||
modes=("work", "plan"),
|
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(
|
"recurring_rhythm": SteeringElementDefinition(
|
||||||
key="recurring_rhythm",
|
key="recurring_rhythm",
|
||||||
label="Rhythmen",
|
label="Rhythmen",
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,8 @@ def register() -> None:
|
||||||
"initiative.recurring_program",
|
"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"}),
|
ui_features=frozenset({"steeringSnapshotOnWorkSprint"}),
|
||||||
graph_profile=STRICT,
|
graph_profile=STRICT,
|
||||||
method_role="modifier",
|
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["initiative_id"] == initiative_id
|
||||||
assert body["archetype_key"] == "initiative.product"
|
assert body["archetype_key"] == "initiative.product"
|
||||||
assert body["method_key"] == "continuous_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"]["planDefaultRoute"] == "/plan/inbox"
|
||||||
assert body["ui_profile"]["workDefaultRoute"] == "/work/sprint"
|
assert body["ui_profile"]["workDefaultRoute"] == "/work/sprint"
|
||||||
assert "work_cycles" in body["data_slices"]
|
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 "program_delivery" not in method.composes_with
|
||||||
|
assert "backlog_epic_hierarchy" in method.steering_elements
|
||||||
|
|
||||||
|
|
||||||
def test_list_composable_modifiers_for_linear():
|
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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| Blocker | ✓ | `action_id` optional |
|
||||||
| Milestone | ◐ | Legacy-Tabelle MVP-Brücke; RoadmapItem bevorzugt |
|
| Milestone | ◐ | Legacy-Tabelle MVP-Brücke; RoadmapItem bevorzugt |
|
||||||
| Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) |
|
| Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) |
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,31 @@
|
||||||
import { useMemo } from 'react'
|
|
||||||
import {
|
import {
|
||||||
BACKLOG_STATUSES,
|
BACKLOG_STATUSES,
|
||||||
BACKLOG_STATUS_LABELS,
|
BACKLOG_STATUS_LABELS,
|
||||||
BACKLOG_ITEM_KINDS,
|
|
||||||
BACKLOG_ITEM_KINDS_WITH_EPIC,
|
|
||||||
BACKLOG_ITEM_KIND_LABELS,
|
|
||||||
PRIORITIES,
|
PRIORITIES,
|
||||||
PRIORITY_LABELS,
|
PRIORITY_LABELS,
|
||||||
} from '../constants/status.js'
|
} from '../constants/status.js'
|
||||||
import { GateSelect } from './GateSelect.jsx'
|
import { GateSelect } from './GateSelect.jsx'
|
||||||
import { FeatureParentSelect } from './FeatureParentSelect.jsx'
|
import { FeatureParentSelect } from './FeatureParentSelect.jsx'
|
||||||
import { EpicParentSelect } from './EpicParentSelect.jsx'
|
import { EpicParentSelect } from './EpicParentSelect.jsx'
|
||||||
|
import { resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js'
|
||||||
|
|
||||||
export function BacklogItemForm({
|
export function BacklogItemForm({
|
||||||
initial = {},
|
initial = {},
|
||||||
roadmapItems = [],
|
roadmapItems = [],
|
||||||
featureActions = [],
|
featureActions = [],
|
||||||
epicItems = [],
|
epicItems = [],
|
||||||
epicHierarchyEnabled = false,
|
backlogVocabulary = null,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy = false,
|
busy = false,
|
||||||
submitLabel = 'Speichern',
|
submitLabel = 'Speichern',
|
||||||
allowStatus = true,
|
allowStatus = true,
|
||||||
}) {
|
}) {
|
||||||
const itemKinds = epicHierarchyEnabled ? BACKLOG_ITEM_KINDS_WITH_EPIC : BACKLOG_ITEM_KINDS
|
const vocabulary = resolveBacklogVocabulary(backlogVocabulary)
|
||||||
const initialKind = initial.item_kind || 'story'
|
const itemKinds = vocabulary.kinds
|
||||||
|
const initialKind = initial.item_kind || vocabulary.default_kind || 'story'
|
||||||
const isEpic = initialKind === 'epic'
|
const isEpic = initialKind === 'epic'
|
||||||
|
const epicHierarchyEnabled = vocabulary.capabilities?.epic_hierarchy ?? vocabulary.epic_hierarchy
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -34,7 +33,7 @@ export function BacklogItemForm({
|
||||||
const gateValue = form.roadmap_item_id?.value ?? ''
|
const gateValue = form.roadmap_item_id?.value ?? ''
|
||||||
const parentValue = form.parent_action_id?.value ?? ''
|
const parentValue = form.parent_action_id?.value ?? ''
|
||||||
const epicValue = form.parent_backlog_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({
|
await onSubmit({
|
||||||
title: form.title.value.trim(),
|
title: form.title.value.trim(),
|
||||||
description: form.description.value,
|
description: form.description.value,
|
||||||
|
|
@ -53,7 +52,8 @@ export function BacklogItemForm({
|
||||||
const statusLocked = initial.status === 'converted'
|
const statusLocked = initial.status === 'converted'
|
||||||
const showFeatureRef =
|
const showFeatureRef =
|
||||||
!isEpic &&
|
!isEpic &&
|
||||||
((initial.item_kind || 'story') !== 'story' || featureActions.length > 0)
|
((initial.item_kind || vocabulary.default_kind || 'story') !== 'story' ||
|
||||||
|
featureActions.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
|
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
|
||||||
|
|
@ -86,7 +86,7 @@ export function BacklogItemForm({
|
||||||
>
|
>
|
||||||
{itemKinds.map((kind) => (
|
{itemKinds.map((kind) => (
|
||||||
<option key={kind} value={kind}>
|
<option key={kind} value={kind}>
|
||||||
{BACKLOG_ITEM_KIND_LABELS[kind]}
|
{vocabulary.labels[kind] || kind}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import { ReorderControls } from './ReorderControls.jsx'
|
||||||
import { gateTitleById } from './GateSelect.jsx'
|
import { gateTitleById } from './GateSelect.jsx'
|
||||||
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
|
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
|
||||||
import { buildBacklogDisplayRows, listEpicBacklogItems } from '../utils/backlogTree.js'
|
import { buildBacklogDisplayRows, listEpicBacklogItems } from '../utils/backlogTree.js'
|
||||||
|
import { backlogKindLabel, resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js'
|
||||||
import { useMinWidth } from '../hooks/useMinWidth.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'
|
import { actionTitleById } from '../utils/actionReferences.js'
|
||||||
|
|
||||||
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
||||||
|
|
@ -24,7 +26,6 @@ export function BacklogSection({
|
||||||
activeWorkCycle = null,
|
activeWorkCycle = null,
|
||||||
featureActions = [],
|
featureActions = [],
|
||||||
initiativeId = '',
|
initiativeId = '',
|
||||||
sprintPlanningEnabled = false,
|
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
|
@ -36,6 +37,7 @@ export function BacklogSection({
|
||||||
sectionTitle = 'Product Backlog',
|
sectionTitle = 'Product Backlog',
|
||||||
sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.',
|
sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.',
|
||||||
}) {
|
}) {
|
||||||
|
const { operatingContext, steeringElements } = useInitiativeOperations()
|
||||||
const [modalMode, setModalMode] = useState(null)
|
const [modalMode, setModalMode] = useState(null)
|
||||||
const [dragItemId, setDragItemId] = useState('')
|
const [dragItemId, setDragItemId] = useState('')
|
||||||
const [dropTargetId, setDropTargetId] = useState('')
|
const [dropTargetId, setDropTargetId] = useState('')
|
||||||
|
|
@ -44,12 +46,23 @@ export function BacklogSection({
|
||||||
const isDesktop = useMinWidth(1024)
|
const isDesktop = useMinWidth(1024)
|
||||||
const canReorder = canManage && typeof onReorder === 'function'
|
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(
|
const planableSprints = useMemo(
|
||||||
() => workCycles.filter((cycle) => PLANNING_STATUSES.has(cycle.status)),
|
() => workCycles.filter((cycle) => PLANNING_STATUSES.has(cycle.status)),
|
||||||
[workCycles],
|
[workCycles],
|
||||||
)
|
)
|
||||||
|
|
||||||
const showSprintPlanning = sprintPlanningEnabled && planableSprints.length > 0
|
const showSprintPlanning = sprintCommitEnabled && planableSprints.length > 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSprintPlanning || !initiativeId) {
|
if (!showSprintPlanning || !initiativeId) {
|
||||||
|
|
@ -93,7 +106,7 @@ export function BacklogSection({
|
||||||
return map
|
return map
|
||||||
}, [epicItems])
|
}, [epicItems])
|
||||||
|
|
||||||
const epicHierarchyEnabled = sprintPlanningEnabled
|
const defaultKind = vocabulary.default_kind || 'story'
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
setModalMode(null)
|
setModalMode(null)
|
||||||
|
|
@ -234,7 +247,7 @@ export function BacklogSection({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sprintPlanningEnabled && canManage && !showSprintPlanning && (
|
{sprintCommitEnabled && canManage && !showSprintPlanning && (
|
||||||
<div className="backlog-sprint-setup card-list-item">
|
<div className="backlog-sprint-setup card-list-item">
|
||||||
<p className="backlog-sprint-setup__title">
|
<p className="backlog-sprint-setup__title">
|
||||||
<strong>Sprint-Planung:</strong> Lege zuerst einen Sprint an.
|
<strong>Sprint-Planung:</strong> Lege zuerst einen Sprint an.
|
||||||
|
|
@ -332,9 +345,9 @@ export function BacklogSection({
|
||||||
disabled={!canManage}
|
disabled={!canManage}
|
||||||
>
|
>
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
{item.item_kind && item.item_kind !== 'story' && (
|
{item.item_kind && item.item_kind !== defaultKind && (
|
||||||
<span className="badge badge--kind muted">
|
<span className="badge badge--kind muted">
|
||||||
{BACKLOG_ITEM_KIND_LABELS[item.item_kind] || item.item_kind}
|
{backlogKindLabel(vocabulary, item.item_kind)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.description && (
|
{item.description && (
|
||||||
|
|
@ -421,7 +434,7 @@ export function BacklogSection({
|
||||||
roadmapItems={roadmapItems}
|
roadmapItems={roadmapItems}
|
||||||
featureActions={featureActions}
|
featureActions={featureActions}
|
||||||
epicItems={epicItems}
|
epicItems={epicItems}
|
||||||
epicHierarchyEnabled={epicHierarchyEnabled}
|
backlogVocabulary={vocabulary}
|
||||||
onSubmit={handleCreateSubmit}
|
onSubmit={handleCreateSubmit}
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
|
@ -434,7 +447,7 @@ export function BacklogSection({
|
||||||
roadmapItems={roadmapItems}
|
roadmapItems={roadmapItems}
|
||||||
featureActions={featureActions}
|
featureActions={featureActions}
|
||||||
epicItems={epicItems.filter((epic) => epic.id !== modalMode.item.id)}
|
epicItems={epicItems.filter((epic) => epic.id !== modalMode.item.id)}
|
||||||
epicHierarchyEnabled={epicHierarchyEnabled}
|
backlogVocabulary={vocabulary}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ export function InitiativeInboxPage() {
|
||||||
initiative,
|
initiative,
|
||||||
initiativeId,
|
initiativeId,
|
||||||
backlogItems,
|
backlogItems,
|
||||||
dataSlices,
|
|
||||||
roadmapItems,
|
roadmapItems,
|
||||||
featureParentActions,
|
featureParentActions,
|
||||||
workCycles,
|
workCycles,
|
||||||
|
|
@ -42,7 +41,6 @@ export function InitiativeInboxPage() {
|
||||||
featureActions={featureParentActions}
|
featureActions={featureParentActions}
|
||||||
workCycles={workCycles}
|
workCycles={workCycles}
|
||||||
activeWorkCycle={activeWorkCycle}
|
activeWorkCycle={activeWorkCycle}
|
||||||
sprintPlanningEnabled={dataSlices.includes('work_cycles')}
|
|
||||||
canManage={capabilities.has('kairo.backlog.manage')}
|
canManage={capabilities.has('kairo.backlog.manage')}
|
||||||
onCreate={handleCreateBacklog}
|
onCreate={handleCreateBacklog}
|
||||||
onUpdate={handleUpdateBacklog}
|
onUpdate={handleUpdateBacklog}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ export const STEERING_ELEMENT_UI = {
|
||||||
nextActionTitle: 'Nächster Schritt aus der Queue',
|
nextActionTitle: 'Nächster Schritt aus der Queue',
|
||||||
nextActionSubtitle: 'Pull-Empfehlung aus dem Eingang.',
|
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