Files
issue-core/issue_core/cli/sync_commands.py
tegwick 99ea1fbc45 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for issue-core
2026-05-17 05:06:10 +02:00

235 lines
8.8 KiB
Python

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