Coverage for little_loops / sync.py: 13%

540 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:19 -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 

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 issues.append(issue_file) 

276 

277 # Include completed if configured 

278 if self.sync_config.github.sync_completed: 

279 completed_dir = self.config.get_completed_dir() 

280 if completed_dir.exists(): 

281 for issue_file in completed_dir.glob("*.md"): 

282 issues.append(issue_file) 

283 

284 return issues 

285 

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

287 """Extract issue ID from filename. 

288 

289 Args: 

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

291 

292 Returns: 

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

294 """ 

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

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

297 if match: 

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

299 return "" 

300 

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

302 """Determine GitHub labels for an issue. 

303 

304 Args: 

305 issue_path: Path to issue file 

306 

307 Returns: 

308 List of label names 

309 """ 

310 labels: list[str] = [] 

311 filename = issue_path.name 

312 

313 # Get type label from mapping 

314 issue_id = self._extract_issue_id(filename) 

315 if issue_id: 

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

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

318 if type_label: 

319 labels.append(type_label) 

320 

321 # Add priority label if configured 

322 if self.sync_config.github.priority_labels: 

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

324 if priority_match: 

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

326 

327 return labels 

328 

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

330 """Push local issues to GitHub. 

331 

332 Args: 

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

334 

335 Returns: 

336 SyncResult with operation details 

337 """ 

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

339 

340 # Verify gh auth 

341 if not _check_gh_auth(self.logger): 

342 result.success = False 

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

344 return result 

345 

346 # Get repo name 

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

348 if not repo: 

349 result.success = False 

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

351 return result 

352 

353 local_issues = self._get_local_issues() 

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

355 

356 for issue_path in local_issues: 

357 issue_id = self._extract_issue_id(issue_path.name) 

358 if not issue_id: 

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

360 continue 

361 

362 # Filter by issue_ids if specified 

363 if issue_ids and issue_id not in issue_ids: 

364 continue 

365 

366 try: 

367 self._push_single_issue(issue_path, issue_id, result) 

368 except Exception as e: 

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

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

371 

372 if result.failed: 

373 result.success = False 

374 

375 return result 

376 

377 def _push_single_issue( 

378 self, 

379 issue_path: Path, 

380 issue_id: str, 

381 result: SyncResult, 

382 ) -> None: 

383 """Push a single issue to GitHub. 

384 

385 Args: 

386 issue_path: Path to local issue file 

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

388 result: SyncResult to update 

389 """ 

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

391 frontmatter = parse_frontmatter(content, coerce_types=True) 

392 title = _parse_issue_title(content) 

393 body = _get_issue_body(content) 

394 

395 # Build full title with issue ID 

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

397 

398 # Get labels 

399 labels = self._get_labels_for_issue(issue_path) 

400 

401 github_number = frontmatter.get("github_issue") 

402 

403 if self.dry_run: 

404 if github_number: 

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

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

407 else: 

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

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

410 return 

411 

412 if github_number: 

413 # Update existing issue 

414 self._update_github_issue(int(github_number), full_title, body, issue_id, result) 

415 else: 

416 # Create new issue 

417 new_number = self._create_github_issue(full_title, body, labels, issue_id, result) 

418 if new_number: 

419 # Update local frontmatter 

420 self._update_local_frontmatter(issue_path, content, new_number) 

421 

422 def _create_github_issue( 

423 self, 

424 title: str, 

425 body: str, 

426 labels: list[str], 

427 issue_id: str, 

428 result: SyncResult, 

429 ) -> int | None: 

430 """Create a new GitHub issue. 

431 

432 Returns: 

433 GitHub issue number if successful, None otherwise 

434 """ 

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

436 for label in labels: 

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

438 

439 try: 

440 cmd_result = _run_gh_command(args, self.logger) 

441 # gh issue create outputs the URL 

442 url = cmd_result.stdout.strip() 

443 # Extract issue number from URL 

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

445 if match: 

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

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

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

449 return issue_num 

450 except subprocess.CalledProcessError as e: 

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

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

453 return None 

454 

455 def _update_github_issue( 

456 self, 

457 github_number: int, 

458 title: str, 

459 body: str, 

460 issue_id: str, 

461 result: SyncResult, 

462 ) -> None: 

463 """Update an existing GitHub issue.""" 

464 args = [ 

465 "issue", 

466 "edit", 

467 str(github_number), 

468 "--title", 

469 title, 

470 "--body", 

471 body, 

472 ] 

473 try: 

474 _run_gh_command(args, self.logger) 

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

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

477 except subprocess.CalledProcessError as e: 

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

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

480 

