Coverage for little_loops / sync.py: 12%

557 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:08 -0500

1"""GitHub Issues sync implementation for little-loops. 

2 

3Provides bidirectional sync between local .issues/ files and GitHub Issues. 

4""" 

5 

6from __future__ import annotations 

7 

8import difflib 

9import json 

10import re 

11import subprocess 

12from dataclasses import dataclass, field 

13from datetime import UTC, datetime 

14from pathlib import Path 

15from typing import TYPE_CHECKING, Any 

16 

17import yaml 

18 

19from little_loops.frontmatter import parse_frontmatter, strip_frontmatter, update_frontmatter 

20from little_loops.issue_parser import get_next_issue_number 

21from little_loops.issue_template import assemble_issue_markdown, load_issue_sections 

22 

23if TYPE_CHECKING: 

24 from little_loops.config import BRConfig 

25 from little_loops.logger import Logger 

26 

27 

28@dataclass 

29class SyncedIssue: 

30 """Represents an issue's sync state.""" 

31 

32 local_path: Path | None = None 

33 issue_id: str = "" 

34 github_number: int | None = None 

35 github_url: str = "" 

36 last_synced: str = "" 

37 local_changed: bool = False 

38 github_changed: bool = False 

39 

40 

41@dataclass 

42class SyncResult: 

43 """Result of a sync operation.""" 

44 

45 action: str # push, pull, status 

46 success: bool 

47 created: list[str] = field(default_factory=list) 

48 updated: list[str] = field(default_factory=list) 

49 skipped: list[str] = field(default_factory=list) 

50 failed: list[tuple[str, str]] = field(default_factory=list) # (issue_id, reason) 

51 errors: list[str] = field(default_factory=list) 

52 

53 def to_dict(self) -> dict[str, Any]: 

54 """Convert to dictionary for JSON serialization.""" 

55 return { 

56 "action": self.action, 

57 "success": self.success, 

58 "created": self.created, 

59 "updated": self.updated, 

60 "skipped": self.skipped, 

61 "failed": self.failed, 

62 "errors": self.errors, 

63 } 

64 

65 

66@dataclass 

67class SyncStatus: 

68 """Sync status overview.""" 

69 

70 provider: str 

71 repo: str 

72 local_total: int = 0 

73 local_synced: int = 0 

74 local_unsynced: int = 0 

75 github_total: int = 0 

76 github_only: int = 0 

77 github_error: str | None = None 

78 

79 def to_dict(self) -> dict[str, Any]: 

80 """Convert to dictionary for JSON serialization.""" 

81 return { 

82 "provider": self.provider, 

83 "repo": self.repo, 

84 "local_total": self.local_total, 

85 "local_synced": self.local_synced, 

86 "local_unsynced": self.local_unsynced, 

87 "github_total": self.github_total, 

88 "github_only": self.github_only, 

89 "github_error": self.github_error, 

90 } 

91 

92 

93# ============================================================================= 

94# Helper Functions 

95# ============================================================================= 

96 

97 

98def _run_gh_command( 

99 args: list[str], 

100 logger: Logger, 

101 check: bool = True, 

102) -> subprocess.CompletedProcess[str]: 

103 """Run a gh CLI command and return result. 

104 

105 Args: 

106 args: Arguments to pass to gh CLI (e.g., ["issue", "list", "--json", "number"]) 

107 logger: Logger for output 

108 check: Whether to raise on non-zero exit (default True) 

109 

110 Returns: 

111 CompletedProcess with stdout/stderr 

112 

113 Raises: 

114 subprocess.CalledProcessError: If command fails and check=True 

115 """ 

116 cmd = ["gh"] + args 

117 logger.debug(f"Running: {' '.join(cmd)}") 

118 result = subprocess.run( 

119 cmd, 

120 capture_output=True, 

121 text=True, 

122 check=check, 

123 ) 

124 return result 

125 

126 

127def _check_gh_auth(logger: Logger) -> bool: 

128 """Check if gh CLI is authenticated. 

129 

130 Returns: 

131 True if authenticated, False otherwise 

132 """ 

133 try: 

134 result = _run_gh_command(["auth", "status"], logger, check=False) 

135 return result.returncode == 0 

136 except FileNotFoundError: 

137 logger.error("gh CLI not found. Install with: brew install gh") 

138 return False 

139 

140 

141def _get_repo_name(logger: Logger) -> str | None: 

142 """Get current repository name from gh CLI. 

143 

144 Returns: 

145 Repository name in owner/repo format, or None if not in a repo 

146 """ 

147 try: 

148 result = _run_gh_command( 

149 ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], 

150 logger, 

151 check=False, 

152 ) 

153 if result.returncode == 0: 

154 return result.stdout.strip() 

155 except Exception as e: 

156 logger.debug(f"Could not get repo name: {e}") 

157 return None 

158 

159 

160def _update_issue_frontmatter( 

161 content: str, 

162 updates: dict[str, str | int], 

163) -> str: 

164 """Update or add frontmatter fields in issue content. 

165 

166 Args: 

167 content: Full file content 

168 updates: Fields to add/update in frontmatter 

169 

170 Returns: 

171 Updated content with modified frontmatter 

172 """ 

