Coverage for little_loops / issue_discovery / search.py: 0%

187 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -0500

1"""Issue file search and main discovery functions.""" 

2 

3from __future__ import annotations 

4 

5import re 

6import subprocess 

7from datetime import datetime 

8from pathlib import Path 

9from typing import TYPE_CHECKING 

10 

11from little_loops.issue_discovery.extraction import ( 

12 _build_reopen_section, 

13 detect_regression_or_duplicate, 

14) 

15from little_loops.issue_discovery.matching import ( 

16 FindingMatch, 

17 MatchClassification, 

18 RegressionEvidence, 

19 _calculate_word_overlap, 

20 _extract_words, 

21 _matches_issue_type, 

22) 

23 

24if TYPE_CHECKING: 

25 from little_loops.config import BRConfig 

26 from little_loops.logger import Logger 

27 

28 

29# ============================================================================= 

30# Issue Search Functions 

31# ============================================================================= 

32 

33 

34def _get_all_issue_files( 

35 config: BRConfig, 

36 include_completed: bool = True, 

37 include_deferred: bool = False, 

38) -> list[tuple[Path, bool]]: 

39 """Get all issue files with their completion status. 

40 

41 Status is read from each file's YAML ``status:`` frontmatter (ENH-1418). 

42 Files live in their type directories (``bugs/``, ``features/`` etc.) 

43 regardless of completion state. ``is_completed`` in the returned tuples 

44 is ``True`` for done/cancelled/deferred issues (i.e. non-active). 

45 

46 For backwards compatibility, files in legacy ``completed/`` and 

47 ``deferred/`` sibling directories are also surfaced when their 

48 respective ``include_*`` flag is set. 

49 

50 Args: 

51 config: Project configuration 

52 include_completed: Whether to include completed/cancelled issues 

53 include_deferred: Whether to include deferred issues 

54 

55 Returns: 

56 List of ``(path, is_completed)`` tuples. 

57 """ 

58 from little_loops.frontmatter import parse_frontmatter 

59 

60 files: list[tuple[Path, bool]] = [] 

61 

62 for category in config.issue_categories: 

63 issue_dir = config.get_issue_dir(category) 

64 if not issue_dir.exists(): 

65 continue 

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

67 try: 

68 fm = parse_frontmatter(f.read_text(encoding="utf-8")) 

69 except Exception: 

70 files.append((f, False)) 

71 continue 

72 status = fm.get("status", "open") 

73 if status in ("done", "cancelled"): 

74 if include_completed: 

75 files.append((f, True)) 

76 elif status == "deferred": 

77 if include_deferred: 

78 files.append((f, True)) 

79 else: 

80 files.append((f, False)) 

81 

82 # Legacy completed/ and deferred/ sibling dirs (pre-ENH-1418) 

83 if include_completed: 

84 legacy_completed = config.get_completed_dir() 

85 if legacy_completed.exists(): 

86 for f in legacy_completed.glob("*.md"): 

87 files.append((f, True)) 

88 

89 if include_deferred: 

90 legacy_deferred = config.get_deferred_dir() 

91 if legacy_deferred.exists(): 

92 for f in legacy_deferred.glob("*.md"): 

93 files.append((f, True)) 

94 

95 return files 

96 

97 

98def search_issues_by_content( 

99 config: BRConfig, 

100 search_terms: list[str], 

101 include_completed: bool = True, 

102) -> list[tuple[Path, float, bool]]: 

103 """Search issues by content with relevance scoring. 

104 

105 Args: 

106 config: Project configuration 

107 search_terms: Terms to search for 

108 include_completed: Whether to include completed issues 

109 

110 Returns: 

111 List of (path, score, is_completed) sorted by score descending 

112 """ 

113 results: list[tuple[Path, float, bool]] = [] 

114 search_words = set() 

115 for term in search_terms: 

116 search_words.update(_extract_words(term)) 

117 

118 if not search_words: 

119 return results 

120 

121 for issue_path, is_completed in _get_all_issue_files(config, include_completed): 

122 try: 

123 content = issue_path.read_text(encoding="utf-8") 

124 content_words = _extract_words(content) 

125 score = _calculate_word_overlap(search_words, content_words) 

126 if score > 0.1: # Minimum threshold 

127 results.append((issue_path, score, is_completed)) 

