Coverage for little_loops / issue_lifecycle.py: 12%

288 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -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 NON_RECOVERABLE = ( 

52 "non_recoverable" # Auth/credential failure — retry won't help, not a code bug 

53 ) 

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

55 

56 

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

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

59 

60 Examines error output for patterns indicating transient failures 

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

62 

63 Args: 

64 error_output: stderr or stdout from failed command 

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

66 

67 Returns: 

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

69 """ 

70 error_lower = error_output.lower() 

71 

72 # API quota/rate limit patterns 

73 quota_patterns = [ 

74 "out of extra usage", 

75 "rate limit", 

76 "quota exceeded", 

77 "too many requests", 

78 "api limit", 

79 "usage limit", 

80 "429", # HTTP Too Many Requests 

81 "resource exhausted", 

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

83 ] 

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

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

86 

87 # Network/connectivity patterns 

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

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

90 network_patterns = [ 

91 "connection refused", 

92 "connection timeout", 

93 "network error", 

94 "dns resolution", 

95 "connection reset", 

96 "service unavailable", 

97 "502 bad gateway", 

98 "503 service unavailable", 

99 "504 gateway timeout", 

100 ] 

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

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

103 

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

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

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

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

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

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

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

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

112 

113 # Timeout patterns 

114 timeout_patterns = [ 

115 "timeout", 

116 "timed out", 

117 "deadline exceeded", 

118 "operation timed out", 

119 ] 

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

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

122 

123 # Resource/system transient patterns 

124 resource_patterns = [ 

125 "disk full", 

126 "no space left", 

127 "resource temporarily unavailable", 

128 "too many open files", 

129 "memory allocation failed", 

130 "out of memory", 

131 ] 

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

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

134 

135 # Auth/credential failure patterns — non-recoverable (retry cannot fix an expired/invalid token) 

136 # Placed before server_error_patterns because "api error" in that list could also match auth text. 

137 auth_patterns = [ 

138 "401", 

139 "403", 

140 "unauthorized", 

141 "forbidden", 

142 "authentication", 

143 "invalid api key", 

144 "invalid_api_key", 

145 "expired token", 

146 ] 

147 if any(pattern in error_lower for pattern in auth_patterns): 

148 return (FailureType.NON_RECOVERABLE, "Auth/credentials failure") 

149 

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

151 server_error_patterns = [ 

152 "the server had an error", 

153 "internal server error", 

154 "overloaded_error", 

155 "overloaded", 

156 "529", # Anthropic overload HTTP code 

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

158 ] 

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

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

161 

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

163 context_patterns = [ 

164 "prompt is too long", 

165 "context length exceeded", 

166 "context window", 

167 "maximum context", 

168 ] 

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

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

171 

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

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

174 session_id_patterns = [ 

175 "requires a valid session id", 

176 "requires a valid session title", 

177 ] 

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

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

180 

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

182 sandbox_patterns = [ 

183 "command not found", 

184 "read-only variable", 

185 ] 

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

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

188 

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

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

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

192 

193 # User-cancelled tool calls — not a defect 

194 if "<tool_use_error>" in error_output: 

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

196 

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

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

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

200 

201 # Default: treat as real failure 

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

203 

204 

205# ============================================================================= 

206# Content Manipulation Helpers 

207# ============================================================================= 

208 

209 

210def _build_closure_resolution( 

211 close_status: str, 

212 close_reason: str, 

213 fix_commit: str | None = None, 

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

215) -> str: 

216 """Build resolution section for closed issues. 

217 

218 Args: 

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

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

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

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

223 

224 Returns: 

225 Resolution section markdown string 

226 """ 

227 # Build fix commit line 

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

229 

230 # Build files changed section 

231 if files_changed: 

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

233 files_section = f""" 

234### Files Changed 

235{files_list} 

236""" 

237 else: 

238 files_section = "" 

239 

240 return f""" 

241 

242--- 

243 

244## Resolution 

245 

246- **Status**: {close_status} 

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

248- **Reason**: {close_reason} 

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

250{fix_commit_line} 

251### Closure Notes 

252Issue was automatically closed during validation. 

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