173 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) 

174 if not fm_match: 

175 # No existing frontmatter, create it 

176 fm_text = yaml.dump(dict(updates), default_flow_style=False, sort_keys=False).strip() 

177 return f"---\n{fm_text}\n---\n{content}" 

178 

179 existing: dict[str, Any] = yaml.safe_load(fm_match.group(1)) or {} 

180 existing.update(updates) 

181 fm_text = yaml.dump(existing, default_flow_style=False, sort_keys=False).strip() 

182 return f"---\n{fm_text}\n---{content[fm_match.end() :]}" 

183 

184 

185def _parse_issue_title(content: str) -> str: 

186 """Extract title from issue content (after frontmatter). 

187 

188 Looks for first markdown heading: # ISSUE-ID: Title 

189 

190 Args: 

191 content: Full file content 

192 

193 Returns: 

194 Title string or empty string if not found 

195 """ 

196 content = strip_frontmatter(content) 

197 

198 # Find first heading 

199 for line in content.split("\n"): 

200 line = line.strip() 

201 if line.startswith("# "): 

202 # Remove issue ID prefix if present 

203 title = line[2:].strip() 

204 # Pattern: ISSUE-ID: Title 

205 if ":" in title: 

206 parts = title.split(":", 1) 

207 if re.match(r"^[A-Z]+-\d+$", parts[0].strip()): 

208 return parts[1].strip() 

209 return title 

210 return "" 

211 

212 

213def _get_issue_body(content: str) -> str: 

214 """Extract body from issue content (after frontmatter and title). 

215 

216 Args: 

217 content: Full file content 

218 

219 Returns: 

220 Body content 

221 """ 

222 content = strip_frontmatter(content) 

223 

224 # Skip leading blank lines 

225 lines = content.split("\n") 

226 while lines and not lines[0].strip(): 

227 lines.pop(0) 

228 

229 # Skip title line 

230 if lines and lines[0].startswith("# "): 

231 lines.pop(0) 

232 

233 return "\n".join(lines).strip() 

234 

235 

236# ============================================================================= 

237# GitHubSyncManager Class 

238# ============================================================================= 

239 

240 

241class GitHubSyncManager: 

242 """Manages bidirectional sync between local issues and GitHub Issues.""" 

243 

244 def __init__( 

245 self, 

246 config: BRConfig, 

247 logger: Logger, 

248 dry_run: bool = False, 

249 ) -> None: 

250 """Initialize sync manager. 

251 

252 Args: 

253 config: Project configuration 

254 logger: Logger for output 

255 dry_run: If True, show what would be done without making changes 

256 """ 

257 self.config = config 

258 self.sync_config = config.sync 

259 self.logger = logger 

260 self.dry_run = dry_run 

261 self.issues_dir = config.project_root / config.issues.base_dir 

262 self._sections_data: dict[str, dict[str, Any]] = {} 

263 

264 def _get_local_issues(self) -> list[Path]: 

265 """Get all local issue files to sync. 

266 

267 Returns: 

268 List of issue file paths 

269 """ 

270 issues: list[Path] = [] 

271 for category in self.config.issue_categories: 

272 category_dir = self.config.get_issue_dir(category) 

273 if category_dir.exists(): 

274 for issue_file in category_dir.glob("*.md"): 

275 if not self.sync_config.github.sync_completed: 

276 fm = parse_frontmatter(issue_file.read_text(encoding="utf-8")) 

277 if fm.get("status", "open") in ("done", "cancelled"): 

278 continue 

279 issues.append(issue_file) 

280 

281 return issues 

282 

283 def _extract_issue_id(self, filename: str) -> str: 

284 """Extract issue ID from filename. 

285 

286 Args: 

287 filename: Issue filename (e.g., P1-BUG-123-description.md) 

288 

289 Returns: 

290 Issue ID (e.g., BUG-123) 

291 """ 

292 # Pattern: P[0-5]-TYPE-NNN-description.md 

293 match = re.search(r"(BUG|FEAT|ENH|EPIC)-(\d+)", filename) 

294 if match: 

295 return f"{match.group(1)}-{match.group(2)}" 

296 return "" 

297 

298 def _get_labels_for_issue(self, issue_path: Path) -> list[str]: 

299 """Determine GitHub labels for an issue. 

300 

301 Args: 

302 issue_path: Path to issue file 

303 

304 Returns: 

305 List of label names 

306 """ 

307 labels: list[str] = [] 

308 filename = issue_path.name 

309 

310 # Get type label from mapping 

311 issue_id = self._extract_issue_id(filename) 

312 if issue_id: 

313 type_prefix = issue_id.split("-")[0] 

314 type_label = self.sync_config.github.label_mapping.get(type_prefix) 

315 if type_label: 

316 labels.append(type_label) 

317 

318 # Add priority label if configured 

319 if self.sync_config.github.priority_labels: 

320 priority_match = re.match(r"^(P[0-5])-", filename) 

321 if priority_match: 

322 labels.append(priority_match.group(1).lower()) 

