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, update_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 if not self.sync_config.github.sync_completed:

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

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

278 continue

279 issues.append(issue_file)

280

281 return issues

282

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

284 """Extract issue ID from filename.

285

286 Args:

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

288

289 Returns:

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

291 """

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

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

294 if match:

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

296 return ""

297

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

299 """Determine GitHub labels for an issue.

300

301 Args:

302 issue_path: Path to issue file

303

304 Returns:

305 List of label names

306 """

307 labels: list[str] = []

308 filename = issue_path.name

309

310 # Get type label from mapping

311 issue_id = self._extract_issue_id(filename)

312 if issue_id:

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

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

315 if type_label:

316 labels.append(type_label)

317

318 # Add priority label if configured

319 if self.sync_config.github.priority_labels:

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

321 if priority_match:

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

323

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

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

326 fm = parse_frontmatter(content, coerce_types=True)

327 if fm.get("blocked_by"):

328 labels.append("blocked-by")

329

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

331 fm_labels = fm.get("labels")

332 if fm_labels:

333 if isinstance(fm_labels, list):

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

335 elif isinstance(fm_labels, str):

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

337

338 return labels

339

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

341 """Push local issues to GitHub.

342

343 Args:

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

345

346 Returns:

347 SyncResult with operation details

348 """

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

350

351 # Verify gh auth

352 if not _check_gh_auth(self.logger):

353 result.success = False

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

355 return result

356

357 # Get repo name

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

359 if not repo:

360 result.success = False

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

362 return result

363

364 local_issues = self._get_local_issues()

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

366

367 for issue_path in local_issues:

368 issue_id = self._extract_issue_id(issue_path.name)

369 if not issue_id:

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

371 continue

372

373 # Filter by issue_ids if specified

374 if issue_ids and issue_id not in issue_ids:

375 continue

376

377 try:

378 self._push_single_issue(issue_path, issue_id, result)

379 except Exception as e:

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

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

382

383 if result.failed:

384 result.success = False

385

386 return result

387

388 def _push_single_issue(

389 self,

390 issue_path: Path,

391 issue_id: str,

392 result: SyncResult,

393 ) -> None:

394 """Push a single issue to GitHub.

395

396 Args:

397 issue_path: Path to local issue file

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

399 result: SyncResult to update

400 """

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

402 frontmatter = parse_frontmatter(content, coerce_types=True)

403 title = _parse_issue_title(content)

404 body = _get_issue_body(content)

405

406 # Build full title with issue ID

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

408

409 # Get labels

410 labels = self._get_labels_for_issue(issue_path)

411

412 github_number = frontmatter.get("github_issue")

413

414 if self.dry_run:

415 if github_number:

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

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

418 else:

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

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

421 return

422

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

424

425 effective_number: int | None = None

426 if github_number:

427 # Update existing issue

428 self._update_github_issue(

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

430 )

431 effective_number = int(github_number)

432 else:

433 # Create new issue

434 new_number = self._create_github_issue(

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

436 )

437 if new_number:

438 # Update local frontmatter

439 self._update_local_frontmatter(issue_path, content, new_number)

440 effective_number = new_number

441

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

443 _run_gh_command(

444 [

445 "issue",

446 "comment",

447 str(effective_number),

448 "--body",

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

450 ],

451 self.logger,

452 )

453

454 def _create_github_issue(

455 self,

456 title: str,

457 body: str,

458 labels: list[str],

459 issue_id: str,

460 result: SyncResult,

461 milestone: str | None = None,

462 ) -> int | None:

463 """Create a new GitHub issue.

464

465 Returns:

466 GitHub issue number if successful, None otherwise

467 """

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

469 for label in labels:

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

471 if milestone:

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

473

474 try:

475 cmd_result = _run_gh_command(args, self.logger)

476 # gh issue create outputs the URL

477 url = cmd_result.stdout.strip()

478 # Extract issue number from URL

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

480 if match:

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

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

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

484 return issue_num

485 except subprocess.CalledProcessError as e:

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

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

488 return None

489

490 def _update_github_issue(

491 self,

492 github_number: int,

493 title: str,

494 body: str,

495 issue_id: str,

496 result: SyncResult,

497 milestone: str | None = None,

498 ) -> None:

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

500 args = [

501 "issue",

502 "edit",

503 str(github_number),

504 "--title",

505 title,

506 "--body",

507 body,

508 ]

509 if milestone:

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

511 try:

512 _run_gh_command(args, self.logger)

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

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

515 except subprocess.CalledProcessError as e:

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

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

