Coverage for little_loops/sync.py: 15%

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

2

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

4"""

5

6 from __future__ import annotations

7

8 import difflib

9 import json

10 import re

11 import subprocess

12 from dataclasses import dataclass, field

13 from datetime import UTC, datetime

14 from pathlib import Path

15 from typing import TYPE_CHECKING, Any

16

17 import yaml

18

19 from little_loops.frontmatter import parse_frontmatter, strip_frontmatter

20 from little_loops.issue_parser import get_next_issue_number

21 from little_loops.issue_template import assemble_issue_markdown, load_issue_sections

22

23 if TYPE_CHECKING:

24 from little_loops.config import BRConfig

25 from little_loops.logger import Logger

26

27

28@dataclass

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

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

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

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

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

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

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

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

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

241 class 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 try:

520 gh_args = [

521 "issue",

522 "list",

523 "--json",

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

525 "--limit",

526 "100",

527 ]

528 if labels:

529 for label in labels:

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

531 cmd_result = _run_gh_command(gh_args, self.logger)

532 github_issues = json.loads(cmd_result.stdout)

533 except Exception as e:

534 result.success = False

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

536 return result

537

538 # Get existing local issue IDs

539 local_github_numbers = self._get_local_github_numbers()

540

541 for gh_issue in github_issues:

542 gh_number = gh_issue["number"]

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

544

545 # Skip closed issues unless configured

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

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

548 continue

549

550 # Skip if already tracked locally

551 if gh_number in local_github_numbers:

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

553 continue

554

555 # Check if has recognized labels

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

557 issue_type = self._determine_issue_type(gh_labels)

558 if not issue_type:

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

560 continue

561

562 if self.dry_run:

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

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

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

566 else:

567 try:

568 self._create_local_issue(gh_issue, issue_type, result)

569 except Exception as e:

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

571

572 if result.failed:

573 result.success = False

574

575 return result

576

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

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

579 numbers: set[int] = set()

580 for issue_path in self._get_local_issues():

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

582 frontmatter = parse_frontmatter(content, coerce_types=True)

583 gh_num = frontmatter.get("github_issue")

584 if gh_num is not None:

585 try:

586 numbers.add(int(gh_num))

587 except (ValueError, TypeError):

588 self.logger.warning(

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

590 )

591 return numbers

592

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

594 """Determine issue type from GitHub labels.

595

596 Returns:

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

598 """

599 # Reverse lookup from label_mapping

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

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

602 if label not in reverse_map:

603 reverse_map[label] = type_prefix

604

605 for label in labels:

606 if label in reverse_map:

607 return reverse_map[label]

608 return None

609

610 def _create_local_issue(

611 self,

612 gh_issue: dict[str, Any],

613 issue_type: str,

614 result: SyncResult,

615 ) -> None:

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

617 gh_number = gh_issue["number"]

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

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

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

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

622

623 # Determine priority from labels or default

624 priority = "P3"

625 for label in gh_labels:

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

627 priority = label.upper()

628 break

629

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

631 next_num = get_next_issue_number(self.config)

632

633 # Generate slug from title

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

635

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

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

638

639 # Determine category directory

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

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

642 category_dir = self.config.get_issue_dir(category)

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

644

645 issue_path = category_dir / filename

646

647 # Build content using per-type sections template

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

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

650

651 if issue_type not in self._sections_data:

652 templates_dir = (

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

654 )

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

656

657 frontmatter = {

658 "github_issue": gh_number,

659 "github_url": gh_url,

660 "last_synced": now,

661 "discovered_by": "github_sync",

662 "discovered_date": today,

663 }

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

665 if gh_body:

666 section_content["Summary"] = gh_body

667 section_content["Impact"] = (

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

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

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

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

672 )

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

674

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

676 if labels_str:

677 section_content["Labels"] = labels_str

678

679 variant = self.sync_config.github.pull_template

680 content = assemble_issue_markdown(

681 sections_data=self._sections_data[issue_type],

682 issue_type=issue_type,

683 variant=variant,

684 issue_id=issue_id,

685 title=gh_title,

686 frontmatter=frontmatter,

687 content=section_content,

688 labels=gh_labels,

689 )

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

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

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

693

694 def get_status(self) -> SyncStatus:

695 """Get sync status overview.

696

697 Returns:

698 SyncStatus with counts

