Coverage for little_loops / issue_lifecycle.py: 12%

284 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:27 -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.file_utils import atomic_write 

22from little_loops.frontmatter import parse_frontmatter, update_frontmatter 

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

24from little_loops.logger import Logger 

25from little_loops.session_log import append_session_log_entry 

26 

27 

28def _iso_now() -> str: 

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

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

31 

32 

33def _completed_at_now() -> str: 

34 """Return current UTC time as ISO 8601 with ``Z`` suffix for ``completed_at``.""" 

35 return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") 

36 

37 

38# ============================================================================= 

39# Failure Classification 

40# ============================================================================= 

41 

42 

43class FailureType(Enum): 

44 """Classification of command failure types. 

45 

46 Used to distinguish between transient errors that should not 

47 create bug issues and real implementation failures that should. 

48 """ 

49 

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

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

52 

53 

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

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

56 

57 Examines error output for patterns indicating transient failures 

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

59 

60 Args: 

61 error_output: stderr or stdout from failed command 

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

63 

64 Returns: 

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

66 """ 

67 error_lower = error_output.lower() 

68 

69 # API quota/rate limit patterns 

70 quota_patterns = [ 

71 "out of extra usage", 

72 "rate limit", 

73 "quota exceeded", 

74 "too many requests", 

75 "api limit", 

76 "usage limit", 

77 "429", # HTTP Too Many Requests 

78 "resource exhausted", 

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

80 ] 

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

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

83 

84 # Network/connectivity patterns 

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

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

87 network_patterns = [ 

88 "connection refused", 

89 "connection timeout", 

90 "network error", 

91 "dns resolution", 

92 "connection reset", 

93 "service unavailable", 

94 "502 bad gateway", 

95 "503 service unavailable", 

96 "504 gateway timeout", 

97 ] 

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

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

100 

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

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

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

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

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

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

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

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

109 

110 # Timeout patterns 

111 timeout_patterns = [ 

112 "timeout", 

113 "timed out", 

114 "deadline exceeded", 

115 "operation timed out", 

116 ] 

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

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

119 

120 # Resource/system transient patterns 

121 resource_patterns = [ 

122 "disk full", 

123 "no space left", 

124 "resource temporarily unavailable", 

125 "too many open files", 

126 "memory allocation failed", 

127 "out of memory", 

128 ] 

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

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

131 

132 # API server error patterns (distinct from rate-limits; trigger short-burst retry in executor) 

133 server_error_patterns = [ 

134 "the server had an error", 

135 "internal server error", 

136 "overloaded_error", 

137 "overloaded", 

138 "529", # Anthropic overload HTTP code 

139 "api error", # generic "API Error: ..." prefix from Claude Code 

140 ] 

141 if any(pattern in error_lower for pattern in server_error_patterns): 

142 return (FailureType.TRANSIENT, "API server error") 

143 

144 # Context window exhaustion patterns — Claude CLI exits non-zero with this on stderr 

145 context_patterns = [ 

146 "prompt is too long", 

147 "context length exceeded", 

148 "context window", 

149 "maximum context", 

150 ] 

151 if any(pattern in error_lower for pattern in context_patterns): 

152 return (FailureType.TRANSIENT, "Context window exhausted") 

153 

154 # CLI session continuation errors — --continue/--resume without a live session. 

155 # Treated as transient so a failed Option E call does not produce phantom issues. 

156 session_id_patterns = [ 

157 "requires a valid session id", 

158 "requires a valid session title", 

159 ] 

160 if any(pattern in error_lower for pattern in session_id_patterns): 

161 return (FailureType.TRANSIENT, "CLI session continuation error") 

162 

163 # Shell sandbox environment errors — the ll CLI never executed (exit 127 indicators) 

164 sandbox_patterns = [ 

165 "command not found", 

166 "read-only variable", 

167 ] 

168 if any(pattern in error_lower for pattern in sandbox_patterns): 

169 return (FailureType.TRANSIENT, "Shell sandbox environment error") 

170 

171 # Process killed by OS (SIGKILL/OOM kill) — exit 137 text signal 

172 if re.search(r"\bkilled\b", error_lower): 

173 return (FailureType.TRANSIENT, "Process killed (OOM/SIGKILL)") 

174 

175 # User-cancelled tool calls — not a defect 

176 if "<tool_use_error>" in error_output: 

177 return (FailureType.TRANSIENT, "User-cancelled tool call") 

178 

179 # Ad-hoc Python snippet tracebacks — not from an ll CLI 

180 if 'file "<string>"' in error_lower or 'file "<stdin>"' in error_lower: 

181 return (FailureType.TRANSIENT, "Ad-hoc Python snippet error, not an ll CLI failure") 

182 

183 # Default: treat as real failure 

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

185 

186 

187# ============================================================================= 

188# Content Manipulation Helpers 

189# ============================================================================= 

190 

191 

192def _build_closure_resolution( 

193 close_status: str, 

194 close_reason: str, 

195 fix_commit: str | None = None, 

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

197) -> str: 

198 """Build resolution section for closed issues. 

