Coverage for little_loops / cli / sync.py: 9%
155 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
1"""ll-sync: GitHub Issues sync."""
3from __future__ import annotations
5import argparse
6import sys
7from pathlib import Path
9from little_loops.cli.output import print_json
10from little_loops.cli_args import add_config_arg, add_dry_run_arg, add_json_arg, add_quiet_arg
11from little_loops.config import BRConfig
12from little_loops.logger import Logger
13from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
14from little_loops.sync import GitHubSyncManager, SyncResult, SyncStatus
17def main_sync() -> int:
18 """Entry point for ll-sync command.
20 Sync local issues with GitHub Issues.
22 Returns:
23 Exit code (0 = success)
24 """
25 with cli_event_context(DEFAULT_DB_PATH, "ll-sync", sys.argv[1:]):
26 parser = argparse.ArgumentParser(
27 prog="ll-sync",
28 description="Sync local .issues/ files with GitHub Issues",
29 formatter_class=argparse.RawDescriptionHelpFormatter,
30 epilog="""
31Examples:
32 %(prog)s status # Show sync status
33 %(prog)s push # Push all local issues to GitHub
34 %(prog)s push BUG-123 # Push specific issue
35 %(prog)s pull # Pull GitHub Issues to local
36 %(prog)s diff BUG-123 # Show diff for specific issue
37 %(prog)s diff # Show diff summary for all synced issues
38 %(prog)s close ENH-123 # Close GitHub issue for ENH-123
39 %(prog)s close --all-completed # Close all completed issues on GitHub
40 %(prog)s reopen BUG-042 # Reopen GitHub issue for BUG-042
41 %(prog)s reopen --all-reopened # Reopen all issues moved back to active locally
42""",
43 )
45 subparsers = parser.add_subparsers(dest="action", help="Sync action")
47 # Status subcommand
48 status_parser = subparsers.add_parser("status", help="Show sync status")
49 add_json_arg(status_parser)
51 # Push subcommand
52 push_parser = subparsers.add_parser("push", help="Push local issues to GitHub")
53 push_parser.add_argument(
54 "issue_ids",
55 nargs="*",
56 help="Specific issue IDs to push (e.g., BUG-123)",
57 )
59 # Pull subcommand
60 pull_parser = subparsers.add_parser("pull", help="Pull GitHub Issues to local")
61 pull_parser.add_argument(
62 "--labels",
63 "-l",
64 type=str,
65 help="Filter by labels (comma-separated)",
66 )
68 # Diff subcommand
69 diff_parser = subparsers.add_parser(
70 "diff", help="Show differences between local and GitHub issues"
71 )
72 diff_parser.add_argument(
73 "issue_id",
74 nargs="?",
75 help="Specific issue ID to diff (e.g., BUG-123). Omit for summary of all.",
76 )
77 add_json_arg(diff_parser)
79 # Close subcommand
80 close_parser = subparsers.add_parser(
81 "close", help="Close GitHub issues for completed local issues"
82 )
83 close_parser.add_argument(
84 "issue_ids",
85 nargs="*",
86 help="Specific issue IDs to close (e.g., ENH-123)",
87 )
88 close_parser.add_argument(
89 "--all-completed",
90 action="store_true",
91 help="Close all GitHub issues whose local counterparts have status: done or status: cancelled",
92 )
94 # Reopen subcommand
95 reopen_parser = subparsers.add_parser(
96 "reopen", help="Reopen GitHub issues for locally-active issues"
97 )
98 reopen_parser.add_argument(
99 "issue_ids",
100 nargs="*",
101 help="Specific issue IDs to reopen (e.g., BUG-042)",
102 )
103 reopen_parser.add_argument(
104 "--all-reopened",
105 action="store_true",
106 help="Reopen all GitHub issues whose local counterparts have moved back to active",
107 )
109 # Common args
110 add_config_arg(parser)
111 add_quiet_arg(parser)
112 add_dry_run_arg(parser)
114 args = parser.parse_args()
116 if not args.action:
117 parser.print_help()
118 return 1
120 project_root = args.config or Path.cwd()
121 config = BRConfig(project_root)
122 logger = Logger(verbose=not getattr(args, "quiet", False))
124 # Check sync is enabled
125 if not config.sync.enabled:
126 logger.error("Sync is not enabled. Add to .ll/ll-config.json:")
127 logger.error(' "sync": { "enabled": true }')
128 return 1
130 dry_run = getattr(args, "dry_run", False)
131 manager = GitHubSyncManager(config, logger, dry_run=dry_run)
133 if args.action == "status":
134 status = manager.get_status()
135 if getattr(args, "json", False):
136 print_json(status.to_dict())
137 else:
138 _print_sync_status(status, logger)
139 return 0
141 elif args.action == "push":
142 if dry_run:
143 logger.info("[DRY RUN] Showing what would be pushed (no changes will be made)")
144 issue_ids = args.issue_ids if args.issue_ids else None
145 result = manager.push_issues(issue_ids)
146 _print_sync_result(result, logger)
147 return 0 if result.success else 1
149 elif args.action == "pull":
150 if dry_run:
151 logger.info("[DRY RUN] Showing what would be pulled (no changes will be made)")
152 labels = args.labels.split(",") if args.labels else None
153 result = manager.pull_issues(labels)
154 _print_sync_result(result, logger)
155 return 0 if result.success else 1
157 elif args.action == "diff":
158 issue_id = getattr(args, "issue_id", None)
159 if issue_id:
160 result = manager.diff_issue(issue_id)
161 else:
162 result = manager.diff_all()
163 if getattr(args, "json", False):
164 print_json(result.to_dict())
165 elif issue_id:
166 _print_diff_result(result, logger)
167 else:
168 _print_sync_result(result, logger)
169 return 0 if result.success else 1
171 elif args.action == "close":
172 if dry_run:
173 logger.info("[DRY RUN] Showing what would be closed (no changes will be made)")
174 issue_ids = args.issue_ids if args.issue_ids else None
175 all_completed = getattr(args, "all_completed", False)
176 result = manager.close_issues(issue_ids, all_completed=all_completed)
177 _print_sync_result(result, logger)
178 return 0 if result.success else 1
180 elif args.action == "reopen":
181 if dry_run:
182 logger.info("[DRY RUN] Showing what would be reopened (no changes will be made)")
183 issue_ids = args.issue_ids if args.issue_ids else None
184 all_reopened = getattr(args, "all_reopened", False)
185 result = manager.reopen_issues(issue_ids, all_reopened=all_reopened)
186 _print_sync_result(result, logger)
187 return 0 if result.success else 1
189 return 1
192def _print_sync_status(status: SyncStatus, logger: Logger) -> None:
193 """Print sync status in formatted output."""
194 logger.info("=" * 80)
195 logger.info("SYNC STATUS")
196 logger.info("=" * 80)
197 logger.info(f"Provider: {status.provider}")
198 logger.info(f"Repository: {status.repo}")
199 logger.info("")
200 logger.info(f"Local Issues: {status.local_total}")
201 logger.info(f"Synced to GitHub: {status.local_synced}")
202 logger.info(f"GitHub Issues: {status.github_total}")
203 logger.info("")
204 logger.info(f"Unsynced local: {status.local_unsynced} (local only, not on GitHub)")
205 logger.info(f"GitHub-only: {status.github_only} (on GitHub, not local)")
206 if status.github_error:
207 logger.info("")
208 logger.warning(f"GitHub data may be incomplete: {status.github_error}")
209 logger.info("=" * 80)
212def _print_sync_result(result: SyncResult, logger: Logger) -> None:
213 """Print sync result in formatted output."""
214 logger.info("=" * 80)
215 logger.info(f"SYNC {result.action.upper()} {'COMPLETE' if result.success else 'FAILED'}")
216 logger.info("=" * 80)
217 logger.info("")
218 logger.info("## SUMMARY")
219 logger.info(f"- Created: {len(result.created)}")
220 logger.info(f"- Updated: {len(result.updated)}")
221 logger.info(f"- Skipped: {len(result.skipped)}")
222 logger.info(f"- Failed: {len(result.failed)}")
223 logger.info("")
224 if result.created:
225 logger.info("## CREATED")
226 for item in result.created:
227 logger.info(f" - {item}")
228 logger.info("")
229 if result.updated:
230 logger.info("## UPDATED")
231 for item in result.updated:
232 logger.info(f" - {item}")
233 logger.info("")
234 if result.failed:
235 logger.info("## FAILED")
236 for issue_id, reason in result.failed:
237 logger.error(f" - {issue_id}: {reason}")
238 logger.info("")
239 if result.errors:
240 logger.info("## ERRORS")
241 for error in result.errors:
242 logger.error(f" - {error}")
243 logger.info("=" * 80)
246def _print_diff_result(result: SyncResult, logger: Logger) -> None:
247 """Print diff result showing unified diff output."""
248 if result.errors:
249 for error in result.errors:
250 logger.error(error)
251 return
253 if result.skipped:
254 for item in result.skipped:
255 logger.info(item)
256 return
258 if result.updated:
259 logger.info(result.updated[0])
260 logger.info("")
262 # Diff lines are stored in created field
263 for line in result.created:
264 logger.info(line)