diff --git a/README.md b/README.md index 3de9c23..11d1b46 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ Ce projet fournit un outil pratique et modulable pour migrer vos repositories de ## ✨ Fonctionnalités - **Migration automatique** : Migre tous vos repositories Gitea vers GitHub en une seule commande -- **Migration sélective** : Choisissez spécifiquement quels repositories migrer -- **Support multi-propriétaire** : Migrez des repositories d'autres utilisateurs/organisations auxquels vous avez accès -- **Interface en ligne de commande** : Interface colorée et intuitive +- **🎯 Mode interactif par défaut** : Interface élégante pour sélectionner/déselectionner les repos à migrer +- **Vision complète** : Voit tous les repositories accessibles (vos repos + ceux d'organisations) +- **Sélection intelligente** : Vos repositories sont pré-sélectionnés, les autres sont désélectionnés par défaut +- **Migration sélective** : Choisissez spécifiquement quels repositories migrer en ligne de commande +- **Interface en ligne de commande** : Interface colorée et intuitive avec navigation au clavier - **Logging complet** : Suivi détaillé des opérations avec fichier de log - **Gestion des erreurs** : Robuste avec gestion gracieuse des erreurs @@ -61,11 +63,16 @@ GITHUB_USERNAME=votre_nom_utilisateur_github Après avoir configuré vos tokens dans le fichier `.env`, utilisez le script de lancement : -### Migration de tous vos repositories +### Migration interactive (par défaut) ```bash ./run.sh ``` +### Migration automatique de tous vos repos +```bash +./run.sh --no-interactive +``` + ### Migration de repositories spécifiques ```bash ./run.sh --repos mon-repo autre-repo @@ -88,27 +95,60 @@ Après avoir configuré vos tokens dans le fichier `.env`, utilisez le script de > **💡 Alternative** : Vous pouvez aussi utiliser directement `python migrate.py` si vous avez activé l'environnement virtuel (`source venv/bin/activate`) +## 🎯 Mode Interactif + +Le mode interactif (activé par défaut) offre une **interface utilisateur élégante** pour sélectionner précisément quels repositories migrer : + +```bash +./run.sh # Mode interactif par défaut +``` + +### Contrôles dans l'interface interactive : +- **↑↓** : Naviguer entre les repositories +- **←→** : Changer de page (si beaucoup de repos) +- **ESPACE** : Cocher/décocher un repository +- **A** : Sélectionner tous les repositories +- **N** : Désélectionner tous les repositories +- **ENTRÉE** : Confirmer la sélection et lancer la migration +- **Q** : Quitter sans migrer + +### Fonctionnalités : +- ✅ **Checkboxes visuelles** avec émojis +- 👤 **Distinction propriétaire** : Vos repos vs repos d'autres utilisateurs +- 🎯 **Sélection intelligente** : Vos repos pré-sélectionnés par défaut +- 📄 **Pagination automatique** (15 repos par page) +- 🎨 **Interface colorée** avec mise en surbrillance +- 📊 **Compteur en temps réel** des repos sélectionnés +- 🔒 **Indicateurs visuels** (privé/public) +- 📝 **Descriptions tronquées** pour un affichage propre + ## 📋 Exemples d'utilisation -### Exemple 1 : Migration complète +### Exemple 1 : Migration interactive (défaut) ```bash -# Migre tous vos repositories +# Interface interactive pour sélectionner les repos ./run.sh ``` -### Exemple 2 : Migration sélective +### Exemple 2 : Migration automatique +```bash +# Migre tous vos repositories automatiquement +./run.sh --no-interactive +``` + +### Exemple 3 : Migration sélective ```bash # Migre seulement les repositories spécifiés ./run.sh --repos projet-web api-backend ``` -### Exemple 3 : Migration depuis une organisation +### Exemple 4 : Migration depuis une organisation ```bash # Migre un repository d'une organisation ./run.sh --repos mon-org/projet-important ``` -### Exemple 4 : Premier lancement (configuration) +### Exemple 5 : Premier lancement (configuration) ```bash # 1. Setup initial ./run.sh --setup diff --git a/interactive_selector.py b/interactive_selector.py new file mode 100644 index 0000000..0bb2cdc --- /dev/null +++ b/interactive_selector.py @@ -0,0 +1,223 @@ +""" +Interactive repository selector for migration tool +""" +import sys +import termios +import tty +from typing import List, Dict, Set +from colorama import Fore, Style, init + +init() + +class InteractiveSelector: + """Interactive repository selector with keyboard navigation""" + + def __init__(self, repositories: List[Dict], username: str): + self.repositories = repositories + self.username = username + # Only select user's own repositories by default + self.selected = set(i for i, repo in enumerate(repositories) + if repo['owner']['login'] == username) + self.current_index = 0 + self.page_size = 15 # Number of repos to show per page + self.current_page = 0 + + def get_key(self): + """Get a single keypress from stdin""" + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(sys.stdin.fileno()) + key = sys.stdin.read(1) + # Handle arrow keys and special keys + if key == '\x1b': # ESC sequence + key += sys.stdin.read(2) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + return key + + def display_page(self): + """Display current page of repositories""" + # Clear screen + print('\033[2J\033[H', end='') + + # Header + print(f"{Fore.CYAN}╔═══════════════════════════════════════════════════════════════╗") + print(f"║ 📋 SELECT REPOSITORIES ║") + print(f"║ ║") + print(f"║ 👤 = Your repos (selected by default) 👥 = Others' repos ║") + print(f"║ ↑↓ navigate, SPACE toggle, A all, N none, ENTER confirm ║") + print(f"╚═══════════════════════════════════════════════════════════════╝{Style.RESET_ALL}") + print() + + # Calculate pagination + start_idx = self.current_page * self.page_size + end_idx = min(start_idx + self.page_size, len(self.repositories)) + + # Show page info + total_pages = (len(self.repositories) + self.page_size - 1) // self.page_size + selected_count = len(self.selected) + total_count = len(self.repositories) + + print(f"{Fore.YELLOW}📊 Page {self.current_page + 1}/{total_pages} | " + f"Selected: {selected_count}/{total_count} repositories{Style.RESET_ALL}") + print() + + # Display repositories for current page + for i in range(start_idx, end_idx): + repo = self.repositories[i] + is_selected = i in self.selected + is_current = i == self.current_index + + # Checkbox + checkbox = "☑️ " if is_selected else "☐ " + + # Repository info + owner = repo['owner']['login'] + name = repo['name'] + private = "🔒" if repo.get('private', False) else "🌐" + is_own_repo = owner == self.username + ownership_indicator = "👤" if is_own_repo else "👥" + description = repo.get('description', 'No description')[:45] + if len(repo.get('description', '')) > 45: + description += "..." + + # Highlight current selection + if is_current: + line = f"{Fore.BLACK}{Style.BRIGHT}> {checkbox}{ownership_indicator} {Fore.BLUE}{owner}/{name}{Style.RESET_ALL}" + line += f"{Fore.BLACK}{Style.BRIGHT} {private} - {description}{Style.RESET_ALL}" + else: + if is_own_repo: + color = Fore.GREEN if is_selected else Fore.WHITE + else: + color = Fore.YELLOW if is_selected else Fore.LIGHTBLACK_EX + line = f" {checkbox}{ownership_indicator} {color}{owner}/{name}{Style.RESET_ALL}" + line += f" {private} - {Fore.LIGHTBLACK_EX}{description}{Style.RESET_ALL}" + + print(line) + + # Navigation help at bottom + print() + nav_help = [] + if self.current_page > 0: + nav_help.append("← PREV PAGE") + if self.current_page < total_pages - 1: + nav_help.append("→ NEXT PAGE") + + if nav_help: + print(f"{Fore.CYAN}Navigation: {' | '.join(nav_help)}{Style.RESET_ALL}") + + print(f"\n{Fore.GREEN}Press ENTER to continue with selected repositories{Style.RESET_ALL}") + print(f"{Fore.RED}Press Q to quit{Style.RESET_ALL}") + + def move_up(self): + """Move selection up""" + if self.current_index > 0: + self.current_index -= 1 + # Check if we need to go to previous page + if self.current_index < self.current_page * self.page_size: + self.current_page = max(0, self.current_page - 1) + + def move_down(self): + """Move selection down""" + if self.current_index < len(self.repositories) - 1: + self.current_index += 1 + # Check if we need to go to next page + total_pages = (len(self.repositories) + self.page_size - 1) // self.page_size + if self.current_index >= (self.current_page + 1) * self.page_size: + self.current_page = min(total_pages - 1, self.current_page + 1) + + def toggle_current(self): + """Toggle selection of current repository""" + if self.current_index in self.selected: + self.selected.remove(self.current_index) + else: + self.selected.add(self.current_index) + + def select_all(self): + """Select all repositories""" + self.selected = set(range(len(self.repositories))) + + def select_none(self): + """Deselect all repositories""" + self.selected.clear() + + def prev_page(self): + """Go to previous page""" + if self.current_page > 0: + self.current_page -= 1 + self.current_index = self.current_page * self.page_size + + def next_page(self): + """Go to next page""" + total_pages = (len(self.repositories) + self.page_size - 1) // self.page_size + if self.current_page < total_pages - 1: + self.current_page += 1 + self.current_index = self.current_page * self.page_size + + def run(self) -> List[Dict]: + """Run interactive selection and return selected repositories""" + if not self.repositories: + print(f"{Fore.YELLOW}⚠️ No repositories found.{Style.RESET_ALL}") + return [] + + try: + while True: + self.display_page() + key = self.get_key() + + if key == '\x1b[A': # Up arrow + self.move_up() + elif key == '\x1b[B': # Down arrow + self.move_down() + elif key == '\x1b[D': # Left arrow (previous page) + self.prev_page() + elif key == '\x1b[C': # Right arrow (next page) + self.next_page() + elif key == ' ': # Space - toggle selection + self.toggle_current() + elif key.lower() == 'a': # Select all + self.select_all() + elif key.lower() == 'n': # Select none + self.select_none() + elif key == '\r' or key == '\n': # Enter - confirm + break + elif key.lower() == 'q': # Quit + print(f"\n{Fore.YELLOW}🚪 Migration cancelled by user.{Style.RESET_ALL}") + sys.exit(0) + + except KeyboardInterrupt: + print(f"\n{Fore.YELLOW}🚪 Migration cancelled by user.{Style.RESET_ALL}") + sys.exit(0) + + # Return selected repositories + selected_repos = [self.repositories[i] for i in sorted(self.selected)] + + # Clear screen and show summary + print('\033[2J\033[H', end='') + print(f"{Fore.GREEN}✅ Selected {len(selected_repos)} repositories for migration:{Style.RESET_ALL}\n") + + for repo in selected_repos: + owner = repo['owner']['login'] + name = repo['name'] + private = "🔒" if repo.get('private', False) else "🌐" + print(f" • {Fore.BLUE}{owner}/{name}{Style.RESET_ALL} {private}") + + print(f"\n{Fore.CYAN}🚀 Starting migration...{Style.RESET_ALL}\n") + + return selected_repos + + +def select_repositories_interactive(repositories: List[Dict], username: str) -> List[Dict]: + """ + Interactive repository selection interface + + Args: + repositories: List of repository dictionaries from Gitea API + username: Current user's username to distinguish own repos + + Returns: + List of selected repositories + """ + selector = InteractiveSelector(repositories, username) + return selector.run() \ No newline at end of file diff --git a/migrate.py b/migrate.py index 9ddd59f..12225be 100755 --- a/migrate.py +++ b/migrate.py @@ -90,7 +90,8 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - %(prog)s # Migrate all your repositories + %(prog)s # Interactive mode (default): select repositories + %(prog)s --no-interactive # Migrate all your repositories automatically %(prog)s --repos repo1 repo2 # Migrate specific repositories %(prog)s --repos owner/repo1 # Migrate repositories from other owners %(prog)s --list # List available repositories @@ -122,6 +123,12 @@ Examples: help='Create .env template file' ) + parser.add_argument( + '--no-interactive', + action='store_true', + help='Skip interactive mode: migrate all your repositories automatically' + ) + args = parser.parse_args() print_banner() @@ -166,8 +173,12 @@ Examples: print(f"{Fore.CYAN}🎯 Migrating specific repositories: {', '.join(args.repos)}{Style.RESET_ALL}") results = migration_tool.migrate_specific_repos(args.repos) else: - print(f"{Fore.CYAN}🚀 Migrating all your repositories...{Style.RESET_ALL}") - results = migration_tool.migrate_all_user_repos() + if args.no_interactive: + print(f"{Fore.CYAN}🚀 Migrating all your repositories automatically...{Style.RESET_ALL}") + results = migration_tool.migrate_all_accessible_repos(interactive=False) + else: + print(f"{Fore.CYAN}🎯 Interactive mode - select repositories to migrate{Style.RESET_ALL}") + results = migration_tool.migrate_all_accessible_repos(interactive=True) # Print results if results: diff --git a/migration_tool.py b/migration_tool.py index f9b9368..8cd2188 100644 --- a/migration_tool.py +++ b/migration_tool.py @@ -12,6 +12,7 @@ from pathlib import Path from config import Config from gitea_client import GiteaClient from github_client import GitHubClient +from interactive_selector import select_repositories_interactive logger = logging.getLogger(__name__) @@ -30,18 +31,33 @@ class MigrationTool: config.github_username ) - def migrate_all_user_repos(self) -> Dict[str, bool]: - """Migrate all repositories owned by the user""" - logger.info("Fetching user repositories from Gitea...") - repos = self.gitea_client.get_user_repos() + def migrate_all_accessible_repos(self, interactive: bool = True) -> Dict[str, bool]: + """Migrate repositories with interactive selection (default) or all user repos""" + logger.info("Fetching accessible repositories from Gitea...") + repos = self.gitea_client.list_accessible_repos() + + if not repos: + logger.warning("No repositories found") + return {} + + # Interactive selection (default behavior) + if interactive: + selected_repos = select_repositories_interactive(repos, self.config.gitea_username) + else: + # Non-interactive: only user's own repositories + selected_repos = [repo for repo in repos if repo['owner']['login'] == self.config.gitea_username] + + if not selected_repos: + logger.info("No repositories selected for migration") + return {} results = {} - for repo in repos: - if repo['owner']['login'] == self.config.gitea_username: - repo_name = repo['name'] - logger.info(f"Migrating repository: {repo_name}") - success = self.migrate_repository(repo) - results[repo_name] = success + for repo in selected_repos: + repo_name = repo['name'] + repo_owner = repo['owner']['login'] + logger.info(f"Migrating repository: {repo_owner}/{repo_name}") + success = self.migrate_repository(repo) + results[f"{repo_owner}/{repo_name}"] = success return results @@ -97,6 +113,7 @@ class MigrationTool: def _clone_and_push_repo(self, repo_owner: str, repo_name: str) -> bool: """Clone repository from Gitea and push to GitHub""" temp_dir = None + original_cwd = os.getcwd() # Save original working directory try: # Create temporary directory @@ -108,32 +125,34 @@ class MigrationTool: clone_cmd = ['git', 'clone', '--mirror', gitea_url, str(repo_path)] logger.info(f"Cloning repository from Gitea: {repo_owner}/{repo_name}") - result = subprocess.run(clone_cmd, capture_output=True, text=True) + result = subprocess.run(clone_cmd, capture_output=True, text=True, cwd=temp_dir) if result.returncode != 0: logger.error(f"Failed to clone repository: {result.stderr}") return False - # Change to repository directory - os.chdir(repo_path) + # Verify repository was cloned successfully + if not repo_path.exists(): + logger.error(f"Repository directory not found after cloning: {repo_path}") + return False - # Add GitHub remote + # Add GitHub remote (run command in the repository directory) github_url = self.github_client.get_authenticated_clone_url( repo_name, self.config.github_token ) add_remote_cmd = ['git', 'remote', 'add', 'github', github_url] - result = subprocess.run(add_remote_cmd, capture_output=True, text=True) + result = subprocess.run(add_remote_cmd, capture_output=True, text=True, cwd=str(repo_path)) if result.returncode != 0: logger.error(f"Failed to add GitHub remote: {result.stderr}") return False - # Push to GitHub + # Push to GitHub (run command in the repository directory) logger.info(f"Pushing repository to GitHub: {repo_name}") push_cmd = ['git', 'push', '--mirror', 'github'] - result = subprocess.run(push_cmd, capture_output=True, text=True) + result = subprocess.run(push_cmd, capture_output=True, text=True, cwd=str(repo_path)) if result.returncode != 0: logger.error(f"Failed to push to GitHub: {result.stderr}") @@ -147,9 +166,18 @@ class MigrationTool: return False finally: + # Restore original working directory + try: + os.chdir(original_cwd) + except Exception as e: + logger.warning(f"Failed to restore original working directory: {e}") + # Clean up temporary directory if temp_dir and os.path.exists(temp_dir): - shutil.rmtree(temp_dir, ignore_errors=True) + try: + shutil.rmtree(temp_dir, ignore_errors=True) + except Exception as e: + logger.warning(f"Failed to clean up temporary directory {temp_dir}: {e}") def _get_authenticated_gitea_url(self, owner: str, repo_name: str) -> str: """Get authenticated Gitea URL for cloning"""