199 

200 Args: 

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

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

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

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

205 

206 Returns: 

207 Resolution section markdown string 

208 """ 

209 # Build fix commit line 

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

211 

212 # Build files changed section 

213 if files_changed: 

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

215 files_section = f""" 

216### Files Changed 

217{files_list} 

218""" 

219 else: 

220 files_section = "" 

221 

222 return f""" 

223 

224--- 

225 

226## Resolution 

227 

228- **Status**: {close_status} 

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

230- **Reason**: {close_reason} 

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

232{fix_commit_line} 

233### Closure Notes 

234Issue was automatically closed during validation. 

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

236{files_section}""" 

237 

238 

239def _build_completion_resolution( 

240 action: str, 

241 fix_commit: str | None = None, 

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

243) -> str: 

244 """Build resolution section for completed issues. 

245 

246 Args: 

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

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

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

250 

251 Returns: 

252 Resolution section markdown string 

253 """ 

254 # Build fix commit line 

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

256 

257 # Build files changed section 

258 if files_changed: 

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

260 files_section = f""" 

261### Files Changed 

262{files_list}""" 

263 else: 

264 files_section = """ 

265### Files Changed 

266- See git history for details""" 

267 

268 return f""" 

269 

270--- 

271 

272## Resolution 

273 

274- **Action**: {action} 

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

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

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

278{fix_commit_line} 

279{files_section} 

280 

281### Verification Results 

282- Automated verification passed 

283 

284### Commits 

285- See git log for details 

286""" 

287 

288 

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

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

291 

292 Args: 

293 original_path: Path to the original issue file 

294 resolution: Resolution section to append 

295 

296 Returns: 

297 Updated file content with resolution section 

298 """ 

299 content = original_path.read_text() 

300 if "## Resolution" not in content: 

301 content += resolution 

302 return content 

303 

304 

305# ============================================================================= 

306# Git Operations Helpers 

307# ============================================================================= 

308 

309 

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

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

312 

313 Args: 

314 file_path: Path to the file to check 

315 

316 Returns: 

317 True if file is tracked by git, False otherwise 

318 """ 

319 try: 

320 result = subprocess.run( 

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

322 capture_output=True, 

323 text=True, 

324 timeout=30, 

325 ) 

326 except subprocess.TimeoutExpired: 

327 return False 

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

329 

330 

331def _commit_issue_completion( 

332 info: IssueInfo, 

333 commit_prefix: str, 

334 commit_body: str, 

335 logger: Logger, 

336) -> bool: 

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

338 

339 Args: 

340 info: Issue information 

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

342 commit_body: Body text for commit message 

343 logger: Logger for output 

344 

345 Returns: 

346 True if commit succeeded or nothing to commit 

347 """ 

348 # Stage all changes 

349 try: 

350 stage_result = subprocess.run( 

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

352 capture_output=True, 

353 text=True, 

354 timeout=30, 

355 ) 

356 if stage_result.returncode != 0: 

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

358 except subprocess.TimeoutExpired: 

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

360 

361 # Create commit 

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

363 try: 

364 commit_result = subprocess.run( 

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

366 capture_output=True, 

367 text=True, 

368 timeout=30, 

369 ) 

370 except subprocess.TimeoutExpired: 

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

372 return True 

373 

374 if commit_result.returncode != 0: 

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

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

377 else: 

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

379 else: 

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

381 if commit_hash_match: 

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

383 else: 

384 logger.success("Committed changes") 

385 

386 return True 

387 

388 

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

390 """Verify that an issue was marked as completed via frontmatter. 

391 

392 Reads the issue file's ``status:`` frontmatter; ``done`` (or ``cancelled``) 

393 means the close path ran successfully. Files no longer move on completion, 

394 so this is a pure frontmatter check. 

395 

396 Args: 

397 info: Issue info 

398 config: Project configuration (unused; kept for signature stability) 

399 logger: Logger for output 

400 

401 Returns: 

402 True if issue's frontmatter shows it is done/cancelled 

403 """ 