128 except Exception: 

129 continue 

130 

131 results.sort(key=lambda x: x[1], reverse=True) 

132 return results 

133 

134 

135def search_issues_by_file_path( 

136 config: BRConfig, 

137 file_path: str, 

138 include_completed: bool = True, 

139) -> list[tuple[Path, bool]]: 

140 """Search for issues mentioning a specific file path. 

141 

142 Args: 

143 config: Project configuration 

144 file_path: File path to search for 

145 include_completed: Whether to include completed issues 

146 

147 Returns: 

148 List of (issue_path, is_completed) tuples 

149 """ 

150 results: list[tuple[Path, bool]] = [] 

151 normalized_path = file_path.strip().lower() 

152 

153 # Also match partial paths (e.g., "module.py" matches "src/module.py") 

154 path_parts = normalized_path.split("/") 

155 filename = path_parts[-1] if path_parts else normalized_path 

156 

157 for issue_path, is_completed in _get_all_issue_files(config, include_completed): 

158 try: 

159 content = issue_path.read_text(encoding="utf-8").lower() 

160 # Check for exact path or filename match 

161 if normalized_path in content or filename in content: 

162 results.append((issue_path, is_completed)) 

163 except Exception: 

164 continue 

165 

166 return results 

167 

168 

169# ============================================================================= 

170# Main Discovery Functions 

171# ============================================================================= 

172 

173 

174def find_existing_issue( 

175 config: BRConfig, 

176 finding_type: str, 

177 file_path: str | None, 

178 finding_title: str, 

179 finding_content: str, 

180) -> FindingMatch: 

181 """Search for an existing issue matching this finding. 

182 

183 Uses a multi-pass approach: 

184 1. Exact file path match in Location sections 

185 2. Title word overlap (> dup_overlap_threshold (default 0.7) = likely duplicate) 

186 3. Content overlap analysis 

187 

188 For matches to completed issues, performs regression analysis to determine 

189 if the match is a regression (fix broke) or invalid fix (never worked). 

190 

191 Args: 

192 config: Project configuration 

193 finding_type: Issue type ("BUG", "ENH", "FEAT") 

194 file_path: File path from finding (if any) 

195 finding_title: Title of the finding 

196 finding_content: Full content/description of finding 

197 

198 Returns: 

199 FindingMatch with best match details, including classification and 

200 regression evidence for completed issue matches 

201 """ 

202 exact_threshold = config.issues.duplicate_detection.exact_threshold 

203 similar_threshold = config.issues.duplicate_detection.similar_threshold 

204 

205 best_match = FindingMatch( 

206 issue_path=None, 

207 match_type="none", 

208 match_score=0.0, 

209 exact_threshold=exact_threshold, 

210 similar_threshold=similar_threshold, 

211 ) 

212 

213 # Pass 1: Exact file path match 

214 if file_path: 

215 path_matches = search_issues_by_file_path(config, file_path) 

216 for issue_path, is_completed in path_matches: 

217 try: 

218 # Check if same type of finding (uses configured categories) 

219 issue_type_match = _matches_issue_type( 

220 finding_type, issue_path, config, is_completed 

221 ) 

222 if issue_type_match: 

223 # Determine classification 

224 if is_completed: 

225 classification, evidence = detect_regression_or_duplicate( 

226 config, issue_path 

227 ) 

228 else: 

229 classification = MatchClassification.DUPLICATE 

230 evidence = None 

231 

232 # High confidence if same file + same type 

233 return FindingMatch( 

234 issue_path=issue_path, 

235 match_type="exact", 

236 match_score=0.85, 

237 is_completed=is_completed, 

238 matched_terms=[file_path], 

239 classification=classification, 

240 regression_evidence=evidence, 

241 exact_threshold=exact_threshold, 

242 similar_threshold=similar_threshold, 

243 ) 

244 except Exception: 

245 continue 

246 

247 # Pass 2: Title similarity 

248 title_words = _extract_words(finding_title) 

249 if title_words: 

250 best_pass2: tuple[Path, bool, float, list[str]] | None = None 

251 best_pass2_score = best_match.match_score 

252 for issue_path, is_completed in _get_all_issue_files(config): 

253 try: 

254 # Extract title from issue file 

