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

2

3 from __future__ import annotations

4

5 import re

6 import subprocess

7 from datetime import datetime

8 from pathlib import Path

9 from typing import TYPE_CHECKING

10

11 from little_loops.issue_discovery.extraction import (

12 _build_reopen_section,

13 detect_regression_or_duplicate,

14)

15 from 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

24 if 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

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

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

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

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

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

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

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