Coverage for little_loops / cli / sync.py: 9%
162 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:55 -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 %(prog)s reconcile # Promote feature-branch issues to done when PR is merged
43""",
44 )
46 subparsers = parser.add_subparsers(dest="action", help="Sync action")
48 # Status subcommand
49 status_parser = subparsers.add_parser("status", help="Show sync status")
50 add_json_arg(status_parser)
52 # Push subcommand
53 push_parser = subparsers.add_parser("push", help="Push local issues to GitHub")
54 push_parser.add_argument(
55 "issue_ids",
56 nargs="*",
57 help="Specific issue IDs to push (e.g., BUG-123)",
58 )
60 # Pull subcommand
61 pull_parser = subparsers.add_parser("pull", help="Pull GitHub Issues to local")
62 pull_parser.add_argument(
63 "--labels",
64 "-l",
65 type=str,
66 help="Filter by labels (comma-separated)",
67 )
69 # Diff subcommand
70 diff_parser = subparsers.add_parser(
71 "diff", help="Show differences between local and GitHub issues"
72 )
73 diff_parser.add_argument(
74 "issue_id",
75 nargs="?",
76 help="Specific issue ID to diff (e.g., BUG-123). Omit for summary of all.",
77 )
78 add_json_arg(diff_parser)
80 # Close subcommand
81 close_parser = subparsers.add_parser(
82 "close", help="Close GitHub issues for completed local issues"
83 )
84 close_parser.add_argument(
85 "issue_ids",
86 nargs="*",
87 help="Specific issue IDs to close (e.g., ENH-123)",
88 )
89 close_parser.add_argument(
90 "--all-completed",
91 action="store_true",
92 help="Close all GitHub issues whose local counterparts have status: done or status: cancelled",
93 )
95 # Reconcile subcommand
96 subparsers.add_parser(
97 "reconcile",
98 help="Promote feature-branch issues to done when their PR is merged",
99 )
101 # Reopen subcommand
102 reopen_parser = subparsers.add_parser(
103 "reopen", help="Reopen GitHub issues for locally-active issues"
104 )
105 reopen_parser.add_argument(
106 "issue_ids",
107 nargs="*",
108 help="Specific issue IDs to reopen (e.g., BUG-042)",
109 )
110 reopen_parser.add_argument(
111 "--all-reopened",
112 action="store_true",
113 help="Reopen all GitHub issues whose local counterparts have moved back to active",
114 )
116 # Common args
117 add_config_arg(parser)
118 add_quiet_arg(parser)
119 add_dry_run_arg(parser)
121 args = parser.parse_args()
123 if not args.action:
124 parser.print_help()
125 return 1
127 project_root = args.config or Path.cwd()
128 config = BRConfig(project_root)
129 logger = Logger(verbose=not getattr(args, "quiet", False))
131 # Check sync is enabled
132 if not config.sync.enabled:
133 logger.error("Sync is not enabled. Add to .ll/ll-config.json:")
134 logger.error(' "sync": { "enabled": true }')
135 return 1
137 dry_run = getattr(args, "dry_run", False)
138 manager = GitHubSyncManager(config, logger, dry_run=dry_run)
140 if args.action == "status":
141 status = manager.get_status()
142 if getattr(args, "json", False):
143 print_json(status.to_dict())
144 else:
145 _print_sync_status(status, logger)
146 return 0
148 elif args.action == "push":
149 if dry_run:
150 logger.info("[DRY RUN] Showing what would be pushed (no changes will be made)")
151 issue_ids = args.issue_ids if args.issue_ids else None
152 result = manager.push_issues(issue_ids)
153 _print_sync_result(result, logger)
154 return 0 if result.success else 1
156 elif args.action == "pull":
157 if dry_run:
158 logger.info("[DRY RUN] Showing what would be pulled (no changes will be made)")
159 labels = args.labels.split(",") if args.labels else None
160 result = manager.pull_issues(labels)
161 _print_sync_result(result, logger)
162 return 0 if result.success else 1
164 elif args.action == "diff":
165 issue_id = getattr(args, "issue_id", None)
166 if issue_id:
167 result = manager.diff_issue(issue_id)
168 else:
169 result = manager.diff_all()
170 if getattr(args, "json", False):
171 print_json(result.to_dict())
172 elif issue_id:
173 _print_diff_result(result, logger)
174 else:
175 _print_sync_result(result, logger)
176 return 0 if result.success else 1
178 elif args.action == "close":
179 if dry_run:
180 logger.info("[DRY RUN] Showing what would be closed (no changes will be made)")
181 issue_ids = args.issue_ids if args.issue_ids else None
182 all_completed = getattr(args, "all_completed", False)
183 result = manager.close_issues(issue_ids, all_completed=all_completed)
184 _print_sync_result(result, logger)
185 return 0 if result.success else 1
187 elif args.action == "reopen":
188 if dry_run:
189 logger.info("[DRY RUN] Showing what would be reopened (no changes will be made)")
190 issue_ids = args.issue_ids if args.issue_ids else None
191 all_reopened = getattr(args, "all_reopened", False)
192 result = manager.reopen_issues(issue_ids, all_reopened=all_reopened)
193 _print_sync_result(result, logger)
194 return 0 if result.success else 1
196 elif args.action == "reconcile":
197 if dry_run:
198 logger.info(
199 "[DRY RUN] Showing which feature-branch issues would be promoted (no changes will be made)"
200 )
201 count = manager.reconcile_pr_merges()
202 logger.info(f"Reconcile complete: {count} issue(s) promoted to done")
203 return 0
205 return 1
208def _print_sync_status(status: SyncStatus, logger: Logger) -> None:
209 """Print sync status in formatted output."""
210 logger.info("=" * 80)
211 logger.info("SYNC STATUS")
212 logger.info("=" * 80)
213 logger.info(f"Provider: {status.provider}")
214 logger.info(f"Repository: {status.repo}")
215 logger.info("")
216 logger.info(f"Local Issues: {status.local_total}")
217 logger.info(f"Synced to GitHub: {status.local_synced}")
218 logger.info(f"GitHub Issues: {status.github_total}")
219 logger.info("")
220 logger.info(f"Unsynced local: {status.local_unsynced} (local only, not on GitHub)")
221 logger.info(f"GitHub-only: {status.github_only} (on GitHub, not local)")
222 if status.github_error:
223 logger.info("")
224 logger.warning(f"GitHub data may be incomplete: {status.github_error}")
225 logger.info("=" * 80)
228def _print_sync_result(result: SyncResult, logger: Logger) -> None:
229 """Print sync result in formatted output."""
230 logger.info("=" * 80)
231 logger.info(f"SYNC {result.action.upper()} {'COMPLETE' if result.success else 'FAILED'}")
232 logger.info("=" * 80)
233 logger.info("")
234 logger.info("## SUMMARY")
235 logger.info(f"- Created: {len(result.created)}")
236 logger.info(f"- Updated: {len(result.updated)}")
237 logger.info(f"- Skipped: {len(result.skipped)}")
238 logger.info(f"- Failed: {len(result.failed)}")
239 logger.info("")
240 if result.created:
241 logger.info("## CREATED")
242 for item in result.created:
243 logger.info(f" - {item}")
244 logger.info("")
245 if result.updated:
246 logger.info("## UPDATED")
247 for item in result.updated:
248 logger.info(f" - {item}")
249 logger.info("")
250 if result.failed:
251 logger.info("## FAILED")
252 for issue_id, reason in result.failed:
253 logger.error(f" - {issue_id}: {reason}")
254 logger.info("")
255 if result.errors:
256 logger.info("## ERRORS")
257 for error in result.errors:
258 logger.error(f" - {error}")
259 logger.info("=" * 80)
262def _print_diff_result(result: SyncResult, logger: Logger) -> None:
263 """Print diff result showing unified diff output."""
264 if result.errors:
265 for error in result.errors:
266 logger.error(error)
267 return
269 if result.skipped:
270 for item in result.skipped:
271 logger.info(item)
272 return
274 if result.updated:
275 logger.info(result.updated[0])
276 logger.info("")
278 # Diff lines are stored in created field
279 for line in result.created:
280 logger.info(line)