Coverage for little_loops / issue_discovery.py: 79%

321 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-02-15 15:23 -0600

1"""Issue discovery and deduplication for little-loops. 

2 

3Provides functions for finding existing issues, detecting duplicates, 

4and reopening completed issues when problems recur. 

5""" 

6 

7from __future__ import annotations 

8 

9import re 

10import subprocess 

11from dataclasses import dataclass, field 

12from datetime import datetime 

13from enum import Enum 

14from pathlib import Path 

15from typing import TYPE_CHECKING 

16 

17if TYPE_CHECKING: 

18 from little_loops.config import BRConfig 

19 from little_loops.logger import Logger 

20 

21 

22# ============================================================================= 

23# Enums 

24# ============================================================================= 

25 

26 

27class MatchClassification(Enum): 

28 """Classification of how a finding matches an existing issue. 

29 

30 Used to distinguish between true duplicates, regressions, and invalid fixes 

31 when a finding matches a completed issue. 

32 """ 

33 

34 NEW_ISSUE = "new_issue" # No existing issue matches 

35 DUPLICATE = "duplicate" # Active issue exists 

36 REGRESSION = "regression" # Completed, files modified AFTER fix (fix broke) 

37 INVALID_FIX = "invalid_fix" # Completed, files NOT modified after fix (never worked) 

38 UNVERIFIED = "unverified" # Completed, no fix commit tracked (can't determine) 

39 

40 

41# ============================================================================= 

42# Data Classes 

43# ============================================================================= 

44 

45 

46@dataclass 

47class RegressionEvidence: 

48 """Evidence for regression vs invalid fix classification. 

49 

50 Attributes: 

51 fix_commit_sha: SHA of the commit that fixed the original issue 

52 fix_commit_exists: Whether the fix commit exists in current history 

53 files_modified_since_fix: Files from the fix that were modified after fix 

54 days_since_fix: Number of days since the fix was applied 

55 related_commits: Commits that modified the relevant files after fix 

56 """ 

57 

58 fix_commit_sha: str | None = None 

59 fix_commit_exists: bool = True 

60 files_modified_since_fix: list[str] = field(default_factory=list) 

61 days_since_fix: int = 0 

62 related_commits: list[str] = field(default_factory=list) 

63 

64 

65@dataclass 

66class FindingMatch: 

67 """Result of matching a finding to an existing issue. 

68 

69 Attributes: 

70 issue_path: Path to matched issue file, or None if no match 

71 match_type: Type of match ("exact", "similar", "content", "none") 

72 match_score: Confidence score from 0.0 to 1.0 

73 is_completed: Whether the matched issue is in completed/ 

74 matched_terms: Terms that matched (for debugging) 

75 classification: How to classify this match (regression, duplicate, etc.) 

76 regression_evidence: Evidence supporting regression classification 

77 """ 

78 

79 issue_path: Path | None 

80 match_type: str 

81 match_score: float 

82 is_completed: bool = False 

83 matched_terms: list[str] = field(default_factory=list) 

84 classification: MatchClassification = MatchClassification.NEW_ISSUE 

85 regression_evidence: RegressionEvidence | None = None 

86 

87 @property 

88 def should_skip(self) -> bool: 

89 """Return True if finding is a duplicate and should be skipped.""" 

90 return self.match_score >= 0.8 

91 

92 @property 

93 def should_update(self) -> bool: 

94 """Return True if finding should update the existing issue.""" 

95 return 0.5 <= self.match_score < 0.8 

96 

97 @property 

98 def should_create(self) -> bool: 

99 """Return True if a new issue should be created.""" 

100 return self.match_score < 0.5 

101 

102 @property 

103 def should_reopen(self) -> bool: 

104 """Return True if a completed issue should be reopened.""" 

105 return self.is_completed and self.match_score >= 0.5 

106 

107 @property 

108 def should_reopen_as_regression(self) -> bool: 

