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>
171 lines
5.9 KiB
Python
171 lines
5.9 KiB
Python
"""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
|