Coverage for little_loops / issue_lifecycle.py: 11%
308 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
1"""Issue lifecycle management for little-loops.
3Provides functions for closing, completing, and verifying issue completion,
4as well as creating new issues from implementation failures.
6Also provides failure classification to distinguish transient errors
7(API quota, network issues, timeouts) from real implementation failures.
8"""
10from __future__ import annotations
12import re
13import subprocess
14from datetime import UTC, datetime
15from enum import Enum
16from pathlib import Path
17from typing import Any
19from little_loops.config import BRConfig
20from little_loops.events import EventBus
21from little_loops.issue_parser import IssueInfo, IssueParser, get_next_issue_number, slugify
22from little_loops.logger import Logger
23from little_loops.session_log import append_session_log_entry
26def _iso_now() -> str:
27 """Return current time as ISO 8601 string."""
28 return datetime.now(UTC).isoformat()
31# =============================================================================
32# Failure Classification
33# =============================================================================
36class FailureType(Enum):
37 """Classification of command failure types.
39 Used to distinguish between transient errors that should not
40 create bug issues and real implementation failures that should.
41 """
43 TRANSIENT = "transient" # Temporary error, don't create issue
44 REAL = "real" # Actual bug/error, create issue
47def classify_failure(error_output: str, returncode: int) -> tuple[FailureType, str]:
48 """Classify a command failure as transient or real.
50 Examines error output for patterns indicating transient failures
51 (API quota, network errors, timeouts) vs real implementation failures.
53 Args:
54 error_output: stderr or stdout from failed command
55 returncode: Process exit code (available for future use)
57 Returns:
58 Tuple of (failure_type, reason) where reason explains the classification
59 """
60 error_lower = error_output.lower()
62 # API quota/rate limit patterns
63 quota_patterns = [
64 "out of extra usage",
65 "rate limit",
66 "quota exceeded",
67 "too many requests",
68 "api limit",
69 "usage limit",
70 "429", # HTTP Too Many Requests
71 "resource exhausted",
72 "resourceexhausted", # No space variant (gRPC style)
73 ]
74 if any(pattern in error_lower for pattern in quota_patterns):
75 return (FailureType.TRANSIENT, "API quota or rate limit exceeded")
77 # Network/connectivity patterns
78 # Note: Use word boundaries where needed to avoid false positives
79 # (e.g., "enotfound" shouldn't match "ModuleNotFoundError")
80 network_patterns = [
81 "connection refused",
82 "connection timeout",
83 "network error",
84 "dns resolution",
85 "connection reset",
86 "service unavailable",
87 "502 bad gateway",
88 "503 service unavailable",
89 "504 gateway timeout",
90 ]
91 if any(pattern in error_lower for pattern in network_patterns):
92 return (FailureType.TRANSIENT, "Network or connectivity error")
94 # Check for Node.js-style error codes with word boundary awareness
95 # These are typically at word boundaries (e.g., "Error: ECONNREFUSED")
96 if re.search(r"\beconnrefused\b", error_lower):
97 return (FailureType.TRANSIENT, "Network or connectivity error")
98 if re.search(r"\benotfound\b", error_lower):
99 return (FailureType.TRANSIENT, "Network or connectivity error")
100 if re.search(r"\betimedout\b", error_lower):
101 return (FailureType.TRANSIENT, "Network or connectivity error")
103 # Timeout patterns
104 timeout_patterns = [
105 "timeout",
106 "timed out",
107 "deadline exceeded",
108 "operation timed out",
109 ]
110 if any(pattern in error_lower for pattern in timeout_patterns):
111 return (FailureType.TRANSIENT, "Command timeout")
113 # Resource/system transient patterns
114 resource_patterns = [
115 "disk full",
116 "no space left",
117 "resource temporarily unavailable",
118 "too many open files",
119 "memory allocation failed",
120 "out of memory",
121 ]
122 if any(pattern in error_lower for pattern in resource_patterns):
123 return (FailureType.TRANSIENT, "System resource error")
125 # Default: treat as real failure
126 return (FailureType.REAL, "Implementation error")
129# =============================================================================
130# Content Manipulation Helpers
131# =============================================================================
134def _build_closure_resolution(
135 close_status: str,
136 close_reason: str,
137 fix_commit: str | None = None,
138 files_changed: list[str] | None = None,
139) -> str:
140 """Build resolution section for closed issues.
142 Args:
143 close_status: Status text (e.g., "Closed - Already Fixed")
144 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
145 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
146 files_changed: List of files modified by the fix (for regression tracking)
148 Returns:
149 Resolution section markdown string
150 """
151 # Build fix commit line
152 fix_commit_line = f"- **Fix Commit**: {fix_commit}\n" if fix_commit else ""
154 # Build files changed section
155 if files_changed:
156 files_list = "\n".join(f" - `{f}`" for f in files_changed)
157 files_section = f"""
158### Files Changed
159{files_list}
160"""
161 else:
162 files_section = ""
164 return f"""
166---
168## Resolution
170- **Status**: {close_status}
171- **Closed**: {datetime.now().strftime("%Y-%m-%d")}
172- **Reason**: {close_reason}
173- **Closure**: Automated (ready-issue validation)
174{fix_commit_line}
175### Closure Notes
176Issue was automatically closed during validation.
177The issue was determined to be invalid, already resolved, or not actionable.
178{files_section}"""
181def _build_completion_resolution(
182 action: str,
183 fix_commit: str | None = None,
184 files_changed: list[str] | None = None,
185) -> str:
186 """Build resolution section for completed issues.
188 Args:
189 action: Action verb (e.g., "fix", "implement")
190 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
191 files_changed: List of files modified by the fix (for regression tracking)
193 Returns:
194 Resolution section markdown string
195 """
196 # Build fix commit line
197 fix_commit_line = f"- **Fix Commit**: {fix_commit}" if fix_commit else ""
199 # Build files changed section
200 if files_changed:
201 files_list = "\n".join(f" - `{f}`" for f in files_changed)
202 files_section = f"""
203### Files Changed
204{files_list}"""
205 else:
206 files_section = """
207### Files Changed
208- See git history for details"""
210 return f"""
212---
214## Resolution
216- **Action**: {action}
217- **Completed**: {datetime.now().strftime("%Y-%m-%d")}
218- **Status**: Completed (automated fallback)
219- **Implementation**: Command exited early but issue was addressed
220{fix_commit_line}
221{files_section}
223### Verification Results
224- Automated verification passed
226### Commits
227- See git log for details
228"""
231def _prepare_issue_content(original_path: Path, resolution: str) -> str:
232 """Read issue file and append resolution section if needed.
234 Args:
235 original_path: Path to the original issue file
236 resolution: Resolution section to append
238 Returns:
239 Updated file content with resolution section
240 """
241 content = original_path.read_text()
242 if "## Resolution" not in content:
243 content += resolution
244 return content
247# =============================================================================
248# Git Operations Helpers
249# =============================================================================
252def _is_git_tracked(file_path: Path) -> bool:
253 """Check if a file is under git version control.
255 Args:
256 file_path: Path to the file to check
258 Returns:
259 True if file is tracked by git, False otherwise
260 """
261 try:
262 result = subprocess.run(
263 ["git", "ls-files", str(file_path)],
264 capture_output=True,
265 text=True,
266 timeout=30,
267 )
268 except subprocess.TimeoutExpired:
269 return False
270 return bool(result.stdout.strip())
273def _cleanup_stale_source(original_path: Path, issue_id: str, logger: Logger) -> None:
274 """Remove orphaned source file and commit cleanup.
276 Args:
277 original_path: Path to the stale source file
278 issue_id: Issue identifier for commit message
279 logger: Logger for output
280 """
281 original_path.unlink()
282 try:
283 subprocess.run(["git", "add", "-A"], capture_output=True, text=True, timeout=30)
284 subprocess.run(
285 ["git", "commit", "-m", f"cleanup: remove stale {issue_id} from bugs/"],
286 capture_output=True,
287 text=True,
288 timeout=30,
289 )
290 except subprocess.TimeoutExpired:
291 logger.warning(f"Git command timed out during cleanup of {issue_id}")
294def _move_issue_to_completed(
295 original_path: Path,
296 completed_path: Path,
297 content: str,
298 logger: Logger,
299) -> bool:
300 """Move issue file to completed dir, preferring git mv for history.
302 Checks if source is under git version control before attempting git mv.
303 If source is tracked, uses git mv for history preservation.
304 If source is not tracked, uses manual copy + delete directly.
306 Args:
307 original_path: Source path of issue file
308 completed_path: Destination path in completed directory
309 content: Updated file content to write
310 logger: Logger for output
312 Returns:
313 True if move succeeded
314 """
315 # Handle pre-existing destination (e.g., from parallel worker or worktree leak)
316 if completed_path.exists():
317 logger.info(f"Destination already exists: {completed_path.name}, updating content")
318 completed_path.write_text(content)
319 original_path.unlink(missing_ok=True)
320 return True
322 # Check if source is under git version control before attempting git mv
323 source_tracked = _is_git_tracked(original_path)
325 if source_tracked:
326 # Source is tracked, use git mv for history preservation
327 try:
328 result = subprocess.run(
329 ["git", "mv", str(original_path), str(completed_path)],
330 capture_output=True,
331 text=True,
332 timeout=30,
333 )
334 except subprocess.TimeoutExpired:
335 logger.warning("git mv timed out, falling back to manual copy")
336 completed_path.write_text(content)
337 original_path.unlink(missing_ok=True)
338 return True
340 if result.returncode != 0:
341 # git mv failed, fall back to manual copy + delete
342 logger.warning(f"git mv failed: {result.stderr}")
343 completed_path.write_text(content)
344 original_path.unlink(missing_ok=True)
345 else:
346 logger.success(f"Used git mv to move {original_path.stem}")
347 # Write updated content to the moved file
348 completed_path.write_text(content)
349 else:
350 # Source is not tracked, use manual copy + delete directly
351 logger.info(f"Source not tracked by git, using manual copy: {original_path.name}")
352 completed_path.write_text(content)
353 original_path.unlink(missing_ok=True)
355 return True
358def _commit_issue_completion(
359 info: IssueInfo,
360 commit_prefix: str,
361 commit_body: str,
362 logger: Logger,
363) -> bool:
364 """Stage all changes and create completion commit.
366 Args:
367 info: Issue information
368 commit_prefix: Prefix for commit message (e.g., "close" or action verb)
369 commit_body: Body text for commit message
370 logger: Logger for output
372 Returns:
373 True if commit succeeded or nothing to commit
374 """
375 # Stage all changes
376 try:
377 stage_result = subprocess.run(
378 ["git", "add", "-A"],
379 capture_output=True,
380 text=True,
381 timeout=30,
382 )
383 if stage_result.returncode != 0:
384 logger.warning(f"git add failed: {stage_result.stderr}")
385 except subprocess.TimeoutExpired:
386 logger.warning("git add timed out")
388 # Create commit
389 commit_msg = f"{commit_prefix}({info.issue_type}): {commit_body}"
390 try:
391 commit_result = subprocess.run(
392 ["git", "commit", "-m", commit_msg],
393 capture_output=True,
394 text=True,
395 timeout=30,
396 )
397 except subprocess.TimeoutExpired:
398 logger.warning("git commit timed out")
399 return True
401 if commit_result.returncode != 0:
402 if "nothing to commit" in commit_result.stdout.lower():
403 logger.info("No changes to commit (already committed)")
404 else:
405 logger.warning(f"git commit failed: {commit_result.stderr}")
406 else:
407 commit_hash_match = re.search(r"\[[\w-]+\s+([a-f0-9]+)\]", commit_result.stdout)
408 if commit_hash_match:
409 logger.success(f"Committed: {commit_hash_match.group(1)}")
410 else:
411 logger.success("Committed changes")
413 return True
416def verify_issue_completed(info: IssueInfo, config: BRConfig, logger: Logger) -> bool:
417 """Verify that an issue was moved to completed directory.
419 Args:
420 info: Issue info
421 config: Project configuration
422 logger: Logger for output
424 Returns:
425 True if issue is in completed directory
426 """
427 completed_path = config.get_completed_dir() / info.path.name
428 original_path = info.path
430 if completed_path.exists() and not original_path.exists():
431 logger.success(f"Verified: {info.issue_id} properly moved to completed")
432 return True
434 if completed_path.exists() and original_path.exists():
435 logger.warning(f"Warning: {info.issue_id} exists in BOTH locations")
437 if not original_path.exists():
438 logger.warning(f"Warning: {info.issue_id} was deleted but not moved to completed")
439 return True
441 logger.warning(f"Warning: {info.issue_id} was NOT moved to completed")
442 return False
445def create_issue_from_failure(
446 error_output: str,
447 parent_info: IssueInfo,
448 config: BRConfig,
449 logger: Logger,
450 event_bus: EventBus | None = None,
451) -> Path | None:
452 """Create a new bug issue file when implementation fails.
454 Args:
455 error_output: Error output from the failed command
456 parent_info: Info about the issue that failed
457 config: Project configuration
458 logger: Logger for output
459 event_bus: Optional EventBus for event emission
461 Returns:
462 Path to new issue file, or None if creation failed
463 """
464 bug_num = get_next_issue_number(config, "bugs")
465 prefix = config.get_issue_prefix("bugs")
466 bug_id = f"{prefix}-{bug_num:03d}"
468 # Try to extract meaningful error info
469 error_lines = error_output.split("\n")[:20] # First 20 lines
470 traceback = "\n".join(error_lines)
472 # Generate title from error
473 title = f"Implementation failure in {parent_info.issue_id}"
474 if "Error" in error_output:
475 error_match = re.search(r"([A-Z]\w+Error[:\s]+[^\n]+)", error_output)
476 if error_match:
477 title = error_match.group(1)
478 title_slug = slugify(title)
480 filename = f"P1-{bug_id}-{title_slug}.md"
481 bugs_dir = config.get_issue_dir("bugs")
482 new_issue_path = bugs_dir / filename
484 content = f"""# {bug_id}: Implementation Failure - {parent_info.issue_id}
486## Summary
487Issue encountered during automated implementation of {parent_info.issue_id}.
489## Current Behavior
490```
491{traceback}
492```
494## Expected Behavior
495Implementation should complete without errors.
497## Root Cause
498Discovered during automated processing of `{parent_info.path}`.
500## Steps to Reproduce
5011. Run: `/ll:manage-issue {parent_info.issue_type} fix {parent_info.issue_id}`
5022. Observe error
504## Proposed Solution
505Investigate the error output above and address the root cause.
507## Impact
508- **Severity**: High
509- **Effort**: Unknown
510- **Risk**: Medium
511- **Breaking Change**: No
513## Labels
514`bug`, `high-priority`, `auto-generated`, `implementation-failure`
516---
518## Status
519**Open** | Created: {_iso_now()} | Priority: P1
521## Related Issues
522- [{parent_info.issue_id}]({parent_info.path})
523"""
525 try:
526 bugs_dir.mkdir(parents=True, exist_ok=True)
527 new_issue_path.write_text(content)
528 logger.success(f"Created new issue: {new_issue_path}")
529 if event_bus is not None:
530 event_bus.emit(
531 {
532 "event": "issue.failure_captured",
533 "ts": _iso_now(),
534 "issue_id": bug_id,
535 "file_path": str(new_issue_path),
536 "parent_issue_id": parent_info.issue_id,
537 }
538 )
539 return new_issue_path
540 except Exception as e:
541 logger.error(f"Failed to create issue: {e}")
542 return None
545def close_issue(
546 info: IssueInfo,
547 config: BRConfig,
548 logger: Logger,
549 close_reason: str | None,
550 close_status: str | None,
551 fix_commit: str | None = None,
552 files_changed: list[str] | None = None,
553 event_bus: EventBus | None = None,
554 interceptors: list[Any] | None = None,
555) -> bool:
556 """Close an issue by moving it to completed with closure status.
558 Used when ready-issue determines an issue should not be implemented
559 (e.g., already fixed, invalid, duplicate).
561 Args:
562 info: Issue info
563 config: Project configuration
564 logger: Logger for output
565 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
566 close_status: Status text (e.g., "Closed - Already Fixed")
567 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
568 files_changed: List of files modified by the fix (for regression tracking)
569 event_bus: Optional EventBus for event emission
570 interceptors: Optional list of interceptor objects; each may implement
571 ``before_issue_close(info) -> bool | None``. Returning ``False``
572 vetoes the close; ``None`` or any truthy value allows it to proceed.
574 Returns:
575 True if successful, False otherwise
576 """
577 completed_dir = config.get_completed_dir()
578 completed_dir.mkdir(parents=True, exist_ok=True)
580 original_path = info.path
581 completed_path = completed_dir / original_path.name
583 # Safety checks - handle stale state gracefully
584 if completed_path.exists():
585 logger.info(f"{info.issue_id} already in completed/ - cleaning up source")
586 if original_path.exists():
587 _cleanup_stale_source(original_path, info.issue_id, logger)
588 return True
590 if not original_path.exists():
591 logger.info(f"{info.issue_id} source already removed - nothing to close")
592 return True
594 # Use defaults if not provided
595 if not close_status:
596 close_status = "Closed - Invalid"
597 if not close_reason:
598 close_reason = "unknown"
600 logger.info(f"Closing {info.issue_id}: {close_status} (reason: {close_reason})")
602 # before_issue_close interceptors — veto check before any file I/O
603 if interceptors:
604 for interceptor in interceptors:
605 if hasattr(interceptor, "before_issue_close"):
606 result = interceptor.before_issue_close(info)
607 if result is False:
608 return False
610 try:
611 # Prepare content with resolution section
612 resolution = _build_closure_resolution(
613 close_status, close_reason, fix_commit, files_changed
614 )
615 content = _prepare_issue_content(original_path, resolution)
617 # Move to completed directory
618 _move_issue_to_completed(original_path, completed_path, content, logger)
620 # Commit the closure
621 commit_body = f"""{info.issue_id} - {close_status}
623Automated closure - issue determined to be invalid or already resolved.
625Issue: {info.issue_id}
626Reason: {close_reason}
627Status: {close_status}"""
628 _commit_issue_completion(info, "close", commit_body, logger)
630 logger.success(f"Closed {info.issue_id}: {close_status}")
631 if event_bus is not None:
632 event_bus.emit(
633 {
634 "event": "issue.closed",
635 "ts": _iso_now(),
636 "issue_id": info.issue_id,
637 "file_path": str(completed_path),
638 "close_reason": close_reason,
639 }
640 )
641 return True
643 except Exception as e:
644 logger.error(f"Failed to close {info.issue_id}: {e}")
645 return False
648def complete_issue_lifecycle(
649 info: IssueInfo,
650 config: BRConfig,
651 logger: Logger,
652 event_bus: EventBus | None = None,
653) -> bool:
654 """Fallback: Complete the issue lifecycle when command exited early.
656 This moves the issue to completed and adds a resolution section.
658 Args:
659 info: Issue info
660 config: Project configuration
661 logger: Logger for output
662 event_bus: Optional EventBus for event emission
664 Returns:
665 True if successful, False otherwise
666 """
667 completed_dir = config.get_completed_dir()
668 completed_dir.mkdir(parents=True, exist_ok=True)
670 original_path = info.path
671 completed_path = completed_dir / original_path.name
673 # Safety checks - handle stale state gracefully
674 if completed_path.exists():
675 logger.info(f"{info.issue_id} already in completed/ - cleaning up source")
676 if original_path.exists():
677 _cleanup_stale_source(original_path, info.issue_id, logger)
678 return True
680 if not original_path.exists():
681 logger.info(f"{info.issue_id} source already removed - nothing to complete")
682 return True
684 logger.info(f"Completing lifecycle for {info.issue_id} (command may have exited early)...")
686 try:
687 # Prepare content with resolution section
688 action = config.get_category_action(info.issue_type)
689 resolution = _build_completion_resolution(action)
690 content = _prepare_issue_content(original_path, resolution)
692 # Move to completed directory
693 _move_issue_to_completed(original_path, completed_path, content, logger)
694 append_session_log_entry(completed_path, "ll-auto")
696 # Commit the completion
697 commit_body = f"""implement {info.issue_id}
699Automated fallback commit - command exited before completion.
701Issue: {info.issue_id}
702Action: {action}
703Status: Completed via fallback lifecycle completion"""
704 _commit_issue_completion(info, action, commit_body, logger)
706 logger.success(f"Completed lifecycle for {info.issue_id}")
707 if event_bus is not None:
708 event_bus.emit(
709 {
710 "event": "issue.completed",
711 "ts": _iso_now(),
712 "issue_id": info.issue_id,
713 "file_path": str(completed_path),
714 }
715 )
716 return True
718 except Exception as e:
719 logger.error(f"Failed to complete lifecycle for {info.issue_id}: {e}")
720 return False
723# =============================================================================
724# Issue Deferral
725# =============================================================================
728def _build_deferred_section(reason: str) -> str:
729 """Build the ## Deferred section content."""
730 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
731 return f"""
733## Deferred
735- **Date**: {now}
736- **Reason**: {reason}
737"""
740def _build_undeferred_section(reason: str) -> str:
741 """Build the ## Undeferred section content."""
742 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
743 return f"""
745## Undeferred
747- **Date**: {now}
748- **Reason**: {reason}
749"""
752def defer_issue(
753 info: IssueInfo,
754 config: BRConfig,
755 logger: Logger,
756 reason: str | None = None,
757 event_bus: EventBus | None = None,
758) -> bool:
759 """Defer an issue by moving it from its active directory to deferred/.
761 Args:
762 info: Issue info
763 config: Project configuration
764 logger: Logger for output
765 reason: Reason for deferring
766 event_bus: Optional EventBus for event emission
768 Returns:
769 True if successful, False otherwise
770 """
771 deferred_dir = config.get_deferred_dir()
772 deferred_dir.mkdir(parents=True, exist_ok=True)
774 original_path = info.path
775 deferred_path = deferred_dir / original_path.name
777 # Safety checks
778 if deferred_path.exists():
779 logger.info(f"{info.issue_id} already in deferred/")
780 return True
782 if not original_path.exists():
783 logger.info(f"{info.issue_id} source not found - nothing to defer")
784 return True
786 if not reason:
787 reason = "Intentionally set aside for later consideration"
789 logger.info(f"Deferring {info.issue_id}: {reason}")
791 try:
792 # Prepare content with deferred section
793 deferred_section = _build_deferred_section(reason)
794 content = original_path.read_text(encoding="utf-8") + deferred_section
796 # Move to deferred directory (reuse the same move helper)
797 _move_issue_to_completed(original_path, deferred_path, content, logger)
799 # Commit the deferral
800 commit_body = f"""{info.issue_id} - Deferred
802Reason: {reason}"""
803 _commit_issue_completion(info, "defer", commit_body, logger)
805 logger.success(f"Deferred {info.issue_id}")
806 if event_bus is not None:
807 event_bus.emit(
808 {
809 "event": "issue.deferred",
810 "ts": _iso_now(),
811 "issue_id": info.issue_id,
812 "file_path": str(deferred_path),
813 "reason": reason,
814 }
815 )
816 return True
818 except Exception as e:
819 logger.error(f"Failed to defer {info.issue_id}: {e}")
820 return False
823# =============================================================================
824# Issue Skip (Deprioritize)
825# =============================================================================
828def _build_skip_section(reason: str | None) -> str:
829 """Build the ## Skip Log section content."""
830 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
831 reason_text = reason or "No reason provided"
832 return f"""
834## Skip Log
836- **Date**: {now}
837- **Reason**: {reason_text}
838"""
841def skip_issue(original_path: Path, new_path: Path, reason: str | None = None) -> None:
842 """Deprioritize an issue by renaming its priority prefix.
844 Appends a ``## Skip Log`` entry with ISO timestamp and optional reason,
845 then renames the file in-place (same directory, new priority prefix).
846 Prefers ``git mv`` for tracked files to preserve history; falls back to
847 an atomic ``Path.rename`` for untracked files.
849 Args:
850 original_path: Current path to the issue file
851 new_path: Target path (same directory, new priority prefix)
852 reason: Optional reason text for the Skip Log entry
854 Raises:
855 FileNotFoundError: If original_path does not exist
856 FileExistsError: If new_path already exists
857 """
858 if not original_path.exists():
859 raise FileNotFoundError(f"Issue file not found: {original_path}")
860 if new_path.exists():
861 raise FileExistsError(f"Target already exists: {new_path}")
863 content = original_path.read_text(encoding="utf-8") + _build_skip_section(reason)
865 if _is_git_tracked(original_path):
866 try:
867 result = subprocess.run(
868 ["git", "mv", str(original_path), str(new_path)],
869 capture_output=True,
870 text=True,
871 timeout=30,
872 )
873 except subprocess.TimeoutExpired:
874 result = None # type: ignore[assignment]
876 if result is None or result.returncode != 0:
877 # git mv failed — fall back to write + rename
878 original_path.write_text(content, encoding="utf-8")
879 original_path.rename(new_path)
880 else:
881 new_path.write_text(content, encoding="utf-8")
882 else:
883 # Not tracked — write updated content then rename atomically
884 original_path.write_text(content, encoding="utf-8")
885 original_path.rename(new_path)
888def undefer_issue(
889 config: BRConfig,
890 deferred_issue_path: Path,
891 logger: Logger,
892 reason: str | None = None,
893) -> Path | None:
894 """Move an issue from deferred/ back to its original category directory.
896 Args:
897 config: Project configuration
898 deferred_issue_path: Path to issue in deferred/
899 logger: Logger for output
900 reason: Reason for undeferring
902 Returns:
903 New path to undeferred issue, or None if failed
904 """
905 from little_loops.issue_discovery.search import _get_category_from_issue_path
907 if not deferred_issue_path.exists():
908 logger.error(f"Deferred issue not found: {deferred_issue_path}")
909 return None
911 # Determine target category directory from filename
912 category = _get_category_from_issue_path(deferred_issue_path, config)
913 target_dir = config.get_issue_dir(category)
914 target_dir.mkdir(parents=True, exist_ok=True)
916 target_path = target_dir / deferred_issue_path.name
918 # Safety check - don't overwrite existing active issue
919 if target_path.exists():
920 logger.warning(f"Active issue already exists: {target_path}")
921 return None
923 if not reason:
924 reason = "Ready to resume active work"
926 logger.info(f"Undeferring {deferred_issue_path.name} -> {category}/")
928 try:
929 content = deferred_issue_path.read_text(encoding="utf-8")
930 content += _build_undeferred_section(reason)
932 # Parse IssueInfo before git mv so the path still exists
933 info = IssueParser(config).parse_file(deferred_issue_path)
935 # Try git mv first for history preservation
936 result = subprocess.run(
937 ["git", "mv", str(deferred_issue_path), str(target_path)],
938 capture_output=True,
939 text=True,
940 )
942 if result.returncode != 0:
943 logger.warning(f"git mv failed, using manual copy: {result.stderr}")
944 target_path.write_text(content, encoding="utf-8")
945 deferred_issue_path.unlink()
946 else:
947 target_path.write_text(content, encoding="utf-8")
949 commit_body = f"""{info.issue_id} - Undeferred
951Reason: {reason}"""
952 _commit_issue_completion(info, "undefer", commit_body, logger)
954 logger.success(f"Undeferred: {target_path.name}")
955 return target_path
957 except Exception as e:
958 logger.error(f"Failed to undefer issue: {e}")
959 return None