699 """

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

701

702 status = SyncStatus(

703 provider=self.sync_config.provider,

704 repo=repo,

705 )

706

707 # Count local issues

708 local_issues = self._get_local_issues()

709 status.local_total = len(local_issues)

710

711 # Count synced (have github_issue)

712 local_github_numbers = self._get_local_github_numbers()

713 status.local_synced = len(local_github_numbers)

714 status.local_unsynced = status.local_total - status.local_synced

715

716 # Count GitHub issues

717 if _check_gh_auth(self.logger):

718 try:

719 cmd_result = _run_gh_command(

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

721 self.logger,

722 )

723 github_issues = json.loads(cmd_result.stdout)

724 status.github_total = len(github_issues)

725

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

727 status.github_only = len(github_numbers - local_github_numbers)

728 except Exception as e:

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

730 self.logger.warning(status.github_error)

731

732 return status

733

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

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

736

737 Searches active category directories and completed directory.

738

739 Args:

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

741

742 Returns:

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

744 """

745 for issue_path in self._get_local_issues():

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

747 return issue_path

748 # Also check completed directory

749 completed_dir = self.config.get_completed_dir()

750 if completed_dir.exists():

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

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

753 return issue_file

754 return None

755

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

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

758

759 Args:

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

761

762 Returns:

763 SyncResult with diff information

764 """

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

766

767 if not _check_gh_auth(self.logger):

768 result.success = False

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

770 return result

771

772 issue_path = self._find_local_issue(issue_id)

773 if not issue_path:

774 result.success = False

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

776 return result

777

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

779 frontmatter = parse_frontmatter(content, coerce_types=True)

780 github_number = frontmatter.get("github_issue")

781

782 if github_number is None:

783 result.success = False

784 result.errors.append(

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

786 )

787 return result

788

789 try:

790 cmd_result = _run_gh_command(

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

792 self.logger,

793 )

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

795 except subprocess.CalledProcessError as e:

796 result.success = False

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

798 return result

799

800 local_body = _get_issue_body(content)

801

802 local_lines = local_body.splitlines(keepends=True)

803 github_lines = github_body.splitlines(keepends=True)

804

805 diff = list(

806 difflib.unified_diff(

807 github_lines,

808 local_lines,

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

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

811 )

812 )

813

814 if diff:

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

816 # Store diff lines in created field for display

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

818 else:

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

820

821 return result

822

823 def diff_all(self) -> SyncResult:

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

825

826 Returns:

827 SyncResult with diff summary

828 """

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

830

831 if not _check_gh_auth(self.logger):

832 result.success = False

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

834 return result

835

836 local_issues = self._get_local_issues()

837

838 for issue_path in local_issues:

839 issue_id = self._extract_issue_id(issue_path.name)

840 if not issue_id:

841 continue

842

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

844 frontmatter = parse_frontmatter(content, coerce_types=True)

845 github_number = frontmatter.get("github_issue")

846

847 if github_number is None:

848 continue

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.failed.append((issue_id, f"Failed to fetch #{github_number}: {e.stderr}"))

858 continue

859

860 local_body = _get_issue_body(content)

861

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

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

864 else:

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

866

867 if result.failed:

868 result.success = False

869

870 return result

871

872 def close_issues(

873 self,

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

875 all_completed: bool = False,

876 ) -> SyncResult:

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

878

879 Args:

880 issue_ids: Specific issue IDs to close, or None

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

882 are in the completed directory

883

884 Returns:

885 SyncResult with operation details

886 """

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

888

889 if not _check_gh_auth(self.logger):

890 result.success = False

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

892 return result

893

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

895

896 if all_completed:

897 completed_dir = self.config.get_completed_dir()

898 if completed_dir.exists():

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

900 eid = self._extract_issue_id(issue_file.name)

901 if eid:

902 files_to_close.append((issue_file, eid))

903 elif issue_ids:

904 for eid in issue_ids:

905 issue_path = self._find_local_issue(eid)

906 if issue_path:

907 files_to_close.append((issue_path, eid))

908 else:

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

910 else:

911 result.success = False

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

913 return result

914

915 for issue_path, issue_id in files_to_close:

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

917 frontmatter = parse_frontmatter(content, coerce_types=True)

918 github_number = frontmatter.get("github_issue")

919

920 if github_number is None:

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

922 continue

923

924 if self.dry_run:

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

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

927 continue

928

929 try:

930 _run_gh_command(

931 [

932 "issue",

933 "close",

934 str(int(github_number)),

935 "--comment",

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

937 ],

938 self.logger,

939 )

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

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

942 except subprocess.CalledProcessError as e:

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

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

945

946 if result.failed:

947 result.success = False

948

949 return result