Coverage for little_loops / cli / issues / epic_progress.py: 0%

67 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:27 -0500

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

2 

3from __future__ import annotations 

4 

5import argparse 

6from typing import TYPE_CHECKING 

7 

8if 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 

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 

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 

38def 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_count = prog.by_status.get("done", 0) + prog.by_status.get("cancelled", 0) 

73 blocked_count = prog.by_status.get("blocked", 0) 

74 pct = round(prog.percent_done) 

75 

76 if fmt == "markdown": 

77 lines = [ 

78 f"## {epic_id}: {prog.epic_title}", 

79 f"- **Progress**: {done_count}/{total} done ({pct}%)", 

80 ] 

81 status_parts = [ 

82 f"{prog.by_status[s]} {s}" for s in _STATUS_ORDER if prog.by_status.get(s, 0) > 0 

83 ] 

84 lines.append("- **Status**: " + " • ".join(status_parts)) 

85 if prog.oldest_open is not None: 

86 age_str = ( 

87 f" ({prog.oldest_open_age_days} days)" 

88 if prog.oldest_open_age_days is not None 

89 else "" 

90 ) 

91 lines.append(f"- **Oldest open**: {prog.oldest_open.issue_id}{age_str}") 

92 if blocked_count > 0: 

93 for bc in (c for c in prog.children if c.status == "blocked"): 

94 by_str = ", ".join(bc.blocked_by) if bc.blocked_by else "(unknown)" 

95 lines.append(f"- **Blocked**: {bc.issue_id} → blocked_by {by_str}") 

96 print("\n".join(lines)) 

97 return 0 

98 

99 # text format 

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

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

102 

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

104 print(f" Progress: {bar} {done_count}/{total} done ({pct}%)") 

105 

106 status_parts = [ 

107 f"{prog.by_status[s]} {s}" for s in _STATUS_ORDER if prog.by_status.get(s, 0) > 0 

108 ] 

109 print(f" Status: {' • '.join(status_parts)}") 

110 

111 if prog.oldest_open is not None: 

112 age_str = ( 

113 f" ({prog.oldest_open_age_days} days)" if prog.oldest_open_age_days is not None else "" 

114 ) 

115 issue_type = prog.oldest_open.issue_id.split("-", 1)[0] 

116 colored_id = colorize(prog.oldest_open.issue_id, TYPE_COLOR.get(issue_type, "0")) 

117 print(f" Oldest open: {colored_id}{age_str}") 

118 

119 if blocked_count > 0: 

120 for bc in (c for c in prog.children if c.status == "blocked"): 

121 by_str = ", ".join(bc.blocked_by) if bc.blocked_by else "(unknown)" 

122 print(f" Blocked: {bc.issue_id} → blocked_by {by_str}") 

123 

124 return 0