Coverage for little_loops / cli / sync.py: 73%
144 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"""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 %(prog)s reopen BUG-042 # Reopen GitHub issue for BUG-042
37 %(prog)s reopen --all-reopened # Reopen all issues moved back to active locally
38""",
39 )
41 subparsers = parser.add_subparsers(dest="action", help="Sync action")
43 # Status subcommand
44 subparsers.add_parser("status", help="Show sync status")
46 # Push subcommand
47 push_parser = subparsers.add_parser("push", help="Push local issues to GitHub")
48 push_parser.add_argument(
49 "issue_ids",
50 nargs="*",
51 help="Specific issue IDs to push (e.g., BUG-123)",
52 )
54 # Pull subcommand
55 pull_parser = subparsers.add_parser("pull", help="Pull GitHub Issues to local")
56 pull_parser.add_argument(
57 "--labels",
58 "-l",
59 type=str,
60 help="Filter by labels (comma-separated)",
61 )
63 # Diff subcommand
64 diff_parser = subparsers.add_parser(
65 "diff", help="Show differences between local and GitHub issues"
66 )
67 diff_parser.add_argument(
68 "issue_id",
69 nargs="?",
70 help="Specific issue ID to diff (e.g., BUG-123). Omit for summary of all.",
71 )
73 # Close subcommand
74 close_parser = subparsers.add_parser(
75 "close", help="Close GitHub issues for completed local issues"
76 )
77 close_parser.add_argument(
78 "issue_ids",
79 nargs="*",
80 help="Specific issue IDs to close (e.g., ENH-123)",
81 )
82 close_parser.add_argument(
83 "--all-completed",
84 action="store_true",
85 help="Close all GitHub issues whose local counterparts have status: done or status: cancelled",
86 )
88 # Reopen subcommand
89 reopen_parser = subparsers.add_parser(
90 "reopen", help="Reopen GitHub issues for locally-active issues"
91 )
92 reopen_parser.add_argument(
93 "issue_ids",
94 nargs="*",
95 help="Specific issue IDs to reopen (e.g., BUG-042)",
96 )
97 reopen_parser.add_argument(
98 "--all-reopened",
99 action="store_true",
100 help="Reopen all GitHub issues whose local counterparts have moved back to active",
101 )
103 # Common args
104 add_config_arg(parser)
105 add_quiet_arg(parser)
106 add_dry_run_arg(parser)
108 args = parser.parse_args()
110 if not args.action:
111 parser.print_help()
112 return 1
114 project_root = args.config or Path.cwd()
115 config = BRConfig(project_root)
116 logger = Logger(verbose=not getattr(args, "quiet", False))
118 # Check sync is enabled
119 if not config.sync.enabled:
120 logger.error("Sync is not enabled. Add to .ll/ll-config.json:")
121 logger.error(' "sync": { "enabled": true }')
122 return 1
124 dry_run = getattr(args, "dry_run", False)
125 manager = GitHubSyncManager(config, logger, dry_run=dry_run)
127 if args.action == "status":
128 status = manager.get_status()
129 _print_sync_status(status, logger)
130 return 0
132 elif args.action == "push":
133 if dry_run:
134 logger.info("[DRY RUN] Showing what would be pushed (no changes will be made)")
135 issue_ids = args.issue_ids if args.issue_ids else None
136 result = manager.push_issues(issue_ids)
137 _print_sync_result(result, logger)
138 return 0 if result.success else 1
140 elif args.action == "pull":
141 if dry_run:
142 logger.info("[DRY RUN] Showing what would be pulled (no changes will be made)")
143 labels = args.labels.split(",") if args.labels else None
144 result = manager.pull_issues(labels)
145 _print_sync_result(result, logger)
146 return 0 if result.success else 1
148 elif args.action == "diff":
149 issue_id = getattr(args, "issue_id", None)
150 if issue_id:
151 result = manager.diff_issue(issue_id)
152 _print_diff_result(result, logger)
153 else:
154 result = manager.diff_all()
155 _print_sync_result(result, logger)
156 return 0 if result.success else 1
158 elif args.action == "close":
159 if dry_run:
160 logger.info("[DRY RUN] Showing what would be closed (no changes will be made)")
161 issue_ids = args.issue_ids if args.issue_ids else None
162 all_completed = getattr(args, "all_completed", False)
163 result = manager.close_issues(issue_ids, all_completed=all_completed)
164 _print_sync_result(result, logger)
165 return 0 if result.success else 1
167 elif args.action == "reopen":
168 if dry_run:
169 logger.info("[DRY RUN] Showing what would be reopened (no changes will be made)")
170 issue_ids = args.issue_ids if args.issue_ids else None
171 all_reopened = getattr(args, "all_reopened", False)
172 result = manager.reopen_issues(issue_ids, all_reopened=all_reopened)
173 _print_sync_result(result, logger)
174 return 0 if result.success else 1
176 return 1
179def _print_sync_status(status: SyncStatus, logger: Logger) -> None:
180 """Print sync status in formatted output."""
181 logger.info("=" * 80)
182 logger.info("SYNC STATUS")
183 logger.info("=" * 80)
184 logger.info(f"Provider: {status.provider}")
185 logger.info(f"Repository: {status.repo}")
186 logger.info("")
187 logger.info(f"Local Issues: {status.local_total}")
188 logger.info(f"Synced to GitHub: {status.local_synced}")
189 logger.info(f"GitHub Issues: {status.github_total}")
190 logger.info("")
191 logger.info(f"Unsynced local: {status.local_unsynced} (local only, not on GitHub)")
192 logger.info(f"GitHub-only: {status.github_only} (on GitHub, not local)")
193 if status.github_error:
194 logger.info("")
195 logger.warning(f"GitHub data may be incomplete: {status.github_error}")
196 logger.info("=" * 80)
199def _print_sync_result(result: SyncResult, logger: Logger) -> None:
200 """Print sync result in formatted output."""
201 logger.info("=" * 80)
202 logger.info(f"SYNC {result.action.upper()} {'COMPLETE' if result.success else 'FAILED'}")
203 logger.info("=" * 80)
204 logger.info("")
205 logger.info("## SUMMARY")
206 logger.info(f"- Created: {len(result.created)}")
207 logger.info(f"- Updated: {len(result.updated)}")
208 logger.info(f"- Skipped: {len(result.skipped)}")
209 logger.info(f"- Failed: {len(result.failed)}")
210 logger.info("")
211 if result.created:
212 logger.info("## CREATED")
213 for item in result.created:
214 logger.info(f" - {item}")
215 logger.info("")
216 if result.updated:
217 logger.info("## UPDATED")
218 for item in result.updated:
219 logger.info(f" - {item}")
220 logger.info("")
221 if result.failed:
222 logger.info("## FAILED")
223 for issue_id, reason in result.failed:
224 logger.error(f" - {issue_id}: {reason}")
225 logger.info("")
226 if result.errors:
227 logger.info("## ERRORS")
228 for error in result.errors:
229 logger.error(f" - {error}")
230 logger.info("=" * 80)
233def _print_diff_result(result: SyncResult, logger: Logger) -> None:
234 """Print diff result showing unified diff output."""
235 if result.errors:
236 for error in result.errors:
237 logger.error(error)
238 return
240 if result.skipped:
241 for item in result.skipped:
242 logger.info(item)
243 return
245 if result.updated:
246 logger.info(result.updated[0])
247 logger.info("")
249 # Diff lines are stored in created field
250 for line in result.created:
251 logger.info(line)