Masterclass

main
Hugo ODY 4 days ago
parent 008cbfd586
commit e4060f4e42

@ -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

@ -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()

@ -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:

@ -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:
for repo in selected_repos:
repo_name = repo['name']
logger.info(f"Migrating repository: {repo_name}")
repo_owner = repo['owner']['login']
logger.info(f"Migrating repository: {repo_owner}/{repo_name}")
success = self.migrate_repository(repo)
results[repo_name] = success
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):
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"""

Loading…
Cancel
Save