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

222 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -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 

17def _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, the completed directory, and the deferred 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 + deferred 

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 search_dirs.append(config.get_deferred_dir()) 

68 

69 # Search for matching files 

70 for search_dir in search_dirs: 

71 if not search_dir.is_dir(): 

72 continue 

73 for path in search_dir.glob(f"*-{numeric_id}-*.md"): 

74 filename = path.name 

75 # Verify type prefix if provided 

76 if type_prefix and f"-{type_prefix}-" not in filename.upper(): 

77 continue 

78 # Verify priority if provided 

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

80 continue 

81 return path 

82 

83 return None 

84 

85 

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

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

88 

89 Args: 

90 path: Path to the issue file 

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

92 

93 Returns: 

94 Dictionary of card fields 

95 """ 

96 from little_loops.frontmatter import parse_frontmatter 

97 

98 content = path.read_text() 

99 frontmatter = parse_frontmatter(content, coerce_types=True) 

100 filename = path.name 

101 

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

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

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

105 

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

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

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

109 

110 # Extract title from content 

111 title: str | None = None 

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

113 if title_match: 

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

115 else: 

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

117 if header_match: 

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

119 else: 

120 title = path.stem 

121 

122 # Determine status 

123 parent_name = path.parent.name 

124 if parent_name == "completed": 

125 status = "Completed" 

126 elif parent_name == "deferred": 

127 status = "Deferred" 

128 else: 

129 status = "Open" 

130 

131 # Extract optional frontmatter fields 

132 confidence = frontmatter.get("confidence_score") 

133 outcome = frontmatter.get("outcome_confidence") 

134 effort = frontmatter.get("effort") 

135 

136 # --- New fields --- 

137 

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

139 summary: str | None = None 

140 summary_match = re.search( 

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

142 ) 

143 if summary_match: 

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

145 if text: 

146 summary = text 

147 

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

149 integration_files: int | None = None 

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

151 if ftm_match: 

152 start = ftm_match.end() 

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

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

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

156 if count > 0: 

157 integration_files = count 

158 

159 # Risk: extract from ## Impact section 

160 risk: str | None = None 

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

162 if risk_match: 

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

164 

165 # Labels: extract backtick-delimited labels from ## Labels section 

166 labels: str | None = None 

167 labels_match = re.search( 

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

169 ) 

170 if labels_match: 

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

172 if found: 

173 labels = ", ".join(found) 

174 

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

176 history: str | None = None 

177 from little_loops.session_log import count_session_commands, parse_session_log 

178 

179 distinct = parse_session_log(content) 

180 if distinct: 

181 counts = count_session_commands(content) 

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

183 history = ", ".join(parts) 

184 

185 # Relative path 

186 try: 

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

188 except ValueError: 

189 rel_path = str(path) 

190 

191 return { 

192 "issue_id": issue_id, 

193 "title": title, 

194 "priority": priority, 

195 "status": status, 

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

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

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

199 "summary": summary, 

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

201 "risk": risk, 

202 "labels": labels, 

203 "history": history, 

204 "path": rel_path, 

205 } 

206 

207 

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

209 

210 

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

212 return _ANSI_RE.sub("", text) 

213 

214 

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

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

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

218 return text + " " * pad 

219 

220 

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

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

223 

224 Args: 

225 fields: Dictionary of card fields from _parse_card_fields 

226 

227 Returns: 

228 Formatted card string 

229 """ 

230 # Box-drawing characters 

231 h = "\u2500" # ─ 

232 v = "\u2502" # │ 

233 tl = "\u250c" # ┌ 

234 tr = "\u2510" # ┐ 

235 bl = "\u2514" # └ 

236 br = "\u2518" # ┘ 

237 ml = "\u251c" # ├ 

238 mr = "\u2524" # ┤ 

239 

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

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

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

243 

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

245 priority = fields.get("priority") 

246 status = fields.get("status") 

247 effort = fields.get("effort") 

248 risk = fields.get("risk") 

249 

250 meta_parts: list[str] = [] 

251 if priority: 

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

253 if status: 

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

255 if effort: 

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

257 if risk: 

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

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

260 

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

262 score_parts: list[str] = [] 

263 if fields.get("confidence"): 

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

265 if fields.get("outcome"): 

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

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

268 

269 # Build detail lines (integration+labels, history) 

270 detail_lines: list[str] = [] 

271 detail_mid_parts: list[str] = [] 

272 if fields.get("integration_files"): 

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

274 if fields.get("labels"): 

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

276 if detail_mid_parts: 

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

278 if fields.get("history"): 

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

280 

281 # Build path line 

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

283 

284 # Calculate structural width from non-summary content 

285 structural_lines = [header, meta_line, path_line] 

286 if scores_line: 

287 structural_lines.append(scores_line) 

288 structural_lines.extend(detail_lines) 

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

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

291 

292 # Build summary lines — wrap to fit structural width 

293 summary_lines: list[str] = [] 

294 summary_text = fields.get("summary") 

295 if summary_text: 

296 for line in summary_text.splitlines(): 

297 if line.strip(): 

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

299 else: 

300 summary_lines.append("") 

301 

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

303 all_lines = structural_lines + summary_lines 

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

305 

306 # Cap width to terminal to prevent overflow 

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

308 

309 # Build colorized header 

310 if issue_id and "-" in issue_id: 

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

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

313 else: 

314 colored_id = issue_id 

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

316 

317 # Build colorized meta line 

318 colored_meta_parts: list[str] = [] 

319 if priority: 

320 colored_meta_parts.append( 

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

322 ) 

323 if status: 

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

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

326 if effort: 

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

328 if risk: 

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

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

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

332 

333 # Build card 

334 lines: list[str] = [] 

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

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

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

338 

339 lines.append(top_border) 

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

341 lines.append(mid_border) 

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

343 if scores_line: 

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

345 if summary_lines: 

346 lines.append(mid_border) 

347 for sl in summary_lines: 

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

349 if detail_lines: 

350 lines.append(mid_border) 

351 for dl in detail_lines: 

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

353 lines.append(mid_border) 

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

355 lines.append(bot_border) 

356 

357 return "\n".join(lines) 

358 

359 

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

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

362 

363 Args: 

364 config: Project configuration 

365 args: Parsed arguments with .issue_id attribute 

366 

367 Returns: 

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

369 """ 

370 issue_id = args.issue_id 

371 path = _resolve_issue_id(config, issue_id) 

372 

373 if path is None: 

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

375 return 1 

376 

377 fields = _parse_card_fields(path, config) 

378 

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

380 print_json(fields) 

381 return 0 

382 

383 card = _render_card(fields) 

384 print(card) 

385 return 0