Coverage for little_loops / issue_lifecycle.py: 11%

308 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -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 UTC, datetime 

15from enum import Enum 

16from pathlib import Path 

17from typing import Any 

18 

19from little_loops.config import BRConfig 

20from little_loops.events import EventBus 

21from little_loops.issue_parser import IssueInfo, IssueParser, get_next_issue_number, slugify 

22from little_loops.logger import Logger 

23from little_loops.session_log import append_session_log_entry 

24 

25 

26def _iso_now() -> str: 

27 """Return current time as ISO 8601 string.""" 

28 return datetime.now(UTC).isoformat() 

29 

30 

31# ============================================================================= 

32# Failure Classification 

33# ============================================================================= 

34 

35 

36class FailureType(Enum): 

37 """Classification of command failure types. 

38 

39 Used to distinguish between transient errors that should not 

40 create bug issues and real implementation failures that should. 

41 """ 

42 

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

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

45 

46 

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

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

49 

50 Examines error output for patterns indicating transient failures 

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

52 

53 Args: 

54 error_output: stderr or stdout from failed command 

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

56 

57 Returns: 

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

59 """ 

60 error_lower = error_output.lower() 

61 

62 # API quota/rate limit patterns 

63 quota_patterns = [ 

64 "out of extra usage", 

65 "rate limit", 

66 "quota exceeded", 

67 "too many requests", 

68 "api limit", 

69 "usage limit", 

70 "429", # HTTP Too Many Requests 

71 "resource exhausted", 

72 "resourceexhausted", # No space variant (gRPC style) 

73 ] 

74 if any(pattern in error_lower for pattern in quota_patterns): 

75 return (FailureType.TRANSIENT, "API quota or rate limit exceeded") 

76 

77 # Network/connectivity patterns 

78 # Note: Use word boundaries where needed to avoid false positives 

79 # (e.g., "enotfound" shouldn't match "ModuleNotFoundError") 

80 network_patterns = [ 

81 "connection refused", 

82 "connection timeout", 

83 "network error", 

84 "dns resolution", 

85 "connection reset", 

86 "service unavailable", 

87 "502 bad gateway", 

88 "503 service unavailable", 

89 "504 gateway timeout", 

90 ] 

91 if any(pattern in error_lower for pattern in network_patterns): 

92 return (FailureType.TRANSIENT, "Network or connectivity error") 

93 

94 # Check for Node.js-style error codes with word boundary awareness 

95 # These are typically at word boundaries (e.g., "Error: ECONNREFUSED") 

96 if re.search(r"\beconnrefused\b", error_lower): 

97 return (FailureType.TRANSIENT, "Network or connectivity error") 

98 if re.search(r"\benotfound\b", error_lower): 

99 return (FailureType.TRANSIENT, "Network or connectivity error") 

100 if re.search(r"\betimedout\b", error_lower): 

101 return (FailureType.TRANSIENT, "Network or connectivity error") 

102 

103 # Timeout patterns 

104 timeout_patterns = [ 

105 "timeout", 

106 "timed out", 

107 "deadline exceeded", 

108 "operation timed out", 

109 ] 

110 if any(pattern in error_lower for pattern in timeout_patterns): 

111 return (FailureType.TRANSIENT, "Command timeout") 

112 

113 # Resource/system transient patterns 

114 resource_patterns = [ 

115 "disk full", 

116 "no space left", 

117 "resource temporarily unavailable", 

118 "too many open files", 

119 "memory allocation failed", 

120 "out of memory", 

121 ] 

122 if any(pattern in error_lower for pattern in resource_patterns): 

123 return (FailureType.TRANSIENT, "System resource error") 

124 

125 # Default: treat as real failure 

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

127 

128 

129# ============================================================================= 

130# Content Manipulation Helpers 

131# ============================================================================= 

132 

133 

134def _build_closure_resolution( 

135 close_status: str, 

136 close_reason: str, 

137 fix_commit: str | None = None, 

138 files_changed: list[str] | None = None, 

139) -> str: 

140 """Build resolution section for closed issues. 