323 

324 # Add blocked-by label if blocked_by frontmatter is non-empty 

325 content = issue_path.read_text(encoding="utf-8") 

326 fm = parse_frontmatter(content, coerce_types=True) 

327 if fm.get("blocked_by"): 

328 labels.append("blocked-by") 

329 

330 # Add issue-level labels from frontmatter labels: field 

331 fm_labels = fm.get("labels") 

332 if fm_labels: 

333 if isinstance(fm_labels, list): 

334 labels.extend(str(lb) for lb in fm_labels) 

335 elif isinstance(fm_labels, str): 

336 labels.extend(lb.strip() for lb in fm_labels.split(",") if lb.strip()) 

337 

338 return labels 

339 

340 def push_issues(self, issue_ids: list[str] | None = None) -> SyncResult: 

341 """Push local issues to GitHub. 

342 

343 Args: 

344 issue_ids: Specific issue IDs to push, or None for all 

345 

346 Returns: 

347 SyncResult with operation details 

348 """ 

349 result = SyncResult(action="push", success=True) 

350 

351 # Verify gh auth 

352 if not _check_gh_auth(self.logger): 

353 result.success = False 

354 result.errors.append("GitHub CLI not authenticated. Run: gh auth login") 

355 return result 

356 

357 # Get repo name 

358 repo = self.sync_config.github.repo or _get_repo_name(self.logger) 

359 if not repo: 

360 result.success = False 

361 result.errors.append("Could not determine repository. Set sync.github.repo in config.") 

362 return result 

363 

364 local_issues = self._get_local_issues() 

365 self.logger.info(f"Found {len(local_issues)} local issues") 

366 

367 for issue_path in local_issues: 

368 issue_id = self._extract_issue_id(issue_path.name) 

369 if not issue_id: 

370 self.logger.debug(f"Skipping {issue_path.name}: no issue ID found") 

371 continue 

372 

373 # Filter by issue_ids if specified 

374 if issue_ids and issue_id not in issue_ids: 

375 continue 

376 

377 try: 

378 self._push_single_issue(issue_path, issue_id, result) 

379 except Exception as e: 

380 result.failed.append((issue_id, str(e))) 

381 self.logger.error(f"Failed to push {issue_id}: {e}") 

382 

383 if result.failed: 

384 result.success = False 

385 

386 return result 

387 

388 def _push_single_issue( 

389 self, 

390 issue_path: Path, 

391 issue_id: str, 

392 result: SyncResult, 

393 ) -> None: 

394 """Push a single issue to GitHub. 

395 

396 Args: 

397 issue_path: Path to local issue file 

398 issue_id: Issue ID (e.g., BUG-123) 

399 result: SyncResult to update 

400 """ 

401 content = issue_path.read_text(encoding="utf-8") 

402 frontmatter = parse_frontmatter(content, coerce_types=True) 

403 title = _parse_issue_title(content) 

404 body = _get_issue_body(content) 

405 

406 # Build full title with issue ID 

407 full_title = f"{issue_id}: {title}" if title else issue_id 

408 

409 # Get labels 

410 labels = self._get_labels_for_issue(issue_path) 

411 

412 github_number = frontmatter.get("github_issue") 

413 

414 if self.dry_run: 

415 if github_number: 

416 result.updated.append(f"{issue_id} → #{github_number} (would update)") 

417 self.logger.info(f"Would update GitHub issue #{github_number} for {issue_id}") 

418 else: 

419 result.created.append(f"{issue_id} (would create)") 

420 self.logger.info(f"Would create GitHub issue for {issue_id}") 

421 return 

422 

423 milestone: str | None = frontmatter.get("milestone") or None 

424 

425 effective_number: int | None = None 

426 if github_number: 

427 # Update existing issue 

428 self._update_github_issue( 

429 int(github_number), full_title, body, issue_id, result, milestone 

430 ) 

431 effective_number = int(github_number) 

432 else: 

433 # Create new issue 

434 new_number = self._create_github_issue( 

435 full_title, body, labels, issue_id, result, milestone 

436 ) 

437 if new_number: 

438 # Update local frontmatter 

439 self._update_local_frontmatter(issue_path, content, new_number) 

440 effective_number = new_number 

441 

442 if effective_number and frontmatter.get("duplicate_of"): 

443 _run_gh_command( 

444 [ 

445 "issue", 

446 "comment", 

447 str(effective_number), 

448 "--body", 

449 f"Duplicate of {frontmatter['duplicate_of']}.", 

450 ], 

451 self.logger, 

452 ) 

453 

454 def _create_github_issue( 

455 self, 

456 title: str, 

457 body: str, 

458 labels: list[str], 

459 issue_id: str, 

460 result: SyncResult, 

461 milestone: str | None = None, 

462 ) -> int | None: 

463 """Create a new GitHub issue. 

464 

465 Returns: 

466 GitHub issue number if successful, None otherwise 

467 """ 

468 args = ["issue", "create", "--title", title, "--body", body] 

469 for label in labels: 

470 args.extend(["--label", label]) 

471 if milestone: 