404 path = info.path 

405 if not path.exists(): 

406 # Source removed without lifecycle update — treat as completed for back-compat 

407 # with any external scripts that delete files manually. 

408 logger.warning(f"Warning: {info.issue_id} source not found at {path}") 

409 return True 

410 

411 try: 

412 fm = parse_frontmatter(path.read_text(encoding="utf-8")) 

413 except Exception as e: 

414 logger.warning(f"Warning: failed to read {info.issue_id} frontmatter: {e}") 

415 return False 

416 

417 status = fm.get("status", "open") 

418 if status in ("done", "cancelled"): 

419 logger.success(f"Verified: {info.issue_id} status={status}") 

420 return True 

421 

422 logger.warning(f"Warning: {info.issue_id} status={status} (expected done/cancelled)") 

423 return False 

424 

425 

426def create_issue_from_failure( 

427 error_output: str, 

428 parent_info: IssueInfo, 

429 config: BRConfig, 

430 logger: Logger, 

431 event_bus: EventBus | None = None, 

432) -> Path | None: 

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

434 

435 Args: 

436 error_output: Error output from the failed command 

437 parent_info: Info about the issue that failed 

438 config: Project configuration 

439 logger: Logger for output 

440 event_bus: Optional EventBus for event emission 

441 

442 Returns: 

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

444 """ 

445 bug_num = get_next_issue_number(config, "bugs") 

446 prefix = config.get_issue_prefix("bugs") 

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

448 

449 # Try to extract meaningful error info 

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

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

452 

453 # Generate title from error 

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

455 if "Error" in error_output: 

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

457 if error_match: 

458 title = error_match.group(1) 

459 title_slug = slugify(title) 

460 

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

462 bugs_dir = config.get_issue_dir("bugs") 

463 new_issue_path = bugs_dir / filename 

464 

465 captured_at_val = _completed_at_now() 

466 content = f"""--- 

467id: {bug_id} 

468type: BUG 

469priority: P1 

470status: open 

471captured_at: {captured_at_val} 

472discovered_by: auto-generated 

473--- 

474 

475# {bug_id}: Implementation Failure - {parent_info.issue_id} 

476 

477## Summary 

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

479 

480## Current Behavior 

481``` 

482{traceback} 

483``` 

484 

485## Expected Behavior 

486Implementation should complete without errors. 

487 

488## Root Cause 

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

490 

491## Steps to Reproduce 

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

4932. Observe error 

494 

495## Proposed Solution 

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

497 

498## Impact 

499- **Severity**: High 

500- **Effort**: Unknown 

501- **Risk**: Medium 

502- **Breaking Change**: No 

503 

504## Labels 

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

506 

507--- 

508 

509## Status 

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

511 

512## Related Issues 

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

514""" 

515 

516 try: 

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

518 new_issue_path.write_text(content) 

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

520 if event_bus is not None: 

521 event_bus.emit( 

522 { 

523 "event": "issue.failure_captured", 

524 "ts": _iso_now(), 

525 "issue_id": bug_id, 

526 "file_path": str(new_issue_path), 

527 "parent_issue_id": parent_info.issue_id, 

528 "captured_at": captured_at_val, 

529 } 

530 ) 

531 return new_issue_path 

532 except Exception as e: 

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

534 return None 

535 

536 

537def close_issue( 

538 info: IssueInfo, 

539 config: BRConfig, 

540 logger: Logger, 

541 close_reason: str | None, 

542 close_status: str | None, 

543 fix_commit: str | None = None, 

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

545 event_bus: EventBus | None = None, 

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

547) -> bool: 

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

549 

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

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

552 

553 Args: 

554 info: Issue info 

555 config: Project configuration 

556 logger: Logger for output 

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

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

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

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

561 event_bus: Optional EventBus for event emission 

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

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

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

565 

566 Returns: 

567 True if successful, False otherwise 

568 """ 

569 original_path = info.path 

570 

571 if not original_path.exists(): 

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

573 return True 

574 

575 # Use defaults if not provided 

576 if not close_status: 

577 close_status = "Closed - Invalid" 

578 if not close_reason: 

579 close_reason = "unknown" 

580 

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

582 

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

584 if interceptors: 

585 for interceptor in interceptors: 

586 if hasattr(interceptor, "before_issue_close"): 

587 result = interceptor.before_issue_close(info) 

588 if result is False: 

589 return False 

590 

591 try: 

592 captured_at = parse_frontmatter(original_path.read_text(encoding="utf-8")).get( 

593 "captured_at" 

594 ) 

