Coverage for little_loops / issue_lifecycle.py: 12%
288 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -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 NON_RECOVERABLE = (
52 "non_recoverable" # Auth/credential failure — retry won't help, not a code bug
53 )
54 REAL = "real" # Actual bug/error, create issue
57def classify_failure(error_output: str, returncode: int) -> tuple[FailureType, str]:
58 """Classify a command failure as transient or real.
60 Examines error output for patterns indicating transient failures
61 (API quota, network errors, timeouts) vs real implementation failures.
63 Args:
64 error_output: stderr or stdout from failed command
65 returncode: Process exit code (available for future use)
67 Returns:
68 Tuple of (failure_type, reason) where reason explains the classification
69 """
70 error_lower = error_output.lower()
72 # API quota/rate limit patterns
73 quota_patterns = [
74 "out of extra usage",
75 "rate limit",
76 "quota exceeded",
77 "too many requests",
78 "api limit",
79 "usage limit",
80 "429", # HTTP Too Many Requests
81 "resource exhausted",
82 "resourceexhausted", # No space variant (gRPC style)
83 ]
84 if any(pattern in error_lower for pattern in quota_patterns):
85 return (FailureType.TRANSIENT, "API quota or rate limit exceeded")
87 # Network/connectivity patterns
88 # Note: Use word boundaries where needed to avoid false positives
89 # (e.g., "enotfound" shouldn't match "ModuleNotFoundError")
90 network_patterns = [
91 "connection refused",
92 "connection timeout",
93 "network error",
94 "dns resolution",
95 "connection reset",
96 "service unavailable",
97 "502 bad gateway",
98 "503 service unavailable",
99 "504 gateway timeout",
100 ]
101 if any(pattern in error_lower for pattern in network_patterns):
102 return (FailureType.TRANSIENT, "Network or connectivity error")
104 # Check for Node.js-style error codes with word boundary awareness
105 # These are typically at word boundaries (e.g., "Error: ECONNREFUSED")
106 if re.search(r"\beconnrefused\b", error_lower):
107 return (FailureType.TRANSIENT, "Network or connectivity error")
108 if re.search(r"\benotfound\b", error_lower):
109 return (FailureType.TRANSIENT, "Network or connectivity error")
110 if re.search(r"\betimedout\b", error_lower):
111 return (FailureType.TRANSIENT, "Network or connectivity error")
113 # Timeout patterns
114 timeout_patterns = [
115 "timeout",
116 "timed out",
117 "deadline exceeded",
118 "operation timed out",
119 ]
120 if any(pattern in error_lower for pattern in timeout_patterns):
121 return (FailureType.TRANSIENT, "Command timeout")
123 # Resource/system transient patterns
124 resource_patterns = [
125 "disk full",
126 "no space left",
127 "resource temporarily unavailable",
128 "too many open files",
129 "memory allocation failed",
130 "out of memory",
131 ]
132 if any(pattern in error_lower for pattern in resource_patterns):
133 return (FailureType.TRANSIENT, "System resource error")
135 # Auth/credential failure patterns — non-recoverable (retry cannot fix an expired/invalid token)
136 # Placed before server_error_patterns because "api error" in that list could also match auth text.
137 auth_patterns = [
138 "401",
139 "403",
140 "unauthorized",
141 "forbidden",
142 "authentication",
143 "invalid api key",
144 "invalid_api_key",
145 "expired token",
146 ]
147 if any(pattern in error_lower for pattern in auth_patterns):
148 return (FailureType.NON_RECOVERABLE, "Auth/credentials failure")
150 # API server error patterns (distinct from rate-limits; trigger short-burst retry in executor)
151 server_error_patterns = [
152 "the server had an error",
153 "internal server error",
154 "overloaded_error",
155 "overloaded",
156 "529", # Anthropic overload HTTP code
157 "api error", # generic "API Error: ..." prefix from Claude Code
158 ]
159 if any(pattern in error_lower for pattern in server_error_patterns):
160 return (FailureType.TRANSIENT, "API server error")
162 # Context window exhaustion patterns — Claude CLI exits non-zero with this on stderr
163 context_patterns = [
164 "prompt is too long",
165 "context length exceeded",
166 "context window",
167 "maximum context",
168 ]
169 if any(pattern in error_lower for pattern in context_patterns):
170 return (FailureType.TRANSIENT, "Context window exhausted")
172 # CLI session continuation errors — --continue/--resume without a live session.
173 # Treated as transient so a failed Option E call does not produce phantom issues.
174 session_id_patterns = [
175 "requires a valid session id",
176 "requires a valid session title",
177 ]
178 if any(pattern in error_lower for pattern in session_id_patterns):
179 return (FailureType.TRANSIENT, "CLI session continuation error")
181 # Shell sandbox environment errors — the ll CLI never executed (exit 127 indicators)
182 sandbox_patterns = [
183 "command not found",
184 "read-only variable",
185 ]
186 if any(pattern in error_lower for pattern in sandbox_patterns):
187 return (FailureType.TRANSIENT, "Shell sandbox environment error")
189 # Process killed by OS (SIGKILL/OOM kill) — exit 137 text signal
190 if re.search(r"\bkilled\b", error_lower):
191 return (FailureType.TRANSIENT, "Process killed (OOM/SIGKILL)")
193 # User-cancelled tool calls — not a defect
194 if "<tool_use_error>" in error_output:
195 return (FailureType.TRANSIENT, "User-cancelled tool call")
197 # Ad-hoc Python snippet tracebacks — not from an ll CLI
198 if 'file "<string>"' in error_lower or 'file "<stdin>"' in error_lower:
199 return (FailureType.TRANSIENT, "Ad-hoc Python snippet error, not an ll CLI failure")
201 # Default: treat as real failure
202 return (FailureType.REAL, "Implementation error")
205# =============================================================================
206# Content Manipulation Helpers
207# =============================================================================
210def _build_closure_resolution(
211 close_status: str,
212 close_reason: str,
213 fix_commit: str | None = None,
214 files_changed: list[str] | None = None,
215) -> str:
216 """Build resolution section for closed issues.
218 Args:
219 close_status: Status text (e.g., "Closed - Already Fixed")
220 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
221 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
222 files_changed: List of files modified by the fix (for regression tracking)
224 Returns:
225 Resolution section markdown string
226 """
227 # Build fix commit line
228 fix_commit_line = f"- **Fix Commit**: {fix_commit}\n" if fix_commit else ""
230 # Build files changed section
231 if files_changed:
232 files_list = "\n".join(f" - `{f}`" for f in files_changed)
233 files_section = f"""
234### Files Changed
235{files_list}
236"""
237 else:
238 files_section = ""
240 return f"""
242---
244## Resolution
246- **Status**: {close_status}
247- **Closed**: {datetime.now().strftime("%Y-%m-%d")}
248- **Reason**: {close_reason}
249- **Closure**: Automated (ready-issue validation)
250{fix_commit_line}
251### Closure Notes
252Issue was automatically closed during validation.
253The issue was determined to be invalid, already resolved, or not actionable.
254{files_section}"""
257def _build_completion_resolution(
258 action: str,
259 fix_commit: str | None = None,
260 files_changed: list[str] | None = None,
261) -> str:
262 """Build resolution section for completed issues.
264 Args:
265 action: Action verb (e.g., "fix", "implement")
266 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
267 files_changed: List of files modified by the fix (for regression tracking)
269 Returns:
270 Resolution section markdown string
271 """
272 # Build fix commit line
273 fix_commit_line = f"- **Fix Commit**: {fix_commit}" if fix_commit else ""
275 # Build files changed section
276 if files_changed:
277 files_list = "\n".join(f" - `{f}`" for f in files_changed)
278 files_section = f"""
279### Files Changed
280{files_list}"""
281 else:
282 files_section = """
283### Files Changed
284- See git history for details"""
286 return f"""
288---
290## Resolution
292- **Action**: {action}
293- **Completed**: {datetime.now().strftime("%Y-%m-%d")}
294- **Status**: Completed (automated fallback)
295- **Implementation**: Command exited early but issue was addressed
296{fix_commit_line}
297{files_section}
299### Verification Results
300- Automated verification passed
302### Commits
303- See git log for details
304"""
307def _prepare_issue_content(original_path: Path, resolution: str) -> str:
308 """Read issue file and append resolution section if needed.
310 Args:
311 original_path: Path to the original issue file
312 resolution: Resolution section to append
314 Returns:
315 Updated file content with resolution section
316 """
317 content = original_path.read_text()
318 if "## Resolution" not in content:
319 content += resolution
320 return content
323# =============================================================================
324# Git Operations Helpers
325# =============================================================================
328def _is_git_tracked(file_path: Path) -> bool:
329 """Check if a file is under git version control.
331 Args:
332 file_path: Path to the file to check
334 Returns:
335 True if file is tracked by git, False otherwise
336 """
337 try:
338 result = subprocess.run(
339 ["git", "ls-files", str(file_path)],
340 capture_output=True,
341 text=True,
342 timeout=30,
343 )
344 except subprocess.TimeoutExpired:
345 return False
346 return bool(result.stdout.strip())
349def _commit_issue_completion(
350 info: IssueInfo,
351 commit_prefix: str,
352 commit_body: str,
353 logger: Logger,
354) -> bool:
355 """Stage all changes and create completion commit.
357 Args:
358 info: Issue information
359 commit_prefix: Prefix for commit message (e.g., "close" or action verb)
360 commit_body: Body text for commit message
361 logger: Logger for output
363 Returns:
364 True if commit succeeded or nothing to commit
365 """
366 # Stage all changes
367 try:
368 stage_result = subprocess.run(
369 ["git", "add", "-A"],
370 capture_output=True,
371 text=True,
372 timeout=30,
373 )
374 if stage_result.returncode != 0:
375 logger.warning(f"git add failed: {stage_result.stderr}")
376 except subprocess.TimeoutExpired:
377 logger.warning("git add timed out")
379 # Create commit
380 commit_msg = f"{commit_prefix}({info.issue_type}): {commit_body}"
381 try:
382 commit_result = subprocess.run(
383 ["git", "commit", "-m", commit_msg],
384 capture_output=True,
385 text=True,
386 timeout=30,
387 )
388 except subprocess.TimeoutExpired:
389 logger.warning("git commit timed out")
390 return True
392 if commit_result.returncode != 0:
393 if "nothing to commit" in commit_result.stdout.lower():
394 logger.info("No changes to commit (already committed)")
395 else:
396 logger.warning(f"git commit failed: {commit_result.stderr}")
397 else:
398 commit_hash_match = re.search(r"\[[\w-]+\s+([a-f0-9]+)\]", commit_result.stdout)
399 if commit_hash_match:
400 logger.success(f"Committed: {commit_hash_match.group(1)}")
401 else:
402 logger.success("Committed changes")
404 return True
407def verify_issue_completed(info: IssueInfo, config: BRConfig, logger: Logger) -> bool:
408 """Verify that an issue was marked as completed via frontmatter.
410 Reads the issue file's ``status:`` frontmatter; ``done`` (or ``cancelled``)
411 means the close path ran successfully. Files no longer move on completion,
412 so this is a pure frontmatter check.
414 Args:
415 info: Issue info
416 config: Project configuration (unused; kept for signature stability)
417 logger: Logger for output
419 Returns:
420 True if issue's frontmatter shows it is done/cancelled
421 """
422 path = info.path
423 if not path.exists():
424 # Source removed without lifecycle update — treat as completed for back-compat
425 # with any external scripts that delete files manually.
426 logger.warning(f"Warning: {info.issue_id} source not found at {path}")
427 return True
429 try:
430 fm = parse_frontmatter(path.read_text(encoding="utf-8"))
431 except Exception as e:
432 logger.warning(f"Warning: failed to read {info.issue_id} frontmatter: {e}")
433 return False
435 status = fm.get("status", "open")
436 if status in ("done", "cancelled"):
437 logger.success(f"Verified: {info.issue_id} status={status}")
438 return True
440 logger.warning(f"Warning: {info.issue_id} status={status} (expected done/cancelled)")
441 return False
444def create_issue_from_failure(
445 error_output: str,
446 parent_info: IssueInfo,
447 config: BRConfig,
448 logger: Logger,
449 event_bus: EventBus | None = None,
450) -> Path | None:
451 """Create a new bug issue file when implementation fails.
453 Args:
454 error_output: Error output from the failed command
455 parent_info: Info about the issue that failed
456 config: Project configuration
457 logger: Logger for output
458 event_bus: Optional EventBus for event emission
460 Returns:
461 Path to new issue file, or None if creation failed
462 """
463 bug_num = get_next_issue_number(config, "bugs")
464 prefix = config.get_issue_prefix("bugs")
465 bug_id = f"{prefix}-{bug_num:03d}"
467 # Try to extract meaningful error info
468 error_lines = error_output.split("\n")[:20] # First 20 lines
469 traceback = "\n".join(error_lines)
471 # Generate title from error
472 title = f"Implementation failure in {parent_info.issue_id}"
473 if "Error" in error_output:
474 error_match = re.search(r"([A-Z]\w+Error[:\s]+[^\n]+)", error_output)
475 if error_match:
476 title = error_match.group(1)
477 title_slug = slugify(title)
479 filename = f"P1-{bug_id}-{title_slug}.md"
480 bugs_dir = config.get_issue_dir("bugs")
481 new_issue_path = bugs_dir / filename
483 captured_at_val = _completed_at_now()
484 content = f"""---
485id: {bug_id}
486type: BUG
487priority: P1
488status: open
489captured_at: {captured_at_val}
490discovered_by: auto-generated
491---
493# {bug_id}: Implementation Failure - {parent_info.issue_id}
495## Summary
496Issue encountered during automated implementation of {parent_info.issue_id}.
498## Current Behavior
499```
500{traceback}
501```
503## Expected Behavior
504Implementation should complete without errors.
506## Root Cause
507Discovered during automated processing of `{parent_info.path}`.
509## Steps to Reproduce
5101. Run: `/ll:manage-issue {parent_info.issue_type} fix {parent_info.issue_id}`
5112. Observe error
513## Proposed Solution
514Investigate the error output above and address the root cause.
516## Impact
517- **Severity**: High
518- **Effort**: Unknown
519- **Risk**: Medium
520- **Breaking Change**: No
522## Labels
523`bug`, `high-priority`, `auto-generated`, `implementation-failure`
525---
527## Status
528**Open** | Created: {_iso_now()} | Priority: P1
530## Related Issues
531- [{parent_info.issue_id}]({parent_info.path})
532"""
534 try:
535 bugs_dir.mkdir(parents=True, exist_ok=True)
536 new_issue_path.write_text(content)
537 logger.success(f"Created new issue: {new_issue_path}")
538 if event_bus is not None:
539 event_bus.emit(
540 {
541 "event": "issue.failure_captured",
542 "ts": _iso_now(),
543 "issue_id": bug_id,
544 "file_path": str(new_issue_path),
545 "parent_issue_id": parent_info.issue_id,
546 "captured_at": captured_at_val,
547 }
548 )
549 return new_issue_path
550 except Exception as e:
551 logger.error(f"Failed to create issue: {e}")
552 return None
555def close_issue(
556 info: IssueInfo,
557 config: BRConfig,
558 logger: Logger,
559 close_reason: str | None,
560 close_status: str | None,
561 fix_commit: str | None = None,
562 files_changed: list[str] | None = None,
563 event_bus: EventBus | None = None,
564 interceptors: list[Any] | None = None,
565) -> bool:
566 """Close an issue by moving it to completed with closure status.
568 Used when ready-issue determines an issue should not be implemented
569 (e.g., already fixed, invalid, duplicate).
571 Args:
572 info: Issue info
573 config: Project configuration
574 logger: Logger for output
575 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
576 close_status: Status text (e.g., "Closed - Already Fixed")
577 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
578 files_changed: List of files modified by the fix (for regression tracking)
579 event_bus: Optional EventBus for event emission
580 interceptors: Optional list of interceptor objects; each may implement
581 ``before_issue_close(info) -> bool | None``. Returning ``False``
582 vetoes the close; ``None`` or any truthy value allows it to proceed.
584 Returns:
585 True if successful, False otherwise
586 """
587 original_path = info.path
589 if not original_path.exists():
590 logger.info(f"{info.issue_id} source already removed - nothing to close")
591 return True
593 # Use defaults if not provided
594 if not close_status:
595 close_status = "Closed - Invalid"
596 if not close_reason:
597 close_reason = "unknown"
599 logger.info(f"Closing {info.issue_id}: {close_status} (reason: {close_reason})")
601 # before_issue_close interceptors — veto check before any file I/O
602 if interceptors:
603 for interceptor in interceptors:
604 if hasattr(interceptor, "before_issue_close"):
605 result = interceptor.before_issue_close(info)
606 if result is False:
607 return False
609 try:
610 captured_at = parse_frontmatter(original_path.read_text(encoding="utf-8")).get(
611 "captured_at"
612 )
613 # Prepare content with resolution section, then write status + completed_at
614 resolution = _build_closure_resolution(
615 close_status, close_reason, fix_commit, files_changed
616 )
617 content = _prepare_issue_content(original_path, resolution)
618 content = update_frontmatter(
619 content,
620 {"status": "done", "completed_at": _completed_at_now()},
621 )
622 original_path.write_text(content, encoding="utf-8")
624 # Commit the closure
625 commit_body = f"""{info.issue_id} - {close_status}
627Automated closure - issue determined to be invalid or already resolved.
629Issue: {info.issue_id}
630Reason: {close_reason}
631Status: {close_status}"""
632 _commit_issue_completion(info, "close", commit_body, logger)
634 logger.success(f"Closed {info.issue_id}: {close_status}")
635 if event_bus is not None:
636 event_bus.emit(
637 {
638 "event": "issue.closed",
639 "ts": _iso_now(),
640 "issue_id": info.issue_id,
641 "file_path": str(original_path),
642 "close_reason": close_reason,
643 "captured_at": captured_at,
644 }
645 )
646 return True
648 except Exception as e:
649 logger.error(f"Failed to close {info.issue_id}: {e}")
650 return False
653def complete_issue_lifecycle(
654 info: IssueInfo,
655 config: BRConfig,
656 logger: Logger,
657 event_bus: EventBus | None = None,
658) -> bool:
659 """Fallback: Complete the issue lifecycle when command exited early.
661 This moves the issue to completed and adds a resolution section.
663 Args:
664 info: Issue info
665 config: Project configuration
666 logger: Logger for output
667 event_bus: Optional EventBus for event emission
669 Returns:
670 True if successful, False otherwise
671 """
672 original_path = info.path
674 if not original_path.exists():
675 logger.info(f"{info.issue_id} source already removed - nothing to complete")
676 return True
678 logger.info(f"Completing lifecycle for {info.issue_id} (command may have exited early)...")
680 try:
681 captured_at = parse_frontmatter(original_path.read_text(encoding="utf-8")).get(
682 "captured_at"
683 )
684 # Prepare content with resolution section, then write status + completed_at
685 action = config.get_category_action(info.issue_type)
686 resolution = _build_completion_resolution(action)
687 content = _prepare_issue_content(original_path, resolution)
688 content = update_frontmatter(
689 content,
690 {"status": "done", "completed_at": _completed_at_now()},
691 )
692 original_path.write_text(content, encoding="utf-8")
693 append_session_log_entry(original_path, "ll-auto")
695 # Commit the completion
696 commit_body = f"""implement {info.issue_id}
698Automated fallback commit - command exited before completion.
700Issue: {info.issue_id}
701Action: {action}
702Status: Completed via fallback lifecycle completion"""
703 _commit_issue_completion(info, action, commit_body, logger)
705 logger.success(f"Completed lifecycle for {info.issue_id}")
706 if event_bus is not None:
707 event_bus.emit(
708 {
709 "event": "issue.completed",
710 "ts": _iso_now(),
711 "issue_id": info.issue_id,
712 "file_path": str(original_path),
713 "captured_at": captured_at,
714 }
715 )
716 return True
718 except Exception as e:
719 logger.error(f"Failed to complete lifecycle for {info.issue_id}: {e}")
720 return False
723# =============================================================================
724# Issue Deferral
725# =============================================================================
728def _build_deferred_section(reason: str) -> str:
729 """Build the ## Deferred section content."""
730 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
731 return f"""
733## Deferred
735- **Date**: {now}
736- **Reason**: {reason}
737"""
740def _build_undeferred_section(reason: str) -> str:
741 """Build the ## Undeferred section content."""
742 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
743 return f"""
745## Undeferred
747- **Date**: {now}
748- **Reason**: {reason}
749"""
752def defer_issue(
753 info: IssueInfo,
754 config: BRConfig,
755 logger: Logger,
756 reason: str | None = None,
757 event_bus: EventBus | None = None,
758) -> bool:
759 """Defer an issue by writing ``status: deferred`` to its frontmatter.
761 The file remains in its type directory; only the ``status:`` field changes.
763 Args:
764 info: Issue info
765 config: Project configuration (unused; kept for signature stability)
766 logger: Logger for output
767 reason: Reason for deferring
768 event_bus: Optional EventBus for event emission
770 Returns:
771 True if successful, False otherwise
772 """
773 original_path = info.path
775 if not original_path.exists():
776 logger.info(f"{info.issue_id} source not found - nothing to defer")
777 return True
779 if not reason:
780 reason = "Intentionally set aside for later consideration"
782 logger.info(f"Deferring {info.issue_id}: {reason}")
784 try:
785 deferred_section = _build_deferred_section(reason)
786 raw_content = original_path.read_text(encoding="utf-8")
787 captured_at = parse_frontmatter(raw_content).get("captured_at")
788 content = raw_content + deferred_section
789 content = update_frontmatter(content, {"status": "deferred"})
790 original_path.write_text(content, encoding="utf-8")
792 commit_body = f"""{info.issue_id} - Deferred
794Reason: {reason}"""
795 _commit_issue_completion(info, "defer", commit_body, logger)
797 logger.success(f"Deferred {info.issue_id}")
798 if event_bus is not None:
799 event_bus.emit(
800 {
801 "event": "issue.deferred",
802 "ts": _iso_now(),
803 "issue_id": info.issue_id,
804 "file_path": str(original_path),
805 "reason": reason,
806 "captured_at": captured_at,
807 }
808 )
809 return True
811 except Exception as e:
812 logger.error(f"Failed to defer {info.issue_id}: {e}")
813 return False
816# =============================================================================
817# Issue Skip (Deprioritize)
818# =============================================================================
821def _build_skip_section(reason: str | None) -> str:
822 """Build the ## Skip Log section content."""
823 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
824 reason_text = reason or "No reason provided"
825 return f"""
827## Skip Log
829- **Date**: {now}
830- **Reason**: {reason_text}
831"""
834def skip_issue(
835 original_path: Path,
836 new_path: Path,
837 reason: str | None = None,
838 event_bus: EventBus | None = None,
839) -> None:
840 """Deprioritize an issue by renaming its priority prefix.
842 Appends a ``## Skip Log`` entry with ISO timestamp and optional reason,
843 then renames the file in-place (same directory, new priority prefix).
844 Prefers ``git mv`` for tracked files to preserve history; falls back to
845 an atomic ``Path.rename`` for untracked files.
847 Args:
848 original_path: Current path to the issue file
849 new_path: Target path (same directory, new priority prefix)
850 reason: Optional reason text for the Skip Log entry
851 event_bus: Optional EventBus for emitting ``issue.skipped``
853 Raises:
854 FileNotFoundError: If original_path does not exist
855 FileExistsError: If new_path already exists
856 """
857 if not original_path.exists():
858 raise FileNotFoundError(f"Issue file not found: {original_path}")
859 if new_path.exists():
860 raise FileExistsError(f"Target already exists: {new_path}")
862 raw_content = original_path.read_text(encoding="utf-8")
863 captured_at = parse_frontmatter(raw_content).get("captured_at")
864 content = raw_content + _build_skip_section(reason)
866 if _is_git_tracked(original_path):
867 try:
868 result = subprocess.run(
869 ["git", "mv", str(original_path), str(new_path)],
870 capture_output=True,
871 text=True,
872 timeout=30,
873 )
874 except subprocess.TimeoutExpired:
875 result = None # type: ignore[assignment]
877 if result is None or result.returncode != 0:
878 # git mv failed — fall back to write + rename
879 atomic_write(original_path, content, encoding="utf-8")
880 original_path.rename(new_path)
881 else:
882 atomic_write(new_path, content, encoding="utf-8")
883 else:
884 # Not tracked — write updated content then rename atomically
885 atomic_write(original_path, content, encoding="utf-8")
886 original_path.rename(new_path)
888 if event_bus is not None:
889 m = re.match(r"P\d+-([A-Z]+-\d+)-", new_path.name)
890 issue_id = m.group(1) if m else str(new_path.stem)
891 event_bus.emit(
892 {
893 "event": "issue.skipped",
894 "ts": _iso_now(),
895 "issue_id": issue_id,
896 "file_path": str(new_path),
897 "reason": reason,
898 "captured_at": captured_at,
899 }
900 )
903def undefer_issue(
904 config: BRConfig,
905 deferred_issue_path: Path,
906 logger: Logger,
907 reason: str | None = None,
908 event_bus: EventBus | None = None,
909) -> Path | None:
910 """Undefer an issue by writing ``status: open`` to its frontmatter.
912 The file remains where it is (in its type directory); only the ``status:``
913 field is updated.
915 Args:
916 config: Project configuration
917 deferred_issue_path: Path to deferred issue (still in its type dir)
918 logger: Logger for output
919 reason: Reason for undeferring
921 Returns:
922 Path to undeferred issue, or None if failed
923 """
924 if not deferred_issue_path.exists():
925 logger.error(f"Deferred issue not found: {deferred_issue_path}")
926 return None
928 if not reason:
929 reason = "Ready to resume active work"
931 logger.info(f"Undeferring {deferred_issue_path.name}")
933 try:
934 info = IssueParser(config).parse_file(deferred_issue_path)
936 content = deferred_issue_path.read_text(encoding="utf-8")
937 captured_at = parse_frontmatter(content).get("captured_at")
938 content += _build_undeferred_section(reason)
939 content = update_frontmatter(content, {"status": "open"})
940 deferred_issue_path.write_text(content, encoding="utf-8")
942 commit_body = f"""{info.issue_id} - Undeferred
944Reason: {reason}"""
945 _commit_issue_completion(info, "undefer", commit_body, logger)
947 logger.success(f"Undeferred: {deferred_issue_path.name}")
948 if event_bus is not None:
949 event_bus.emit(
950 {
951 "event": "issue.started",
952 "ts": _iso_now(),
953 "issue_id": info.issue_id,
954 "file_path": str(deferred_issue_path),
955 "reason": reason,
956 "captured_at": captured_at,
957 }
958 )
959 return deferred_issue_path
961 except Exception as e:
962 logger.error(f"Failed to undefer issue: {e}")
963 return None