Coverage for little_loops / sync.py: 13%
540 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23: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
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 issues.append(issue_file)
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)
284 return issues
286 def _extract_issue_id(self, filename: str) -> str:
287 """Extract issue ID from filename.
289 Args:
290 filename: Issue filename (e.g., P1-BUG-123-description.md)
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 ""
301 def _get_labels_for_issue(self, issue_path: Path) -> list[str]:
302 """Determine GitHub labels for an issue.
304 Args:
305 issue_path: Path to issue file
307 Returns:
308 List of label names
309 """
310 labels: list[str] = []
311 filename = issue_path.name
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)
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())
327 return labels
329 def push_issues(self, issue_ids: list[str] | None = None) -> SyncResult:
330 """Push local issues to GitHub.
332 Args:
333 issue_ids: Specific issue IDs to push, or None for all
335 Returns:
336 SyncResult with operation details
337 """
338 result = SyncResult(action="push", success=True)
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
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
353 local_issues = self._get_local_issues()
354 self.logger.info(f"Found {len(local_issues)} local issues")
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
362 # Filter by issue_ids if specified
363 if issue_ids and issue_id not in issue_ids:
364 continue
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}")
372 if result.failed:
373 result.success = False
375 return result
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.
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)
395 # Build full title with issue ID
396 full_title = f"{issue_id}: {title}" if title else issue_id
398 # Get labels
399 labels = self._get_labels_for_issue(issue_path)
401 github_number = frontmatter.get("github_issue")
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
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)
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.
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])
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
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}")
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")
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}")
501 def pull_issues(self, labels: list[str] | None = None) -> SyncResult:
502 """Pull GitHub Issues to local files.
504 Args:
505 labels: Filter by labels, or None for all recognized labels
507 Returns:
508 SyncResult with operation details
509 """
510 result = SyncResult(action="pull", success=True)
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
518 # List GitHub issues
519 pull_limit = self.sync_config.github.pull_limit
520 try:
521 gh_args = [
522 "issue",
523 "list",
524 "--json",
525 "number,title,body,labels,state,url",
526 "--limit",
527 str(pull_limit),
528 ]
529 if labels:
530 for label in labels:
531 gh_args.extend(["--label", label])
532 cmd_result = _run_gh_command(gh_args, self.logger)
533 github_issues = json.loads(cmd_result.stdout)
534 except Exception as e:
535 result.success = False
536 result.errors.append(f"Failed to list GitHub issues: {e}")
537 return result
539 if len(github_issues) >= pull_limit:
540 self.logger.warning(
541 f"Fetched {len(github_issues)} issues which equals the pull_limit ({pull_limit}). "
542 "Results may be truncated. Increase sync.github.pull_limit in ll-config.json to fetch more."
543 )
545 # Get existing local issue IDs
546 local_github_numbers = self._get_local_github_numbers()
548 for gh_issue in github_issues:
549 gh_number = gh_issue["number"]
550 gh_state = gh_issue.get("state", "OPEN")
552 # Skip closed issues unless configured
553 if gh_state != "OPEN" and not self.sync_config.github.sync_completed:
554 result.skipped.append(f"#{gh_number} (closed)")
555 continue
557 # Skip if already tracked locally
558 if gh_number in local_github_numbers:
559 result.skipped.append(f"#{gh_number} (already tracked)")
560 continue
562 # Check if has recognized labels
563 gh_labels = [lbl.get("name", "") for lbl in gh_issue.get("labels", [])]
564 issue_type = self._determine_issue_type(gh_labels)
565 if not issue_type:
566 result.skipped.append(f"#{gh_number} (no recognized type label)")
567 continue
569 if self.dry_run:
570 gh_title = gh_issue.get("title", f"Issue #{gh_number}")
571 result.created.append(f"#{gh_number}: {gh_title} (would create as {issue_type})")
572 self.logger.info(f"Would create local issue from GitHub #{gh_number}: {gh_title}")
573 else:
574 try:
575 self._create_local_issue(gh_issue, issue_type, result)
576 except Exception as e:
577 result.failed.append((f"#{gh_number}", str(e)))
579 if result.failed:
580 result.success = False
582 return result
584 def _get_local_github_numbers(self) -> set[int]:
585 """Get set of GitHub issue numbers tracked locally."""
586 numbers: set[int] = set()
587 for issue_path in self._get_local_issues():
588 content = issue_path.read_text(encoding="utf-8")
589 frontmatter = parse_frontmatter(content, coerce_types=True)
590 gh_num = frontmatter.get("github_issue")
591 if gh_num is not None:
592 try:
593 numbers.add(int(gh_num))
594 except (ValueError, TypeError):
595 self.logger.warning(
596 f"Malformed github_issue value in {issue_path.name}: {gh_num!r}"
597 )
598 return numbers
600 def _determine_issue_type(self, labels: list[str]) -> str | None:
601 """Determine issue type from GitHub labels.
603 Returns:
604 Issue type prefix (BUG, FEAT, ENH) or None
605 """
606 # Reverse lookup from label_mapping
607 reverse_map: dict[str, str] = {}
608 for type_prefix, label in self.sync_config.github.label_mapping.items():
609 if label not in reverse_map:
610 reverse_map[label] = type_prefix
612 for label in labels:
613 if label in reverse_map:
614 return reverse_map[label]
615 return None
617 def _create_local_issue(
618 self,
619 gh_issue: dict[str, Any],
620 issue_type: str,
621 result: SyncResult,
622 ) -> None:
623 """Create a local issue file from GitHub issue."""
624 gh_number = gh_issue["number"]
625 gh_title = gh_issue.get("title", f"Issue #{gh_number}")
626 gh_body = gh_issue.get("body", "") or ""
627 gh_url = gh_issue.get("url", "")
628 gh_labels = [lbl.get("name", "") for lbl in gh_issue.get("labels", [])]
630 # Determine priority from labels or default
631 priority = "P3"
632 for label in gh_labels:
633 if re.match(r"^p[0-5]$", label, re.IGNORECASE):
634 priority = label.upper()
635 break
637 # Generate next issue number (uses global numbering across all dirs)
638 next_num = get_next_issue_number(self.config)
640 # Generate slug from title
641 slug = re.sub(r"[^a-z0-9]+", "-", gh_title.lower())[:40].strip("-")
643 issue_id = f"{issue_type}-{next_num}"
644 filename = f"{priority}-{issue_id}-{slug}.md"
646 # Determine category directory
647 cat = self.config.issues.get_category_by_prefix(issue_type)
648 category = cat.dir if cat else "features"
649 category_dir = self.config.get_issue_dir(category)
650 category_dir.mkdir(parents=True, exist_ok=True)
652 issue_path = category_dir / filename
654 # Build content using per-type sections template
655 now = datetime.now(UTC).isoformat(timespec="seconds")
656 today = datetime.now(UTC).strftime("%Y-%m-%d")
658 if issue_type not in self._sections_data:
659 templates_dir = (
660 Path(self.config.issues.templates_dir) if self.config.issues.templates_dir else None
661 )
662 self._sections_data[issue_type] = load_issue_sections(issue_type, templates_dir)
664 frontmatter = {
665 "github_issue": gh_number,
666 "github_url": gh_url,
667 "last_synced": now,
668 "discovered_by": "github_sync",
669 "discovered_date": today,
670 }
671 section_content: dict[str, str] = {}
672 if gh_body:
673 section_content["Summary"] = gh_body
674 section_content["Impact"] = (
675 f"- **Priority**: {priority}\n"
676 f"- **Effort**: Unknown\n"
677 f"- **Risk**: Unknown\n"
678 f"- **Breaking Change**: Unknown"
679 )
680 section_content["Status"] = f"**Open** | Created: {today} | Priority: {priority}"
682 labels_str = ", ".join(f"`{lbl}`" for lbl in gh_labels) if gh_labels else ""
683 if labels_str:
684 section_content["Labels"] = labels_str
686 variant = self.sync_config.github.pull_template
687 content = assemble_issue_markdown(
688 sections_data=self._sections_data[issue_type],
689 issue_type=issue_type,
690 variant=variant,
691 issue_id=issue_id,
692 title=gh_title,
693 frontmatter=frontmatter,
694 content=section_content,
695 labels=gh_labels,
696 )
697 issue_path.write_text(content, encoding="utf-8")
698 result.created.append(f"#{gh_number} → {issue_id}")
699 self.logger.success(f"Created {filename} from GitHub #{gh_number}")
701 def get_status(self) -> SyncStatus:
702 """Get sync status overview.
704 Returns:
705 SyncStatus with counts
706 """
707 repo = self.sync_config.github.repo or _get_repo_name(self.logger) or "unknown"
709 status = SyncStatus(
710 provider=self.sync_config.provider,
711 repo=repo,
712 )
714 # Count local issues
715 local_issues = self._get_local_issues()
716 status.local_total = len(local_issues)
718 # Count synced (have github_issue)
719 local_github_numbers = self._get_local_github_numbers()
720 status.local_synced = len(local_github_numbers)
721 status.local_unsynced = status.local_total - status.local_synced
723 # Count GitHub issues
724 if _check_gh_auth(self.logger):
725 try:
726 cmd_result = _run_gh_command(
727 ["issue", "list", "--json", "number", "--limit", "500"],
728 self.logger,
729 )
730 github_issues = json.loads(cmd_result.stdout)
731 status.github_total = len(github_issues)
733 github_numbers = {issue["number"] for issue in github_issues}
734 status.github_only = len(github_numbers - local_github_numbers)
735 except Exception as e:
736 status.github_error = f"Failed to query GitHub: {e}"
737 self.logger.warning(status.github_error)
739 return status
741 def _find_local_issue(self, issue_id: str) -> Path | None:
742 """Find the local file matching an issue ID.
744 Searches active category directories and completed directory.
746 Args:
747 issue_id: Issue ID to find (e.g., BUG-123)
749 Returns:
750 Path to the issue file, or None if not found
751 """
752 for issue_path in self._get_local_issues():
753 if self._extract_issue_id(issue_path.name) == issue_id:
754 return issue_path
755 # Also check completed directory
756 completed_dir = self.config.get_completed_dir()
757 if completed_dir.exists():
758 for issue_file in completed_dir.glob("*.md"):
759 if self._extract_issue_id(issue_file.name) == issue_id:
760 return issue_file
761 return None
763 def diff_issue(self, issue_id: str) -> SyncResult:
764 """Show content differences between a local issue and its GitHub counterpart.
766 Args:
767 issue_id: Issue ID to diff (e.g., BUG-123)
769 Returns:
770 SyncResult with diff information
771 """
772 result = SyncResult(action="diff", success=True)
774 if not _check_gh_auth(self.logger):
775 result.success = False
776 result.errors.append("GitHub CLI not authenticated. Run: gh auth login")
777 return result
779 issue_path = self._find_local_issue(issue_id)
780 if not issue_path:
781 result.success = False
782 result.errors.append(f"Local issue {issue_id} not found")
783 return result
785 content = issue_path.read_text(encoding="utf-8")
786 frontmatter = parse_frontmatter(content, coerce_types=True)
787 github_number = frontmatter.get("github_issue")
789 if github_number is None:
790 result.success = False
791 result.errors.append(
792 f"{issue_id} is not synced to GitHub (no github_issue in frontmatter)"
793 )
794 return result
796 try:
797 cmd_result = _run_gh_command(
798 ["issue", "view", str(int(github_number)), "--json", "body", "-q", ".body"],
799 self.logger,
800 )
801 github_body = cmd_result.stdout.rstrip("\n")
802 except subprocess.CalledProcessError as e:
803 result.success = False
804 result.errors.append(f"Failed to fetch GitHub issue #{github_number}: {e.stderr}")
805 return result
807 local_body = _get_issue_body(content)
809 local_lines = local_body.splitlines(keepends=True)
810 github_lines = github_body.splitlines(keepends=True)
812 diff = list(
813 difflib.unified_diff(
814 github_lines,
815 local_lines,
816 fromfile=f"github:#{github_number}",
817 tofile=f"local:{issue_id}",
818 )
819 )
821 if diff:
822 result.updated.append(f"{issue_id} (#{github_number}): differs")
823 # Store diff lines in created field for display
824 result.created = [line.rstrip("\n") for line in diff]
825 else:
826 result.skipped.append(f"{issue_id} (#{github_number}): in sync")
828 return result
830 def diff_all(self) -> SyncResult:
831 """Show summary of differences between all synced local issues and GitHub.
833 Returns:
834 SyncResult with diff summary
835 """
836 result = SyncResult(action="diff", success=True)
838 if not _check_gh_auth(self.logger):
839 result.success = False
840 result.errors.append("GitHub CLI not authenticated. Run: gh auth login")
841 return result
843 local_issues = self._get_local_issues()
845 # First pass: collect all issues that have been synced to GitHub
846 synced: list[tuple[str, int, str]] = [] # (issue_id, github_number, content)
847 for issue_path in local_issues:
848 issue_id = self._extract_issue_id(issue_path.name)
849 if not issue_id:
850 continue
852 content = issue_path.read_text(encoding="utf-8")
853 frontmatter = parse_frontmatter(content, coerce_types=True)
854 github_number = frontmatter.get("github_issue")
856 if github_number is None:
857 continue
859 synced.append((issue_id, int(github_number), content))
861 if not synced:
862 return result
864 # Batch-fetch all GitHub issue bodies in a single API call
865 try:
866 cmd_result = _run_gh_command(
867 ["issue", "list", "--json", "number,body", "--limit", "500", "--state", "all"],
868 self.logger,
869 )
870 github_bodies: dict[int, str] = {
871 item["number"]: item["body"] for item in json.loads(cmd_result.stdout)
872 }
873 except subprocess.CalledProcessError as e:
874 result.success = False
875 result.errors.append(f"Failed to batch-fetch GitHub issues: {e.stderr}")
876 return result
877 except Exception as e:
878 result.success = False
879 result.errors.append(f"Failed to batch-fetch GitHub issues: {e}")
880 return result
882 # Compare local vs GitHub bodies using the batch-fetched data
883 for issue_id, github_number, content in synced:
884 if github_number not in github_bodies:
885 result.failed.append((issue_id, f"Issue #{github_number} not found in GitHub"))
886 continue
888 local_body = _get_issue_body(content)
889 github_body = github_bodies[github_number]
891 if local_body.strip() != github_body.strip():
892 result.updated.append(f"{issue_id} (#{github_number}): differs")
893 else:
894 result.skipped.append(f"{issue_id} (#{github_number}): in sync")
896 if result.failed:
897 result.success = False
899 return result
901 def close_issues(
902 self,
903 issue_ids: list[str] | None = None,
904 all_completed: bool = False,
905 ) -> SyncResult:
906 """Close GitHub issues for completed local issues.
908 Args:
909 issue_ids: Specific issue IDs to close, or None
910 all_completed: If True, close all GitHub issues whose local counterparts
911 are in the completed directory
913 Returns:
914 SyncResult with operation details
915 """
916 result = SyncResult(action="close", success=True)
918 if not _check_gh_auth(self.logger):
919 result.success = False
920 result.errors.append("GitHub CLI not authenticated. Run: gh auth login")
921 return result
923 files_to_close: list[tuple[Path, str]] = [] # (path, issue_id)
925 if all_completed:
926 completed_dir = self.config.get_completed_dir()
927 if completed_dir.exists():
928 for issue_file in completed_dir.glob("*.md"):
929 eid = self._extract_issue_id(issue_file.name)
930 if eid:
931 files_to_close.append((issue_file, eid))
932 elif issue_ids:
933 for eid in issue_ids:
934 issue_path = self._find_local_issue(eid)
935 if issue_path:
936 files_to_close.append((issue_path, eid))
937 else:
938 result.failed.append((eid, "Local issue not found"))
939 else:
940 result.success = False
941 result.errors.append("Specify issue IDs or use --all-completed")
942 return result
944 for issue_path, issue_id in files_to_close:
945 content = issue_path.read_text(encoding="utf-8")
946 frontmatter = parse_frontmatter(content, coerce_types=True)
947 github_number = frontmatter.get("github_issue")
949 if github_number is None:
950 result.skipped.append(f"{issue_id} (not synced to GitHub)")
951 continue
953 if self.dry_run:
954 result.updated.append(f"{issue_id} → #{github_number} (would close)")
955 self.logger.info(f"Would close GitHub issue #{github_number} for {issue_id}")
956 continue
958 try:
959 _run_gh_command(
960 [
961 "issue",
962 "close",
963 str(int(github_number)),
964 "--comment",
965 f"Closed via ll-sync. Issue {issue_id} completed locally.",
966 ],
967 self.logger,
968 )
969 result.updated.append(f"{issue_id} → #{github_number} (closed)")
970 self.logger.success(f"Closed GitHub issue #{github_number} for {issue_id}")
971 except subprocess.CalledProcessError as e:
972 result.failed.append((issue_id, f"gh issue close failed: {e.stderr}"))
973 self.logger.error(f"Failed to close GitHub issue #{github_number}: {e.stderr}")
975 if result.failed:
976 result.success = False
978 return result
980 def reopen_issues(
981 self,
982 issue_ids: list[str] | None = None,
983 all_reopened: bool = False,
984 ) -> SyncResult:
985 """Reopen GitHub issues for locally-active issues.
987 Args:
988 issue_ids: Specific issue IDs to reopen, or None
989 all_reopened: If True, reopen GitHub issues for all local issues in active
990 directories that are CLOSED on GitHub
992 Returns:
993 SyncResult with operation details
994 """
995 result = SyncResult(action="reopen", success=True)
997 if not _check_gh_auth(self.logger):
998 result.success = False
999 result.errors.append("GitHub CLI not authenticated. Run: gh auth login")
1000 return result
1002 files_to_reopen: list[tuple[Path, str]] = [] # (path, issue_id)
1004 if all_reopened:
1005 for issue_path in self._get_local_issues():
1006 eid = self._extract_issue_id(issue_path.name)
1007 if eid:
1008 files_to_reopen.append((issue_path, eid))
1009 elif issue_ids:
1010 for eid in issue_ids:
1011 found_path = self._find_local_issue(eid)
1012 if found_path:
1013 files_to_reopen.append((found_path, eid))
1014 else:
1015 result.failed.append((eid, "Local issue not found"))
1016 else:
1017 result.success = False
1018 result.errors.append("Specify issue IDs or use --all-reopened")
1019 return result
1021 for issue_path, issue_id in files_to_reopen:
1022 content = issue_path.read_text(encoding="utf-8")
1023 frontmatter = parse_frontmatter(content, coerce_types=True)
1024 github_number = frontmatter.get("github_issue")
1026 if github_number is None:
1027 result.skipped.append(f"{issue_id} (not synced to GitHub)")
1028 continue
1030 if all_reopened:
1031 try:
1032 state_result = _run_gh_command(
1033 [
1034 "issue",
1035 "view",
1036 str(int(github_number)),
1037 "--json",
1038 "state",
1039 "-q",
1040 ".state",
1041 ],
1042 self.logger,
1043 )
1044 state = state_result.stdout.strip()
1045 if state != "CLOSED":
1046 result.skipped.append(
1047 f"{issue_id} (#{github_number}: already open on GitHub)"
1048 )
1049 continue
1050 except subprocess.CalledProcessError as e:
1051 result.failed.append((issue_id, f"gh issue view failed: {e.stderr}"))
1052 continue
1054 if self.dry_run:
1055 result.updated.append(f"{issue_id} → #{github_number} (would reopen)")
1056 self.logger.info(f"Would reopen GitHub issue #{github_number} for {issue_id}")
1057 continue
1059 try:
1060 _run_gh_command(
1061 [
1062 "issue",
1063 "reopen",
1064 str(int(github_number)),
1065 "--comment",
1066 f"Reopened via ll-sync. Issue {issue_id} moved back to active locally.",
1067 ],
1068 self.logger,
1069 )
1070 result.updated.append(f"{issue_id} → #{github_number} (reopened)")
1071 self.logger.success(f"Reopened GitHub issue #{github_number} for {issue_id}")
1072 except subprocess.CalledProcessError as e:
1073 result.failed.append((issue_id, f"gh issue reopen failed: {e.stderr}"))
1074 self.logger.error(f"Failed to reopen GitHub issue #{github_number}: {e.stderr}")
1075 continue
1077 # Move local file from completed/ back to active directory if needed
1078 completed_dir = self.config.get_completed_dir()
1079 if issue_path.parent == completed_dir:
1080 type_prefix = issue_id.split("-")[0]
1081 category_map = {"BUG": "bugs", "FEAT": "features", "ENH": "enhancements"}
1082 category = category_map.get(type_prefix)
1083 if category:
1084 target_dir = self.config.get_issue_dir(category)
1085 target_path = target_dir / issue_path.name
1086 try:
1087 subprocess.run(
1088 ["git", "mv", str(issue_path), str(target_path)],
1089 check=True,
1090 capture_output=True,
1091 )
1092 self.logger.info(f"Moved {issue_path.name} back to {category}/")
1093 except subprocess.CalledProcessError:
1094 target_path.write_text(content, encoding="utf-8")
1095 issue_path.unlink()
1096 self.logger.info(f"Moved {issue_path.name} back to {category}/ (fallback)")
1098 if result.failed:
1099 result.success = False
1101 return result