Coverage for little_loops / cli / issues / show.py: 0%

281 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -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 ( 

12 PRIORITY_COLOR, 

13 TYPE_COLOR, 

14 colorize, 

15 print_json, 

16 strip_ansi, 

17 terminal_width, 

18) 

19 

20if TYPE_CHECKING: 

21 from little_loops.config import BRConfig 

22 

23 

24_SHOW_CMD_ALIASES: dict[str, str] = { 

25 "/ll:capture-issue": "capture", 

26 "/ll:scan-codebase": "scan", 

27 "/ll:audit-architecture": "audit", 

28 "/ll:format-issue": "format", 

29} 

30 

31 

32def _source_label(discovered_by: str | None) -> str: 

33 """Return a short display label for the discovered_by frontmatter field.""" 

34 if not discovered_by: 

35 return "\u2014" 

36 return _SHOW_CMD_ALIASES.get(discovered_by, discovered_by[:7]) 

37 

38 

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

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

41 

42 Accepts three input formats: 

43 - Numeric ID only: "518" 

44 - Type + ID: "FEAT-518" 

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

46 

47 Searches the type-scoped category directories. Status (open/done/deferred) 

48 lives in frontmatter, so active and inactive issues alike resolve here. 

49 

50 Issue numbers are globally unique across types (see ``get_next_issue_number``), 

51 so a numeric match is unambiguous. The type prefix and priority are therefore 

52 treated as **advisory**: an exact match is preferred, but a stale or mismatched 

53 prefix (e.g. ``FEAT-1903`` for a file now named ``ENH-1903``) still resolves to 

54 the one file bearing that number rather than reporting "not found" (BUG-2003). 

55 

56 Args: 

57 config: Project configuration 

58 user_input: Issue ID string in any supported format 

59 

60 Returns: 

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

62 """ 

63 user_input = user_input.strip() 

64 

65 # Parse input to extract components 

66 numeric_id: str | None = None 

67 type_prefix: str | None = None 

68 priority: str | None = None 

69 

70 # Try P-TYPE-NNN format (e.g., P3-FEAT-518) 

71 m = re.match(r"^(P\d)-(BUG|FEAT|ENH|EPIC)-(\d+)$", user_input, re.IGNORECASE) 

72 if m: 

73 priority = m.group(1).upper() 

74 type_prefix = m.group(2).upper() 

75 numeric_id = m.group(3) 

76 else: 

77 # Try TYPE-NNN format (e.g., FEAT-518) 

78 m = re.match(r"^(BUG|FEAT|ENH|EPIC)-(\d+)$", user_input, re.IGNORECASE) 

79 if m: 

80 type_prefix = m.group(1).upper() 

81 numeric_id = m.group(2) 

82 else: 

83 # Try numeric only (e.g., 518) 

84 m = re.match(r"^(\d+)$", user_input) 

85 if m: 

86 numeric_id = m.group(1) 

87 

88 if numeric_id is None: 

89 return None 

90 

91 # Build search directories: type-scoped dirs only 

92 search_dirs: list[Path] = [] 

93 for category in config.issue_categories: 

94 search_dirs.append(config.get_issue_dir(category)) 

95 

96 # Collect every file matching the numeric ID. Because numbers are globally 

97 # unique, this is normally a single candidate; the prefix/priority hints only 

98 # disambiguate the rare artificial case of two files sharing a number. 

99 candidates: list[Path] = [] 

100 for search_dir in search_dirs: 

101 if not search_dir.is_dir(): 

102 continue 

103 candidates.extend(sorted(search_dir.glob(f"*-{numeric_id}-*.md"))) 

104 

105 if not candidates: 

106 return None 

107 

108 def _matches_type(path: Path) -> bool: 

109 upper = path.name.upper() 

110 return f"-{type_prefix}-" in upper or upper.startswith(f"{type_prefix}-") 

111 

112 # Prefer an exact-type match; fall back to the unambiguous numeric match when 

113 # the caller's type prefix is stale or mismatched (advisory, not required). 

114 pool = [p for p in candidates if _matches_type(p)] if type_prefix else candidates 

115 if not pool: 

116 pool = candidates 

117 

118 # Within the chosen pool, prefer an exact priority match if one exists. 

119 if priority: 

120 prioritized = [p for p in pool if p.name.upper().startswith(f"{priority}-")] 

121 if prioritized: 

122 return prioritized[0] 

123 

124 return pool[0] 

125 

126 

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

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

129 

130 Args: 

131 path: Path to the issue file 

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

133 

134 Returns: 

135 Dictionary of card fields 

136 """ 