472 args.extend(["--milestone", milestone]) 

473 

474 try: 

475 cmd_result = _run_gh_command(args, self.logger) 

476 # gh issue create outputs the URL 

477 url = cmd_result.stdout.strip() 

478 # Extract issue number from URL 

479 match = re.search(r"/issues/(\d+)$", url) 

480 if match: 

481 issue_num = int(match.group(1)) 

482 result.created.append(f"{issue_id} → #{issue_num}") 

483 self.logger.success(f"Created GitHub issue #{issue_num} for {issue_id}") 

484 return issue_num 

485 except subprocess.CalledProcessError as e: 

486 result.failed.append((issue_id, f"gh issue create failed: {e.stderr}")) 

487 self.logger.error(f"Failed to create GitHub issue for {issue_id}: {e.stderr}") 

488 return None 

489 

490 def _update_github_issue( 

491 self, 

492 github_number: int, 

493 title: str, 

494 body: str, 

495 issue_id: str, 

496 result: SyncResult, 

497 milestone: str | None = None, 

498 ) -> None: 

499 """Update an existing GitHub issue.""" 

500 args = [ 

501 "issue", 

502 "edit", 

503 str(github_number), 

504 "--title", 

505 title, 

506 "--body", 

507 body, 

508 ] 

509 if milestone: 

510 args.extend(["--milestone", milestone]) 

511 try: 

512 _run_gh_command(args, self.logger) 

513 result.updated.append(f"{issue_id} → #{github_number}") 

514 self.logger.success(f"Updated GitHub issue #{github_number} for {issue_id}") 

515 except subprocess.CalledProcessError as e: 

516 result.failed.append((issue_id, f"gh issue edit failed: {e.stderr}")) 

517 self.logger.error(f"Failed to update GitHub issue #{github_number}: {e.stderr}") 

518 

519 def _update_local_frontmatter( 

520 self, 

521 issue_path: Path, 

522 content: str, 

523 github_number: int, 

524 ) -> None: 

525 """Update local issue file with GitHub sync info.""" 

526 repo = self.sync_config.github.repo or _get_repo_name(self.logger) or "" 

527 github_url = f"https://github.com/{repo}/issues/{github_number}" if repo else "" 

528 now = datetime.now(UTC).isoformat(timespec="seconds") 

529 

530 updates: dict[str, str | int] = { 

531 "github_issue": github_number, 

532 "github_url": github_url, 

533 "last_synced": now, 

534 } 

535 updated_content = _update_issue_frontmatter(content, updates) 

536 issue_path.write_text(updated_content, encoding="utf-8") 

537 self.logger.debug(f"Updated frontmatter in {issue_path.name}") 

538 

539 def pull_issues(self, labels: list[str] | None = None) -> SyncResult: 

540 """Pull GitHub Issues to local files. 

541 

542 Args: 

543 labels: Filter by labels, or None for all recognized labels 

544 

545 Returns: 

546 SyncResult with operation details 

547 """ 

548 result = SyncResult(action="pull", success=True) 

549 

550 # Verify gh auth 

551 if not _check_gh_auth(self.logger): 

552 result.success = False 

553 result.errors.append("GitHub CLI not authenticated. Run: gh auth login") 

554 return result 

555 

556 # List GitHub issues 

557 pull_limit = self.sync_config.github.pull_limit 

558 try: 

559 gh_args = [ 

560 "issue", 

561 "list", 

562 "--json", 

563 "number,title,body,labels,state,url", 

564 "--limit", 

565 str(pull_limit), 

566 ] 

567 if labels: 

568 for label in labels: 

569 gh_args.extend(["--label", label]) 

570 cmd_result = _run_gh_command(gh_args, self.logger) 

571 github_issues = json.loads(cmd_result.stdout) 

572 except Exception as e: 

573 result.success = False 

574 result.errors.append(f"Failed to list GitHub issues: {e}") 

575 return result 

576 

577 if len(github_issues) >= pull_limit: 

578 self.logger.warning( 

579 f"Fetched {len(github_issues)} issues which equals the pull_limit ({pull_limit}). " 

580 "Results may be truncated. Increase sync.github.pull_limit in ll-config.json to fetch more." 

581 ) 

582 

583 # Get existing local issue IDs 

584 local_github_numbers = self._get_local_github_numbers() 

585 

586 for gh_issue in github_issues: 

587 gh_number = gh_issue["number"] 

588 gh_state = gh_issue.get("state", "OPEN") 

589 

590 # Skip closed issues unless configured 

591 if gh_state != "OPEN" and not self.sync_config.github.sync_completed: 

592 result.skipped.append(f"#{gh_number} (closed)") 

593 continue 

594 

595 # Skip if already tracked locally 

596 if gh_number in local_github_numbers: 

597 result.skipped.append(f"#{gh_number} (already tracked)") 

598 continue 

599 

600 # Check if has recognized labels 

601 gh_labels = [lbl.get("name", "") for lbl in gh_issue.get("labels", [])] 

602 issue_type = self._determine_issue_type(gh_labels) 

603 if not issue_type: 

