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

277 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-18 02:58 -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 type_prefix and f"-{type_prefix}-" not in upper and not upper.startswith(f"{type_prefix}-"): 

91 continue 

92 # Verify priority if provided 

93 if priority and not filename.upper().startswith(f"{priority}-"): 

94 continue 

95 return path 

96 

97 return None 

98 

99 

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

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

102 

103 Args: 

104 path: Path to the issue file 

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

106 

107 Returns: 

108 Dictionary of card fields 

109 """ 

110 from little_loops.frontmatter import parse_frontmatter 

111 

112 content = path.read_text() 

113 frontmatter = parse_frontmatter(content, coerce_types=True) 

114 filename = path.name 

115 

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

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

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

119 

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

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

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

123 

124 # Extract title from content 

125 title: str | None = None 

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

127 if title_match: 

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

129 else: 

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

131 if header_match: 

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

133 else: 

134 title = path.stem 

135 

136 # Determine status from frontmatter field 

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

138 _STATUS_DISPLAY = { 

139 "done": "Completed", 

140 "cancelled": "Cancelled", 

141 "deferred": "Deferred", 

142 "in_progress": "In Progress", 

143 "blocked": "Blocked", 

144 "open": "Open", 

145 } 

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

147 

148 # Extract optional frontmatter fields 

149 confidence = frontmatter.get("confidence_score") 

150 outcome = frontmatter.get("outcome_confidence") 

151 score_complexity = frontmatter.get("score_complexity") 

152 score_test_coverage = frontmatter.get("score_test_coverage") 

153 score_ambiguity = frontmatter.get("score_ambiguity") 

154 score_change_surface = frontmatter.get("score_change_surface") 

155 effort = frontmatter.get("effort") 

156 discovered_by = frontmatter.get("discovered_by") 

157 captured_at = frontmatter.get("captured_at") 

158 completed_at = frontmatter.get("completed_at") 

159 decision_needed_raw = frontmatter.get("decision_needed") 

160 missing_artifacts_raw = frontmatter.get("missing_artifacts") 

161 implementation_order_risk_raw = frontmatter.get("implementation_order_risk") 

162 

163 # Source / norm / fmt fields 

164 from little_loops.issue_parser import is_formatted, is_normalized 

165 

166 source = _source_label(discovered_by) 

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

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

169 

170 # --- New fields --- 

171 

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

173 summary: str | None = None 

174 summary_match = re.search( 

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

176 ) 

177 if summary_match: 

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

179 if text: 

180 summary = text 

181 

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

183 integration_files: int | None = None 

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

185 if ftm_match: 

186 start = ftm_match.end() 

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

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

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

190 if count > 0: 

191 integration_files = count 

192 

193 # Risk: extract from ## Impact section 

194 risk: str | None = None 

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

196 if risk_match: 

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

198 

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

200 labels: str | None = None 

201 fm_labels_raw = frontmatter.get("labels") 

202 if fm_labels_raw: 

203 if isinstance(fm_labels_raw, list): 

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

205 else: 

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

207 if fm_label_list: 

208 labels = ", ".join(fm_label_list) 

209 if not labels: 

210 labels_match = re.search( 

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

212 ) 

213 if labels_match: 

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

215 if found: 

216 labels = ", ".join(found) 

217 

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

219 history: str | None = None 

220 from little_loops.session_log import count_session_commands, parse_session_log 

221 

222 distinct = parse_session_log(content) 

223 if distinct: 

224 counts = count_session_commands(content) 

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

226 history = ", ".join(parts) 

227 

228 # Relative path 

229 try: 

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

231 except ValueError: 

232 rel_path = str(path) 

233 

234 return { 

235 "issue_id": issue_id, 

236 "title": title, 

237 "priority": priority, 

238 "status": status, 

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

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

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

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

243 "score_test_coverage": str(score_test_coverage) 

244 if score_test_coverage is not None 

245 else None, 

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

247 "score_change_surface": str(score_change_surface) 

248 if score_change_surface is not None 

249 else None, 

250 "summary": summary, 

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

252 "risk": risk, 

253 "labels": labels, 

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

255 "history": history, 

256 "path": rel_path, 

257 "source": source, 

258 "norm": norm, 

259 "fmt": fmt, 

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

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

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

263 if decision_needed_raw is not None 

264 else None, 

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

266 if missing_artifacts_raw is not None 

267 else None, 

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

269 if implementation_order_risk_raw is not None 

270 else None, 

271 } 

272 

273 

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

275 

276 

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

278 return _ANSI_RE.sub("", text) 

279 

280 

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

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

283 pad = max(0, width - len(_strip_ansi(text))) 

284 return text + " " * pad 

285 

286 

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

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

289 

290 Args: 

291 fields: Dictionary of card fields from _parse_card_fields 

292 

293 Returns: 

294 Formatted card string 

295 """ 

