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

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 PRIORITY_COLOR, TYPE_COLOR, colorize, print_json, terminal_width

12

13 if TYPE_CHECKING:

14 from little_loops.config import BRConfig

15

16

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

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

19

20 Accepts three input formats:

21 - Numeric ID only: "518"

22 - Type + ID: "FEAT-518"

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

24

25 Searches all active category directories and the completed directory.

26

27 Args:

28 config: Project configuration

29 user_input: Issue ID string in any supported format

30

31 Returns:

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

33 """

34 user_input = user_input.strip()

35

36 # Parse input to extract components

37 numeric_id: str | None = None

38 type_prefix: str | None = None

39 priority: str | None = None

40

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)

58

59 if numeric_id is None:

60 return None

61

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

67

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

81

82 return None

83

84

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

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

87

88 Args:

89 path: Path to the issue file

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

91

92 Returns:

93 Dictionary of card fields

94 """

95 from little_loops.frontmatter import parse_frontmatter

96

97 content = path.read_text()

98 frontmatter = parse_frontmatter(content, coerce_types=True)

99 filename = path.name

100

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

104

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

108

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

120

121 # Determine status

122 is_completed = path.parent.name == "completed"

123 status = "Completed" if is_completed else "Open"

124

125 # Extract optional frontmatter fields

126 confidence = frontmatter.get("confidence_score")

127 outcome = frontmatter.get("outcome_confidence")

128 effort = frontmatter.get("effort")

129

130 # --- New fields ---

131

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

141

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

152

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

158

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)

168

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

172

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)

178

179 # Relative path

180 try:

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

182 except ValueError:

183 rel_path = str(path)

184

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 }

200

201

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

203

204

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

206 return _ANSI_RE.sub("", text)

207

208

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

213

214

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

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

217

218 Args:

219 fields: Dictionary of card fields from _parse_card_fields

220

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

233

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

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

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

237

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

243

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)

254

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

262

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

274

275 # Build path line

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

277

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

285

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

295

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

299

300 # Cap width to terminal to prevent overflow

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

302

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

310

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)

326

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

332

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)

350

351 return "\n".join(lines)

352

353

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

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

356

357 Args:

358 config: Project configuration

359 args: Parsed arguments with .issue_id attribute

360

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)

366

367 if path is None:

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

369 return 1

370

371 fields = _parse_card_fields(path, config)

372

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

374 print_json(fields)

375 return 0

376

377 card = _render_card(fields)

378 print(card)

379 return 0