604 result.skipped.append(f"#{gh_number} (no recognized type label)") 

605 continue 

606 

607 if self.dry_run: 

608 gh_title = gh_issue.get("title", f"Issue #{gh_number}") 

609 result.created.append(f"#{gh_number}: {gh_title} (would create as {issue_type})") 

610 self.logger.info(f"Would create local issue from GitHub #{gh_number}: {gh_title}") 

611 else: 

612 try: 

613 self._create_local_issue(gh_issue, issue_type, result) 

614 except Exception as e: 

615 result.failed.append((f"#{gh_number}", str(e))) 

616 

617 if result.failed: 

618 result.success = False 

619 

620 return result 

621 

622 def _get_local_github_numbers(self) -> set[int]: 

623 """Get set of GitHub issue numbers tracked locally.""" 

624 numbers: set[int] = set() 

625 for issue_path in self._get_local_issues(): 

626 content = issue_path.read_text(encoding="utf-8") 

627 frontmatter = parse_frontmatter(content, coerce_types=True) 

628 gh_num = frontmatter.get("github_issue") 

629 if gh_num is not None: 

630 try: 

631 numbers.add(int(gh_num)) 

632 except (ValueError, TypeError): 

633 self.logger.warning( 

634 f"Malformed github_issue value in {issue_path.name}: {gh_num!r}" 

635 ) 

636 return numbers 

637 

638 def _determine_issue_type(self, labels: list[str]) -> str | None: 

639 """Determine issue type from GitHub labels. 

640 

641 Returns: 

642 Issue type prefix (BUG, FEAT, ENH) or None 

643 """ 

644 # Reverse lookup from label_mapping 

645 reverse_map: dict[str, str] = {} 

646 for type_prefix, label in self.sync_config.github.label_mapping.items(): 

647 if label not in reverse_map: 

648 reverse_map[label] = type_prefix 

649 

650 for label in labels: 

651 if label in reverse_map: 

652 return reverse_map[label] 

653 return None 

654 

655 def _create_local_issue( 

656 self, 

657 gh_issue: dict[str, Any], 

658 issue_type: str, 

659 result: SyncResult, 

660 ) -> None: 

661 """Create a local issue file from GitHub issue.""" 

662 gh_number = gh_issue["number"] 

663 gh_title = gh_issue.get("title", f"Issue #{gh_number}") 

664 gh_body = gh_issue.get("body", "") or "" 

665 gh_url = gh_issue.get("url", "") 

666 gh_labels = [lbl.get("name", "") for lbl in gh_issue.get("labels", [])] 

667 

668 # Determine priority from labels or default 

669 priority = "P3" 

670 for label in gh_labels: 

671 if re.match(r"^p[0-5]$", label, re.IGNORECASE): 

672 priority = label.upper() 

673 break 

674 

675 # Generate next issue number (uses global numbering across all dirs) 

676 next_num = get_next_issue_number(self.config) 

677 

678 # Generate slug from title 

679 slug = re.sub(r"[^a-z0-9]+", "-", gh_title.lower())[:40].strip("-") 

680 

681 issue_id = f"{issue_type}-{next_num}" 

682 filename = f"{priority}-{issue_id}-{slug}.md" 

683 

684 # Determine category directory 

685 cat = self.config.issues.get_category_by_prefix(issue_type) 

686 category = cat.dir if cat else "features" 

687 category_dir = self.config.get_issue_dir(category) 

688 category_dir.mkdir(parents=True, exist_ok=True) 

689 

690 issue_path = category_dir / filename 

691 

692 # Build content using per-type sections template 

693 now = datetime.now(UTC).isoformat(timespec="seconds") 

694 today = datetime.now(UTC).strftime("%Y-%m-%d") 

695 

696 if issue_type not in self._sections_data: 

697 templates_dir = ( 

698 Path(self.config.issues.templates_dir) if self.config.issues.templates_dir else None 

699 ) 

700 self._sections_data[issue_type] = load_issue_sections(issue_type, templates_dir) 

701 

702 # Strip ll-managed labels (type, priority, blocked-by) to keep only user-facing labels 

703 _managed_prefixes = ("p0", "p1", "p2", "p3", "p4", "p5", "blocked-by") 

704 _managed_type_labels = { 

705 v.lower() for v in (self.sync_config.github.label_mapping or {}).values() if v 

706 } 

707 user_labels = [ 

708 lbl 

709 for lbl in gh_labels 

710 if lbl.lower() not in _managed_prefixes and lbl.lower() not in _managed_type_labels 

711 ] 

712 

713 frontmatter = { 

714 "github_issue": gh_number, 

715 "github_url": gh_url, 

716 "captured_at": now, 

717 "last_synced": now, 

718 "discovered_by": "github_sync", 

719 "discovered_date": today, 

720 } 

721 if user_labels: 

722 frontmatter["labels"] = user_labels 

723 section_content: dict[str, str] = {} 

724 if gh_body: 

725 section_content["Summary"] = gh_body 