137 from little_loops.frontmatter import parse_frontmatter 

138 

139 content = path.read_text() 

140 frontmatter = parse_frontmatter(content, coerce_types=True) 

141 filename = path.name 

142 

143 # Extract priority from filename (e.g., P3-FEAT-518-...) 

144 priority_match = re.match(r"^(P\d)-", filename) 

145 priority = priority_match.group(1) if priority_match else None 

146 

147 # Extract type and ID from filename (e.g., FEAT-518) 

148 type_id_match = re.search(r"(BUG|FEAT|ENH|EPIC)-(\d+)", filename) 

149 issue_id = f"{type_id_match.group(1)}-{type_id_match.group(2)}" if type_id_match else None 

150 

151 # Extract title from content 

152 title: str | None = None 

153 title_match = re.search(r"^#\s+[\w-]+:\s*(.+)$", content, re.MULTILINE) 

154 if title_match: 

155 title = title_match.group(1).strip() 

156 else: 

157 header_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE) 

158 if header_match: 

159 title = header_match.group(1).strip() 

160 else: 

161 title = path.stem 

162 

163 # Determine status from frontmatter field 

164 raw_status = frontmatter.get("status", "open") 

165 _STATUS_DISPLAY = { 

166 "done": "Completed", 

167 "cancelled": "Cancelled", 

168 "deferred": "Deferred", 

169 "in_progress": "In Progress", 

170 "blocked": "Blocked", 

171 "open": "Open", 

172 } 

173 status = _STATUS_DISPLAY.get(str(raw_status), str(raw_status).replace("_", " ").title()) 

174 

175 # Extract optional frontmatter fields 

176 confidence = frontmatter.get("confidence_score") 

177 outcome = frontmatter.get("outcome_confidence") 

178 score_complexity = frontmatter.get("score_complexity") 

179 score_test_coverage = frontmatter.get("score_test_coverage") 

180 score_ambiguity = frontmatter.get("score_ambiguity") 

181 score_change_surface = frontmatter.get("score_change_surface") 

182 effort = frontmatter.get("effort") 

183 discovered_by = frontmatter.get("discovered_by") 

184 captured_at = frontmatter.get("captured_at") 

185 completed_at = frontmatter.get("completed_at") 

186 decision_needed_raw = frontmatter.get("decision_needed") 

187 missing_artifacts_raw = frontmatter.get("missing_artifacts") 

188 implementation_order_risk_raw = frontmatter.get("implementation_order_risk") 

189 learning_tests_raw = frontmatter.get("learning_tests_required") 

190 

191 # Source / norm / fmt fields 

192 from little_loops.issue_parser import is_formatted, is_normalized 

193 

194 source = _source_label(discovered_by) 

195 norm = "\u2713" if is_normalized(path.name) else "\u2717" 

196 fmt = "\u2713" if is_formatted(path) else "\u2717" 

197 

198 # --- New fields --- 

199 

200 # Summary: full first paragraph from ## Summary section 

201 summary: str | None = None 

202 summary_match = re.search( 

203 r"^## Summary\s*\n+(.*?)(?:\n\n|\n##|\Z)", content, re.MULTILINE | re.DOTALL 

204 ) 

205 if summary_match: 

206 text = summary_match.group(1).strip() 

207 if text: 

208 summary = text 

209 

210 # Integration file count: count items under ### Files to Modify 

211 integration_files: int | None = None 

212 ftm_match = re.search(r"^### Files to Modify\s*$", content, re.MULTILINE) 

