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
« prev ^ index » next coverage.py v7.12.0, created at 2026-02-15 15:23 -0600
1"""Issue discovery and deduplication for little-loops.
3Provides functions for finding existing issues, detecting duplicates,
4and reopening completed issues when problems recur.
5"""
7from __future__ import annotations
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
17if TYPE_CHECKING:
18 from little_loops.config import BRConfig
19 from little_loops.logger import Logger
22# =============================================================================
23# Enums
24# =============================================================================
27class MatchClassification(Enum):
28 """Classification of how a finding matches an existing issue.
30 Used to distinguish between true duplicates, regressions, and invalid fixes
31 when a finding matches a completed issue.
32 """
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)
41# =============================================================================
42# Data Classes
43# =============================================================================
46@dataclass
47class RegressionEvidence:
48 """Evidence for regression vs invalid fix classification.
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 """
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)
65@dataclass
66class FindingMatch:
67 """Result of matching a finding to an existing issue.
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 """
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
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
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
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
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
107 @property
108 def should_reopen_as_regression(self) -> bool:
109 """Return True if issue should be reopened as a regression.
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 )
119 @property
120 def should_reopen_as_invalid_fix(self) -> bool:
121 """Return True if issue should be reopened due to invalid fix.
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 )
131 @property
132 def is_unverified(self) -> bool:
133 """Return True if regression status cannot be determined.
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 )
145# =============================================================================
146# Text Matching Helpers
147# =============================================================================
150def _normalize_text(text: str) -> str:
151 """Normalize text for comparison.
153 Args:
154 text: Input text
156 Returns:
157 Lowercase text with normalized whitespace
158 """
159 return re.sub(r"\s+", " ", text.lower().strip())
162def _extract_words(text: str) -> set[str]:
163 """Extract significant words from text.
165 Args:
166 text: Input text
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
204def _calculate_word_overlap(words1: set[str], words2: set[str]) -> float:
205 """Calculate Jaccard similarity between word sets.
207 Args:
208 words1: First word set
209 words2: Second word set
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)
221def _extract_line_numbers(text: str) -> set[int]:
222 """Extract line numbers from text.
224 Args:
225 text: Input text
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
245# =============================================================================
246# Issue Search Functions
247# =============================================================================
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.
256 Args:
257 config: Project configuration
258 include_completed: Whether to include completed issues
260 Returns:
261 List of (path, is_completed) tuples
262 """
263 files: list[tuple[Path, bool]] = []
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))
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))
279 return files
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.
289 Args:
290 config: Project configuration
291 search_terms: Terms to search for
292 include_completed: Whether to include completed issues
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))
302 if not search_words:
303 return results
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
315 results.sort(key=lambda x: x[1], reverse=True)
316 return results
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.
326 Args:
327 config: Project configuration
328 file_path: File path to search for
329 include_completed: Whether to include completed issues
331 Returns:
332 List of (issue_path, is_completed) tuples
333 """
334 results: list[tuple[Path, bool]] = []
335 normalized_path = file_path.strip().lower()
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
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
350 return results
353# =============================================================================
354# Git History Analysis
355# =============================================================================
358def _extract_fix_commit(content: str) -> str | None:
359 """Extract fix commit SHA from issue Resolution section.
361 Args:
362 content: Issue file content
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
374def _extract_files_changed(content: str) -> list[str]:
375 """Extract files changed from issue Resolution section.
377 Args:
378 content: Issue file content
380 Returns:
381 List of file paths that were changed to fix the issue
382 """
383 files: list[str] = []
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)
399 return files
402def _extract_completion_date(content: str) -> datetime | None:
403 """Extract completion/closed date from issue Resolution section.
405 Args:
406 content: Issue file content
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
421def _commit_exists_in_history(commit_sha: str) -> bool:
422 """Check if a commit exists in the current git history.
424 Args:
425 commit_sha: SHA of the commit to check
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"
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.
444 Uses a single batched git log call instead of per-file subprocess calls.
446 Args:
447 since_commit: SHA of the commit to check since
448 target_files: List of file paths to check
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 [], []
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 )
466 if result.returncode != 0 or not result.stdout.strip():
467 return [], []
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()
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)
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)
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.
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
502 Args:
503 config: Project configuration
504 completed_issue_path: Path to the completed issue file
506 Returns:
507 Tuple of (classification, evidence) with analysis results
508 """
509 evidence = RegressionEvidence()
511 try:
512 content = completed_issue_path.read_text(encoding="utf-8")
513 except Exception:
514 return MatchClassification.UNVERIFIED, evidence
516 # Extract fix commit
517 fix_commit = _extract_fix_commit(content)
518 evidence.fix_commit_sha = fix_commit
520 if not fix_commit:
521 # No fix commit tracked - can't determine regression vs invalid fix
522 return MatchClassification.UNVERIFIED, evidence
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
529 # Extract files changed in the fix
530 files_changed = _extract_files_changed(content)
532 if not files_changed:
533 # No files tracked - can't determine
534 return MatchClassification.UNVERIFIED, evidence
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
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
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
554# =============================================================================
555# Main Discovery Functions
556# =============================================================================
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.
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
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).
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
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 )
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
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
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
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
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
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 )
685 # If no match found, classification is NEW_ISSUE (the default)
686 return best_match
689# =============================================================================
690# Issue Reopening
691# =============================================================================
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.
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
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 = ""
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}")
749 if evidence_lines:
750 evidence_section = "\n### Evidence\n\n" + "\n".join(evidence_lines)
752 return f"""
754---
756{section_header}
758- **Date**: {datetime.now().strftime("%Y-%m-%d")}
759- **By**: {source_command}
760- **Reason**: {reason}
761{classification_line}
762{evidence_section}
764### New Findings
766{new_context}
767"""
770def _get_category_from_issue_path(issue_path: Path, config: BRConfig) -> str:
771 """Determine the category for an issue based on its filename.
773 Args:
774 issue_path: Path to issue file
775 config: Project configuration
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
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.
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
801 Returns:
802 True if the finding type matches the issue path's category
803 """
804 if is_completed:
805 return True
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
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.
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
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
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)
848 target_path = target_dir / completed_issue_path.name
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
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}/")
863 try:
864 # Read and update content
865 content = completed_issue_path.read_text(encoding="utf-8")
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
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 )
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")
893 logger.success(f"Reopened: {target_path.name}")
894 return target_path
896 except Exception as e:
897 logger.error(f"Failed to reopen issue: {e}")
898 return None
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.
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
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
926 try:
927 content = issue_path.read_text(encoding="utf-8")
929 # Build update section
930 update_section = f"""
932---
934## {update_section_name}
936- **Date**: {datetime.now().strftime("%Y-%m-%d")}
937- **Source**: {source_command}
939{update_content}
940"""
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")
950 return True
952 except Exception as e:
953 logger.error(f"Failed to update issue: {e}")
954 return False