Coverage for little_loops / issue_lifecycle.py: 11%
264 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -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 datetime
15from enum import Enum
16from pathlib import Path
18from little_loops.config import BRConfig
19from little_loops.issue_parser import IssueInfo, get_next_issue_number, slugify
20from little_loops.logger import Logger
22# =============================================================================
23# Failure Classification
24# =============================================================================
27class FailureType(Enum):
28 """Classification of command failure types.
30 Used to distinguish between transient errors that should not
31 create bug issues and real implementation failures that should.
32 """
34 TRANSIENT = "transient" # Temporary error, don't create issue
35 REAL = "real" # Actual bug/error, create issue
38def classify_failure(error_output: str, returncode: int) -> tuple[FailureType, str]:
39 """Classify a command failure as transient or real.
41 Examines error output for patterns indicating transient failures
42 (API quota, network errors, timeouts) vs real implementation failures.
44 Args:
45 error_output: stderr or stdout from failed command
46 returncode: Process exit code (available for future use)
48 Returns:
49 Tuple of (failure_type, reason) where reason explains the classification
50 """
51 error_lower = error_output.lower()
53 # API quota/rate limit patterns
54 quota_patterns = [
55 "out of extra usage",
56 "rate limit",
57 "quota exceeded",
58 "too many requests",
59 "api limit",
60 "usage limit",
61 "429", # HTTP Too Many Requests
62 "resource exhausted",
63 "resourceexhausted", # No space variant (gRPC style)
64 ]
65 if any(pattern in error_lower for pattern in quota_patterns):
66 return (FailureType.TRANSIENT, "API quota or rate limit exceeded")
68 # Network/connectivity patterns
69 # Note: Use word boundaries where needed to avoid false positives
70 # (e.g., "enotfound" shouldn't match "ModuleNotFoundError")
71 network_patterns = [
72 "connection refused",
73 "connection timeout",
74 "network error",
75 "dns resolution",
76 "connection reset",
77 "service unavailable",
78 "502 bad gateway",
79 "503 service unavailable",
80 "504 gateway timeout",
81 ]
82 if any(pattern in error_lower for pattern in network_patterns):
83 return (FailureType.TRANSIENT, "Network or connectivity error")
85 # Check for Node.js-style error codes with word boundary awareness
86 # These are typically at word boundaries (e.g., "Error: ECONNREFUSED")
87 if re.search(r"\beconnrefused\b", error_lower):
88 return (FailureType.TRANSIENT, "Network or connectivity error")
89 if re.search(r"\benotfound\b", error_lower):
90 return (FailureType.TRANSIENT, "Network or connectivity error")
91 if re.search(r"\betimedout\b", error_lower):
92 return (FailureType.TRANSIENT, "Network or connectivity error")
94 # Timeout patterns
95 timeout_patterns = [
96 "timeout",
97 "timed out",
98 "deadline exceeded",
99 "operation timed out",
100 ]
101 if any(pattern in error_lower for pattern in timeout_patterns):
102 return (FailureType.TRANSIENT, "Command timeout")
104 # Resource/system transient patterns
105 resource_patterns = [
106 "disk full",
107 "no space left",
108 "resource temporarily unavailable",
109 "too many open files",
110 "memory allocation failed",
111 "out of memory",
112 ]
113 if any(pattern in error_lower for pattern in resource_patterns):
114 return (FailureType.TRANSIENT, "System resource error")
116 # Default: treat as real failure
117 return (FailureType.REAL, "Implementation error")
120# =============================================================================
121# Content Manipulation Helpers
122# =============================================================================
125def _build_closure_resolution(
126 close_status: str,
127 close_reason: str,
128 fix_commit: str | None = None,
129 files_changed: list[str] | None = None,
130) -> str:
131 """Build resolution section for closed issues.
133 Args:
134 close_status: Status text (e.g., "Closed - Already Fixed")
135 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
136 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
137 files_changed: List of files modified by the fix (for regression tracking)
139 Returns:
140 Resolution section markdown string
141 """
142 # Build fix commit line
143 fix_commit_line = f"- **Fix Commit**: {fix_commit}\n" if fix_commit else ""
145 # Build files changed section
146 if files_changed:
147 files_list = "\n".join(f" - `{f}`" for f in files_changed)
148 files_section = f"""
149### Files Changed
150{files_list}
151"""
152 else:
153 files_section = ""
155 return f"""
157---
159## Resolution
161- **Status**: {close_status}
162- **Closed**: {datetime.now().strftime("%Y-%m-%d")}
163- **Reason**: {close_reason}
164- **Closure**: Automated (ready-issue validation)
165{fix_commit_line}
166### Closure Notes
167Issue was automatically closed during validation.
168The issue was determined to be invalid, already resolved, or not actionable.
169{files_section}"""
172def _build_completion_resolution(
173 action: str,
174 fix_commit: str | None = None,
175 files_changed: list[str] | None = None,
176) -> str:
177 """Build resolution section for completed issues.
179 Args:
180 action: Action verb (e.g., "fix", "implement")
181 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
182 files_changed: List of files modified by the fix (for regression tracking)
184 Returns:
185 Resolution section markdown string
186 """
187 # Build fix commit line
188 fix_commit_line = f"- **Fix Commit**: {fix_commit}" if fix_commit else ""
190 # Build files changed section
191 if files_changed:
192 files_list = "\n".join(f" - `{f}`" for f in files_changed)
193 files_section = f"""
194### Files Changed
195{files_list}"""
196 else:
197 files_section = """
198### Files Changed
199- See git history for details"""
201 return f"""
203---
205## Resolution
207- **Action**: {action}
208- **Completed**: {datetime.now().strftime("%Y-%m-%d")}
209- **Status**: Completed (automated fallback)
210- **Implementation**: Command exited early but issue was addressed
211{fix_commit_line}
212{files_section}
214### Verification Results
215- Automated verification passed
217### Commits
218- See git log for details
219"""
222def _prepare_issue_content(original_path: Path, resolution: str) -> str:
223 """Read issue file and append resolution section if needed.
225 Args:
226 original_path: Path to the original issue file
227 resolution: Resolution section to append
229 Returns:
230 Updated file content with resolution section
231 """
232 content = original_path.read_text()
233 if "## Resolution" not in content:
234 content += resolution
235 return content
238# =============================================================================
239# Git Operations Helpers
240# =============================================================================
243def _is_git_tracked(file_path: Path) -> bool:
244 """Check if a file is under git version control.
246 Args:
247 file_path: Path to the file to check
249 Returns:
250 True if file is tracked by git, False otherwise
251 """
252 try:
253 result = subprocess.run(
254 ["git", "ls-files", str(file_path)],
255 capture_output=True,
256 text=True,
257 timeout=30,
258 )
259 except subprocess.TimeoutExpired:
260 return False
261 return bool(result.stdout.strip())
264def _cleanup_stale_source(original_path: Path, issue_id: str, logger: Logger) -> None:
265 """Remove orphaned source file and commit cleanup.
267 Args:
268 original_path: Path to the stale source file
269 issue_id: Issue identifier for commit message
270 logger: Logger for output
271 """
272 original_path.unlink()
273 try:
274 subprocess.run(["git", "add", "-A"], capture_output=True, text=True, timeout=30)
275 subprocess.run(
276 ["git", "commit", "-m", f"cleanup: remove stale {issue_id} from bugs/"],
277 capture_output=True,
278 text=True,
279 timeout=30,
280 )
281 except subprocess.TimeoutExpired:
282 logger.warning(f"Git command timed out during cleanup of {issue_id}")
285def _move_issue_to_completed(
286 original_path: Path,
287 completed_path: Path,
288 content: str,
289 logger: Logger,
290) -> bool:
291 """Move issue file to completed dir, preferring git mv for history.
293 Checks if source is under git version control before attempting git mv.
294 If source is tracked, uses git mv for history preservation.
295 If source is not tracked, uses manual copy + delete directly.
297 Args:
298 original_path: Source path of issue file
299 completed_path: Destination path in completed directory
300 content: Updated file content to write
301 logger: Logger for output
303 Returns:
304 True if move succeeded
305 """
306 # Handle pre-existing destination (e.g., from parallel worker or worktree leak)
307 if completed_path.exists():
308 logger.info(f"Destination already exists: {completed_path.name}, updating content")
309 completed_path.write_text(content)
310 original_path.unlink(missing_ok=True)
311 return True
313 # Check if source is under git version control before attempting git mv
314 source_tracked = _is_git_tracked(original_path)
316 if source_tracked:
317 # Source is tracked, use git mv for history preservation
318 try:
319 result = subprocess.run(
320 ["git", "mv", str(original_path), str(completed_path)],
321 capture_output=True,
322 text=True,
323 timeout=30,
324 )
325 except subprocess.TimeoutExpired:
326 logger.warning("git mv timed out, falling back to manual copy")
327 completed_path.write_text(content)
328 original_path.unlink(missing_ok=True)
329 return True
331 if result.returncode != 0:
332 # git mv failed, fall back to manual copy + delete
333 logger.warning(f"git mv failed: {result.stderr}")
334 completed_path.write_text(content)
335 original_path.unlink(missing_ok=True)
336 else:
337 logger.success(f"Used git mv to move {original_path.stem}")
338 # Write updated content to the moved file
339 completed_path.write_text(content)
340 else:
341 # Source is not tracked, use manual copy + delete directly
342 logger.info(f"Source not tracked by git, using manual copy: {original_path.name}")
343 completed_path.write_text(content)
344 original_path.unlink(missing_ok=True)
346 return True
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 moved to completed directory.
410 Args:
411 info: Issue info
412 config: Project configuration
413 logger: Logger for output
415 Returns:
416 True if issue is in completed directory
417 """
418 completed_path = config.get_completed_dir() / info.path.name
419 original_path = info.path
421 if completed_path.exists() and not original_path.exists():
422 logger.success(f"Verified: {info.issue_id} properly moved to completed")
423 return True
425 if completed_path.exists() and original_path.exists():
426 logger.warning(f"Warning: {info.issue_id} exists in BOTH locations")
428 if not original_path.exists():
429 logger.warning(f"Warning: {info.issue_id} was deleted but not moved to completed")
430 return True
432 logger.warning(f"Warning: {info.issue_id} was NOT moved to completed")
433 return False
436def create_issue_from_failure(
437 error_output: str,
438 parent_info: IssueInfo,
439 config: BRConfig,
440 logger: Logger,
441) -> Path | None:
442 """Create a new bug issue file when implementation fails.
444 Args:
445 error_output: Error output from the failed command
446 parent_info: Info about the issue that failed
447 config: Project configuration
448 logger: Logger for output
450 Returns:
451 Path to new issue file, or None if creation failed
452 """
453 bug_num = get_next_issue_number(config, "bugs")
454 prefix = config.get_issue_prefix("bugs")
455 bug_id = f"{prefix}-{bug_num:03d}"
457 # Try to extract meaningful error info
458 error_lines = error_output.split("\n")[:20] # First 20 lines
459 traceback = "\n".join(error_lines)
461 # Generate title from error
462 title = f"Implementation failure in {parent_info.issue_id}"
463 if "Error" in error_output:
464 error_match = re.search(r"([A-Z]\w+Error[:\s]+[^\n]+)", error_output)
465 if error_match:
466 title = error_match.group(1)
467 title_slug = slugify(title)
469 filename = f"P1-{bug_id}-{title_slug}.md"
470 bugs_dir = config.get_issue_dir("bugs")
471 new_issue_path = bugs_dir / filename
473 content = f"""# {bug_id}: Implementation Failure - {parent_info.issue_id}
475## Summary
476Issue encountered during automated implementation of {parent_info.issue_id}.
478## Current Behavior
479```
480{traceback}
481```
483## Expected Behavior
484Implementation should complete without errors.
486## Root Cause
487Discovered during automated processing of `{parent_info.path}`.
489## Steps to Reproduce
4901. Run: `/ll:manage-issue {parent_info.issue_type} fix {parent_info.issue_id}`
4912. Observe error
493## Proposed Solution
494Investigate the error output above and address the root cause.
496## Impact
497- **Severity**: High
498- **Effort**: Unknown
499- **Risk**: Medium
500- **Breaking Change**: No
502## Labels
503`bug`, `high-priority`, `auto-generated`, `implementation-failure`
505---
507## Status
508**Open** | Created: {datetime.now().isoformat()} | Priority: P1
510## Related Issues
511- [{parent_info.issue_id}]({parent_info.path})
512"""
514 try:
515 bugs_dir.mkdir(parents=True, exist_ok=True)
516 new_issue_path.write_text(content)
517 logger.success(f"Created new issue: {new_issue_path}")
518 return new_issue_path
519 except Exception as e:
520 logger.error(f"Failed to create issue: {e}")
521 return None
524def close_issue(
525 info: IssueInfo,
526 config: BRConfig,
527 logger: Logger,
528 close_reason: str | None,
529 close_status: str | None,
530 fix_commit: str | None = None,
531 files_changed: list[str] | None = None,
532) -> bool:
533 """Close an issue by moving it to completed with closure status.
535 Used when ready-issue determines an issue should not be implemented
536 (e.g., already fixed, invalid, duplicate).
538 Args:
539 info: Issue info
540 config: Project configuration
541 logger: Logger for output
542 close_reason: Reason code (e.g., "already_fixed", "invalid_ref")
543 close_status: Status text (e.g., "Closed - Already Fixed")
544 fix_commit: SHA of the commit that fixed the issue (for regression tracking)
545 files_changed: List of files modified by the fix (for regression tracking)
547 Returns:
548 True if successful, False otherwise
549 """
550 completed_dir = config.get_completed_dir()
551 completed_dir.mkdir(parents=True, exist_ok=True)
553 original_path = info.path
554 completed_path = completed_dir / original_path.name
556 # Safety checks - handle stale state gracefully
557 if completed_path.exists():
558 logger.info(f"{info.issue_id} already in completed/ - cleaning up source")
559 if original_path.exists():
560 _cleanup_stale_source(original_path, info.issue_id, logger)
561 return True
563 if not original_path.exists():
564 logger.info(f"{info.issue_id} source already removed - nothing to close")
565 return True
567 # Use defaults if not provided
568 if not close_status:
569 close_status = "Closed - Invalid"
570 if not close_reason:
571 close_reason = "unknown"
573 logger.info(f"Closing {info.issue_id}: {close_status} (reason: {close_reason})")
575 try:
576 # Prepare content with resolution section
577 resolution = _build_closure_resolution(
578 close_status, close_reason, fix_commit, files_changed
579 )
580 content = _prepare_issue_content(original_path, resolution)
582 # Move to completed directory
583 _move_issue_to_completed(original_path, completed_path, content, logger)
585 # Commit the closure
586 commit_body = f"""{info.issue_id} - {close_status}
588Automated closure - issue determined to be invalid or already resolved.
590Issue: {info.issue_id}
591Reason: {close_reason}
592Status: {close_status}"""
593 _commit_issue_completion(info, "close", commit_body, logger)
595 logger.success(f"Closed {info.issue_id}: {close_status}")
596 return True
598 except Exception as e:
599 logger.error(f"Failed to close {info.issue_id}: {e}")
600 return False
603def complete_issue_lifecycle(
604 info: IssueInfo,
605 config: BRConfig,
606 logger: Logger,
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
617 Returns:
618 True if successful, False otherwise
619 """
620 completed_dir = config.get_completed_dir()
621 completed_dir.mkdir(parents=True, exist_ok=True)
623 original_path = info.path
624 completed_path = completed_dir / original_path.name
626 # Safety checks - handle stale state gracefully
627 if completed_path.exists():
628 logger.info(f"{info.issue_id} already in completed/ - cleaning up source")
629 if original_path.exists():
630 _cleanup_stale_source(original_path, info.issue_id, logger)
631 return True
633 if not original_path.exists():
634 logger.info(f"{info.issue_id} source already removed - nothing to complete")
635 return True
637 logger.info(f"Completing lifecycle for {info.issue_id} (command may have exited early)...")
639 try:
640 # Prepare content with resolution section
641 action = config.get_category_action(info.issue_type)
642 resolution = _build_completion_resolution(action)
643 content = _prepare_issue_content(original_path, resolution)
645 # Move to completed directory
646 _move_issue_to_completed(original_path, completed_path, content, logger)
648 # Commit the completion
649 commit_body = f"""implement {info.issue_id}
651Automated fallback commit - command exited before completion.
653Issue: {info.issue_id}
654Action: {action}
655Status: Completed via fallback lifecycle completion"""
656 _commit_issue_completion(info, action, commit_body, logger)
658 logger.success(f"Completed lifecycle for {info.issue_id}")
659 return True
661 except Exception as e:
662 logger.error(f"Failed to complete lifecycle for {info.issue_id}: {e}")
663 return False
666# =============================================================================
667# Issue Deferral
668# =============================================================================
671def _build_deferred_section(reason: str) -> str:
672 """Build the ## Deferred section content."""
673 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
674 return f"""
676## Deferred
678- **Date**: {now}
679- **Reason**: {reason}
680"""
683def _build_undeferred_section(reason: str) -> str:
684 """Build the ## Undeferred section content."""
685 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
686 return f"""
688## Undeferred
690- **Date**: {now}
691- **Reason**: {reason}
692"""
695def defer_issue(
696 info: IssueInfo,
697 config: BRConfig,
698 logger: Logger,
699 reason: str | None = None,
700) -> bool:
701 """Defer an issue by moving it from its active directory to deferred/.
703 Args:
704 info: Issue info
705 config: Project configuration
706 logger: Logger for output
707 reason: Reason for deferring
709 Returns:
710 True if successful, False otherwise
711 """
712 deferred_dir = config.get_deferred_dir()
713 deferred_dir.mkdir(parents=True, exist_ok=True)
715 original_path = info.path
716 deferred_path = deferred_dir / original_path.name
718 # Safety checks
719 if deferred_path.exists():
720 logger.info(f"{info.issue_id} already in deferred/")
721 return True
723 if not original_path.exists():
724 logger.info(f"{info.issue_id} source not found - nothing to defer")
725 return True
727 if not reason:
728 reason = "Intentionally set aside for later consideration"
730 logger.info(f"Deferring {info.issue_id}: {reason}")
732 try:
733 # Prepare content with deferred section
734 deferred_section = _build_deferred_section(reason)
735 content = original_path.read_text(encoding="utf-8") + deferred_section
737 # Move to deferred directory (reuse the same move helper)
738 _move_issue_to_completed(original_path, deferred_path, content, logger)
740 # Commit the deferral
741 commit_body = f"""{info.issue_id} - Deferred
743Reason: {reason}"""
744 _commit_issue_completion(info, "defer", commit_body, logger)
746 logger.success(f"Deferred {info.issue_id}")
747 return True
749 except Exception as e:
750 logger.error(f"Failed to defer {info.issue_id}: {e}")
751 return False
754def undefer_issue(
755 config: BRConfig,
756 deferred_issue_path: Path,
757 logger: Logger,
758 reason: str | None = None,
759) -> Path | None:
760 """Move an issue from deferred/ back to its original category directory.
762 Args:
763 config: Project configuration
764 deferred_issue_path: Path to issue in deferred/
765 logger: Logger for output
766 reason: Reason for undeferring
768 Returns:
769 New path to undeferred issue, or None if failed
770 """
771 from little_loops.issue_discovery.search import _get_category_from_issue_path
773 if not deferred_issue_path.exists():
774 logger.error(f"Deferred issue not found: {deferred_issue_path}")
775 return None
777 # Determine target category directory from filename
778 category = _get_category_from_issue_path(deferred_issue_path, config)
779 target_dir = config.get_issue_dir(category)
780 target_dir.mkdir(parents=True, exist_ok=True)
782 target_path = target_dir / deferred_issue_path.name
784 # Safety check - don't overwrite existing active issue
785 if target_path.exists():
786 logger.warning(f"Active issue already exists: {target_path}")
787 return None
789 if not reason:
790 reason = "Ready to resume active work"
792 logger.info(f"Undeferring {deferred_issue_path.name} -> {category}/")
794 try:
795 content = deferred_issue_path.read_text(encoding="utf-8")
796 content += _build_undeferred_section(reason)
798 # Try git mv first for history preservation
799 result = subprocess.run(
800 ["git", "mv", str(deferred_issue_path), str(target_path)],
801 capture_output=True,
802 text=True,
803 )
805 if result.returncode != 0:
806 logger.warning(f"git mv failed, using manual copy: {result.stderr}")
807 target_path.write_text(content, encoding="utf-8")
808 deferred_issue_path.unlink()
809 else:
810 target_path.write_text(content, encoding="utf-8")
812 logger.success(f"Undeferred: {target_path.name}")
813 return target_path
815 except Exception as e:
816 logger.error(f"Failed to undefer issue: {e}")
817 return None