Coverage for little_loops/issue_discovery.py: 79%

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

2

3 Provides functions for finding existing issues, detecting duplicates,

4 and reopening completed issues when problems recur.

5"""

6

7 from __future__ import annotations

8

9 import re

10 import subprocess

11 from dataclasses import dataclass, field

12 from datetime import datetime

13 from enum import Enum

14 from pathlib import Path

15 from typing import TYPE_CHECKING

16

17 if TYPE_CHECKING:

18 from little_loops.config import BRConfig

19 from little_loops.logger import Logger

20

21

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

23# Enums

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

25

26

27 class 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

47 class 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

66 class 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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