Coverage for little_loops / issue_lifecycle.py: 13%

275 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -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 # Default: treat as real failure 

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

165 

166 

167# ============================================================================= 

168# Content Manipulation Helpers 

169# ============================================================================= 

170 

171 

172def _build_closure_resolution( 

173 close_status: str, 

174 close_reason: str, 

175 fix_commit: str | None = None, 

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

177) -> str: 

178 """Build resolution section for closed issues. 

179 

180 Args: 

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

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

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

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

185 

186 Returns: 

187 Resolution section markdown string 

188 """ 

189 # Build fix commit line 

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

191 

192 # Build files changed section 

193 if files_changed: 

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

195 files_section = f""" 

196### Files Changed 

197{files_list} 

198""" 

199 else: 

200 files_section = "" 

201 

202 return f""" 

203 

204--- 

205 

206## Resolution 

207 

208- **Status**: {close_status} 

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

210- **Reason**: {close_reason} 

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

212{fix_commit_line} 

213### Closure Notes 

214Issue was automatically closed during validation. 

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

216{files_section}""" 

217 

218 

219def _build_completion_resolution( 

220 action: str, 

221 fix_commit: str | None = None, 

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

223) -> str: 

224 """Build resolution section for completed issues. 

225 

226 Args: 

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

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

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

230 

231 Returns: 

232 Resolution section markdown string 

233 """ 

234 # Build fix commit line 

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

236 

237 # Build files changed section 

238 if files_changed: 

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

240 files_section = f""" 

241### Files Changed 

242{files_list}""" 

243 else: 

244 files_section = """ 

245### Files Changed 

246- See git history for details""" 

247 

248 return f""" 

249 

250--- 

251 

252## Resolution 

253 

254- **Action**: {action} 

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

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

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

258{fix_commit_line} 

259{files_section} 

260 

261### Verification Results 

262- Automated verification passed 

263 

264### Commits 

265- See git log for details 

266""" 

267 

268 

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

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

271 

272 Args: 

273 original_path: Path to the original issue file 

274 resolution: Resolution section to append 

275 

276 Returns: 

277 Updated file content with resolution section 

278 """ 

279 content = original_path.read_text() 

280 if "## Resolution" not in content: 

281 content += resolution 

282 return content 

283 

284 

285# ============================================================================= 

286# Git Operations Helpers 

287# ============================================================================= 

288 

289 

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

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

292 

293 Args: 

294 file_path: Path to the file to check 

295 

296 Returns: 

297 True if file is tracked by git, False otherwise 

298 """ 

299 try: 

300 result = subprocess.run( 

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

302 capture_output=True, 

303 text=True, 

304 timeout=30, 

305 ) 

306 except subprocess.TimeoutExpired: 

307 return False 

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

309 

310 

311def _commit_issue_completion( 

312 info: IssueInfo, 

313 commit_prefix: str, 

314 commit_body: str, 

315 logger: Logger, 

316) -> bool: 

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

318 

319 Args: 

320 info: Issue information 

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

322 commit_body: Body text for commit message 

323 logger: Logger for output 

324 

325 Returns: 

326 True if commit succeeded or nothing to commit 

327 """ 

328 # Stage all changes 

329 try: 

330 stage_result = subprocess.run( 

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

332 capture_output=True, 

333 text=True, 

334 timeout=30, 

335 ) 

336 if stage_result.returncode != 0: 

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

338 except subprocess.TimeoutExpired: 

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

340 

341 # Create commit 

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

343 try: 

344 commit_result = subprocess.run( 

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

346 capture_output=True, 

347 text=True, 

348 timeout=30, 

349 ) 

350 except subprocess.TimeoutExpired: 

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

352 return True 

353 

354 if commit_result.returncode != 0: 

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

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

357 else: 

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

359 else: 

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

361 if commit_hash_match: 

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

363 else: 

364 logger.success("Committed changes") 

365 

366 return True 

367 

368 

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

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

371 

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

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

374 so this is a pure frontmatter check. 

375 

376 Args: 

377 info: Issue info 

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

379 logger: Logger for output 

380 

381 Returns: 

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

383 """ 

384 path = info.path 

385 if not path.exists(): 

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

387 # with any external scripts that delete files manually. 

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

389 return True 

390 

391 try: 

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

393 except Exception as e: 

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

395 return False 

396 

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

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

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

400 return True 

401 

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

403 return False 

404 

405 

406def create_issue_from_failure( 

407 error_output: str, 

408 parent_info: IssueInfo, 

409 config: BRConfig, 

410 logger: Logger, 

411 event_bus: EventBus | None = None, 

412) -> Path | None: 

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

414 

415 Args: 

416 error_output: Error output from the failed command 

417 parent_info: Info about the issue that failed 

418 config: Project configuration 

419 logger: Logger for output 

420 event_bus: Optional EventBus for event emission 

421 

422 Returns: 

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

