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

217 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-18 16:18 -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 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 

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

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

206 return _ANSI_RE.sub("", text) 

207 

208 

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

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

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