141 

142 Args: 

143 close_status: Status text (e.g., "Closed - Already Fixed") 

144 close_reason: Reason code (e.g., "already_fixed", "invalid_ref") 

145 fix_commit: SHA of the commit that fixed the issue (for regression tracking) 

146 files_changed: List of files modified by the fix (for regression tracking) 

147 

148 Returns: 

149 Resolution section markdown string 

150 """ 

151 # Build fix commit line 

152 fix_commit_line = f"- **Fix Commit**: {fix_commit}\n" if fix_commit else "" 

153 

154 # Build files changed section 

155 if files_changed: 

156 files_list = "\n".join(f" - `{f}`" for f in files_changed) 

157 files_section = f""" 

158### Files Changed 

159{files_list} 

160""" 

161 else: 

162 files_section = "" 

163 

164 return f""" 

165 

166--- 

167 

168## Resolution 

169 

170- **Status**: {close_status} 

171- **Closed**: {datetime.now().strftime("%Y-%m-%d")} 

172- **Reason**: {close_reason} 

173- **Closure**: Automated (ready-issue validation) 

174{fix_commit_line} 

175### Closure Notes 

176Issue was automatically closed during validation. 

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

178{files_section}""" 

179 

180 

181def _build_completion_resolution( 

182 action: str, 

183 fix_commit: str | None = None, 

184 files_changed: list[str] | None = None, 

185) -> str: 

186 """Build resolution section for completed issues. 

187 

188 Args: 

189 action: Action verb (e.g., "fix", "implement") 

190 fix_commit: SHA of the commit that fixed the issue (for regression tracking) 

191 files_changed: List of files modified by the fix (for regression tracking) 

192 

193 Returns: 

194 Resolution section markdown string 

195 """ 

196 # Build fix commit line 

197 fix_commit_line = f"- **Fix Commit**: {fix_commit}" if fix_commit else "" 

198 

199 # Build files changed section 

200 if files_changed: 

201 files_list = "\n".join(f" - `{f}`" for f in files_changed) 

202 files_section = f""" 

203### Files Changed 

204{files_list}""" 

205 else: 

206 files_section = """ 

207### Files Changed 

208- See git history for details""" 

209 

210 return f""" 

211 

212--- 

213 

214## Resolution 

215 

216- **Action**: {action} 

217- **Completed**: {datetime.now().strftime("%Y-%m-%d")} 

218- **Status**: Completed (automated fallback) 

219- **Implementation**: Command exited early but issue was addressed 

220{fix_commit_line} 

221{files_section} 

222 

223### Verification Results 

224- Automated verification passed 

225 

226### Commits 

227- See git log for details 

228""" 

229 

230 

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

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

233 

234 Args: 

235 original_path: Path to the original issue file 

236 resolution: Resolution section to append 

237 

238 Returns: 

239 Updated file content with resolution section 

240 """ 

241 content = original_path.read_text() 

242 if "## Resolution" not in content: 

243 content += resolution 

244 return content 

245 

246 

247# ============================================================================= 

248# Git Operations Helpers 

249# ============================================================================= 

250 

251 

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

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

254 

255 Args: 

256 file_path: Path to the file to check 

257 

258 Returns: 

259 True if file is tracked by git, False otherwise 

260 """ 

261 try: 

262 result = subprocess.run( 

263 ["git", "ls-files", str(file_path)], 

264 capture_output=True, 

265 text=True, 

266 timeout=30, 

267 ) 

268 except subprocess.TimeoutExpired: 

269 return False 

270 return bool(result.stdout.strip()) 

271 

272 

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

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

275 

276 Args: 

277 original_path: Path to the stale source file 

278 issue_id: Issue identifier for commit message 

279 logger: Logger for output 

280 """ 

281 original_path.unlink() 

282 try: 

283 subprocess.run(["git", "add", "-A"], capture_output=True, text=True, timeout=30) 

