Coverage for little_loops / issue_lifecycle.py: 13%
268 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -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 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 from little_loops.frontmatter import parse_frontmatter
386 path = info.path
387 if not path.exists():
388 # Source removed without lifecycle update — treat as completed for back-compat
389 # with any external scripts that delete files manually.
390 logger.warning(f"Warning: {info.issue_id} source not found at {path}")
391 return True
393 try:
394 fm = parse_frontmatter(path.read_text(encoding="utf-8"))
395 except Exception as e:
396 logger.warning(f"Warning: failed to read {info.issue_id} frontmatter: {e}")
397 return False
399 status = fm.get("status", "open")
400 if status in ("done", "cancelled"):
401 logger.success(f"Verified: {info.issue_id} status={status}")
402 return True
404 logger.warning(f"Warning: {info.issue_id} status={status} (expected done/cancelled)")
405 return False
408def create_issue_from_failure(
409 error_output: str,
410 parent_info: IssueInfo,
411 config: BRConfig,
412 logger: Logger,
413 event_bus: EventBus | None = None,
414) -> Path | None:
415 """Create a new bug issue file when implementation fails.
417 Args:
418 error_output: Error output from the failed command
419 parent_info: Info about the issue that failed
420 config: Project configuration
421 logger: Logger for output
422 event_bus: Optional EventBus for event emission
424 Returns:
425 Path to new issue file, or None if creation failed
426 """
427 bug_num = get_next_issue_number(config, "bugs")
428 prefix = config.get_issue_prefix("bugs")
429 bug_id = f"{prefix}-{bug_num:03d}"
431 # Try to extract meaningful error info
432 error_lines = error_output.split("\n")[:20] # First 20 lines
433 traceback = "\n".join(error_lines)
435 # Generate title from error
436 title = f"Implementation failure in {parent_info.issue_id}"
437 if "Error" in error_output:
438 error_match = re.search(r"([A-Z]\w+Error[:\s]+[^\n]+)", error_output)
439 if error_match:
440 title = error_match.group(1)
441 title_slug = slugify(title)
443 filename = f"P1-{bug_id}-{title_slug}.md"
444 bugs_dir = config.get_issue_dir("bugs")
445 new_issue_path = bugs_dir / filename
447 content = f"""---
448id: {bug_id}
449type: BUG
450priority: P1
451status: open
452captured_at: {_completed_at_now()}
453discovered_by: auto-generated
454---
456# {bug_id}: Implementation Failure - {parent_info.issue_id}
458## Summary
459Issue encountered during automated implementation of {parent_info.issue_id}.
461## Current Behavior
462```
463{traceback}
464```
466## Expected Behavior
467Implementation should complete without errors.
469## Root Cause
470Discovered during automated processing of `{parent_info.path}`.
472## Steps to Reproduce
4731. Run: `/ll:manage-issue {parent_info.issue_type} fix {parent_info.issue_id}`
4742. Observe error
476## Proposed Solution
477Investigate the error output above and address the root cause.
479## Impact
480- **Severity**: High
481- **Effort**: Unknown
482- **Risk**: Medium
483- **Breaking Change**: No
485## Labels
486`bug`, `high-priority`, `auto-generated`, `implementation-failure`
488---
490## Status
491**Open** | Created: {_iso_now()} | Priority: P1
493## Related Issues
494- [{parent_info.issue_id}]({parent_info.path})
495"""
497 try:
498 bugs_dir.mkdir(parents=True, exist_ok=True)
499 new_issue_path.write_text(content)
500 logger.success(f"Created new issue: {new_issue_path}")
501 if event_bus is not None:
502 event_bus.emit(
503 {
504 "event": "issue.failure_captured",
505 "ts": _iso_now(),
506 "issue_id": bug_id,
507 "file_path": str(new_issue_path),
508 "parent_issue_id": parent_info.issue_id,
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 # Prepare content with resolution section, then write status + completed_at
573 resolution = _build_closure_resolution(
574 close_status, close_reason, fix_commit, files_changed
575 )
576 content = _prepare_issue_content(original_path, resolution)
577 content = update_frontmatter(
578 content,
579 {"status": "done", "completed_at": _completed_at_now()},
580 )
581 original_path.write_text(content, encoding="utf-8")
583 # Commit the closure
584 commit_body = f"""{info.issue_id} - {close_status}
586Automated closure - issue determined to be invalid or already resolved.
588Issue: {info.issue_id}
589Reason: {close_reason}
590Status: {close_status}"""
591 _commit_issue_completion(info, "close", commit_body, logger)
593 logger.success(f"Closed {info.issue_id}: {close_status}")
594 if event_bus is not None:
595 event_bus.emit(
596 {
597 "event": "issue.closed",
598 "ts": _iso_now(),
599 "issue_id": info.issue_id,
600 "file_path": str(original_path),
601 "close_reason": close_reason,
602 }
603 )
604 return True
606 except Exception as e:
607 logger.error(f"Failed to close {info.issue_id}: {e}")
608 return False
611def complete_issue_lifecycle(
612 info: IssueInfo,
613 config: BRConfig,
614 logger: Logger,
615 event_bus: EventBus | None = None,
616) -> bool:
617 """Fallback: Complete the issue lifecycle when command exited early.
619 This moves the issue to completed and adds a resolution section.
621 Args:
622 info: Issue info
623 config: Project configuration
624 logger: Logger for output
625 event_bus: Optional EventBus for event emission
627 Returns:
628 True if successful, False otherwise
629 """
630 original_path = info.path
632 if not original_path.exists():
633 logger.info(f"{info.issue_id} source already removed - nothing to complete")
634 return True
636 logger.info(f"Completing lifecycle for {info.issue_id} (command may have exited early)...")
638 try:
639 # Prepare content with resolution section, then write status + completed_at
640 action = config.get_category_action(info.issue_type)
641 resolution = _build_completion_resolution(action)
642 content = _prepare_issue_content(original_path, resolution)
643 content = update_frontmatter(
644 content,
645 {"status": "done", "completed_at": _completed_at_now()},
646 )
647 original_path.write_text(content, encoding="utf-8")
648 append_session_log_entry(original_path, "ll-auto")
650 # Commit the completion
651 commit_body = f"""implement {info.issue_id}
653Automated fallback commit - command exited before completion.
655Issue: {info.issue_id}
656Action: {action}
657Status: Completed via fallback lifecycle completion"""
658 _commit_issue_completion(info, action, commit_body, logger)
660 logger.success(f"Completed lifecycle for {info.issue_id}")
661 if event_bus is not None:
662 event_bus.emit(
663 {
664 "event": "issue.completed",
665 "ts": _iso_now(),
666 "issue_id": info.issue_id,
667 "file_path": str(original_path),
668 }
669 )
670 return True
672 except Exception as e:
673 logger.error(f"Failed to complete lifecycle for {info.issue_id}: {e}")
674 return False
677# =============================================================================
678# Issue Deferral
679# =============================================================================
682def _build_deferred_section(reason: str) -> str:
683 """Build the ## Deferred section content."""
684 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
685 return f"""
687## Deferred
689- **Date**: {now}
690- **Reason**: {reason}
691"""
694def _build_undeferred_section(reason: str) -> str:
695 """Build the ## Undeferred section content."""
696 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
697 return f"""
699## Undeferred
701- **Date**: {now}
702- **Reason**: {reason}
703"""
706def defer_issue(
707 info: IssueInfo,
708 config: BRConfig,
709 logger: Logger,
710 reason: str | None = None,
711 event_bus: EventBus | None = None,
712) -> bool:
713 """Defer an issue by writing ``status: deferred`` to its frontmatter.
715 The file remains in its type directory; only the ``status:`` field changes.
717 Args:
718 info: Issue info
719 config: Project configuration (unused; kept for signature stability)
720 logger: Logger for output
721 reason: Reason for deferring
722 event_bus: Optional EventBus for event emission
724 Returns:
725 True if successful, False otherwise
726 """
727 original_path = info.path
729 if not original_path.exists():
730 logger.info(f"{info.issue_id} source not found - nothing to defer")
731 return True
733 if not reason:
734 reason = "Intentionally set aside for later consideration"
736 logger.info(f"Deferring {info.issue_id}: {reason}")
738 try:
739 deferred_section = _build_deferred_section(reason)
740 content = original_path.read_text(encoding="utf-8") + deferred_section
741 content = update_frontmatter(content, {"status": "deferred"})
742 original_path.write_text(content, encoding="utf-8")
744 commit_body = f"""{info.issue_id} - Deferred
746Reason: {reason}"""
747 _commit_issue_completion(info, "defer", commit_body, logger)
749 logger.success(f"Deferred {info.issue_id}")
750 if event_bus is not None:
751 event_bus.emit(
752 {
753 "event": "issue.deferred",
754 "ts": _iso_now(),
755 "issue_id": info.issue_id,
756 "file_path": str(original_path),
757 "reason": reason,
758 }
759 )
760 return True
762 except Exception as e:
763 logger.error(f"Failed to defer {info.issue_id}: {e}")
764 return False
767# =============================================================================
768# Issue Skip (Deprioritize)
769# =============================================================================
772def _build_skip_section(reason: str | None) -> str:
773 """Build the ## Skip Log section content."""
774 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
775 reason_text = reason or "No reason provided"
776 return f"""
778## Skip Log
780- **Date**: {now}
781- **Reason**: {reason_text}
782"""
785def skip_issue(
786 original_path: Path,
787 new_path: Path,
788 reason: str | None = None,
789 event_bus: EventBus | None = None,
790) -> None:
791 """Deprioritize an issue by renaming its priority prefix.
793 Appends a ``## Skip Log`` entry with ISO timestamp and optional reason,
794 then renames the file in-place (same directory, new priority prefix).
795 Prefers ``git mv`` for tracked files to preserve history; falls back to
796 an atomic ``Path.rename`` for untracked files.
798 Args:
799 original_path: Current path to the issue file
800 new_path: Target path (same directory, new priority prefix)
801 reason: Optional reason text for the Skip Log entry
802 event_bus: Optional EventBus for emitting ``issue.skipped``
804 Raises:
805 FileNotFoundError: If original_path does not exist
806 FileExistsError: If new_path already exists
807 """
808 if not original_path.exists():
809 raise FileNotFoundError(f"Issue file not found: {original_path}")
810 if new_path.exists():
811 raise FileExistsError(f"Target already exists: {new_path}")
813 content = original_path.read_text(encoding="utf-8") + _build_skip_section(reason)
815 if _is_git_tracked(original_path):
816 try:
817 result = subprocess.run(
818 ["git", "mv", str(original_path), str(new_path)],
819 capture_output=True,
820 text=True,
821 timeout=30,
822 )
823 except subprocess.TimeoutExpired:
824 result = None # type: ignore[assignment]
826 if result is None or result.returncode != 0:
827 # git mv failed — fall back to write + rename
828 atomic_write(original_path, content, encoding="utf-8")
829 original_path.rename(new_path)
830 else:
831 atomic_write(new_path, content, encoding="utf-8")
832 else:
833 # Not tracked — write updated content then rename atomically
834 atomic_write(original_path, content, encoding="utf-8")
835 original_path.rename(new_path)
837 if event_bus is not None:
838 m = re.match(r"P\d+-([A-Z]+-\d+)-", new_path.name)
839 issue_id = m.group(1) if m else str(new_path.stem)
840 event_bus.emit(
841 {
842 "event": "issue.skipped",
843 "ts": _iso_now(),
844 "issue_id": issue_id,
845 "file_path": str(new_path),
846 "reason": reason,
847 }
848 )
851def undefer_issue(
852 config: BRConfig,
853 deferred_issue_path: Path,
854 logger: Logger,
855 reason: str | None = None,
856 event_bus: EventBus | None = None,
857) -> Path | None:
858 """Undefer an issue by writing ``status: open`` to its frontmatter.
860 The file remains where it is (in its type directory); only the ``status:``
861 field is updated.
863 Args:
864 config: Project configuration
865 deferred_issue_path: Path to deferred issue (still in its type dir)
866 logger: Logger for output
867 reason: Reason for undeferring
869 Returns:
870 Path to undeferred issue, or None if failed
871 """
872 if not deferred_issue_path.exists():
873 logger.error(f"Deferred issue not found: {deferred_issue_path}")
874 return None
876 if not reason:
877 reason = "Ready to resume active work"
879 logger.info(f"Undeferring {deferred_issue_path.name}")
881 try:
882 info = IssueParser(config).parse_file(deferred_issue_path)
884 content = deferred_issue_path.read_text(encoding="utf-8")
885 content += _build_undeferred_section(reason)
886 content = update_frontmatter(content, {"status": "open"})
887 deferred_issue_path.write_text(content, encoding="utf-8")
889 commit_body = f"""{info.issue_id} - Undeferred
891Reason: {reason}"""
892 _commit_issue_completion(info, "undefer", commit_body, logger)
894 logger.success(f"Undeferred: {deferred_issue_path.name}")
895 if event_bus is not None:
896 event_bus.emit(
897 {
898 "event": "issue.started",
899 "ts": _iso_now(),
900 "issue_id": info.issue_id,
901 "file_path": str(deferred_issue_path),
902 "reason": reason,
903 }
904 )
905 return deferred_issue_path
907 except Exception as e:
908 logger.error(f"Failed to undefer issue: {e}")
909 return None