255 content = issue_path.read_text(encoding="utf-8") 

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

257 if title_match: 

258 issue_title = title_match.group(1) 

259 issue_words = _extract_words(issue_title) 

260 overlap = _calculate_word_overlap(title_words, issue_words) 

261 if ( 

262 overlap > config.history.capture_issue.dup_overlap_threshold 

263 and overlap > best_pass2_score 

264 ): 

265 best_pass2_score = overlap 

266 best_pass2 = ( 

267 issue_path, 

268 is_completed, 

269 overlap, 

270 list(title_words & issue_words), 

271 ) 

272 except Exception: 

273 continue 

274 

275 # Determine classification once for the single best Pass 2 match 

276 if best_pass2 is not None: 

277 issue_path, is_completed, overlap, matched_terms = best_pass2 

278 if is_completed: 

279 classification, evidence = detect_regression_or_duplicate(config, issue_path) 

280 else: 

281 classification = MatchClassification.DUPLICATE 

282 evidence = None 

283 best_match = FindingMatch( 

284 issue_path=issue_path, 

285 match_type="similar", 

286 match_score=overlap, 

287 is_completed=is_completed, 

288 matched_terms=matched_terms, 

289 classification=classification, 

290 regression_evidence=evidence, 

291 exact_threshold=exact_threshold, 

292 similar_threshold=similar_threshold, 

293 ) 

294 

295 # Pass 3: Content analysis 

296 if best_match.match_score < similar_threshold: 

297 content_matches = search_issues_by_content( 

298 config, 

299 [finding_title, finding_content], 

300 ) 

301 best_pass3: tuple[Path, bool, float] | None = None 

302 best_pass3_score = best_match.match_score 

303 for issue_path, score, is_completed in content_matches[:5]: # Top 5 

304 adjusted_score = score * 0.8 # Content matches are less precise 

305 if adjusted_score > best_pass3_score: 

306 best_pass3_score = adjusted_score 

307 best_pass3 = (issue_path, is_completed, adjusted_score) 

308 

309 # Determine classification once for the single best Pass 3 match 

310 if best_pass3 is not None: 

311 issue_path, is_completed, adjusted_score = best_pass3 

312 if is_completed: 

313 classification, evidence = detect_regression_or_duplicate(config, issue_path) 

314 else: 

315 classification = MatchClassification.DUPLICATE 

316 evidence = None 

317 best_match = FindingMatch( 

318 issue_path=issue_path, 

319 match_type="content", 

320 match_score=adjusted_score, 

321 is_completed=is_completed, 

322 classification=classification, 

323 regression_evidence=evidence, 

324 exact_threshold=exact_threshold, 

325 similar_threshold=similar_threshold, 

326 ) 

327 

328 # If no match found, classification is NEW_ISSUE (the default) 

329 return best_match 

330 

331 

332# ============================================================================= 

333# Issue Reopening and Updating 

334# ============================================================================= 

335 

336 

337def _get_category_from_issue_path(issue_path: Path, config: BRConfig) -> str: 

338 """Determine the category for an issue based on its filename. 

339 

340 Args: 

341 issue_path: Path to issue file 

342 config: Project configuration 

343 

344 Returns: 

345 Category name (e.g., "bugs", "enhancements", "features") 

346 """ 

347 filename = issue_path.name.upper() 

348 for category_name, category_config in config.issues.categories.items(): 

349 if category_config.prefix in filename: 

350 return category_name 

351 return "bugs" # Default 

352 

353 

354def reopen_issue( 

355 config: BRConfig, 

356 completed_issue_path: Path, 

357 reopen_reason: str, 

358 new_context: str, 

359 source_command: str, 

360 logger: Logger, 

361 classification: MatchClassification | None = None, 

362 regression_evidence: RegressionEvidence | None = None, 

363) -> Path | None: 

364 """Move issue from completed back to active with Reopened section. 

365 

366 Args: 

367 config: Project configuration 

368 completed_issue_path: Path to issue in completed/ 

369 reopen_reason: Reason for reopening 

370 new_context: New context/findings to add 

371 source_command: Command triggering the reopen 

372 logger: Logger for output 

373 classification: How this issue was classified (regression, invalid_fix, etc.) 

374 regression_evidence: Evidence supporting the classification 

375 

376 Returns: 

377 New path to reopened issue, or None if failed 

378 """ 