284 subprocess.run( 

285 ["git", "commit", "-m", f"cleanup: remove stale {issue_id} from bugs/"], 

286 capture_output=True, 

287 text=True, 

288 timeout=30, 

289 ) 

290 except subprocess.TimeoutExpired: 

291 logger.warning(f"Git command timed out during cleanup of {issue_id}") 

292 

293 

294def _move_issue_to_completed( 

295 original_path: Path, 

296 completed_path: Path, 

297 content: str, 

298 logger: Logger, 

299) -> bool: 

300 """Move issue file to completed dir, preferring git mv for history. 

301 

302 Checks if source is under git version control before attempting git mv. 

303 If source is tracked, uses git mv for history preservation. 

304 If source is not tracked, uses manual copy + delete directly. 

305 

306 Args: 

307 original_path: Source path of issue file 

308 completed_path: Destination path in completed directory 

309 content: Updated file content to write 

310 logger: Logger for output 

311 

312 Returns: 

313 True if move succeeded 

314 """ 

315 # Handle pre-existing destination (e.g., from parallel worker or worktree leak) 

316 if completed_path.exists(): 

317 logger.info(f"Destination already exists: {completed_path.name}, updating content") 

318 completed_path.write_text(content) 

319 original_path.unlink(missing_ok=True) 

320 return True 

321 

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

323 source_tracked = _is_git_tracked(original_path) 

324 

325 if source_tracked: 

326 # Source is tracked, use git mv for history preservation 

327 try: 

328 result = subprocess.run( 

329 ["git", "mv", str(original_path), str(completed_path)], 

330 capture_output=True, 

331 text=True, 

332 timeout=30, 

333 ) 

334 except subprocess.TimeoutExpired: 

335 logger.warning("git mv timed out, falling back to manual copy") 

336 completed_path.write_text(content) 

337 original_path.unlink(missing_ok=True) 

338 return True 

339 

340 if result.returncode != 0: 

341 # git mv failed, fall back to manual copy + delete 

342 logger.warning(f"git mv failed: {result.stderr}") 

343 completed_path.write_text(content) 

344 original_path.unlink(missing_ok=True) 

345 else: 

346 logger.success(f"Used git mv to move {original_path.stem}") 

347 # Write updated content to the moved file 

348 completed_path.write_text(content) 

349 else: 

350 # Source is not tracked, use manual copy + delete directly 

351 logger.info(f"Source not tracked by git, using manual copy: {original_path.name}") 

352 completed_path.write_text(content) 

353 original_path.unlink(missing_ok=True) 

354 

355 return True 

356 

357 

358def _commit_issue_completion( 

359 info: IssueInfo, 

360 commit_prefix: str, 

361 commit_body: str, 

362 logger: Logger, 

363) -> bool: 

364 """Stage all changes and create completion commit. 

365 

366 Args: 

367 info: Issue information 

368 commit_prefix: Prefix for commit message (e.g., "close" or action verb) 

369 commit_body: Body text for commit message 

370 logger: Logger for output 

371 

372 Returns: 

373 True if commit succeeded or nothing to commit 

374 """ 

375 # Stage all changes 

376 try: 

377 stage_result = subprocess.run( 

378 ["git", "add", "-A"], 

379 capture_output=True, 

380 text=True, 

381 timeout=30, 

382 ) 

383 if stage_result.returncode != 0: 

384 logger.warning(f"git add failed: {stage_result.stderr}") 

385 except subprocess.TimeoutExpired: 

386 logger.warning("git add timed out") 

387 

388 # Create commit 

389 commit_msg = f"{commit_prefix}({info.issue_type}): {commit_body}" 

390 try: 

391 commit_result = subprocess.run( 

392 ["git", "commit", "-m", commit_msg], 

393 capture_output=True, 

394 text=True, 

395 timeout=30, 

396 ) 

397 except subprocess.TimeoutExpired: 

398 logger.warning("git commit timed out") 

399 return True 

400 

401 if commit_result.returncode != 0: 

402 if "nothing to commit" in commit_result.stdout.lower(): 

