Coverage for little_loops / cli / issues / show.py: 0%
217 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -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 and the completed 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
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())
68 # Search for matching files
69 for search_dir in search_dirs:
70 if not search_dir.is_dir():
71 continue
72 for path in search_dir.glob(f"*-{numeric_id}-*.md"):
73 filename = path.name
74 # Verify type prefix if provided
75 if type_prefix and f"-{type_prefix}-" not in filename.upper():
76 continue
77 # Verify priority if provided
78 if priority and not filename.upper().startswith(f"{priority}-"):
79 continue
80 return path
82 return None
85def _parse_card_fields(path: Path, config: BRConfig) -> dict[str, str | None]:
86 """Parse issue file to extract summary card fields.
88 Args:
89 path: Path to the issue file
90 config: Project configuration (used for relative path computation)
92 Returns:
93 Dictionary of card fields
94 """
95 from little_loops.frontmatter import parse_frontmatter
97 content = path.read_text()
98 frontmatter = parse_frontmatter(content, coerce_types=True)
99 filename = path.name
101 # Extract priority from filename (e.g., P3-FEAT-518-...)
102 priority_match = re.match(r"^(P\d)-", filename)
103 priority = priority_match.group(1) if priority_match else None
105 # Extract type and ID from filename (e.g., FEAT-518)
106 type_id_match = re.search(r"(BUG|FEAT|ENH)-(\d+)", filename)
107 issue_id = f"{type_id_match.group(1)}-{type_id_match.group(2)}" if type_id_match else None
109 # Extract title from content
110 title: str | None = None
111 title_match = re.search(r"^#\s+[\w-]+:\s*(.+)$", content, re.MULTILINE)
112 if title_match:
113 title = title_match.group(1).strip()
114 else:
115 header_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
116 if header_match:
117 title = header_match.group(1).strip()
118 else:
119 title = path.stem
121 # Determine status
122 is_completed = path.parent.name == "completed"
123 status = "Completed" if is_completed else "Open"
125 # Extract optional frontmatter fields
126 confidence = frontmatter.get("confidence_score")
127 outcome = frontmatter.get("outcome_confidence")
128 effort = frontmatter.get("effort")
130 # --- New fields ---
132 # Summary: full first paragraph from ## Summary section
133 summary: str | None = None
134 summary_match = re.search(
135 r"^## Summary\s*\n+(.*?)(?:\n\n|\n##|\Z)", content, re.MULTILINE | re.DOTALL
136 )
137 if summary_match:
138 text = summary_match.group(1).strip()
139 if text:
140 summary = text
142 # Integration file count: count items under ### Files to Modify
143 integration_files: int | None = None
144 ftm_match = re.search(r"^### Files to Modify\s*$", content, re.MULTILINE)
145 if ftm_match:
146 start = ftm_match.end()
147 next_header = re.search(r"^#{2,3}\s+", content[start:], re.MULTILINE)
148 section = content[start : start + next_header.start()] if next_header else content[start:]
149 count = len(re.findall(r"^- .+", section, re.MULTILINE))
150 if count > 0:
151 integration_files = count
153 # Risk: extract from ## Impact section
154 risk: str | None = None
155 risk_match = re.search(r"\*\*Risk\*\*:\s*(Low|Medium|High)", content, re.IGNORECASE)
156 if risk_match:
157 risk = risk_match.group(1).capitalize()
159 # Labels: extract backtick-delimited labels from ## Labels section
160 labels: str | None = None
161 labels_match = re.search(
162 r"^## Labels\s*\n+(.*?)(?:\n\n|\n##|\Z)", content, re.MULTILINE | re.DOTALL
163 )
164 if labels_match:
165 found = re.findall(r"`([^`]+)`", labels_match.group(1))
166 if found:
167 labels = ", ".join(found)
169 # Session log: parse ## Session Log for unique /ll:* commands with counts
170 history: str | None = None
171 from little_loops.session_log import count_session_commands, parse_session_log
173 distinct = parse_session_log(content)
174 if distinct:
175 counts = count_session_commands(content)
176 parts = [f"{cmd} ({counts[cmd]})" if counts.get(cmd, 1) > 1 else cmd for cmd in distinct]
177 history = ", ".join(parts)
179 # Relative path
180 try:
181 rel_path = str(path.relative_to(config.project_root))
182 except ValueError:
183 rel_path = str(path)
185 return {
186 "issue_id": issue_id,
187 "title": title,
188 "priority": priority,
189 "status": status,
190 "effort": str(effort) if effort is not None else None,
191 "confidence": str(confidence) if confidence is not None else None,
192 "outcome": str(outcome) if outcome is not None else None,
193 "summary": summary,
194 "integration_files": str(integration_files) if integration_files is not None else None,
195 "risk": risk,
196 "labels": labels,
197 "history": history,
198 "path": rel_path,
199 }
202_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
205def _strip_ansi(text: str) -> str:
206 return _ANSI_RE.sub("", text)
209def _ljust(text: str, width: int) -> str:
210 """Left-justify text accounting for invisible ANSI escape codes."""
211 pad = max(0, width - len(_strip_ansi(text)))
212 return text + " " * pad
215def _render_card(fields: dict[str, str | None]) -> str:
216 """Render a summary card using box-drawing characters.
218 Args:
219 fields: Dictionary of card fields from _parse_card_fields
221 Returns:
222 Formatted card string
223 """
224 # Box-drawing characters
225 h = "\u2500" # ─
226 v = "\u2502" # │
227 tl = "\u250c" # ┌
228 tr = "\u2510" # ┐
229 bl = "\u2514" # └
230 br = "\u2518" # ┘
231 ml = "\u251c" # ├
232 mr = "\u2524" # ┤
234 issue_id = fields.get("issue_id") or "???"
235 title = fields.get("title") or "Untitled"
236 header = f"{issue_id}: {title}"
238 # Build metadata line (plain, for width calculation)
239 priority = fields.get("priority")
240 status = fields.get("status")
241 effort = fields.get("effort")
242 risk = fields.get("risk")
244 meta_parts: list[str] = []
245 if priority:
246 meta_parts.append(f"Priority: {priority}")
247 if status:
248 meta_parts.append(f"Status: {status}")
249 if effort:
250 meta_parts.append(f"Effort: {effort}")
251 if risk:
252 meta_parts.append(f"Risk: {risk}")
253 meta_line = " \u2502 ".join(meta_parts)
255 # Build scores line (only if at least one score present)
256 score_parts: list[str] = []
257 if fields.get("confidence"):
258 score_parts.append(f"Confidence: {fields['confidence']}")
259 if fields.get("outcome"):
260 score_parts.append(f"Outcome: {fields['outcome']}")
261 scores_line = " \u2502 ".join(score_parts) if score_parts else None
263 # Build detail lines (integration+labels, history)
264 detail_lines: list[str] = []
265 detail_mid_parts: list[str] = []
266 if fields.get("integration_files"):
267 detail_mid_parts.append(f"Integration: {fields['integration_files']} files")
268 if fields.get("labels"):
269 detail_mid_parts.append(f"Labels: {fields['labels']}")
270 if detail_mid_parts:
271 detail_lines.append(" \u2502 ".join(detail_mid_parts))
272 if fields.get("history"):
273 detail_lines.append(f"History: {fields['history']}")
275 # Build path line
276 path_line = f"Path: {fields.get('path', '???')}"
278 # Calculate structural width from non-summary content
279 structural_lines = [header, meta_line, path_line]
280 if scores_line:
281 structural_lines.append(scores_line)
282 structural_lines.extend(detail_lines)
283 wrap_width = max((len(line) for line in structural_lines), default=60)
284 wrap_width = max(wrap_width, 60) # minimum content width
286 # Build summary lines — wrap to fit structural width
287 summary_lines: list[str] = []
288 summary_text = fields.get("summary")
289 if summary_text:
290 for line in summary_text.splitlines():
291 if line.strip():
292 summary_lines.extend(textwrap.wrap(line, width=wrap_width, break_long_words=False))
293 else:
294 summary_lines.append("")
296 # Final width includes wrapped summary (may exceed wrap_width for unbreakable tokens)
297 all_lines = structural_lines + summary_lines
298 width = max(len(line) for line in all_lines) + 2 # +2 for padding
300 # Cap width to terminal to prevent overflow
301 width = min(width, terminal_width() - 4)
303 # Build colorized header
304 if issue_id and "-" in issue_id:
305 itype = issue_id.split("-")[0]
306 colored_id = colorize(issue_id, TYPE_COLOR.get(itype, "0"))
307 else:
308 colored_id = issue_id
309 colored_header = f"{colored_id}: {title}"
311 # Build colorized meta line
312 colored_meta_parts: list[str] = []
313 if priority:
314 colored_meta_parts.append(
315 f"Priority: {colorize(priority, PRIORITY_COLOR.get(priority, '0'))}"
316 )
317 if status:
318 colored_status = colorize("Completed", "32") if status == "Completed" else status
319 colored_meta_parts.append(f"Status: {colored_status}")
320 if effort:
321 colored_meta_parts.append(f"Effort: {effort}")
322 if risk:
323 risk_code = {"High": "38;5;208", "Medium": "33", "Low": "2"}.get(risk, "0")
324 colored_meta_parts.append(f"Risk: {colorize(risk, risk_code)}")
325 colored_meta_line = " \u2502 ".join(colored_meta_parts)
327 # Build card
328 lines: list[str] = []
329 top_border = f"{tl}{h * width}{tr}"
330 mid_border = f"{ml}{h * width}{mr}"
331 bot_border = f"{bl}{h * width}{br}"
333 lines.append(top_border)
334 lines.append(f"{v} {_ljust(colored_header, width - 1)}{v}")
335 lines.append(mid_border)
336 lines.append(f"{v} {_ljust(colored_meta_line, width - 1)}{v}")
337 if scores_line:
338 lines.append(f"{v} {scores_line:<{width - 1}}{v}")
339 if summary_lines:
340 lines.append(mid_border)
341 for sl in summary_lines:
342 lines.append(f"{v} {sl:<{width - 1}}{v}")
343 if detail_lines:
344 lines.append(mid_border)
345 for dl in detail_lines:
346 lines.append(f"{v} {dl:<{width - 1}}{v}")
347 lines.append(mid_border)
348 lines.append(f"{v} {path_line:<{width - 1}}{v}")
349 lines.append(bot_border)
351 return "\n".join(lines)
354def cmd_show(config: BRConfig, args: argparse.Namespace) -> int:
355 """Display summary card for a single issue.
357 Args:
358 config: Project configuration
359 args: Parsed arguments with .issue_id attribute
361 Returns:
362 Exit code (0 = success, 1 = not found)
363 """
364 issue_id = args.issue_id
365 path = _resolve_issue_id(config, issue_id)
367 if path is None:
368 print(f"Error: Issue '{issue_id}' not found.")
369 return 1
371 fields = _parse_card_fields(path, config)
373 if getattr(args, "json", False):
374 print_json(fields)
375 return 0
377 card = _render_card(fields)
378 print(card)
379 return 0