726 section_content["Impact"] = ( 

727 f"- **Priority**: {priority}\n" 

728 f"- **Effort**: Unknown\n" 

729 f"- **Risk**: Unknown\n" 

730 f"- **Breaking Change**: Unknown" 

731 ) 

732 section_content["Status"] = f"**Open** | Created: {today} | Priority: {priority}" 

733 

734 labels_str = ", ".join(f"`{lbl}`" for lbl in gh_labels) if gh_labels else "" 

735 if labels_str: 

736 section_content["Labels"] = labels_str 

737 

738 variant = self.sync_config.github.pull_template 

739 content = assemble_issue_markdown( 

740 sections_data=self._sections_data[issue_type], 

741 issue_type=issue_type, 

742 variant=variant, 

743 issue_id=issue_id, 

744 title=gh_title, 

745 frontmatter=frontmatter, 

746 content=section_content, 

747 labels=gh_labels, 

748 ) 

749 issue_path.write_text(content, encoding="utf-8") 

750 result.created.append(f"#{gh_number}{issue_id}") 

751 self.logger.success(f"Created {filename} from GitHub #{gh_number}") 

752 

753 def get_status(self) -> SyncStatus: 

754 """Get sync status overview. 

755 

756 Returns: 

757 SyncStatus with counts 

758 """ 

759 repo = self.sync_config.github.repo or _get_repo_name(self.logger) or "unknown" 

760 

761 status = SyncStatus( 

762 provider=self.sync_config.provider, 

763 repo=repo, 

764 ) 

765 

766 # Count local issues 

767 local_issues = self._get_local_issues() 

768 status.local_total = len(local_issues) 

769 

770 # Count synced (have github_issue) 

771 local_github_numbers = self._get_local_github_numbers() 

772 status.local_synced = len(local_github_numbers) 

773 status.local_unsynced = status.local_total - status.local_synced 

774 

775 # Count GitHub issues 

776 if _check_gh_auth(self.logger): 

777 try: 

778 cmd_result = _run_gh_command( 

779 ["issue", "list", "--json", "number", "--limit", "500"], 

780 self.logger, 

781 ) 

782 github_issues = json.loads(cmd_result.stdout) 

783 status.github_total = len(github_issues) 

784 

785 github_numbers = {issue["number"] for issue in github_issues} 

786 status.github_only = len(github_numbers - local_github_numbers) 

787 except Exception as e: 

788 status.github_error = f"Failed to query GitHub: {e}" 

789 self.logger.warning(status.github_error) 

790 

791 return status 

792 

793 def _find_local_issue(self, issue_id: str) -> Path | None: 

794 """Find the local file matching an issue ID. 

795 

796 Searches active category directories and completed directory. 

797 

798 Args: 

799 issue_id: Issue ID to find (e.g., BUG-123) 

800 

801 Returns: 

802 Path to the issue file, or None if not found 

803 """ 

804 for issue_path in self._get_local_issues(): 

805 if self._extract_issue_id(issue_path.name) == issue_id: 

806 return issue_path 

807 # Search all type dirs (catches done/cancelled issues excluded when sync_completed=False) 

808 for category in self.config.issue_categories: 

809 category_dir = self.config.get_issue_dir(category) 

810 if not category_dir.exists(): 

811 continue 

812 for issue_file in category_dir.glob("*.md"): 

813 if self._extract_issue_id(issue_file.name) == issue_id: 

814 return issue_file 

815 return None 

816 

817 def diff_issue(self, issue_id: str) -> SyncResult: 

818 """Show content differences between a local issue and its GitHub counterpart. 

819 

820 Args: 

821 issue_id: Issue ID to diff (e.g., BUG-123) 

822 

823 Returns: 

824 SyncResult with diff information 

825 """ 

826 result = SyncResult(action="diff", success=True) 

827 

828 if not _check_gh_auth(self.logger): 

829 result.success = False 

830 result.errors.append("GitHub CLI not authenticated. Run: gh auth login") 

831 return result 

832 

833 issue_path = self._find_local_issue(issue_id) 

834 if not issue_path: 

835 result.success = False 

836 result.errors.append(f"Local issue {issue_id} not found") 

837 return result 

838 

839 content = issue_path.read_text(encoding="utf-8") 

840 frontmatter = parse_frontmatter(content, coerce_types=True) 

841 github_number = frontmatter.get("github_issue") 

842 

843 if github_number is None: 

844 result.success = False 

845 result.errors.append( 

846 f"{issue_id} is not synced to GitHub (no github_issue in frontmatter)" 

847 ) 

848 return result 

849 

850 try: 

851 cmd_result = _run_gh_command( 

852 ["issue", "view", str(int(github_number)), "--json", "body", "-q", ".body"], 

853 self.logger, 

854 ) 

855 github_body = cmd_result.stdout.rstrip("\n") 

856 except subprocess.CalledProcessError as e: 

857 result.success = False 

858 result.errors.append(f"Failed to fetch GitHub issue #{github_number}: {e.stderr}") 

859 return result 

860 

861 local_body = _get_issue_body(content) 

862 

863 local_lines = local_body.splitlines(keepends=True) 

864 github_lines = github_body.splitlines(keepends=True) 

865 