213 if ftm_match: 

214 start = ftm_match.end() 

215 next_header = re.search(r"^#{2,3}\s+", content[start:], re.MULTILINE) 

216 section = content[start : start + next_header.start()] if next_header else content[start:] 

217 count = len(re.findall(r"^- .+", section, re.MULTILINE)) 

218 if count > 0: 

219 integration_files = count 

220 

221 # Risk: extract from ## Impact section 

222 risk: str | None = None 

223 risk_match = re.search(r"\*\*Risk\*\*:\s*(Low|Medium|High)", content, re.IGNORECASE) 

224 if risk_match: 

225 risk = risk_match.group(1).capitalize() 

226 

227 # Labels: prefer frontmatter labels: field; fall back to ## Labels body section 

228 labels: str | None = None 

229 fm_labels_raw = frontmatter.get("labels") 

230 if fm_labels_raw: 

231 if isinstance(fm_labels_raw, list): 

232 fm_label_list = [str(lb) for lb in fm_labels_raw if lb] 

233 else: 

234 fm_label_list = [lb.strip() for lb in str(fm_labels_raw).split(",") if lb.strip()] 

235 if fm_label_list: 

236 labels = ", ".join(fm_label_list) 

237 if not labels: 

238 labels_match = re.search( 

239 r"^## Labels\s*\n+(.*?)(?:\n\n|\n##|\Z)", content, re.MULTILINE | re.DOTALL 

240 ) 

241 if labels_match: 

242 found = re.findall(r"`([^`]+)`", labels_match.group(1)) 

243 if found: 

244 labels = ", ".join(found) 

245 

246 # Session log: parse ## Session Log for unique /ll:* commands with counts 

247 history: str | None = None 

248 from little_loops.session_log import count_session_commands, parse_session_log 

249 

250 distinct = parse_session_log(content) 

251 if distinct: 

252 counts = count_session_commands(content) 

253 parts = [f"{cmd} ({counts[cmd]})" if counts.get(cmd, 1) > 1 else cmd for cmd in distinct] 

254 history = ", ".join(parts) 

255 

256 # Relative path 

257 try: 

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

259 except ValueError: 

260 rel_path = str(path) 

261 

262 return { 

263 "issue_id": issue_id, 

264 "title": title, 

265 "priority": priority, 

266 "status": status, 

267 "effort": str(effort) if effort is not None else None, 

268 "confidence": str(confidence) if confidence is not None else None, 

269 "outcome": str(outcome) if outcome is not None else None, 

270 "score_complexity": str(score_complexity) if score_complexity is not None else None, 

271 "score_test_coverage": str(score_test_coverage) 

272 if score_test_coverage is not None 

273 else None, 

274 "score_ambiguity": str(score_ambiguity) if score_ambiguity is not None else None, 

275 "score_change_surface": str(score_change_surface) 

276 if score_change_surface is not None 

277 else None, 

278 "summary": summary, 

279 "integration_files": str(integration_files) if integration_files is not None else None, 

280 "risk": risk, 

281 "labels": labels, 

282 "milestone": frontmatter.get("milestone") or None, 

283 "history": history, 

284 "path": rel_path, 

285 "source": source, 

286 "norm": norm, 

287 "fmt": fmt, 

288 "captured_at": str(captured_at) if captured_at is not None else None, 

289 "completed_at": str(completed_at) if completed_at is not None else None, 

290 "decision_needed": str(decision_needed_raw).lower() 

291 if decision_needed_raw is not None 

292 else None, 

293 "missing_artifacts": str(missing_artifacts_raw).lower() 

294 if missing_artifacts_raw is not None 

295 else None, 

296 "implementation_order_risk": str(implementation_order_risk_raw).lower() 

297 if implementation_order_risk_raw is not None 

298 else None, 

299 "learning_tests_required": ", ".join(str(t) for t in learning_tests_raw) 

300 if learning_tests_raw 

301 else None, 

302 } 

303 

304 

305def _ljust(text: str, width: int) -> str: 

306 """Left-justify text accounting for invisible ANSI escape codes.""" 