403 logger.info("No changes to commit (already committed)") 

404 else: 

405 logger.warning(f"git commit failed: {commit_result.stderr}") 

406 else: 

407 commit_hash_match = re.search(r"\[[\w-]+\s+([a-f0-9]+)\]", commit_result.stdout) 

408 if commit_hash_match: 

409 logger.success(f"Committed: {commit_hash_match.group(1)}") 

410 else: 

411 logger.success("Committed changes") 

412 

413 return True 

414 

415 

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

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

418 

419 Args: 

420 info: Issue info 

421 config: Project configuration 

422 logger: Logger for output 

423 

424 Returns: 

425 True if issue is in completed directory 

426 """ 

427 completed_path = config.get_completed_dir() / info.path.name 

428 original_path = info.path 

429 

430 if completed_path.exists() and not original_path.exists(): 

431 logger.success(f"Verified: {info.issue_id} properly moved to completed") 

432 return True 

433 

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

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

436 

437 if not original_path.exists(): 

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

439 return True 

440 

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

442 return False 

443 

444 

445def create_issue_from_failure( 

446 error_output: str, 

447 parent_info: IssueInfo, 

448 config: BRConfig, 

449 logger: Logger, 

450 event_bus: EventBus | None = None, 

451) -> Path | None: 

452 """Create a new bug issue file when implementation fails. 

453 

454 Args: 

455 error_output: Error output from the failed command 

456 parent_info: Info about the issue that failed 

457 config: Project configuration 

458 logger: Logger for output 

459 event_bus: Optional EventBus for event emission 

460 

461 Returns: 

462 Path to new issue file, or None if creation failed 

463 """ 

464 bug_num = get_next_issue_number(config, "bugs") 

465 prefix = config.get_issue_prefix("bugs") 

466 bug_id = f"{prefix}-{bug_num:03d}" 

467 

468 # Try to extract meaningful error info 

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

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

471 

472 # Generate title from error 

473 title = f"Implementation failure in {parent_info.issue_id}" 

474 if "Error" in error_output: 

475 error_match = re.search(r"([A-Z]\w+Error[:\s]+[^\n]+)", error_output) 

476 if error_match: 

477 title = error_match.group(1) 

478 title_slug = slugify(title) 

479 

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

481 bugs_dir = config.get_issue_dir("bugs") 

482 new_issue_path = bugs_dir / filename 

483 

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

485 

486## Summary 

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

488 

489## Current Behavior 

490``` 

491{traceback} 

492``` 

493 

494## Expected Behavior 

495Implementation should complete without errors. 

496 

497## Root Cause 

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

499 

500## Steps to Reproduce 

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

5022. Observe error 

503 

504## Proposed Solution 

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

506 

507## Impact 

508- **Severity**: High 

509- **Effort**: Unknown 

510- **Risk**: Medium 

511- **Breaking Change**: No 

512 

513## Labels 

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

515 

516--- 

517 

518## Status 

519**Open** | Created: {_iso_now()} | Priority: P1 

520 

521## Related Issues 

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

523""" 

524 

525 try: 

526 bugs_dir.mkdir(parents=True, exist_ok=True) 

527 new_issue_path.write_text(content) 

528 logger.success(f"Created new issue: {new_issue_path}") 

529 if event_bus is not None: 

530 event_bus.emit( 

531 { 

532 "event": "issue.failure_captured", 

533 "ts": _iso_now(), 

534 "issue_id": bug_id, 

535 "file_path": str(new_issue_path), 

536 "parent_issue_id": parent_info.issue_id, 

537 } 

538 ) 

539 return new_issue_path 

540 except Exception as e: 

541 logger.error(f"Failed to create issue: {e}") 

542 return None 

543 

544 

545def close_issue( 

546 info: IssueInfo, 

547 config: BRConfig, 

548 logger: Logger, 

549 close_reason: str | None, 

550 close_status: str | None, 

551 fix_commit: str | None = None, 

552 files_changed: list[str] | None = None, 

553 event_bus: EventBus | None = None, 

554 interceptors: list[Any] | None = None, 

555) -> bool: 