866 diff = list( 

867 difflib.unified_diff( 

868 github_lines, 

869 local_lines, 

870 fromfile=f"github:#{github_number}", 

871 tofile=f"local:{issue_id}", 

872 ) 

873 ) 

874 

875 if diff: 

876 result.updated.append(f"{issue_id} (#{github_number}): differs") 

877 # Store diff lines in created field for display 

878 result.created = [line.rstrip("\n") for line in diff] 

879 else: 

880 result.skipped.append(f"{issue_id} (#{github_number}): in sync") 

881 

882 return result 

883 

884 def diff_all(self) -> SyncResult: 

885 """Show summary of differences between all synced local issues and GitHub. 

886 

887 Returns: 

888 SyncResult with diff summary 

889 """ 

890 result = SyncResult(action="diff", success=True) 

891 

892 if not _check_gh_auth(self.logger): 

893 result.success = False 

894 result.errors.append("GitHub CLI not authenticated. Run: gh auth login") 

895 return result 

896 

897 local_issues = self._get_local_issues() 

898 

899 # First pass: collect all issues that have been synced to GitHub 

900 synced: list[tuple[str, int, str]] = [] # (issue_id, github_number, content) 

901 for issue_path in local_issues: 

902 issue_id = self._extract_issue_id(issue_path.name) 

903 if not issue_id: 

904 continue 

905 

906 content = issue_path.read_text(encoding="utf-8") 

907 frontmatter = parse_frontmatter(content, coerce_types=True) 

908 github_number = frontmatter.get("github_issue") 

909 

910 if github_number is None: 

911 continue 

912 

913 synced.append((issue_id, int(github_number), content)) 

914 

915 if not synced: 

916 return result 

917 

918 # Batch-fetch all GitHub issue bodies in a single API call 

919 try: 

920 cmd_result = _run_gh_command( 

921 ["issue", "list", "--json", "number,body", "--limit", "500", "--state", "all"], 

922 self.logger, 

923 ) 

924 github_bodies: dict[int, str] = { 

925 item["number"]: item["body"] for item in json.loads(cmd_result.stdout) 

926 } 

927 except subprocess.CalledProcessError as e: 

928 result.success = False 

929 result.errors.append(f"Failed to batch-fetch GitHub issues: {e.stderr}") 

930 return result 

931 except Exception as e: 

932 result.success = False 

933 result.errors.append(f"Failed to batch-fetch GitHub issues: {e}") 

934 return result 

935 

936 # Compare local vs GitHub bodies using the batch-fetched data 

937 for issue_id, github_number, content in synced: 

938 if github_number not in github_bodies: 

939 result.failed.append((issue_id, f"Issue #{github_number} not found in GitHub")) 

940 continue 

941 

942 local_body = _get_issue_body(content) 

943 github_body = github_bodies[github_number] 

944 

945 if local_body.strip() != github_body.strip(): 

946 result.updated.append(f"{issue_id} (#{github_number}): differs") 

947 else: 

948 result.skipped.append(f"{issue_id} (#{github_number}): in sync") 

949 

950 if result.failed: 

951 result.success = False 

952 

953 return result 

954 

955 def close_issues( 

956 self, 

957 issue_ids: list[str] | None = None, 

958 all_completed: bool = False, 

959 ) -> SyncResult: 

960 """Close GitHub issues for completed local issues. 

961 

962 Args: 

963 issue_ids: Specific issue IDs to close, or None 

964 all_completed: If True, close all GitHub issues whose local counterparts 

965 are in the completed directory 

966 

967 Returns: 

968 SyncResult with operation details 

969 """ 

970 result = SyncResult(action="close", success=True) 

971 

972 if not _check_gh_auth(self.logger): 

973 result.success = False 

974 result.errors.append("GitHub CLI not authenticated. Run: gh auth login") 

975 return result 

976 

977 files_to_close: list[tuple[Path, str]] = [] # (path, issue_id) 

978 

979 if all_completed: 

980 for category in self.config.issue_categories: 

981 category_dir = self.config.get_issue_dir(category) 

982 if not category_dir.exists(): 

983 continue 

984 for issue_file in category_dir.glob("*.md"): 

985 fm = parse_frontmatter(issue_file.read_text(encoding="utf-8")) 

986 if fm.get("status", "open") in ("done", "cancelled"): 

987 eid = self._extract_issue_id(issue_file.name) 

988 if eid: 

989 files_to_close.append((issue_file, eid)) 

990 elif issue_ids: 

991 for eid in issue_ids: 

992 issue_path = self._find_local_issue(eid) 

993 if issue_path: 

994 files_to_close.append((issue_path, eid)) 

995 else: 

996 result.failed.append((eid, "Local issue not found")) 

997 else: 

998 result.success = False 

999 result.errors.append("Specify issue IDs or use --all-completed") 

1000 return result 

1001 

1002 for issue_path, issue_id in files_to_close: 

1003 content = issue_path.read_text(encoding="utf-8") 

1004 frontmatter = parse_frontmatter(content, coerce_types=True) 

1005 github_number = frontmatter.get("github_issue") 

