Coverage for little_loops / cli / issues / list_cmd.py: 0%
165 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
1"""ll-issues list: List active issues with optional type/priority/status filters."""
3from __future__ import annotations
5import argparse
6from typing import TYPE_CHECKING
8from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, print_json, terminal_width
10if TYPE_CHECKING:
11 from little_loops.config import BRConfig
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
20def cmd_list(config: BRConfig, args: argparse.Namespace) -> int:
21 """List issues with optional filters.
23 Args:
24 config: Project configuration
25 args: Parsed arguments with optional .type, .priority, .status, .flat, and .json attributes
27 Returns:
28 Exit code (0 = success)
29 """
30 from datetime import date, datetime
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 )
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")
45 raw = _load_issues_with_status(config, include_open, include_done, include_deferred)
47 from little_loops.cli_args import parse_priorities
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
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 ]
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
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))
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"}
105 enriched = _sort_issues(enriched, sort_field, descending)
107 limit = getattr(args, "limit", None)
108 if limit is not None and limit < 1:
109 import sys
111 print(f"Error: --limit must be a positive integer, got {limit}", file=sys.stderr)
112 return 1
114 if limit is not None:
115 enriched = enriched[:limit]
117 issues_with_status = [(item[0], item[1]) for item in enriched]
119 if not issues_with_status:
120 print("No active issues")
121 return 0
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
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
149 no_truncate = getattr(args, "no_truncate", False)
150 tw = terminal_width(default=120)
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
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)
168 parent_titles: dict[str, str] = {i.issue_id: i.title for i in _all_issues if i.title}
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}
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
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))
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 [])
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
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)
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
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))
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