595 # Prepare content with resolution section, then write status + completed_at 

596 resolution = _build_closure_resolution( 

597 close_status, close_reason, fix_commit, files_changed 

598 ) 

599 content = _prepare_issue_content(original_path, resolution) 

600 content = update_frontmatter( 

601 content, 

602 {"status": "done", "completed_at": _completed_at_now()}, 

603 ) 

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

605 

606 # Commit the closure 

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

608 

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

610 

611Issue: {info.issue_id} 

612Reason: {close_reason} 

613Status: {close_status}""" 

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

615 

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

617 if event_bus is not None: 

618 event_bus.emit( 

619 { 

620 "event": "issue.closed", 

621 "ts": _iso_now(), 

622 "issue_id": info.issue_id, 

623 "file_path": str(original_path), 

624 "close_reason": close_reason, 

625 "captured_at": captured_at, 

626 } 

627 ) 

628 return True 

629 

630 except Exception as e: 

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

632 return False 

633 

634 

635def complete_issue_lifecycle( 

636 info: IssueInfo, 

637 config: BRConfig, 

638 logger: Logger, 

639 event_bus: EventBus | None = None, 

640) -> bool: 

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

642 

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

644 

645 Args: 

646 info: Issue info 

647 config: Project configuration 

648 logger: Logger for output 

649 event_bus: Optional EventBus for event emission 

650 

651 Returns: 

652 True if successful, False otherwise 

653 """ 

654 original_path = info.path 

655 

656 if not original_path.exists(): 

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

658 return True 

659 

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

661 

662 try: 

663 captured_at = parse_frontmatter(original_path.read_text(encoding="utf-8")).get( 

664 "captured_at" 

665 ) 

666 # Prepare content with resolution section, then write status + completed_at 

667 action = config.get_category_action(info.issue_type) 

668 resolution = _build_completion_resolution(action) 

669 content = _prepare_issue_content(original_path, resolution) 

670 content = update_frontmatter( 

671 content, 

672 {"status": "done", "completed_at": _completed_at_now()}, 

673 ) 

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

675 append_session_log_entry(original_path, "ll-auto") 

676 

677 # Commit the completion 

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

679 

680Automated fallback commit - command exited before completion. 

681 

682Issue: {info.issue_id} 

683Action: {action} 

684Status: Completed via fallback lifecycle completion""" 

685 _commit_issue_completion(info, action, commit_body, logger) 

686 

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

688 if event_bus is not None: 

689 event_bus.emit( 

690 { 

691 "event": "issue.completed", 

692 "ts": _iso_now(), 

693 "issue_id": info.issue_id, 

694 "file_path": str(original_path), 

695 "captured_at": captured_at, 

696 } 

697 ) 

698 return True 

699 

700 except Exception as e: 

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

702 return False 

703 

704 

705# ============================================================================= 

706# Issue Deferral 

707# ============================================================================= 

708 

709 

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

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

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

713 return f""" 

714 

715## Deferred 

716 

717- **Date**: {now} 

718- **Reason**: {reason} 

719""" 

720 

721 

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

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

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

725 return f""" 

726 

727## Undeferred 

728 

729- **Date**: {now} 

730- **Reason**: {reason} 

731""" 

732 

733 

734def defer_issue( 

735 info: IssueInfo, 

736 config: BRConfig, 

737 logger: Logger, 

738 reason: str | None = None, 

739 event_bus: EventBus | None = None, 

740) -> bool: 

741 """Defer an issue by writing ``status: deferred`` to its frontmatter. 

742 

743 The file remains in its type directory; only the ``status:`` field changes. 

744 

745 Args: 

746 info: Issue info 

747 config: Project configuration (unused; kept for signature stability) 

748 logger: Logger for output 

749 reason: Reason for deferring 

750 event_bus: Optional EventBus for event emission 

751 

752 Returns: 

753 True if successful, False otherwise 

754 """ 

755 original_path = info.path 

756 

757 if not original_path.exists(): 

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

759 return True 

760 

761 if not reason: 

762 reason = "Intentionally set aside for later consideration" 

763 

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

765 

766 try: 

767 deferred_section = _build_deferred_section(reason) 

768 raw_content = original_path.read_text(encoding="utf-8") 

769 captured_at = parse_frontmatter(raw_content).get("captured_at") 

770 content = raw_content + deferred_section 

771 content = update_frontmatter(content, {"status": "deferred"}) 

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

773 

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

775 

776Reason: {reason}""" 

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