254{files_section}""" 

255 

256 

257def _build_completion_resolution( 

258 action: str, 

259 fix_commit: str | None = None, 

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

261) -> str: 

262 """Build resolution section for completed issues. 

263 

264 Args: 

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

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

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

268 

269 Returns: 

270 Resolution section markdown string 

271 """ 

272 # Build fix commit line 

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

274 

275 # Build files changed section 

276 if files_changed: 

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

278 files_section = f""" 

279### Files Changed 

280{files_list}""" 

281 else: 

282 files_section = """ 

283### Files Changed 

284- See git history for details""" 

285 

286 return f""" 

287 

288--- 

289 

290## Resolution 

291 

292- **Action**: {action} 

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

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

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

296{fix_commit_line} 

297{files_section} 

298 

299### Verification Results 

300- Automated verification passed 

301 

302### Commits 

303- See git log for details 

304""" 

305 

306 

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

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

309 

310 Args: 

311 original_path: Path to the original issue file 

312 resolution: Resolution section to append 

313 

314 Returns: 

315 Updated file content with resolution section 

316 """ 

317 content = original_path.read_text() 

318 if "## Resolution" not in content: 

319 content += resolution 

320 return content 

321 

322 

323# ============================================================================= 

324# Git Operations Helpers 

325# ============================================================================= 

326 

327 

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

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

330 

331 Args: 

332 file_path: Path to the file to check 

333 

334 Returns: 

335 True if file is tracked by git, False otherwise 

336 """ 

337 try: 

338 result = subprocess.run( 

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

340 capture_output=True, 

341 text=True, 

342 timeout=30, 

343 ) 

344 except subprocess.TimeoutExpired: 

345 return False 

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

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 marked as completed via frontmatter. 

409 

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

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

412 so this is a pure frontmatter check. 

413 

414 Args: 

415 info: Issue info 

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

417 logger: Logger for output 

418 

419 Returns: 

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

421 """ 

422 path = info.path 

423 if not path.exists(): 

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

425 # with any external scripts that delete files manually. 

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

427 return True 

428 

429 try: 

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

431 except Exception as e: 

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

433 return False 

434 

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

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

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

438 return True 

439 

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

441 return False 

442 

443 

444def create_issue_from_failure( 

445 error_output: str, 

446 parent_info: IssueInfo, 

447 config: BRConfig, 

448 logger: Logger, 

449 event_bus: EventBus | None = None, 

450) -> Path | None: 

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

452 

453 Args: 

454 error_output: Error output from the failed command 

455 parent_info: Info about the issue that failed 

456 config: Project configuration 

457 logger: Logger for output 

458 event_bus: Optional EventBus for event emission 

459 

460 Returns: 

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

462 """ 

463 bug_num = get_next_issue_number(config, "bugs") 

464 prefix = config.get_issue_prefix("bugs") 

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

466 

467 # Try to extract meaningful error info 

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

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

470 

471 # Generate title from error 

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

473 if "Error" in error_output: 

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

475 if error_match: 

476 title = error_match.group(1) 

477 title_slug = slugify(title) 

478 

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

480 bugs_dir = config.get_issue_dir("bugs") 

481 new_issue_path = bugs_dir / filename 

482 

483 captured_at_val = _completed_at_now() 

484 content = f"""--- 

485id: {bug_id} 

486type: BUG 

487priority: P1 

488status: open 

489captured_at: {captured_at_val} 

490discovered_by: auto-generated 

491--- 

492 

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

494 

495## Summary 

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

497 

498## Current Behavior 

499``` 

500{traceback} 

501``` 

502 

503## Expected Behavior 

504Implementation should complete without errors. 

505 

506## Root Cause 

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

508 

509## Steps to Reproduce 

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

5112. Observe error 

512 

513## Proposed Solution 

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

515 

516## Impact 

517- **Severity**: High 

518- **Effort**: Unknown 

519- **Risk**: Medium 

520- **Breaking Change**: No 

521 

522## Labels 

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

524 

525--- 

526 

527## Status 

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

529 

530## Related Issues 

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

