feat(steering): Epic Roll-up Read Model und Attention (ADP P4)
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Schliesst den Backlog/Epic-Track mit kernel-basiertem Fortschritt pro Epic, Steuerungs-Attention und UI-Fortschrittsanzeige im Plan-Eingang ab. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a13913f1a0
commit
ef5ee0d19b
|
|
@ -125,7 +125,8 @@ def get_initiative_steering_snapshot(
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, title, status, priority, converted_action_id, created_at, updated_at
|
SELECT id, title, status, priority, converted_action_id, item_kind,
|
||||||
|
parent_backlog_id, created_at, updated_at
|
||||||
FROM backlog_items
|
FROM backlog_items
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
|
|
@ -297,6 +298,31 @@ def get_initiative_steering_snapshot(
|
||||||
next_actions = evaluation.next_work
|
next_actions = evaluation.next_work
|
||||||
kernel_attention = evaluation.attention
|
kernel_attention = evaluation.attention
|
||||||
|
|
||||||
|
from services.operating_context import get_operating_context
|
||||||
|
from steering.read_models.epic_rollup import compute_epic_rollup
|
||||||
|
|
||||||
|
operating = get_operating_context(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
epic_rollup: list[dict[str, Any]] = []
|
||||||
|
if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get(
|
||||||
|
"epic_hierarchy"
|
||||||
|
):
|
||||||
|
epic_rollup = compute_epic_rollup(
|
||||||
|
backlog_items=[
|
||||||
|
{
|
||||||
|
"id": _sid(b["id"]),
|
||||||
|
"title": b["title"],
|
||||||
|
"status": b["status"],
|
||||||
|
"item_kind": b.get("item_kind"),
|
||||||
|
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
|
||||||
|
"converted_action_id": _sid(b.get("converted_action_id")),
|
||||||
|
}
|
||||||
|
for b in backlog
|
||||||
|
],
|
||||||
|
actions=actions_raw,
|
||||||
|
)
|
||||||
|
|
||||||
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||||
metadata = steering.get("lifecycle_metadata") or {}
|
metadata = steering.get("lifecycle_metadata") or {}
|
||||||
if isinstance(metadata, str):
|
if isinstance(metadata, str):
|
||||||
|
|
@ -355,6 +381,7 @@ def get_initiative_steering_snapshot(
|
||||||
"upcoming_roadmap_items": upcoming_milestones,
|
"upcoming_roadmap_items": upcoming_milestones,
|
||||||
"next_actions": next_actions,
|
"next_actions": next_actions,
|
||||||
"active_work_cycle": active_work_cycle,
|
"active_work_cycle": active_work_cycle,
|
||||||
|
"epic_rollup": epic_rollup,
|
||||||
"counts": {
|
"counts": {
|
||||||
"actions_open": open_actions,
|
"actions_open": open_actions,
|
||||||
"actions_blocked": blocked_actions,
|
"actions_blocked": blocked_actions,
|
||||||
|
|
@ -384,6 +411,8 @@ def get_initiative_steering_snapshot(
|
||||||
"id": _sid(b["id"]),
|
"id": _sid(b["id"]),
|
||||||
"title": b["title"],
|
"title": b["title"],
|
||||||
"status": b["status"],
|
"status": b["status"],
|
||||||
|
"item_kind": b.get("item_kind"),
|
||||||
|
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
|
||||||
"converted_action_id": _sid(b.get("converted_action_id")),
|
"converted_action_id": _sid(b.get("converted_action_id")),
|
||||||
}
|
}
|
||||||
for b in backlog
|
for b in backlog
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,27 @@ def evaluate_attention(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from services.operating_context import get_operating_context
|
||||||
|
from services import backlog as backlog_service
|
||||||
|
from steering.read_models.epic_rollup import (
|
||||||
|
compute_epic_rollup,
|
||||||
|
epic_rollup_attention_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
operating = get_operating_context(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get(
|
||||||
|
"epic_hierarchy"
|
||||||
|
):
|
||||||
|
backlog_items = backlog_service.list_backlog_for_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
rollups = compute_epic_rollup(backlog_items=backlog_items, actions=actions)
|
||||||
|
items.extend(
|
||||||
|
epic_rollup_attention_items(initiative_id=initiative_id, rollups=rollups)
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not next_work
|
not next_work
|
||||||
and binding.primary_method_key in _GATE_METHODS
|
and binding.primary_method_key in _GATE_METHODS
|
||||||
|
|
|
||||||
1
backend/steering/read_models/__init__.py
Normal file
1
backend/steering/read_models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Steering read models — derived views, no OM mutation."""
|
||||||
170
backend/steering/read_models/epic_rollup.py
Normal file
170
backend/steering/read_models/epic_rollup.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""Epic roll-up read model — ADP P4 / Backlog-Hierarchie.
|
||||||
|
|
||||||
|
Fortschritt eines Epics = Aggregation committeter/erledigter Stories im Subbaum.
|
||||||
|
Kein Gate-Status; Leading bleibt Action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
EpicRollupStatus = Literal["empty", "intake_only", "in_progress", "done"]
|
||||||
|
|
||||||
|
_DONE_ACTION = frozenset({"done"})
|
||||||
|
_ACTIVE_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"})
|
||||||
|
_CHILD_EXCLUDE = frozenset({"rejected"})
|
||||||
|
|
||||||
|
|
||||||
|
def _action_status(actions_by_id: dict[str, dict[str, Any]], action_id: Any) -> str | None:
|
||||||
|
if not action_id:
|
||||||
|
return None
|
||||||
|
row = actions_by_id.get(str(action_id))
|
||||||
|
return row.get("status") if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_epic_rollup(
|
||||||
|
*,
|
||||||
|
backlog_items: list[dict[str, Any]],
|
||||||
|
actions: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Roll-up pro Epic-BacklogItem (item_kind=epic, nicht rejected)."""
|
||||||
|
actions_by_id = {str(a["id"]): a for a in actions if a.get("id")}
|
||||||
|
epics = [
|
||||||
|
b
|
||||||
|
for b in backlog_items
|
||||||
|
if b.get("item_kind") == "epic" and b.get("status") not in _CHILD_EXCLUDE
|
||||||
|
]
|
||||||
|
|
||||||
|
rollups: list[dict[str, Any]] = []
|
||||||
|
for epic in epics:
|
||||||
|
epic_id = str(epic["id"])
|
||||||
|
children = [
|
||||||
|
b
|
||||||
|
for b in backlog_items
|
||||||
|
if b.get("parent_backlog_id")
|
||||||
|
and str(b["parent_backlog_id"]) == epic_id
|
||||||
|
and b.get("item_kind") != "epic"
|
||||||
|
and b.get("status") not in _CHILD_EXCLUDE
|
||||||
|
]
|
||||||
|
|
||||||
|
child_total = len(children)
|
||||||
|
committed: list[dict[str, Any]] = []
|
||||||
|
uncommitted: list[dict[str, Any]] = []
|
||||||
|
for child in children:
|
||||||
|
if child.get("status") == "converted" or child.get("converted_action_id"):
|
||||||
|
committed.append(child)
|
||||||
|
else:
|
||||||
|
uncommitted.append(child)
|
||||||
|
|
||||||
|
actions_done = actions_in_progress = actions_open = 0
|
||||||
|
for child in committed:
|
||||||
|
status = _action_status(actions_by_id, child.get("converted_action_id"))
|
||||||
|
if status in _DONE_ACTION:
|
||||||
|
actions_done += 1
|
||||||
|
elif status in _ACTIVE_ACTION:
|
||||||
|
actions_in_progress += 1
|
||||||
|
actions_open += 1
|
||||||
|
elif status:
|
||||||
|
actions_open += 1
|
||||||
|
|
||||||
|
if child_total == 0:
|
||||||
|
rollup_status: EpicRollupStatus = "empty"
|
||||||
|
elif len(committed) == 0:
|
||||||
|
rollup_status = "intake_only"
|
||||||
|
elif actions_done == child_total:
|
||||||
|
rollup_status = "done"
|
||||||
|
else:
|
||||||
|
rollup_status = "in_progress"
|
||||||
|
|
||||||
|
completion_ratio = actions_done / child_total if child_total else None
|
||||||
|
commit_ratio = len(committed) / child_total if child_total else None
|
||||||
|
|
||||||
|
rollups.append(
|
||||||
|
{
|
||||||
|
"epic_backlog_id": epic_id,
|
||||||
|
"epic_title": epic.get("title") or "Epic",
|
||||||
|
"child_total": child_total,
|
||||||
|
"child_uncommitted": len(uncommitted),
|
||||||
|
"child_committed": len(committed),
|
||||||
|
"actions_done": actions_done,
|
||||||
|
"actions_in_progress": actions_in_progress,
|
||||||
|
"actions_open": actions_open,
|
||||||
|
"completion_ratio": completion_ratio,
|
||||||
|
"commit_ratio": commit_ratio,
|
||||||
|
"status": rollup_status,
|
||||||
|
"data_source": "epic_rollup_v0.1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rollups.sort(key=lambda r: (r["status"] != "in_progress", r["epic_title"].lower()))
|
||||||
|
return rollups
|
||||||
|
|
||||||
|
|
||||||
|
def epic_rollup_attention_items(
|
||||||
|
*,
|
||||||
|
initiative_id: str,
|
||||||
|
rollups: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Attention aus Epic-Roll-up — Steuerung, nicht nur Anzeige."""
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for rollup in rollups:
|
||||||
|
epic_id = rollup["epic_backlog_id"]
|
||||||
|
title = rollup["epic_title"]
|
||||||
|
child_total = rollup["child_total"]
|
||||||
|
if child_total == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rollup["child_committed"] == 0:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "epic_awaiting_commit",
|
||||||
|
"severity": "info",
|
||||||
|
"title": title,
|
||||||
|
"summary": (
|
||||||
|
f"Epic hat {rollup['child_uncommitted']} Item(s) im Eingang — "
|
||||||
|
"noch keine committete Arbeit"
|
||||||
|
),
|
||||||
|
"scope_type": "backlog",
|
||||||
|
"scope_id": epic_id,
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"reason_code": "epic_awaiting_commit",
|
||||||
|
"data_source": "epic_rollup",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rollup["child_uncommitted"] > 0 and rollup["actions_in_progress"] > 0:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "epic_uncommitted_during_execution",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": title,
|
||||||
|
"summary": (
|
||||||
|
f"{rollup['child_uncommitted']} Item(s) noch im Eingang, "
|
||||||
|
f"während {rollup['actions_in_progress']} Arbeitspaket(e) laufen"
|
||||||
|
),
|
||||||
|
"scope_type": "backlog",
|
||||||
|
"scope_id": epic_id,
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"reason_code": "epic_uncommitted_during_execution",
|
||||||
|
"data_source": "epic_rollup",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if rollup["status"] == "done":
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "epic_complete",
|
||||||
|
"severity": "info",
|
||||||
|
"title": title,
|
||||||
|
"summary": "Alle Stories im Epic erledigt",
|
||||||
|
"scope_type": "backlog",
|
||||||
|
"scope_id": epic_id,
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"reason_code": "epic_complete",
|
||||||
|
"data_source": "epic_rollup",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return items
|
||||||
141
backend/tests/test_ap22h_epic_rollup.py
Normal file
141
backend/tests/test_ap22h_epic_rollup.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"""AP2.2h — Epic Roll-up Read Model (ADP P4)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||||
|
|
||||||
|
|
||||||
|
def _create_product_initiative(client, token, title="Epic Rollup"):
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title=title,
|
||||||
|
archetype_key="initiative.product",
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
return created.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_steering_snapshot_includes_epic_rollup(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_product_initiative(client, token)
|
||||||
|
|
||||||
|
epic = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/backlog",
|
||||||
|
json={"title": "Checkout Epic", "item_kind": "epic", "status": "accepted"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
epic_id = epic.json()["id"]
|
||||||
|
|
||||||
|
story_a = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/backlog",
|
||||||
|
json={
|
||||||
|
"title": "Login Story",
|
||||||
|
"item_kind": "story",
|
||||||
|
"parent_backlog_id": epic_id,
|
||||||
|
"status": "accepted",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
story_a_id = story_a.json()["id"]
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/backlog",
|
||||||
|
json={
|
||||||
|
"title": "Payment Story",
|
||||||
|
"item_kind": "story",
|
||||||
|
"parent_backlog_id": epic_id,
|
||||||
|
"status": "accepted",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
converted = client.post(
|
||||||
|
f"/api/backlog/{story_a_id}/convert-to-action",
|
||||||
|
json={},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert converted.status_code == 201
|
||||||
|
action_id = converted.json()["action"]["id"]
|
||||||
|
|
||||||
|
done = client.patch(
|
||||||
|
f"/api/actions/{action_id}",
|
||||||
|
json={"status": "done"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert done.status_code == 200
|
||||||
|
|
||||||
|
snapshot = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snapshot.status_code == 200
|
||||||
|
body = snapshot.json()
|
||||||
|
assert "epic_rollup" in body
|
||||||
|
assert len(body["epic_rollup"]) == 1
|
||||||
|
|
||||||
|
rollup = body["epic_rollup"][0]
|
||||||
|
assert rollup["epic_backlog_id"] == epic_id
|
||||||
|
assert rollup["child_total"] == 2
|
||||||
|
assert rollup["child_uncommitted"] == 1
|
||||||
|
assert rollup["child_committed"] == 1
|
||||||
|
assert rollup["actions_done"] == 1
|
||||||
|
assert rollup["status"] == "in_progress"
|
||||||
|
assert rollup["completion_ratio"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_steering_snapshot_epic_attention_awaiting_commit(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_product_initiative(client, token, title="Epic Attention")
|
||||||
|
|
||||||
|
epic = client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/backlog",
|
||||||
|
json={"title": "Epic", "item_kind": "epic", "status": "accepted"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
epic_id = epic.json()["id"]
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/backlog",
|
||||||
|
json={
|
||||||
|
"title": "Story",
|
||||||
|
"item_kind": "story",
|
||||||
|
"parent_backlog_id": epic_id,
|
||||||
|
"status": "accepted",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snapshot.status_code == 200
|
||||||
|
attention_codes = [
|
||||||
|
item.get("code") or item.get("reason_code")
|
||||||
|
for item in snapshot.json().get("attention_items", [])
|
||||||
|
]
|
||||||
|
assert "epic_awaiting_commit" in attention_codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_linear_initiative_has_no_epic_rollup(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = _create_initiative(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
title="Linear Rollup",
|
||||||
|
archetype_key="initiative.linear_project",
|
||||||
|
)
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
snapshot = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snapshot.status_code == 200
|
||||||
|
assert snapshot.json().get("epic_rollup") == []
|
||||||
125
backend/tests/test_epic_rollup_unit.py
Normal file
125
backend/tests/test_epic_rollup_unit.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""Unit tests — Epic roll-up read model (P4)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from steering.read_models.epic_rollup import (
|
||||||
|
compute_epic_rollup,
|
||||||
|
epic_rollup_attention_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _item(
|
||||||
|
*,
|
||||||
|
id: str,
|
||||||
|
title: str,
|
||||||
|
item_kind: str = "story",
|
||||||
|
status: str = "accepted",
|
||||||
|
parent_backlog_id: str | None = None,
|
||||||
|
converted_action_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"title": title,
|
||||||
|
"item_kind": item_kind,
|
||||||
|
"status": status,
|
||||||
|
"parent_backlog_id": parent_backlog_id,
|
||||||
|
"converted_action_id": converted_action_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_epic_rollup_empty_epic():
|
||||||
|
rollups = compute_epic_rollup(
|
||||||
|
backlog_items=[_item(id="e1", title="Epic", item_kind="epic")],
|
||||||
|
actions=[],
|
||||||
|
)
|
||||||
|
assert len(rollups) == 1
|
||||||
|
assert rollups[0]["status"] == "empty"
|
||||||
|
assert rollups[0]["child_total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_epic_rollup_intake_only():
|
||||||
|
rollups = compute_epic_rollup(
|
||||||
|
backlog_items=[
|
||||||
|
_item(id="e1", title="Checkout", item_kind="epic"),
|
||||||
|
_item(id="s1", title="Login", parent_backlog_id="e1"),
|
||||||
|
_item(id="s2", title="Pay", parent_backlog_id="e1"),
|
||||||
|
],
|
||||||
|
actions=[],
|
||||||
|
)
|
||||||
|
assert rollups[0]["status"] == "intake_only"
|
||||||
|
assert rollups[0]["child_total"] == 2
|
||||||
|
assert rollups[0]["child_uncommitted"] == 2
|
||||||
|
assert rollups[0]["completion_ratio"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_epic_rollup_partial_and_done():
|
||||||
|
rollups = compute_epic_rollup(
|
||||||
|
backlog_items=[
|
||||||
|
_item(id="e1", title="Epic", item_kind="epic"),
|
||||||
|
_item(
|
||||||
|
id="s1",
|
||||||
|
title="Done story",
|
||||||
|
parent_backlog_id="e1",
|
||||||
|
status="converted",
|
||||||
|
converted_action_id="a1",
|
||||||
|
),
|
||||||
|
_item(id="s2", title="Open story", parent_backlog_id="e1"),
|
||||||
|
],
|
||||||
|
actions=[
|
||||||
|
{"id": "a1", "status": "done"},
|
||||||
|
{"id": "a2", "status": "in_progress"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert rollups[0]["status"] == "in_progress"
|
||||||
|
assert rollups[0]["actions_done"] == 1
|
||||||
|
assert rollups[0]["child_uncommitted"] == 1
|
||||||
|
assert rollups[0]["completion_ratio"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_epic_rollup_all_done():
|
||||||
|
rollups = compute_epic_rollup(
|
||||||
|
backlog_items=[
|
||||||
|
_item(id="e1", title="Epic", item_kind="epic"),
|
||||||
|
_item(
|
||||||
|
id="s1",
|
||||||
|
title="A",
|
||||||
|
parent_backlog_id="e1",
|
||||||
|
status="converted",
|
||||||
|
converted_action_id="a1",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
actions=[{"id": "a1", "status": "done"}],
|
||||||
|
)
|
||||||
|
assert rollups[0]["status"] == "done"
|
||||||
|
assert rollups[0]["completion_ratio"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_epic_rollup_attention_awaiting_commit():
|
||||||
|
rollups = compute_epic_rollup(
|
||||||
|
backlog_items=[
|
||||||
|
_item(id="e1", title="Epic", item_kind="epic"),
|
||||||
|
_item(id="s1", title="Story", parent_backlog_id="e1"),
|
||||||
|
],
|
||||||
|
actions=[],
|
||||||
|
)
|
||||||
|
items = epic_rollup_attention_items(initiative_id="init-1", rollups=rollups)
|
||||||
|
assert any(i["reason_code"] == "epic_awaiting_commit" for i in items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_epic_rollup_attention_split_focus():
|
||||||
|
rollups = compute_epic_rollup(
|
||||||
|
backlog_items=[
|
||||||
|
_item(id="e1", title="Epic", item_kind="epic"),
|
||||||
|
_item(
|
||||||
|
id="s1",
|
||||||
|
title="Running",
|
||||||
|
parent_backlog_id="e1",
|
||||||
|
status="converted",
|
||||||
|
converted_action_id="a1",
|
||||||
|
),
|
||||||
|
_item(id="s2", title="Waiting", parent_backlog_id="e1"),
|
||||||
|
],
|
||||||
|
actions=[{"id": "a1", "status": "in_progress"}],
|
||||||
|
)
|
||||||
|
items = epic_rollup_attention_items(initiative_id="init-1", rollups=rollups)
|
||||||
|
assert any(i["reason_code"] == "epic_uncommitted_during_execution" for i in items)
|
||||||
98
docs/architecture/ADP_Agile_Steering_Program_v0.1.md
Normal file
98
docs/architecture/ADP_Agile_Steering_Program_v0.1.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# ADP — Agile Steering Program (jenseits P4) v0.1
|
||||||
|
|
||||||
|
**Status:** PO-Freigabe (Planung — nicht implementiert)
|
||||||
|
**Stand:** 2026-07-27
|
||||||
|
**Bezug:** ADP Backlog/Epic P1–P4, SPEC-D `agile_iteration`, Steering Kernel Spine, Vision v0.2 (Program Director)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ausgangslage
|
||||||
|
|
||||||
|
Mit **P4 (Epic Roll-up)** ist der **Backlog/Epic-Track** des Agile-Intake abgeschlossen:
|
||||||
|
|
||||||
|
- Dual Commit Path (Direct AP + Intake)
|
||||||
|
- Epic-Baum, Profil-Vokabular, Roll-up + Attention
|
||||||
|
|
||||||
|
Das reicht für **Dokumentation und Nachverfolgung**, aber noch nicht für **vorausschauendes Dirigieren** im Sinne der Product Vision: Kairo soll priorisieren, planen und steuern — nicht nur CRUD und Status anzeigen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Lücke — „Dirigieren“ vs. „Dokumentieren“
|
||||||
|
|
||||||
|
| Fähigkeit | Ist (nach P4) | Ziel (Program Director) |
|
||||||
|
|-----------|---------------|-------------------------|
|
||||||
|
| Epic-Fortschritt | Roll-up aus committeten Actions | ✓ |
|
||||||
|
| Sprint-Commit | Manuell aus Eingang | Vorschlag nächster Sprint-Inhalt |
|
||||||
|
| Priorisierung | Manuell (Priority-Feld) | Kernel-Vorschlag (Stories, Bugs, Debt) |
|
||||||
|
| Architekturschuld | Nicht modelliert | Führen, abbauen, Attention |
|
||||||
|
| Guardrail-/Architektur-Reviews | Review-Entität vorhanden | Regelmäßig, automatisiert, KI-unterstützt |
|
||||||
|
| Agent-Steuerung | Leading = Action | Task-Baum + Operating Context (P5) |
|
||||||
|
|
||||||
|
**Fazit:** Agile **Intake/Hierarchie** ist fertig; Agile **Steuerungsprogramm** (Planung, Debt, Reviews) ist ein **eigenes Programm** — nicht in P4 mischen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Entscheidung — Phasen P5–P8 (Agile Steering Program)
|
||||||
|
|
||||||
|
| Phase | Inhalt | Schicht | Abhängigkeit |
|
||||||
|
|-------|--------|---------|--------------|
|
||||||
|
| **P5** | Agent-Task-Baum unter Action | Ist / Recursive Tasks | ADP Recursive (AP1.5d) |
|
||||||
|
| **P6** | **Sprint-Vorschlag** — Read Model: committbare Items + Prioritätsranking | Kernel + Snapshot | P4, work_cycles |
|
||||||
|
| **P7** | **Tech-/Architekturschuld** — Backlog-Typ oder Tag + Attention-Regeln + Abbau-Ziele | Plan + Kernel | Evidence, Review |
|
||||||
|
| **P8** | **Automatisierte Reviews** — Guardrails, Architektur, Zielerreichung (Actor + optional KI) | Recurring + Review + Agent-Slot | Principle Gate, MCP-Freigabe |
|
||||||
|
|
||||||
|
Kein Phase-Sprung. **KI/Prompt/MCP** für P8 erst nach Principle Gate und stabilem Read-Model-Kern (vgl. Architecture Rules — eingefroren bis OM trägt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. P6 — Sprint-Vorschlag (Skizze)
|
||||||
|
|
||||||
|
**Read Model** `sprint_planning_proposal` (kein Pflicht-Commit):
|
||||||
|
|
||||||
|
- Input: offene Backlog-Items (Epic-Subbaum optional), aktiver/geplanter Sprint, Action-Ist, Blocker
|
||||||
|
- Output: ranked Liste `{ backlog_item_id, reason_code, score_hint }`
|
||||||
|
- Attention wenn: Sprint startet in N Tagen und Commit leer; Bugs ohne Owner; Epic intake_only trotz aktivem Sprint
|
||||||
|
|
||||||
|
**UI:** Plan → Sprint — „Vorgeschlagene Items“ (Accept/Adjust), nicht Auto-Commit.
|
||||||
|
|
||||||
|
**Steuerung:** `evaluate_next_work` / eigene Strategy-Hook — **keine** Page-Ifs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. P7 — Architekturschuld (Skizze)
|
||||||
|
|
||||||
|
| Option | Beschreibung | Empfehlung |
|
||||||
|
|--------|--------------|------------|
|
||||||
|
| A | `item_kind=tech_debt` im Backlog | Profilgebunden, Commit wie Story |
|
||||||
|
| B | `action_kind=tech_debt` Direct AP | Continuous-Pfad |
|
||||||
|
| C | Review + Evidence verknüpft mit Gate | Für Guardrail-Reviews |
|
||||||
|
|
||||||
|
**Empfehlung:** A + B (Dual Path analog Features/Bugs) + Attention `architecture_debt_stale` wenn Debt älter als Schwellwert ohne Commit.
|
||||||
|
|
||||||
|
**Abbau:** nicht als Gate — als committete Actions mit Review-Evidence (Guardrail-Checkliste).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. P8 — Automatisierte Reviews (Skizze)
|
||||||
|
|
||||||
|
- **RecurringElement** pro Initiative: „Architektur-Guardrail-Review“, „Sprint-Retro“, „Ziel-Check“
|
||||||
|
- **Review**-Entität mit Checkliste (config, nicht hardcoded in UI)
|
||||||
|
- **Actor-Slot:** Agent führt Review aus → Evidence + Decision-Vorschlag
|
||||||
|
- **KI:** nur über auditierten Agent-Actor; Kontext aus Snapshot/Operating Context — nicht Prompt-Drift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Abgrenzung zu P4
|
||||||
|
|
||||||
|
P4 liefert **Ist-Fortschritt auf Epic-Ebene**. P6–P8 liefern **Vorausschau und Korrektur** — eigenes ADP-Implementierungsprogramm, gleicher Kernel-Einstieg (`evaluate_steering`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Referenzen
|
||||||
|
|
||||||
|
| Dokument | Pfad |
|
||||||
|
|----------|------|
|
||||||
|
| ADP Backlog/Epic | `docs/architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md` |
|
||||||
|
| SPEC agile_iteration | `docs/architecture/methods/SPEC_D_agile_iteration_v0.1.md` |
|
||||||
|
| Recursive Tasks | AP1.5d / ADP Recursive Containers |
|
||||||
|
| Implementation Truth Table | `docs/product/Kairo_Implementation_Truth_Table_v0.1.md` |
|
||||||
|
|
@ -164,7 +164,7 @@ Product `planOutlineKeys` (Ziel): `profile`, `inbox`, `work`, `sprint`, `gates`
|
||||||
| **P1 — Direct AP** | Plan → Arbeit im Product-Profil; Product-Labels | UI only |
|
| **P1 — Direct AP** | Plan → Arbeit im Product-Profil; Product-Labels | UI only |
|
||||||
| **P2 — Backlog-Baum** | `parent_backlog_id`, `item_kind=epic` | Migration |
|
| **P2 — Backlog-Baum** | `parent_backlog_id`, `item_kind=epic` | Migration |
|
||||||
| **P3 — Profil-Vokabular** | Operating Context liefert erlaubte `backlog_item_kinds` | AP2.4 Erweiterung |
|
| **P3 — Profil-Vokabular** | Operating Context liefert erlaubte `backlog_item_kinds` | AP2.4 Erweiterung |
|
||||||
| **P4 — Epic Roll-up** | Read Model Fortschritt / Attention | Steering Kernel |
|
| **P4 — Epic Roll-up** | Read Model Fortschritt / Attention | Steering Kernel | ✓ AP2.2h |
|
||||||
| **P5 — Agent-Tasks** | Task-Baum unter AP (ADP Recursive) | AP1.5d |
|
| **P5 — Agent-Tasks** | Task-Baum unter AP (ADP Recursive) | AP1.5d |
|
||||||
|
|
||||||
Kein Phase-Sprung ohne ADP/Spec-Referenz.
|
Kein Phase-Sprung ohne ADP/Spec-Referenz.
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| `actions.sort_order` | ◐ | Migration 023 |
|
| `actions.sort_order` | ◐ | Migration 023 |
|
||||||
| BacklogItem | ✓ | Gate-Zuordnung AP1.6, `sort_order` |
|
| BacklogItem | ✓ | Gate-Zuordnung AP1.6, `sort_order` |
|
||||||
| 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/h: Epic-Baum + P4 Roll-up Read Model (`epic_rollup`, Attention) |
|
||||||
| 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 | ◐ | AP2.2g: `backlog_vocabulary` + `backlog_epic_hierarchy` via effektive `steering_elements` |
|
| Profil-Vokabular Backlog | ◐ | AP2.2g: `backlog_vocabulary` + `backlog_epic_hierarchy` via effektive `steering_elements` |
|
||||||
| Blocker | ✓ | `action_id` optional |
|
| Blocker | ✓ | `action_id` optional |
|
||||||
|
|
@ -228,7 +228,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1
|
||||||
|----|-----------|
|
|----|-----------|
|
||||||
| AP2.3/4 | ✓ Plugin-Architektur + Element-Registry (2026-07-25) |
|
| AP2.3/4 | ✓ Plugin-Architektur + Element-Registry (2026-07-25) |
|
||||||
| AP2.2a | Starter-Kits ◐→✓ |
|
| AP2.2a | Starter-Kits ◐→✓ |
|
||||||
| ADP Backlog/Epic | P1 Direct AP · P2 Epic-Baum · P3 Profil-Vokabular · P4 Roll-up (2026-07-26 Doku) |
|
| ADP Backlog/Epic | P1–P4 ✓ (Roll-up AP2.2h) · P5 Agent-Tasks offen |
|
||||||
| AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ |
|
| AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ |
|
||||||
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
||||||
| AP1.7b | Op-API Parität |
|
| AP1.7b | Op-API Parität |
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ import { useInitiativeOperations } from '../context/InitiativeOperationsContext.
|
||||||
import { hasSteeringElement } from '../registry/steeringElementRegistry.js'
|
import { hasSteeringElement } from '../registry/steeringElementRegistry.js'
|
||||||
import { BacklogIntakeGuide } from './BacklogIntakeGuide.jsx'
|
import { BacklogIntakeGuide } from './BacklogIntakeGuide.jsx'
|
||||||
import { actionTitleById } from '../utils/actionReferences.js'
|
import { actionTitleById } from '../utils/actionReferences.js'
|
||||||
|
import {
|
||||||
|
epicRollupById,
|
||||||
|
epicRollupProgressPercent,
|
||||||
|
formatEpicRollupSummary,
|
||||||
|
} from '../utils/epicRollup.js'
|
||||||
|
|
||||||
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
|
||||||
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
|
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
|
||||||
|
|
@ -38,7 +43,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 { operatingContext, steeringElements, steeringSnapshot } = 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('')
|
||||||
|
|
@ -107,6 +112,12 @@ export function BacklogSection({
|
||||||
return map
|
return map
|
||||||
}, [epicItems])
|
}, [epicItems])
|
||||||
|
|
||||||
|
const rollupByEpicId = useMemo(
|
||||||
|
() => epicRollupById(steeringSnapshot?.epic_rollup),
|
||||||
|
[steeringSnapshot?.epic_rollup],
|
||||||
|
)
|
||||||
|
const showEpicRollup = Boolean(vocabulary.capabilities?.epic_hierarchy)
|
||||||
|
|
||||||
const defaultKind = vocabulary.default_kind || 'story'
|
const defaultKind = vocabulary.default_kind || 'story'
|
||||||
const hasConvertedItems = items.some((item) => item.status === 'converted')
|
const hasConvertedItems = items.some((item) => item.status === 'converted')
|
||||||
const directWorkEnabled = Boolean(
|
const directWorkEnabled = Boolean(
|
||||||
|
|
@ -326,6 +337,7 @@ export function BacklogSection({
|
||||||
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
||||||
const reorderable = canReorder && item.status !== 'converted'
|
const reorderable = canReorder && item.status !== 'converted'
|
||||||
const isEpic = item.item_kind === 'epic'
|
const isEpic = item.item_kind === 'epic'
|
||||||
|
const epicRollup = isEpic && showEpicRollup ? rollupByEpicId[item.id] : null
|
||||||
const flatIndex = sortedItems.findIndex((row) => row.id === item.id)
|
const flatIndex = sortedItems.findIndex((row) => row.id === item.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -376,6 +388,24 @@ export function BacklogSection({
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p className="list-item-desc">{item.description}</p>
|
<p className="list-item-desc">{item.description}</p>
|
||||||
)}
|
)}
|
||||||
|
{epicRollup && (
|
||||||
|
<div className="epic-rollup-summary">
|
||||||
|
<div
|
||||||
|
className="epic-rollup-summary__bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={epicRollupProgressPercent(epicRollup)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`Epic-Fortschritt ${epicRollupProgressPercent(epicRollup)} Prozent`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="epic-rollup-summary__fill"
|
||||||
|
style={{ width: `${epicRollupProgressPercent(epicRollup)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="list-item-sub muted">{formatEpicRollupSummary(epicRollup)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{item.parent_backlog_id && (
|
{item.parent_backlog_id && (
|
||||||
<p className="list-item-sub muted">
|
<p className="list-item-sub muted">
|
||||||
Epic: {epicTitleById.get(item.parent_backlog_id) || '…'}
|
Epic: {epicTitleById.get(item.parent_backlog_id) || '…'}
|
||||||
|
|
|
||||||
|
|
@ -1678,6 +1678,27 @@
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.epic-rollup-summary {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epic-rollup-summary__bar {
|
||||||
|
height: 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--jk-primary) 10%, var(--jk-surface));
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epic-rollup-summary__fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--jk-primary);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.archetype-badge {
|
.archetype-badge {
|
||||||
background: var(--jk-surface-muted, #eef2f6);
|
background: var(--jk-surface-muted, #eef2f6);
|
||||||
color: var(--jk-text-secondary, #445);
|
color: var(--jk-text-secondary, #445);
|
||||||
|
|
|
||||||
35
frontend/src/utils/epicRollup.js
Normal file
35
frontend/src/utils/epicRollup.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/** Epic roll-up labels for Backlog UI (P4). */
|
||||||
|
|
||||||
|
export function epicRollupById(epicRollup = []) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
(epicRollup || []).map((row) => [row.epic_backlog_id, row]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEpicRollupSummary(rollup) {
|
||||||
|
if (!rollup || rollup.child_total === 0) {
|
||||||
|
return 'Noch keine Stories unter diesem Epic'
|
||||||
|
}
|
||||||
|
const done = rollup.actions_done ?? 0
|
||||||
|
const total = rollup.child_total
|
||||||
|
const uncommitted = rollup.child_uncommitted ?? 0
|
||||||
|
if (rollup.status === 'done') {
|
||||||
|
return `${done}/${total} erledigt — Epic abgeschlossen`
|
||||||
|
}
|
||||||
|
if (rollup.status === 'intake_only') {
|
||||||
|
return `${total} Item(s) im Eingang — noch nicht committet`
|
||||||
|
}
|
||||||
|
let text = `${done}/${total} erledigt`
|
||||||
|
if (uncommitted > 0) {
|
||||||
|
text += `, ${uncommitted} noch im Eingang`
|
||||||
|
}
|
||||||
|
if (rollup.actions_in_progress > 0) {
|
||||||
|
text += `, ${rollup.actions_in_progress} in Arbeit`
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export function epicRollupProgressPercent(rollup) {
|
||||||
|
if (!rollup?.child_total) return 0
|
||||||
|
return Math.round((rollup.completion_ratio ?? 0) * 100)
|
||||||
|
}
|
||||||
32
frontend/src/utils/epicRollup.test.js
Normal file
32
frontend/src/utils/epicRollup.test.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
epicRollupById,
|
||||||
|
epicRollupProgressPercent,
|
||||||
|
formatEpicRollupSummary,
|
||||||
|
} from './epicRollup.js'
|
||||||
|
|
||||||
|
describe('epicRollup', () => {
|
||||||
|
const rollup = {
|
||||||
|
epic_backlog_id: 'e1',
|
||||||
|
child_total: 4,
|
||||||
|
child_uncommitted: 1,
|
||||||
|
actions_done: 2,
|
||||||
|
actions_in_progress: 1,
|
||||||
|
completion_ratio: 0.5,
|
||||||
|
status: 'in_progress',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('maps rollup by epic id', () => {
|
||||||
|
const map = epicRollupById([rollup])
|
||||||
|
expect(map.e1).toEqual(rollup)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats in-progress summary', () => {
|
||||||
|
expect(formatEpicRollupSummary(rollup)).toContain('2/4 erledigt')
|
||||||
|
expect(formatEpicRollupSummary(rollup)).toContain('1 noch im Eingang')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes progress percent', () => {
|
||||||
|
expect(epicRollupProgressPercent(rollup)).toBe(50)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user