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

232 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -0500

1"""ll-issues search: Search issues with filters and sorting.""" 

2 

3from __future__ import annotations 

4 

5import argparse 

6import re 

7from datetime import date 

8from typing import TYPE_CHECKING 

9 

10from little_loops.cli.output import PRIORITY_COLOR, TYPE_COLOR, colorize, print_json 

11 

12if TYPE_CHECKING: 

13 from pathlib import Path 

14 

15 from little_loops.config import BRConfig 

16 from little_loops.issue_parser import IssueInfo 

17 

18 

19def _parse_discovered_date(content: str) -> date | None: 

20 """Extract discovered_date from YAML frontmatter.""" 

21 match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) 

22 if not match: 

23 return None 

24 date_match = re.search(r"discovered_date:\s*(\S+)", match.group(1)) 

25 if not date_match: 

26 return None 

27 date_str = date_match.group(1).strip("\"'") 

28 try: 

29 # Handle ISO datetime strings like 2026-03-13T21:11:34Z 

30 return date.fromisoformat(date_str[:10]) 

31 except ValueError: 

32 return None 

33 

34 

35def _parse_updated_date(content: str, file_path: Path) -> date | None: 

36 r"""Extract last-activity date from the ## Session Log section, or fall back to file mtime. 

37 

38 Scans for the most recent timestamp entry in the Session Log section. 

39 Entry format: `- \`/ll:cmd\` - YYYY-MM-DDTHH:MM:SS - \`path\`` 

40 Falls back to file mtime when no session log timestamps are found. 

41 """ 

42 import re as _re 

43 

44 _SESSION_LOG_RE = _re.compile( 

45 r"^## Session Log\s*\n+(.*?)(?:\n##|\n---|\Z)", _re.MULTILINE | _re.DOTALL 

46 ) 

47 _TIMESTAMP_RE = _re.compile(r"- `[^`]+` - (\d{4}-\d{2}-\d{2})T\d{2}:\d{2}:\d{2}") 

48 

49 match = _SESSION_LOG_RE.search(content) 

50 if match: 

51 timestamps = _TIMESTAMP_RE.findall(match.group(1)) 

52 if timestamps: 

53 try: 

54 return date.fromisoformat(timestamps[-1]) 

55 except ValueError: 

56 pass 

57 

58 # Fallback to file mtime 

59 try: 

60 return date.fromtimestamp(file_path.stat().st_mtime) 

61 except OSError: 

62 return None 

63 

64 

65def _parse_labels_from_content(content: str) -> list[str]: 

66 """Extract labels from ## Labels section (backtick-wrapped items).""" 

67 match = re.search(r"## Labels\s*\n(.*?)(?:\n##|\Z)", content, re.DOTALL) 

68 if not match: 

69 return [] 

70 return [m.lower() for m in re.findall(r"`([^`]+)`", match.group(1))] 

71 

72 

73def _parse_priority_filter(priority_values: list[str]) -> set[str]: 

74 """Parse priority filter values, supporting ranges like P0-P2.""" 

75 priorities: set[str] = set() 

76 for val in priority_values: 

77 range_match = re.match(r"^(P\d)-(P\d)$", val) 

78 if range_match: 

79 start = int(range_match.group(1)[1:]) 

80 end = int(range_match.group(2)[1:]) 

81 for i in range(start, end + 1): 

82 priorities.add(f"P{i}") 

83 elif re.match(r"^P\d$", val): 

84 priorities.add(val) 

85 return priorities 

86 

87 

88def _load_issues_with_status( 

89 config: BRConfig, 

90 include_active: bool, 

91 include_completed: bool, 

92 include_deferred: bool, 

93) -> list[tuple[IssueInfo, str]]: 

94 """Load issues from the relevant directories, tagged with their status. 

95 

96 Returns: 

97 List of (IssueInfo, status) where status is 'active', 'completed', or 'deferred'. 

98 """ 

99 from little_loops.issue_parser import IssueParser 

100 

101 parser = IssueParser(config) 

102 results: list[tuple[IssueInfo, str]] = [] 

103 

104 if include_active: 

105 for category in config.issue_categories: 

106 issue_dir = config.get_issue_dir(category) 

107 if issue_dir.exists(): 

108 for f in sorted(issue_dir.glob("*.md")): 

109 try: 

110 results.append((parser.parse_file(f), "active")) 

111 except Exception: 

112 continue 

113 

114 if include_completed: 

115 completed_dir = config.get_completed_dir() 

116 if completed_dir.exists(): 

117 for f in sorted(completed_dir.glob("*.md")): 

118 try: 

119 results.append((parser.parse_file(f), "completed")) 

120 except Exception: 

121 continue 

122 

123 if include_deferred: 

