Coverage for little_loops/issue_lifecycle.py: 11%

1"""Issue lifecycle management for little-loops.

2

3 Provides functions for closing, completing, and verifying issue completion,

4 as well as creating new issues from implementation failures.

5

6 Also provides failure classification to distinguish transient errors

7(API quota, network issues, timeouts) from real implementation failures.

8"""

9

10 from __future__ import annotations

11

12 import re

13 import subprocess

14 from datetime import datetime

15 from enum import Enum

16 from pathlib import Path

17

18 from little_loops.config import BRConfig

19 from little_loops.issue_parser import IssueInfo, get_next_issue_number, slugify

20 from little_loops.logger import Logger

21

22# =============================================================================

23# Failure Classification

24# =============================================================================

25

26

27 class FailureType(Enum):

28 """Classification of command failure types.

29

30 Used to distinguish between transient errors that should not

31 create bug issues and real implementation failures that should.

32 """

33

34 TRANSIENT = "transient" # Temporary error, don't create issue

35 REAL = "real" # Actual bug/error, create issue

36

37

38 def classify_failure(error_output: str, returncode: int) -> tuple[FailureType, str]:

39 """Classify a command failure as transient or real.

40

41 Examines error output for patterns indicating transient failures

42 (API quota, network errors, timeouts) vs real implementation failures.

43

44 Args:

45 error_output: stderr or stdout from failed command

46 returncode: Process exit code (available for future use)

47

48 Returns:

49 Tuple of (failure_type, reason) where reason explains the classification

50 """

51 error_lower = error_output.lower()

52

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")

67

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")

84

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")

93

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")

103

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")

115

116 # Default: treat as real failure

117 return (FailureType.REAL, "Implementation error")

118

119

120# =============================================================================

121# Content Manipulation Helpers

122# =============================================================================

123

124

125 def _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.

132

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)

138

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 ""

144

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 = ""

154

155 return f"""

156

157---

158

159## Resolution

160

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

167 Issue was automatically closed during validation.

168 The issue was determined to be invalid, already resolved, or not actionable.

169{files_section}"""

170

171

172 def _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.

178

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)

183

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 ""

189

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"""

200

201 return f"""

202

203---

204

205## Resolution

206

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}

213

214### Verification Results

215- Automated verification passed

216

217### Commits

218- See git log for details

219"""

220

221

222 def _prepare_issue_content(original_path: Path, resolution: str) -> str:

223 """Read issue file and append resolution section if needed.

224

225 Args:

226 original_path: Path to the original issue file

227 resolution: Resolution section to append

228

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

236

237

238# =============================================================================

239# Git Operations Helpers

240# =============================================================================

241

242

243 def _is_git_tracked(file_path: Path) -> bool:

244 """Check if a file is under git version control.

245

246 Args:

247 file_path: Path to the file to check

248

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())

262

263

264 def _cleanup_stale_source(original_path: Path, issue_id: str, logger: Logger) -> None:

265 """Remove orphaned source file and commit cleanup.

266

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}")

283

284

285 def _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.

292

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.

296

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

302

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

312

313 # Check if source is under git version control before attempting git mv

314 source_tracked = _is_git_tracked(original_path)

315

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

330

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)

345

346 return True

347

348

349 def _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.

356

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

362

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")

378

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

391

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")

403

404 return True

405

406

407 def verify_issue_completed(info: IssueInfo, config: BRConfig, logger: Logger) -> bool:

408 """Verify that an issue was moved to completed directory.

409

410 Args:

411 info: Issue info

412 config: Project configuration

413 logger: Logger for output

414

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

420

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

424

425 if completed_path.exists() and original_path.exists():

426 logger.warning(f"Warning: {info.issue_id} exists in BOTH locations")

427

428 if not original_path.exists():

429 logger.warning(f"Warning: {info.issue_id} was deleted but not moved to completed")

430 return True

431

432 logger.warning(f"Warning: {info.issue_id} was NOT moved to completed")

433 return False

434

435

436 def 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.

443

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

449

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}"

456

457 # Try to extract meaningful error info

458 error_lines = error_output.split("\n")[:20] # First 20 lines

459 traceback = "\n".join(error_lines)

460

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)

468

469 filename = f"P1-{bug_id}-{title_slug}.md"

470 bugs_dir = config.get_issue_dir("bugs")

471 new_issue_path = bugs_dir / filename

472

473 content = f"""# {bug_id}: Implementation Failure - {parent_info.issue_id}

474

475## Summary

476 Issue encountered during automated implementation of {parent_info.issue_id}.

477

478## Current Behavior

479```

480{traceback}

481```

482

483## Expected Behavior

484 Implementation should complete without errors.

485

486## Root Cause

487 Discovered during automated processing of `{parent_info.path}`.

488

489## Steps to Reproduce

490 1. Run: `/ll:manage-issue {parent_info.issue_type} fix {parent_info.issue_id}`

491 2. Observe error

492

493## Proposed Solution

494 Investigate the error output above and address the root cause.

495

496## Impact

497- **Severity**: High

498- **Effort**: Unknown

499- **Risk**: Medium

