Coverage for little_loops / cli / sync.py: 8%
133 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"""ll-sync: GitHub Issues sync."""
3from __future__ import annotations
5import argparse
6from pathlib import Path
8from little_loops.cli_args import add_config_arg, add_dry_run_arg, add_quiet_arg
9from little_loops.config import BRConfig
10from little_loops.logger import Logger
11from little_loops.sync import GitHubSyncManager, SyncResult, SyncStatus
14def main_sync() -> int:
15 """Entry point for ll-sync command.
17 Sync local issues with GitHub Issues.
19 Returns:
20 Exit code (0 = success)
21 """
22 parser = argparse.ArgumentParser(
23 prog="ll-sync",
24 description="Sync local .issues/ files with GitHub Issues",
25 formatter_class=argparse.RawDescriptionHelpFormatter,
26 epilog="""
27Examples:
28 %(prog)s status # Show sync status
29 %(prog)s push # Push all local issues to GitHub
30 %(prog)s push BUG-123 # Push specific issue
31 %(prog)s pull # Pull GitHub Issues to local
32 %(prog)s diff BUG-123 # Show diff for specific issue
33 %(prog)s diff # Show diff summary for all synced issues
34 %(prog)s close ENH-123 # Close GitHub issue for ENH-123
35 %(prog)s close --all-completed # Close all completed issues on GitHub
36""",
37 )
39 subparsers = parser.add_subparsers(dest="action", help="Sync action")
41 # Status subcommand
42 subparsers.add_parser("status", help="Show sync status")
44 # Push subcommand
45 push_parser = subparsers.add_parser("push", help="Push local issues to GitHub")
46 push_parser.add_argument(
47 "issue_ids",
48 nargs="*",
49 help="Specific issue IDs to push (e.g., BUG-123)",
50 )
52 # Pull subcommand
53 pull_parser = subparsers.add_parser("pull", help="Pull GitHub Issues to local")
54 pull_parser.add_argument(
55 "--labels",
56 "-l",
57 type=str,
58 help="Filter by labels (comma-separated)",
59 )
61 # Diff subcommand
62 diff_parser = subparsers.add_parser(
63 "diff", help="Show differences between local and GitHub issues"
64 )
65 diff_parser.add_argument(
66 "issue_id",
67 nargs="?",
68 help="Specific issue ID to diff (e.g., BUG-123). Omit for summary of all.",
69 )
71 # Close subcommand
72 close_parser = subparsers.add_parser(
73 "close", help="Close GitHub issues for completed local issues"
74 )
75 close_parser.add_argument(
76 "issue_ids",
77 nargs="*",
78 help="Specific issue IDs to close (e.g., ENH-123)",
79 )
80 close_parser.add_argument(
81 "--all-completed",
82 action="store_true",
83 help="Close all GitHub issues whose local counterparts are in completed/",
84 )
86 # Common args
87 add_config_arg(parser)
88 add_quiet_arg(parser)
89 add_dry_run_arg(parser)
91 args = parser.parse_args()
93 if not args.action:
94 parser.print_help()
95 return 1
97 project_root = args.config or Path.cwd()
98 config = BRConfig(project_root)
99 logger = Logger(verbose=not getattr(args, "quiet", False))
101 # Check sync is enabled
102 if not config.sync.enabled:
103 logger.error("Sync is not enabled. Add to .claude/ll-config.json:")
104 logger.error(' "sync": { "enabled": true }')
105 return 1
107 dry_run = getattr(args, "dry_run", False)
108 manager = GitHubSyncManager(config, logger, dry_run=dry_run)
110 if args.action == "status":
111 status = manager.get_status()
112 _print_sync_status(status, logger)
113 return 0
115 elif args.action == "push":
116 if dry_run:
117 logger.info("[DRY RUN] Showing what would be pushed (no changes will be made)")
118 issue_ids = args.issue_ids if args.issue_ids else None
119 result = manager.push_issues(issue_ids)
120 _print_sync_result(result, logger)
121 return 0 if result.success else 1
123 elif args.action == "pull":
124 if dry_run:
125 logger.info("[DRY RUN] Showing what would be pulled (no changes will be made)")
126 labels = args.labels.split(",") if args.labels else None
127 result = manager.pull_issues(labels)
128 _print_sync_result(result, logger)
129 return 0 if result.success else 1
131 elif args.action == "diff":
132 issue_id = getattr(args, "issue_id", None)
133 if issue_id:
134 result = manager.diff_issue(issue_id)
135 _print_diff_result(result, logger)
136 else:
137 result = manager.diff_all()
138 _print_sync_result(result, logger)
139 return 0 if result.success else 1
141 elif args.action == "close":
142 if dry_run:
143 logger.info("[DRY RUN] Showing what would be closed (no changes will be made)")
144 issue_ids = args.issue_ids if args.issue_ids else None
145 all_completed = getattr(args, "all_completed", False)
146 result = manager.close_issues(issue_ids, all_completed=all_completed)
147 _print_sync_result(result, logger)
148 return 0 if result.success else 1
150 return 1
153def _print_sync_status(status: SyncStatus, logger: Logger) -> None:
154 """Print sync status in formatted output."""
155 logger.info("=" * 80)
156 logger.info("SYNC STATUS")
157 logger.info("=" * 80)
158 logger.info(f"Provider: {status.provider}")
159 logger.info(f"Repository: {status.repo}")
160 logger.info("")
161 logger.info(f"Local Issues: {status.local_total}")
162 logger.info(f"Synced to GitHub: {status.local_synced}")
163 logger.info(f"GitHub Issues: {status.github_total}")
164 logger.info("")
165 logger.info(f"Unsynced local: {status.local_unsynced} (local only, not on GitHub)")
166 logger.info(f"GitHub-only: {status.github_only} (on GitHub, not local)")
167 if status.github_error:
168 logger.info("")
169 logger.warning(f"GitHub data may be incomplete: {status.github_error}")
170 logger.info("=" * 80)
173def _print_sync_result(result: SyncResult, logger: Logger) -> None:
174 """Print sync result in formatted output."""
175 logger.info("=" * 80)
176 logger.info(f"SYNC {result.action.upper()} {'COMPLETE' if result.success else 'FAILED'}")
177 logger.info("=" * 80)
178 logger.info("")
179 logger.info("## SUMMARY")
180 logger.info(f"- Created: {len(result.created)}")
181 logger.info(f"- Updated: {len(result.updated)}")
182 logger.info(f"- Skipped: {len(result.skipped)}")
183 logger.info(f"- Failed: {len(result.failed)}")
184 logger.info("")
185 if result.created:
186 logger.info("## CREATED")
187 for item in result.created:
188 logger.info(f" - {item}")
189 logger.info("")
190 if result.updated:
191 logger.info("## UPDATED")
192 for item in result.updated:
193 logger.info(f" - {item}")
194 logger.info("")
195 if result.failed:
196 logger.info("## FAILED")
197 for issue_id, reason in result.failed:
198 logger.error(f" - {issue_id}: {reason}")
199 logger.info("")
200 if result.errors:
201 logger.info("## ERRORS")
202 for error in result.errors:
203 logger.error(f" - {error}")
204 logger.info("=" * 80)
207def _print_diff_result(result: SyncResult, logger: Logger) -> None:
208 """Print diff result showing unified diff output."""
209 if result.errors:
210 for error in result.errors:
211 logger.error(error)
212 return
214 if result.skipped:
215 for item in result.skipped:
216 logger.info(item)
217 return
219 if result.updated:
220 logger.info(result.updated[0])
221 logger.info("")
223 # Diff lines are stored in created field
224 for line in result.created:
225 logger.info(line)