Coverage for little_loops / sync.py: 15%
455 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-03-18 16:18 -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 try:
520 gh_args = [
521 "issue",
522 "list",
523 "--json",
524 "number,title,body,labels,state,url",
525 "--limit",
526 "100",
527 ]
528 if labels:
529 for label in labels:
530 gh_args.extend(["--label", label])
531 cmd_result = _run_gh_command(gh_args, self.logger)
532 github_issues = json.loads(cmd_result.stdout)
533 except Exception as e:
534 result.success = False
535 result.errors.append(f"Failed to list GitHub issues: {e}")
536 return result
538 # Get existing local issue IDs
539 local_github_numbers = self._get_local_github_numbers()
541 for gh_issue in github_issues:
542 gh_number = gh_issue["number"]
543 gh_state = gh_issue.get("state", "OPEN")
545 # Skip closed issues unless configured
546 if gh_state != "OPEN" and not self.sync_config.github.sync_completed:
547 result.skipped.append(f"#{gh_number} (closed)")
548 continue
550 # Skip if already tracked locally
551 if gh_number in local_github_numbers:
552 result.skipped.append(f"#{gh_number} (already tracked)")
553 continue
555 # Check if has recognized labels
556 gh_labels = [lbl.get("name", "") for lbl in gh_issue.get("labels", [])]
557 issue_type = self._determine_issue_type(gh_labels)
558 if not issue_type:
559 result.skipped.append(f"#{gh_number} (no recognized type label)")
560 continue
562 if self.dry_run:
563 gh_title = gh_issue.get("title", f"Issue #{gh_number}")
564 result.created.append(f"#{gh_number}: {gh_title} (would create as {issue_type})")
565 self.logger.info(f"Would create local issue from GitHub #{gh_number}: {gh_title}")
566 else:
567 try:
568 self._create_local_issue(gh_issue, issue_type, result)
569 except Exception as e:
570 result.failed.append((f"#{gh_number}", str(e)))
572 if result.failed:
573 result.success = False
575 return result
577 def _get_local_github_numbers(self) -> set[int]:
578 """Get set of GitHub issue numbers tracked locally."""
579 numbers: set[int] = set()
580 for issue_path in self._get_local_issues():
581 content = issue_path.read_text(encoding="utf-8")
582 frontmatter = parse_frontmatter(content, coerce_types=True)
583 gh_num = frontmatter.get("github_issue")
584 if gh_num is not None:
585 try:
586 numbers.add(int(gh_num))
587 except (ValueError, TypeError):
588 self.logger.warning(
589 f"Malformed github_issue value in {issue_path.name}: {gh_num!r}"
590 )
591 return numbers
593 def _determine_issue_type(self, labels: list[str]) -> str | None:
594 """Determine issue type from GitHub labels.
596 Returns:
597 Issue type prefix (BUG, FEAT, ENH) or None
598 """
599 # Reverse lookup from label_mapping
600 reverse_map: dict[str, str] = {}
601 for type_prefix, label in self.sync_config.github.label_mapping.items():
602 if label not in reverse_map:
603 reverse_map[label] = type_prefix
605 for label in labels:
606 if label in reverse_map:
607 return reverse_map[label]
608 return None
610 def _create_local_issue(
611 self,
612 gh_issue: dict[str, Any],
613 issue_type: str,
614 result: SyncResult,
615 ) -> None:
616 """Create a local issue file from GitHub issue."""
617 gh_number = gh_issue["number"]
618 gh_title = gh_issue.get("title", f"Issue #{gh_number}")
619 gh_body = gh_issue.get("body", "") or ""
620 gh_url = gh_issue.get("url", "")
621 gh_labels = [lbl.get("name", "") for lbl in gh_issue.get("labels", [])]
623 # Determine priority from labels or default
624 priority = "P3"
625 for label in gh_labels:
626 if re.match(r"^p[0-5]$", label, re.IGNORECASE):
627 priority = label.upper()
628 break
630 # Generate next issue number (uses global numbering across all dirs)
631 next_num = get_next_issue_number(self.config)
633 # Generate slug from title
634 slug = re.sub(r"[^a-z0-9]+", "-", gh_title.lower())[:40].strip("-")
636 issue_id = f"{issue_type}-{next_num}"
637 filename = f"{priority}-{issue_id}-{slug}.md"
639 # Determine category directory
640 cat = self.config.issues.get_category_by_prefix(issue_type)
641 category = cat.dir if cat else "features"
642 category_dir = self.config.get_issue_dir(category)
643 category_dir.mkdir(parents=True, exist_ok=True)
645 issue_path = category_dir / filename
647 # Build content using per-type sections template
648 now = datetime.now(UTC).isoformat(timespec="seconds")
649 today = datetime.now(UTC).strftime("%Y-%m-%d")
651 if issue_type not in self._sections_data:
652 templates_dir = (
653 Path(self.config.issues.templates_dir) if self.config.issues.templates_dir else None
654 )
655 self._sections_data[issue_type] = load_issue_sections(issue_type, templates_dir)
657 frontmatter = {
658 "github_issue": gh_number,
659 "github_url": gh_url,
660 "last_synced": now,
661 "discovered_by": "github_sync",
662 "discovered_date": today,
663 }
664 section_content: dict[str, str] = {}
665 if gh_body:
666 section_content["Summary"] = gh_body
667 section_content["Impact"] = (
668 f"- **Priority**: {priority}\n"
669 f"- **Effort**: Unknown\n"
670 f"- **Risk**: Unknown\n"
671 f"- **Breaking Change**: Unknown"
672 )
673 section_content["Status"] = f"**Open** | Created: {today} | Priority: {priority}"
675 labels_str = ", ".join(f"`{lbl}`" for lbl in gh_labels) if gh_labels else ""
676 if labels_str:
677 section_content["Labels"] = labels_str
679 variant = self.sync_config.github.pull_template
680 content = assemble_issue_markdown(
681 sections_data=self._sections_data[issue_type],
682 issue_type=issue_type,
683 variant=variant,
684 issue_id=issue_id,
685 title=gh_title,
686 frontmatter=frontmatter,
687 content=section_content,
688 labels=gh_labels,
689 )
690 issue_path.write_text(content, encoding="utf-8")
691 result.created.append(f"#{gh_number} → {issue_id}")
692 self.logger.success(f"Created {filename} from GitHub #{gh_number}")
694 def get_status(self) -> SyncStatus:
695 """Get sync status overview.
697 Returns:
698 SyncStatus with counts
699 """
700 repo = self.sync_config.github.repo or _get_repo_name(self.logger) or "unknown"
702 status = SyncStatus(
703 provider=self.sync_config.provider,
704 repo=repo,
705 )
707 # Count local issues
708 local_issues = self._get_local_issues()
709 status.local_total = len(local_issues)
711 # Count synced (have github_issue)
712 local_github_numbers = self._get_local_github_numbers()
713 status.local_synced = len(local_github_numbers)
714 status.local_unsynced = status.local_total - status.local_synced
716 # Count GitHub issues
717 if _check_gh_auth(self.logger):
718 try:
719 cmd_result = _run_gh_command(
720 ["issue", "list", "--json", "number", "--limit", "500"],
721 self.logger,
722 )
723 github_issues = json.loads(cmd_result.stdout)
724 status.github_total = len(github_issues)
726 github_numbers = {issue["number"] for issue in github_issues}
727 status.github_only = len(github_numbers - local_github_numbers)
728 except Exception as e:
729 status.github_error = f"Failed to query GitHub: {e}"
730 self.logger.warning(status.github_error)
732 return status
734 def _find_local_issue(self, issue_id: str) -> Path | None:
735 """Find the local file matching an issue ID.
737 Searches active category directories and completed directory.
739 Args:
740 issue_id: Issue ID to find (e.g., BUG-123)
742 Returns:
743 Path to the issue file, or None if not found
744 """
745 for issue_path in self._get_local_issues():
746 if self._extract_issue_id(issue_path.name) == issue_id:
747 return issue_path
748 # Also check completed directory
749 completed_dir = self.config.get_completed_dir()
750 if completed_dir.exists():
751 for issue_file in completed_dir.glob("*.md"):
752 if self._extract_issue_id(issue_file.name) == issue_id:
753 return issue_file
754 return None
756 def diff_issue(self, issue_id: str) -> SyncResult:
757 """Show content differences between a local issue and its GitHub counterpart.
759 Args:
760 issue_id: Issue ID to diff (e.g., BUG-123)
762 Returns:
763 SyncResult with diff information
764 """
765 result = SyncResult(action="diff", success=True)
767 if not _check_gh_auth(self.logger):
768 result.success = False
769 result.errors.append("GitHub CLI not authenticated. Run: gh auth login")
770 return result
772 issue_path = self._find_local_issue(issue_id)
773 if not issue_path:
774 result.success = False
775 result.errors.append(f"Local issue {issue_id} not found")
776 return result
778 content = issue_path.read_text(encoding="utf-8")
779 frontmatter = parse_frontmatter(content, coerce_types=True)
780 github_number = frontmatter.get("github_issue")
782 if github_number is None:
783 result.success = False
784 result.errors.append(
785 f"{issue_id} is not synced to GitHub (no github_issue in frontmatter)"
786 )
787 return result
789 try:
790 cmd_result = _run_gh_command(
791 ["issue", "view", str(int(github_number)), "--json", "body", "-q", ".body"],
792 self.logger,
793 )
794 github_body = cmd_result.stdout.rstrip("\n")
795 except subprocess.CalledProcessError as e:
796 result.success = False
797 result.errors.append(f"Failed to fetch GitHub issue #{github_number}: {e.stderr}")
798 return result
800 local_body = _get_issue_body(content)
802 local_lines = local_body.splitlines(keepends=True)
803 github_lines = github_body.splitlines(keepends=True)
805 diff = list(
806 difflib.unified_diff(
807 github_lines,
808 local_lines,
809 fromfile=f"github:#{github_number}",
810 tofile=f"local:{issue_id}",
811 )
812 )
814 if diff:
815 result.updated.append(f"{issue_id} (#{github_number}): differs")
816 # Store diff lines in created field for display
817 result.created = [line.rstrip("\n") for line in diff]
818 else:
819 result.skipped.append(f"{issue_id} (#{github_number}): in sync")
821 return result
823 def diff_all(self) -> SyncResult:
824 """Show summary of differences between all synced local issues and GitHub.
826 Returns:
827 SyncResult with diff summary
828 """
829 result = SyncResult(action="diff", success=True)
831 if not _check_gh_auth(self.logger):
832 result.success = False
833 result.errors.append("GitHub CLI not authenticated. Run: gh auth login")
834 return result
836 local_issues = self._get_local_issues()
838 for issue_path in local_issues:
839 issue_id = self._extract_issue_id(issue_path.name)
840 if not issue_id:
841 continue
843 content = issue_path.read_text(encoding="utf-8")
844 frontmatter = parse_frontmatter(content, coerce_types=True)
845 github_number = frontmatter.get("github_issue")
847 if github_number is None:
848 continue
850 try:
851 cmd_result = _run_gh_command(
852 ["issue", "view", str(int(github_number)), "--json", "body", "-q", ".body"],
853 self.logger,
854 )
855 github_body = cmd_result.stdout.rstrip("\n")
856 except subprocess.CalledProcessError as e:
857 result.failed.append((issue_id, f"Failed to fetch #{github_number}: {e.stderr}"))
858 continue
860 local_body = _get_issue_body(content)
862 if local_body.strip() != github_body.strip():
863 result.updated.append(f"{issue_id} (#{github_number}): differs")
864 else:
865 result.skipped.append(f"{issue_id} (#{github_number}): in sync")
867 if result.failed:
868 result.success = False
870 return result
872 def close_issues(
873 self,
874 issue_ids: list[str] | None = None,
875 all_completed: bool = False,
876 ) -> SyncResult:
877 """Close GitHub issues for completed local issues.
879 Args:
880 issue_ids: Specific issue IDs to close, or None
881 all_completed: If True, close all GitHub issues whose local counterparts
882 are in the completed directory
884 Returns:
885 SyncResult with operation details
886 """
887 result = SyncResult(action="close", success=True)
889 if not _check_gh_auth(self.logger):
890 result.success = False
891 result.errors.append("GitHub CLI not authenticated. Run: gh auth login")
892 return result
894 files_to_close: list[tuple[Path, str]] = [] # (path, issue_id)
896 if all_completed:
897 completed_dir = self.config.get_completed_dir()
898 if completed_dir.exists():
899 for issue_file in completed_dir.glob("*.md"):
900 eid = self._extract_issue_id(issue_file.name)
901 if eid:
902 files_to_close.append((issue_file, eid))
903 elif issue_ids:
904 for eid in issue_ids:
905 issue_path = self._find_local_issue(eid)
906 if issue_path:
907 files_to_close.append((issue_path, eid))
908 else:
909 result.failed.append((eid, "Local issue not found"))
910 else:
911 result.success = False
912 result.errors.append("Specify issue IDs or use --all-completed")
913 return result
915 for issue_path, issue_id in files_to_close:
916 content = issue_path.read_text(encoding="utf-8")
917 frontmatter = parse_frontmatter(content, coerce_types=True)
918 github_number = frontmatter.get("github_issue")
920 if github_number is None:
921 result.skipped.append(f"{issue_id} (not synced to GitHub)")
922 continue
924 if self.dry_run:
925 result.updated.append(f"{issue_id} → #{github_number} (would close)")
926 self.logger.info(f"Would close GitHub issue #{github_number} for {issue_id}")
927 continue
929 try:
930 _run_gh_command(
931 [
932 "issue",
933 "close",
934 str(int(github_number)),
935 "--comment",
936 f"Closed via ll-sync. Issue {issue_id} completed locally.",
937 ],
938 self.logger,
939 )
940 result.updated.append(f"{issue_id} → #{github_number} (closed)")
941 self.logger.success(f"Closed GitHub issue #{github_number} for {issue_id}")
942 except subprocess.CalledProcessError as e:
943 result.failed.append((issue_id, f"gh issue close failed: {e.stderr}"))
944 self.logger.error(f"Failed to close GitHub issue #{github_number}: {e.stderr}")
946 if result.failed:
947 result.success = False
949 return result