109 """Return True if issue should be reopened as a regression. 

110 

111 A regression means the fix was applied but later code changes broke it. 

112 """ 

113 return ( 

114 self.is_completed 

115 and self.match_score >= 0.5 

116 and self.classification == MatchClassification.REGRESSION 

117 ) 

118 

119 @property 

120 def should_reopen_as_invalid_fix(self) -> bool: 

121 """Return True if issue should be reopened due to invalid fix. 

122 

123 An invalid fix means the original fix never actually resolved the issue. 

124 """ 

125 return ( 

126 self.is_completed 

127 and self.match_score >= 0.5 

128 and self.classification == MatchClassification.INVALID_FIX 

129 ) 

130 

131 @property 

132 def is_unverified(self) -> bool: 

133 """Return True if regression status cannot be determined. 

134 

135 Unverified means the completed issue has no fix commit tracked, 

136 so we cannot determine if this is a regression or invalid fix. 

137 """ 

138 return ( 

139 self.is_completed 

140 and self.match_score >= 0.5 

141 and self.classification == MatchClassification.UNVERIFIED 

142 ) 

143 

144 

145# ============================================================================= 

146# Text Matching Helpers 

147# ============================================================================= 

148 

149 

150def _normalize_text(text: str) -> str: 

151 """Normalize text for comparison. 

152 

153 Args: 

154 text: Input text 

155 

156 Returns: 

157 Lowercase text with normalized whitespace 

158 """ 

159 return re.sub(r"\s+", " ", text.lower().strip()) 

160 

161 

162def _extract_words(text: str) -> set[str]: 

163 """Extract significant words from text. 

164 

165 Args: 

166 text: Input text 

167 

168 Returns: 

169 Set of lowercase words (3+ chars, excluding common words) 

170 """ 

171 common_words = { 

172 "the", 

173 "and", 

174 "for", 

175 "this", 

176 "that", 

177 "with", 

178 "from", 

179 "are", 

180 "was", 

181 "were", 

182 "been", 

183 "have", 

184 "has", 

185 "had", 

186 "not", 

187 "but", 

188 "can", 

189 "will", 

190 "should", 

191 "would", 

192 "could", 

193 "may", 

194 "might", 

195 "must", 

196 "file", 

197 "code", 

198 "issue", 

199 } 

200 words = set(re.findall(r"\b[a-z]{3,}\b", text.lower())) 

201 return words - common_words 

202 

203 

204def _calculate_word_overlap(words1: set[str], words2: set[str]) -> float: 

205 """Calculate Jaccard similarity between word sets. 

206 

207 Args: 

208 words1: First word set 

209 words2: Second word set 

210 

211 Returns: 

212 Similarity score from 0.0 to 1.0 

213 """ 

214 if not words1 or not words2: 

215 return 0.0 

216 intersection = words1 & words2 

217 union = words1 | words2 

218 return len(intersection) / len(union) 

219 

220 

221def _extract_line_numbers(text: str) -> set[int]: 

222 """Extract line numbers from text. 

223 

224 Args: 

225 text: Input text 

226 

227 Returns: 

228 Set of line numbers found 

229 """ 

230 numbers: set[int] = set() 

231 # Match line number patterns 

232 patterns = [ 

233 r"\*\*Line(?:\(s\))?\*\*:\s*(\d+)(?:-(\d+))?", # **Line(s)**: 42-45 

234 r":(\d+)(?:-(\d+))?", # :42-45 (in paths) 

235 r"line\s+(\d+)", # line 42 

236 ] 

237 for pattern in patterns: 

238 for match in re.finditer(pattern, text, re.IGNORECASE): 

239 numbers.add(int(match.group(1))) 

240 if match.lastindex and match.lastindex >= 2 and match.group(2): 

241 numbers.add(int(match.group(2))) 

242 return numbers 

243 

244 

245# ============================================================================= 

246# Issue Search Functions 

247# ============================================================================= 

248 

249 