556 """Close an issue by moving it to completed with closure status. 

557 

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

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

560 

561 Args: 

562 info: Issue info 

563 config: Project configuration 

564 logger: Logger for output 

565 close_reason: Reason code (e.g., "already_fixed", "invalid_ref") 

566 close_status: Status text (e.g., "Closed - Already Fixed") 

567 fix_commit: SHA of the commit that fixed the issue (for regression tracking) 

568 files_changed: List of files modified by the fix (for regression tracking) 

569 event_bus: Optional EventBus for event emission 

570 interceptors: Optional list of interceptor objects; each may implement 

571 ``before_issue_close(info) -> bool | None``. Returning ``False`` 

572 vetoes the close; ``None`` or any truthy value allows it to proceed. 

573 

574 Returns: 

575 True if successful, False otherwise 

576 """ 

577 completed_dir = config.get_completed_dir() 

578 completed_dir.mkdir(parents=True, exist_ok=True) 

579 

580 original_path = info.path 

581 completed_path = completed_dir / original_path.name 

582 

583 # Safety checks - handle stale state gracefully 

584 if completed_path.exists(): 

585 logger.info(f"{info.issue_id} already in completed/ - cleaning up source") 

586 if original_path.exists(): 

587 _cleanup_stale_source(original_path, info.issue_id, logger) 

588 return True 

589 

590 if not original_path.exists(): 

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

592 return True 

593 

594 # Use defaults if not provided 

595 if not close_status: 

596 close_status = "Closed - Invalid" 

597 if not close_reason: 

598 close_reason = "unknown" 

599 

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

601 

602 # before_issue_close interceptors — veto check before any file I/O 

603 if interceptors: 

604 for interceptor in interceptors: 

605 if hasattr(interceptor, "before_issue_close"): 

606 result = interceptor.before_issue_close(info) 

607 if result is False: 

608 return False 

609 

610 try: 

611 # Prepare content with resolution section 

612 resolution = _build_closure_resolution( 

613 close_status, close_reason, fix_commit, files_changed 

614 ) 

615 content = _prepare_issue_content(original_path, resolution) 

616 

617 # Move to completed directory 

618 _move_issue_to_completed(original_path, completed_path, content, logger) 

619 

620 # Commit the closure 

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

622 

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

624 

625Issue: {info.issue_id} 

626Reason: {close_reason} 

627Status: {close_status}""" 

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

629 

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

631 if event_bus is not None: 

632 event_bus.emit( 

633 { 

634 "event": "issue.closed", 

635 "ts": _iso_now(), 

636 "issue_id": info.issue_id, 

637 "file_path": str(completed_path), 

638 "close_reason": close_reason, 

639 } 

640 ) 

641 return True 

642 

643 except Exception as e: 

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

645 return False 

646 

647 

648def complete_issue_lifecycle( 

649 info: IssueInfo, 

650 config: BRConfig, 

651 logger: Logger, 

652 event_bus: EventBus | None = None, 

653) -> bool: 

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

655 

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

657 

658 Args: 

659 info: Issue info 

660 config: Project configuration 

661 logger: Logger for output 

662 event_bus: Optional EventBus for event emission 

663 

664 Returns: 

665 True if successful, False otherwise 

666 """ 

667 completed_dir = config.get_completed_dir() 

668 completed_dir.mkdir(parents=True, exist_ok=True) 

669 

670 original_path = info.path 

671 completed_path = completed_dir / original_path.name 

672 

673 # Safety checks - handle stale state gracefully 

674 if completed_path.exists(): 

675 logger.info(f"{info.issue_id} already in completed/ - cleaning up source") 

676 if original_path.exists(): 

677 _cleanup_stale_source(original_path, info.issue_id, logger) 

678 return True 

679 

680 if not original_path.exists(): 

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

682 return True 

683 

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

685 

686 try: 