1006 

1007 if github_number is None: 

1008 result.skipped.append(f"{issue_id} (not synced to GitHub)") 

1009 continue 

1010 

1011 if self.dry_run: 

1012 result.updated.append(f"{issue_id} → #{github_number} (would close)") 

1013 self.logger.info(f"Would close GitHub issue #{github_number} for {issue_id}") 

1014 continue 

1015 

1016 try: 

1017 _run_gh_command( 

1018 [ 

1019 "issue", 

1020 "close", 

1021 str(int(github_number)), 

1022 "--comment", 

1023 f"Closed via ll-sync. Issue {issue_id} completed locally.", 

1024 ], 

1025 self.logger, 

1026 ) 

1027 result.updated.append(f"{issue_id} → #{github_number} (closed)") 

1028 self.logger.success(f"Closed GitHub issue #{github_number} for {issue_id}") 

1029 except subprocess.CalledProcessError as e: 

1030 result.failed.append((issue_id, f"gh issue close failed: {e.stderr}")) 

1031 self.logger.error(f"Failed to close GitHub issue #{github_number}: {e.stderr}") 

1032 

1033 if result.failed: 

1034 result.success = False 

1035 

1036 return result 

1037 

1038 def reopen_issues( 

1039 self, 

1040 issue_ids: list[str] | None = None, 

1041 all_reopened: bool = False, 

1042 ) -> SyncResult: 

1043 """Reopen GitHub issues for locally-active issues. 

1044 

1045 Args: 

1046 issue_ids: Specific issue IDs to reopen, or None 

1047 all_reopened: If True, reopen GitHub issues for all local issues in active 

1048 directories that are CLOSED on GitHub 

1049 

1050 Returns: 

1051 SyncResult with operation details 

1052 """ 

1053 result = SyncResult(action="reopen", success=True) 

1054 

1055 if not _check_gh_auth(self.logger): 

1056 result.success = False 

1057 result.errors.append("GitHub CLI not authenticated. Run: gh auth login") 

1058 return result 

1059 

1060 files_to_reopen: list[tuple[Path, str]] = [] # (path, issue_id) 

1061 

1062 if all_reopened: 

1063 for issue_path in self._get_local_issues(): 

1064 eid = self._extract_issue_id(issue_path.name) 

1065 if eid: 

1066 files_to_reopen.append((issue_path, eid)) 

1067 elif issue_ids: 

1068 for eid in issue_ids: 

1069 found_path = self._find_local_issue(eid) 

1070 if found_path: 

1071 files_to_reopen.append((found_path, eid)) 

1072 else: 

1073 result.failed.append((eid, "Local issue not found")) 

1074 else: 

1075 result.success = False 

1076 result.errors.append("Specify issue IDs or use --all-reopened") 

1077 return result 

1078 

1079 for issue_path, issue_id in files_to_reopen: 

1080 content = issue_path.read_text(encoding="utf-8") 

1081 frontmatter = parse_frontmatter(content, coerce_types=True) 

1082 github_number = frontmatter.get("github_issue") 

1083 

1084 if github_number is None: 

1085 result.skipped.append(f"{issue_id} (not synced to GitHub)") 

1086 continue 

1087 

1088 if all_reopened: 

1089 try: 

1090 state_result = _run_gh_command( 

1091 [ 

1092 "issue", 

1093 "view", 

1094 str(int(github_number)), 

1095 "--json", 

1096 "state", 

1097 "-q", 

1098 ".state", 

1099 ], 

1100 self.logger, 

1101 ) 

1102 state = state_result.stdout.strip() 

1103 if state != "CLOSED": 

1104 result.skipped.append( 

1105 f"{issue_id} (#{github_number}: already open on GitHub)" 

1106 ) 

1107 continue 

1108 except subprocess.CalledProcessError as e: 

1109 result.failed.append((issue_id, f"gh issue view failed: {e.stderr}")) 

1110 continue 

1111 

1112 if self.dry_run: 

1113 result.updated.append(f"{issue_id} → #{github_number} (would reopen)") 

1114 self.logger.info(f"Would reopen GitHub issue #{github_number} for {issue_id}") 

1115 continue 

1116 

1117 try: 

1118 _run_gh_command( 

1119 [ 

1120 "issue", 

1121 "reopen", 

1122 str(int(github_number)), 

1123 "--comment", 

1124 f"Reopened via ll-sync. Issue {issue_id} moved back to active locally.", 

1125 ], 

1126 self.logger, 

1127 ) 

1128 result.updated.append(f"{issue_id} → #{github_number} (reopened)") 

1129 self.logger.success(f"Reopened GitHub issue #{github_number} for {issue_id}") 

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

1131 issue_path.write_text(new_content, encoding="utf-8") 

1132 except subprocess.CalledProcessError as e: 

1133 result.failed.append((issue_id, f"gh issue reopen failed: {e.stderr}")) 

1134 self.logger.error(f"Failed to reopen GitHub issue #{github_number}: {e.stderr}") 

1135 continue 

1136 

1137 if result.failed: 

1138 result.success = False 

1139 

1140 return result