518

519 def _update_local_frontmatter(

520 self,

521 issue_path: Path,

522 content: str,

523 github_number: int,

524 ) -> None:

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

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

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

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

529

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

531 "github_issue": github_number,

532 "github_url": github_url,

533 "last_synced": now,

534 }

535 updated_content = _update_issue_frontmatter(content, updates)

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

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

538

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

540 """Pull GitHub Issues to local files.

541

542 Args:

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

544

545 Returns:

546 SyncResult with operation details

547 """

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

549

550 # Verify gh auth

551 if not _check_gh_auth(self.logger):

552 result.success = False

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

554 return result

555

556 # List GitHub issues

557 pull_limit = self.sync_config.github.pull_limit

558 try:

559 gh_args = [

560 "issue",

561 "list",

562 "--json",

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

564 "--limit",

565 str(pull_limit),

566 ]

567 if labels:

568 for label in labels:

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

570 cmd_result = _run_gh_command(gh_args, self.logger)

571 github_issues = json.loads(cmd_result.stdout)

572 except Exception as e:

573 result.success = False

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

575 return result

576

577 if len(github_issues) >= pull_limit:

578 self.logger.warning(

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

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

581 )

582

583 # Get existing local issue IDs

584 local_github_numbers = self._get_local_github_numbers()

585

586 for gh_issue in github_issues:

587 gh_number = gh_issue["number"]

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

589

590 # Skip closed issues unless configured

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

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

593 continue

594

595 # Skip if already tracked locally

596 if gh_number in local_github_numbers:

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

598 continue

599

600 # Check if has recognized labels

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

602 issue_type = self._determine_issue_type(gh_labels)

603 if not issue_type:

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

605 continue

606

607 if self.dry_run:

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

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

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

611 else:

612 try:

613 self._create_local_issue(gh_issue, issue_type, result)

614 except Exception as e:

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

616

617 if result.failed:

618 result.success = False

619

620 return result

621

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

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

624 numbers: set[int] = set()

625 for issue_path in self._get_local_issues():

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

627 frontmatter = parse_frontmatter(content, coerce_types=True)

628 gh_num = frontmatter.get("github_issue")

629 if gh_num is not None:

630 try:

631 numbers.add(int(gh_num))

632 except (ValueError, TypeError):

633 self.logger.warning(

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

635 )

636 return numbers

637

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

639 """Determine issue type from GitHub labels.

640

641 Returns:

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

643 """

644 # Reverse lookup from label_mapping

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

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

647 if label not in reverse_map:

648 reverse_map[label] = type_prefix

649

650 for label in labels:

651 if label in reverse_map:

652 return reverse_map[label]

653 return None

654

655 def _create_local_issue(

656 self,

657 gh_issue: dict[str, Any],

658 issue_type: str,

659 result: SyncResult,

660 ) -> None:

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

662 gh_number = gh_issue["number"]

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

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

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

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

667

668 # Determine priority from labels or default

669 priority = "P3"

670 for label in gh_labels:

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

672 priority = label.upper()

673 break

674

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

676 next_num = get_next_issue_number(self.config)

677

678 # Generate slug from title

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

680

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

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

683

684 # Determine category directory

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

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

687 category_dir = self.config.get_issue_dir(category)

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

689

690 issue_path = category_dir / filename

691

692 # Build content using per-type sections template

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

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

695

696 if issue_type not in self._sections_data:

697 templates_dir = (

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

699 )

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

701

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

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

704 _managed_type_labels = {

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

706 }

707 user_labels = [

708 lbl

709 for lbl in gh_labels

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

711 ]

712

713 frontmatter = {

714 "github_issue": gh_number,

715 "github_url": gh_url,

716 "captured_at": now,

717 "last_synced": now,

718 "discovered_by": "github_sync",

719 "discovered_date": today,

720 }

721 if user_labels:

722 frontmatter["labels"] = user_labels

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

724 if gh_body:

725 section_content["Summary"] = gh_body

726 section_content["Impact"] = (

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

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

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

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

731 )

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

733

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

735 if labels_str:

736 section_content["Labels"] = labels_str

737

738 variant = self.sync_config.github.pull_template

739 content = assemble_issue_markdown(

740 sections_data=self._sections_data[issue_type],

741 issue_type=issue_type,

742 variant=variant,

743 issue_id=issue_id,

744 title=gh_title,

745 frontmatter=frontmatter,

746 content=section_content,

747 labels=gh_labels,

748 )

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

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

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

752

753 def get_status(self) -> SyncStatus:

