Coverage for little_loops / issue_lifecycle.py: 21%
284 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:12 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:12 -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.file_utils import atomic_write
22from little_loops.frontmatter import parse_frontmatter, update_frontmatter
23from little_loops.issue_parser import IssueInfo, IssueParser, get_next_issue_number, slugify
24from little_loops.logger import Logger
25from little_loops.session_log import append_session_log_entry
28def _iso_now() -> str:
29 """Return current time as ISO 8601 string."""
30 return datetime.now(UTC).isoformat()
33def _completed_at_now() -> str:
34 """Return current UTC time as ISO 8601 with ``Z`` suffix for ``completed_at``."""
35 return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
38# =============================================================================
39# Failure Classification
40# =============================================================================
43class FailureType(Enum):
44 """Classification of command failure types.
46 Used to distinguish between transient errors that should not
47 create bug issues and real implementation failures that should.
48 """
50 TRANSIENT = "transient" # Temporary error, don't create issue
51 REAL = "real" # Actual bug/error, create issue
54def classify_failure(error_output: str, returncode: int) -> tuple[FailureType, str]:
55 """Classify a command failure as transient or real.
57 Examines error output for patterns indicating transient failures
58 (API quota, network errors, timeouts) vs real implementation failures.
60 Args:
61 error_output: stderr or stdout from failed command
62 returncode: Process exit code (available for future use)
64 Returns:
65 Tuple of (failure_type, reason) where reason explains the classification
66 """
67 error_lower = error_output.lower()
69 # API quota/rate limit patterns
70 quota_patterns = [
71 "out of extra usage",
72 "rate limit",
73 "quota exceeded",
74 "too many requests",
75 "api limit",
76 "usage limit",
77 "429", # HTTP Too Many Requests
78 "resource exhausted",
79 "resourceexhausted", # No space variant (gRPC style)
80 ]
81 if any(pattern in error_lower for pattern in quota_patterns):
82 return (FailureType.TRANSIENT, "API quota or rate limit exceeded")
84 # Network/connectivity patterns
85 # Note: Use word boundaries where needed to avoid false positives
86 # (e.g., "enotfound" shouldn't match "ModuleNotFoundError")
87 network_patterns = [
88 "connection refused",
89 "connection timeout",
90 "network error",
91 "dns resolution",
92 "connection reset",
93 "service unavailable",
94 "502 bad gateway",
95 "503 service unavailable",
96 "504 gateway timeout",
97 ]
98 if any(pattern in error_lower for pattern in network_patterns):
99 return (FailureType.TRANSIENT, "Network or connectivity error")
101 # Check for Node.js-style error codes with word boundary awareness
102 # These are typically at word boundaries (e.g., "Error: ECONNREFUSED")
103 if re.search(r"\beconnrefused\b", error_lower):
104 return (FailureType.TRANSIENT, "Network or connectivity error")
105 if re.search(r"\benotfound\b", error_lower):
106 return (FailureType.TRANSIENT, "Network or connectivity error")
107 if re.search(r"\betimedout\b", error_lower):
108 return (FailureType.TRANSIENT, "Network or connectivity error")
110 # Timeout patterns
111 timeout_patterns = [
112 "timeout",
113 "timed out",
114 "deadline exceeded",
115 "operation timed out",
116 ]
117 if any(pattern in error_lower for pattern in timeout_patterns):
118 return (FailureType.TRANSIENT, "Command timeout")
120 # Resource/system transient patterns
121 resource_patterns = [
122 "disk full",
123 "no space left",
124 "resource temporarily unavailable",
125 "too many open files",
126 "memory allocation failed",
127 "out of memory",
128 ]
129 if any(pattern in error_lower for pattern in resource_patterns):
130 return (FailureType.TRANSIENT, "System resource error")
132 # API server error patterns (distinct from rate-limits; trigger short-burst retry in executor)
133 server_error_patterns = [
134 "the server had an error",
135 "internal server error",
136 "overloaded_error",
137 "overloaded",
138 "529", # Anthropic overload HTTP code
139 "api error", # generic "API Error: ..." prefix from Claude Code
140 ]
141 if any(pattern in error_lower for pattern in server_error_patterns):
142 return (FailureType.TRANSIENT, "API server error")
144 # Context window exhaustion patterns — Claude CLI exits non-zero with this on stderr
145 context_patterns = [
146 "prompt is too long",
147 "context length exceeded",
148 "context window",
149 "maximum context",
150 ]
151 if any(pattern in error_lower for pattern in context_patterns):
152 return (FailureType.TRANSIENT, "Context window exhausted")
154 # CLI session continuation errors — --continue/--resume without a live session.
155 # Treated as transient so a failed Option E call does not produce phantom issues.
156 session_id_patterns = [
157 "requires a valid session id",
158 "requires a valid session title",
159 ]
160 if any(pattern in error_lower for pattern in session_id_patterns):
161 return (FailureType.TRANSIENT, "CLI session continuation error")
163 # Shell sandbox environment errors — the ll CLI never executed (exit 127 indicators)
164 sandbox_patterns = [
165 "command not found",
166 "read-only variable",
167 ]
168 if any(pattern in error_lower for pattern in sandbox_patterns):
169 return (FailureType.TRANSIENT, "Shell sandbox environment error")
171 # Process killed by OS (SIGKILL/OOM kill) — exit 137 text signal
172 if re.search(r"\bkilled\b", error_lower):
173 return (FailureType.TRANSIENT, "Process killed (OOM/SIGKILL)")
175 # User-cancelled tool calls — not a defect
176 if "<tool_use_error>" in error_output:
177 return (FailureType.TRANSIENT, "User-cancelled tool call")
179 # Ad-hoc Python snippet tracebacks — not from an ll CLI
180 if 'file "<string>"' in error_lower or 'file "<stdin>"' in error_lower:
181 return (FailureType.TRANSIENT, "Ad-hoc Python snippet error, not an ll CLI failure")
183 # Default: treat as real failure
184 return (FailureType.REAL, "Implementation error")
187# =============================================================================
188# Content Manipulation Helpers
189# =============================================================================
192def _build_closure_resolution(
193 close_status: str,
194 close_reason: str,
195 fix_commit: str | None = None,
196 files_changed: list[str] | None = None,
197) -> str:
198 """Build resolution section for closed issues.
200 Args:
201 close_status: Status text (e.g., "Closed - Already Fixed")
202 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
203 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
204 files_changed: List of files modified by the fix (for regression tracking)
206 Returns:
207 Resolution section markdown string
208 """
209 # Build fix commit line
210 fix_commit_line = f"- **Fix Commit**: {fix_commit}\n" if fix_commit else ""
212 # Build files changed section
213 if files_changed:
214 files_list = "\n".join(f" - `{f}`" for f in files_changed)
215 files_section = f"""
216### Files Changed
217{files_list}
218"""
219 else:
220 files_section = ""
222 return f"""
224---
226## Resolution
228- **Status**: {close_status}
229- **Closed**: {datetime.now().strftime("%Y-%m-%d")}
230- **Reason**: {close_reason}
231- **Closure**: Automated (ready-issue validation)
232{fix_commit_line}
233### Closure Notes
234Issue was automatically closed during validation.
235The issue was determined to be invalid, already resolved, or not actionable.
236{files_section}"""
239def _build_completion_resolution(
240 action: str,
241 fix_commit: str | None = None,
242 files_changed: list[str] | None = None,
243) -> str:
244 """Build resolution section for completed issues.
246 Args:
247 action: Action verb (e.g., "fix", "implement")
248 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
249 files_changed: List of files modified by the fix (for regression tracking)
251 Returns:
252 Resolution section markdown string
253 """
254 # Build fix commit line
255 fix_commit_line = f"- **Fix Commit**: {fix_commit}" if fix_commit else ""
257 # Build files changed section
258 if files_changed:
259 files_list = "\n".join(f" - `{f}`" for f in files_changed)
260 files_section = f"""
261### Files Changed
262{files_list}"""
263 else:
264 files_section = """
265### Files Changed
266- See git history for details"""
268 return f"""
270---
272## Resolution
274- **Action**: {action}
275- **Completed**: {datetime.now().strftime("%Y-%m-%d")}
276- **Status**: Completed (automated fallback)
277- **Implementation**: Command exited early but issue was addressed
278{fix_commit_line}
279{files_section}
281### Verification Results
282- Automated verification passed
284### Commits
285- See git log for details
286"""
289def _prepare_issue_content(original_path: Path, resolution: str) -> str:
290 """Read issue file and append resolution section if needed.
292 Args:
293 original_path: Path to the original issue file
294 resolution: Resolution section to append
296 Returns:
297 Updated file content with resolution section
298 """
299 content = original_path.read_text()
300 if "## Resolution" not in content:
301 content += resolution
302 return content
305# =============================================================================
306# Git Operations Helpers
307# =============================================================================
310def _is_git_tracked(file_path: Path) -> bool:
311 """Check if a file is under git version control.
313 Args:
314 file_path: Path to the file to check
316 Returns:
317 True if file is tracked by git, False otherwise
318 """
319 try:
320 result = subprocess.run(
321 ["git", "ls-files", str(file_path)],
322 capture_output=True,
323 text=True,
324 timeout=30,
325 )
326 except subprocess.TimeoutExpired:
327 return False
328 return bool(result.stdout.strip())
331def _commit_issue_completion(
332 info: IssueInfo,
333 commit_prefix: str,
334 commit_body: str,
335 logger: Logger,
336) -> bool:
337 """Stage all changes and create completion commit.
339 Args:
340 info: Issue information
341 commit_prefix: Prefix for commit message (e.g., "close" or action verb)
342 commit_body: Body text for commit message
343 logger: Logger for output
345 Returns:
346 True if commit succeeded or nothing to commit
347 """
348 # Stage all changes
349 try:
350 stage_result = subprocess.run(
351 ["git", "add", "-A"],
352 capture_output=True,
353 text=True,
354 timeout=30,
355 )
356 if stage_result.returncode != 0:
357 logger.warning(f"git add failed: {stage_result.stderr}")
358 except subprocess.TimeoutExpired:
359 logger.warning("git add timed out")
361 # Create commit
362 commit_msg = f"{commit_prefix}({info.issue_type}): {commit_body}"
363 try:
364 commit_result = subprocess.run(
365 ["git", "commit", "-m", commit_msg],
366 capture_output=True,
367 text=True,
368 timeout=30,
369 )
370 except subprocess.TimeoutExpired:
371 logger.warning("git commit timed out")
372 return True
374 if commit_result.returncode != 0:
375 if "nothing to commit" in commit_result.stdout.lower():
376 logger.info("No changes to commit (already committed)")
377 else:
378 logger.warning(f"git commit failed: {commit_result.stderr}")
379 else:
380 commit_hash_match = re.search(r"\[[\w-]+\s+([a-f0-9]+)\]", commit_result.stdout)
381 if commit_hash_match:
382 logger.success(f"Committed: {commit_hash_match.group(1)}")
383 else:
384 logger.success("Committed changes")
386 return True
389def verify_issue_completed(info: IssueInfo, config: BRConfig, logger: Logger) -> bool:
390 """Verify that an issue was marked as completed via frontmatter.
392 Reads the issue file's ``status:`` frontmatter; ``done`` (or ``cancelled``)
393 means the close path ran successfully. Files no longer move on completion,
394 so this is a pure frontmatter check.
396 Args:
397 info: Issue info
398 config: Project configuration (unused; kept for signature stability)
399 logger: Logger for output
401 Returns:
402 True if issue's frontmatter shows it is done/cancelled
403 """
404 path = info.path
405 if not path.exists():
406 # Source removed without lifecycle update — treat as completed for back-compat
407 # with any external scripts that delete files manually.
408 logger.warning(f"Warning: {info.issue_id} source not found at {path}")
409 return True
411 try:
412 fm = parse_frontmatter(path.read_text(encoding="utf-8"))
413 except Exception as e:
414 logger.warning(f"Warning: failed to read {info.issue_id} frontmatter: {e}")
415 return False
417 status = fm.get("status", "open")
418 if status in ("done", "cancelled"):
419 logger.success(f"Verified: {info.issue_id} status={status}")
420 return True
422 logger.warning(f"Warning: {info.issue_id} status={status} (expected done/cancelled)")
423 return False
426def create_issue_from_failure(
427 error_output: str,
428 parent_info: IssueInfo,
429 config: BRConfig,
430 logger: Logger,
431 event_bus: EventBus | None = None,
432) -> Path | None:
433 """Create a new bug issue file when implementation fails.
435 Args:
436 error_output: Error output from the failed command
437 parent_info: Info about the issue that failed
438 config: Project configuration
439 logger: Logger for output
440 event_bus: Optional EventBus for event emission
442 Returns:
443 Path to new issue file, or None if creation failed
444 """
445 bug_num = get_next_issue_number(config, "bugs")
446 prefix = config.get_issue_prefix("bugs")
447 bug_id = f"{prefix}-{bug_num:03d}"
449 # Try to extract meaningful error info
450 error_lines = error_output.split("\n")[:20] # First 20 lines
451 traceback = "\n".join(error_lines)
453 # Generate title from error
454 title = f"Implementation failure in {parent_info.issue_id}"
455 if "Error" in error_output:
456 error_match = re.search(r"([A-Z]\w+Error[:\s]+[^\n]+)", error_output)
457 if error_match:
458 title = error_match.group(1)
459 title_slug = slugify(title)
461 filename = f"P1-{bug_id}-{title_slug}.md"
462 bugs_dir = config.get_issue_dir("bugs")
463 new_issue_path = bugs_dir / filename
465 captured_at_val = _completed_at_now()
466 content = f"""---
467id: {bug_id}
468type: BUG
469priority: P1
470status: open
471captured_at: {captured_at_val}
472discovered_by: auto-generated
473---
475# {bug_id}: Implementation Failure - {parent_info.issue_id}
477## Summary
478Issue encountered during automated implementation of {parent_info.issue_id}.
480## Current Behavior
481```
482{traceback}
483```
485## Expected Behavior
486Implementation should complete without errors.
488## Root Cause
489Discovered during automated processing of `{parent_info.path}`.
491## Steps to Reproduce
4921. Run: `/ll:manage-issue {parent_info.issue_type} fix {parent_info.issue_id}`
4932. Observe error
495## Proposed Solution
496Investigate the error output above and address the root cause.
498## Impact
499- **Severity**: High
500- **Effort**: Unknown
501- **Risk**: Medium
502- **Breaking Change**: No
504## Labels
505`bug`, `high-priority`, `auto-generated`, `implementation-failure`
507---
509## Status
510**Open** | Created: {_iso_now()} | Priority: P1
512## Related Issues
513- [{parent_info.issue_id}]({parent_info.path})
514"""
516 try:
517 bugs_dir.mkdir(parents=True, exist_ok=True)
518 new_issue_path.write_text(content)
519 logger.success(f"Created new issue: {new_issue_path}")
520 if event_bus is not None:
521 event_bus.emit(
522 {
523 "event": "issue.failure_captured",
524 "ts": _iso_now(),
525 "issue_id": bug_id,
526 "file_path": str(new_issue_path),
527 "parent_issue_id": parent_info.issue_id,
528 "captured_at": captured_at_val,
529 }
530 )
531 return new_issue_path
532 except Exception as e:
533 logger.error(f"Failed to create issue: {e}")
534 return None
537def close_issue(
538 info: IssueInfo,
539 config: BRConfig,
540 logger: Logger,
541 close_reason: str | None,
542 close_status: str | None,
543 fix_commit: str | None = None,
544 files_changed: list[str] | None = None,
545 event_bus: EventBus | None = None,
546 interceptors: list[Any] | None = None,
547) -> bool:
548 """Close an issue by moving it to completed with closure status.
550 Used when ready-issue determines an issue should not be implemented
551 (e.g., already fixed, invalid, duplicate).
553 Args:
554 info: Issue info
555 config: Project configuration
556 logger: Logger for output
557 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
558 close_status: Status text (e.g., "Closed - Already Fixed")
559 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
560 files_changed: List of files modified by the fix (for regression tracking)
561 event_bus: Optional EventBus for event emission
562 interceptors: Optional list of interceptor objects; each may implement
563 ``before_issue_close(info) -> bool | None``. Returning ``False``
564 vetoes the close; ``None`` or any truthy value allows it to proceed.
566 Returns:
567 True if successful, False otherwise
568 """
569 original_path = info.path
571 if not original_path.exists():
572 logger.info(f"{info.issue_id} source already removed - nothing to close")
573 return True
575 # Use defaults if not provided
576 if not close_status:
577 close_status = "Closed - Invalid"
578 if not close_reason:
579 close_reason = "unknown"
581 logger.info(f"Closing {info.issue_id}: {close_status} (reason: {close_reason})")
583 # before_issue_close interceptors — veto check before any file I/O
584 if interceptors:
585 for interceptor in interceptors:
586 if hasattr(interceptor, "before_issue_close"):
587 result = interceptor.before_issue_close(info)
588 if result is False:
589 return False
591 try:
592 captured_at = parse_frontmatter(original_path.read_text(encoding="utf-8")).get(
593 "captured_at"
594 )
595 # Prepare content with resolution section, then write status + completed_at
596 resolution = _build_closure_resolution(
597 close_status, close_reason, fix_commit, files_changed
598 )
599 content = _prepare_issue_content(original_path, resolution)
600 content = update_frontmatter(
601 content,
602 {"status": "done", "completed_at": _completed_at_now()},
603 )
604 original_path.write_text(content, encoding="utf-8")
606 # Commit the closure
607 commit_body = f"""{info.issue_id} - {close_status}
609Automated closure - issue determined to be invalid or already resolved.
611Issue: {info.issue_id}
612Reason: {close_reason}
613Status: {close_status}"""
614 _commit_issue_completion(info, "close", commit_body, logger)
616 logger.success(f"Closed {info.issue_id}: {close_status}")
617 if event_bus is not None:
618 event_bus.emit(
619 {
620 "event": "issue.closed",
621 "ts": _iso_now(),
622 "issue_id": info.issue_id,
623 "file_path": str(original_path),
624 "close_reason": close_reason,
625 "captured_at": captured_at,
626 }
627 )
628 return True
630 except Exception as e:
631 logger.error(f"Failed to close {info.issue_id}: {e}")
632 return False
635def complete_issue_lifecycle(
636 info: IssueInfo,
637 config: BRConfig,
638 logger: Logger,
639 event_bus: EventBus | None = None,
640) -> bool:
641 """Fallback: Complete the issue lifecycle when command exited early.
643 This moves the issue to completed and adds a resolution section.
645 Args:
646 info: Issue info
647 config: Project configuration
648 logger: Logger for output
649 event_bus: Optional EventBus for event emission
651 Returns:
652 True if successful, False otherwise
653 """
654 original_path = info.path
656 if not original_path.exists():
657 logger.info(f"{info.issue_id} source already removed - nothing to complete")
658 return True
660 logger.info(f"Completing lifecycle for {info.issue_id} (command may have exited early)...")
662 try:
663 captured_at = parse_frontmatter(original_path.read_text(encoding="utf-8")).get(
664 "captured_at"
665 )
666 # Prepare content with resolution section, then write status + completed_at
667 action = config.get_category_action(info.issue_type)
668 resolution = _build_completion_resolution(action)
669 content = _prepare_issue_content(original_path, resolution)
670 content = update_frontmatter(
671 content,
672 {"status": "done", "completed_at": _completed_at_now()},
673 )
674 original_path.write_text(content, encoding="utf-8")
675 append_session_log_entry(original_path, "ll-auto")
677 # Commit the completion
678 commit_body = f"""implement {info.issue_id}
680Automated fallback commit - command exited before completion.
682Issue: {info.issue_id}
683Action: {action}
684Status: Completed via fallback lifecycle completion"""
685 _commit_issue_completion(info, action, commit_body, logger)
687 logger.success(f"Completed lifecycle for {info.issue_id}")
688 if event_bus is not None:
689 event_bus.emit(
690 {
691 "event": "issue.completed",
692 "ts": _iso_now(),
693 "issue_id": info.issue_id,
694 "file_path": str(original_path),
695 "captured_at": captured_at,
696 }
697 )
698 return True
700 except Exception as e:
701 logger.error(f"Failed to complete lifecycle for {info.issue_id}: {e}")
702 return False
705# =============================================================================
706# Issue Deferral
707# =============================================================================
710def _build_deferred_section(reason: str) -> str:
711 """Build the ## Deferred section content."""
712 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
713 return f"""
715## Deferred
717- **Date**: {now}
718- **Reason**: {reason}
719"""
722def _build_undeferred_section(reason: str) -> str:
723 """Build the ## Undeferred section content."""
724 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
725 return f"""
727## Undeferred
729- **Date**: {now}
730- **Reason**: {reason}
731"""
734def defer_issue(
735 info: IssueInfo,
736 config: BRConfig,
737 logger: Logger,
738 reason: str | None = None,
739 event_bus: EventBus | None = None,
740) -> bool:
741 """Defer an issue by writing ``status: deferred`` to its frontmatter.
743 The file remains in its type directory; only the ``status:`` field changes.
745 Args:
746 info: Issue info
747 config: Project configuration (unused; kept for signature stability)
748 logger: Logger for output
749 reason: Reason for deferring
750 event_bus: Optional EventBus for event emission
752 Returns:
753 True if successful, False otherwise
754 """
755 original_path = info.path
757 if not original_path.exists():
758 logger.info(f"{info.issue_id} source not found - nothing to defer")
759 return True
761 if not reason:
762 reason = "Intentionally set aside for later consideration"
764 logger.info(f"Deferring {info.issue_id}: {reason}")
766 try:
767 deferred_section = _build_deferred_section(reason)
768 raw_content = original_path.read_text(encoding="utf-8")
769 captured_at = parse_frontmatter(raw_content).get("captured_at")
770 content = raw_content + deferred_section
771 content = update_frontmatter(content, {"status": "deferred"})
772 original_path.write_text(content, encoding="utf-8")
774 commit_body = f"""{info.issue_id} - Deferred
776Reason: {reason}"""
777 _commit_issue_completion(info, "defer", commit_body, logger)
779 logger.success(f"Deferred {info.issue_id}")
780 if event_bus is not None:
781 event_bus.emit(
782 {
783 "event": "issue.deferred",
784 "ts": _iso_now(),
785 "issue_id": info.issue_id,
786 "file_path": str(original_path),
787 "reason": reason,
788 "captured_at": captured_at,
789 }
790 )
791 return True
793 except Exception as e:
794 logger.error(f"Failed to defer {info.issue_id}: {e}")
795 return False
798# =============================================================================
799# Issue Skip (Deprioritize)
800# =============================================================================
803def _build_skip_section(reason: str | None) -> str:
804 """Build the ## Skip Log section content."""
805 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
806 reason_text = reason or "No reason provided"
807 return f"""
809## Skip Log
811- **Date**: {now}
812- **Reason**: {reason_text}
813"""
816def skip_issue(
817 original_path: Path,
818 new_path: Path,
819 reason: str | None = None,
820 event_bus: EventBus | None = None,
821) -> None:
822 """Deprioritize an issue by renaming its priority prefix.
824 Appends a ``## Skip Log`` entry with ISO timestamp and optional reason,
825 then renames the file in-place (same directory, new priority prefix).
826 Prefers ``git mv`` for tracked files to preserve history; falls back to
827 an atomic ``Path.rename`` for untracked files.
829 Args:
830 original_path: Current path to the issue file
831 new_path: Target path (same directory, new priority prefix)
832 reason: Optional reason text for the Skip Log entry
833 event_bus: Optional EventBus for emitting ``issue.skipped``
835 Raises:
836 FileNotFoundError: If original_path does not exist
837 FileExistsError: If new_path already exists
838 """
839 if not original_path.exists():
840 raise FileNotFoundError(f"Issue file not found: {original_path}")
841 if new_path.exists():
842 raise FileExistsError(f"Target already exists: {new_path}")
844 raw_content = original_path.read_text(encoding="utf-8")
845 captured_at = parse_frontmatter(raw_content).get("captured_at")
846 content = raw_content + _build_skip_section(reason)
848 if _is_git_tracked(original_path):
849 try:
850 result = subprocess.run(
851 ["git", "mv", str(original_path), str(new_path)],
852 capture_output=True,
853 text=True,
854 timeout=30,
855 )
856 except subprocess.TimeoutExpired:
857 result = None # type: ignore[assignment]
859 if result is None or result.returncode != 0:
860 # git mv failed — fall back to write + rename
861 atomic_write(original_path, content, encoding="utf-8")
862 original_path.rename(new_path)
863 else:
864 atomic_write(new_path, content, encoding="utf-8")
865 else:
866 # Not tracked — write updated content then rename atomically
867 atomic_write(original_path, content, encoding="utf-8")
868 original_path.rename(new_path)
870 if event_bus is not None:
871 m = re.match(r"P\d+-([A-Z]+-\d+)-", new_path.name)
872 issue_id = m.group(1) if m else str(new_path.stem)
873 event_bus.emit(
874 {
875 "event": "issue.skipped",
876 "ts": _iso_now(),
877 "issue_id": issue_id,
878 "file_path": str(new_path),
879 "reason": reason,
880 "captured_at": captured_at,
881 }
882 )
885def undefer_issue(
886 config: BRConfig,
887 deferred_issue_path: Path,
888 logger: Logger,
889 reason: str | None = None,
890 event_bus: EventBus | None = None,
891) -> Path | None:
892 """Undefer an issue by writing ``status: open`` to its frontmatter.
894 The file remains where it is (in its type directory); only the ``status:``
895 field is updated.
897 Args:
898 config: Project configuration
899 deferred_issue_path: Path to deferred issue (still in its type dir)
900 logger: Logger for output
901 reason: Reason for undeferring
903 Returns:
904 Path to undeferred issue, or None if failed
905 """
906 if not deferred_issue_path.exists():
907 logger.error(f"Deferred issue not found: {deferred_issue_path}")
908 return None
910 if not reason:
911 reason = "Ready to resume active work"
913 logger.info(f"Undeferring {deferred_issue_path.name}")
915 try:
916 info = IssueParser(config).parse_file(deferred_issue_path)
918 content = deferred_issue_path.read_text(encoding="utf-8")
919 captured_at = parse_frontmatter(content).get("captured_at")
920 content += _build_undeferred_section(reason)
921 content = update_frontmatter(content, {"status": "open"})
922 deferred_issue_path.write_text(content, encoding="utf-8")
924 commit_body = f"""{info.issue_id} - Undeferred
926Reason: {reason}"""
927 _commit_issue_completion(info, "undefer", commit_body, logger)
929 logger.success(f"Undeferred: {deferred_issue_path.name}")
930 if event_bus is not None:
931 event_bus.emit(
932 {
933 "event": "issue.started",
934 "ts": _iso_now(),
935 "issue_id": info.issue_id,
936 "file_path": str(deferred_issue_path),
937 "reason": reason,
938 "captured_at": captured_at,
939 }
940 )
941 return deferred_issue_path
943 except Exception as e:
944 logger.error(f"Failed to undefer issue: {e}")
945 return None