Coverage for little_loops / cli / issues / show.py: 94%
277 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 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
17_SHOW_CMD_ALIASES: dict[str, str] = {
18 "/ll:capture-issue": "capture",
19 "/ll:scan-codebase": "scan",
20 "/ll:audit-architecture": "audit",
21 "/ll:format-issue": "format",
22}
25def _source_label(discovered_by: str | None) -> str:
26 """Return a short display label for the discovered_by frontmatter field."""
27 if not discovered_by:
28 return "\u2014"
29 return _SHOW_CMD_ALIASES.get(discovered_by, discovered_by[:7])
32def _resolve_issue_id(config: BRConfig, user_input: str) -> Path | None:
33 """Resolve user input to an issue file path.
35 Accepts three input formats:
36 - Numeric ID only: "518"
37 - Type + ID: "FEAT-518"
38 - Priority + Type + ID: "P3-FEAT-518"
40 Searches all active category directories, the completed directory, and the deferred directory.
42 Args:
43 config: Project configuration
44 user_input: Issue ID string in any supported format
46 Returns:
47 Path to the matched issue file, or None if not found
48 """
49 user_input = user_input.strip()
51 # Parse input to extract components
52 numeric_id: str | None = None
53 type_prefix: str | None = None
54 priority: str | None = None
56 # Try P-TYPE-NNN format (e.g., P3-FEAT-518)
57 m = re.match(r"^(P\d)-(BUG|FEAT|ENH|EPIC)-(\d+)$", user_input, re.IGNORECASE)
58 if m:
59 priority = m.group(1).upper()
60 type_prefix = m.group(2).upper()
61 numeric_id = m.group(3)
62 else:
63 # Try TYPE-NNN format (e.g., FEAT-518)
64 m = re.match(r"^(BUG|FEAT|ENH|EPIC)-(\d+)$", user_input, re.IGNORECASE)
65 if m:
66 type_prefix = m.group(1).upper()
67 numeric_id = m.group(2)
68 else:
69 # Try numeric only (e.g., 518)
70 m = re.match(r"^(\d+)$", user_input)
71 if m:
72 numeric_id = m.group(1)
74 if numeric_id is None:
75 return None
77 # Build search directories: type-scoped dirs only
78 search_dirs: list[Path] = []
79 for category in config.issue_categories:
80 search_dirs.append(config.get_issue_dir(category))
82 # Search for matching files
83 for search_dir in search_dirs:
84 if not search_dir.is_dir():
85 continue
86 for path in search_dir.glob(f"*-{numeric_id}-*.md"):
87 filename = path.name
88 # Verify type prefix if provided
89 upper = filename.upper()
90 if (
91 type_prefix
92 and f"-{type_prefix}-" not in upper
93 and not upper.startswith(f"{type_prefix}-")
94 ):
95 continue
96 # Verify priority if provided
97 if priority and not filename.upper().startswith(f"{priority}-"):
98 continue
99 return path
101 return None
104def _parse_card_fields(path: Path, config: BRConfig) -> dict[str, str | None]:
105 """Parse issue file to extract summary card fields.
107 Args:
108 path: Path to the issue file
109 config: Project configuration (used for relative path computation)
111 Returns:
112 Dictionary of card fields
113 """
114 from little_loops.frontmatter import parse_frontmatter
116 content = path.read_text()
117 frontmatter = parse_frontmatter(content, coerce_types=True)
118 filename = path.name
120 # Extract priority from filename (e.g., P3-FEAT-518-...)
121 priority_match = re.match(r"^(P\d)-", filename)
122 priority = priority_match.group(1) if priority_match else None
124 # Extract type and ID from filename (e.g., FEAT-518)
125 type_id_match = re.search(r"(BUG|FEAT|ENH|EPIC)-(\d+)", filename)
126 issue_id = f"{type_id_match.group(1)}-{type_id_match.group(2)}" if type_id_match else None
128 # Extract title from content
129 title: str | None = None
130 title_match = re.search(r"^#\s+[\w-]+:\s*(.+)$", content, re.MULTILINE)
131 if title_match:
132 title = title_match.group(1).strip()
133 else:
134 header_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
135 if header_match:
136 title = header_match.group(1).strip()
137 else:
138 title = path.stem
140 # Determine status from frontmatter field
141 raw_status = frontmatter.get("status", "open")
142 _STATUS_DISPLAY = {
143 "done": "Completed",
144 "cancelled": "Cancelled",
145 "deferred": "Deferred",
146 "in_progress": "In Progress",
147 "blocked": "Blocked",
148 "open": "Open",
149 }
150 status = _STATUS_DISPLAY.get(str(raw_status), str(raw_status).replace("_", " ").title())
152 # Extract optional frontmatter fields
153 confidence = frontmatter.get("confidence_score")
154 outcome = frontmatter.get("outcome_confidence")
155 score_complexity = frontmatter.get("score_complexity")
156 score_test_coverage = frontmatter.get("score_test_coverage")
157 score_ambiguity = frontmatter.get("score_ambiguity")
158 score_change_surface = frontmatter.get("score_change_surface")
159 effort = frontmatter.get("effort")
160 discovered_by = frontmatter.get("discovered_by")
161 captured_at = frontmatter.get("captured_at")
162 completed_at = frontmatter.get("completed_at")
163 decision_needed_raw = frontmatter.get("decision_needed")
164 missing_artifacts_raw = frontmatter.get("missing_artifacts")
165 implementation_order_risk_raw = frontmatter.get("implementation_order_risk")
167 # Source / norm / fmt fields
168 from little_loops.issue_parser import is_formatted, is_normalized
170 source = _source_label(discovered_by)
171 norm = "\u2713" if is_normalized(path.name) else "\u2717"
172 fmt = "\u2713" if is_formatted(path) else "\u2717"
174 # --- New fields ---
176 # Summary: full first paragraph from ## Summary section
177 summary: str | None = None
178 summary_match = re.search(
179 r"^## Summary\s*\n+(.*?)(?:\n\n|\n##|\Z)", content, re.MULTILINE | re.DOTALL
180 )
181 if summary_match:
182 text = summary_match.group(1).strip()
183 if text:
184 summary = text
186 # Integration file count: count items under ### Files to Modify
187 integration_files: int | None = None
188 ftm_match = re.search(r"^### Files to Modify\s*$", content, re.MULTILINE)
189 if ftm_match:
190 start = ftm_match.end()
191 next_header = re.search(r"^#{2,3}\s+", content[start:], re.MULTILINE)
192 section = content[start : start + next_header.start()] if next_header else content[start:]
193 count = len(re.findall(r"^- .+", section, re.MULTILINE))
194 if count > 0:
195 integration_files = count
197 # Risk: extract from ## Impact section
198 risk: str | None = None
199 risk_match = re.search(r"\*\*Risk\*\*:\s*(Low|Medium|High)", content, re.IGNORECASE)
200 if risk_match:
201 risk = risk_match.group(1).capitalize()
203 # Labels: prefer frontmatter labels: field; fall back to ## Labels body section
204 labels: str | None = None
205 fm_labels_raw = frontmatter.get("labels")
206 if fm_labels_raw:
207 if isinstance(fm_labels_raw, list):
208 fm_label_list = [str(lb) for lb in fm_labels_raw if lb]
209 else:
210 fm_label_list = [lb.strip() for lb in str(fm_labels_raw).split(",") if lb.strip()]
211 if fm_label_list:
212 labels = ", ".join(fm_label_list)
213 if not labels:
214 labels_match = re.search(
215 r"^## Labels\s*\n+(.*?)(?:\n\n|\n##|\Z)", content, re.MULTILINE | re.DOTALL
216 )
217 if labels_match:
218 found = re.findall(r"`([^`]+)`", labels_match.group(1))
219 if found:
220 labels = ", ".join(found)
222 # Session log: parse ## Session Log for unique /ll:* commands with counts
223 history: str | None = None
224 from little_loops.session_log import count_session_commands, parse_session_log
226 distinct = parse_session_log(content)
227 if distinct:
228 counts = count_session_commands(content)
229 parts = [f"{cmd} ({counts[cmd]})" if counts.get(cmd, 1) > 1 else cmd for cmd in distinct]
230 history = ", ".join(parts)
232 # Relative path
233 try:
234 rel_path = str(path.relative_to(config.project_root))
235 except ValueError:
236 rel_path = str(path)
238 return {
239 "issue_id": issue_id,
240 "title": title,
241 "priority": priority,
242 "status": status,
243 "effort": str(effort) if effort is not None else None,
244 "confidence": str(confidence) if confidence is not None else None,
245 "outcome": str(outcome) if outcome is not None else None,
246 "score_complexity": str(score_complexity) if score_complexity is not None else None,
247 "score_test_coverage": str(score_test_coverage)
248 if score_test_coverage is not None
249 else None,
250 "score_ambiguity": str(score_ambiguity) if score_ambiguity is not None else None,
251 "score_change_surface": str(score_change_surface)
252 if score_change_surface is not None
253 else None,
254 "summary": summary,
255 "integration_files": str(integration_files) if integration_files is not None else None,
256 "risk": risk,
257 "labels": labels,
258 "milestone": frontmatter.get("milestone") or None,
259 "history": history,
260 "path": rel_path,
261 "source": source,
262 "norm": norm,
263 "fmt": fmt,
264 "captured_at": str(captured_at) if captured_at is not None else None,
265 "completed_at": str(completed_at) if completed_at is not None else None,
266 "decision_needed": str(decision_needed_raw).lower()
267 if decision_needed_raw is not None
268 else None,
269 "missing_artifacts": str(missing_artifacts_raw).lower()
270 if missing_artifacts_raw is not None
271 else None,
272 "implementation_order_risk": str(implementation_order_risk_raw).lower()
273 if implementation_order_risk_raw is not None
274 else None,
275 }
278_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
281def _strip_ansi(text: str) -> str:
282 return _ANSI_RE.sub("", text)
285def _ljust(text: str, width: int) -> str:
286 """Left-justify text accounting for invisible ANSI escape codes."""
287 pad = max(0, width - len(_strip_ansi(text)))
288 return text + " " * pad
291def _render_card(fields: dict[str, str | None]) -> str:
292 """Render a summary card using box-drawing characters.
294 Args:
295 fields: Dictionary of card fields from _parse_card_fields
297 Returns:
298 Formatted card string
299 """
300 # Box-drawing characters
301 h = "\u2500" # ─
302 v = "\u2502" # │
303 tl = "\u250c" # ┌
304 tr = "\u2510" # ┐
305 bl = "\u2514" # └
306 br = "\u2518" # ┘
307 ml = "\u251c" # ├
308 mr = "\u2524" # ┤
310 issue_id = fields.get("issue_id") or "???"
311 title = fields.get("title") or "Untitled"
312 header = f"{issue_id}: {title}"
314 # Build metadata line (plain, for width calculation)
315 priority = fields.get("priority")
316 status = fields.get("status")
317 effort = fields.get("effort")
318 risk = fields.get("risk")
320 meta_parts: list[str] = []
321 if priority:
322 meta_parts.append(f"Priority: {priority}")
323 if status:
324 meta_parts.append(f"Status: {status}")
325 if effort:
326 meta_parts.append(f"Effort: {effort}")
327 if risk:
328 meta_parts.append(f"Risk: {risk}")
329 meta_line = " \u2502 ".join(meta_parts)
331 # Build scores line (only if at least one score present)
332 score_parts: list[str] = []
333 if fields.get("confidence"):
334 score_parts.append(f"Confidence: {fields['confidence']}")
335 if fields.get("outcome"):
336 score_parts.append(f"Outcome: {fields['outcome']}")
337 scores_line = " \u2502 ".join(score_parts) if score_parts else None
339 # Build dimension scores line (only if at least one dimension score present)
340 dim_parts: list[str] = []
341 if fields.get("score_complexity"):
342 dim_parts.append(f"Cmplx: {fields['score_complexity']}")
343 if fields.get("score_test_coverage"):
344 dim_parts.append(f"Tcov: {fields['score_test_coverage']}")
345 if fields.get("score_ambiguity"):
346 dim_parts.append(f"Ambig: {fields['score_ambiguity']}")
347 if fields.get("score_change_surface"):
348 dim_parts.append(f"Chsrf: {fields['score_change_surface']}")
349 dim_scores_line = " \u2502 ".join(dim_parts) if dim_parts else None
351 # Build detail lines (source/norm/fmt, integration+labels, history)
352 detail_lines: list[str] = []
353 source_parts: list[str] = []
354 source_val = fields.get("source")
355 if source_val and source_val != "\u2014":
356 source_parts.append(f"Source: {source_val}")
357 norm_val = fields.get("norm")
358 if norm_val:
359 source_parts.append(f"Norm: {norm_val}")
360 fmt_val = fields.get("fmt")
361 if fmt_val:
362 source_parts.append(f"Fmt: {fmt_val}")
363 if source_parts:
364 detail_lines.append(" \u2502 ".join(source_parts))
365 detail_mid_parts: list[str] = []
366 if fields.get("integration_files"):
367 detail_mid_parts.append(f"Integration: {fields['integration_files']} files")
368 if fields.get("labels"):
369 detail_mid_parts.append(f"Labels: {fields['labels']}")
370 if fields.get("milestone"):
371 detail_mid_parts.append(f"Milestone: {fields['milestone']}")
372 if detail_mid_parts:
373 detail_lines.append(" \u2502 ".join(detail_mid_parts))
374 if fields.get("captured_at"):
375 detail_lines.append(f"Captured at: {fields['captured_at']}")
376 if fields.get("completed_at"):
377 detail_lines.append(f"Completed at: {fields['completed_at']}")
378 if fields.get("history"):
379 detail_lines.append(f"History: {fields['history']}")
381 # Build path line
382 path_line = f"Path: {fields.get('path', '???')}"
384 # Calculate structural width from non-summary content
385 structural_lines = [header, meta_line, path_line]
386 if scores_line:
387 structural_lines.append(scores_line)
388 if dim_scores_line:
389 structural_lines.append(dim_scores_line)
390 structural_lines.extend(detail_lines)
391 wrap_width = max((len(line) for line in structural_lines), default=60)
392 wrap_width = max(wrap_width, 60) # minimum content width
394 # Build summary lines — wrap to fit structural width
395 summary_lines: list[str] = []
396 summary_text = fields.get("summary")
397 if summary_text:
398 for line in summary_text.splitlines():
399 if line.strip():
400 summary_lines.extend(textwrap.wrap(line, width=wrap_width, break_long_words=False))
401 else:
402 summary_lines.append("")
404 # Final width includes wrapped summary (may exceed wrap_width for unbreakable tokens)
405 all_lines = structural_lines + summary_lines
406 width = max(len(line) for line in all_lines) + 2 # +2 for padding
408 # Cap width to terminal to prevent overflow
409 width = min(width, terminal_width() - 4)
411 # Build colorized header
412 if issue_id and "-" in issue_id:
413 itype = issue_id.split("-")[0]
414 colored_id = colorize(issue_id, TYPE_COLOR.get(itype, "0"))
415 else:
416 colored_id = issue_id
417 colored_header = f"{colored_id}: {title}"
419 # Build colorized meta line
420 colored_meta_parts: list[str] = []
421 if priority:
422 colored_meta_parts.append(
423 f"Priority: {colorize(priority, PRIORITY_COLOR.get(priority, '0'))}"
424 )
425 if status:
426 colored_status = colorize("Completed", "32") if status == "Completed" else status
427 colored_meta_parts.append(f"Status: {colored_status}")
428 if effort:
429 colored_meta_parts.append(f"Effort: {effort}")
430 if risk:
431 risk_code = {"High": "38;5;208", "Medium": "33", "Low": "2"}.get(risk, "0")
432 colored_meta_parts.append(f"Risk: {colorize(risk, risk_code)}")
433 colored_meta_line = " \u2502 ".join(colored_meta_parts)
435 # Build card
436 lines: list[str] = []
437 top_border = f"{tl}{h * width}{tr}"
438 mid_border = f"{ml}{h * width}{mr}"
439 bot_border = f"{bl}{h * width}{br}"
441 lines.append(top_border)
442 lines.append(f"{v} {_ljust(colored_header, width - 1)}{v}")
443 lines.append(mid_border)
444 lines.append(f"{v} {_ljust(colored_meta_line, width - 1)}{v}")
445 if scores_line:
446 lines.append(f"{v} {scores_line:<{width - 1}}{v}")
447 if dim_scores_line:
448 lines.append(f"{v} {dim_scores_line:<{width - 1}}{v}")
449 if summary_lines:
450 lines.append(mid_border)
451 for sl in summary_lines:
452 lines.append(f"{v} {sl:<{width - 1}}{v}")
453 if detail_lines:
454 lines.append(mid_border)
455 for dl in detail_lines:
456 lines.append(f"{v} {dl:<{width - 1}}{v}")
457 lines.append(mid_border)
458 lines.append(f"{v} {path_line:<{width - 1}}{v}")
459 lines.append(bot_border)
461 return "\n".join(lines)
464def cmd_show(config: BRConfig, args: argparse.Namespace) -> int:
465 """Display summary card for a single issue.
467 Args:
468 config: Project configuration
469 args: Parsed arguments with .issue_id attribute
471 Returns:
472 Exit code (0 = success, 1 = not found)
473 """
474 issue_id = args.issue_id
475 path = _resolve_issue_id(config, issue_id)
477 if path is None:
478 print(f"Error: Issue '{issue_id}' not found.")
479 return 1
481 fields = _parse_card_fields(path, config)
483 if getattr(args, "json", False):
484 print_json(fields)
485 return 0
487 card = _render_card(fields)
488 print(card)
489 return 0