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

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

2 

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

4as well as creating new issues from implementation failures. 

5 

6Also provides failure classification to distinguish transient errors 

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

8""" 

9 

10from __future__ import annotations 

11 

12import re 

13import subprocess 

14from datetime import datetime 

15from enum import Enum 

16from pathlib import Path 

17 

18from little_loops.config import BRConfig 

19from little_loops.issue_parser import IssueInfo, get_next_issue_number, slugify 

20from little_loops.logger import Logger 

21 

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

23# Failure Classification 

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

25 

26 

27class 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 

38def 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 

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. 

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 

167Issue was automatically closed during validation. 

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

169{files_section}""" 

170 

171 

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. 

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 

222def _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 

243def _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 

264def _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 

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. 

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 

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. 

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 

407def 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 

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. 

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 

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

477 

478## Current Behavior 

479``` 

480{traceback} 

481``` 

482 

483## Expected Behavior 

484Implementation should complete without errors. 

485 

486## Root Cause 

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

488 

489## Steps to Reproduce 

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

4912. Observe error 

492 

493## Proposed Solution 

494Investigate 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 

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. 

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 

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

589 

590Issue: {info.issue_id} 

591Reason: {close_reason} 

592Status: {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 

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. 

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 

651Automated fallback commit - command exited before completion. 

652 

653Issue: {info.issue_id} 

654Action: {action} 

655Status: 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 

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

675 

676## Deferred 

677 

678- **Date**: {now} 

679- **Reason**: {reason} 

680""" 

681 

682 

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

687 

688## Undeferred 

689 

690- **Date**: {now} 

691- **Reason**: {reason} 

692""" 

693 

694 

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

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 

743Reason: {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 

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. 

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