markitect-main/issue-facade/cli/sync_commands.py

235 lines
8.8 KiB
Python
Raw Normal View History

"""
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")