Coverage for little_loops / issue_manager.py: 13%
382 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"""Automated issue management for little-loops.
3Provides the AutoManager class for sequential issue processing with
4Claude CLI integration and state persistence for resume capability.
5"""
7from __future__ import annotations
9import signal
10import subprocess
11import sys
12import time
13from collections.abc import Generator
14from contextlib import contextmanager
15from dataclasses import dataclass, field
16from datetime import datetime
17from pathlib import Path
18from types import FrameType
20from little_loops.config import BRConfig
21from little_loops.dependency_graph import DependencyGraph
22from little_loops.git_operations import check_git_status, verify_work_was_done
23from little_loops.issue_lifecycle import (
24 FailureType,
25 classify_failure,
26 close_issue,
27 complete_issue_lifecycle,
28 create_issue_from_failure,
29 verify_issue_completed,
30)
31from little_loops.issue_parser import IssueInfo, IssueParser, find_issues
32from little_loops.logger import Logger, format_duration
33from little_loops.output_parsing import parse_ready_issue_output
34from little_loops.state import ProcessingState, StateManager
35from little_loops.subprocess_utils import (
36 detect_context_handoff,
37 read_continuation_prompt,
38)
39from little_loops.subprocess_utils import (
40 run_claude_command as _run_claude_base,
41)
44def _compute_relative_path(abs_path: Path, base_dir: Path | None = None) -> str:
45 """Compute relative path from base directory for command input.
47 Used for fallback retry when ready-issue resolves to wrong file -
48 allows retrying with explicit file path instead of ambiguous ID.
50 Args:
51 abs_path: Absolute path to the file
52 base_dir: Base directory (defaults to cwd)
54 Returns:
55 Relative path string suitable for ready-issue command
56 """
57 base = base_dir or Path.cwd()
58 try:
59 return str(abs_path.relative_to(base))
60 except ValueError:
61 # Path not relative to base, use absolute
62 return str(abs_path)
65@contextmanager
66def timed_phase(
67 logger: Logger,
68 phase_name: str,
69) -> Generator[dict[str, float], None, None]:
70 """Context manager for timing phases.
72 Yields a dict that will be populated with 'elapsed' after the context exits.
74 Args:
75 logger: Logger for output
76 phase_name: Name of the phase being timed
78 Yields:
79 Dict that will contain 'elapsed' key after context exits
80 """
81 timing_result: dict[str, float] = {}
82 start = time.time()
83 try:
84 yield timing_result
85 finally:
86 elapsed = time.time() - start
87 timing_result["elapsed"] = elapsed
88 logger.timing(f"{phase_name} completed in {format_duration(elapsed)}")
91def run_claude_command(
92 command: str,
93 logger: Logger,
94 timeout: int = 3600,
95 stream_output: bool = True,
96 idle_timeout: int = 0,
97) -> subprocess.CompletedProcess[str]:
98 """Invoke Claude CLI command with real-time output streaming.
100 Args:
101 command: Command to pass to Claude CLI
102 logger: Logger for output
103 timeout: Timeout in seconds
104 stream_output: Whether to stream output to console
105 idle_timeout: Kill process if no output for this many seconds (0 to disable)
107 Returns:
108 CompletedProcess with stdout/stderr captured
109 """
110 logger.info(f"Running: claude --dangerously-skip-permissions -p {command!r}")
112 def stream_callback(line: str, is_stderr: bool) -> None:
113 if stream_output:
114 if is_stderr:
115 print(f" {line}", file=sys.stderr)
116 else:
117 print(f" {line}")
119 return _run_claude_base(
120 command=command,
121 timeout=timeout,
122 stream_callback=stream_callback if stream_output else None,
123 idle_timeout=idle_timeout,
124 )
127def run_with_continuation(
128 initial_command: str,
129 logger: Logger,
130 timeout: int = 3600,
131 stream_output: bool = True,
132 max_continuations: int = 3,
133 repo_path: Path | None = None,
134 idle_timeout: int = 0,
135) -> subprocess.CompletedProcess[str]:
136 """Run a Claude command with automatic continuation on context handoff.
138 If the command signals CONTEXT_HANDOFF, reads the continuation prompt
139 and spawns a fresh Claude session to continue the work.
141 Args:
142 initial_command: Initial command to run
143 logger: Logger for output
144 timeout: Timeout per session in seconds
145 stream_output: Whether to stream output
146 max_continuations: Maximum number of continuation attempts
147 repo_path: Repository root path
148 idle_timeout: Kill process if no output for this many seconds (0 to disable)
150 Returns:
151 Final CompletedProcess result
152 """
153 all_stdout: list[str] = []
154 all_stderr: list[str] = []
155 current_command = initial_command
156 continuation_count = 0
157 result: subprocess.CompletedProcess[str] = subprocess.CompletedProcess(
158 args=[], returncode=1, stdout="", stderr=""
159 )
161 while continuation_count <= max_continuations:
162 result = run_claude_command(
163 current_command,
164 logger,
165 timeout=timeout,
166 stream_output=stream_output,
167 idle_timeout=idle_timeout,
168 )
170 all_stdout.append(result.stdout)
171 all_stderr.append(result.stderr)
173 # Check for context handoff signal
174 if detect_context_handoff(result.stdout):
175 logger.info("Detected CONTEXT_HANDOFF signal")
177 # Read continuation prompt
178 prompt_content = read_continuation_prompt(repo_path)
179 if not prompt_content:
180 logger.warning("Context handoff signaled but no continuation prompt found")
181 break
183 if continuation_count >= max_continuations:
184 logger.warning(f"Reached max continuations ({max_continuations}), stopping")
185 break
187 continuation_count += 1
188 logger.info(f"Starting continuation session #{continuation_count}")
190 # Re-invoke the original command with --resume flag so the skill
191 # lifecycle (including completion/file-move) runs in the new session.
192 # The skill reads .claude/ll-continue-prompt.md for context.
193 current_command = f"{initial_command} --resume"
194 continue
196 # No handoff signal, we're done
197 break
199 return subprocess.CompletedProcess(
200 args=result.args,
201 returncode=result.returncode,
202 stdout="\n---CONTINUATION---\n".join(all_stdout),
203 stderr="\n---CONTINUATION---\n".join(all_stderr),
204 )
207def detect_plan_creation(output: str, issue_id: str) -> Path | None:
208 """Detect if manage-issue created a plan file awaiting approval.
210 Checks for plan file creation in thoughts/shared/plans/ matching the issue ID.
211 This happens when manage-issue creates a plan but waits for user approval.
213 Args:
214 output: Command stdout (unused, for future pattern matching)
215 issue_id: Issue ID (e.g., "BUG-280")
217 Returns:
218 Path to plan file if created, None otherwise
219 """
220 plans_dir = Path("thoughts/shared/plans")
221 if not plans_dir.exists():
222 return None
224 # Find plan files matching this issue ID (format: YYYY-MM-DD-ISSUE-ID-*.md)
225 # Use glob pattern with issue_id
226 pattern = f"*-{issue_id}-*.md"
227 matching_plans = list(plans_dir.glob(pattern))
229 if not matching_plans:
230 return None
232 # Return the most recently modified plan file
233 # (in case multiple exist, take the latest)
234 latest_plan = max(matching_plans, key=lambda p: p.stat().st_mtime)
235 return latest_plan
238def check_content_markers(issue_path: Path) -> bool:
239 """Check if issue file content contains implementation markers.
241 Looks for indicators that an implementation was completed, such as
242 Resolution sections or status markers added by manage-issue.
244 Args:
245 issue_path: Path to the issue file
247 Returns:
248 True if implementation markers found
249 """
250 try:
251 content = issue_path.read_text(encoding="utf-8")
252 except (OSError, UnicodeDecodeError):
253 return False
255 markers = [
256 "## Resolution",
257 "Status: Implemented",
258 "Status: Completed",
259 "**Completed**:",
260 ]
261 return any(marker in content for marker in markers)
264@dataclass
265class IssueProcessingResult:
266 """Result of processing a single issue in-place."""
268 success: bool
269 duration: float
270 issue_id: str
271 was_closed: bool = False
272 was_blocked: bool = False
273 failure_reason: str = ""
274 corrections: list[str] = field(default_factory=list)
275 plan_created: bool = False
276 plan_path: str = ""
279def process_issue_inplace(
280 info: IssueInfo,
281 config: BRConfig,
282 logger: Logger,
283 dry_run: bool = False,
284) -> IssueProcessingResult:
285 """Process a single issue through the 3-phase workflow in the current working tree.
287 This is the core processing logic extracted from AutoManager._process_issue(),
288 suitable for use outside of AutoManager (e.g., single-issue sprint waves).
290 Args:
291 info: Issue information
292 config: Project configuration
293 logger: Logger for output
294 dry_run: If True, only preview what would be done
296 Returns:
297 IssueProcessingResult with outcome details
298 """
299 issue_start_time = time.time()
300 corrections: list[str] = []
302 logger.header(f"Processing: {info.issue_id} - {info.title}")
304 issue_timing: dict[str, float] = {}
306 # Track whether we used fallback path resolution for ready-issue.
307 validated_via_fallback = False
309 # Phase 1: Ready/verify the issue
310 logger.info(f"Phase 1: Verifying issue {info.issue_id}...")
311 with timed_phase(logger, "Phase 1 (ready-issue)") as phase1_timing:
312 if not dry_run:
313 result = run_claude_command(
314 f"/ll:ready-issue {info.issue_id}",
315 logger,
316 timeout=config.automation.timeout_seconds,
317 stream_output=config.automation.stream_output,
318 idle_timeout=config.automation.idle_timeout_seconds,
319 )
320 if result.returncode != 0:
321 logger.warning("ready-issue command failed to execute, continuing anyway...")
322 else:
323 # Parse the verdict from the output
324 parsed = parse_ready_issue_output(result.stdout)
325 logger.info(f"ready-issue verdict: {parsed['verdict']}")
327 # Validate that ready-issue analyzed the expected file
328 validated_path = parsed.get("validated_file_path")
329 if validated_path:
330 # Normalize paths for comparison (resolve to absolute)
331 expected_path = str(info.path.resolve())
332 # Handle both absolute and relative paths from ready_issue
333 validated_resolved = Path(validated_path).resolve()
334 if str(validated_resolved) != expected_path:
335 # Check if this is a legitimate rename (new file exists,
336 # old file doesn't) vs a mismatch error
337 old_file_exists = info.path.exists()
338 new_file_exists = validated_resolved.exists()
340 if new_file_exists and not old_file_exists:
341 # ready-issue renamed the file - update tracking
342 logger.info(
343 f"Issue file renamed: '{info.path.name}' -> "
344 f"'{validated_resolved.name}'"
345 )
346 info.path = validated_resolved
347 else:
348 # Genuine mismatch - attempt fallback with explicit path
349 logger.warning(
350 f"Path mismatch: ready-issue validated "
351 f"'{validated_path}' but expected '{info.path}'"
352 )
353 logger.info(
354 "Attempting fallback: retrying ready-issue "
355 "with explicit file path..."
356 )
358 # Compute relative path for the command
359 relative_path = _compute_relative_path(info.path)
361 # Retry with explicit path
362 retry_result = run_claude_command(
363 f"/ll:ready-issue {relative_path}",
364 logger,
365 timeout=config.automation.timeout_seconds,
366 stream_output=config.automation.stream_output,
367 idle_timeout=config.automation.idle_timeout_seconds,
368 )
370 if retry_result.returncode != 0:
371 logger.error(f"Fallback ready-issue failed for {info.issue_id}")
372 return IssueProcessingResult(
373 success=False,
374 duration=time.time() - issue_start_time,
375 issue_id=info.issue_id,
376 failure_reason="Fallback failed after path mismatch",
377 )
379 # Re-parse and validate retry output
380 retry_parsed = parse_ready_issue_output(retry_result.stdout)
381 retry_validated_path = retry_parsed.get("validated_file_path")
383 if retry_validated_path:
384 retry_resolved = Path(retry_validated_path).resolve()
385 if str(retry_resolved) != str(info.path.resolve()):
386 logger.error(
387 f"Fallback still mismatched: "
388 f"got '{retry_validated_path}', "
389 f"expected '{info.path}'"
390 )
391 return IssueProcessingResult(
392 success=False,
393 duration=time.time() - issue_start_time,
394 issue_id=info.issue_id,
395 failure_reason="Path mismatch persisted after fallback",
396 )
398 # Fallback succeeded - use retry result
399 logger.info("Fallback succeeded: validated correct file")
400 parsed = retry_parsed
401 validated_via_fallback = True
403 # Log and store any corrections made
404 if parsed.get("was_corrected"):
405 logger.info(f"Issue {info.issue_id} was auto-corrected")
406 phase_corrections = parsed.get("corrections", [])
407 for correction in phase_corrections:
408 logger.info(f" Correction: {correction}")
409 if phase_corrections:
410 corrections.extend(phase_corrections)
412 # Log any concerns found
413 if parsed["concerns"]:
414 for concern in parsed["concerns"]:
415 logger.warning(f" Concern: {concern}")
417 # Handle CLOSE verdict - issue should not be implemented
418 if parsed.get("should_close"):
419 close_reason = parsed.get("close_reason", "unknown")
420 logger.info(f"Issue {info.issue_id} should be closed (reason: {close_reason})")
422 # CRITICAL: Skip file operations for invalid references
423 if close_reason == "invalid_ref":
424 logger.warning(
425 f"Skipping {info.issue_id}: invalid reference - "
426 "no matching issue file exists"
427 )
428 return IssueProcessingResult(
429 success=False,
430 duration=time.time() - issue_start_time,
431 issue_id=info.issue_id,
432 failure_reason=f"Invalid reference: {close_reason}",
433 corrections=corrections,
434 )
436 # Also require validated_file_path to match before closing
437 close_validated_path = parsed.get("validated_file_path")
438 if not close_validated_path:
439 logger.warning(
440 f"Skipping close for {info.issue_id}: "
441 "ready-issue did not return validated file path"
442 )
443 return IssueProcessingResult(
444 success=False,
445 duration=time.time() - issue_start_time,
446 issue_id=info.issue_id,
447 failure_reason="CLOSE without validated file path",
448 corrections=corrections,
449 )
451 if close_issue(
452 info,
453 config,
454 logger,
455 close_reason,
456 parsed.get("close_status"),
457 ):
458 return IssueProcessingResult(
459 success=True,
460 duration=time.time() - issue_start_time,
461 issue_id=info.issue_id,
462 was_closed=True,
463 corrections=corrections,
464 )
465 else:
466 return IssueProcessingResult(
467 success=False,
468 duration=time.time() - issue_start_time,
469 issue_id=info.issue_id,
470 failure_reason=f"CLOSE failed: {parsed.get('close_status', 'unknown')}",
471 corrections=corrections,
472 )
474 # Handle BLOCKED verdict - issue has open dependencies
475 if parsed.get("is_blocked"):
476 logger.warning(
477 f"Issue {info.issue_id} blocked — open dependency detected by ready-issue"
478 )
479 return IssueProcessingResult(
480 success=False,
481 was_blocked=True,
482 duration=time.time() - issue_start_time,
483 issue_id=info.issue_id,
484 failure_reason=f"BLOCKED: {parsed.get('concerns', [])}",
485 corrections=corrections,
486 )
488 # Check if issue is NOT READY (and not closeable)
489 if not parsed["is_ready"]:
490 logger.error(
491 f"Issue {info.issue_id} is NOT READY for implementation "
492 f"(verdict: {parsed['verdict']})"
493 )
494 return IssueProcessingResult(
495 success=False,
496 duration=time.time() - issue_start_time,
497 issue_id=info.issue_id,
498 failure_reason=(
499 f"NOT READY: {parsed['verdict']} - {len(parsed['concerns'])} concern(s)"
500 ),
501 corrections=corrections,
502 )
504 # Log if proceeding with corrected issue
505 if parsed.get("was_corrected"):
506 logger.success(f"Issue {info.issue_id} corrected and ready for implementation")
507 else:
508 logger.info(f"Would run: /ll:ready-issue {info.issue_id}")
509 issue_timing["ready"] = phase1_timing.get("elapsed", 0.0)
511 # Phase 2: Implement the issue (with automatic continuation on context handoff)
512 action = config.get_category_action(info.issue_type)
513 logger.info(f"Phase 2: Implementing {info.issue_id}...")
514 with timed_phase(logger, "Phase 2 (implement)") as phase2_timing:
515 if not dry_run:
516 # Build manage-issue command
517 type_name = info.issue_type.rstrip("s") # bugs -> bug
519 # Use relative path if fallback was used, otherwise use issue_id
520 if validated_via_fallback:
521 issue_arg = _compute_relative_path(info.path)
522 else:
523 issue_arg = info.issue_id
525 # Use run_with_continuation to handle context exhaustion
526 result = run_with_continuation(
527 f"/ll:manage-issue {type_name} {action} {issue_arg}",
528 logger,
529 timeout=config.automation.timeout_seconds,
530 stream_output=config.automation.stream_output,
531 max_continuations=config.automation.max_continuations,
532 repo_path=config.repo_path,
533 idle_timeout=config.automation.idle_timeout_seconds,
534 )
535 else:
536 logger.info(f"Would run: /ll:manage-issue {info.issue_type} {action} {info.issue_id}")
537 result = subprocess.CompletedProcess(args=[], returncode=0)
538 issue_timing["implement"] = phase2_timing.get("elapsed", 0.0)
540 # Handle implementation failure
541 if result.returncode != 0:
542 error_output = result.stderr or result.stdout or "Unknown error"
543 failure_type, failure_reason_text = classify_failure(error_output, result.returncode)
545 if failure_type == FailureType.TRANSIENT:
546 # Transient failure - log but don't create bug issue
547 logger.warning(f"Transient failure for {info.issue_id}: {failure_reason_text}")
548 logger.warning("Not creating bug issue - this is a temporary error")
549 logger.info("Error output (first 500 chars):")
550 logger.info(error_output[:500])
552 return IssueProcessingResult(
553 success=False,
554 duration=time.time() - issue_start_time,
555 issue_id=info.issue_id,
556 failure_reason=f"Transient: {failure_reason_text}",
557 corrections=corrections,
558 )
560 # Real failure - create issue as before
561 logger.error(f"Implementation failed for {info.issue_id}")
563 failure_reason = ""
564 if not dry_run:
565 # Create new issue for the failure
566 new_issue = create_issue_from_failure(
567 error_output,
568 info,
569 config,
570 logger,
571 )
572 failure_reason = str(new_issue) if new_issue else error_output
573 else:
574 logger.info("Would create new bug issue for this failure")
576 return IssueProcessingResult(
577 success=False,
578 duration=time.time() - issue_start_time,
579 issue_id=info.issue_id,
580 failure_reason=failure_reason,
581 corrections=corrections,
582 )
584 # Phase 3: Verify completion
585 logger.info(f"Phase 3: Verifying {info.issue_id} completion...")
586 verified = False
587 with timed_phase(logger, "Phase 3 (verify)") as phase3_timing:
588 if not dry_run:
589 verified = verify_issue_completed(info, config, logger)
591 # Fallback: Only complete lifecycle if:
592 # 1. Command returned success (returncode 0)
593 # 2. File wasn't moved to completed
594 # 3. There's EVIDENCE of actual work being done (code changes)
595 if not verified and result.returncode == 0:
596 # Check if a plan was created awaiting approval
597 plan_path = detect_plan_creation(result.stdout, info.issue_id)
598 if plan_path is not None:
599 logger.info(
600 f"Plan created at {plan_path}, awaiting approval - "
601 "issue will remain incomplete until plan is approved and implemented"
602 )
603 return IssueProcessingResult(
604 success=False,
605 duration=time.time() - issue_start_time,
606 issue_id=info.issue_id,
607 plan_created=True,
608 plan_path=str(plan_path),
609 failure_reason="", # Not a failure - plan awaiting approval
610 corrections=corrections,
611 )
613 logger.info(
614 "Command returned success but issue not moved - "
615 "checking for evidence of work..."
616 )
618 # Check issue file content for implementation markers
619 if check_content_markers(info.path):
620 logger.info(
621 "Implementation markers found in issue file - completing lifecycle..."
622 )
623 verified = complete_issue_lifecycle(info, config, logger)
624 if verified:
625 logger.success(f"Content marker completion succeeded for {info.issue_id}")
626 else:
627 logger.warning(f"Content marker completion failed for {info.issue_id}")
628 else:
629 # CRITICAL: Verify actual implementation work was done
630 work_done = verify_work_was_done(logger)
631 if work_done:
632 logger.info("Evidence of code changes found - completing lifecycle...")
633 verified = complete_issue_lifecycle(info, config, logger)
634 if verified:
635 logger.success(f"Fallback completion succeeded for {info.issue_id}")
636 else:
637 logger.warning(f"Fallback completion failed for {info.issue_id}")
638 else:
639 # NO work was done - do NOT mark as completed
640 logger.error(
641 f"REFUSING to mark {info.issue_id} as completed: "
642 "no code changes detected despite returncode 0"
643 )
644 logger.error(
645 "This likely indicates the command was not executed "
646 "properly. Check command invocation and Claude CLI "
647 "output."
648 )
649 verified = False
650 else:
651 logger.info("Would verify issue moved to completed")
652 verified = True # In dry run, assume success
653 issue_timing["verify"] = phase3_timing.get("elapsed", 0.0)
655 # Record timing
656 total_issue_time = time.time() - issue_start_time
657 issue_timing["total"] = total_issue_time
658 logger.timing(f"Total processing time: {format_duration(total_issue_time)}")
660 if verified:
661 logger.success(f"Completed: {info.issue_id}")
662 else:
663 logger.warning(f"Issue {info.issue_id} was attempted but verification failed")
664 logger.info("This issue will be skipped on future runs (check logs above for details)")
666 return IssueProcessingResult(
667 success=verified,
668 duration=total_issue_time,
669 issue_id=info.issue_id,
670 corrections=corrections,
671 )
674class AutoManager:
675 """Automated issue manager for sequential processing.
677 Processes issues in priority order using Claude CLI commands,
678 with state persistence for resume capability.
679 """
681 def __init__(
682 self,
683 config: BRConfig,
684 dry_run: bool = False,
685 max_issues: int = 0,
686 resume: bool = False,
687 category: str | None = None,
688 only_ids: list[str] | set[str] | None = None,
689 skip_ids: set[str] | None = None,
690 type_prefixes: set[str] | None = None,
691 verbose: bool = True,
692 ) -> None:
693 """Initialize the auto manager.
695 Args:
696 config: Project configuration
697 dry_run: If True, only preview what would be done
698 max_issues: Maximum issues to process (0 = unlimited)
699 resume: Whether to resume from previous state
700 category: Optional category to filter (e.g., "bugs")
701 only_ids: If provided, only process these issue IDs. When a list,
702 issues are processed in list order (input sequence preserved).
703 skip_ids: Issue IDs to skip (in addition to attempted issues)
704 type_prefixes: If provided, only process issues with these type prefixes
705 verbose: Whether to output progress messages
706 """
707 self.config = config
708 self.dry_run = dry_run
709 self.max_issues = max_issues
710 self.resume = resume
711 self.category = category
712 self.only_ids = only_ids
713 self.skip_ids = skip_ids or set()
714 self.type_prefixes = type_prefixes
716 self.logger = Logger(verbose=verbose)
717 self.state_manager = StateManager(config.get_state_file(), self.logger)
718 self.parser = IssueParser(config)
720 # Build dependency graph for dependency-aware sequencing (ENH-016)
721 # Note: don't filter by type here — we need all issues for dependency resolution
722 all_issues = find_issues(self.config, self.category)
723 all_known_ids: set[str] | None = None
724 try:
725 from little_loops.dependency_mapper import gather_all_issue_ids
727 issues_dir = config.project_root / config.issues.base_dir
728 all_known_ids = gather_all_issue_ids(issues_dir, config=config)
729 except Exception:
730 self.logger.debug("Dependency mapping unavailable — skipping")
731 self.dep_graph = DependencyGraph.from_issues(all_issues, all_known_ids=all_known_ids)
733 # Warn about any cycles
734 if self.dep_graph.has_cycles():
735 cycles = self.dep_graph.detect_cycles()
736 for cycle in cycles:
737 self.logger.warning(f"Dependency cycle detected: {' -> '.join(cycle)}")
739 self.processed_count = 0
740 self._shutdown_requested = False
742 signal.signal(signal.SIGINT, self._signal_handler)
743 signal.signal(signal.SIGTERM, self._signal_handler)
745 def _signal_handler(self, signum: int, frame: FrameType | None) -> None:
746 """Handle shutdown signals gracefully."""
747 self._shutdown_requested = True
748 self.logger.warning(f"Received signal {signum}, shutting down gracefully...")
750 def _get_next_issue(self) -> IssueInfo | None:
751 """Get next issue respecting dependencies.
753 Returns the highest priority issue whose blockers have all been
754 completed. If no ready issues exist but blocked issues remain,
755 logs warnings about what is blocking progress.
757 Returns:
758 Next IssueInfo to process, or None if no ready issues
759 """
760 # Get completed issues from state
761 completed = set(self.state_manager.state.completed_issues)
763 # Combine skip_ids from state and CLI argument
764 skip_ids = self.state_manager.state.attempted_issues | self.skip_ids
766 # Get issues that are ready (blockers satisfied)
767 ready_issues = self.dep_graph.get_ready_issues(completed)
769 # Filter by skip_ids, only_ids, type_prefixes
770 candidates = [
771 i
772 for i in ready_issues
773 if i.issue_id not in skip_ids
774 and (self.only_ids is None or i.issue_id in self.only_ids)
775 and (self.type_prefixes is None or i.issue_id.split("-", 1)[0] in self.type_prefixes)
776 ]
778 if candidates:
779 # When only_ids is a list, respect input order; otherwise use priority order
780 only_ids = self.only_ids
781 if isinstance(only_ids, list):
782 order = {issue_id: i for i, issue_id in enumerate(only_ids)}
783 candidates.sort(key=lambda x: order.get(x.issue_id, len(only_ids)))
784 return candidates[0]
786 # No ready candidates - check if there are blocked issues remaining
787 all_in_graph = set(self.dep_graph.issues.keys())
788 remaining = all_in_graph - completed - skip_ids
789 if self.only_ids is not None:
790 remaining = remaining & set(self.only_ids)
791 if self.type_prefixes is not None:
792 remaining = {r for r in remaining if r.split("-", 1)[0] in self.type_prefixes}
794 if remaining:
795 self._log_blocked_issues(remaining, completed)
797 return None
799 def _log_blocked_issues(self, remaining: set[str], completed: set[str]) -> None:
800 """Log information about blocked issues when processing stalls.
802 Args:
803 remaining: Set of issue IDs that haven't been processed
804 completed: Set of completed issue IDs
805 """
806 blocked_count = 0
807 for issue_id in sorted(remaining):
808 blockers = self.dep_graph.get_blocking_issues(issue_id, completed)
809 if blockers:
810 blocked_count += 1
811 self.logger.info(f" {issue_id} blocked by: {', '.join(sorted(blockers))}")
813 if blocked_count > 0:
814 self.logger.warning(f"{blocked_count} issue(s) remain blocked - check dependencies")
816 def run(self) -> int:
817 """Run the automation loop.
819 Returns:
820 Exit code (0 = success)
821 """
822 run_start_time = time.time()
823 self.logger.info("Starting automated issue management...")
825 if self.dry_run:
826 self.logger.info("DRY RUN MODE - No actual changes will be made")
828 if not self.dry_run:
829 has_changes = check_git_status(self.logger)
830 if has_changes:
831 self.logger.warning("Proceeding anyway...")
833 # Load or initialize state
834 if self.resume:
835 state = self.state_manager.load()
836 if state:
837 self.logger.info(f"Resuming from: {state.current_issue}")
838 self.processed_count = len(state.completed_issues)
839 else:
840 # Fresh start
841 self.state_manager._state = ProcessingState(timestamp=datetime.now().isoformat())
843 try:
844 while not self._shutdown_requested:
845 if self.max_issues > 0 and self.processed_count >= self.max_issues:
846 self.logger.info(f"Reached max issues limit: {self.max_issues}")
847 break
849 info = self._get_next_issue()
850 if not info:
851 self.logger.success("No more issues to process!")
852 break
854 success = self._process_issue(info)
855 if success:
856 self.processed_count += 1
858 except Exception as e:
859 self.logger.error(f"Fatal error: {e}")
860 return 1
862 finally:
863 if not self._shutdown_requested:
864 self.state_manager.cleanup()
866 self._log_timing_summary(run_start_time)
867 self.logger.success(f"Processed {self.processed_count} issue(s)")
868 return 0
870 def _log_timing_summary(self, run_start_time: float) -> None:
871 """Log aggregate timing summary."""
872 total_run_time = time.time() - run_start_time
874 self.logger.info("")
875 self.logger.header("PROCESSING SUMMARY")
876 self.logger.timing(f"Total run time: {format_duration(total_run_time)}")
877 self.logger.timing(f"Issues processed: {self.processed_count}")
879 state = self.state_manager.state
880 if state.timing:
881 total_times = [t.get("total", 0) for t in state.timing.values()]
882 if total_times:
883 avg_time = sum(total_times) / len(total_times)
884 self.logger.timing(f"Average per issue: {format_duration(avg_time)}")
886 if state.failed_issues:
887 self.logger.warning(f"Failed issues: {len(state.failed_issues)}")
888 for issue_id, reason in state.failed_issues.items():
889 self.logger.warning(f" - {issue_id}: {reason[:50]}...")
891 # Log correction statistics for quality tracking
892 if state.corrections:
893 total_corrected = len(state.corrections)
894 total_issues = len(state.completed_issues) + len(state.failed_issues)
895 correction_rate = (total_corrected / total_issues * 100) if total_issues > 0 else 0
896 self.logger.info(
897 f"Auto-corrections: {total_corrected}/{total_issues} ({correction_rate:.1f}%)"
898 )
900 # Log most common correction types
901 from collections import Counter
903 all_corrections: list[str] = []
904 for corrections in state.corrections.values():
905 all_corrections.extend(corrections)
906 if all_corrections:
907 common = Counter(all_corrections).most_common(3)
908 self.logger.info("Most common corrections:")
909 for correction, count in common:
910 # Truncate long correction descriptions
911 display = correction[:60] + "..." if len(correction) > 60 else correction
912 self.logger.info(f" - {display}: {count}")
914 def _process_issue(self, info: IssueInfo) -> bool:
915 """Process a single issue through the workflow.
917 Delegates to process_issue_inplace() and maps the result back
918 to state manager calls.
920 Args:
921 info: Issue information
923 Returns:
924 True if processing succeeded
925 """
926 # Pre-processing state updates (before delegating)
927 self.state_manager.mark_attempted(info.issue_id, save=False)
928 self.state_manager.update_current(str(info.path), "processing")
930 result = process_issue_inplace(info, self.config, self.logger, self.dry_run)
932 # Map result back to state tracking
933 if result.was_closed:
934 self.state_manager.mark_completed(info.issue_id)
935 elif result.was_blocked:
936 # Blocked issues are skipped, not failed — leave in pending state
937 self.logger.info(f"{info.issue_id} skipped — blocked by open dependency")
938 elif result.success:
939 self.state_manager.mark_completed(info.issue_id, {"total": result.duration})
940 elif result.plan_created:
941 # Don't mark as failed if a plan was created (awaiting approval)
942 self.logger.info(
943 f"{info.issue_id} has plan at {result.plan_path} - "
944 "leaving in pending state for manual approval"
945 )
946 # Issue remains in pending state (not marked as failed)
947 elif result.failure_reason:
948 self.state_manager.mark_failed(info.issue_id, result.failure_reason)
950 if result.corrections:
951 self.state_manager.record_corrections(info.issue_id, result.corrections)
953 return result.success