124 deferred_dir = config.get_deferred_dir() 

125 if deferred_dir.exists(): 

126 for f in sorted(deferred_dir.glob("*.md")): 

127 try: 

128 results.append((parser.parse_file(f), "deferred")) 

129 except Exception: 

130 continue 

131 

132 return results 

133 

134 

135def _sort_issues( 

136 items: list[tuple[IssueInfo, str, date | None, date | None]], 

137 sort_field: str, 

138 descending: bool, 

139) -> list[tuple[IssueInfo, str, date | None, date | None]]: 

140 """Sort issues by the requested field.""" 

141 sentinel = date(9999, 12, 31) 

142 

143 def key(item: tuple) -> tuple: 

144 issue, _status, disc_date, comp_date = item 

145 if sort_field == "priority": 

146 return (issue.priority_int, issue.issue_id) 

147 if sort_field == "id": 

148 m = re.search(r"-(\d+)$", issue.issue_id) 

149 num = int(m.group(1)) if m else 0 

150 return (issue.issue_id.split("-", 1)[0], num) 

151 if sort_field in ("date", "created"): 

152 return (disc_date or sentinel,) 

153 if sort_field == "completed": 

154 return (comp_date or sentinel,) 

155 if sort_field == "type": 

156 return (issue.issue_id.split("-", 1)[0], issue.priority_int, issue.issue_id) 

157 if sort_field == "title": 

158 return (issue.title.lower(),) 

159 if sort_field == "confidence": 

160 score = issue.confidence_score if issue.confidence_score is not None else 9999 

161 return (score,) 

162 if sort_field == "outcome": 

163 score = issue.outcome_confidence if issue.outcome_confidence is not None else 9999 

164 return (score,) 

165 if sort_field == "refinement": 

166 refinement_commands = { 

167 "/ll:verify-issues", 

168 "/ll:refine-issue", 

169 "/ll:tradeoff-review-issues", 

170 "/ll:map-dependencies", 

171 "/ll:ready-issue", 

172 } 

173 counts: dict[str, int] = getattr(issue, "session_command_counts", {}) or {} 

174 total = sum(counts.get(cmd, 0) for cmd in refinement_commands) 

175 return (total,) 

176 return (issue.priority_int, issue.issue_id) 

177 

178 return sorted(items, key=key, reverse=descending) 

179 

180 

181def cmd_search(config: BRConfig, args: argparse.Namespace) -> int: 

182 """Search issues with optional text query, filters, and sorting. 

183 

184 Args: 

185 config: Project configuration 

186 args: Parsed arguments 

187 

188 Returns: 

189 Exit code (0 = success) 

190 """ 

191 # Resolve status flags 

192 status = getattr(args, "status", "active") 

193 if getattr(args, "include_completed", False): 

194 status = "all" 

195 

196 include_active = status in ("active", "all") 

197 include_completed = status in ("completed", "all") 

198 include_deferred = status in ("deferred", "all") 

199 

200 # Load issues 

201 raw = _load_issues_with_status(config, include_active, include_completed, include_deferred) 

202 

203 # Parse additional metadata (dates, labels) only when needed 

204 query: str | None = getattr(args, "query", None) 

205 since_date: date | None = None 

206 until_date: date | None = None 

207 raw_since = getattr(args, "since", None) 

208 raw_until = getattr(args, "until", None) 

209 if raw_since: 

210 try: 

211 since_date = date.fromisoformat(raw_since) 

212 except ValueError: 

213 print(f"Invalid --since date: {raw_since!r}. Use YYYY-MM-DD.") 

214 return 1 

215 if raw_until: 

216 try: 

217 until_date = date.fromisoformat(raw_until) 

218 except ValueError: 

219 print(f"Invalid --until date: {raw_until!r}. Use YYYY-MM-DD.") 

220 return 1 

221 

222 sort_field = getattr(args, "sort", "priority") or "priority" 

223 need_content = bool( 

224 query 

225 or since_date 

226 or until_date 

227 or getattr(args, "label", None) 

228 or sort_field in {"date", "created", "completed"} 

229 or getattr(args, "date_field", "discovered") == "updated" 

230 ) 

231 

232 # Build enriched list: (IssueInfo, status, discovered_date, completed_date) 

233 enriched: list[tuple[IssueInfo, str, date | None, date | None]] = [] 

234 for issue, stat in raw: 

235 if need_content: 

236 try: 

237 content = issue.path.read_text(encoding="utf-8") 

238 except Exception: 

239 content = "" 

240 disc_date = _parse_discovered_date(content) 

241 labels = _parse_labels_from_content(content) 

242 else: 

243 content = "" 

244 disc_date = None 

245 labels = [] 

246 

247 # --- Filter: text query --- 

248 if query: 