754 """Get sync status overview.

755

756 Returns:

757 SyncStatus with counts

758 """

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

760

761 status = SyncStatus(

762 provider=self.sync_config.provider,

763 repo=repo,

764 )

765

766 # Count local issues

767 local_issues = self._get_local_issues()

768 status.local_total = len(local_issues)

769

770 # Count synced (have github_issue)

771 local_github_numbers = self._get_local_github_numbers()

772 status.local_synced = len(local_github_numbers)

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

774

775 # Count GitHub issues

776 if _check_gh_auth(self.logger):

777 try:

778 cmd_result = _run_gh_command(

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

780 self.logger,

781 )

782 github_issues = json.loads(cmd_result.stdout)

783 status.github_total = len(github_issues)

784

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

786 status.github_only = len(github_numbers - local_github_numbers)

787 except Exception as e:

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

789 self.logger.warning(status.github_error)

790

791 return status

792

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

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

795

796 Searches active category directories and completed directory.

797

798 Args:

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

800

801 Returns:

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

803 """

804 for issue_path in self._get_local_issues():

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

806 return issue_path

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

808 for category in self.config.issue_categories:

809 category_dir = self.config.get_issue_dir(category)

810 if not category_dir.exists():

811 continue

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

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

814 return issue_file

815 return None

816

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

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

819

820 Args:

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

822

823 Returns:

824 SyncResult with diff information

825 """

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

827

828 if not _check_gh_auth(self.logger):

829 result.success = False

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

831 return result

832

833 issue_path = self._find_local_issue(issue_id)

834 if not issue_path:

835 result.success = False

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

837 return result

838

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

840 frontmatter = parse_frontmatter(content, coerce_types=True)

841 github_number = frontmatter.get("github_issue")

842

843 if github_number is None:

844 result.success = False

845 result.errors.append(

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

847 )

848 return result

849

850 try:

851 cmd_result = _run_gh_command(

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

853 self.logger,

854 )

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

856 except subprocess.CalledProcessError as e:

857 result.success = False

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

859 return result

860

861 local_body = _get_issue_body(content)

862

863 local_lines = local_body.splitlines(keepends=True)

864 github_lines = github_body.splitlines(keepends=True)

865

866 diff = list(

867 difflib.unified_diff(

868 github_lines,

869 local_lines,

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

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

872 )

873 )

874

875 if diff:

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

877 # Store diff lines in created field for display

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

879 else:

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

881

882 return result

883

884 def diff_all(self) -> SyncResult:

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

886

887 Returns:

888 SyncResult with diff summary

889 """

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

891

892 if not _check_gh_auth(self.logger):

893 result.success = False

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

895 return result

896

897 local_issues = self._get_local_issues()

898

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

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

901 for issue_path in local_issues:

902 issue_id = self._extract_issue_id(issue_path.name)

903 if not issue_id:

904 continue

905

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

907 frontmatter = parse_frontmatter(content, coerce_types=True)

908 github_number = frontmatter.get("github_issue")

909

910 if github_number is None:

911 continue

912

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

914

915 if not synced:

916 return result

917

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

919 try:

920 cmd_result = _run_gh_command(

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

922 self.logger,

923 )

924 github_bodies: dict[int, str] = {

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

926 }

927 except subprocess.CalledProcessError as e:

928 result.success = False

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

930 return result

931 except Exception as e:

932 result.success = False

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

934 return result

935

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

937 for issue_id, github_number, content in synced:

938 if github_number not in github_bodies:

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

940 continue

941

942 local_body = _get_issue_body(content)

943 github_body = github_bodies[github_number]

944

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

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

947 else:

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

949

950 if result.failed:

951 result.success = False

952

953 return result

954

955 def close_issues(

956 self,

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

958 all_completed: bool = False,

959 ) -> SyncResult:

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

961

962 Args:

963 issue_ids: Specific issue IDs to close, or None

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

965 are in the completed directory

966

967 Returns:

968 SyncResult with operation details

969 """

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

971

972 if not _check_gh_auth(self.logger):

973 result.success = False

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

975 return result

976

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

978

979 if all_completed:

980 for category in self.config.issue_categories:

981 category_dir = self.config.get_issue_dir(category)

982 if not category_dir.exists():

983 continue

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

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

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

987 eid = self._extract_issue_id(issue_file.name)

988 if eid:

989 files_to_close.append((issue_file, eid))

990 elif issue_ids:

991 for eid in issue_ids:

992 issue_path = self._find_local_issue(eid)

993 if issue_path:

