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

1"""ll-issues show: Display summary card for a single issue.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import re 

7import textwrap 

8from pathlib import Path 

9from typing import TYPE_CHECKING 

10 

11from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, print_json, terminal_width 

12 

13if TYPE_CHECKING: 

14 from little_loops.config import BRConfig 

15 

16 

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} 

23 

24 

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]) 

30 

31 

32def _resolve_issue_id(config: BRConfig, user_input: str) -> Path | None: 

33 """Resolve user input to an issue file path. 

34 

35 Accepts three input formats: 

36 - Numeric ID only: "518" 

37 - Type + ID: "FEAT-518" 

38 - Priority + Type + ID: "P3-FEAT-518" 

39 

40 Searches all active category directories, the completed directory, and the deferred directory. 

41 

42 Args: 

43 config: Project configuration 

44 user_input: Issue ID string in any supported format 

45 

46 Returns: 

47 Path to the matched issue file, or None if not found 

48 """ 

49 user_input = user_input.strip() 

50 

51 # Parse input to extract components 

52 numeric_id: str | None = None 

53 type_prefix: str | None = None 

54 priority: str | None = None 

55 

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) 

73 

74 if numeric_id is None: 

75 return None 

76 

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)) 

81 

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 

100 

101 return None 

102 

103 

104def _parse_card_fields(path: Path, config: BRConfig) -> dict[str, str | None]: 

105 """Parse issue file to extract summary card fields. 

106 

107 Args: 

108 path: Path to the issue file 

109 config: Project configuration (used for relative path computation) 

110 

111 Returns: 

112 Dictionary of card fields 

113 """ 

114 from little_loops.frontmatter import parse_frontmatter 

115 

116 content = path.read_text() 

117 frontmatter = parse_frontmatter(content, coerce_types=True) 

118 filename = path.name 

119 

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 

123 

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 

127 

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 

139 

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()) 

151 

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") 

166 

167 # Source / norm / fmt fields 

168 from little_loops.issue_parser import is_formatted, is_normalized 

169 

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" 

173 

174 # --- New fields --- 

175 

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 

185 

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 

196 

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() 

202 

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) 

221 

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 

225 

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) 

231 

232 # Relative path 

233 try: 

234 rel_path = str(path.relative_to(config.project_root)) 

235 except ValueError: 

236 rel_path = str(path) 

237 

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 } 

276 

277 

278_ANSI_RE = re.compile(r"\033\[[0-9;]*m") 

279 

280 

281def _strip_ansi(text: str) -> str: 

282 return _ANSI_RE.sub("", text) 

283 

284 

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 

289 

290 

291def _render_card(fields: dict[str, str | None]) -> str: 

292 """Render a summary card using box-drawing characters. 

293 

294 Args: 

295 fields: Dictionary of card fields from _parse_card_fields 

296 

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" # ┤ 

309 

310 issue_id = fields.get("issue_id") or "???" 

311 title = fields.get("title") or "Untitled" 

312 header = f"{issue_id}: {title}" 

313 

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") 

319 

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) 

330 

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 

338 

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 

350 

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']}") 

380 

381 # Build path line 

382 path_line = f"Path: {fields.get('path', '???')}" 

383 

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 

393 

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("") 

403 

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 

407 

408 # Cap width to terminal to prevent overflow 

409 width = min(width, terminal_width() - 4) 

410 

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}" 

418 

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) 

434 

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}" 

440 

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) 

460 

461 return "\n".join(lines) 

462 

463 

464def cmd_show(config: BRConfig, args: argparse.Namespace) -> int: 

465 """Display summary card for a single issue. 

466 

467 Args: 

468 config: Project configuration 

469 args: Parsed arguments with .issue_id attribute 

470 

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) 

476 

477 if path is None: 

478 print(f"Error: Issue '{issue_id}' not found.") 

479 return 1 

480 

481 fields = _parse_card_fields(path, config) 

482 

483 if getattr(args, "json", False): 

484 print_json(fields) 

485 return 0 

486 

487 card = _render_card(fields) 

488 print(card) 

489 return 0