296 # Box-drawing characters 

297 h = "\u2500" # ─ 

298 v = "\u2502" # │ 

299 tl = "\u250c" # ┌ 

300 tr = "\u2510" # ┐ 

301 bl = "\u2514" # └ 

302 br = "\u2518" # ┘ 

303 ml = "\u251c" # ├ 

304 mr = "\u2524" # ┤ 

305 

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

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

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

309 

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

311 priority = fields.get("priority") 

312 status = fields.get("status") 

313 effort = fields.get("effort") 

314 risk = fields.get("risk") 

315 

316 meta_parts: list[str] = [] 

317 if priority: 

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

319 if status: 

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

321 if effort: 

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

323 if risk: 

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

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

326 

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

328 score_parts: list[str] = [] 

329 if fields.get("confidence"): 

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

331 if fields.get("outcome"): 

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

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

334 

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

336 dim_parts: list[str] = [] 

337 if fields.get("score_complexity"): 

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

339 if fields.get("score_test_coverage"): 

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

341 if fields.get("score_ambiguity"): 

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

343 if fields.get("score_change_surface"): 

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

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

346 

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

348 detail_lines: list[str] = [] 

349 source_parts: list[str] = [] 

350 source_val = fields.get("source") 

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

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

353 norm_val = fields.get("norm") 

354 if norm_val: 

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

356 fmt_val = fields.get("fmt") 

357 if fmt_val: 

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

359 if source_parts: 

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

361 detail_mid_parts: list[str] = [] 

362 if fields.get("integration_files"): 

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

364 if fields.get("labels"): 

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

366 if fields.get("milestone"): 

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

368 if detail_mid_parts: 

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

370 if fields.get("captured_at"): 

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

372 if fields.get("completed_at"): 

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

374 if fields.get("history"): 

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

376 

377 # Build path line 

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

379 

380 # Calculate structural width from non-summary content 

381 structural_lines = [header, meta_line, path_line] 

382 if scores_line: 

383 structural_lines.append(scores_line) 

384 if dim_scores_line: 

385 structural_lines.append(dim_scores_line) 

386 structural_lines.extend(detail_lines) 

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

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

389 

390 # Build summary lines — wrap to fit structural width 

391 summary_lines: list[str] = [] 

392 summary_text = fields.get("summary") 

393 if summary_text: 

394 for line in summary_text.splitlines(): 

395 if line.strip(): 

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

397 else: 

398 summary_lines.append("") 

399 

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

401 all_lines = structural_lines + summary_lines 

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

403 

404 # Cap width to terminal to prevent overflow 

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

406 

407 # Build colorized header 

408 if issue_id and "-" in issue_id: 

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

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

411 else: 

412 colored_id = issue_id 

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

414 

415 # Build colorized meta line 

416 colored_meta_parts: list[str] = [] 

417 if priority: 

418 colored_meta_parts.append( 

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

420 ) 

421 if status: 

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

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

424 if effort: 

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

426 if risk: 

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

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

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

430 

431 # Build card 

432 lines: list[str] = [] 

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

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

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

436 

437 lines.append(top_border) 

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

439 lines.append(mid_border) 

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

441 if scores_line: 

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

443 if dim_scores_line: 

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

445 if summary_lines: 

446 lines.append(mid_border) 

447 for sl in summary_lines: 

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

449 if detail_lines: 

450 lines.append(mid_border) 

451 for dl in detail_lines: 

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

453 lines.append(mid_border) 

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

455 lines.append(bot_border) 

456 

457 return "\n".join(lines) 

458 

459 

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

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

462 

463 Args: 

464 config: Project configuration 

465 args: Parsed arguments with .issue_id attribute 

466 

467 Returns: 

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

469 """ 

470 issue_id = args.issue_id 

471 path = _resolve_issue_id(config, issue_id) 

472 

473 if path is None: 

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

475 return 1 

476 

477 fields = _parse_card_fields(path, config) 

478 

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

480 print_json(fields) 

481 return 0 

482 

483 card = _render_card(fields) 

484 print(card) 

485 return 0