Add comprehensive issue tracking facade system that provides a unified CLI interface to any issue tracking backend. The facade automatically detects the repository's issue tracker and provides consistent commands across all platforms. Key features: - Repository-aware automatic backend detection (GitHub, GitLab, Gitea, local SQLite) - Unified CLI interface with same commands across all backends - Plugin architecture for extensible backend support - Local SQLite backend for offline development - Gitea backend with full API integration - Bidirectional synchronization between backends - Performance-optimized domain models with caching - Clean architecture with separation of concerns The facade acts as a "universal remote control" for issue tracking systems, eliminating the need to learn different CLIs for each platform while providing seamless offline capability and cross-platform consistency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
235 lines
8.8 KiB
Python
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") |