532""" 

533 

534 try: 

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

536 new_issue_path.write_text(content) 

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

538 if event_bus is not None: 

539 event_bus.emit( 

540 { 

541 "event": "issue.failure_captured", 

542 "ts": _iso_now(), 

543 "issue_id": bug_id, 

544 "file_path": str(new_issue_path), 

545 "parent_issue_id": parent_info.issue_id, 

546 "captured_at": captured_at_val, 

547 } 

548 ) 

549 return new_issue_path 

550 except Exception as e: 

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

552 return None 

553 

554 

555def close_issue( 

556 info: IssueInfo, 

557 config: BRConfig, 

558 logger: Logger, 

559 close_reason: str | None, 

560 close_status: str | None, 

561 fix_commit: str | None = None, 

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

563 event_bus: EventBus | None = None, 

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

565) -> bool: 

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

567 

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

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

570 

571 Args: 

572 info: Issue info 

573 config: Project configuration 

574 logger: Logger for output 

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

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

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

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

579 event_bus: Optional EventBus for event emission 

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

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

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

583 

584 Returns: 

585 True if successful, False otherwise 

586 """ 

587 original_path = info.path 

588 

589 if not original_path.exists(): 

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

591 return True 

592 

593 # Use defaults if not provided 

594 if not close_status: 

595 close_status = "Closed - Invalid" 

596 if not close_reason: 

597 close_reason = "unknown" 

598 

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

600 

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

602 if interceptors: 

603 for interceptor in interceptors: 

604 if hasattr(interceptor, "before_issue_close"): 

605 result = interceptor.before_issue_close(info) 

606 if result is False: 

607 return False 

608 

609 try: 

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

611 "captured_at" 

612 ) 

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

614 resolution = _build_closure_resolution( 

615 close_status, close_reason, fix_commit, files_changed 

616 ) 

617 content = _prepare_issue_content(original_path, resolution) 

618 content = update_frontmatter( 

619 content, 

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

621 ) 

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

623 

624 # Commit the closure 

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

626 

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

628 

629Issue: {info.issue_id} 

630Reason: {close_reason} 

631Status: {close_status}""" 

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

633 

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

635 if event_bus is not None: 

636 event_bus.emit( 

637 { 

638 "event": "issue.closed", 

639 "ts": _iso_now(), 

640 "issue_id": info.issue_id, 

641 "file_path": str(original_path), 

642 "close_reason": close_reason, 

643 "captured_at": captured_at, 

644 } 

645 ) 

646 return True 

647 

648 except Exception as e: 

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

650 return False 

651 

652 

653def complete_issue_lifecycle( 

654 info: IssueInfo, 

655 config: BRConfig, 

656 logger: Logger, 

657 event_bus: EventBus | None = None, 

658) -> bool: 

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

660 

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

662 

663 Args: 

664 info: Issue info 

665 config: Project configuration 

666 logger: Logger for output 

667 event_bus: Optional EventBus for event emission 

668 

669 Returns: 

670 True if successful, False otherwise 

671 """ 

672 original_path = info.path 

673 

674 if not original_path.exists(): 

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

676 return True 

677 

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

679 

680 try: 

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

682 "captured_at" 

683 ) 

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

685 action = config.get_category_action(info.issue_type) 

686 resolution = _build_completion_resolution(action) 

687 content = _prepare_issue_content(original_path, resolution) 

688 content = update_frontmatter( 

689 content, 

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

691 ) 

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

693 append_session_log_entry(original_path, "ll-auto") 

694 

695 # Commit the completion 

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

697 

698Automated fallback commit - command exited before completion. 

699 

700Issue: {info.issue_id} 

701Action: {action} 

702Status: Completed via fallback lifecycle completion""" 

703 _commit_issue_completion(info, action, commit_body, logger) 

704 

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

706 if event_bus is not None: 

707 event_bus.emit( 

708 { 

709 "event": "issue.completed", 

710 "ts": _iso_now(), 

711 "issue_id": info.issue_id, 

712 "file_path": str(original_path), 

713 "captured_at": captured_at, 

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 writing ``status: deferred`` to its frontmatter. 

760 

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

762 

763 Args: 

764 info: Issue info 

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

766 logger: Logger for output 

767 reason: Reason for deferring 

768 event_bus: Optional EventBus for event emission 

769 

770 Returns: 

771 True if successful, False otherwise 

772 """ 

