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
« 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."""
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 _sort_issues,
37 )
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")
44 raw = _load_issues_with_status(config, include_open, include_done, include_deferred)
46 from little_loops.cli_args import parse_priorities
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
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 ]
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
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))
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"}
100 enriched = _sort_issues(enriched, sort_field, descending)
102 limit = getattr(args, "limit", None)
103 if limit is not None and limit < 1:
104 import sys
106 print(f"Error: --limit must be a positive integer, got {limit}", file=sys.stderr)
107 return 1
109 if limit is not None:
110 enriched = enriched[:limit]
112 issues_with_status = [(item[0], item[1]) for item in enriched]
114 if not issues_with_status:
115 print("No active issues")
116 return 0
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
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
143 no_truncate = getattr(args, "no_truncate", False)
144 tw = terminal_width(default=120)
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}
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}
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
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))
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 [])
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
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)
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
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))
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