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

2

3 from __future__ import annotations

4

5 import argparse

6 from typing import TYPE_CHECKING

7

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

9

10 if TYPE_CHECKING:

11 from little_loops.config import BRConfig

12

13

14 def _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

20 def 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 _parse_summary_from_content,

37 _sort_issues,

38 )

39

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

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

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

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

44

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

46

47 from little_loops.cli_args import parse_priorities

48

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

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

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

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

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

54

55 filtered = [

56 (issue, stat)

57 for issue, stat in raw

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

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

60 and (

61 not label_filters

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

63 )

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

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

66 ]

67

68 # Sort

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

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

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

72 want_summary = want_json and getattr(args, "include_summary", False)

73 enriched: list[tuple] = []

74 for issue, stat in filtered:

75 disc_date: datetime | None = None

76 comp_date: date | None = None

77 labels: list[str] = []

78 content = ""

79 if need_content or want_json:

80 try:

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

82 except Exception:

83 content = ""

84 if need_content:

85 if sort_field == "created":

86 disc_date = _parse_discovered_date(content, issue.path)

87 elif sort_field == "completed":

88 from little_loops.issue_history.parsing import _parse_completion_date

89

90 comp_date = _parse_completion_date(content, issue.path)

91 summary = ""

92 if want_json:

93 labels = _parse_labels_from_content(content)

94 if want_summary:

95 summary = _parse_summary_from_content(content)

96 enriched.append((issue, stat, disc_date, comp_date, labels, summary))

97

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

99 descending = True

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

101 descending = False

102 else:

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

104

105 enriched = _sort_issues(enriched, sort_field, descending)

106

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

108 if limit is not None and limit < 1:

109 import sys

110

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

112 return 1

113

114 if limit is not None:

115 enriched = enriched[:limit]

116

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

118

119 if not issues_with_status:

120 print("No active issues")

121 return 0

122

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

124 print_json(

125 [

126 {

127 "id": issue.issue_id,

128 "priority": issue.priority,

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

130 "title": issue.title,

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

132 "status": stat,

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

134 "parent": issue.parent,

135 "labels": lbls,

136 "milestone": issue.milestone,

137 **({"summary": smry} if want_summary else {}),

138 }

139 for issue, stat, disc_date, _comp_date, lbls, smry in enriched

140 ]

141 )

142 return 0

143

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

145 for issue, _stat in issues_with_status:

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

147 return 0

148

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

150 tw = terminal_width(default=120)

151

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

153 if group_by == "epic":

154 from little_loops.issue_parser import find_issues as _find_issues_all

155

156 _all_statuses: set[str] = {

157 "open",

158 "in_progress",

159 "blocked",

160 "done",

161 "cancelled",

162 "deferred",

163 }

164 # Load all issues regardless of status filter so chain traversal and title

165 # lookup are not severed at completed/deferred intermediate nodes.

166 _all_issues = _find_issues_all(config, status_filter=_all_statuses)

167

168 parent_titles: dict[str, str] = {i.issue_id: i.title for i in _all_issues if i.title}

169

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

171 _parent_map: dict[str, str] = {i.issue_id: i.parent for i in _all_issues if i.parent}

172

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

174 seen: set[str] = set()

175 current = _parent_map.get(issue_id)

176 while current and current not in seen:

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

178 return current

179 seen.add(current)

180 current = _parent_map.get(current)

181 return None

182

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

184 for issue, stat in issues_with_status:

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

186 continue

187 key = _find_epic_ancestor(issue.issue_id)

188 if key not in parent_buckets:

189 parent_buckets[key] = []

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

191

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

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

194

195 # Pre-compute progress badges for named EPIC buckets

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

197 if named_keys:

198 from little_loops.issue_progress import compute_epic_progress

199

200 for epic_key in named_keys:

201 prog = compute_epic_progress(epic_key, _all_issues)

202 if prog is not None:

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

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

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

206

207 lines: list[str] = []

208 for key in ordered_keys:

209 group = parent_buckets[key]

210 if key is None:

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

212 else:

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

214 badge = ""

215 if key in epic_progress_cache:

216 done, total, blocked = epic_progress_cache[key]

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

218 if blocked > 0:

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

220 badge += ")"

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

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

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

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

225 lines.append(header)

226 for issue, stat in group:

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

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

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

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

231 prefix_width = (

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

233 )

234 title = (

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

236 )

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

238 lines.append("")

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

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

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

242 return 0

243

244 # Group by type prefix

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

246 for issue, stat in issues_with_status:

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

248 if prefix in buckets:

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

250

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

252 lines = []

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

254 group = buckets[prefix]

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

256 lines.append(header)

257 for issue, stat in group:

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

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

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

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

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

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

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

265 lines.append("")

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

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

268 return 0