diff --git a/README.md b/README.md index d57574d..2d50062 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Ce projet fournit un outil pratique et modulable pour migrer vos repositories de - **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 +- **Renommage intelligent** : Possibilité de renommer les repositories lors de la migration - **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 @@ -104,15 +105,23 @@ Le mode interactif (activé par défaut) offre une **interface utilisateur élé - **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 +- **ENTRÉE** : Confirmer la sélection et passer au renommage (optionnel) - **Q** : Quitter sans migrer +### Interface de renommage : +Après la sélection, l'outil propose de renommer les repositories : +- **Y** : Ouvrir l'interface de renommage +- **N/ENTRÉE** : Conserver les noms actuels +- **Validation automatique** des noms de repositories GitHub + ### 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 +- 📋 **Tri intelligent** : Vos repos en premier, puis les autres, tous par ordre alphabétique +- ✏️ **Renommage optionnel** : Possibilité de renommer les repos sur GitHub - 📄 **Pagination automatique** (15 repos par page) -- 🎨 **Interface colorée** avec mise en surbrillance +- 🎨 **Interface colorée** avec mise en surbrillance et séparateurs visuels - 📊 **Compteur en temps réel** des repos sélectionnés - 🔒 **Indicateurs visuels** (privé/public) - 📝 **Descriptions tronquées** pour un affichage propre @@ -154,8 +163,21 @@ nano .env # 3. Listez vos repositories disponibles ./run.sh --list -# 4. Lancez la migration +# 4. Lancez la migration interactive +./run.sh +``` + +### Exemple 6 : Migration avec renommage +```bash +# 1. Lancer le mode interactif ./run.sh + +# 2. Sélectionner les repos à migrer +# 3. Choisir "Y" pour le renommage +# 4. Renommer les repos un par un +# - Appuyer sur ENTRÉE pour garder le nom original +# - Taper un nouveau nom pour renommer +# 5. Confirmer et lancer la migration ``` ## 📊 Résultats diff --git a/interactive_selector.py b/interactive_selector.py index 0bb2cdc..1d44e64 100644 --- a/interactive_selector.py +++ b/interactive_selector.py @@ -13,10 +13,11 @@ class InteractiveSelector: """Interactive repository selector with keyboard navigation""" def __init__(self, repositories: List[Dict], username: str): - self.repositories = repositories self.username = username + # Sort repositories: user's repos first, then others, both alphabetically + self.repositories = self._sort_repositories(repositories, username) # Only select user's own repositories by default - self.selected = set(i for i, repo in enumerate(repositories) + self.selected = set(i for i, repo in enumerate(self.repositories) if repo['owner']['login'] == username) self.current_index = 0 self.page_size = 15 # Number of repos to show per page @@ -64,19 +65,30 @@ class InteractiveSelector: print() # Display repositories for current page + last_owner_type = None # Track if we're switching from user repos to others + for i in range(start_idx, end_idx): repo = self.repositories[i] is_selected = i in self.selected is_current = i == self.current_index + # Check if we need to add a separator + owner = repo['owner']['login'] + is_own_repo = owner == self.username + current_owner_type = "own" if is_own_repo else "others" + + # Add separator when transitioning from own repos to others + if last_owner_type == "own" and current_owner_type == "others": + print(f" {Fore.LIGHTBLACK_EX}{'─' * 50} Autres repositories {'─' * 10}{Style.RESET_ALL}") + + last_owner_type = current_owner_type + # 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: @@ -155,6 +167,97 @@ class InteractiveSelector: self.current_page += 1 self.current_index = self.current_page * self.page_size + def _rename_repositories_interface(self, selected_repos: List[Dict]) -> List[Dict]: + """Interface for renaming selected repositories""" + print('\033[2J\033[H', end='') # Clear screen + + print(f"{Fore.CYAN}╔═══════════════════════════════════════════════════════════════╗") + print(f"║ ✏️ RENAME REPOSITORIES ║") + print(f"║ ║") + print(f"║ Press ENTER to keep current name, or type new name ║") + print(f"║ Repository names should be valid GitHub repo names ║") + print(f"╚═══════════════════════════════════════════════════════════════╝{Style.RESET_ALL}") + print() + + renamed_repos = [] + + for i, repo in enumerate(selected_repos, 1): + owner = repo['owner']['login'] + original_name = repo['name'] + private = "🔒" if repo.get('private', False) else "🌐" + + print(f"{Fore.YELLOW}📦 Repository {i}/{len(selected_repos)}:{Style.RESET_ALL}") + print(f" Source: {Fore.BLUE}{owner}/{original_name}{Style.RESET_ALL} {private}") + + # Get new name from user + new_name = input(f" GitHub name [{Fore.GREEN}{original_name}{Style.RESET_ALL}]: ").strip() + + # Validate and use new name + if new_name: + # Basic validation + if not self._is_valid_repo_name(new_name): + print(f" {Fore.RED}⚠️ Invalid repository name. Using original name: {original_name}{Style.RESET_ALL}") + new_name = original_name + else: + print(f" {Fore.GREEN}✅ Will rename to: {new_name}{Style.RESET_ALL}") + else: + new_name = original_name + print(f" {Fore.CYAN}ℹ️ Keeping original name: {original_name}{Style.RESET_ALL}") + + # Create new repo dict with updated name + renamed_repo = repo.copy() + renamed_repo['github_name'] = new_name # Add field for GitHub name + renamed_repos.append(renamed_repo) + print() + + # Summary + print(f"{Fore.GREEN}✅ Repository renaming complete!{Style.RESET_ALL}") + print(f"\n{Fore.CYAN}📋 Migration summary:{Style.RESET_ALL}") + + for repo in renamed_repos: + owner = repo['owner']['login'] + original_name = repo['name'] + github_name = repo['github_name'] + private = "🔒" if repo.get('private', False) else "🌐" + + if original_name != github_name: + print(f" • {Fore.BLUE}{owner}/{original_name}{Style.RESET_ALL} → {Fore.GREEN}{github_name}{Style.RESET_ALL} {private}") + else: + print(f" • {Fore.BLUE}{owner}/{original_name}{Style.RESET_ALL} {private}") + + input(f"\n{Fore.YELLOW}Press ENTER to continue...{Style.RESET_ALL}") + return renamed_repos + + def _sort_repositories(self, repositories: List[Dict], username: str) -> List[Dict]: + """Sort repositories: user's repos first, then others, both alphabetically""" + def sort_key(repo): + owner = repo['owner']['login'] + name = repo['name'].lower() # Case-insensitive sorting + is_user_repo = owner == username + + # Return tuple: (is_not_user_repo, owner.lower(), name) + # This will sort user repos first (False < True), then alphabetically + return (not is_user_repo, owner.lower(), name) + + return sorted(repositories, key=sort_key) + + def _is_valid_repo_name(self, name: str) -> bool: + """Validate GitHub repository name""" + if not name: + return False + + # GitHub repo name rules (simplified) + if len(name) > 100: + return False + + # Should not start or end with special characters + if name.startswith('.') or name.startswith('-') or name.endswith('.'): + return False + + # Should contain only alphanumeric, hyphens, underscores, and dots + import re + return bool(re.match(r'^[a-zA-Z0-9._-]+$', name)) + def run(self) -> List[Dict]: """Run interactive selection and return selected repositories""" if not self.repositories: @@ -203,6 +306,16 @@ class InteractiveSelector: private = "🔒" if repo.get('private', False) else "🌐" print(f" • {Fore.BLUE}{owner}/{name}{Style.RESET_ALL} {private}") + # Ask if user wants to rename repositories + print(f"\n{Fore.YELLOW}📝 Voulez-vous changer le nom de certains repos sur GitHub ?{Style.RESET_ALL}") + print(f"{Fore.CYAN}[Y/y] Oui - Interface de renommage{Style.RESET_ALL}") + print(f"{Fore.CYAN}[N/n ou ENTER] Non - Conserver les noms actuels{Style.RESET_ALL}") + + choice = input(f"\n{Fore.YELLOW}Votre choix: {Style.RESET_ALL}").strip().lower() + + if choice == 'y' or choice == 'yes' or choice == 'oui': + selected_repos = self._rename_repositories_interface(selected_repos) + print(f"\n{Fore.CYAN}🚀 Starting migration...{Style.RESET_ALL}\n") return selected_repos diff --git a/migration_tool.py b/migration_tool.py index 8cd2188..111bd9b 100644 --- a/migration_tool.py +++ b/migration_tool.py @@ -55,9 +55,18 @@ class MigrationTool: for repo in selected_repos: repo_name = repo['name'] repo_owner = repo['owner']['login'] - logger.info(f"Migrating repository: {repo_owner}/{repo_name}") + github_name = repo.get('github_name', repo_name) # Use renamed name if available + + if github_name != repo_name: + logger.info(f"Migrating repository: {repo_owner}/{repo_name} → {github_name}") + else: + logger.info(f"Migrating repository: {repo_owner}/{repo_name}") + success = self.migrate_repository(repo) - results[f"{repo_owner}/{repo_name}"] = success + display_name = f"{repo_owner}/{repo_name}" + if github_name != repo_name: + display_name += f" → {github_name}" + results[display_name] = success return results @@ -91,11 +100,12 @@ class MigrationTool: """Migrate a single repository""" repo_name = repo_info['name'] repo_owner = repo_info['owner']['login'] + github_name = repo_info.get('github_name', repo_name) # Use renamed name if available try: - # Create GitHub repository + # Create GitHub repository with the (possibly renamed) name success = self.github_client.create_repository( - repo_name=repo_name, + repo_name=github_name, description=repo_info.get('description', ''), private=repo_info.get('private', False) ) @@ -104,14 +114,17 @@ class MigrationTool: return False # Clone and push repository - return self._clone_and_push_repo(repo_owner, repo_name) + return self._clone_and_push_repo(repo_owner, repo_name, github_name) except Exception as e: logger.error(f"Failed to migrate repository {repo_name}: {e}") return False - def _clone_and_push_repo(self, repo_owner: str, repo_name: str) -> bool: + def _clone_and_push_repo(self, repo_owner: str, repo_name: str, github_name: str = None) -> bool: """Clone repository from Gitea and push to GitHub""" + if github_name is None: + github_name = repo_name + temp_dir = None original_cwd = os.getcwd() # Save original working directory @@ -138,7 +151,7 @@ class MigrationTool: # Add GitHub remote (run command in the repository directory) github_url = self.github_client.get_authenticated_clone_url( - repo_name, + github_name, # Use the GitHub name (possibly renamed) self.config.github_token ) @@ -150,15 +163,31 @@ class MigrationTool: return False # 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, cwd=str(repo_path)) + if github_name != repo_name: + logger.info(f"Pushing repository to GitHub: {repo_name} → {github_name}") + else: + logger.info(f"Pushing repository to GitHub: {repo_name}") + + # Push branches first + push_branches_cmd = ['git', 'push', '--all', 'github'] + result = subprocess.run(push_branches_cmd, capture_output=True, text=True, cwd=str(repo_path)) if result.returncode != 0: - logger.error(f"Failed to push to GitHub: {result.stderr}") + logger.error(f"Failed to push branches to GitHub: {result.stderr}") return False - logger.info(f"Successfully migrated repository: {repo_name}") + # Push tags + push_tags_cmd = ['git', 'push', '--tags', 'github'] + result = subprocess.run(push_tags_cmd, capture_output=True, text=True, cwd=str(repo_path)) + + if result.returncode != 0: + logger.warning(f"Failed to push tags to GitHub (this is often normal): {result.stderr}") + # Don't fail the migration if only tags fail + + if github_name != repo_name: + logger.info(f"Successfully migrated repository: {repo_name} → {github_name}") + else: + logger.info(f"Successfully migrated repository: {repo_name}") return True except Exception as e: @@ -179,6 +208,8 @@ class MigrationTool: 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""" base_url = self.config.gitea_url.replace('https://', '').replace('http://', '')