424 """ 

425 bug_num = get_next_issue_number(config, "bugs") 

426 prefix = config.get_issue_prefix("bugs") 

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

428 

429 # Try to extract meaningful error info 

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

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

432 

433 # Generate title from error 

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

435 if "Error" in error_output: 

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

437 if error_match: 

438 title = error_match.group(1) 

439 title_slug = slugify(title) 

440 

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

442 bugs_dir = config.get_issue_dir("bugs") 

443 new_issue_path = bugs_dir / filename 

444 

445 captured_at_val = _completed_at_now() 

446 content = f"""--- 

447id: {bug_id} 

448type: BUG 

449priority: P1 

450status: open 

451captured_at: {captured_at_val} 

452discovered_by: auto-generated 

453--- 

454 

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

456 

457## Summary 

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

459 

460## Current Behavior 

461``` 

462{traceback} 

463``` 

464 

465## Expected Behavior 

466Implementation should complete without errors. 

467 

468## Root Cause 

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

470 

471## Steps to Reproduce 

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

4732. Observe error 

474 

475## Proposed Solution 

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

477 

478## Impact 

479- **Severity**: High 

480- **Effort**: Unknown 

481- **Risk**: Medium 

482- **Breaking Change**: No 

483 

484## Labels 

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

486 

487--- 

488 

489## Status 

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

491 

492## Related Issues 

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

494""" 

495 

496 try: 

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

498 new_issue_path.write_text(content) 

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

500 if event_bus is not None: 

501 event_bus.emit( 

502 { 

503 "event": "issue.failure_captured", 

504 "ts": _iso_now(), 

505 "issue_id": bug_id, 

506 "file_path": str(new_issue_path), 

507 "parent_issue_id": parent_info.issue_id, 

508 "captured_at": captured_at_val, 

509 } 

510 ) 

511 return new_issue_path 

512 except Exception as e: 

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

514 return None 

515 

516 

517def close_issue( 

518 info: IssueInfo, 

519 config: BRConfig, 

520 logger: Logger, 

521 close_reason: str | None, 

522 close_status: str | None, 

523 fix_commit: str | None = None, 

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

525 event_bus: EventBus | None = None, 

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

527) -> bool: 

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

529 

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

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

532 

533 Args: 

534 info: Issue info 

535 config: Project configuration 

536 logger: Logger for output 

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

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

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

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

541 event_bus: Optional EventBus for event emission 

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

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

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

545 

546 Returns: 

547 True if successful, False otherwise 

548 """ 

549 original_path = info.path 

550 

551 if not original_path.exists(): 

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

553 return True 

554 

555 # Use defaults if not provided 

556 if not close_status: 

557 close_status = "Closed - Invalid" 

558 if not close_reason: 

559 close_reason = "unknown" 

560 

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

562 

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

564 if interceptors: 

565 for interceptor in interceptors: 

566 if hasattr(interceptor, "before_issue_close"): 

567 result = interceptor.before_issue_close(info) 

568 if result is False: 

569 return False 

570 

571 try: 

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

573 "captured_at" 

574 ) 

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

576 resolution = _build_closure_resolution( 

577 close_status, close_reason, fix_commit, files_changed 

578 ) 

579 content = _prepare_issue_content(original_path, resolution) 

580 content = update_frontmatter( 

581 content, 

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

583 ) 

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

585 

586 # Commit the closure 

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

588 

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

590 

591Issue: {info.issue_id} 

592Reason: {close_reason} 

593Status: {close_status}""" 

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

595 

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

597 if event_bus is not None: 

598 event_bus.emit( 

599 { 

600 "event": "issue.closed", 

601 "ts": _iso_now(), 

602 "issue_id": info.issue_id, 

603 "file_path": str(original_path), 

604 "close_reason": close_reason, 

605 "captured_at": captured_at, 

606 } 

607 ) 

608 return True 

609 

610 except Exception as e: 

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

612 return False 

613 

614 

615def complete_issue_lifecycle( 

616 info: IssueInfo, 

617 config: BRConfig, 

618 logger: Logger, 

619 event_bus: EventBus | None = None, 

620) -> bool: 

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

622 

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

624 

625 Args: 

626 info: Issue info 

627 config: Project configuration 

628 logger: Logger for output 

629 event_bus: Optional EventBus for event emission 

630 

631 Returns: 

632 True if successful, False otherwise 

633 """ 

634 original_path = info.path 

635 

636 if not original_path.exists(): 

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

638 return True 

639 

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

641 

642 try: 

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

644 "captured_at" 

645 ) 

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

647 action = config.get_category_action(info.issue_type) 

648 resolution = _build_completion_resolution(action) 

649 content = _prepare_issue_content(original_path, resolution) 

650 content = update_frontmatter( 

651 content, 

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

653 ) 

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

655 append_session_log_entry(original_path, "ll-auto") 

656 

657 # Commit the completion 

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

659 

660Automated fallback commit - command exited before completion. 

661 

662Issue: {info.issue_id} 

663Action: {action} 

