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
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
1"""GitHub Issues sync implementation for little-loops.
3Provides bidirectional sync between local .issues/ files and GitHub Issues.
4"""
6from __future__ import annotations
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
17import yaml
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
23if TYPE_CHECKING:
24 from little_loops.config import BRConfig
25 from little_loops.logger import Logger
28@dataclass
29class SyncedIssue:
30 """Represents an issue's sync state."""
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
41@dataclass
42class SyncResult:
43 """Result of a sync operation."""
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)
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 }
66@dataclass
67class SyncStatus:
68 """Sync status overview."""
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
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 }
93# =============================================================================
94# Helper Functions
95# =============================================================================
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.
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)
110 Returns:
111 CompletedProcess with stdout/stderr
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
127def _check_gh_auth(logger: Logger) -> bool:
128 """Check if gh CLI is authenticated.
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
141def _get_repo_name(logger: Logger) -> str | None:
142 """Get current repository name from gh CLI.
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
160def _update_issue_frontmatter(
161 content: str,
162 updates: dict[str, str | int],
163) -> str:
164 """Update or add frontmatter fields in issue content.
166 Args:
167 content: Full file content
168 updates: Fields to add/update in frontmatter
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}"
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() :]}"
185def _parse_issue_title(content: str) -> str:
186 """Extract title from issue content (after frontmatter).
188 Looks for first markdown heading: # ISSUE-ID: Title
190 Args:
191 content: Full file content
193 Returns:
194 Title string or empty string if not found
195 """
196 content = strip_frontmatter(content)
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 ""
213def _get_issue_body(content: str) -> str:
214 """Extract body from issue content (after frontmatter and title).
216 Args:
217 content: Full file content
219 Returns:
220 Body content
221 """
222 content = strip_frontmatter(content)
224 # Skip leading blank lines
225 lines = content.split("\n")
226 while lines and not lines[0].strip():
227 lines.pop(0)
229 # Skip title line
230 if lines and lines[0].startswith("# "):
231 lines.pop(0)
233 return "\n".join(lines).strip()
236# =============================================================================
237# GitHubSyncManager Class
238# =============================================================================
241class GitHubSyncManager:
242 """Manages bidirectional sync between local issues and GitHub Issues."""
244 def __init__(
245 self,
246 config: BRConfig,
247 logger: Logger,
248 dry_run: bool = False,
249 ) -> None:
250 """Initialize sync manager.
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]] = {}
264 def _get_local_issues(self) -> list[Path]:
265 """Get all local issue files to sync.
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)
281 return issues
283 def _extract_issue_id(self, filename: str) -> str:
284 """Extract issue ID from filename.
286 Args:
287 filename: Issue filename (e.g., P1-BUG-123-description.md)
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 ""
298 def _get_labels_for_issue(self, issue_path: Path) -> list[str]:
299 """Determine GitHub labels for an issue.
301 Args:
302 issue_path: Path to issue file
304 Returns:
305 List of label names
306 """
307 labels: list[str] = []
308 filename = issue_path.name
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)
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())
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")
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())
338 return labels
340 def push_issues(self, issue_ids: list[str] | None = None) -> SyncResult:
341 """Push local issues to GitHub.
343 Args:
344 issue_ids: Specific issue IDs to push, or None for all
346 Returns:
347 SyncResult with operation details
348 """
349 result = SyncResult(action="push", success=True)
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
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
364 local_issues = self._get_local_issues()
365 self.logger.info(f"Found {len(local_issues)} local issues")
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
373 # Filter by issue_ids if specified
374 if issue_ids and issue_id not in issue_ids:
375 continue
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}")
383 if result.failed:
384 result.success = False
386 return result
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.
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)
406 # Build full title with issue ID
407 full_title = f"{issue_id}: {title}" if title else issue_id
409 # Get labels
410 labels = self._get_labels_for_issue(issue_path)
412 github_number = frontmatter.get("github_issue")
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
423 milestone: str | None = frontmatter.get("milestone") or None
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
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 )
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.
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])
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
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}")
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")
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}")
539 def pull_issues(self, labels: list[str] | None = None) -> SyncResult:
540 """Pull GitHub Issues to local files.
542 Args:
543 labels: Filter by labels, or None for all recognized labels
545 Returns:
546 SyncResult with operation details
547 """
548 result = SyncResult(action="pull", success=True)
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
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
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 )
583 # Get existing local issue IDs
584 local_github_numbers = self._get_local_github_numbers()
586 for gh_issue in github_issues:
587 gh_number = gh_issue["number"]
588 gh_state = gh_issue.get("state", "OPEN")
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
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
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
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)))
617 if result.failed:
618 result.success = False
620 return result
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
638 def _determine_issue_type(self, labels: list[str]) -> str | None:
639 """Determine issue type from GitHub labels.
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
650 for label in labels:
651 if label in reverse_map:
652 return reverse_map[label]
653 return None
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", [])]
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
675 # Generate next issue number (uses global numbering across all dirs)
676 next_num = get_next_issue_number(self.config)
678 # Generate slug from title
679 slug = re.sub(r"[^a-z0-9]+", "-", gh_title.lower())[:40].strip("-")
681 issue_id = f"{issue_type}-{next_num}"
682 filename = f"{priority}-{issue_id}-{slug}.md"
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)
690 issue_path = category_dir / filename
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")
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)
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 ]
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}"
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
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}")
752 def get_status(self) -> SyncStatus:
753 """Get sync status overview.
755 Returns:
756 SyncStatus with counts
757 """
758 repo = self.sync_config.github.repo or _get_repo_name(self.logger) or "unknown"
760 status = SyncStatus(
761 provider=self.sync_config.provider,
762 repo=repo,
763 )
765 # Count local issues
766 local_issues = self._get_local_issues()
767 status.local_total = len(local_issues)
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
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)
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)
790 return status
792 def _find_local_issue(self, issue_id: str) -> Path | None:
793 """Find the local file matching an issue ID.
795 Searches active category directories and completed directory.
797 Args:
798 issue_id: Issue ID to find (e.g., BUG-123)
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
816 def diff_issue(self, issue_id: str) -> SyncResult:
817 """Show content differences between a local issue and its GitHub counterpart.
819 Args:
820 issue_id: Issue ID to diff (e.g., BUG-123)
822 Returns:
823 SyncResult with diff information
824 """
825 result = SyncResult(action="diff", success=True)
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
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
838 content = issue_path.read_text(encoding="utf-8")
839 frontmatter = parse_frontmatter(content, coerce_types=True)
840 github_number = frontmatter.get("github_issue")
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
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
860 local_body = _get_issue_body(content)
862 local_lines = local_body.splitlines(keepends=True)
863 github_lines = github_body.splitlines(keepends=True)
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 )
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")
881 return result
883 def diff_all(self) -> SyncResult:
884 """Show summary of differences between all synced local issues and GitHub.
886 Returns:
887 SyncResult with diff summary
888 """
889 result = SyncResult(action="diff", success=True)
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
896 local_issues = self._get_local_issues()
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
905 content = issue_path.read_text(encoding="utf-8")
906 frontmatter = parse_frontmatter(content, coerce_types=True)
907 github_number = frontmatter.get("github_issue")
909 if github_number is None:
910 continue
912 synced.append((issue_id, int(github_number), content))
914 if not synced:
915 return result
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
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
941 local_body = _get_issue_body(content)
942 github_body = github_bodies[github_number]
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")
949 if result.failed:
950 result.success = False
952 return result
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.
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
966 Returns:
967 SyncResult with operation details
968 """
969 result = SyncResult(action="close", success=True)
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
976 files_to_close: list[tuple[Path, str]] = [] # (path, issue_id)
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
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")
1006 if github_number is None:
1007 result.skipped.append(f"{issue_id} (not synced to GitHub)")
1008 continue
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
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}")
1032 if result.failed:
1033 result.success = False
1035 return result
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.
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
1049 Returns:
1050 SyncResult with operation details
1051 """
1052 result = SyncResult(action="reopen", success=True)
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
1059 files_to_reopen: list[tuple[Path, str]] = [] # (path, issue_id)
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
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")
1083 if github_number is None:
1084 result.skipped.append(f"{issue_id} (not synced to GitHub)")
1085 continue
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
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
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
1136 if result.failed:
1137 result.success = False
1139 return result