778 

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

780 if event_bus is not None: 

781 event_bus.emit( 

782 { 

783 "event": "issue.deferred", 

784 "ts": _iso_now(), 

785 "issue_id": info.issue_id, 

786 "file_path": str(original_path), 

787 "reason": reason, 

788 "captured_at": captured_at, 

789 } 

790 ) 

791 return True 

792 

793 except Exception as e: 

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

795 return False 

796 

797 

798# ============================================================================= 

799# Issue Skip (Deprioritize) 

800# ============================================================================= 

801 

802 

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

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

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

806 reason_text = reason or "No reason provided" 

807 return f""" 

808 

809## Skip Log 

810 

811- **Date**: {now} 

812- **Reason**: {reason_text} 

813""" 

814 

815 

816def skip_issue( 

817 original_path: Path, 

818 new_path: Path, 

819 reason: str | None = None, 

820 event_bus: EventBus | None = None, 

821) -> None: 

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

823 

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

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

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

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

828 

829 Args: 

830 original_path: Current path to the issue file 

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

832 reason: Optional reason text for the Skip Log entry 

833 event_bus: Optional EventBus for emitting ``issue.skipped`` 

834 

835 Raises: 

836 FileNotFoundError: If original_path does not exist 

837 FileExistsError: If new_path already exists 

838 """ 

839 if not original_path.exists(): 

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

841 if new_path.exists(): 

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

843 

844 raw_content = original_path.read_text(encoding="utf-8") 

845 captured_at = parse_frontmatter(raw_content).get("captured_at") 

846 content = raw_content + _build_skip_section(reason) 

847 

848 if _is_git_tracked(original_path): 

849 try: 

850 result = subprocess.run( 

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

852 capture_output=True, 

853 text=True, 

854 timeout=30, 

855 ) 

856 except subprocess.TimeoutExpired: 

857 result = None # type: ignore[assignment] 

858 

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

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

861 atomic_write(original_path, content, encoding="utf-8") 

862 original_path.rename(new_path) 

863 else: 

864 atomic_write(new_path, content, encoding="utf-8") 

865 else: 

866 # Not tracked — write updated content then rename atomically 

867 atomic_write(original_path, content, encoding="utf-8") 

868 original_path.rename(new_path) 

869 

870 if event_bus is not None: 

871 m = re.match(r"P\d+-([A-Z]+-\d+)-", new_path.name) 

872 issue_id = m.group(1) if m else str(new_path.stem) 

873 event_bus.emit( 

874 { 

875 "event": "issue.skipped", 

876 "ts": _iso_now(), 

877 "issue_id": issue_id, 

878 "file_path": str(new_path), 

879 "reason": reason, 

880 "captured_at": captured_at, 

881 } 

882 ) 

883 

884 

885def undefer_issue( 

886 config: BRConfig, 

887 deferred_issue_path: Path, 

888 logger: Logger, 

889 reason: str | None = None, 

890 event_bus: EventBus | None = None, 

891) -> Path | None: 

892 """Undefer an issue by writing ``status: open`` to its frontmatter. 

893 

894 The file remains where it is (in its type directory); only the ``status:`` 

895 field is updated. 

896 

897 Args: 

898 config: Project configuration 

899 deferred_issue_path: Path to deferred issue (still in its type dir) 

900 logger: Logger for output 

901 reason: Reason for undeferring 

902 

903 Returns: 

904 Path to undeferred issue, or None if failed 

905 """ 

906 if not deferred_issue_path.exists(): 

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

908 return None 

909 

910 if not reason: 

911 reason = "Ready to resume active work" 

912 

913 logger.info(f"Undeferring {deferred_issue_path.name}") 

914 

915 try: 

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

917 

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

919 captured_at = parse_frontmatter(content).get("captured_at") 

920 content += _build_undeferred_section(reason) 

921 content = update_frontmatter(content, {"status": "open"}) 

922 deferred_issue_path.write_text(content, encoding="utf-8") 

923 

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

925 

926Reason: {reason}""" 

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

928 

929 logger.success(f"Undeferred: {deferred_issue_path.name}") 

930 if event_bus is not None: 

931 event_bus.emit( 

932 { 

933 "event": "issue.started", 

934 "ts": _iso_now(), 

935 "issue_id": info.issue_id, 

936 "file_path": str(deferred_issue_path), 

937 "reason": reason, 

938 "captured_at": captured_at, 

939 } 

940 ) 

941 return deferred_issue_path 

942 

943 except Exception as e: 

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

945 return None