Coverage for little_loops / sync.py: 88%

557 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16: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, 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 "last_synced": now, 

717 "discovered_by": "github_sync", 

718 "discovered_date": today, 

719 } 

720 if user_labels: 

721 frontmatter["labels"] = user_labels 

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

723 if gh_body: 

724 section_content["Summary"] = gh_body 

725 section_content["Impact"] = ( 

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

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

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

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

730 ) 

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

732 

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

734 if labels_str: 

735 section_content["Labels"] = labels_str 

736 

737 variant = self.sync_config.github.pull_template 

738 content = assemble_issue_markdown( 

739 sections_data=self._sections_data[issue_type], 

740 issue_type=issue_type, 

741 variant=variant, 

742 issue_id=issue_id, 

743 title=gh_title, 

744 frontmatter=frontmatter, 

745 content=section_content, 

746 labels=gh_labels, 

747 ) 

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

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

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

751 

752 def get_status(self) -> SyncStatus: 

753 """Get sync status overview. 

754 

755 Returns: 

756 SyncStatus with counts 

757 """ 

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

759 

760 status = SyncStatus( 

761 provider=self.sync_config.provider, 

762 repo=repo, 

763 ) 

764 

765 # Count local issues 

766 local_issues = self._get_local_issues() 

767 status.local_total = len(local_issues) 

768 

769 # Count synced (have github_issue) 

770 local_github_numbers = self._get_local_github_numbers() 

771 status.local_synced = len(local_github_numbers) 

772 status.local_unsynced = status.local_total - status.local_synced 

773 

774 # Count GitHub issues 

775 if _check_gh_auth(self.logger): 

776 try: 

777 cmd_result = _run_gh_command( 

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

779 self.logger, 

780 ) 

781 github_issues = json.loads(cmd_result.stdout) 

782 status.github_total = len(github_issues) 

783 

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

785 status.github_only = len(github_numbers - local_github_numbers) 

786 except Exception as e: 

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

788 self.logger.warning(status.github_error) 

789 

790 return status 

791 

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

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

794 

795 Searches active category directories and completed directory. 

796 

797 Args: 

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

799 

800 Returns: 

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

802 """ 

803 for issue_path in self._get_local_issues(): 

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

805 return issue_path 

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

807 for category in self.config.issue_categories: 

808 category_dir = self.config.get_issue_dir(category) 

809 if not category_dir.exists(): 

810 continue 

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

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

813 return issue_file 

814 return None 

815 

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

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

818 

819 Args: 

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

821 

822 Returns: 

823 SyncResult with diff information 

824 """ 

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

826 

827 if not _check_gh_auth(self.logger): 

828 result.success = False 

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

830 return result 

831 

832 issue_path = self._find_local_issue(issue_id) 

833 if not issue_path: 

834 result.success = False 

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

836 return result 

837 

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

839 frontmatter = parse_frontmatter(content, coerce_types=True) 

840 github_number = frontmatter.get("github_issue") 

841 

842 if github_number is None: 

843 result.success = False 

844 result.errors.append( 

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

846 ) 

847 return result 

848 

849 try: 

850 cmd_result = _run_gh_command( 

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

852 self.logger, 

853 ) 

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

855 except subprocess.CalledProcessError as e: 

856 result.success = False 

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

858 return result 

859 

860 local_body = _get_issue_body(content) 

861 

862 local_lines = local_body.splitlines(keepends=True) 

863 github_lines = github_body.splitlines(keepends=True) 

864 

865 diff = list( 

866 difflib.unified_diff( 

867 github_lines, 

868 local_lines, 

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

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

871 ) 

872 ) 

873 

874 if diff: 

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

876 # Store diff lines in created field for display 

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

878 else: 

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

880 

881 return result 

882 

883 def diff_all(self) -> SyncResult: 

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

885 

886 Returns: 

887 SyncResult with diff summary 

888 """ 

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

890 

891 if not _check_gh_auth(self.logger): 

892 result.success = False 

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

894 return result 

895 

896 local_issues = self._get_local_issues() 

897 

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

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

900 for issue_path in local_issues: 

901 issue_id = self._extract_issue_id(issue_path.name) 

902 if not issue_id: 

903 continue 

904 

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

906 frontmatter = parse_frontmatter(content, coerce_types=True) 

907 github_number = frontmatter.get("github_issue") 

908 

909 if github_number is None: 

910 continue 

911 

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

913 

914 if not synced: 

915 return result 

916 

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

918 try: 

919 cmd_result = _run_gh_command( 

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

921 self.logger, 

922 ) 

923 github_bodies: dict[int, str] = { 

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

925 } 

926 except subprocess.CalledProcessError as e: 

927 result.success = False 

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

929 return result 

930 except Exception as e: 

931 result.success = False 

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

933 return result 

934 

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

936 for issue_id, github_number, content in synced: 

937 if github_number not in github_bodies: 

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

939 continue 

940 

941 local_body = _get_issue_body(content) 

942 github_body = github_bodies[github_number] 

943 

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

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

946 else: 

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

948 

949 if result.failed: 

950 result.success = False 

951 

952 return result 

953 

954 def close_issues( 

955 self, 

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

957 all_completed: bool = False, 

958 ) -> SyncResult: 

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

960 

961 Args: 

962 issue_ids: Specific issue IDs to close, or None 

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

964 are in the completed directory 

965 

966 Returns: 

967 SyncResult with operation details 

968 """ 

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

