1"""Issue lifecycle management for little-loops.

2

3 Provides functions for closing, completing, and verifying issue completion,

4 as well as creating new issues from implementation failures.

5

6 Also provides failure classification to distinguish transient errors

7(API quota, network issues, timeouts) from real implementation failures.

8"""

9

10 from __future__ import annotations

11

12 import re

13 import subprocess

14 from datetime import UTC, datetime

15 from enum import Enum

16 from pathlib import Path

17 from typing import Any

18

19 from little_loops.config import BRConfig

20 from little_loops.events import EventBus

21 from little_loops.file_utils import atomic_write

22 from little_loops.frontmatter import parse_frontmatter, update_frontmatter

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

24 from little_loops.logger import Logger

25 from little_loops.session_log import append_session_log_entry

26

27

28 def _iso_now() -> str:

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

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

31

32

33 def _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

43 class 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

57 def 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

210 def _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

252 Issue was automatically closed during validation.

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

254{files_section}"""

255

256

257 def _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

307 def _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

328 def _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

349 def _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

407 def 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

444 def 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"""---

485 id: {bug_id}

486 type: BUG

487 priority: P1

488 status: open

489 captured_at: {captured_at_val}

490 discovered_by: auto-generated

491---

492

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

494

495## Summary

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

497

498## Current Behavior

499```

500{traceback}

501```

502

503## Expected Behavior

504 Implementation should complete without errors.

505

506## Root Cause

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

508

509## Steps to Reproduce

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

511 2. Observe error

512

513## Proposed Solution

514 Investigate 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

555 def 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

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

628

629 Issue: {info.issue_id}

630 Reason: {close_reason}

631 Status: {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

653 def 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

698 Automated fallback commit - command exited before completion.

699

700 Issue: {info.issue_id}

701 Action: {action}

702 Status: 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

728 def _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

740 def _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

752 def 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

794 Reason: {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

821 def _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

834 def 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

903 def 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

944 Reason: {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