687 # Prepare content with resolution section 

688 action = config.get_category_action(info.issue_type) 

689 resolution = _build_completion_resolution(action) 

690 content = _prepare_issue_content(original_path, resolution) 

691 

692 # Move to completed directory 

693 _move_issue_to_completed(original_path, completed_path, content, logger) 

694 append_session_log_entry(completed_path, "ll-auto") 

695 

696 # Commit the completion 

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

698 

699Automated fallback commit - command exited before completion. 

700 

701Issue: {info.issue_id} 

702Action: {action} 

703Status: Completed via fallback lifecycle completion""" 

704 _commit_issue_completion(info, action, commit_body, logger) 

705 

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

707 if event_bus is not None: 

708 event_bus.emit( 

709 { 

710 "event": "issue.completed", 

711 "ts": _iso_now(), 

712 "issue_id": info.issue_id, 

713 "file_path": str(completed_path), 

714 } 

715 ) 

716 return True 

717 

718 except Exception as e: 

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

720 return False 

721 

722 

723# ============================================================================= 

724# Issue Deferral 

725# ============================================================================= 

726 

727 

728def _build_deferred_section(reason: str) -> str: 

729 """Build the ## Deferred section content.""" 

730 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") 

731 return f""" 

732 

733## Deferred 

734 

735- **Date**: {now} 

736- **Reason**: {reason} 

737""" 

738 

739 

740def _build_undeferred_section(reason: str) -> str: 

741 """Build the ## Undeferred section content.""" 

742 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") 

743 return f""" 

744 

745## Undeferred 

746 

747- **Date**: {now} 

748- **Reason**: {reason} 

749""" 

750 

751 

752def defer_issue( 

753 info: IssueInfo, 

754 config: BRConfig, 

755 logger: Logger, 

756 reason: str | None = None, 

757 event_bus: EventBus | None = None, 

758) -> bool: 

759 """Defer an issue by moving it from its active directory to deferred/. 

760 

761 Args: 

762 info: Issue info 

763 config: Project configuration 

764 logger: Logger for output 

765 reason: Reason for deferring 

766 event_bus: Optional EventBus for event emission 

767 

768 Returns: 

769 True if successful, False otherwise 

770 """ 

771 deferred_dir = config.get_deferred_dir() 

772 deferred_dir.mkdir(parents=True, exist_ok=True) 

773 

774 original_path = info.path 

775 deferred_path = deferred_dir / original_path.name 

776 

777 # Safety checks 

778 if deferred_path.exists(): 

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

780 return True 

781 

782 if not original_path.exists(): 

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

784 return True 

785 

786 if not reason: 

787 reason = "Intentionally set aside for later consideration" 

788 

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

790 

791 try: 

792 # Prepare content with deferred section 

793 deferred_section = _build_deferred_section(reason) 

794 content = original_path.read_text(encoding="utf-8") + deferred_section 

795 

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

797 _move_issue_to_completed(original_path, deferred_path, content, logger) 

798 

799 # Commit the deferral 

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

801 

802Reason: {reason}""" 

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

804 

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

806 if event_bus is not None: 

807 event_bus.emit( 

808 { 

809 "event": "issue.deferred", 

810 "ts": _iso_now(), 

811 "issue_id": info.issue_id, 

812 "file_path": str(deferred_path), 

813 "reason": reason, 

814 } 

815 ) 

816 return True 

817 

818 except Exception as e: 

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

820 return False 

821 

822 

823# ============================================================================= 

824# Issue Skip (Deprioritize) 

825# ============================================================================= 

826 

827 

828def _build_skip_section(reason: str | None) -> str: 

829 """Build the ## Skip Log section content.""" 

830 now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") 

831 reason_text = reason or "No reason provided" 

832 return f""" 

833 

834## Skip Log 

835 

836- **Date**: {now} 

837- **Reason**: {reason_text} 

838""" 

839 

840 

841def skip_issue(original_path: Path, new_path: Path, reason: str | None = None) -> None: 

