Coverage for little_loops / issue_lifecycle.py: 86%
262 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -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"""# {bug_id}: Implementation Failure - {parent_info.issue_id}
449## Summary
450Issue encountered during automated implementation of {parent_info.issue_id}.
452## Current Behavior
453```
454{traceback}
455```
457## Expected Behavior
458Implementation should complete without errors.
460## Root Cause
461Discovered during automated processing of `{parent_info.path}`.
463## Steps to Reproduce
4641. Run: `/ll:manage-issue {parent_info.issue_type} fix {parent_info.issue_id}`
4652. Observe error
467## Proposed Solution
468Investigate the error output above and address the root cause.
470## Impact
471- **Severity**: High
472- **Effort**: Unknown
473- **Risk**: Medium
474- **Breaking Change**: No
476## Labels
477`bug`, `high-priority`, `auto-generated`, `implementation-failure`
479---
481## Status
482**Open** | Created: {_iso_now()} | Priority: P1
484## Related Issues
485- [{parent_info.issue_id}]({parent_info.path})
486"""
488 try:
489 bugs_dir.mkdir(parents=True, exist_ok=True)
490 new_issue_path.write_text(content)
491 logger.success(f"Created new issue: {new_issue_path}")
492 if event_bus is not None:
493 event_bus.emit(
494 {
495 "event": "issue.failure_captured",
496 "ts": _iso_now(),
497 "issue_id": bug_id,
498 "file_path": str(new_issue_path),
499 "parent_issue_id": parent_info.issue_id,
500 }
501 )
502 return new_issue_path
503 except Exception as e:
504 logger.error(f"Failed to create issue: {e}")
505 return None
508def close_issue(
509 info: IssueInfo,
510 config: BRConfig,
511 logger: Logger,
512 close_reason: str | None,
513 close_status: str | None,
514 fix_commit: str | None = None,
515 files_changed: list[str] | None = None,
516 event_bus: EventBus | None = None,
517 interceptors: list[Any] | None = None,
518) -> bool:
519 """Close an issue by moving it to completed with closure status.
521 Used when ready-issue determines an issue should not be implemented
522 (e.g., already fixed, invalid, duplicate).
524 Args:
525 info: Issue info
526 config: Project configuration
527 logger: Logger for output
528 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
529 close_status: Status text (e.g., "Closed - Already Fixed")
530 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
531 files_changed: List of files modified by the fix (for regression tracking)
532 event_bus: Optional EventBus for event emission
533 interceptors: Optional list of interceptor objects; each may implement
534 ``before_issue_close(info) -> bool | None``. Returning ``False``
535 vetoes the close; ``None`` or any truthy value allows it to proceed.
537 Returns:
538 True if successful, False otherwise
539 """
540 original_path = info.path
542 if not original_path.exists():
543 logger.info(f"{info.issue_id} source already removed - nothing to close")
544 return True
546 # Use defaults if not provided
547 if not close_status:
548 close_status = "Closed - Invalid"
549 if not close_reason:
550 close_reason = "unknown"
552 logger.info(f"Closing {info.issue_id}: {close_status} (reason: {close_reason})")
554 # before_issue_close interceptors — veto check before any file I/O
555 if interceptors:
556 for interceptor in interceptors:
557 if hasattr(interceptor, "before_issue_close"):
558 result = interceptor.before_issue_close(info)
559 if result is False:
560 return False
562 try:
563 # Prepare content with resolution section, then write status + completed_at
564 resolution = _build_closure_resolution(
565 close_status, close_reason, fix_commit, files_changed
566 )
567 content = _prepare_issue_content(original_path, resolution)
568 content = update_frontmatter(
569 content,
570 {"status": "done", "completed_at": _completed_at_now()},
571 )
572 original_path.write_text(content, encoding="utf-8")
574 # Commit the closure
575 commit_body = f"""{info.issue_id} - {close_status}
577Automated closure - issue determined to be invalid or already resolved.
579Issue: {info.issue_id}
580Reason: {close_reason}
581Status: {close_status}"""
582 _commit_issue_completion(info, "close", commit_body, logger)
584 logger.success(f"Closed {info.issue_id}: {close_status}")
585 if event_bus is not None:
586 event_bus.emit(
587 {
588 "event": "issue.closed",
589 "ts": _iso_now(),
590 "issue_id": info.issue_id,
591 "file_path": str(original_path),
592 "close_reason": close_reason,
593 }
594 )
595 return True
597 except Exception as e:
598 logger.error(f"Failed to close {info.issue_id}: {e}")
599 return False
602def complete_issue_lifecycle(
603 info: IssueInfo,
604 config: BRConfig,
605 logger: Logger,
606 event_bus: EventBus | None = None,
607) -> bool:
608 """Fallback: Complete the issue lifecycle when command exited early.
610 This moves the issue to completed and adds a resolution section.
612 Args:
613 info: Issue info
614 config: Project configuration
615 logger: Logger for output
616 event_bus: Optional EventBus for event emission
618 Returns:
619 True if successful, False otherwise
620 """
621 original_path = info.path
623 if not original_path.exists():
624 logger.info(f"{info.issue_id} source already removed - nothing to complete")
625 return True
627 logger.info(f"Completing lifecycle for {info.issue_id} (command may have exited early)...")
629 try:
630 # Prepare content with resolution section, then write status + completed_at
631 action = config.get_category_action(info.issue_type)
632 resolution = _build_completion_resolution(action)
633 content = _prepare_issue_content(original_path, resolution)
634 content = update_frontmatter(
635 content,
636 {"status": "done", "completed_at": _completed_at_now()},
637 )
638 original_path.write_text(content, encoding="utf-8")
639 append_session_log_entry(original_path, "ll-auto")
641 # Commit the completion
642 commit_body = f"""implement {info.issue_id}
644Automated fallback commit - command exited before completion.
646Issue: {info.issue_id}
647Action: {action}
648Status: Completed via fallback lifecycle completion"""
649 _commit_issue_completion(info, action, commit_body, logger)
651 logger.success(f"Completed lifecycle for {info.issue_id}")
652 if event_bus is not None:
653 event_bus.emit(
654 {
655 "event": "issue.completed",
656 "ts": _iso_now(),
657 "issue_id": info.issue_id,
658 "file_path": str(original_path),
659 }
660 )
661 return True
663 except Exception as e:
664 logger.error(f"Failed to complete lifecycle for {info.issue_id}: {e}")
665 return False
668# =============================================================================
669# Issue Deferral
670# =============================================================================
673def _build_deferred_section(reason: str) -> str:
674 """Build the ## Deferred section content."""
675 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
676 return f"""
678## Deferred
680- **Date**: {now}
681- **Reason**: {reason}
682"""
685def _build_undeferred_section(reason: str) -> str:
686 """Build the ## Undeferred section content."""
687 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
688 return f"""
690## Undeferred
692- **Date**: {now}
693- **Reason**: {reason}
694"""
697def defer_issue(
698 info: IssueInfo,
699 config: BRConfig,
700 logger: Logger,
701 reason: str | None = None,
702 event_bus: EventBus | None = None,
703) -> bool:
704 """Defer an issue by writing ``status: deferred`` to its frontmatter.
706 The file remains in its type directory; only the ``status:`` field changes.
708 Args:
709 info: Issue info
710 config: Project configuration (unused; kept for signature stability)
711 logger: Logger for output
712 reason: Reason for deferring
713 event_bus: Optional EventBus for event emission
715 Returns:
716 True if successful, False otherwise
717 """
718 original_path = info.path
720 if not original_path.exists():
721 logger.info(f"{info.issue_id} source not found - nothing to defer")
722 return True
724 if not reason:
725 reason = "Intentionally set aside for later consideration"
727 logger.info(f"Deferring {info.issue_id}: {reason}")
729 try:
730 deferred_section = _build_deferred_section(reason)
731 content = original_path.read_text(encoding="utf-8") + deferred_section
732 content = update_frontmatter(content, {"status": "deferred"})
733 original_path.write_text(content, encoding="utf-8")
735 commit_body = f"""{info.issue_id} - Deferred
737Reason: {reason}"""
738 _commit_issue_completion(info, "defer", commit_body, logger)
740 logger.success(f"Deferred {info.issue_id}")
741 if event_bus is not None:
742 event_bus.emit(
743 {
744 "event": "issue.deferred",
745 "ts": _iso_now(),
746 "issue_id": info.issue_id,
747 "file_path": str(original_path),
748 "reason": reason,
749 }
750 )
751 return True
753 except Exception as e:
754 logger.error(f"Failed to defer {info.issue_id}: {e}")
755 return False
758# =============================================================================
759# Issue Skip (Deprioritize)
760# =============================================================================
763def _build_skip_section(reason: str | None) -> str:
764 """Build the ## Skip Log section content."""
765 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
766 reason_text = reason or "No reason provided"
767 return f"""
769## Skip Log
771- **Date**: {now}
772- **Reason**: {reason_text}
773"""
776def skip_issue(original_path: Path, new_path: Path, reason: str | None = None) -> None:
777 """Deprioritize an issue by renaming its priority prefix.
779 Appends a ``## Skip Log`` entry with ISO timestamp and optional reason,
780 then renames the file in-place (same directory, new priority prefix).
781 Prefers ``git mv`` for tracked files to preserve history; falls back to
782 an atomic ``Path.rename`` for untracked files.
784 Args:
785 original_path: Current path to the issue file
786 new_path: Target path (same directory, new priority prefix)
787 reason: Optional reason text for the Skip Log entry
789 Raises:
790 FileNotFoundError: If original_path does not exist
791 FileExistsError: If new_path already exists
792 """
793 if not original_path.exists():
794 raise FileNotFoundError(f"Issue file not found: {original_path}")
795 if new_path.exists():
796 raise FileExistsError(f"Target already exists: {new_path}")
798 content = original_path.read_text(encoding="utf-8") + _build_skip_section(reason)
800 if _is_git_tracked(original_path):
801 try:
802 result = subprocess.run(
803 ["git", "mv", str(original_path), str(new_path)],
804 capture_output=True,
805 text=True,
806 timeout=30,
807 )
808 except subprocess.TimeoutExpired:
809 result = None # type: ignore[assignment]
811 if result is None or result.returncode != 0:
812 # git mv failed — fall back to write + rename
813 atomic_write(original_path, content, encoding="utf-8")
814 original_path.rename(new_path)
815 else:
816 atomic_write(new_path, content, encoding="utf-8")
817 else:
818 # Not tracked — write updated content then rename atomically
819 atomic_write(original_path, content, encoding="utf-8")
820 original_path.rename(new_path)
823def undefer_issue(
824 config: BRConfig,
825 deferred_issue_path: Path,
826 logger: Logger,
827 reason: str | None = None,
828) -> Path | None:
829 """Undefer an issue by writing ``status: open`` to its frontmatter.
831 The file remains where it is (in its type directory); only the ``status:``
832 field is updated.
834 Args:
835 config: Project configuration
836 deferred_issue_path: Path to deferred issue (still in its type dir)
837 logger: Logger for output
838 reason: Reason for undeferring
840 Returns:
841 Path to undeferred issue, or None if failed
842 """
843 if not deferred_issue_path.exists():
844 logger.error(f"Deferred issue not found: {deferred_issue_path}")
845 return None
847 if not reason:
848 reason = "Ready to resume active work"
850 logger.info(f"Undeferring {deferred_issue_path.name}")
852 try:
853 info = IssueParser(config).parse_file(deferred_issue_path)
855 content = deferred_issue_path.read_text(encoding="utf-8")
856 content += _build_undeferred_section(reason)
857 content = update_frontmatter(content, {"status": "open"})
858 deferred_issue_path.write_text(content, encoding="utf-8")
860 commit_body = f"""{info.issue_id} - Undeferred
862Reason: {reason}"""
863 _commit_issue_completion(info, "undefer", commit_body, logger)
865 logger.success(f"Undeferred: {deferred_issue_path.name}")
866 return deferred_issue_path
868 except Exception as e:
869 logger.error(f"Failed to undefer issue: {e}")
870 return None