500- **Breaking Change**: No

501

502## Labels

503`bug`, `high-priority`, `auto-generated`, `implementation-failure`

504

505---

506

507## Status

508**Open** | Created: {datetime.now().isoformat()} | Priority: P1

509

510## Related Issues

511- [{parent_info.issue_id}]({parent_info.path})

512"""

513

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

522

523

524 def 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.

534

535 Used when ready-issue determines an issue should not be implemented

536 (e.g., already fixed, invalid, duplicate).

537

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)

546

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)

552

553 original_path = info.path

554 completed_path = completed_dir / original_path.name

555

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

562

563 if not original_path.exists():

564 logger.info(f"{info.issue_id} source already removed - nothing to close")

565 return True

566

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"

572

573 logger.info(f"Closing {info.issue_id}: {close_status} (reason: {close_reason})")

574

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)

581

582 # Move to completed directory

583 _move_issue_to_completed(original_path, completed_path, content, logger)

584

585 # Commit the closure

586 commit_body = f"""{info.issue_id} - {close_status}

587

588 Automated closure - issue determined to be invalid or already resolved.

589

590 Issue: {info.issue_id}

591 Reason: {close_reason}

592 Status: {close_status}"""

593 _commit_issue_completion(info, "close", commit_body, logger)

594

595 logger.success(f"Closed {info.issue_id}: {close_status}")

596 return True

597

598 except Exception as e:

599 logger.error(f"Failed to close {info.issue_id}: {e}")

600 return False

601

602

603 def complete_issue_lifecycle(

604 info: IssueInfo,

605 config: BRConfig,

606 logger: Logger,

607) -> bool:

608 """Fallback: Complete the issue lifecycle when command exited early.

609

610 This moves the issue to completed and adds a resolution section.

611

612 Args:

613 info: Issue info

614 config: Project configuration

615 logger: Logger for output

616

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)

622

623 original_path = info.path

624 completed_path = completed_dir / original_path.name

625

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

632

633 if not original_path.exists():

634 logger.info(f"{info.issue_id} source already removed - nothing to complete")

635 return True

636

637 logger.info(f"Completing lifecycle for {info.issue_id} (command may have exited early)...")

638

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)

644

645 # Move to completed directory

646 _move_issue_to_completed(original_path, completed_path, content, logger)

647

648 # Commit the completion

649 commit_body = f"""implement {info.issue_id}

650

651 Automated fallback commit - command exited before completion.

652

653 Issue: {info.issue_id}

654 Action: {action}

655 Status: Completed via fallback lifecycle completion"""

656 _commit_issue_completion(info, action, commit_body, logger)

657

658 logger.success(f"Completed lifecycle for {info.issue_id}")

659 return True

660

661 except Exception as e:

662 logger.error(f"Failed to complete lifecycle for {info.issue_id}: {e}")

663 return False

664

665

666# =============================================================================

667# Issue Deferral

668# =============================================================================

669

670

671 def _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"""

675

676## Deferred

677

678- **Date**: {now}

679- **Reason**: {reason}

680"""

681

682

683 def _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"""

687

688## Undeferred

689

690- **Date**: {now}

691- **Reason**: {reason}

692"""

693

694

695 def 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/.

702

703 Args:

704 info: Issue info

705 config: Project configuration

706 logger: Logger for output

707 reason: Reason for deferring

708

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)

714

715 original_path = info.path

716 deferred_path = deferred_dir / original_path.name

717

718 # Safety checks

719 if deferred_path.exists():

720 logger.info(f"{info.issue_id} already in deferred/")

721 return True

722

723 if not original_path.exists():

724 logger.info(f"{info.issue_id} source not found - nothing to defer")

725 return True

726

727 if not reason:

728 reason = "Intentionally set aside for later consideration"

729

730 logger.info(f"Deferring {info.issue_id}: {reason}")

731

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

736

737 # Move to deferred directory (reuse the same move helper)

738 _move_issue_to_completed(original_path, deferred_path, content, logger)

739

740 # Commit the deferral

741 commit_body = f"""{info.issue_id} - Deferred

742

743 Reason: {reason}"""

744 _commit_issue_completion(info, "defer", commit_body, logger)

745

746 logger.success(f"Deferred {info.issue_id}")

747 return True

748

749 except Exception as e:

750 logger.error(f"Failed to defer {info.issue_id}: {e}")

751 return False

752

753

754 def 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.

761

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

767

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

772

773 if not deferred_issue_path.exists():

774 logger.error(f"Deferred issue not found: {deferred_issue_path}")

775 return None

776

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)

781

782 target_path = target_dir / deferred_issue_path.name

783

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

788

789 if not reason:

790 reason = "Ready to resume active work"

791

792 logger.info(f"Undeferring {deferred_issue_path.name} -> {category}/")

793

794 try:

795 content = deferred_issue_path.read_text(encoding="utf-8")

796 content += _build_undeferred_section(reason)

797

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 )

804

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")

811

812 logger.success(f"Undeferred: {target_path.name}")

813 return target_path

814

815 except Exception as e:

816 logger.error(f"Failed to undefer issue: {e}")

817 return None