664Status: Completed via fallback lifecycle completion""" 

665 _commit_issue_completion(info, action, commit_body, logger) 

666 

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

668 if event_bus is not None: 

669 event_bus.emit( 

670 { 

671 "event": "issue.completed", 

672 "ts": _iso_now(), 

673 "issue_id": info.issue_id, 

674 "file_path": str(original_path), 

675 "captured_at": captured_at, 

676 } 

677 ) 

678 return True 

679 

680 except Exception as e: 

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

682 return False 

683 

684 

685# ============================================================================= 

686# Issue Deferral 

687# ============================================================================= 

688 

689 

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

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

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

693 return f""" 

694 

695## Deferred 

696 

697- **Date**: {now} 

698- **Reason**: {reason} 

699""" 

700 

701 

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

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

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

705 return f""" 

706 

707## Undeferred 

708 

709- **Date**: {now} 

710- **Reason**: {reason} 

711""" 

712 

713 

714def defer_issue( 

715 info: IssueInfo, 

716 config: BRConfig, 

717 logger: Logger, 

718 reason: str | None = None, 

719 event_bus: EventBus | None = None, 

720) -> bool: 

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

722 

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

724 

725 Args: 

726 info: Issue info 

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

728 logger: Logger for output 

729 reason: Reason for deferring 

730 event_bus: Optional EventBus for event emission 

731 

732 Returns: 

733 True if successful, False otherwise 

734 """ 

735 original_path = info.path 

736 

737 if not original_path.exists(): 

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

739 return True 

740 

741 if not reason: 

742 reason = "Intentionally set aside for later consideration" 

743 

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

745 

746 try: 

747 deferred_section = _build_deferred_section(reason) 

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

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

750 content = raw_content + deferred_section 

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

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

753 

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

755 

756Reason: {reason}""" 

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

758 

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

760 if event_bus is not None: 

761 event_bus.emit( 

762 { 

763 "event": "issue.deferred", 

764 "ts": _iso_now(), 

765 "issue_id": info.issue_id, 

766 "file_path": str(original_path), 

767 "reason": reason, 

768 "captured_at": captured_at, 

769 } 

770 ) 

771 return True 

772 

773 except Exception as e: 

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

775 return False 

776 

777 

778# ============================================================================= 

779# Issue Skip (Deprioritize) 

780# ============================================================================= 

781 

782 

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

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

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

786 reason_text = reason or "No reason provided" 

787 return f""" 

788 

789## Skip Log 

790 

791- **Date**: {now} 

792- **Reason**: {reason_text} 

793""" 

794 

795 

796def skip_issue( 

797 original_path: Path, 

798 new_path: Path, 

799 reason: str | None = None, 

800 event_bus: EventBus | None = None, 

801) -> None: 

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

803 

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

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

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

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

808 

809 Args: 

810 original_path: Current path to the issue file 

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

812 reason: Optional reason text for the Skip Log entry 

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

814 

815 Raises: 

816 FileNotFoundError: If original_path does not exist 

817 FileExistsError: If new_path already exists 

818 """ 

819 if not original_path.exists(): 

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

821 if new_path.exists(): 

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

823 

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

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

826 content = raw_content + _build_skip_section(reason) 

827 

828 if _is_git_tracked(original_path): 

829 try: 

830 result = subprocess.run( 

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

832 capture_output=True, 

833 text=True, 

834 timeout=30, 

835 ) 

836 except subprocess.TimeoutExpired: 

837 result = None # type: ignore[assignment] 

838 

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

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

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

842 original_path.rename(new_path) 

843 else: 

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

845 else: 

846 # Not tracked — write updated content then rename atomically 

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

848 original_path.rename(new_path) 

849 

850 if event_bus is not None: 

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

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

853 event_bus.emit( 

854 { 

855 "event": "issue.skipped", 

856 "ts": _iso_now(), 

857 "issue_id": issue_id, 

858 "file_path": str(new_path), 

859 "reason": reason, 

860 "captured_at": captured_at, 

861 } 

862 ) 

863 

864 

865def undefer_issue( 

866 config: BRConfig, 

867 deferred_issue_path: Path, 

868 logger: Logger, 

869 reason: str | None = None, 

870 event_bus: EventBus | None = None, 

871) -> Path | None: 

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

873 

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

875 field is updated. 

876 

877 Args: 

878 config: Project configuration 

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

880 logger: Logger for output 

881 reason: Reason for undeferring 

882 

883 Returns: 

884 Path to undeferred issue, or None if failed 

885 """ 

886 if not deferred_issue_path.exists(): 

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

888 return None 

889 

890 if not reason: 

891 reason = "Ready to resume active work" 

892 

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

894 

895 try: 

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

897 

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

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

900 content += _build_undeferred_section(reason) 

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

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

903 

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

905 

906Reason: {reason}""" 

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

908 

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

910 if event_bus is not None: 

911 event_bus.emit( 

912 { 

913 "event": "issue.started", 

914 "ts": _iso_now(), 

915 "issue_id": info.issue_id, 

916 "file_path": str(deferred_issue_path), 

917 "reason": reason, 

918 "captured_at": captured_at, 

919 } 

920 ) 

921 return deferred_issue_path 

922 

923 except Exception as e: 

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

925 return None