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

140 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

1"""ll-issues list: List active issues with optional type/priority/status filters.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6from typing import TYPE_CHECKING 

7 

8from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, print_json 

9 

10if TYPE_CHECKING: 

11 from little_loops.config import BRConfig 

12 

13 

14def cmd_list(config: BRConfig, args: argparse.Namespace) -> int: 

15 """List issues with optional filters. 

16 

17 Args: 

18 config: Project configuration 

19 args: Parsed arguments with optional .type, .priority, .status, .flat, and .json attributes 

20 

21 Returns: 

22 Exit code (0 = success) 

23 """ 

24 from datetime import date, datetime 

25 

26 from little_loops.cli.issues.search import ( 

27 _load_issues_with_status, 

28 _parse_discovered_date, 

29 _parse_labels_from_content, 

30 _sort_issues, 

31 ) 

32 

33 status = getattr(args, "status", "open") or "open" 

34 include_open = status in ("open", "in_progress", "blocked", "all") 

35 include_done = status in ("done", "cancelled", "all") 

36 include_deferred = status in ("deferred", "all") 

37 

38 raw = _load_issues_with_status(config, include_open, include_done, include_deferred) 

39 

40 from little_loops.cli_args import parse_priorities 

41 

42 type_filter = getattr(args, "type", None) 

43 priority_filter: set[str] | None = parse_priorities(getattr(args, "priority", None)) 

44 label_filters: list[str] = getattr(args, "label", None) or [] 

45 milestone_filter: str | None = getattr(args, "milestone", None) or None 

46 

47 filtered = [ 

48 (issue, stat) 

49 for issue, stat in raw 

50 if (not type_filter or issue.issue_id.split("-", 1)[0] == type_filter) 

51 and (not priority_filter or issue.priority in priority_filter) 

52 and ( 

53 not label_filters 

54 or any(lf.lower() in [lb.lower() for lb in issue.labels] for lf in label_filters) 

55 ) 

56 and (not milestone_filter or issue.milestone == milestone_filter) 

57 ] 

58 

59 # Sort 

60 sort_field = getattr(args, "sort", "priority") or "priority" 

61 need_content = sort_field in {"created", "completed"} 

62 want_json = getattr(args, "json", False) 

63 enriched: list[tuple] = [] 

64 for issue, stat in filtered: 

65 disc_date: datetime | None = None 

66 comp_date: date | None = None 

67 labels: list[str] = [] 

68 content = "" 

69 if need_content or want_json: 

70 try: 

71 content = issue.path.read_text(encoding="utf-8") 

72 except Exception: 

73 content = "" 

74 if need_content: 

75 if sort_field == "created": 

76 disc_date = _parse_discovered_date(content, issue.path) 

77 elif sort_field == "completed": 

78 from little_loops.issue_history.parsing import _parse_completion_date 

79 

80 comp_date = _parse_completion_date(content, issue.path) 

81 if want_json: 

82 labels = _parse_labels_from_content(content) 

83 enriched.append((issue, stat, disc_date, comp_date, labels)) 

84 

85 if getattr(args, "desc", False): 

86 descending = True 

87 elif getattr(args, "asc", False): 

88 descending = False 

89 else: 

90 descending = sort_field in {"created", "completed"} 

91 

92 enriched = _sort_issues(enriched, sort_field, descending) 

93 

94 limit = getattr(args, "limit", None) 

95 if limit is not None and limit < 1: 

96 import sys 

97 

98 print(f"Error: --limit must be a positive integer, got {limit}", file=sys.stderr) 

99 return 1 

100 

101 if limit is not None: 

102 enriched = enriched[:limit] 

103 

104 issues_with_status = [(item[0], item[1]) for item in enriched] 

105 

106 if not issues_with_status: 

107 print("No active issues") 

108 return 0 

109 

110 if getattr(args, "json", False): 

111 print_json( 

112 [ 

113 { 

114 "id": issue.issue_id, 

115 "priority": issue.priority, 

116 "type": issue.issue_id.split("-", 1)[0], 

117 "title": issue.title, 

118 "path": str(issue.path), 

119 "status": stat, 

120 "discovered_date": disc_date.date().isoformat() if disc_date else None, 

121 "parent": issue.parent, 

122 "labels": lbls, 

123 "milestone": issue.milestone, 

124 } 

125 for issue, stat, disc_date, _comp_date, lbls in enriched 

126 ] 

127 ) 

128 return 0 

129 

130 if getattr(args, "flat", False): 

131 for issue, _stat in issues_with_status: 

132 print(f"{issue.path.name} {issue.title}") 

133 return 0 

134 

135 group_by = getattr(args, "group_by", "type") 

136 if group_by == "epic": 

137 # Build parent title lookup from raw (before type filter) so headers 

138 # resolve correctly when --type BUG is combined with --group-by epic 

139 parent_titles: dict[str, str] = {i.issue_id: i.title for i, _ in raw if i.title} 

140 

141 parent_buckets: dict[str | None, list] = {} 

142 for issue, stat in issues_with_status: 

143 if issue.issue_id.split("-", 1)[0] == "EPIC": 

144 continue 

145 key = ( 

146 issue.parent if (issue.parent and issue.parent.split("-", 1)[0] == "EPIC") else None 

147 ) 

148 if key not in parent_buckets: 

149 parent_buckets[key] = [] 

150 parent_buckets[key].append((issue, stat)) 

151 

152 named_keys = sorted(k for k in parent_buckets if k is not None) 

153 ordered_keys = named_keys + ([None] if None in parent_buckets else []) 

154 

155 # Pre-compute progress badges for named EPIC buckets 

156 epic_progress_cache: dict[str, tuple[int, int, int]] = {} 

157 if named_keys: 

158 from little_loops.issue_parser import find_issues as _find_issues_all 

159 from little_loops.issue_progress import compute_epic_progress 

160 

161 _all_statuses: set[str] = { 

162 "open", 

163 "in_progress", 

164 "blocked", 

165 "done", 

166 "cancelled", 

167 "deferred", 

168 } 

169 all_issues_for_progress = _find_issues_all(config, status_filter=_all_statuses) 

170 for epic_key in named_keys: 

171 prog = compute_epic_progress(epic_key, all_issues_for_progress) 

172 if prog is not None: 

173 done = prog.by_status.get("done", 0) + prog.by_status.get("cancelled", 0) 

174 blocked = prog.by_status.get("blocked", 0) 

175 epic_progress_cache[epic_key] = (done, len(prog.children), blocked) 

176 

177 lines: list[str] = [] 

178 for key in ordered_keys: 

179 group = parent_buckets[key] 

180 if key is None: 

181 header = colorize(f"Unparented ({len(group)})", "1") 

182 else: 

183 title = parent_titles.get(key, "") 

184 badge = "" 

185 if key in epic_progress_cache: 

186 done, total, blocked = epic_progress_cache[key] 

187 badge = f" ({done}/{total} done" 

188 if blocked > 0: 

189 badge += f" · {blocked} blocked" 

190 badge += ")" 

191 base_label = f"{key}: {title}" if title else key 

192 label = f"{base_label} ({len(group)}){badge}" 

193 parent_prefix = key.split("-", 1)[0] 

194 header = colorize(label, f"{TYPE_COLOR.get(parent_prefix, '0')};1") 

195 lines.append(header) 

196 for issue, stat in group: 

197 issue_type = issue.issue_id.split("-", 1)[0] 

198 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0")) 

199 colored_priority = colorize(issue.priority, PRIORITY_COLOR.get(issue.priority, "0")) 

200 status_tag = f" [{stat}]" if stat not in ("open", "in_progress") else "" 

201 lines.append(f" {colored_priority} {colored_id} {issue.title}{status_tag}") 

202 lines.append("") 

203 displayed = sum(len(g) for g in parent_buckets.values()) 

204 lines.append(f"Total: {displayed} active issues (excluding EPICs)") 

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

206 return 0 

207 

208 # Group by type prefix 

209 buckets: dict[str, list] = {"BUG": [], "FEAT": [], "ENH": [], "EPIC": []} 

210 for issue, stat in issues_with_status: 

211 prefix = issue.issue_id.split("-", 1)[0] 

212 if prefix in buckets: 

213 buckets[prefix].append((issue, stat)) 

214 

215 type_labels = {"BUG": "Bugs", "FEAT": "Features", "ENH": "Enhancements", "EPIC": "Epics"} 

216 lines = [] 

217 for prefix, label in type_labels.items(): 

218 group = buckets[prefix] 

219 header = colorize(f"{label} ({len(group)})", f"{TYPE_COLOR.get(prefix, '0')};1") 

220 lines.append(header) 

221 for issue, stat in group: 

222 issue_type = issue.issue_id.split("-", 1)[0] 

223 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0")) 

224 colored_priority = colorize(issue.priority, PRIORITY_COLOR.get(issue.priority, "0")) 

225 status_tag = f" [{stat}]" if stat not in ("open", "in_progress") else "" 

226 lines.append(f" {colored_priority} {colored_id} {issue.title}{status_tag}") 

227 lines.append("") 

228 lines.append(f"Total: {len(issues_with_status)} active issues") 

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

230 return 0