379 if not completed_issue_path.exists(): 

380 logger.error(f"Completed issue not found: {completed_issue_path}") 

381 return None 

382 

383 # Determine target category directory 

384 category = _get_category_from_issue_path(completed_issue_path, config) 

385 target_dir = config.get_issue_dir(category) 

386 target_dir.mkdir(parents=True, exist_ok=True) 

387 

388 target_path = target_dir / completed_issue_path.name 

389 

390 # Safety check - don't overwrite a *different* active issue at the 

391 # target. If the completed_issue_path is already at target_path 

392 # (post-ENH-1418: file lives in its type dir), this is the same file 

393 # and we just rewrite frontmatter in place. 

394 if target_path.exists() and target_path.resolve() != completed_issue_path.resolve(): 

395 logger.warning(f"Active issue already exists: {target_path}") 

396 return None 

397 

398 # Log with classification info if available 

399 if classification == MatchClassification.REGRESSION: 

400 logger.info(f"Reopening {completed_issue_path.name} as REGRESSION -> {category}/") 

401 elif classification == MatchClassification.INVALID_FIX: 

402 logger.info(f"Reopening {completed_issue_path.name} as INVALID_FIX -> {category}/") 

403 else: 

404 logger.info(f"Reopening {completed_issue_path.name} -> {category}/") 

405 

406 try: 

407 from little_loops.frontmatter import update_frontmatter 

408 

409 content = completed_issue_path.read_text(encoding="utf-8") 

410 

411 reopen_section = _build_reopen_section( 

412 reopen_reason, 

413 new_context, 

414 source_command, 

415 classification, 

416 regression_evidence, 

417 ) 

418 content += reopen_section 

419 content = update_frontmatter(content, {"status": "open"}) 

420 

421 same_file = completed_issue_path.resolve() == target_path.resolve() 

422 if same_file: 

423 # Post-ENH-1418: file already in its type dir; just rewrite content. 

424 completed_issue_path.write_text(content, encoding="utf-8") 

425 else: 

426 # Legacy path: file lives in completed/ — move it back to the type dir. 

427 result = subprocess.run( 

428 ["git", "mv", str(completed_issue_path), str(target_path)], 

429 capture_output=True, 

430 text=True, 

431 ) 

432 if result.returncode != 0: 

433 logger.warning(f"git mv failed, using manual copy: {result.stderr}") 

434 target_path.write_text(content, encoding="utf-8") 

435 completed_issue_path.unlink() 

436 else: 

437 target_path.write_text(content, encoding="utf-8") 

438 

439 logger.success(f"Reopened: {target_path.name}") 

440 return target_path 

441 

442 except Exception as e: 

443 logger.error(f"Failed to reopen issue: {e}") 

444 return None 

445 

446 

447def update_existing_issue( 

448 config: BRConfig, 

449 issue_path: Path, 

450 update_section_name: str, 

451 update_content: str, 

452 source_command: str, 

453 logger: Logger, 

454) -> bool: 

455 """Add new findings to an existing issue. 

456 

457 Args: 

458 config: Project configuration 

459 issue_path: Path to issue file 

460 update_section_name: Name for the update section 

461 update_content: Content to add 

462 source_command: Command triggering the update 

463 logger: Logger for output 

464 

465 Returns: 

466 True if update succeeded 

467 """ 

468 if not issue_path.exists(): 

469 logger.error(f"Issue not found: {issue_path}") 

470 return False 

471 

472 try: 

473 content = issue_path.read_text(encoding="utf-8") 

474 

475 # Build update section 

476 update_section = f""" 

477 

478--- 

479 

480## {update_section_name} 

481 

482- **Date**: {datetime.now().strftime("%Y-%m-%d")} 

483- **Source**: {source_command} 

484 

485{update_content} 

486""" 

487 

488 # Check if section already exists 

489 if f"## {update_section_name}" not in content: 

490 content += update_section 

491 issue_path.write_text(content, encoding="utf-8") 

492 logger.success(f"Updated: {issue_path.name}") 

493 else: 

494 logger.info(f"Section already exists in {issue_path.name}, skipping") 

495 

496 return True 

497 

498 except Exception as e: 

499 logger.error(f"Failed to update issue: {e}") 

500 return False