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

161 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -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, terminal_width 

9 

10if TYPE_CHECKING: 

11 from little_loops.config import BRConfig 

12 

13 

14def _truncate_title(title: str, max_len: int) -> str: 

15 if max_len > 20 and len(title) > max_len: 

16 return title[: max_len - 1] + "…" 

17 return title 

18 

19 

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

21 """List issues with optional filters. 

22 

23 Args: 

24 config: Project configuration 

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

26 

27 Returns: 

28 Exit code (0 = success) 

29 """ 

30 from datetime import date, datetime 

31 

32 from little_loops.cli.issues.search import ( 

33 _load_issues_with_status, 

34 _parse_discovered_date, 

35 _parse_labels_from_content, 

36 _sort_issues, 

37 ) 

38 

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

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

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

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

43 

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

45 

46 from little_loops.cli_args import parse_priorities 

47 

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

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

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

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

52 parent_filter: str | None = getattr(args, "parent", None) or None 

53 

54 filtered = [ 

55 (issue, stat) 

56 for issue, stat in raw 

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

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

59 and ( 

60 not label_filters 

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

62 ) 

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

64 and (not parent_filter or issue.parent == parent_filter) 

65 ] 

66 

67 # Sort 

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

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

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

71 enriched: list[tuple] = [] 

72 for issue, stat in filtered: 

73 disc_date: datetime | None = None 

74 comp_date: date | None = None 

75 labels: list[str] = [] 

76 content = "" 

77 if need_content or want_json: 

78 try: 

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

80 except Exception: 

81 content = "" 

82 if need_content: 

83 if sort_field == "created": 

84 disc_date = _parse_discovered_date(content, issue.path) 

85 elif sort_field == "completed": 

86 from little_loops.issue_history.parsing import _parse_completion_date 

87 

88 comp_date = _parse_completion_date(content, issue.path) 

89 if want_json: 

90 labels = _parse_labels_from_content(content) 

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

92 

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

94 descending = True 

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

96 descending = False 

97 else: 

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

99 

100 enriched = _sort_issues(enriched, sort_field, descending) 

101 

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

103 if limit is not None and limit < 1: 

104 import sys 

105 

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

107 return 1 

108 

109 if limit is not None: 

110 enriched = enriched[:limit] 

111 

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

113 

114 if not issues_with_status: 

115 print("No active issues") 

116 return 0 

117 

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

119 print_json( 

120 [ 

121 { 

122 "id": issue.issue_id, 

123 "priority": issue.priority, 

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

125 "title": issue.title, 

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

127 "status": stat, 

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

129 "parent": issue.parent, 

130 "labels": lbls, 

131 "milestone": issue.milestone, 

132 } 

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

134 ] 

135 ) 

136 return 0 

137 

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

139 for issue, _stat in issues_with_status: 

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

141 return 0 

142 

143 no_truncate = getattr(args, "no_truncate", False) 

144 tw = terminal_width(default=120) 

145 

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

147 if group_by == "epic": 

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

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

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

151 

152 # Build parent map for walking up multi-level chains (e.g. FEAT → FEAT → EPIC) 

153 _parent_map: dict[str, str] = {i.issue_id: i.parent for i, _ in raw if i.parent} 

154 

155 def _find_epic_ancestor(issue_id: str) -> str | None: 

156 seen: set[str] = set() 

157 current = _parent_map.get(issue_id) 

158 while current and current not in seen: 

159 if current.split("-", 1)[0] == "EPIC": 

160 return current 

161 seen.add(current) 

162 current = _parent_map.get(current) 

163 return None 

164 

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

166 for issue, stat in issues_with_status: 

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

168 continue 

169 key = _find_epic_ancestor(issue.issue_id) 

170 if key not in parent_buckets: 

171 parent_buckets[key] = [] 

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

173 

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

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

176 

177 # Pre-compute progress badges for named EPIC buckets 

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

179 if named_keys: 

180 from little_loops.issue_parser import find_issues as _find_issues_all 

181 from little_loops.issue_progress import compute_epic_progress 

182 

183 _all_statuses: set[str] = { 

184 "open", 

185 "in_progress", 

186 "blocked", 

187 "done", 

188 "cancelled", 

189 "deferred", 

190 } 

191 all_issues_for_progress = _find_issues_all(config, status_filter=_all_statuses) 

192 for epic_key in named_keys: 

193 prog = compute_epic_progress(epic_key, all_issues_for_progress) 

194 if prog is not None: 

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

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

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

198 

199 lines: list[str] = [] 

200 for key in ordered_keys: 

201 group = parent_buckets[key] 

202 if key is None: 

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

204 else: 

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

206 badge = "" 

207 if key in epic_progress_cache: 

208 done, total, blocked = epic_progress_cache[key] 

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

210 if blocked > 0: 

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

212 badge += ")" 

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

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

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

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

217 lines.append(header) 

218 for issue, stat in group: 

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

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

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

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

223 prefix_width = ( 

224 2 + len(issue.priority) + 2 + len(issue.issue_id) + 2 + len(status_tag) 

225 ) 

226 title = ( 

227 issue.title if no_truncate else _truncate_title(issue.title, tw - prefix_width) 

228 ) 

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

230 lines.append("") 

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

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

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

234 return 0 

235 

236 # Group by type prefix 

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

238 for issue, stat in issues_with_status: 

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

240 if prefix in buckets: 

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

242 

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

244 lines = [] 

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

246 group = buckets[prefix] 

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

248 lines.append(header) 

249 for issue, stat in group: 

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

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

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

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

254 prefix_width = 2 + len(issue.priority) + 2 + len(issue.issue_id) + 2 + len(status_tag) 

255 title = issue.title if no_truncate else _truncate_title(issue.title, tw - prefix_width) 

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

257 lines.append("") 

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

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

260 return 0