""" Synchronization CLI Commands Commands for synchronizing issues between different backends. """ import click from datetime import datetime, timezone from .utils import ( load_backend_configs, get_backend, echo_success, echo_error, echo_warning, echo_info, progress_bar, confirm_action ) from ..core.interfaces import BackendFactory @click.group() def sync_group(): """Issue synchronization between backends.""" pass @sync_group.command('status') @click.argument('backend_name', required=False) @click.pass_context def sync_status(ctx, backend_name): """Show sync status for backends.""" configs = load_backend_configs() if backend_name: backends_to_check = [backend_name] if backend_name in configs else [] if not backends_to_check: echo_error(f"Backend '{backend_name}' not found") return else: backends_to_check = [k for k in configs.keys() if k != 'default'] for name in backends_to_check: config = configs[name] try: backend = BackendFactory.create_backend(config['type']) backend.connect(config) # Get basic stats all_issues = backend.list_issues() open_issues = [i for i in all_issues if i.state.value != 'closed'] click.echo(f"\n{name} ({config['type']}):") click.echo(f" Total issues: {len(all_issues)}") click.echo(f" Open issues: {len(open_issues)}") click.echo(f" Closed issues: {len(all_issues) - len(open_issues)}") # Check last sync if hasattr(backend, 'get_last_sync_timestamp'): last_sync = backend.get_last_sync_timestamp() if last_sync: click.echo(f" Last sync: {last_sync}") else: click.echo(f" Last sync: never") backend.disconnect() except Exception as e: echo_error(f" Error accessing {name}: {e}") @sync_group.command('pull') @click.argument('source_backend') @click.option('--target', default='local', help='Target backend (default: local)') @click.option('--dry-run', is_flag=True, help='Show what would be synced without making changes') @click.option('--force', is_flag=True, help='Force sync even with conflicts') @click.pass_context def sync_pull(ctx, source_backend, target, dry_run, force): """Pull issues from source backend to target backend.""" configs = load_backend_configs() if source_backend not in configs: echo_error(f"Source backend '{source_backend}' not found") return if target not in configs: echo_error(f"Target backend '{target}' not found") return if source_backend == target: echo_error("Source and target backends cannot be the same") return try: # Connect to backends source_config = configs[source_backend] target_config = configs[target] source = BackendFactory.create_backend(source_config['type']) source.connect(source_config) target = BackendFactory.create_backend(target_config['type']) target.connect(target_config) echo_info(f"Syncing from {source_backend} to {target}") # Get issues from source source_issues = source.list_issues() echo_info(f"Found {len(source_issues)} issues in source") # Get existing issues in target target_issues = target.list_issues() target_numbers = {issue.number for issue in target_issues} # Determine what needs to be synced new_issues = [] updated_issues = [] for issue in source_issues: if issue.number not in target_numbers: new_issues.append(issue) else: # Check if update is needed (simplified check by updated_at) target_issue = next((i for i in target_issues if i.number == issue.number), None) if target_issue and issue.updated_at > target_issue.updated_at: updated_issues.append(issue) echo_info(f"New issues to sync: {len(new_issues)}") echo_info(f"Updated issues to sync: {len(updated_issues)}") if dry_run: if new_issues: click.echo("\nNew issues:") for issue in new_issues: click.echo(f" #{issue.number}: {issue.title}") if updated_issues: click.echo("\nUpdated issues:") for issue in updated_issues: click.echo(f" #{issue.number}: {issue.title}") click.echo(f"\nDry run complete. {len(new_issues + updated_issues)} issues would be synced.") return # Confirm sync total_sync = len(new_issues) + len(updated_issues) if total_sync == 0: echo_success("No issues need syncing") return if not force and not confirm_action(f"Sync {total_sync} issues?"): click.echo("Aborted") return # Perform sync synced_count = 0 errors = [] all_to_sync = new_issues + updated_issues with progress_bar(all_to_sync, label="Syncing issues") as items: for issue in items: try: # Clear backend-specific IDs for new backend issue.backend_id = None issue.backend_type = target_config['type'] if issue in new_issues: target.create_issue(issue) else: # For updates, we need to find the target issue and update it target_issue = target.get_issue_by_number(issue.number) if target_issue: # Copy relevant fields target_issue.title = issue.title target_issue.description = issue.description target_issue.state = issue.state target_issue.labels = issue.labels target_issue.assignees = issue.assignees target_issue.milestone = issue.milestone target_issue.updated_at = issue.updated_at target_issue.closed_at = issue.closed_at target.update_issue(target_issue) synced_count += 1 except Exception as e: errors.append(f"Issue #{issue.number}: {e}") # Report results echo_success(f"Synced {synced_count} issues successfully") if errors: echo_warning(f"{len(errors)} errors occurred:") for error in errors[:5]: # Show first 5 errors echo_error(f" {error}") if len(errors) > 5: echo_warning(f" ... and {len(errors) - 5} more errors") # Cleanup source.disconnect() target.disconnect() except Exception as e: echo_error(f"Sync failed: {e}") @sync_group.command('push') @click.argument('target_backend') @click.option('--source', default='local', help='Source backend (default: local)') @click.option('--dry-run', is_flag=True, help='Show what would be synced without making changes') @click.option('--force', is_flag=True, help='Force sync even with conflicts') @click.pass_context def sync_push(ctx, target_backend, source, dry_run, force): """Push issues from source backend to target backend.""" # This is essentially the same as pull but with arguments swapped ctx.invoke(sync_pull, source_backend=source, target=target_backend, dry_run=dry_run, force=force) @sync_group.command('bidirectional') @click.argument('backend1') @click.argument('backend2') @click.option('--dry-run', is_flag=True, help='Show what would be synced without making changes') @click.option('--force', is_flag=True, help='Force sync even with conflicts') @click.pass_context def sync_bidirectional(ctx, backend1, backend2, dry_run, force): """Bidirectional sync between two backends.""" echo_warning("Bidirectional sync is a complex operation that can cause conflicts.") if not force and not confirm_action("Continue with bidirectional sync?"): click.echo("Aborted") return # First sync backend1 -> backend2 echo_info(f"Step 1: Syncing {backend1} -> {backend2}") ctx.invoke(sync_pull, source_backend=backend1, target=backend2, dry_run=dry_run, force=True) # Then sync backend2 -> backend1 echo_info(f"Step 2: Syncing {backend2} -> {backend1}") ctx.invoke(sync_pull, source_backend=backend2, target=backend1, dry_run=dry_run, force=True) echo_success("Bidirectional sync completed")