249 haystack = (issue.title + "\n" + content).lower() 

250 if query.lower() not in haystack: 

251 continue 

252 

253 # --- Filter: type --- 

254 type_filters: list[str] = getattr(args, "type", None) or [] 

255 if type_filters: 

256 issue_type = issue.issue_id.split("-", 1)[0] 

257 if issue_type not in type_filters: 

258 continue 

259 

260 # --- Filter: priority --- 

261 priority_filters: list[str] = getattr(args, "priority", None) or [] 

262 if priority_filters: 

263 allowed = _parse_priority_filter(priority_filters) 

264 if issue.priority not in allowed: 

265 continue 

266 

267 # --- Filter: label --- 

268 label_filters: list[str] = getattr(args, "label", None) or [] 

269 if label_filters: 

270 if not any(lf.lower() in labels for lf in label_filters): 

271 continue 

272 

273 # --- Filter: date range --- 

274 date_field = getattr(args, "date_field", "discovered") 

275 if date_field == "discovered": 

276 ref_date = disc_date 

277 else: 

278 ref_date = _parse_updated_date(content, issue.path) 

279 

280 if since_date and (ref_date is None or ref_date < since_date): 

281 continue 

282 if until_date and (ref_date is None or ref_date > until_date): 

283 continue 

284 

285 comp_date: date | None = None 

286 if sort_field == "completed" and need_content: 

287 from little_loops.issue_history.parsing import _parse_completion_date 

288 

289 comp_date = _parse_completion_date(content, issue.path) 

290 enriched.append((issue, stat, disc_date, comp_date)) 

291 

292 # --- Sort --- 

293 # Default direction: desc for date/created/completed (newest first), asc for everything else 

294 if getattr(args, "desc", False): 

295 descending = True 

296 elif getattr(args, "asc", False): 

297 descending = False 

298 else: 

299 descending = sort_field in {"date", "created", "completed"} 

300 

301 enriched = _sort_issues(enriched, sort_field, descending) 

302 

303 # --- Limit --- 

304 limit = getattr(args, "limit", None) 

305 if limit and limit > 0: 

306 enriched = enriched[:limit] 

307 

308 if not enriched: 

309 print("No issues found.") 

310 return 0 

311 

312 # --- Output --- 

313 issues_out = [item[0] for item in enriched] 

314 statuses_out = [item[1] for item in enriched] 

315 dates_out = [item[2] for item in enriched] 

316 

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

318 print_json( 

319 [ 

320 { 

321 "id": issue.issue_id, 

322 "priority": issue.priority, 

323 "type": issue.issue_id.split("-", 1)[0], 

324 "title": issue.title, 

325 "path": str(issue.path), 

326 "status": stat, 

327 "discovered_date": str(d) if d else None, 

328 } 

329 for issue, stat, d in zip(issues_out, statuses_out, dates_out, strict=True) 

330 ] 

331 ) 

332 return 0 

333 

334 fmt = getattr(args, "format", "table") or "table" 

335 

336 if fmt == "ids": 

337 for issue in issues_out: 

338 print(issue.issue_id) 

339 return 0 

340 

341 if fmt == "list": 

342 for issue in issues_out: 

343 print(f"{issue.path.name} {issue.title}") 

344 return 0 

345 

346 # Default: table (grouped by type, similar to ll-issues list) 

347 buckets: dict[str, list[tuple[IssueInfo, str]]] = {"BUG": [], "FEAT": [], "ENH": []} 

348 for issue, stat in zip(issues_out, statuses_out, strict=True): 

349 prefix = issue.issue_id.split("-", 1)[0] 

350 if prefix in buckets: 

351 buckets[prefix].append((issue, stat)) 

352 

353 type_labels = {"BUG": "Bugs", "FEAT": "Features", "ENH": "Enhancements"} 

354 lines: list[str] = [] 

355 for prefix, label in type_labels.items(): 

356 group = buckets[prefix] 

357 if not group: 

358 continue 

359 header = colorize(f"{label} ({len(group)})", f"{TYPE_COLOR.get(prefix, '0')};1") 

360 lines.append(header) 

361 for issue, stat in group: 

362 issue_type = issue.issue_id.split("-", 1)[0] 

363 colored_id = colorize(issue.issue_id, TYPE_COLOR.get(issue_type, "0")) 

364 colored_priority = colorize(issue.priority, PRIORITY_COLOR.get(issue.priority, "0")) 

365 status_tag = f" [{stat}]" if stat != "active" else "" 

366 lines.append(f" {colored_priority} {colored_id} {issue.title}{status_tag}") 

367 lines.append("") 

368 lines.append(f"Total: {len(issues_out)} issue(s) found") 

369 print("\n".join(lines)) 

370 return 0