970 

971 if not _check_gh_auth(self.logger): 

972 result.success = False 

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

974 return result 

975 

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

977 

978 if all_completed: 

979 for category in self.config.issue_categories: 

980 category_dir = self.config.get_issue_dir(category) 

981 if not category_dir.exists(): 

982 continue 

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

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

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

986 eid = self._extract_issue_id(issue_file.name) 

987 if eid: 

988 files_to_close.append((issue_file, eid)) 

989 elif issue_ids: 

990 for eid in issue_ids: 

991 issue_path = self._find_local_issue(eid) 

992 if issue_path: 

993 files_to_close.append((issue_path, eid)) 

994 else: 

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

996 else: 

997 result.success = False 

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

999 return result 

1000 

1001 for issue_path, issue_id in files_to_close: 

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

1003 frontmatter = parse_frontmatter(content, coerce_types=True) 

1004 github_number = frontmatter.get("github_issue") 

1005 

1006 if github_number is None: 

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

1008 continue 

1009 

1010 if self.dry_run: 

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

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

1013 continue 

1014 

1015 try: 

1016 _run_gh_command( 

1017 [ 

1018 "issue", 

1019 "close", 

1020 str(int(github_number)), 

1021 "--comment", 

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

1023 ], 

1024 self.logger, 

1025 ) 

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

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

1028 except subprocess.CalledProcessError as e: 

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

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

1031 

1032 if result.failed: 

1033 result.success = False 

1034 

1035 return result 

1036 

1037 def reopen_issues( 

1038 self, 

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

1040 all_reopened: bool = False, 

1041 ) -> SyncResult: 

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

1043 

1044 Args: 

1045 issue_ids: Specific issue IDs to reopen, or None 

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

1047 directories that are CLOSED on GitHub 

1048 

1049 Returns: 

1050 SyncResult with operation details 

1051 """ 

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

1053 

1054 if not _check_gh_auth(self.logger): 

1055 result.success = False 

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

1057 return result 

1058 

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

1060 

1061 if all_reopened: 

1062 for issue_path in self._get_local_issues(): 

1063 eid = self._extract_issue_id(issue_path.name) 

1064 if eid: 

1065 files_to_reopen.append((issue_path, eid)) 

1066 elif issue_ids: 

1067 for eid in issue_ids: 

1068 found_path = self._find_local_issue(eid) 

1069 if found_path: 

1070 files_to_reopen.append((found_path, eid)) 

1071 else: 

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

1073 else: 

1074 result.success = False 

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

1076 return result 

1077 

1078 for issue_path, issue_id in files_to_reopen: 

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

1080 frontmatter = parse_frontmatter(content, coerce_types=True) 

1081 github_number = frontmatter.get("github_issue") 

1082 

1083 if github_number is None: 

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

1085 continue 

1086 

1087 if all_reopened: 

1088 try: 

1089 state_result = _run_gh_command( 

1090 [ 

1091 "issue", 

1092 "view", 

1093 str(int(github_number)), 

1094 "--json", 

1095 "state", 

1096 "-q", 

1097 ".state", 

1098 ], 

1099 self.logger, 

1100 ) 

1101 state = state_result.stdout.strip() 

1102 if state != "CLOSED": 

1103 result.skipped.append( 

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

1105 ) 

1106 continue 

1107 except subprocess.CalledProcessError as e: 

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

1109 continue 

1110 

1111 if self.dry_run: 

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

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

1114 continue 

1115 

1116 try: 

1117 _run_gh_command( 

1118 [ 

1119 "issue", 

1120 "reopen", 

1121 str(int(github_number)), 

1122 "--comment", 

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

1124 ], 

1125 self.logger, 

1126 ) 

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

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

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

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

1131 except subprocess.CalledProcessError as e: 

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

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

1134 continue 

1135 

1136 if result.failed: 

1137 result.success = False 

1138 

1139 return result