Coverage for little_loops / cli / issues / show.py: 6%
222 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
1"""ll-issues show: Display summary card for a single issue."""
3from __future__ import annotations
5import argparse
6import re
7import textwrap
8from pathlib import Path
9from typing import TYPE_CHECKING
11from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, print_json, terminal_width
13if TYPE_CHECKING:
14 from little_loops.config import BRConfig
17def _resolve_issue_id(config: BRConfig, user_input: str) -> Path | None:
18 """Resolve user input to an issue file path.
20 Accepts three input formats:
21 - Numeric ID only: "518"
22 - Type + ID: "FEAT-518"
23 - Priority + Type + ID: "P3-FEAT-518"
25 Searches all active category directories, the completed directory, and the deferred directory.
27 Args:
28 config: Project configuration
29 user_input: Issue ID string in any supported format
31 Returns:
32 Path to the matched issue file, or None if not found
33 """
34 user_input = user_input.strip()
36 # Parse input to extract components
37 numeric_id: str | None = None
38 type_prefix: str | None = None
39 priority: str | None = None
41 # Try P-TYPE-NNN format (e.g., P3-FEAT-518)
42 m = re.match(r"^(P\d)-(BUG|FEAT|ENH)-(\d+)$", user_input, re.IGNORECASE)
43 if m:
44 priority = m.group(1).upper()
45 type_prefix = m.group(2).upper()
46 numeric_id = m.group(3)
47 else:
48 # Try TYPE-NNN format (e.g., FEAT-518)
49 m = re.match(r"^(BUG|FEAT|ENH)-(\d+)$", user_input, re.IGNORECASE)
50 if m:
51 type_prefix = m.group(1).upper()
52 numeric_id = m.group(2)
53 else:
54 # Try numeric only (e.g., 518)
55 m = re.match(r"^(\d+)$", user_input)
56 if m:
57 numeric_id = m.group(1)
59 if numeric_id is None:
60 return None
62 # Build search directories: all active categories + completed + deferred
63 search_dirs: list[Path] = []
64 for category in config.issue_categories:
65 search_dirs.append(config.get_issue_dir(category))
66 search_dirs.append(config.get_completed_dir())
67 search_dirs.append(config.get_deferred_dir())
69 # Search for matching files
70 for search_dir in search_dirs:
71 if not search_dir.is_dir():
72 continue
73 for path in search_dir.glob(f"*-{numeric_id}-*.md"):
74 filename = path.name
75 # Verify type prefix if provided
76 if type_prefix and f"-{type_prefix}-" not in filename.upper():
77 continue
78 # Verify priority if provided
79 if priority and not filename.upper().startswith(f"{priority}-"):
80 continue
81 return path
83 return None
86def _parse_card_fields(path: Path, config: BRConfig) -> dict[str, str | None]:
87 """Parse issue file to extract summary card fields.
89 Args:
90 path: Path to the issue file
91 config: Project configuration (used for relative path computation)
93 Returns:
94 Dictionary of card fields
95 """
96 from little_loops.frontmatter import parse_frontmatter
98 content = path.read_text()
99 frontmatter = parse_frontmatter(content, coerce_types=True)
100 filename = path.name
102 # Extract priority from filename (e.g., P3-FEAT-518-...)
103 priority_match = re.match(r"^(P\d)-", filename)
104 priority = priority_match.group(1) if priority_match else None
106 # Extract type and ID from filename (e.g., FEAT-518)
107 type_id_match = re.search(r"(BUG|FEAT|ENH)-(\d+)", filename)
108 issue_id = f"{type_id_match.group(1)}-{type_id_match.group(2)}" if type_id_match else None
110 # Extract title from content
111 title: str | None = None
112 title_match = re.search(r"^#\s+[\w-]+:\s*(.+)$", content, re.MULTILINE)
113 if title_match:
114 title = title_match.group(1).strip()
115 else:
116 header_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
117 if header_match:
118 title = header_match.group(1).strip()
119 else:
120 title = path.stem
122 # Determine status
123 parent_name = path.parent.name
124 if parent_name == "completed":
125 status = "Completed"
126 elif parent_name == "deferred":
127 status = "Deferred"
128 else:
129 status = "Open"
131 # Extract optional frontmatter fields
132 confidence = frontmatter.get("confidence_score")
133 outcome = frontmatter.get("outcome_confidence")
134 effort = frontmatter.get("effort")
136 # --- New fields ---
138 # Summary: full first paragraph from ## Summary section
139 summary: str | None = None
140 summary_match = re.search(
141 r"^## Summary\s*\n+(.*?)(?:\n\n|\n##|\Z)", content, re.MULTILINE | re.DOTALL
142 )
143 if summary_match:
144 text = summary_match.group(1).strip()
145 if text:
146 summary = text
148 # Integration file count: count items under ### Files to Modify
149 integration_files: int | None = None
150 ftm_match = re.search(r"^### Files to Modify\s*$", content, re.MULTILINE)
151 if ftm_match:
152 start = ftm_match.end()
153 next_header = re.search(r"^#{2,3}\s+", content[start:], re.MULTILINE)
154 section = content[start : start + next_header.start()] if next_header else content[start:]
155 count = len(re.findall(r"^- .+", section, re.MULTILINE))
156 if count > 0:
157 integration_files = count
159 # Risk: extract from ## Impact section
160 risk: str | None = None
161 risk_match = re.search(r"\*\*Risk\*\*:\s*(Low|Medium|High)", content, re.IGNORECASE)
162 if risk_match:
163 risk = risk_match.group(1).capitalize()
165 # Labels: extract backtick-delimited labels from ## Labels section
166 labels: str | None = None
167 labels_match = re.search(
168 r"^## Labels\s*\n+(.*?)(?:\n\n|\n##|\Z)", content, re.MULTILINE | re.DOTALL
169 )
170 if labels_match:
171 found = re.findall(r"`([^`]+)`", labels_match.group(1))
172 if found:
173 labels = ", ".join(found)
175 # Session log: parse ## Session Log for unique /ll:* commands with counts
176 history: str | None = None
177 from little_loops.session_log import count_session_commands, parse_session_log
179 distinct = parse_session_log(content)
180 if distinct:
181 counts = count_session_commands(content)
182 parts = [f"{cmd} ({counts[cmd]})" if counts.get(cmd, 1) > 1 else cmd for cmd in distinct]
183 history = ", ".join(parts)
185 # Relative path
186 try:
187 rel_path = str(path.relative_to(config.project_root))
188 except ValueError:
189 rel_path = str(path)
191 return {
192 "issue_id": issue_id,
193 "title": title,
194 "priority": priority,
195 "status": status,
196 "effort": str(effort) if effort is not None else None,
197 "confidence": str(confidence) if confidence is not None else None,
198 "outcome": str(outcome) if outcome is not None else None,
199 "summary": summary,
200 "integration_files": str(integration_files) if integration_files is not None else None,
201 "risk": risk,
202 "labels": labels,
203 "history": history,
204 "path": rel_path,
205 }
208_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
211def _strip_ansi(text: str) -> str:
212 return _ANSI_RE.sub("", text)
215def _ljust(text: str, width: int) -> str:
216 """Left-justify text accounting for invisible ANSI escape codes."""
217 pad = max(0, width - len(_strip_ansi(text)))
218 return text + " " * pad
221def _render_card(fields: dict[str, str | None]) -> str:
222 """Render a summary card using box-drawing characters.
224 Args:
225 fields: Dictionary of card fields from _parse_card_fields
227 Returns:
228 Formatted card string
229 """
230 # Box-drawing characters
231 h = "\u2500" # ─
232 v = "\u2502" # │
233 tl = "\u250c" # ┌
234 tr = "\u2510" # ┐
235 bl = "\u2514" # └
236 br = "\u2518" # ┘
237 ml = "\u251c" # ├
238 mr = "\u2524" # ┤
240 issue_id = fields.get("issue_id") or "???"
241 title = fields.get("title") or "Untitled"
242 header = f"{issue_id}: {title}"
244 # Build metadata line (plain, for width calculation)
245 priority = fields.get("priority")
246 status = fields.get("status")
247 effort = fields.get("effort")
248 risk = fields.get("risk")
250 meta_parts: list[str] = []
251 if priority:
252 meta_parts.append(f"Priority: {priority}")
253 if status:
254 meta_parts.append(f"Status: {status}")
255 if effort:
256 meta_parts.append(f"Effort: {effort}")
257 if risk:
258 meta_parts.append(f"Risk: {risk}")
259 meta_line = " \u2502 ".join(meta_parts)
261 # Build scores line (only if at least one score present)
262 score_parts: list[str] = []
263 if fields.get("confidence"):
264 score_parts.append(f"Confidence: {fields['confidence']}")
265 if fields.get("outcome"):
266 score_parts.append(f"Outcome: {fields['outcome']}")
267 scores_line = " \u2502 ".join(score_parts) if score_parts else None
269 # Build detail lines (integration+labels, history)
270 detail_lines: list[str] = []
271 detail_mid_parts: list[str] = []
272 if fields.get("integration_files"):
273 detail_mid_parts.append(f"Integration: {fields['integration_files']} files")
274 if fields.get("labels"):
275 detail_mid_parts.append(f"Labels: {fields['labels']}")
276 if detail_mid_parts:
277 detail_lines.append(" \u2502 ".join(detail_mid_parts))
278 if fields.get("history"):
279 detail_lines.append(f"History: {fields['history']}")
281 # Build path line
282 path_line = f"Path: {fields.get('path', '???')}"
284 # Calculate structural width from non-summary content
285 structural_lines = [header, meta_line, path_line]
286 if scores_line:
287 structural_lines.append(scores_line)
288 structural_lines.extend(detail_lines)
289 wrap_width = max((len(line) for line in structural_lines), default=60)
290 wrap_width = max(wrap_width, 60) # minimum content width
292 # Build summary lines — wrap to fit structural width
293 summary_lines: list[str] = []
294 summary_text = fields.get("summary")
295 if summary_text:
296 for line in summary_text.splitlines():
297 if line.strip():
298 summary_lines.extend(textwrap.wrap(line, width=wrap_width, break_long_words=False))
299 else:
300 summary_lines.append("")
302 # Final width includes wrapped summary (may exceed wrap_width for unbreakable tokens)
303 all_lines = structural_lines + summary_lines
304 width = max(len(line) for line in all_lines) + 2 # +2 for padding
306 # Cap width to terminal to prevent overflow
307 width = min(width, terminal_width() - 4)
309 # Build colorized header
310 if issue_id and "-" in issue_id:
311 itype = issue_id.split("-")[0]
312 colored_id = colorize(issue_id, TYPE_COLOR.get(itype, "0"))
313 else:
314 colored_id = issue_id
315 colored_header = f"{colored_id}: {title}"
317 # Build colorized meta line
318 colored_meta_parts: list[str] = []
319 if priority:
320 colored_meta_parts.append(
321 f"Priority: {colorize(priority, PRIORITY_COLOR.get(priority, '0'))}"
322 )
323 if status:
324 colored_status = colorize("Completed", "32") if status == "Completed" else status
325 colored_meta_parts.append(f"Status: {colored_status}")
326 if effort:
327 colored_meta_parts.append(f"Effort: {effort}")
328 if risk:
329 risk_code = {"High": "38;5;208", "Medium": "33", "Low": "2"}.get(risk, "0")
330 colored_meta_parts.append(f"Risk: {colorize(risk, risk_code)}")
331 colored_meta_line = " \u2502 ".join(colored_meta_parts)
333 # Build card
334 lines: list[str] = []
335 top_border = f"{tl}{h * width}{tr}"
336 mid_border = f"{ml}{h * width}{mr}"
337 bot_border = f"{bl}{h * width}{br}"
339 lines.append(top_border)
340 lines.append(f"{v} {_ljust(colored_header, width - 1)}{v}")
341 lines.append(mid_border)
342 lines.append(f"{v} {_ljust(colored_meta_line, width - 1)}{v}")
343 if scores_line:
344 lines.append(f"{v} {scores_line:<{width - 1}}{v}")
345 if summary_lines:
346 lines.append(mid_border)
347 for sl in summary_lines:
348 lines.append(f"{v} {sl:<{width - 1}}{v}")
349 if detail_lines:
350 lines.append(mid_border)
351 for dl in detail_lines:
352 lines.append(f"{v} {dl:<{width - 1}}{v}")
353 lines.append(mid_border)
354 lines.append(f"{v} {path_line:<{width - 1}}{v}")
355 lines.append(bot_border)
357 return "\n".join(lines)
360def cmd_show(config: BRConfig, args: argparse.Namespace) -> int:
361 """Display summary card for a single issue.
363 Args:
364 config: Project configuration
365 args: Parsed arguments with .issue_id attribute
367 Returns:
368 Exit code (0 = success, 1 = not found)
369 """
370 issue_id = args.issue_id
371 path = _resolve_issue_id(config, issue_id)
373 if path is None:
374 print(f"Error: Issue '{issue_id}' not found.")
375 return 1
377 fields = _parse_card_fields(path, config)
379 if getattr(args, "json", False):
380 print_json(fields)
381 return 0
383 card = _render_card(fields)
384 print(card)
385 return 0