Coverage for little_loops / cli / issues / epic_progress.py: 0%
70 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
1"""ll-issues epic-progress: EPIC child-issue progress aggregation and display."""
3from __future__ import annotations
5import argparse
6from typing import TYPE_CHECKING
8if TYPE_CHECKING:
9 from little_loops.config import BRConfig
11_ALL_STATUSES: set[str] = {"open", "in_progress", "blocked", "done", "cancelled", "deferred"}
13_STATUS_ORDER = ["in_progress", "blocked", "open", "done", "cancelled", "deferred"]
16def add_epic_progress_parser(subs: argparse._SubParsersAction) -> argparse.ArgumentParser:
17 """Register the epic-progress subparser on *subs*."""
18 from little_loops.cli_args import add_config_arg
20 p = subs.add_parser(
21 "epic-progress",
22 aliases=["ep"],
23 help="Show aggregate progress for an EPIC (done/total, blocked, oldest open)",
24 )
25 p.set_defaults(command="epic-progress")
26 p.add_argument("epic_id", help="EPIC ID (e.g., EPIC-1773)")
27 p.add_argument(
28 "--format",
29 "-f",
30 choices=["text", "json", "markdown"],
31 default="text",
32 help="Output format (default: text)",
33 )
34 add_config_arg(p)
35 return p
38def cmd_epic_progress(config: BRConfig, args: argparse.Namespace) -> int:
39 """Display progress aggregates for a single EPIC.
41 Returns:
42 0 on success, 1 on error (EPIC not found or bad ID).
43 """
44 from little_loops.cli.output import TYPE_COLOR, colorize, print_json, sparkline
45 from little_loops.issue_parser import find_issues
46 from little_loops.issue_progress import compute_epic_progress
48 epic_id = args.epic_id.strip().upper()
49 if not epic_id.startswith("EPIC-"):
50 print(f"Error: expected an EPIC ID (e.g., EPIC-1773), got {args.epic_id!r}")
51 return 1
53 all_issues = find_issues(config, status_filter=_ALL_STATUSES)
54 prog = compute_epic_progress(epic_id, all_issues)
56 if prog is None:
57 print(f"Error: EPIC {epic_id!r} not found")
58 return 1
60 fmt = getattr(args, "format", "text") or "text"
62 if fmt == "json":
63 print_json(prog.to_dict())
64 return 0
66 total = len(prog.children)
67 if total == 0:
68 print(f"{epic_id}: {prog.epic_title}")
69 print(" (no children)")
70 return 0
72 done_only = prog.by_status.get("done", 0)
73 cancelled_count = prog.by_status.get("cancelled", 0)
74 # "resolved" = terminal states (done + cancelled). Labeled distinctly from
75 # "done" so the numerator never contradicts the per-status breakdown below.
76 done_count = done_only + cancelled_count
77 blocked_count = prog.by_status.get("blocked", 0)
78 pct = round(prog.percent_done)
79 resolved_breakdown = (
80 f" ({done_only} done, {cancelled_count} cancelled)" if cancelled_count > 0 else ""
81 )
83 if fmt == "markdown":
84 lines = [
85 f"## {epic_id}: {prog.epic_title}",
86 f"- **Progress**: {done_count}/{total} resolved ({pct}%){resolved_breakdown}",
87 ]
88 status_parts = [
89 f"{prog.by_status[s]} {s}" for s in _STATUS_ORDER if prog.by_status.get(s, 0) > 0
90 ]
91 lines.append("- **Status**: " + " • ".join(status_parts))
92 if prog.oldest_open is not None:
93 age_str = (
94 f" ({prog.oldest_open_age_days} days)"
95 if prog.oldest_open_age_days is not None
96 else ""
97 )
98 lines.append(f"- **Oldest open**: {prog.oldest_open.issue_id}{age_str}")
99 if blocked_count > 0:
100 for bc in (c for c in prog.children if c.status == "blocked"):
101 by_str = ", ".join(bc.blocked_by) if bc.blocked_by else "(unknown)"
102 lines.append(f"- **Blocked**: {bc.issue_id} → blocked_by {by_str}")
103 print("\n".join(lines))
104 return 0
106 # text format
107 epic_color = TYPE_COLOR.get("EPIC", "35")
108 print(colorize(f"{epic_id}: {prog.epic_title}", f"{epic_color};1"))
110 bar = colorize(sparkline(done_count, total, width=16), "32")
111 print(f" Progress: {bar} {done_count}/{total} resolved ({pct}%){resolved_breakdown}")
113 status_parts = [
114 f"{prog.by_status[s]} {s}" for s in _STATUS_ORDER if prog.by_status.get(s, 0) > 0
115 ]
116 print(f" Status: {' • '.join(status_parts)}")
118 if prog.oldest_open is not None:
119 age_str = (
120 f" ({prog.oldest_open_age_days} days)" if prog.oldest_open_age_days is not None else ""
121 )
122 issue_type = prog.oldest_open.issue_id.split("-", 1)[0]
123 colored_id = colorize(prog.oldest_open.issue_id, TYPE_COLOR.get(issue_type, "0"))
124 print(f" Oldest open: {colored_id}{age_str}")
126 if blocked_count > 0:
127 for bc in (c for c in prog.children if c.status == "blocked"):
128 by_str = ", ".join(bc.blocked_by) if bc.blocked_by else "(unknown)"
129 print(f" Blocked: {bc.issue_id} → blocked_by {by_str}")
131 return 0