Coverage for little_loops / cli / issues / list_cmd.py: 94%
86 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -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
10if TYPE_CHECKING:
11 from little_loops.config import BRConfig
14def cmd_list(config: BRConfig, args: argparse.Namespace) -> int:
15 """List issues with optional filters.
17 Args:
18 config: Project configuration
19 args: Parsed arguments with optional .type, .priority, .status, .flat, and .json attributes
21 Returns:
22 Exit code (0 = success)
23 """
24 from datetime import date, datetime
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 )
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")
38 raw = _load_issues_with_status(config, include_open, include_done, include_deferred)
40 from little_loops.cli_args import parse_priorities
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
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 ]
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)
77 elif sort_field == "completed":
78 from little_loops.issue_history.parsing import _parse_completion_date
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))
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"}
92 enriched = _sort_issues(enriched, sort_field, descending)
94 limit = getattr(args, "limit", None)
95 if limit is not None and limit < 1:
96 import sys
98 print(f"Error: --limit must be a positive integer, got {limit}", file=sys.stderr)
99 return 1
101 if limit is not None:
102 enriched = enriched[:limit]
104 issues_with_status = [(item[0], item[1]) for item in enriched]
106 if not issues_with_status:
107 print("No active issues")
108 return 0
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 "labels": lbls,
122 "milestone": issue.milestone,
123 }
124 for issue, stat, disc_date, _comp_date, lbls in enriched
125 ]
126 )
127 return 0
129 if getattr(args, "flat", False):
130 for issue, _stat in issues_with_status:
131 print(f"{issue.path.name} {issue.title}")
132 return 0
134 # Group by type prefix
135 buckets: dict[str, list] = {"BUG": [], "FEAT": [], "ENH": [], "EPIC": []}
136 for issue, stat in issues_with_status:
137 prefix = issue.issue_id.split("-", 1)[0]
138 if prefix in buckets:
139 buckets[prefix].append((issue, stat))
141 type_labels = {"BUG": "Bugs", "FEAT": "Features", "ENH": "Enhancements", "EPIC": "Epics"}
142 lines: list[str] = []
143 for prefix, label in type_labels.items():
144 group = buckets[prefix]
145 header = colorize(f"{label} ({len(group)})", f"{TYPE_COLOR.get(prefix, '0')};1")
146 lines.append(header)
147 for issue, stat in group:
148 issue_type = issue.issue_id.split("-", 1)[0]
149 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0"))
150 colored_priority = colorize(issue.priority, PRIORITY_COLOR.get(issue.priority, "0"))
151 status_tag = f" [{stat}]" if stat not in ("open", "in_progress") else ""
152 lines.append(f" {colored_priority} {colored_id} {issue.title}{status_tag}")
153 lines.append("")
154 lines.append(f"Total: {len(issues_with_status)} active issues")
155 print("\n".join(lines))
156 return 0