Coverage for little_loops / issue_lifecycle.py: 20%
275 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -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 # Default: treat as real failure
164 return (FailureType.REAL, "Implementation error")
167# =============================================================================
168# Content Manipulation Helpers
169# =============================================================================
172def _build_closure_resolution(
173 close_status: str,
174 close_reason: str,
175 fix_commit: str | None = None,
176 files_changed: list[str] | None = None,
177) -> str:
178 """Build resolution section for closed issues.
180 Args:
181 close_status: Status text (e.g., "Closed - Already Fixed")
182 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
183 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
184 files_changed: List of files modified by the fix (for regression tracking)
186 Returns:
187 Resolution section markdown string
188 """
189 # Build fix commit line
190 fix_commit_line = f"- **Fix Commit**: {fix_commit}\n" if fix_commit else ""
192 # Build files changed section
193 if files_changed:
194 files_list = "\n".join(f" - `{f}`" for f in files_changed)
195 files_section = f"""
196### Files Changed
197{files_list}
198"""
199 else:
200 files_section = ""
202 return f"""
204---
206## Resolution
208- **Status**: {close_status}
209- **Closed**: {datetime.now().strftime("%Y-%m-%d")}
210- **Reason**: {close_reason}
211- **Closure**: Automated (ready-issue validation)
212{fix_commit_line}
213### Closure Notes
214Issue was automatically closed during validation.
215The issue was determined to be invalid, already resolved, or not actionable.
216{files_section}"""
219def _build_completion_resolution(
220 action: str,
221 fix_commit: str | None = None,
222 files_changed: list[str] | None = None,
223) -> str:
224 """Build resolution section for completed issues.
226 Args:
227 action: Action verb (e.g., "fix", "implement")
228 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
229 files_changed: List of files modified by the fix (for regression tracking)
231 Returns:
232 Resolution section markdown string
233 """
234 # Build fix commit line
235 fix_commit_line = f"- **Fix Commit**: {fix_commit}" if fix_commit else ""
237 # Build files changed section
238 if files_changed:
239 files_list = "\n".join(f" - `{f}`" for f in files_changed)
240 files_section = f"""
241### Files Changed
242{files_list}"""
243 else:
244 files_section = """
245### Files Changed
246- See git history for details"""
248 return f"""
250---
252## Resolution
254- **Action**: {action}
255- **Completed**: {datetime.now().strftime("%Y-%m-%d")}
256- **Status**: Completed (automated fallback)
257- **Implementation**: Command exited early but issue was addressed
258{fix_commit_line}
259{files_section}
261### Verification Results
262- Automated verification passed
264### Commits
265- See git log for details
266"""
269def _prepare_issue_content(original_path: Path, resolution: str) -> str:
270 """Read issue file and append resolution section if needed.
272 Args:
273 original_path: Path to the original issue file
274 resolution: Resolution section to append
276 Returns:
277 Updated file content with resolution section
278 """
279 content = original_path.read_text()
280 if "## Resolution" not in content:
281 content += resolution
282 return content
285# =============================================================================
286# Git Operations Helpers
287# =============================================================================
290def _is_git_tracked(file_path: Path) -> bool:
291 """Check if a file is under git version control.
293 Args:
294 file_path: Path to the file to check
296 Returns:
297 True if file is tracked by git, False otherwise
298 """
299 try:
300 result = subprocess.run(
301 ["git", "ls-files", str(file_path)],
302 capture_output=True,
303 text=True,
304 timeout=30,
305 )
306 except subprocess.TimeoutExpired:
307 return False
308 return bool(result.stdout.strip())
311def _commit_issue_completion(
312 info: IssueInfo,
313 commit_prefix: str,
314 commit_body: str,
315 logger: Logger,
316) -> bool:
317 """Stage all changes and create completion commit.
319 Args:
320 info: Issue information
321 commit_prefix: Prefix for commit message (e.g., "close" or action verb)
322 commit_body: Body text for commit message
323 logger: Logger for output
325 Returns:
326 True if commit succeeded or nothing to commit
327 """
328 # Stage all changes
329 try:
330 stage_result = subprocess.run(
331 ["git", "add", "-A"],
332 capture_output=True,
333 text=True,
334 timeout=30,
335 )
336 if stage_result.returncode != 0:
337 logger.warning(f"git add failed: {stage_result.stderr}")
338 except subprocess.TimeoutExpired:
339 logger.warning("git add timed out")
341 # Create commit
342 commit_msg = f"{commit_prefix}({info.issue_type}): {commit_body}"
343 try:
344 commit_result = subprocess.run(
345 ["git", "commit", "-m", commit_msg],
346 capture_output=True,
347 text=True,
348 timeout=30,
349 )
350 except subprocess.TimeoutExpired:
351 logger.warning("git commit timed out")
352 return True
354 if commit_result.returncode != 0:
355 if "nothing to commit" in commit_result.stdout.lower():
356 logger.info("No changes to commit (already committed)")
357 else:
358 logger.warning(f"git commit failed: {commit_result.stderr}")
359 else:
360 commit_hash_match = re.search(r"\[[\w-]+\s+([a-f0-9]+)\]", commit_result.stdout)
361 if commit_hash_match:
362 logger.success(f"Committed: {commit_hash_match.group(1)}")
363 else:
364 logger.success("Committed changes")
366 return True
369def verify_issue_completed(info: IssueInfo, config: BRConfig, logger: Logger) -> bool:
370 """Verify that an issue was marked as completed via frontmatter.
372 Reads the issue file's ``status:`` frontmatter; ``done`` (or ``cancelled``)
373 means the close path ran successfully. Files no longer move on completion,
374 so this is a pure frontmatter check.
376 Args:
377 info: Issue info
378 config: Project configuration (unused; kept for signature stability)
379 logger: Logger for output
381 Returns:
382 True if issue's frontmatter shows it is done/cancelled
383 """
384 path = info.path
385 if not path.exists():
386 # Source removed without lifecycle update — treat as completed for back-compat
387 # with any external scripts that delete files manually.
388 logger.warning(f"Warning: {info.issue_id} source not found at {path}")
389 return True
391 try:
392 fm = parse_frontmatter(path.read_text(encoding="utf-8"))
393 except Exception as e:
394 logger.warning(f"Warning: failed to read {info.issue_id} frontmatter: {e}")
395 return False
397 status = fm.get("status", "open")
398 if status in ("done", "cancelled"):
399 logger.success(f"Verified: {info.issue_id} status={status}")
400 return True
402 logger.warning(f"Warning: {info.issue_id} status={status} (expected done/cancelled)")
403 return False
406def create_issue_from_failure(
407 error_output: str,
408 parent_info: IssueInfo,
409 config: BRConfig,
410 logger: Logger,
411 event_bus: EventBus | None = None,
412) -> Path | None:
413 """Create a new bug issue file when implementation fails.
415 Args:
416 error_output: Error output from the failed command
417 parent_info: Info about the issue that failed
418 config: Project configuration
419 logger: Logger for output
420 event_bus: Optional EventBus for event emission
422 Returns:
423 Path to new issue file, or None if creation failed
424 """
425 bug_num = get_next_issue_number(config, "bugs")
426 prefix = config.get_issue_prefix("bugs")
427 bug_id = f"{prefix}-{bug_num:03d}"
429 # Try to extract meaningful error info
430 error_lines = error_output.split("\n")[:20] # First 20 lines
431 traceback = "\n".join(error_lines)
433 # Generate title from error
434 title = f"Implementation failure in {parent_info.issue_id}"
435 if "Error" in error_output:
436 error_match = re.search(r"([A-Z]\w+Error[:\s]+[^\n]+)", error_output)
437 if error_match:
438 title = error_match.group(1)
439 title_slug = slugify(title)
441 filename = f"P1-{bug_id}-{title_slug}.md"
442 bugs_dir = config.get_issue_dir("bugs")
443 new_issue_path = bugs_dir / filename
445 captured_at_val = _completed_at_now()
446 content = f"""---
447id: {bug_id}
448type: BUG
449priority: P1
450status: open
451captured_at: {captured_at_val}
452discovered_by: auto-generated
453---
455# {bug_id}: Implementation Failure - {parent_info.issue_id}
457## Summary
458Issue encountered during automated implementation of {parent_info.issue_id}.
460## Current Behavior
461```
462{traceback}
463```
465## Expected Behavior
466Implementation should complete without errors.
468## Root Cause
469Discovered during automated processing of `{parent_info.path}`.
471## Steps to Reproduce
4721. Run: `/ll:manage-issue {parent_info.issue_type} fix {parent_info.issue_id}`
4732. Observe error
475## Proposed Solution
476Investigate the error output above and address the root cause.
478## Impact
479- **Severity**: High
480- **Effort**: Unknown
481- **Risk**: Medium
482- **Breaking Change**: No
484## Labels
485`bug`, `high-priority`, `auto-generated`, `implementation-failure`
487---
489## Status
490**Open** | Created: {_iso_now()} | Priority: P1
492## Related Issues
493- [{parent_info.issue_id}]({parent_info.path})
494"""
496 try:
497 bugs_dir.mkdir(parents=True, exist_ok=True)
498 new_issue_path.write_text(content)
499 logger.success(f"Created new issue: {new_issue_path}")
500 if event_bus is not None:
501 event_bus.emit(
502 {
503 "event": "issue.failure_captured",
504 "ts": _iso_now(),
505 "issue_id": bug_id,
506 "file_path": str(new_issue_path),
507 "parent_issue_id": parent_info.issue_id,
508 "captured_at": captured_at_val,
509 }
510 )
511 return new_issue_path
512 except Exception as e:
513 logger.error(f"Failed to create issue: {e}")
514 return None
517def close_issue(
518 info: IssueInfo,
519 config: BRConfig,
520 logger: Logger,
521 close_reason: str | None,
522 close_status: str | None,
523 fix_commit: str | None = None,
524 files_changed: list[str] | None = None,
525 event_bus: EventBus | None = None,
526 interceptors: list[Any] | None = None,
527) -> bool:
528 """Close an issue by moving it to completed with closure status.
530 Used when ready-issue determines an issue should not be implemented
531 (e.g., already fixed, invalid, duplicate).
533 Args:
534 info: Issue info
535 config: Project configuration
536 logger: Logger for output
537 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
538 close_status: Status text (e.g., "Closed - Already Fixed")
539 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
540 files_changed: List of files modified by the fix (for regression tracking)
541 event_bus: Optional EventBus for event emission
542 interceptors: Optional list of interceptor objects; each may implement
543 ``before_issue_close(info) -> bool | None``. Returning ``False``
544 vetoes the close; ``None`` or any truthy value allows it to proceed.
546 Returns:
547 True if successful, False otherwise
548 """
549 original_path = info.path
551 if not original_path.exists():
552 logger.info(f"{info.issue_id} source already removed - nothing to close")
553 return True
555 # Use defaults if not provided
556 if not close_status:
557 close_status = "Closed - Invalid"
558 if not close_reason:
559 close_reason = "unknown"
561 logger.info(f"Closing {info.issue_id}: {close_status} (reason: {close_reason})")
563 # before_issue_close interceptors — veto check before any file I/O
564 if interceptors:
565 for interceptor in interceptors:
566 if hasattr(interceptor, "before_issue_close"):
567 result = interceptor.before_issue_close(info)
568 if result is False:
569 return False
571 try:
572 captured_at = parse_frontmatter(original_path.read_text(encoding="utf-8")).get(
573 "captured_at"
574 )
575 # Prepare content with resolution section, then write status + completed_at
576 resolution = _build_closure_resolution(
577 close_status, close_reason, fix_commit, files_changed
578 )
579 content = _prepare_issue_content(original_path, resolution)
580 content = update_frontmatter(
581 content,
582 {"status": "done", "completed_at": _completed_at_now()},
583 )
584 original_path.write_text(content, encoding="utf-8")
586 # Commit the closure
587 commit_body = f"""{info.issue_id} - {close_status}
589Automated closure - issue determined to be invalid or already resolved.
591Issue: {info.issue_id}
592Reason: {close_reason}
593Status: {close_status}"""
594 _commit_issue_completion(info, "close", commit_body, logger)
596 logger.success(f"Closed {info.issue_id}: {close_status}")
597 if event_bus is not None:
598 event_bus.emit(
599 {
600 "event": "issue.closed",
601 "ts": _iso_now(),
602 "issue_id": info.issue_id,
603 "file_path": str(original_path),
604 "close_reason": close_reason,
605 "captured_at": captured_at,
606 }
607 )
608 return True
610 except Exception as e:
611 logger.error(f"Failed to close {info.issue_id}: {e}")
612 return False
615def complete_issue_lifecycle(
616 info: IssueInfo,
617 config: BRConfig,
618 logger: Logger,
619 event_bus: EventBus | None = None,
620) -> bool:
621 """Fallback: Complete the issue lifecycle when command exited early.
623 This moves the issue to completed and adds a resolution section.
625 Args:
626 info: Issue info
627 config: Project configuration
628 logger: Logger for output
629 event_bus: Optional EventBus for event emission
631 Returns:
632 True if successful, False otherwise
633 """
634 original_path = info.path
636 if not original_path.exists():
637 logger.info(f"{info.issue_id} source already removed - nothing to complete")
638 return True
640 logger.info(f"Completing lifecycle for {info.issue_id} (command may have exited early)...")
642 try:
643 captured_at = parse_frontmatter(original_path.read_text(encoding="utf-8")).get(
644 "captured_at"
645 )
646 # Prepare content with resolution section, then write status + completed_at
647 action = config.get_category_action(info.issue_type)
648 resolution = _build_completion_resolution(action)
649 content = _prepare_issue_content(original_path, resolution)
650 content = update_frontmatter(
651 content,
652 {"status": "done", "completed_at": _completed_at_now()},
653 )
654 original_path.write_text(content, encoding="utf-8")
655 append_session_log_entry(original_path, "ll-auto")
657 # Commit the completion
658 commit_body = f"""implement {info.issue_id}
660Automated fallback commit - command exited before completion.
662Issue: {info.issue_id}
663Action: {action}
664Status: Completed via fallback lifecycle completion"""
665 _commit_issue_completion(info, action, commit_body, logger)
667 logger.success(f"Completed lifecycle for {info.issue_id}")
668 if event_bus is not None:
669 event_bus.emit(
670 {
671 "event": "issue.completed",
672 "ts": _iso_now(),
673 "issue_id": info.issue_id,
674 "file_path": str(original_path),
675 "captured_at": captured_at,
676 }
677 )
678 return True
680 except Exception as e:
681 logger.error(f"Failed to complete lifecycle for {info.issue_id}: {e}")
682 return False
685# =============================================================================
686# Issue Deferral
687# =============================================================================
690def _build_deferred_section(reason: str) -> str:
691 """Build the ## Deferred section content."""
692 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
693 return f"""
695## Deferred
697- **Date**: {now}
698- **Reason**: {reason}
699"""
702def _build_undeferred_section(reason: str) -> str:
703 """Build the ## Undeferred section content."""
704 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
705 return f"""
707## Undeferred
709- **Date**: {now}
710- **Reason**: {reason}
711"""
714def defer_issue(
715 info: IssueInfo,
716 config: BRConfig,
717 logger: Logger,
718 reason: str | None = None,
719 event_bus: EventBus | None = None,
720) -> bool:
721 """Defer an issue by writing ``status: deferred`` to its frontmatter.
723 The file remains in its type directory; only the ``status:`` field changes.
725 Args:
726 info: Issue info
727 config: Project configuration (unused; kept for signature stability)
728 logger: Logger for output
729 reason: Reason for deferring
730 event_bus: Optional EventBus for event emission
732 Returns:
733 True if successful, False otherwise
734 """
735 original_path = info.path
737 if not original_path.exists():
738 logger.info(f"{info.issue_id} source not found - nothing to defer")
739 return True
741 if not reason:
742 reason = "Intentionally set aside for later consideration"
744 logger.info(f"Deferring {info.issue_id}: {reason}")
746 try:
747 deferred_section = _build_deferred_section(reason)
748 raw_content = original_path.read_text(encoding="utf-8")
749 captured_at = parse_frontmatter(raw_content).get("captured_at")
750 content = raw_content + deferred_section
751 content = update_frontmatter(content, {"status": "deferred"})
752 original_path.write_text(content, encoding="utf-8")
754 commit_body = f"""{info.issue_id} - Deferred
756Reason: {reason}"""
757 _commit_issue_completion(info, "defer", commit_body, logger)
759 logger.success(f"Deferred {info.issue_id}")
760 if event_bus is not None:
761 event_bus.emit(
762 {
763 "event": "issue.deferred",
764 "ts": _iso_now(),
765 "issue_id": info.issue_id,
766 "file_path": str(original_path),
767 "reason": reason,
768 "captured_at": captured_at,
769 }
770 )
771 return True
773 except Exception as e:
774 logger.error(f"Failed to defer {info.issue_id}: {e}")
775 return False
778# =============================================================================
779# Issue Skip (Deprioritize)
780# =============================================================================
783def _build_skip_section(reason: str | None) -> str:
784 """Build the ## Skip Log section content."""
785 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
786 reason_text = reason or "No reason provided"
787 return f"""
789## Skip Log
791- **Date**: {now}
792- **Reason**: {reason_text}
793"""
796def skip_issue(
797 original_path: Path,
798 new_path: Path,
799 reason: str | None = None,
800 event_bus: EventBus | None = None,
801) -> None:
802 """Deprioritize an issue by renaming its priority prefix.
804 Appends a ``## Skip Log`` entry with ISO timestamp and optional reason,
805 then renames the file in-place (same directory, new priority prefix).
806 Prefers ``git mv`` for tracked files to preserve history; falls back to
807 an atomic ``Path.rename`` for untracked files.
809 Args:
810 original_path: Current path to the issue file
811 new_path: Target path (same directory, new priority prefix)
812 reason: Optional reason text for the Skip Log entry
813 event_bus: Optional EventBus for emitting ``issue.skipped``
815 Raises:
816 FileNotFoundError: If original_path does not exist
817 FileExistsError: If new_path already exists
818 """
819 if not original_path.exists():
820 raise FileNotFoundError(f"Issue file not found: {original_path}")
821 if new_path.exists():
822 raise FileExistsError(f"Target already exists: {new_path}")
824 raw_content = original_path.read_text(encoding="utf-8")
825 captured_at = parse_frontmatter(raw_content).get("captured_at")
826 content = raw_content + _build_skip_section(reason)
828 if _is_git_tracked(original_path):
829 try:
830 result = subprocess.run(
831 ["git", "mv", str(original_path), str(new_path)],
832 capture_output=True,
833 text=True,
834 timeout=30,
835 )
836 except subprocess.TimeoutExpired:
837 result = None # type: ignore[assignment]
839 if result is None or result.returncode != 0:
840 # git mv failed — fall back to write + rename
841 atomic_write(original_path, content, encoding="utf-8")
842 original_path.rename(new_path)
843 else:
844 atomic_write(new_path, content, encoding="utf-8")
845 else:
846 # Not tracked — write updated content then rename atomically
847 atomic_write(original_path, content, encoding="utf-8")
848 original_path.rename(new_path)
850 if event_bus is not None:
851 m = re.match(r"P\d+-([A-Z]+-\d+)-", new_path.name)
852 issue_id = m.group(1) if m else str(new_path.stem)
853 event_bus.emit(
854 {
855 "event": "issue.skipped",
856 "ts": _iso_now(),
857 "issue_id": issue_id,
858 "file_path": str(new_path),
859 "reason": reason,
860 "captured_at": captured_at,
861 }
862 )
865def undefer_issue(
866 config: BRConfig,
867 deferred_issue_path: Path,
868 logger: Logger,
869 reason: str | None = None,
870 event_bus: EventBus | None = None,
871) -> Path | None:
872 """Undefer an issue by writing ``status: open`` to its frontmatter.
874 The file remains where it is (in its type directory); only the ``status:``
875 field is updated.
877 Args:
878 config: Project configuration
879 deferred_issue_path: Path to deferred issue (still in its type dir)
880 logger: Logger for output
881 reason: Reason for undeferring
883 Returns:
884 Path to undeferred issue, or None if failed
885 """
886 if not deferred_issue_path.exists():
887 logger.error(f"Deferred issue not found: {deferred_issue_path}")
888 return None
890 if not reason:
891 reason = "Ready to resume active work"
893 logger.info(f"Undeferring {deferred_issue_path.name}")
895 try:
896 info = IssueParser(config).parse_file(deferred_issue_path)
898 content = deferred_issue_path.read_text(encoding="utf-8")
899 captured_at = parse_frontmatter(content).get("captured_at")
900 content += _build_undeferred_section(reason)
901 content = update_frontmatter(content, {"status": "open"})
902 deferred_issue_path.write_text(content, encoding="utf-8")
904 commit_body = f"""{info.issue_id} - Undeferred
906Reason: {reason}"""
907 _commit_issue_completion(info, "undefer", commit_body, logger)
909 logger.success(f"Undeferred: {deferred_issue_path.name}")
910 if event_bus is not None:
911 event_bus.emit(
912 {
913 "event": "issue.started",
914 "ts": _iso_now(),
915 "issue_id": info.issue_id,
916 "file_path": str(deferred_issue_path),
917 "reason": reason,
918 "captured_at": captured_at,
919 }
920 )
921 return deferred_issue_path
923 except Exception as e:
924 logger.error(f"Failed to undefer issue: {e}")
925 return None