773 original_path = info.path 

774 

775 if not original_path.exists(): 

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

777 return True 

778 

779 if not reason: 

780 reason = "Intentionally set aside for later consideration" 

781 

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

783 

784 try: 

785 deferred_section = _build_deferred_section(reason) 

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

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

788 content = raw_content + deferred_section 

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

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

791 

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

793 

794Reason: {reason}""" 

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

796 

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

798 if event_bus is not None: 

799 event_bus.emit( 

800 { 

801 "event": "issue.deferred", 

802 "ts": _iso_now(), 

803 "issue_id": info.issue_id, 

804 "file_path": str(original_path), 

805 "reason": reason, 

806 "captured_at": captured_at, 

807 } 

808 ) 

809 return True 

810 

811 except Exception as e: 

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

813 return False 

814 

815 

816# ============================================================================= 

817# Issue Skip (Deprioritize) 

818# ============================================================================= 

819 

820 

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

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

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

824 reason_text = reason or "No reason provided" 

825 return f""" 

826 

827## Skip Log 

828 

829- **Date**: {now} 

830- **Reason**: {reason_text} 

831""" 

832 

833 

834def skip_issue( 

835 original_path: Path, 

836 new_path: Path, 

837 reason: str | None = None, 

838 event_bus: EventBus | None = None, 

839) -> None: 

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

841 

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

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

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

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

846 

847 Args: 

848 original_path: Current path to the issue file 

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

850 reason: Optional reason text for the Skip Log entry 

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

852 

853 Raises: 

854 FileNotFoundError: If original_path does not exist 

855 FileExistsError: If new_path already exists 

856 """ 

857 if not original_path.exists(): 

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

859 if new_path.exists(): 

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

861 

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

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

864 content = raw_content + _build_skip_section(reason) 

865 

866 if _is_git_tracked(original_path): 

867 try: 

868 result = subprocess.run( 

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

870 capture_output=True, 

871 text=True, 

872 timeout=30, 

873 ) 

874 except subprocess.TimeoutExpired: 

875 result = None # type: ignore[assignment] 

876 

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

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

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

880 original_path.rename(new_path) 

881 else: 

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

883 else: 

884 # Not tracked — write updated content then rename atomically 

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

886 original_path.rename(new_path) 

887 

888 if event_bus is not None: 

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

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

891 event_bus.emit( 

892 { 

893 "event": "issue.skipped", 

894 "ts": _iso_now(), 

895 "issue_id": issue_id, 

896 "file_path": str(new_path), 

897 "reason": reason, 

898 "captured_at": captured_at, 

899 } 

900 ) 

901 

902 

903def undefer_issue( 

904 config: BRConfig, 

905 deferred_issue_path: Path, 

906 logger: Logger, 

907 reason: str | None = None, 

908 event_bus: EventBus | None = None, 

909) -> Path | None: 

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

911 

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

913 field is updated. 

914 

915 Args: 

916 config: Project configuration 

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

918 logger: Logger for output 

919 reason: Reason for undeferring 

920 

921 Returns: 

922 Path to undeferred issue, or None if failed 

923 """ 

924 if not deferred_issue_path.exists(): 

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

926 return None 

927 

928 if not reason: 

929 reason = "Ready to resume active work" 

930 

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

932 

933 try: 

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

935 

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

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

938 content += _build_undeferred_section(reason) 

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

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

941 

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

943 

944Reason: {reason}""" 

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

946 

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

948 if event_bus is not None: 

949 event_bus.emit( 

950 { 

951 "event": "issue.started", 

952 "ts": _iso_now(), 

953 "issue_id": info.issue_id, 

954 "file_path": str(deferred_issue_path), 

955 "reason": reason, 

956 "captured_at": captured_at, 

957 } 

958 ) 

959 return deferred_issue_path 

960 

961 except Exception as e: 

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

963 return None