481 def _update_local_frontmatter( 

482 self, 

483 issue_path: Path, 

484 content: str, 

485 github_number: int, 

486 ) -> None: 

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

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

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

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

491 

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

493 "github_issue": github_number, 

494 "github_url": github_url, 

495 "last_synced": now, 

496 } 

497 updated_content = _update_issue_frontmatter(content, updates) 

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

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

500 

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

502 """Pull GitHub Issues to local files. 

503 

504 Args: 

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

506 

507 Returns: 

508 SyncResult with operation details 

509 """ 

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

511 

512 # Verify gh auth 

513 if not _check_gh_auth(self.logger): 

514 result.success = False 

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

516 return result 

517 

518 # List GitHub issues 

519 pull_limit = self.sync_config.github.pull_limit 

520 try: 

521 gh_args = [ 

522 "issue", 

523 "list", 

524 "--json", 

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

526 "--limit", 

527 str(pull_limit), 

528 ] 

529 if labels: 

530 for label in labels: 

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

532 cmd_result = _run_gh_command(gh_args, self.logger) 

533 github_issues = json.loads(cmd_result.stdout) 

534 except Exception as e: 

535 result.success = False 

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

537 return result 

538 

539 if len(github_issues) >= pull_limit: 

540 self.logger.warning( 

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

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

543 ) 

544 

545 # Get existing local issue IDs 

546 local_github_numbers = self._get_local_github_numbers() 

547 

548 for gh_issue in github_issues: 

549 gh_number = gh_issue["number"] 

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

551 

552 # Skip closed issues unless configured 

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

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

555 continue 

556 

557 # Skip if already tracked locally 

558 if gh_number in local_github_numbers: 

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

560 continue 

561 

562 # Check if has recognized labels 

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

564 issue_type = self._determine_issue_type(gh_labels) 

565 if not issue_type: 

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

567 continue 

568 

569 if self.dry_run: 

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

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

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

573 else: 

574 try: 

575 self._create_local_issue(gh_issue, issue_type, result) 

576 except Exception as e: 

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

578 

579 if result.failed: 

580 result.success = False 

581 

582 return result 

583 

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

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

586 numbers: set[int] = set() 

587 for issue_path in self._get_local_issues(): 

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

589 frontmatter = parse_frontmatter(content, coerce_types=True) 

590 gh_num = frontmatter.get("github_issue") 

591 if gh_num is not None: 

592 try: 

593 numbers.add(int(gh_num)) 

594 except (ValueError, TypeError): 

595 self.logger.warning( 

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

597 ) 

598 return numbers 

599 

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

601 """Determine issue type from GitHub labels. 

602 

603 Returns: 

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

605 """ 

606 # Reverse lookup from label_mapping 

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

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

609 if label not in reverse_map: 

610 reverse_map[label] = type_prefix 

611 

612 for label in labels: 

613 if label in reverse_map: 

614 return reverse_map[label] 

615 return None 

616 

617 def _create_local_issue( 

618 self, 

619 gh_issue: dict[str, Any], 

620 issue_type: str, 

621 result: SyncResult, 

622 ) -> None: 

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

624 gh_number = gh_issue["number"] 

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

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

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

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

629 

630 # Determine priority from labels or default 

631 priority = "P3" 

632 for label in gh_labels: 

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

634 priority = label.upper() 

635 break 

636 

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

638 next_num = get_next_issue_number(self.config) 

639 

640 # Generate slug from title 

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

642 

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

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

645 

646 # Determine category directory 

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

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

649 category_dir = self.config.get_issue_dir(category) 

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

651 

652 issue_path = category_dir / filename 

653 

654 # Build content using per-type sections template 

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

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

657 

658 if issue_type not in self._sections_data: 

659 templates_dir = ( 

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

661 ) 

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

663 

664 frontmatter = { 

665 "github_issue": gh_number, 

666 "github_url": gh_url, 

667 "last_synced": now, 

668 "discovered_by": "github_sync", 

669 "discovered_date": today, 

670 } 

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

672 if gh_body: 

673 section_content["Summary"] = gh_body 

674 section_content["Impact"] = ( 

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

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

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

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

679 ) 

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

681 

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

683 if labels_str: 

684 section_content["Labels"] = labels_str 

685 

686 variant = self.sync_config.github.pull_template 

687 content = assemble_issue_markdown( 

688 sections_data=self._sections_data[issue_type], 

689 issue_type=issue_type, 

690 variant=variant, 

691 issue_id=issue_id, 

692 title=gh_title, 

693 frontmatter=frontmatter, 

694 content=section_content, 

695 labels=gh_labels, 

696 ) 

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

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

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

700 

