1"""ll-issues epic-progress: EPIC child-issue progress aggregation and display."""

2

3 from __future__ import annotations

4

5 import argparse

6 from typing import TYPE_CHECKING

7

8 if TYPE_CHECKING:

9 from little_loops.config import BRConfig

10

11_ALL_STATUSES: set[str] = {"open", "in_progress", "blocked", "done", "cancelled", "deferred"}

12

13_STATUS_ORDER = ["in_progress", "blocked", "open", "done", "cancelled", "deferred"]

14

15

16 def 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

19

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

36

37

38 def cmd_epic_progress(config: BRConfig, args: argparse.Namespace) -> int:

39 """Display progress aggregates for a single EPIC.

40

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

47

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

52

53 all_issues = find_issues(config, status_filter=_ALL_STATUSES)

54 prog = compute_epic_progress(epic_id, all_issues)

55

56 if prog is None:

57 print(f"Error: EPIC {epic_id!r} not found")

58 return 1

59

60 fmt = getattr(args, "format", "text") or "text"

61

62 if fmt == "json":

63 print_json(prog.to_dict())

64 return 0

65

66 total = len(prog.children)

67 if total == 0:

68 print(f"{epic_id}: {prog.epic_title}")

69 print(" (no children)")

70 return 0

71

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 )

82

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

105

106 # text format

107 epic_color = TYPE_COLOR.get("EPIC", "35")

108 print(colorize(f"{epic_id}: {prog.epic_title}", f"{epic_color};1"))

109

110 bar = colorize(sparkline(done_count, total, width=16), "32")

111 print(f" Progress: {bar} {done_count}/{total} resolved ({pct}%){resolved_breakdown}")

112

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

117

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

125

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

130

131 return 0