250def _get_all_issue_files( 

251 config: BRConfig, 

252 include_completed: bool = True, 

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

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

255 

256 Args: 

257 config: Project configuration 

258 include_completed: Whether to include completed issues 

259 

260 Returns: 

261 List of (path, is_completed) tuples 

262 """ 

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

264 

265 # Active issues 

266 for category in config.issue_categories: 

267 issue_dir = config.get_issue_dir(category) 

268 if issue_dir.exists(): 

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

270 files.append((f, False)) 

271 

272 # Completed issues 

273 if include_completed: 

274 completed_dir = config.get_completed_dir() 

275 if completed_dir.exists(): 

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

277 files.append((f, True)) 

278 

279 return files 

280 

281 

282def search_issues_by_content( 

283 config: BRConfig, 

284 search_terms: list[str], 

285 include_completed: bool = True, 

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

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

288 

289 Args: 

290 config: Project configuration 

291 search_terms: Terms to search for 

292 include_completed: Whether to include completed issues 

293 

294 Returns: 

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

296 """ 

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

298 search_words = set() 

299 for term in search_terms: 

300 search_words.update(_extract_words(term)) 

301 

302 if not search_words: 

303 return results 

304 

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

306 try: 

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

308 content_words = _extract_words(content) 

309 score = _calculate_word_overlap(search_words, content_words) 

310 if score > 0.1: # Minimum threshold 

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

312 except Exception: 

313 continue 

314 

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

316 return results 

317 

318 

319def search_issues_by_file_path( 

320 config: BRConfig, 

321 file_path: str, 

322 include_completed: bool = True, 

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

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

325 

326 Args: 

327 config: Project configuration 

328 file_path: File path to search for 

329 include_completed: Whether to include completed issues 

330 

331 Returns: 

332 List of (issue_path, is_completed) tuples 

333 """ 

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

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

336 

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

338 path_parts = normalized_path.split("/") 

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

340 

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

342 try: 

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

344 # Check for exact path or filename match 

345 if normalized_path in content or filename in content: 

346 results.append((issue_path, is_completed)) 

347 except Exception: 

348 continue 

349 

350 return results 

351 

352 

353# ============================================================================= 

354# Git History Analysis 

355# ============================================================================= 

356 

357 

358def _extract_fix_commit(content: str) -> str | None: 

359 """Extract fix commit SHA from issue Resolution section. 

360 

361 Args: 

362 content: Issue file content 

363 

364 Returns: 

365 Fix commit SHA if found, None otherwise 

366 """ 

367 # Look for "Fix Commit: <sha>" pattern in Resolution section 

368 match = re.search(r"\*\*Fix Commit\*\*:\s*([a-f0-9]{7,40})", content) 

369 if match: 

370 return match.group(1) 

371 return None 

372 

373 

374def _extract_files_changed(content: str) -> list[str]: 

375 """Extract files changed from issue Resolution section. 

376 

377 Args: 

378 content: Issue file content 

379 

380 Returns: 

381 List of file paths that were changed to fix the issue 

382 """ 

383 files: list[str] = [] 

384 

385 # Look for Files Changed section 

386 section_match = re.search( 

387 r"###\s*Files Changed\s*\n(.*?)(?=\n###|\n##|\Z)", 

388 content, 

389 re.DOTALL, 

390 ) 

391 if section_match: 

392 section = section_match.group(1) 

393 # Extract backtick-quoted paths: `path/to/file.py` 

394 for match in re.finditer(r"`([^`]+)`", section): 

395 path = match.group(1).strip() 

396 if path and not path.startswith("See "): # Skip placeholder text 

397 files.append(path) 

398 

399 return files 

400 

401 

402def _extract_completion_date(content: str) -> datetime | None: 

403 """Extract completion/closed date from issue Resolution section. 

404 

405 Args: 

406 content: Issue file content 

407 

408 Returns: 

409 Completion date if found, None otherwise 

410 """ 

411 # Look for "Completed: YYYY-MM-DD" or "Closed: YYYY-MM-DD" 

412 match = re.search(r"\*\*(?:Completed|Closed)\*\*:\s*(\d{4}-\d{2}-\d{2})", content) 

413 if match: 

414 try: 

415 return datetime.strptime(match.group(1), "%Y-%m-%d") 

416 except ValueError: 

417 return None 

418 return None 

419 

420 

421def _commit_exists_in_history(commit_sha: str) -> bool: 

422 """Check if a commit exists in the current git history. 

423 

424 Args: 

425 commit_sha: SHA of the commit to check 

426 

427 Returns: 

428 True if commit exists in current history 

429 """ 

430 result = subprocess.run( 

431 ["git", "cat-file", "-t", commit_sha], 

432 capture_output=True, 

433 text=True, 

434 ) 

435 return result.returncode == 0 and result.stdout.strip() == "commit" 

436 

437 

438def _get_files_modified_since_commit( 

439 since_commit: str, 

440 target_files: list[str], 

441) -> tuple[list[str], list[str]]: 

442 """Find which target files have been modified since a given commit. 

443 

444 Uses a single batched git log call instead of per-file subprocess calls. 

445 

446 Args: 

447 since_commit: SHA of the commit to check since 

448 target_files: List of file paths to check 

449 

450 Returns: 

451 Tuple of (modified_files, related_commits) where: 

452 - modified_files: Target files that were modified after the commit 

453 - related_commits: SHAs of commits that modified the target files 

454 """ 

455 if not target_files: 

456 return [], [] 

457 

458 # Single batched git log call with all file paths 

459 result = subprocess.run( 

460 ["git", "log", "--pretty=format:%H", "--name-only", f"{since_commit}..HEAD", "--"] 

461 + target_files, 

462 capture_output=True, 

463 text=True, 

464 ) 

465 

466 if result.returncode != 0 or not result.stdout.strip(): 

467 return [], [] 

468 

469 # Parse output: blocks separated by blank lines, each block is SHA followed by file names 

470 target_set = set(target_files) 

471 modified_set: set[str] = set() 

472 related_commits: set[str] = set() 

473 

474 for block in result.stdout.strip().split("\n\n"): 

475 lines = block.strip().split("\n") 

476 if not lines: 

477 continue 

478 commit_sha = lines[0] 

479 related_commits.add(commit_sha[:8]) 

480 for file_name in lines[1:]: 

481 file_name = file_name.strip() 

482 if file_name in target_set: 

483 modified_set.add(file_name) 

484 

485 # Preserve original order from target_files 

486 modified_files = [f for f in target_files if f in modified_set] 

487 return modified_files, list(related_commits) 

488 

489 

490def detect_regression_or_duplicate( 

491 config: BRConfig, 

492 completed_issue_path: Path, 

493) -> tuple[MatchClassification, RegressionEvidence]: 

494 """Analyze a completed issue to classify if a match is a regression or invalid fix. 

495 

496 Classification Logic: 

497 - UNVERIFIED: No fix commit tracked - can't determine 

498 - INVALID_FIX: Fix commit not in history - fix was never merged/deployed 

499 - REGRESSION: Files modified AFTER fix - fix worked but later changes broke it 

500 - INVALID_FIX: Files NOT modified after fix - fix was applied but never worked 

501 

502 Args: 

503 config: Project configuration 

504 completed_issue_path: Path to the completed issue file 

505 

506 Returns: 

507 Tuple of (classification, evidence) with analysis results 

508 """ 

509 evidence = RegressionEvidence() 

510 

511 try: 

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

513 except Exception: 

514 return MatchClassification.UNVERIFIED, evidence 

515 

516 # Extract fix commit 

517 fix_commit = _extract_fix_commit(content) 

518 evidence.fix_commit_sha = fix_commit 

519 

520 if not fix_commit: 

521 # No fix commit tracked - can't determine regression vs invalid fix 

522 return MatchClassification.UNVERIFIED, evidence 

523 

524 # Check if fix commit exists in current history 

525 if not _commit_exists_in_history(fix_commit): 

526 evidence.fix_commit_exists = False 

527 return MatchClassification.INVALID_FIX, evidence 

528 

529 # Extract files changed in the fix 

530 files_changed = _extract_files_changed(content) 

531 

532 if not files_changed: 

533 # No files tracked - can't determine 

534 return MatchClassification.UNVERIFIED, evidence 

535 

536 # Check if any of those files were modified since the fix 

537 modified_files, related_commits = _get_files_modified_since_commit(fix_commit, files_changed) 

538 evidence.files_modified_since_fix = modified_files 

539 evidence.related_commits = related_commits 

540 

541 # Calculate days since fix 

542 completion_date = _extract_completion_date(content) 

543 if completion_date: 

544 evidence.days_since_fix = (datetime.now() - completion_date).days 

545 

546 if modified_files: 

547 # Files were modified after fix - this is a regression 

548 return MatchClassification.REGRESSION, evidence 

549 else: 

550 # Files were NOT modified after fix - the fix never actually worked 

551 return MatchClassification.INVALID_FIX, evidence 

552 

553 

554# ============================================================================= 

555# Main Discovery Functions 

556# ============================================================================= 

557 

558 

559def find_existing_issue( 

560 config: BRConfig, 

561 finding_type: str, 

562 file_path: str | None, 

563 finding_title: str, 

564 finding_content: str, 

565) -> FindingMatch: 

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

567 

568 Uses a multi-pass approach: 

569 1. Exact file path match in Location sections 

570 2. Title word overlap (>70% = likely duplicate) 

571 3. Content overlap analysis 

572 

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

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

575 

576 Args: 

577 config: Project configuration 

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

579 file_path: File path from finding (if any) 

580 finding_title: Title of the finding 

581 finding_content: Full content/description of finding 

582 

583 Returns: 

584 FindingMatch with best match details, including classification and 

585 regression evidence for completed issue matches 

586 """ 

587 best_match = FindingMatch( 

588 issue_path=None, 

589 match_type="none", 

590 match_score=0.0, 

591 ) 

592 

593 # Pass 1: Exact file path match 

594 if file_path: 

595 path_matches = search_issues_by_file_path(config, file_path) 

596 for issue_path, is_completed in path_matches: 

597 try: 

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

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

600 issue_type_match = _matches_issue_type( 

601 finding_type, issue_path, config, is_completed 

602 ) 

603 if issue_type_match: 

604 # Determine classification 

605 if is_completed: 

606 classification, evidence = detect_regression_or_duplicate( 

607 config, issue_path 

608 ) 

609 else: 

610 classification = MatchClassification.DUPLICATE 

611 evidence = None 

612 

613 # High confidence if same file + same type 

614 return FindingMatch( 

615 issue_path=issue_path, 

616 match_type="exact", 

617 match_score=0.85, 

618 is_completed=is_completed, 

619 matched_terms=[file_path], 

620 classification=classification, 

621 regression_evidence=evidence, 

622 ) 

623 except Exception: 

624 continue 

625 

626 # Pass 2: Title similarity 

627 title_words = _extract_words(finding_title) 

628 if title_words: 

629 for issue_path, is_completed in _get_all_issue_files(config): 

630 try: 

631 # Extract title from issue file 

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

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

634 if title_match: 

635 issue_title = title_match.group(1) 

636 issue_words = _extract_words(issue_title) 

637 overlap = _calculate_word_overlap(title_words, issue_words) 

638 if overlap > 0.7 and overlap > best_match.match_score: 

639 # Determine classification 

640 if is_completed: 

641 classification, evidence = detect_regression_or_duplicate( 

642 config, issue_path 

643 ) 

644 else: 

645 classification = MatchClassification.DUPLICATE 

646 evidence = None 

647 

648 best_match = FindingMatch( 

649 issue_path=issue_path, 

650 match_type="similar", 

651 match_score=overlap, 

652 is_completed=is_completed, 

653 matched_terms=list(title_words & issue_words), 

654 classification=classification, 

655 regression_evidence=evidence, 

656 ) 

657 except Exception: 

658 continue 

659 

660 # Pass 3: Content analysis 

661 if best_match.match_score < 0.5: 

662 content_matches = search_issues_by_content( 

663 config, 

664 [finding_title, finding_content], 

665 ) 

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

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

668 if adjusted_score > best_match.match_score: 

669 # Determine classification 

670 if is_completed: 

671 classification, evidence = detect_regression_or_duplicate(config, issue_path) 

672 else: 

673 classification = MatchClassification.DUPLICATE 

674 evidence = None 

675 

676 best_match = FindingMatch( 

677 issue_path=issue_path, 

678 match_type="content", 

679 match_score=adjusted_score, 

680 is_completed=is_completed, 

681 classification=classification, 

682 regression_evidence=evidence, 

683 ) 

684 

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

686 return best_match 

687 

688 

689# ============================================================================= 

690# Issue Reopening 

691# ============================================================================= 

692 

693 

694def _build_reopen_section( 

695 reason: str, 

696 new_context: str, 

697 source_command: str, 

698 classification: MatchClassification | None = None, 

699 regression_evidence: RegressionEvidence | None = None, 

700) -> str: 

701 """Build the reopened section for an issue. 

702 

703 Args: 

704 reason: Reason for reopening 

705 new_context: New context/findings 

706 source_command: Command that triggered reopen 

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

708 regression_evidence: Evidence supporting the classification 

709 

710 Returns: 

711 Markdown section string 

712 """ 

713 # Determine section header based on classification 

714 if classification == MatchClassification.REGRESSION: 

715 section_header = "## Regression" 

716 classification_line = "- **Classification**: Regression (fix was broken by later changes)" 

717 elif classification == MatchClassification.INVALID_FIX: 

718 section_header = "## Reopened (Invalid Fix)" 

719 classification_line = ( 

720 "- **Classification**: Invalid Fix (original fix never resolved the issue)" 

721 ) 

722 else: 

723 section_header = "## Reopened" 

724 classification_line = "" 

725 

726 # Build evidence section if available 

727 evidence_section = "" 

728 if regression_evidence: 

729 evidence_lines = [] 

730 if regression_evidence.fix_commit_sha: 

731 evidence_lines.append( 

732 f"- **Original Fix Commit**: {regression_evidence.fix_commit_sha}" 

733 ) 

734 if not regression_evidence.fix_commit_exists: 

735 evidence_lines.append( 

736 "- **Fix Status**: Fix commit not found in history (possibly never merged)" 

737 ) 

738 if regression_evidence.files_modified_since_fix: 

739 files_list = ", ".join( 

740 f"`{f}`" for f in regression_evidence.files_modified_since_fix[:5] 

741 ) 

742 evidence_lines.append(f"- **Files Modified Since Fix**: {files_list}") 

743 if regression_evidence.related_commits: 

744 commits_list = ", ".join(regression_evidence.related_commits[:5]) 

745 evidence_lines.append(f"- **Related Commits**: {commits_list}") 

746 if regression_evidence.days_since_fix > 0: 

747 evidence_lines.append(f"- **Days Since Fix**: {regression_evidence.days_since_fix}") 

748 

749 if evidence_lines: 

750 evidence_section = "\n### Evidence\n\n" + "\n".join(evidence_lines) 

751 

752 return f""" 

753 

754--- 

755 

756{section_header} 

757 

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

759- **By**: {source_command} 

760- **Reason**: {reason} 

761{classification_line} 

762{evidence_section} 

763 

764### New Findings 

765 

766{new_context} 

767""" 

768 

769 

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

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

772 

773 Args: 

774 issue_path: Path to issue file 

775 config: Project configuration 

776 

777 Returns: 

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

779 """ 

780 filename = issue_path.name.upper() 

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

782 if category_config.prefix in filename: 

783 return category_name 

784 return "bugs" # Default 

785 

786 

787def _matches_issue_type( 

788 finding_type: str, 

789 issue_path: Path, 

790 config: BRConfig, 

791 is_completed: bool, 

792) -> bool: 

793 """Check if finding type matches issue path using configured categories. 

794 

795 Args: 

796 finding_type: The type of finding (e.g., 'BUG', 'ENH', 'FEAT') 

797 issue_path: Path to the issue file 

798 config: Configuration with category definitions 

799 is_completed: Whether the issue is in the completed directory 

800 

801 Returns: 

802 True if the finding type matches the issue path's category 

803 """ 

804 if is_completed: 

805 return True 

806 

807 path_str = str(issue_path) 

808 for category in config.issues.categories.values(): 

809 if finding_type == category.prefix and f"/{category.dir}/" in path_str: 

810 return True 

811 return False 

812 

813 

814def reopen_issue( 

815 config: BRConfig, 

816 completed_issue_path: Path, 

817 reopen_reason: str, 

818 new_context: str, 

819 source_command: str, 

820 logger: Logger, 

821 classification: MatchClassification | None = None, 

822 regression_evidence: RegressionEvidence | None = None, 

823) -> Path | None: 

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

825 

826 Args: 

827 config: Project configuration 

828 completed_issue_path: Path to issue in completed/ 

829 reopen_reason: Reason for reopening 

830 new_context: New context/findings to add 

831 source_command: Command triggering the reopen 

832 logger: Logger for output 

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

834 regression_evidence: Evidence supporting the classification 

835 

836 Returns: 

837 New path to reopened issue, or None if failed 

838 """ 

839 if not completed_issue_path.exists(): 

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

841 return None 

842 

843 # Determine target category directory 

844 category = _get_category_from_issue_path(completed_issue_path, config) 

845 target_dir = config.get_issue_dir(category) 

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

847 

848 target_path = target_dir / completed_issue_path.name 

849 

850 # Safety check - don't overwrite existing active issue 

851 if target_path.exists(): 

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

853 return None 

854 

855 # Log with classification info if available 

856 if classification == MatchClassification.REGRESSION: 

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

858 elif classification == MatchClassification.INVALID_FIX: 

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

860 else: 

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

862 

863 try: 

864 # Read and update content 

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

866 

867 # Add reopened section with classification info 

868 reopen_section = _build_reopen_section( 

869 reopen_reason, 

870 new_context, 

871 source_command, 

872 classification, 

873 regression_evidence, 

874 ) 

875 content += reopen_section 

876 

877 # Try git mv first for history preservation 

878 result = subprocess.run( 

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

880 capture_output=True, 

881 text=True, 

882 ) 

883 

884 if result.returncode != 0: 

885 # Fall back to manual copy 

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

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

888 completed_issue_path.unlink() 

889 else: 

890 # Write updated content 

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

892 

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

894 return target_path 

895 

896 except Exception as e: 

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

898 return None 

899 

900 

901def update_existing_issue( 

902 config: BRConfig, 

903 issue_path: Path, 

904 update_section_name: str, 

905 update_content: str, 

906 source_command: str, 

907 logger: Logger, 

908) -> bool: 

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

910 

911 Args: 

912 config: Project configuration 

913 issue_path: Path to issue file 

914 update_section_name: Name for the update section 

915 update_content: Content to add 

916 source_command: Command triggering the update 

917 logger: Logger for output 

918 

919 Returns: 

920 True if update succeeded 

921 """ 

922 if not issue_path.exists(): 

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

924 return False 

925 

926 try: 

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

928 

929 # Build update section 

930 update_section = f""" 

931 

932--- 

933 

934## {update_section_name} 

935 

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

937- **Source**: {source_command} 

938 

939{update_content} 

940""" 

941 

942 # Check if section already exists 

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

944 content += update_section 

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

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

947 else: 

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

949 

950 return True 

951 

952 except Exception as e: 

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

954 return False