Coverage for little_loops / issue_lifecycle.py: 13%

268 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-28 13:07 -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 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 from little_loops.frontmatter import parse_frontmatter 

385 

386 path = info.path 

387 if not path.exists(): 

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

389 # with any external scripts that delete files manually. 

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

391 return True 

392 

393 try: 

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

395 except Exception as e: 

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

397 return False 

398 

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

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

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

402 return True 

403 

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

405 return False 

406 

407 

408def create_issue_from_failure( 

409 error_output: str, 

410 parent_info: IssueInfo, 

411 config: BRConfig, 

412 logger: Logger, 

413 event_bus: EventBus | None = None, 

414) -> Path | None: 

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

416 

417 Args: 

418 error_output: Error output from the failed command 

419 parent_info: Info about the issue that failed 

420 config: Project configuration 

421 logger: Logger for output 

422 event_bus: Optional EventBus for event emission 

423 

424 Returns: 

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

426 """ 

427 bug_num = get_next_issue_number(config, "bugs") 

428 prefix = config.get_issue_prefix("bugs") 

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

430 

431 # Try to extract meaningful error info 

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

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

434 

435 # Generate title from error 

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

437 if "Error" in error_output: 

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

439 if error_match: 

440 title = error_match.group(1) 

441 title_slug = slugify(title) 

442 

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

444 bugs_dir = config.get_issue_dir("bugs") 

445 new_issue_path = bugs_dir / filename 

446 

447 content = f"""--- 

448id: {bug_id} 

449type: BUG 

450priority: P1 

451status: open 

452captured_at: {_completed_at_now()} 

453discovered_by: auto-generated 

454--- 

455 

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

457 

458## Summary 

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

460 

461## Current Behavior 

462``` 

463{traceback} 

464``` 

465 

466## Expected Behavior 

467Implementation should complete without errors. 

468 

469## Root Cause 

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

471 

472## Steps to Reproduce 

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

4742. Observe error 

475 

476## Proposed Solution 

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

478 

479## Impact 

480- **Severity**: High 

481- **Effort**: Unknown 

482- **Risk**: Medium 

483- **Breaking Change**: No 

484 

485## Labels 

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

487 

488--- 

489 

490## Status 

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

492 

493## Related Issues 

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

495""" 

496 

497 try: 

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

499 new_issue_path.write_text(content) 

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

501 if event_bus is not None: 

502 event_bus.emit( 

503 { 

504 "event": "issue.failure_captured", 

505 "ts": _iso_now(), 

506 "issue_id": bug_id, 

507 "file_path": str(new_issue_path), 

508 "parent_issue_id": parent_info.issue_id, 

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 # Prepare content with resolution section, then write status + completed_at 

573 resolution = _build_closure_resolution( 

574 close_status, close_reason, fix_commit, files_changed 

575 ) 

576 content = _prepare_issue_content(original_path, resolution) 

577 content = update_frontmatter( 

578 content, 

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

580 ) 

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

582 

583 # Commit the closure 

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

585 

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

587 

588Issue: {info.issue_id} 

589Reason: {close_reason} 

590Status: {close_status}""" 

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

592 

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

594 if event_bus is not None: 

595 event_bus.emit( 

596 { 

597 "event": "issue.closed", 

598 "ts": _iso_now(), 

599 "issue_id": info.issue_id, 

600 "file_path": str(original_path), 

601 "close_reason": close_reason, 

602 } 

603 ) 

604 return True 

605 

606 except Exception as e: 

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

608 return False 

609 

610 

611def complete_issue_lifecycle( 

612 info: IssueInfo, 

613 config: BRConfig, 

614 logger: Logger, 

615 event_bus: EventBus | None = None, 

616) -> bool: 

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

618 

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

620 

621 Args: 

622 info: Issue info 

623 config: Project configuration 

624 logger: Logger for output 

625 event_bus: Optional EventBus for event emission 

626 

627 Returns: 

628 True if successful, False otherwise 

629 """ 

630 original_path = info.path 

631 

632 if not original_path.exists(): 

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

634 return True 

635 

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

637 

638 try: 

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

640 action = config.get_category_action(info.issue_type) 

641 resolution = _build_completion_resolution(action) 

642 content = _prepare_issue_content(original_path, resolution) 

643 content = update_frontmatter( 

644 content, 

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

646 ) 

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

648 append_session_log_entry(original_path, "ll-auto") 

649 

650 # Commit the completion 

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

652 

653Automated fallback commit - command exited before completion. 

654 

655Issue: {info.issue_id} 

656Action: {action} 

657Status: Completed via fallback lifecycle completion""" 

658 _commit_issue_completion(info, action, commit_body, logger) 

659 

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

661 if event_bus is not None: 

