"""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