701 def get_status(self) -> SyncStatus: 

702 """Get sync status overview. 

703 

704 Returns: 

705 SyncStatus with counts 

706 """ 

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

708 

709 status = SyncStatus( 

710 provider=self.sync_config.provider, 

711 repo=repo, 

712 ) 

713 

714 # Count local issues 

715 local_issues = self._get_local_issues() 

716 status.local_total = len(local_issues) 

717 

718 # Count synced (have github_issue) 

719 local_github_numbers = self._get_local_github_numbers() 

720 status.local_synced = len(local_github_numbers) 

721 status.local_unsynced = status.local_total - status.local_synced 

722 

723 # Count GitHub issues 

724 if _check_gh_auth(self.logger): 

725 try: 

726 cmd_result = _run_gh_command( 

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

728 self.logger, 

729 ) 

730 github_issues = json.loads(cmd_result.stdout) 

731 status.github_total = len(github_issues) 

732 

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

734 status.github_only = len(github_numbers - local_github_numbers) 

735 except Exception as e: 

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

737 self.logger.warning(status.github_error) 

738 

739 return status 

740 

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

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

743 

744 Searches active category directories and completed directory. 

745 

746 Args: 

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

748 

749 Returns: 

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

751 """ 

752 for issue_path in self._get_local_issues(): 

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

754 return issue_path 

755 # Also check completed directory 

756 completed_dir = self.config.get_completed_dir() 

757 if completed_dir.exists(): 

758 for issue_file in completed_dir.glob("*.md"): 

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

760 return issue_file 

761 return None 

762 

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

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

765 

766 Args: 

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

768 

769 Returns: 

770 SyncResult with diff information 

771 """ 

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

773 

774 if not _check_gh_auth(self.logger): 

775 result.success = False 

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

777 return result 

778 

779 issue_path = self._find_local_issue(issue_id) 

780 if not issue_path: 

781 result.success = False 

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

783 return result 

784 

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

786 frontmatter = parse_frontmatter(content, coerce_types=True) 

787 github_number = frontmatter.get("github_issue") 

788 

789 if github_number is None: 

790 result.success = False 

791 result.errors.append( 

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

793 ) 

794 return result 

795 

796 try: 

797 cmd_result = _run_gh_command( 

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

799 self.logger, 

800 ) 

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

802 except subprocess.CalledProcessError as e: 

803 result.success = False 

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

805 return result 

806 

807 local_body = _get_issue_body(content) 

808 

809 local_lines = local_body.splitlines(keepends=True) 

810 github_lines = github_body.splitlines(keepends=True) 

811 

812 diff = list( 

813 difflib.unified_diff( 

814 github_lines, 

815 local_lines, 

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

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

818 ) 

819 ) 

820 

821 if diff: 

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

823 # Store diff lines in created field for display 

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

825 else: 

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

827 

828 return result 

829 

830 def diff_all(self) -> SyncResult: 

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

832 

833 Returns: 

834 SyncResult with diff summary 

835 """ 

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

837 

838 if not _check_gh_auth(self.logger): 

839 result.success = False 

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

841 return result 

842 

843 local_issues = self._get_local_issues() 

844 

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

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

847 for issue_path in local_issues: 

848 issue_id = self._extract_issue_id(issue_path.name) 

849 if not issue_id: 

850 continue 

851 

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

853 frontmatter = parse_frontmatter(content, coerce_types=True) 

854 github_number = frontmatter.get("github_issue") 

855 

856 if github_number is None: 

857 continue 

858 

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

860 

861 if not synced: 

862 return result 

863 

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

865 try: 

866 cmd_result = _run_gh_command( 

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

868 self.logger, 

869 ) 

870 github_bodies: dict[int, str] = { 

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

872 } 

873 except subprocess.CalledProcessError as e: 

874 result.success = False 

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

876 return result 

877 except Exception as e: 

878 result.success = False 

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

880 return result 

881 

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

883 for issue_id, github_number, content in synced: 

884 if github_number not in github_bodies: 

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

886 continue 

887 

888 local_body = _get_issue_body(content) 

889 github_body = github_bodies[github_number] 

890 

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

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

893 else: 

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

895 

896 if result.failed: 

897 result.success = False 

898 

899 return result 

900 

901 def close_issues( 

902 self, 

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

904 all_completed: bool = False, 

905 ) -> SyncResult: 

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

907 

908 Args: 

909 issue_ids: Specific issue IDs to close, or None 

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

911 are in the completed directory 

912 

913 Returns: 

914 SyncResult with operation details 

915 """ 

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

917 

918 if not _check_gh_auth(self.logger): 

919 result.success = False 

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