307 pad = max(0, width - len(strip_ansi(text))) 

308 return text + " " * pad 

309 

310 

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

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

313 

314 Args: 

315 fields: Dictionary of card fields from _parse_card_fields 

316 

317 Returns: 

318 Formatted card string 

319 """ 

320 # Box-drawing characters 

321 h = "\u2500" # ─ 

322 v = "\u2502" # │ 

323 tl = "\u250c" # ┌ 

324 tr = "\u2510" # ┐ 

325 bl = "\u2514" # └ 

326 br = "\u2518" # ┘ 

327 ml = "\u251c" # ├ 

328 mr = "\u2524" # ┤ 

329 

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

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

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

333 

334 # Build metadata line (plain, for width calculation) 

335 priority = fields.get("priority") 

336 status = fields.get("status") 

337 effort = fields.get("effort") 

338 risk = fields.get("risk") 

339 

340 meta_parts: list[str] = [] 

341 if priority: 

342 meta_parts.append(f"Priority: {priority}") 

343 if status: 

344 meta_parts.append(f"Status: {status}") 

345 if effort: 

346 meta_parts.append(f"Effort: {effort}") 

347 if risk: 

348 meta_parts.append(f"Risk: {risk}") 

349 meta_line = " \u2502 ".join(meta_parts) 

350 

351 # Build scores line (only if at least one score present) 

352 score_parts: list[str] = [] 

353 if fields.get("confidence"): 

354 score_parts.append(f"Confidence: {fields['confidence']}") 

355 if fields.get("outcome"): 

356 score_parts.append(f"Outcome: {fields['outcome']}") 

357 scores_line = " \u2502 ".join(score_parts) if score_parts else None 

358 

359 # Build dimension scores line (only if at least one dimension score present) 

360 dim_parts: list[str] = [] 

361 if fields.get("score_complexity"): 

362 dim_parts.append(f"Cmplx: {fields['score_complexity']}") 

363 if fields.get("score_test_coverage"): 

364 dim_parts.append(f"Tcov: {fields['score_test_coverage']}") 

365 if fields.get("score_ambiguity"): 

366 dim_parts.append(f"Ambig: {fields['score_ambiguity']}") 

367 if fields.get("score_change_surface"): 

368 dim_parts.append(f"Chsrf: {fields['score_change_surface']}") 

369 dim_scores_line = " \u2502 ".join(dim_parts) if dim_parts else None 

370 

371 # Build detail lines (source/norm/fmt, integration+labels, history) 

372 detail_lines: list[str] = [] 

373 source_parts: list[str] = [] 

374 source_val = fields.get("source") 

375 if source_val and source_val != "\u2014": 

376 source_parts.append(f"Source: {source_val}") 

377 norm_val = fields.get("norm") 

378 if norm_val: 

379 source_parts.append(f"Norm: {norm_val}") 

380 fmt_val = fields.get("fmt") 

381 if fmt_val: 

382 source_parts.append(f"Fmt: {fmt_val}") 

383 if source_parts: 

384 detail_lines.append(" \u2502 ".join(source_parts)) 

385 detail_mid_parts: list[str] = [] 

386 if fields.get("integration_files"): 

387 detail_mid_parts.append(f"Integration: {fields['integration_files']} files") 

388 if fields.get("labels"): 

389 detail_mid_parts.append(f"Labels: {fields['labels']}") 

390 if fields.get("milestone"): 

391 detail_mid_parts.append(f"Milestone: {fields['milestone']}") 

392 if detail_mid_parts: 

393 detail_lines.append(" \u2502 ".join(detail_mid_parts)) 

394 if fields.get("captured_at"): 

395 detail_lines.append(f"Captured at: {fields['captured_at']}") 

396 if fields.get("completed_at"): 

397 detail_lines.append(f"Completed at: {fields['completed_at']}") 

398 if fields.get("history"): 

399 detail_lines.append(f"History: {fields['history']}") 

400 

401 # Build path line 

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

403 

404 # Calculate structural width from non-summary content 

405 structural_lines = [header, meta_line, path_line] 

406 if scores_line: 

407 structural_lines.append(scores_line) 

408 if dim_scores_line: 

409 structural_lines.append(dim_scores_line) 

410 structural_lines.extend(detail_lines) 

411 wrap_width = max((len(line) for line in structural_lines), default=60) 

412 wrap_width = max(wrap_width, 60) # minimum content width 

413 

414 # Build summary lines — wrap to fit structural width 

415 summary_lines: list[str] = [] 

416 summary_text = fields.get("summary") 

417 if summary_text: 

418 for line in summary_text.splitlines(): 

419 if line.strip(): 

420 summary_lines.extend(textwrap.wrap(line, width=wrap_width, break_long_words=False)) 

421 else: 

422 summary_lines.append("") 

423 

424 # Final width includes wrapped summary (may exceed wrap_width for unbreakable tokens) 

425 all_lines = structural_lines + summary_lines 

426 width = max(len(line) for line in all_lines) + 2 # +2 for padding 

427 

428 # Cap width to terminal to prevent overflow 

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

430 

431 # Build colorized header 

432 if issue_id and "-" in issue_id: 

433 itype = issue_id.split("-")[0] 

434 colored_id = colorize(issue_id, TYPE_COLOR.get(itype, "0")) 

435 else: 

436 colored_id = issue_id 

437 colored_header = f"{colored_id}: {title}" 

438 

439 # Build colorized meta line 

440 colored_meta_parts: list[str] = [] 

441 if priority: 

442 colored_meta_parts.append( 

443 f"Priority: {colorize(priority, PRIORITY_COLOR.get(priority, '0'))}" 

444 ) 

445 if status: 

446 colored_status = colorize("Completed", "32") if status == "Completed" else status 

447 colored_meta_parts.append(f"Status: {colored_status}") 

448 if effort: 

449 colored_meta_parts.append(f"Effort: {effort}") 

450 if risk: 

451 risk_code = {"High": "38;5;208", "Medium": "33", "Low": "2"}.get(risk, "0") 

452 colored_meta_parts.append(f"Risk: {colorize(risk, risk_code)}") 

453 colored_meta_line = " \u2502 ".join(colored_meta_parts) 

454 

455 # Build card 

456 lines: list[str] = [] 

457 top_border = f"{tl}{h * width}{tr}" 

458 mid_border = f"{ml}{h * width}{mr}" 

459 bot_border = f"{bl}{h * width}{br}" 

460 

461 lines.append(top_border) 

462 lines.append(f"{v} {_ljust(colored_header, width - 1)}{v}") 

463 lines.append(mid_border) 

464 lines.append(f"{v} {_ljust(colored_meta_line, width - 1)}{v}") 

465 if scores_line: 

466 lines.append(f"{v} {scores_line:<{width - 1}}{v}") 

467 if dim_scores_line: 

468 lines.append(f"{v} {dim_scores_line:<{width - 1}}{v}") 

469 if summary_lines: 

470 lines.append(mid_border) 

471 for sl in summary_lines: 

472 lines.append(f"{v} {sl:<{width - 1}}{v}") 

473 if detail_lines: 

474 lines.append(mid_border) 

475 for dl in detail_lines: 

476 lines.append(f"{v} {dl:<{width - 1}}{v}") 

477 lines.append(mid_border) 

478 lines.append(f"{v} {path_line:<{width - 1}}{v}") 

479 lines.append(bot_border) 

480 

481 return "\n".join(lines) 

482 

483 

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

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

486 

487 Args: 

488 config: Project configuration 

489 args: Parsed arguments with .issue_id attribute 

490 

491 Returns: 

492 Exit code (0 = success, 1 = not found) 

493 """ 

494 issue_id = args.issue_id 

495 path = _resolve_issue_id(config, issue_id) 

496 

497 if path is None: 

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

499 return 1 

500 

501 fields = _parse_card_fields(path, config) 

502 

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

504 print_json(fields) 

505 return 0 

506 

507 card = _render_card(fields) 

508 print(card) 

509 return 0