994 files_to_close.append((issue_path, eid))

995 else:

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

997 else:

998 result.success = False

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

1000 return result

1001

1002 for issue_path, issue_id in files_to_close:

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

1004 frontmatter = parse_frontmatter(content, coerce_types=True)

1005 github_number = frontmatter.get("github_issue")

1006

1007 if github_number is None:

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

1009 continue

1010

1011 if self.dry_run:

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

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

1014 continue

1015

1016 try:

1017 _run_gh_command(

1018 [

1019 "issue",

1020 "close",

1021 str(int(github_number)),

1022 "--comment",

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

1024 ],

1025 self.logger,

1026 )

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

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

1029 except subprocess.CalledProcessError as e:

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

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

1032

1033 if result.failed:

1034 result.success = False

1035

1036 return result

1037

1038 def reopen_issues(

1039 self,

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

1041 all_reopened: bool = False,

1042 ) -> SyncResult:

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

1044

1045 Args:

1046 issue_ids: Specific issue IDs to reopen, or None

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

1048 directories that are CLOSED on GitHub

1049

1050 Returns:

1051 SyncResult with operation details

1052 """

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

1054

1055 if not _check_gh_auth(self.logger):

1056 result.success = False

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

1058 return result

1059

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

1061

1062 if all_reopened:

1063 for issue_path in self._get_local_issues():

1064 eid = self._extract_issue_id(issue_path.name)

1065 if eid:

1066 files_to_reopen.append((issue_path, eid))

1067 elif issue_ids:

1068 for eid in issue_ids:

1069 found_path = self._find_local_issue(eid)

1070 if found_path:

1071 files_to_reopen.append((found_path, eid))

1072 else:

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

1074 else:

1075 result.success = False

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

1077 return result

1078

1079 for issue_path, issue_id in files_to_reopen:

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

1081 frontmatter = parse_frontmatter(content, coerce_types=True)

1082 github_number = frontmatter.get("github_issue")

1083

1084 if github_number is None:

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

1086 continue

1087

1088 if all_reopened:

1089 try:

1090 state_result = _run_gh_command(

1091 [

1092 "issue",

1093 "view",

1094 str(int(github_number)),

1095 "--json",

1096 "state",

1097 "-q",

1098 ".state",

1099 ],

1100 self.logger,

1101 )

1102 state = state_result.stdout.strip()

1103 if state != "CLOSED":

1104 result.skipped.append(

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

1106 )

1107 continue

1108 except subprocess.CalledProcessError as e:

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

1110 continue

1111

1112 if self.dry_run:

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

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

1115 continue

1116

1117 try:

1118 _run_gh_command(

1119 [

1120 "issue",

1121 "reopen",

1122 str(int(github_number)),

1123 "--comment",

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

1125 ],

1126 self.logger,

1127 )

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

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

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

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

1132 except subprocess.CalledProcessError as e:

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

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

1135 continue

1136

1137 if result.failed:

1138 result.success = False

1139

1140 return result

1141

1142 def reconcile_pr_merges(self) -> int:

1143 """Promote feature-branch issues to done when their PR has been merged.

1144

1145 Enumerates all in_progress issues that have a ``pr_url:`` or ``branch:``

1146 frontmatter field, queries GitHub PR state via ``gh pr view``, and writes

1147 ``status: done`` for any whose PR has been merged into the base branch.

1148

1149 Returns:

1150 Count of issues promoted to ``done``

1151 """

1152 from little_loops.parallel.github_utils import is_pr_merged

1153

1154 promoted = 0

1155 for issue_path in self._get_local_issues():

1156 try:

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

1158 fm = parse_frontmatter(content)

1159 if fm.get("status") != "in_progress":

1160 continue

1161 pr_url = fm.get("pr_url") or None

1162 branch = fm.get("branch") or None

1163 if not pr_url and not branch:

1164 continue

1165 if is_pr_merged(branch or "", pr_url):

1166 issue_id = self._extract_issue_id(issue_path.name)

1167 if self.dry_run:

1168 self.logger.info(f"[DRY RUN] Would promote {issue_id} to done (PR merged)")

1169 promoted += 1

1170 else:

1171 now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")

1172 new_content = update_frontmatter(

1173 content, {"status": "done", "completed_at": now}

1174 )

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

1176 self.logger.success(f"Promoted {issue_id} to done (PR merged)")

1177 promoted += 1

1178 except Exception as exc:

1179 self.logger.warning(f"reconcile: error processing {issue_path.name}: {exc}")

1180

1181 return promoted