662 event_bus.emit( 

663 { 

664 "event": "issue.completed", 

665 "ts": _iso_now(), 

666 "issue_id": info.issue_id, 

667 "file_path": str(original_path), 

668 } 

669 ) 

670 return True 

671 

672 except Exception as e: 

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

674 return False 

675 

676 

677# ============================================================================= 

678# Issue Deferral 

679# ============================================================================= 

680 

681 

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

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

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

685 return f""" 

686 

687## Deferred 

688 

689- **Date**: {now} 

690- **Reason**: {reason} 

691""" 

692 

693 

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

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

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

697 return f""" 

698 

699## Undeferred 

700 

701- **Date**: {now} 

702- **Reason**: {reason} 

703""" 

704 

705 

706def defer_issue( 

707 info: IssueInfo, 

708 config: BRConfig, 

709 logger: Logger, 

710 reason: str | None = None, 

711 event_bus: EventBus | None = None, 

712) -> bool: 

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

714 

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

716 

717 Args: 

718 info: Issue info 

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

720 logger: Logger for output 

721 reason: Reason for deferring 

722 event_bus: Optional EventBus for event emission 

723 

724 Returns: 

725 True if successful, False otherwise 

726 """ 

727 original_path = info.path 

728 

729 if not original_path.exists(): 

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

731 return True 

732 

733 if not reason: 

734 reason = "Intentionally set aside for later consideration" 

735 

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

737 

738 try: 

739 deferred_section = _build_deferred_section(reason) 

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

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

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

743 

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

745 

746Reason: {reason}""" 

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

748 

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

750 if event_bus is not None: 

751 event_bus.emit( 

752 { 

753 "event": "issue.deferred", 

754 "ts": _iso_now(), 

755 "issue_id": info.issue_id, 

756 "file_path": str(original_path), 

757 "reason": reason, 

758 } 

759 ) 

760 return True 

761 

762 except Exception as e: 

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

764 return False 

765 

766 

767# ============================================================================= 

768# Issue Skip (Deprioritize) 

769# ============================================================================= 

770 

771 

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

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

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

775 reason_text = reason or "No reason provided" 

776 return f""" 

777 

778## Skip Log 

779 

780- **Date**: {now} 

781- **Reason**: {reason_text} 

782""" 

783 

784 

785def skip_issue( 

786 original_path: Path, 

787 new_path: Path, 

788 reason: str | None = None, 

789 event_bus: EventBus | None = None, 

790) -> None: 

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

792 

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

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

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

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

797 

798 Args: 

799 original_path: Current path to the issue file 

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

801 reason: Optional reason text for the Skip Log entry 

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

803 

804 Raises: 

805 FileNotFoundError: If original_path does not exist 

806 FileExistsError: If new_path already exists 

807 """ 

808 if not original_path.exists(): 

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

810 if new_path.exists(): 

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

812 

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

814 

815 if _is_git_tracked(original_path): 

816 try: 

817 result = subprocess.run( 

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

819 capture_output=True, 

820 text=True, 

821 timeout=30, 

822 ) 

823 except subprocess.TimeoutExpired: 

824 result = None # type: ignore[assignment] 

825 

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

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

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

829 original_path.rename(new_path) 

830 else: 

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

832 else: 

833 # Not tracked — write updated content then rename atomically 

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

835 original_path.rename(new_path) 

836 

837 if event_bus is not None: 

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

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

840 event_bus.emit( 

841 { 

842 "event": "issue.skipped", 

843 "ts": _iso_now(), 

844 "issue_id": issue_id, 

845 "file_path": str(new_path), 

846 "reason": reason, 

847 } 

848 ) 

849 

850 

851def undefer_issue( 

852 config: BRConfig, 

853 deferred_issue_path: Path, 

854 logger: Logger, 

855 reason: str | None = None, 

856 event_bus: EventBus | None = None, 

857) -> Path | None: 

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

859 

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

861 field is updated. 

862 

863 Args: 

864 config: Project configuration 

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

866 logger: Logger for output 

867 reason: Reason for undeferring 

868 

869 Returns: 

870 Path to undeferred issue, or None if failed 

871 """ 

872 if not deferred_issue_path.exists(): 

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

874 return None 

875 

876 if not reason: 

877 reason = "Ready to resume active work" 

878 

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

880 

881 try: 

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

883 

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

885 content += _build_undeferred_section(reason) 

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

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

888 

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

890 

891Reason: {reason}""" 

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

893 

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

895 if event_bus is not None: 

896 event_bus.emit( 

897 { 

898 "event": "issue.started", 

899 "ts": _iso_now(), 

900 "issue_id": info.issue_id, 

901 "file_path": str(deferred_issue_path), 

902 "reason": reason, 

903 } 

904 ) 

905 return deferred_issue_path 

906 

907 except Exception as e: 

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

909 return None