842 """Deprioritize an issue by renaming its priority prefix. 

843 

844 Appends a ``## Skip Log`` entry with ISO timestamp and optional reason, 

845 then renames the file in-place (same directory, new priority prefix). 

846 Prefers ``git mv`` for tracked files to preserve history; falls back to 

847 an atomic ``Path.rename`` for untracked files. 

848 

849 Args: 

850 original_path: Current path to the issue file 

851 new_path: Target path (same directory, new priority prefix) 

852 reason: Optional reason text for the Skip Log entry 

853 

854 Raises: 

855 FileNotFoundError: If original_path does not exist 

856 FileExistsError: If new_path already exists 

857 """ 

858 if not original_path.exists(): 

859 raise FileNotFoundError(f"Issue file not found: {original_path}") 

860 if new_path.exists(): 

861 raise FileExistsError(f"Target already exists: {new_path}") 

862 

863 content = original_path.read_text(encoding="utf-8") + _build_skip_section(reason) 

864 

865 if _is_git_tracked(original_path): 

866 try: 

867 result = subprocess.run( 

868 ["git", "mv", str(original_path), str(new_path)], 

869 capture_output=True, 

870 text=True, 

871 timeout=30, 

872 ) 

873 except subprocess.TimeoutExpired: 

874 result = None # type: ignore[assignment] 

875 

876 if result is None or result.returncode != 0: 

877 # git mv failed — fall back to write + rename 

878 original_path.write_text(content, encoding="utf-8") 

879 original_path.rename(new_path) 

880 else: 

881 new_path.write_text(content, encoding="utf-8") 

882 else: 

883 # Not tracked — write updated content then rename atomically 

884 original_path.write_text(content, encoding="utf-8") 

885 original_path.rename(new_path) 

886 

887 

888def undefer_issue( 

889 config: BRConfig, 

890 deferred_issue_path: Path, 

891 logger: Logger, 

892 reason: str | None = None, 

893) -> Path | None: 

894 """Move an issue from deferred/ back to its original category directory. 

895 

896 Args: 

897 config: Project configuration 

898 deferred_issue_path: Path to issue in deferred/ 

899 logger: Logger for output 

900 reason: Reason for undeferring 

901 

902 Returns: 

903 New path to undeferred issue, or None if failed 

904 """ 

905 from little_loops.issue_discovery.search import _get_category_from_issue_path 

906 

907 if not deferred_issue_path.exists(): 

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

909 return None 

910 

911 # Determine target category directory from filename 

912 category = _get_category_from_issue_path(deferred_issue_path, config) 

913 target_dir = config.get_issue_dir(category) 

914 target_dir.mkdir(parents=True, exist_ok=True) 

915 

916 target_path = target_dir / deferred_issue_path.name 

917 

918 # Safety check - don't overwrite existing active issue 

919 if target_path.exists(): 

920 logger.warning(f"Active issue already exists: {target_path}") 

921 return None 

922 

923 if not reason: 

924 reason = "Ready to resume active work" 

925 

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

927 

928 try: 

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

930 content += _build_undeferred_section(reason) 

931 

932 # Parse IssueInfo before git mv so the path still exists 

933 info = IssueParser(config).parse_file(deferred_issue_path) 

934 

935 # Try git mv first for history preservation 

936 result = subprocess.run( 

937 ["git", "mv", str(deferred_issue_path), str(target_path)], 

938 capture_output=True, 

939 text=True, 

940 ) 

941 

942 if result.returncode != 0: 

943 logger.warning(f"git mv failed, using manual copy: {result.stderr}") 

944 target_path.write_text(content, encoding="utf-8") 

945 deferred_issue_path.unlink() 

946 else: 

947 target_path.write_text(content, encoding="utf-8") 

948 

949 commit_body = f"""{info.issue_id} - Undeferred 

950 

951Reason: {reason}""" 

952 _commit_issue_completion(info, "undefer", commit_body, logger) 

953 

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

955 return target_path 

956 

957 except Exception as e: 

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

959 return None