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

2

3 from __future__ import annotations

4

5 import argparse

6 import re

7 import textwrap

8 from pathlib import Path

9 from typing import TYPE_CHECKING

10

11 from little_loops.cli.output import (

12 PRIORITY_COLOR,

13 TYPE_COLOR,

14 colorize,

15 print_json,

16 strip_ansi,

17 terminal_width,

18)

19

20 if 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

32 def _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

39 def _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

127 def _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

305 def _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

311 def _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

484 def 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