921 return result 

922 

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

924 

925 if all_completed: 

926 completed_dir = self.config.get_completed_dir() 

927 if completed_dir.exists(): 

928 for issue_file in completed_dir.glob("*.md"): 

929 eid = self._extract_issue_id(issue_file.name) 

930 if eid: 

931 files_to_close.append((issue_file, eid)) 

932 elif issue_ids: 

933 for eid in issue_ids: 

934 issue_path = self._find_local_issue(eid) 

935 if issue_path: 

936 files_to_close.append((issue_path, eid)) 

937 else: 

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

939 else: 

940 result.success = False 

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

942 return result 

943 

944 for issue_path, issue_id in files_to_close: 

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

946 frontmatter = parse_frontmatter(content, coerce_types=True) 

947 github_number = frontmatter.get("github_issue") 

948 

949 if github_number is None: 

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

951 continue 

952 

953 if self.dry_run: 

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

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

956 continue 

957 

958 try: 

959 _run_gh_command( 

960 [ 

961 "issue", 

962 "close", 

963 str(int(github_number)), 

964 "--comment", 

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

966 ], 

967 self.logger, 

968 ) 

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

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

971 except subprocess.CalledProcessError as e: 

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

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

974 

975 if result.failed: 

976 result.success = False 

977 

978 return result 

979 

980 def reopen_issues( 

981 self, 

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

983 all_reopened: bool = False, 

984 ) -> SyncResult: 

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

986 

987 Args: 

988 issue_ids: Specific issue IDs to reopen, or None 

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

990 directories that are CLOSED on GitHub 

991 

992 Returns: 

993 SyncResult with operation details 

994 """ 

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

996 

997 if not _check_gh_auth(self.logger): 

998 result.success = False 

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

1000 return result 

1001 

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

1003 

1004 if all_reopened: 

1005 for issue_path in self._get_local_issues(): 

1006 eid = self._extract_issue_id(issue_path.name) 

1007 if eid: 

1008 files_to_reopen.append((issue_path, eid)) 

1009 elif issue_ids: 

1010 for eid in issue_ids: 

1011 found_path = self._find_local_issue(eid) 

1012 if found_path: 

1013 files_to_reopen.append((found_path, eid)) 

1014 else: 

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

1016 else: 

1017 result.success = False 

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

1019 return result 

1020 

1021 for issue_path, issue_id in files_to_reopen: 

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

1023 frontmatter = parse_frontmatter(content, coerce_types=True) 

1024 github_number = frontmatter.get("github_issue") 

1025 

1026 if github_number is None: 

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

1028 continue 

1029 

1030 if all_reopened: 

1031 try: 

1032 state_result = _run_gh_command( 

1033 [ 

1034 "issue", 

1035 "view", 

1036 str(int(github_number)), 

1037 "--json", 

1038 "state", 

1039 "-q", 

1040 ".state", 

1041 ], 

1042 self.logger, 

1043 ) 

1044 state = state_result.stdout.strip() 

1045 if state != "CLOSED": 

1046 result.skipped.append( 

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

1048 ) 

1049 continue 

1050 except subprocess.CalledProcessError as e: 

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

1052 continue 

1053 

1054 if self.dry_run: 

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

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

1057 continue 

1058 

1059 try: 

1060 _run_gh_command( 

1061 [ 

1062 "issue", 

1063 "reopen", 

1064 str(int(github_number)), 

1065 "--comment", 

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

1067 ], 

1068 self.logger, 

1069 ) 

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

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

1072 except subprocess.CalledProcessError as e: 

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

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

1075 continue 

1076 

1077 # Move local file from completed/ back to active directory if needed 

1078 completed_dir = self.config.get_completed_dir() 

1079 if issue_path.parent == completed_dir: 

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

1081 category_map = {"BUG": "bugs", "FEAT": "features", "ENH": "enhancements"} 

1082 category = category_map.get(type_prefix) 

1083 if category: 

1084 target_dir = self.config.get_issue_dir(category) 

1085 target_path = target_dir / issue_path.name 

1086 try: 

1087 subprocess.run( 

1088 ["git", "mv", str(issue_path), str(target_path)], 

1089 check=True, 

1090 capture_output=True, 

1091 ) 

1092 self.logger.info(f"Moved {issue_path.name} back to {category}/") 

1093 except subprocess.CalledProcessError: 

1094 target_path.write_text(content, encoding="utf-8") 

1095 issue_path.unlink() 

1096 self.logger.info(f"Moved {issue_path.name} back to {category}/ (fallback)") 

1097 

1098 if result.failed: 

1099 result.success = False 

1100 

1101 return result