parent
3f546b745b
commit
c57269dcfd
@ -0,0 +1,258 @@
|
||||
# 🏗 Architecture du Migration Tool
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le Migration Tool a été conçu selon les principes de développement suivants :
|
||||
|
||||
- **Single Responsibility Principle (SRP)** : Chaque classe a une responsabilité unique
|
||||
- **Open/Closed Principle** : Ouvert à l'extension, fermé à la modification
|
||||
- **Dependency Inversion** : Dépendance sur des abstractions, pas des implémentations
|
||||
- **Extensibilité** : Facilité d'ajout de nouveaux providers
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
GiteaToGithubMigrator/
|
||||
├── providers/ # Providers pour différents services Git
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py # Classes abstraites et modèles
|
||||
│ ├── factory.py # Factory pour créer les providers
|
||||
│ ├── source/ # Providers source (Gitea, GitLab, etc.)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── gitea.py
|
||||
│ │ └── gitlab.py
|
||||
│ └── destination/ # Providers destination (GitHub, GitLab, etc.)
|
||||
│ ├── __init__.py
|
||||
│ ├── github.py
|
||||
│ └── gitlab.py
|
||||
├── core/ # Logique métier centrale
|
||||
│ ├── __init__.py
|
||||
│ ├── config.py # Gestion de la configuration
|
||||
│ └── migration_engine.py # Moteur de migration
|
||||
├── ui/ # Interface utilisateur
|
||||
│ ├── __init__.py
|
||||
│ └── interactive_selector.py
|
||||
├── main.py # Point d'entrée principal
|
||||
└── run.sh # Script de lancement
|
||||
```
|
||||
|
||||
## Responsabilités des modules
|
||||
|
||||
### 🔧 Providers (providers/)
|
||||
|
||||
#### Base (`providers/base.py`)
|
||||
- **Repository** : Modèle de données unifié pour tous les providers
|
||||
- **SourceProvider** : Interface abstraite pour les providers source
|
||||
- **DestinationProvider** : Interface abstraite pour les providers destination
|
||||
- **Exceptions** : Exceptions spécialisées pour la gestion d'erreurs
|
||||
|
||||
#### Factory (`providers/factory.py`)
|
||||
- Création dynamique des instances de providers
|
||||
- Enregistrement de nouveaux providers
|
||||
- Validation des types de providers disponibles
|
||||
|
||||
#### Source Providers (`providers/source/`)
|
||||
- **Gitea** : Implémentation pour Gitea
|
||||
- **GitLab** : Implémentation pour GitLab (exemple d'extensibilité)
|
||||
|
||||
#### Destination Providers (`providers/destination/`)
|
||||
- **GitHub** : Implémentation pour GitHub
|
||||
- **GitLab** : Implémentation pour GitLab (exemple d'extensibilité)
|
||||
|
||||
### ⚙ Core (`core/`)
|
||||
|
||||
#### Configuration (`core/config.py`)
|
||||
- Gestion centralisée de la configuration
|
||||
- Support multi-providers via variables d'environnement
|
||||
- Validation des paramètres de configuration
|
||||
|
||||
#### Migration Engine (`core/migration_engine.py`)
|
||||
- Orchestration du processus de migration
|
||||
- Gestion des repositories temporaires
|
||||
- Exécution des commandes Git
|
||||
- Logging et rapport de progression
|
||||
|
||||
### 🎨 UI (`ui/`)
|
||||
|
||||
#### Interactive Selector (`ui/interactive_selector.py`)
|
||||
- Interface interactive pour la sélection de repositories
|
||||
- Navigation au clavier
|
||||
- Système de renommage
|
||||
- Affichage paginated
|
||||
|
||||
## Extensibilité
|
||||
|
||||
### Ajouter un nouveau provider source
|
||||
|
||||
1. **Créer le fichier** : `providers/source/mon_provider.py`
|
||||
|
||||
```python
|
||||
from typing import List, Optional
|
||||
from ..base import SourceProvider, Repository, ProviderError, ConfigurationError
|
||||
|
||||
class MonProviderSourceProvider(SourceProvider):
|
||||
def _validate_config(self) -> None:
|
||||
# Valider la configuration spécifique
|
||||
pass
|
||||
|
||||
def get_user_repositories(self) -> List[Repository]:
|
||||
# Récupérer les repos de l'utilisateur
|
||||
pass
|
||||
|
||||
def get_accessible_repositories(self) -> List[Repository]:
|
||||
# Récupérer tous les repos accessibles
|
||||
pass
|
||||
|
||||
def get_repository_info(self, owner: str, name: str) -> Optional[Repository]:
|
||||
# Récupérer les infos d'un repo spécifique
|
||||
pass
|
||||
|
||||
def get_authenticated_clone_url(self, repository: Repository) -> str:
|
||||
# Générer l'URL de clone authentifiée
|
||||
pass
|
||||
```
|
||||
|
||||
2. **Enregistrer le provider** dans `providers/factory.py` :
|
||||
|
||||
```python
|
||||
from .source.mon_provider import MonProviderSourceProvider
|
||||
|
||||
class ProviderFactory:
|
||||
_source_providers: Dict[str, Type[SourceProvider]] = {
|
||||
'gitea': GiteaSourceProvider,
|
||||
'gitlab': GitLabSourceProvider,
|
||||
'mon_provider': MonProviderSourceProvider, # Nouveau provider
|
||||
}
|
||||
```
|
||||
|
||||
3. **Ajouter la configuration** dans `core/config.py` :
|
||||
|
||||
```python
|
||||
def _load_source_config(self) -> Dict[str, Any]:
|
||||
if self.source_provider == 'mon_provider':
|
||||
return {
|
||||
'url': os.getenv('MON_PROVIDER_URL'),
|
||||
'token': os.getenv('MON_PROVIDER_TOKEN'),
|
||||
'username': os.getenv('MON_PROVIDER_USERNAME')
|
||||
}
|
||||
# ... autres providers
|
||||
```
|
||||
|
||||
### Ajouter un nouveau provider destination
|
||||
|
||||
Le processus est identique, mais dans `providers/destination/`.
|
||||
|
||||
## Patterns utilisés
|
||||
|
||||
### 1. Abstract Factory Pattern
|
||||
- `ProviderFactory` crée des instances de providers
|
||||
- Permet d'ajouter de nouveaux providers sans modifier le code existant
|
||||
|
||||
### 2. Strategy Pattern
|
||||
- Les providers implémentent des stratégies différentes pour accéder aux APIs
|
||||
- Le moteur de migration utilise ces stratégies de manière transparente
|
||||
|
||||
### 3. Template Method Pattern
|
||||
- `SourceProvider` et `DestinationProvider` définissent le squelette des opérations
|
||||
- Les implémentations concrètes remplissent les détails spécifiques
|
||||
|
||||
### 4. Dependency Injection
|
||||
- Les providers sont injectés dans `MigrationEngine`
|
||||
- Facilite les tests et la flexibilité
|
||||
|
||||
## Configuration
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
```bash
|
||||
# Provider source
|
||||
SOURCE_PROVIDER=gitea|gitlab
|
||||
GITEA_URL=https://gitea.example.com
|
||||
GITEA_TOKEN=your_token
|
||||
GITEA_USERNAME=your_username
|
||||
|
||||
# Provider destination
|
||||
DESTINATION_PROVIDER=github|gitlab
|
||||
GITHUB_TOKEN=your_token
|
||||
GITHUB_USERNAME=your_username
|
||||
```
|
||||
|
||||
### Extensibilité de la configuration
|
||||
|
||||
Pour ajouter un nouveau provider, il suffit d'ajouter les variables correspondantes et de modifier `MigrationConfig`.
|
||||
|
||||
## Tests
|
||||
|
||||
### Structure recommandée
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/
|
||||
│ ├── providers/
|
||||
│ │ ├── test_gitea_provider.py
|
||||
│ │ └── test_github_provider.py
|
||||
│ ├── core/
|
||||
│ │ └── test_migration_engine.py
|
||||
│ └── ui/
|
||||
│ └── test_interactive_selector.py
|
||||
├── integration/
|
||||
│ └── test_full_migration.py
|
||||
└── fixtures/
|
||||
└── sample_repositories.json
|
||||
```
|
||||
|
||||
### Exemples de tests
|
||||
|
||||
```python
|
||||
# Test d'un provider
|
||||
def test_gitea_provider_validates_config():
|
||||
config = {'url': 'https://gitea.com', 'token': 'token', 'username': 'user'}
|
||||
provider = GiteaSourceProvider(config)
|
||||
assert provider.base_url == 'https://gitea.com'
|
||||
|
||||
# Test du moteur de migration
|
||||
def test_migration_engine_handles_errors():
|
||||
source = Mock(spec=SourceProvider)
|
||||
dest = Mock(spec=DestinationProvider)
|
||||
engine = MigrationEngine(source, dest)
|
||||
# Test error handling...
|
||||
```
|
||||
|
||||
## Bonnes pratiques
|
||||
|
||||
### 1. Gestion d'erreurs
|
||||
- Exceptions spécialisées pour chaque type d'erreur
|
||||
- Logging approprié à chaque niveau
|
||||
- Gestion gracieuse des échecs
|
||||
|
||||
### 2. Documentation
|
||||
- Docstrings pour toutes les méthodes publiques
|
||||
- Type hints pour la clarté du code
|
||||
- README et documentation d'architecture
|
||||
|
||||
### 3. Sécurité
|
||||
- Tokens jamais loggés
|
||||
- Nettoyage des repositories temporaires
|
||||
- Validation des entrées utilisateur
|
||||
|
||||
### 4. Performance
|
||||
- Pagination pour les listes de repositories
|
||||
- Parallélisation possible des migrations
|
||||
- Gestion efficace de la mémoire
|
||||
|
||||
## Évolutions futures
|
||||
|
||||
### Fonctionnalités potentielles
|
||||
- Migration incrémentale (seulement les changements)
|
||||
- Support des webhooks
|
||||
- Interface web
|
||||
- API REST
|
||||
- Migration de métadonnées (issues, pull requests)
|
||||
|
||||
### Nouveaux providers
|
||||
- Bitbucket
|
||||
- Azure DevOps
|
||||
- Sourcehut
|
||||
- Codeberg
|
||||
|
||||
L'architecture actuelle permet d'ajouter facilement ces fonctionnalités sans restructuration majeure.
|
@ -1,45 +0,0 @@
|
||||
"""
|
||||
Configuration module for Gitea to GitHub migration tool
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
"""Configuration class for migration settings"""
|
||||
|
||||
def __init__(self):
|
||||
self.gitea_url = os.getenv('GITEA_URL', 'https://codefirst.iut.uca.fr/git')
|
||||
self.gitea_token = os.getenv('GITEA_TOKEN')
|
||||
self.github_token = os.getenv('GITHUB_TOKEN')
|
||||
self.github_username = os.getenv('GITHUB_USERNAME')
|
||||
self.gitea_username = os.getenv('GITEA_USERNAME')
|
||||
|
||||
# Validate required configuration
|
||||
self._validate_config()
|
||||
|
||||
def _validate_config(self):
|
||||
"""Validate that all required configuration is present"""
|
||||
missing = []
|
||||
|
||||
if not self.gitea_token:
|
||||
missing.append('GITEA_TOKEN')
|
||||
if not self.github_token:
|
||||
missing.append('GITHUB_TOKEN')
|
||||
if not self.github_username:
|
||||
missing.append('GITHUB_USERNAME')
|
||||
if not self.gitea_username:
|
||||
missing.append('GITEA_USERNAME')
|
||||
|
||||
if missing:
|
||||
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if configuration is valid"""
|
||||
try:
|
||||
self._validate_config()
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Core migration engine and utilities
|
||||
"""
|
@ -0,0 +1,76 @@
|
||||
"""
|
||||
Configuration management for migration tool
|
||||
"""
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from dotenv import load_dotenv
|
||||
from providers.base import ConfigurationError
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class MigrationConfig:
|
||||
"""Configuration manager for migration settings"""
|
||||
|
||||
def __init__(self):
|
||||
self.source_provider = os.getenv('SOURCE_PROVIDER', 'gitea').lower()
|
||||
self.destination_provider = os.getenv('DESTINATION_PROVIDER', 'github').lower()
|
||||
|
||||
self.source_config = self._load_source_config()
|
||||
self.destination_config = self._load_destination_config()
|
||||
|
||||
self._validate_config()
|
||||
|
||||
def _load_source_config(self) -> Dict[str, Any]:
|
||||
"""Load source provider configuration"""
|
||||
if self.source_provider == 'gitea':
|
||||
return {
|
||||
'url': os.getenv('GITEA_URL', 'https://codefirst.iut.uca.fr/git'),
|
||||
'token': os.getenv('GITEA_TOKEN'),
|
||||
'username': os.getenv('GITEA_USERNAME')
|
||||
}
|
||||
elif self.source_provider == 'gitlab':
|
||||
return {
|
||||
'url': os.getenv('GITLAB_URL', 'https://gitlab.com'),
|
||||
'token': os.getenv('GITLAB_TOKEN'),
|
||||
'username': os.getenv('GITLAB_USERNAME')
|
||||
}
|
||||
else:
|
||||
raise ConfigurationError(f"Unsupported source provider: {self.source_provider}")
|
||||
|
||||
def _load_destination_config(self) -> Dict[str, Any]:
|
||||
"""Load destination provider configuration"""
|
||||
if self.destination_provider == 'github':
|
||||
return {
|
||||
'token': os.getenv('GITHUB_TOKEN'),
|
||||
'username': os.getenv('GITHUB_USERNAME')
|
||||
}
|
||||
elif self.destination_provider == 'gitlab':
|
||||
return {
|
||||
'url': os.getenv('GITLAB_DEST_URL', 'https://gitlab.com'),
|
||||
'token': os.getenv('GITLAB_DEST_TOKEN'),
|
||||
'username': os.getenv('GITLAB_DEST_USERNAME')
|
||||
}
|
||||
else:
|
||||
raise ConfigurationError(f"Unsupported destination provider: {self.destination_provider}")
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate configuration completeness"""
|
||||
# Check source config
|
||||
missing_source = [key for key, value in self.source_config.items() if not value]
|
||||
if missing_source:
|
||||
raise ConfigurationError(f"Missing {self.source_provider} source configuration: {', '.join(missing_source)}")
|
||||
|
||||
# Check destination config
|
||||
missing_dest = [key for key, value in self.destination_config.items() if not value]
|
||||
if missing_dest:
|
||||
raise ConfigurationError(f"Missing {self.destination_provider} destination configuration: {', '.join(missing_dest)}")
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if configuration is valid"""
|
||||
try:
|
||||
self._validate_config()
|
||||
return True
|
||||
except ConfigurationError:
|
||||
return False
|
@ -0,0 +1,164 @@
|
||||
"""
|
||||
Main migration engine
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
import logging
|
||||
from typing import List, Dict
|
||||
from pathlib import Path
|
||||
|
||||
from providers.base import SourceProvider, DestinationProvider, Repository, MigrationError
|
||||
from .config import MigrationConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MigrationEngine:
|
||||
"""Main migration engine that orchestrates repository migrations"""
|
||||
|
||||
def __init__(self, source_provider: SourceProvider, destination_provider: DestinationProvider):
|
||||
self.source_provider = source_provider
|
||||
self.destination_provider = destination_provider
|
||||
|
||||
def migrate_repositories(self, repositories: List[Repository]) -> Dict[str, bool]:
|
||||
"""Migrate a list of repositories"""
|
||||
if not repositories:
|
||||
logger.info("No repositories selected for migration")
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
for repository in repositories:
|
||||
target_name = repository.github_name or repository.name
|
||||
|
||||
if repository.github_name and repository.github_name != repository.name:
|
||||
logger.info(f"Migrating repository: {repository.owner}/{repository.name} → {target_name}")
|
||||
else:
|
||||
logger.info(f"Migrating repository: {repository.owner}/{repository.name}")
|
||||
|
||||
try:
|
||||
success = self._migrate_single_repository(repository, target_name)
|
||||
display_name = f"{repository.owner}/{repository.name}"
|
||||
if target_name != repository.name:
|
||||
display_name += f" → {target_name}"
|
||||
results[display_name] = success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error migrating {repository.name}: {e}")
|
||||
display_name = f"{repository.owner}/{repository.name}"
|
||||
if target_name != repository.name:
|
||||
display_name += f" → {target_name}"
|
||||
results[display_name] = False
|
||||
|
||||
return results
|
||||
|
||||
def _migrate_single_repository(self, repository: Repository, target_name: str) -> bool:
|
||||
"""Migrate a single repository"""
|
||||
try:
|
||||
# Create destination repository
|
||||
logger.info(f"Creating destination repository: {target_name}")
|
||||
success = self.destination_provider.create_repository(repository, target_name)
|
||||
if not success:
|
||||
logger.error(f"Failed to create destination repository: {target_name}")
|
||||
return False
|
||||
|
||||
# Clone and push repository
|
||||
logger.info(f"Starting clone and push for repository: {repository.name}")
|
||||
return self._clone_and_push_repository(repository, target_name)
|
||||
|
||||
except MigrationError as e:
|
||||
logger.error(f"Migration error for repository {repository.name}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error migrating repository {repository.name}: {e}")
|
||||
logger.error(f"Exception type: {type(e).__name__}")
|
||||
logger.error(f"Exception args: {e.args}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
def _clone_and_push_repository(self, repository: Repository, target_name: str) -> bool:
|
||||
"""Clone repository from source and push to destination"""
|
||||
temp_dir = None
|
||||
original_cwd = os.getcwd()
|
||||
|
||||
try:
|
||||
# Create temporary directory
|
||||
temp_dir = tempfile.mkdtemp(prefix=f"migration_{repository.name}_")
|
||||
repo_path = Path(temp_dir) / repository.name
|
||||
|
||||
# Clone from source
|
||||
source_url = self.source_provider.get_authenticated_clone_url(repository)
|
||||
clone_cmd = ['git', 'clone', '--mirror', source_url, str(repo_path)]
|
||||
|
||||
logger.info(f"Cloning repository from {self.source_provider.__class__.__name__}: {repository.owner}/{repository.name}")
|
||||
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
|
||||
|
||||
# Verify repository was cloned successfully
|
||||
if not repo_path.exists():
|
||||
logger.error(f"Repository directory not found after cloning: {repo_path}")
|
||||
return False
|
||||
|
||||
# Add destination remote
|
||||
dest_url = self.destination_provider.get_authenticated_push_url(target_name)
|
||||
add_remote_cmd = ['git', 'remote', 'add', 'destination', dest_url]
|
||||
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 destination remote: {result.stderr}")
|
||||
return False
|
||||
|
||||
# Push to destination
|
||||
return self._push_to_destination(repository, target_name, repo_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during repository migration: {e}")
|
||||
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 _push_to_destination(self, repository: Repository, target_name: str, repo_path: Path) -> bool:
|
||||
"""Push repository to destination provider"""
|
||||
if target_name != repository.name:
|
||||
logger.info(f"Pushing repository to destination: {repository.name} → {target_name}")
|
||||
else:
|
||||
logger.info(f"Pushing repository to destination: {repository.name}")
|
||||
|
||||
# Push branches
|
||||
push_branches_cmd = ['git', 'push', '--all', 'destination']
|
||||
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 branches to destination: {result.stderr}")
|
||||
return False
|
||||
|
||||
# Push tags (non-blocking)
|
||||
push_tags_cmd = ['git', 'push', '--tags', 'destination']
|
||||
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 destination (this is often normal): {result.stderr}")
|
||||
|
||||
if target_name != repository.name:
|
||||
logger.info(f"Successfully migrated repository: {repository.name} → {target_name}")
|
||||
else:
|
||||
logger.info(f"Successfully migrated repository: {repository.name}")
|
||||
|
||||
return True
|
@ -1,87 +0,0 @@
|
||||
"""
|
||||
Gitea API client for repository operations
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
class GiteaClient:
|
||||
"""Client for interacting with Gitea API"""
|
||||
|
||||
def __init__(self, base_url: str, token: str, username: str):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.token = token
|
||||
self.username = username
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'Authorization': f'token {token}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
|
||||
def get_user_repos(self) -> List[Dict]:
|
||||
"""Get all repositories owned by the user"""
|
||||
url = f"{self.base_url}/api/v1/user/repos"
|
||||
params = {
|
||||
'limit': 100, # Adjust as needed
|
||||
'page': 1
|
||||
}
|
||||
|
||||
all_repos = []
|
||||
|
||||
while True:
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
repos = response.json()
|
||||
if not repos:
|
||||
break
|
||||
|
||||
all_repos.extend(repos)
|
||||
params['page'] += 1
|
||||
|
||||
# Break if we got less than the limit (last page)
|
||||
if len(repos) < params.get('limit', 100):
|
||||
break
|
||||
|
||||
return all_repos
|
||||
|
||||
def get_repo_info(self, owner: str, repo_name: str) -> Optional[Dict]:
|
||||
"""Get information about a specific repository"""
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{repo_name}"
|
||||
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
def get_repo_clone_url(self, owner: str, repo_name: str) -> str:
|
||||
"""Get the clone URL for a repository with authentication"""
|
||||
return f"{self.base_url}/{owner}/{repo_name}.git"
|
||||
|
||||
def list_accessible_repos(self) -> List[Dict]:
|
||||
"""List all repositories the user has access to (including organizations)"""
|
||||
url = f"{self.base_url}/api/v1/user/repos"
|
||||
params = {
|
||||
'limit': 100,
|
||||
'page': 1
|
||||
}
|
||||
|
||||
all_repos = []
|
||||
|
||||
while True:
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
repos = response.json()
|
||||
if not repos:
|
||||
break
|
||||
|
||||
all_repos.extend(repos)
|
||||
params['page'] += 1
|
||||
|
||||
if len(repos) < params.get('limit', 100):
|
||||
break
|
||||
|
||||
return all_repos
|
@ -1,54 +0,0 @@
|
||||
"""
|
||||
GitHub API client for repository operations
|
||||
"""
|
||||
from github import Github
|
||||
from github.GithubException import GithubException
|
||||
from typing import Optional, Dict
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GitHubClient:
|
||||
"""Client for interacting with GitHub API"""
|
||||
|
||||
def __init__(self, token: str, username: str):
|
||||
self.github = Github(token)
|
||||
self.username = username
|
||||
self.user = self.github.get_user()
|
||||
|
||||
def create_repository(self, repo_name: str, description: str = "", private: bool = False) -> bool:
|
||||
"""Create a new repository on GitHub"""
|
||||
try:
|
||||
# Check if repository already exists
|
||||
if self.repository_exists(repo_name):
|
||||
logger.warning(f"Repository {repo_name} already exists on GitHub")
|
||||
return True
|
||||
|
||||
self.user.create_repo(
|
||||
name=repo_name,
|
||||
description=description,
|
||||
private=private,
|
||||
auto_init=False # Don't auto-init since we'll push existing content
|
||||
)
|
||||
logger.info(f"Created repository: {repo_name}")
|
||||
return True
|
||||
|
||||
except GithubException as e:
|
||||
logger.error(f"Failed to create repository {repo_name}: {e}")
|
||||
return False
|
||||
|
||||
def repository_exists(self, repo_name: str) -> bool:
|
||||
"""Check if a repository exists"""
|
||||
try:
|
||||
self.user.get_repo(repo_name)
|
||||
return True
|
||||
except GithubException:
|
||||
return False
|
||||
|
||||
def get_repo_clone_url(self, repo_name: str) -> str:
|
||||
"""Get the clone URL for a GitHub repository"""
|
||||
return f"https://github.com/{self.username}/{repo_name}.git"
|
||||
|
||||
def get_authenticated_clone_url(self, repo_name: str, token: str) -> str:
|
||||
"""Get the authenticated clone URL for pushing"""
|
||||
return f"https://{token}@github.com/{self.username}/{repo_name}.git"
|
@ -1,220 +0,0 @@
|
||||
"""
|
||||
Main migration tool for transferring repositories from Gitea to GitHub
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
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__)
|
||||
|
||||
class MigrationTool:
|
||||
"""Main tool for migrating repositories from Gitea to GitHub"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.gitea_client = GiteaClient(
|
||||
config.gitea_url,
|
||||
config.gitea_token,
|
||||
config.gitea_username
|
||||
)
|
||||
self.github_client = GitHubClient(
|
||||
config.github_token,
|
||||
config.github_username
|
||||
)
|
||||
|
||||
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 selected_repos:
|
||||
repo_name = repo['name']
|
||||
repo_owner = repo['owner']['login']
|
||||
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)
|
||||
display_name = f"{repo_owner}/{repo_name}"
|
||||
if github_name != repo_name:
|
||||
display_name += f" → {github_name}"
|
||||
results[display_name] = success
|
||||
|
||||
return results
|
||||
|
||||
def migrate_specific_repos(self, repo_specs: List[str]) -> Dict[str, bool]:
|
||||
"""
|
||||
Migrate specific repositories
|
||||
repo_specs: List of repository specifications in format 'owner/repo' or just 'repo'
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for repo_spec in repo_specs:
|
||||
if '/' in repo_spec:
|
||||
owner, repo_name = repo_spec.split('/', 1)
|
||||
else:
|
||||
owner = self.config.gitea_username
|
||||
repo_name = repo_spec
|
||||
|
||||
logger.info(f"Migrating repository: {owner}/{repo_name}")
|
||||
repo_info = self.gitea_client.get_repo_info(owner, repo_name)
|
||||
|
||||
if repo_info:
|
||||
success = self.migrate_repository(repo_info)
|
||||
results[f"{owner}/{repo_name}"] = success
|
||||
else:
|
||||
logger.error(f"Repository {owner}/{repo_name} not found or not accessible")
|
||||
results[f"{owner}/{repo_name}"] = False
|
||||
|
||||
return results
|
||||
|
||||
def migrate_repository(self, repo_info: Dict) -> bool:
|
||||
"""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 with the (possibly renamed) name
|
||||
success = self.github_client.create_repository(
|
||||
repo_name=github_name,
|
||||
description=repo_info.get('description', ''),
|
||||
private=repo_info.get('private', False)
|
||||
)
|
||||
|
||||
if not success:
|
||||
return False
|
||||
|
||||
# Clone and push repository
|
||||
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, 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
|
||||
|
||||
try:
|
||||
# Create temporary directory
|
||||
temp_dir = tempfile.mkdtemp(prefix=f"migration_{repo_name}_")
|
||||
repo_path = Path(temp_dir) / repo_name
|
||||
|
||||
# Clone from Gitea
|
||||
gitea_url = self._get_authenticated_gitea_url(repo_owner, repo_name)
|
||||
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, cwd=temp_dir)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to clone repository: {result.stderr}")
|
||||
return False
|
||||
|
||||
# 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 (run command in the repository directory)
|
||||
github_url = self.github_client.get_authenticated_clone_url(
|
||||
github_name, # Use the GitHub name (possibly renamed)
|
||||
self.config.github_token
|
||||
)
|
||||
|
||||
add_remote_cmd = ['git', 'remote', 'add', 'github', github_url]
|
||||
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 (run command in the repository directory)
|
||||
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 branches to GitHub: {result.stderr}")
|
||||
return False
|
||||
|
||||
# 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:
|
||||
logger.error(f"Error during repository migration: {e}")
|
||||
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"""
|
||||
base_url = self.config.gitea_url.replace('https://', '').replace('http://', '')
|
||||
return f"https://{self.config.gitea_username}:{self.config.gitea_token}@{base_url}/{owner}/{repo_name}.git"
|
||||
|
||||
def list_available_repos(self) -> List[Dict]:
|
||||
"""List all repositories available for migration"""
|
||||
return self.gitea_client.list_accessible_repos()
|
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Providers package for different Git hosting services
|
||||
"""
|
@ -0,0 +1,96 @@
|
||||
"""
|
||||
Abstract base classes for source and destination providers
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Repository:
|
||||
"""Repository data model"""
|
||||
name: str
|
||||
owner: str
|
||||
description: str
|
||||
private: bool
|
||||
clone_url: str
|
||||
ssh_url: Optional[str] = None
|
||||
web_url: Optional[str] = None
|
||||
default_branch: Optional[str] = None
|
||||
github_name: Optional[str] = None # For renaming during migration
|
||||
|
||||
|
||||
class SourceProvider(ABC):
|
||||
"""Abstract base class for source providers (Gitea, GitLab, etc.)"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
self._validate_config()
|
||||
|
||||
@abstractmethod
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate provider-specific configuration"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_user_repositories(self) -> List[Repository]:
|
||||
"""Get repositories owned by the authenticated user"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_accessible_repositories(self) -> List[Repository]:
|
||||
"""Get all repositories accessible to the authenticated user"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_repository_info(self, owner: str, name: str) -> Optional[Repository]:
|
||||
"""Get information about a specific repository"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_authenticated_clone_url(self, repository: Repository) -> str:
|
||||
"""Get authenticated clone URL for a repository"""
|
||||
pass
|
||||
|
||||
|
||||
class DestinationProvider(ABC):
|
||||
"""Abstract base class for destination providers (GitHub, GitLab, etc.)"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
self._validate_config()
|
||||
|
||||
@abstractmethod
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate provider-specific configuration"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_repository(self, repository: Repository, target_name: str) -> bool:
|
||||
"""Create a new repository in the destination provider"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def repository_exists(self, name: str) -> bool:
|
||||
"""Check if a repository exists"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_authenticated_push_url(self, name: str) -> str:
|
||||
"""Get authenticated URL for pushing to repository"""
|
||||
pass
|
||||
|
||||
|
||||
class MigrationError(Exception):
|
||||
"""Base exception for migration errors"""
|
||||
pass
|
||||
|
||||
|
||||
class ProviderError(MigrationError):
|
||||
"""Exception for provider-specific errors"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(MigrationError):
|
||||
"""Exception for configuration errors"""
|
||||
pass
|
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Destination providers for migration tool
|
||||
"""
|
@ -0,0 +1,88 @@
|
||||
"""
|
||||
GitHub destination provider implementation
|
||||
"""
|
||||
import logging
|
||||
from github import Github
|
||||
from github.GithubException import GithubException
|
||||
from typing import Dict
|
||||
from ..base import DestinationProvider, Repository, ProviderError, ConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitHubDestinationProvider(DestinationProvider):
|
||||
"""GitHub destination provider implementation"""
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate GitHub-specific configuration"""
|
||||
required_keys = ['token', 'username']
|
||||
missing = [key for key in required_keys if not self.config.get(key)]
|
||||
|
||||
if missing:
|
||||
raise ConfigurationError(f"Missing GitHub configuration: {', '.join(missing)}")
|
||||
|
||||
self.token = self.config['token']
|
||||
self.username = self.config['username']
|
||||
|
||||
try:
|
||||
self.github = Github(self.token)
|
||||
self.user = self.github.get_user()
|
||||
except GithubException as e:
|
||||
raise ConfigurationError(f"Failed to authenticate with GitHub: {e}")
|
||||
|
||||
def create_repository(self, repository: Repository, target_name: str) -> bool:
|
||||
"""Create a new repository on GitHub"""
|
||||
try:
|
||||
# Check if repository already exists
|
||||
if self.repository_exists(target_name):
|
||||
logger.warning(f"Repository {target_name} already exists on GitHub")
|
||||
return True
|
||||
|
||||
logger.info(f"Creating GitHub repository: {target_name}")
|
||||
self.user.create_repo(
|
||||
name=target_name,
|
||||
description=repository.description or '',
|
||||
private=repository.private,
|
||||
auto_init=False # Don't auto-init since we'll push existing content
|
||||
)
|
||||
logger.info(f"Successfully created repository: {target_name}")
|
||||
return True
|
||||
|
||||
except GithubException as e:
|
||||
logger.error(f"GitHub API error creating repository {target_name}: {e}")
|
||||
logger.error(f"GitHub error status: {e.status}")
|
||||
logger.error(f"GitHub error data: {e.data}")
|
||||
|
||||
# Handle specific GitHub errors
|
||||
if e.status == 422 and 'already exists' in str(e.data).lower():
|
||||
logger.warning(f"Repository {target_name} already exists (422 error)")
|
||||
return True
|
||||
|
||||
raise ProviderError(f"Failed to create GitHub repository: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating GitHub repository {target_name}: {e}")
|
||||
logger.error(f"Exception type: {type(e).__name__}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
raise ProviderError(f"Unexpected error creating GitHub repository: {e}")
|
||||
|
||||
def repository_exists(self, name: str) -> bool:
|
||||
"""Check if a repository exists"""
|
||||
try:
|
||||
repo = self.user.get_repo(name)
|
||||
logger.debug(f"Repository {name} exists: {repo.full_name}")
|
||||
return True
|
||||
except GithubException as e:
|
||||
if e.status == 404:
|
||||
logger.debug(f"Repository {name} does not exist (404)")
|
||||
return False
|
||||
else:
|
||||
logger.warning(f"Error checking if repository {name} exists: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error checking repository existence {name}: {e}")
|
||||
return False
|
||||
|
||||
def get_authenticated_push_url(self, name: str) -> str:
|
||||
"""Get authenticated URL for pushing to repository"""
|
||||
return f"https://{self.token}@github.com/{self.username}/{name}.git"
|
@ -0,0 +1,105 @@
|
||||
"""
|
||||
GitLab destination provider implementation (Example for extensibility)
|
||||
"""
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict
|
||||
from ..base import DestinationProvider, Repository, ProviderError, ConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitLabDestinationProvider(DestinationProvider):
|
||||
"""GitLab destination provider implementation"""
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate GitLab-specific configuration"""
|
||||
required_keys = ['url', 'token', 'username']
|
||||
missing = [key for key in required_keys if not self.config.get(key)]
|
||||
|
||||
if missing:
|
||||
raise ConfigurationError(f"Missing GitLab configuration: {', '.join(missing)}")
|
||||
|
||||
self.base_url = self.config['url'].rstrip('/')
|
||||
self.token = self.config['token']
|
||||
self.username = self.config['username']
|
||||
|
||||
# Setup HTTP session
|
||||
self.session = requests.Session()
|
||||
# GitLab uses different token formats - try Private-Token first
|
||||
if self.token.startswith('glpat-'):
|
||||
# Personal Access Token
|
||||
self.session.headers.update({
|
||||
'Private-Token': self.token,
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
else:
|
||||
# OAuth token or other format
|
||||
self.session.headers.update({
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
|
||||
# Verify authentication
|
||||
try:
|
||||
response = self.session.get(f"{self.base_url}/api/v4/user")
|
||||
response.raise_for_status()
|
||||
self.user_id = response.json()['id']
|
||||
except requests.RequestException as e:
|
||||
raise ConfigurationError(f"Failed to authenticate with GitLab: {e}")
|
||||
|
||||
def create_repository(self, repository: Repository, target_name: str) -> bool:
|
||||
"""Create a new repository on GitLab"""
|
||||
response = None
|
||||
try:
|
||||
# Check if repository already exists
|
||||
if self.repository_exists(target_name):
|
||||
logger.warning(f"Repository {target_name} already exists on GitLab")
|
||||
return True
|
||||
|
||||
project_data = {
|
||||
'name': target_name,
|
||||
'description': repository.description or '',
|
||||
'visibility': 'private' if repository.private else 'public',
|
||||
'initialize_with_readme': False
|
||||
}
|
||||
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/v4/projects",
|
||||
json=project_data
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(f"Created repository: {target_name}")
|
||||
return True
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to create repository {target_name}: {e}")
|
||||
if response and response.status_code == 400:
|
||||
# Repository might already exist or name is invalid
|
||||
logger.warning(f"Repository creation failed, possibly already exists: {target_name}")
|
||||
return self.repository_exists(target_name)
|
||||
elif response and response.status_code == 409:
|
||||
# Conflict - repository already exists
|
||||
logger.warning(f"Repository {target_name} already exists (conflict)")
|
||||
return True
|
||||
raise ProviderError(f"Failed to create GitLab repository: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating repository {target_name}: {e}")
|
||||
return False
|
||||
|
||||
def repository_exists(self, name: str) -> bool:
|
||||
"""Check if a repository exists"""
|
||||
project_path = f"{self.username}/{name}"
|
||||
url = f"{self.base_url}/api/v4/projects/{requests.utils.quote(project_path, safe='')}"
|
||||
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
return response.status_code == 200
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
def get_authenticated_push_url(self, name: str) -> str:
|
||||
"""Get authenticated URL for pushing to repository"""
|
||||
base_url = self.base_url.replace('https://', '').replace('http://', '')
|
||||
return f"https://oauth2:{self.token}@{base_url}/{self.username}/{name}.git"
|
@ -0,0 +1,63 @@
|
||||
"""
|
||||
Factory for creating provider instances
|
||||
"""
|
||||
from typing import Dict, Type
|
||||
from .base import SourceProvider, DestinationProvider, ConfigurationError
|
||||
from .source.gitea import GiteaSourceProvider
|
||||
from .source.gitlab import GitLabSourceProvider
|
||||
from .destination.github import GitHubDestinationProvider
|
||||
from .destination.gitlab import GitLabDestinationProvider
|
||||
|
||||
|
||||
class ProviderFactory:
|
||||
"""Factory for creating provider instances"""
|
||||
|
||||
_source_providers: Dict[str, Type[SourceProvider]] = {
|
||||
'gitea': GiteaSourceProvider,
|
||||
'gitlab': GitLabSourceProvider,
|
||||
}
|
||||
|
||||
_destination_providers: Dict[str, Type[DestinationProvider]] = {
|
||||
'github': GitHubDestinationProvider,
|
||||
'gitlab': GitLabDestinationProvider,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create_source_provider(cls, provider_type: str, config: Dict) -> SourceProvider:
|
||||
"""Create a source provider instance"""
|
||||
if provider_type not in cls._source_providers:
|
||||
available = ', '.join(cls._source_providers.keys())
|
||||
raise ConfigurationError(f"Unknown source provider '{provider_type}'. Available: {available}")
|
||||
|
||||
provider_class = cls._source_providers[provider_type]
|
||||
return provider_class(config)
|
||||
|
||||
@classmethod
|
||||
def create_destination_provider(cls, provider_type: str, config: Dict) -> DestinationProvider:
|
||||
"""Create a destination provider instance"""
|
||||
if provider_type not in cls._destination_providers:
|
||||
available = ', '.join(cls._destination_providers.keys())
|
||||
raise ConfigurationError(f"Unknown destination provider '{provider_type}'. Available: {available}")
|
||||
|
||||
provider_class = cls._destination_providers[provider_type]
|
||||
return provider_class(config)
|
||||
|
||||
@classmethod
|
||||
def register_source_provider(cls, name: str, provider_class: Type[SourceProvider]) -> None:
|
||||
"""Register a new source provider"""
|
||||
cls._source_providers[name] = provider_class
|
||||
|
||||
@classmethod
|
||||
def register_destination_provider(cls, name: str, provider_class: Type[DestinationProvider]) -> None:
|
||||
"""Register a new destination provider"""
|
||||
cls._destination_providers[name] = provider_class
|
||||
|
||||
@classmethod
|
||||
def get_available_source_providers(cls) -> list:
|
||||
"""Get list of available source providers"""
|
||||
return list(cls._source_providers.keys())
|
||||
|
||||
@classmethod
|
||||
def get_available_destination_providers(cls) -> list:
|
||||
"""Get list of available destination providers"""
|
||||
return list(cls._destination_providers.keys())
|
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Source providers for migration tool
|
||||
"""
|
@ -0,0 +1,92 @@
|
||||
"""
|
||||
Gitea source provider implementation
|
||||
"""
|
||||
import requests
|
||||
from typing import List, Dict, Optional
|
||||
from ..base import SourceProvider, Repository, ProviderError, ConfigurationError
|
||||
|
||||
|
||||
class GiteaSourceProvider(SourceProvider):
|
||||
"""Gitea source provider implementation"""
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate Gitea-specific configuration"""
|
||||
required_keys = ['url', 'token', 'username']
|
||||
missing = [key for key in required_keys if not self.config.get(key)]
|
||||
|
||||
if missing:
|
||||
raise ConfigurationError(f"Missing Gitea configuration: {', '.join(missing)}")
|
||||
|
||||
self.base_url = self.config['url'].rstrip('/')
|
||||
self.token = self.config['token']
|
||||
self.username = self.config['username']
|
||||
|
||||
# Setup HTTP session
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'Authorization': f'token {self.token}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
|
||||
def get_user_repositories(self) -> List[Repository]:
|
||||
"""Get repositories owned by the authenticated user"""
|
||||
repositories = self.get_accessible_repositories()
|
||||
return [repo for repo in repositories if repo.owner == self.username]
|
||||
|
||||
def get_accessible_repositories(self) -> List[Repository]:
|
||||
"""Get all repositories accessible to the authenticated user"""
|
||||
url = f"{self.base_url}/api/v1/user/repos"
|
||||
params = {'limit': 100, 'page': 1}
|
||||
|
||||
all_repos = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
repos_data = response.json()
|
||||
|
||||
if not repos_data:
|
||||
break
|
||||
|
||||
repos = [self._parse_repository(repo_data) for repo_data in repos_data]
|
||||
all_repos.extend(repos)
|
||||
|
||||
params['page'] += 1
|
||||
|
||||
if len(repos_data) < params['limit']:
|
||||
break
|
||||
|
||||
except requests.RequestException as e:
|
||||
raise ProviderError(f"Failed to fetch repositories from Gitea: {e}")
|
||||
|
||||
return all_repos
|
||||
|
||||
def get_repository_info(self, owner: str, name: str) -> Optional[Repository]:
|
||||
"""Get information about a specific repository"""
|
||||
url = f"{self.base_url}/api/v1/repos/{owner}/{name}"
|
||||
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return self._parse_repository(response.json())
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
def get_authenticated_clone_url(self, repository: Repository) -> str:
|
||||
"""Get authenticated clone URL for a repository"""
|
||||
base_url = self.base_url.replace('https://', '').replace('http://', '')
|
||||
return f"https://{self.username}:{self.token}@{base_url}/{repository.owner}/{repository.name}.git"
|
||||
|
||||
def _parse_repository(self, repo_data: Dict) -> Repository:
|
||||
"""Parse repository data from Gitea API response"""
|
||||
return Repository(
|
||||
name=repo_data['name'],
|
||||
owner=repo_data['owner']['login'],
|
||||
description=repo_data.get('description', ''),
|
||||
private=repo_data.get('private', False),
|
||||
clone_url=repo_data['clone_url'],
|
||||
ssh_url=repo_data.get('ssh_url'),
|
||||
web_url=repo_data.get('html_url'),
|
||||
default_branch=repo_data.get('default_branch', 'main')
|
||||
)
|
@ -0,0 +1,111 @@
|
||||
"""
|
||||
GitLab source provider implementation (Example for extensibility)
|
||||
"""
|
||||
import requests
|
||||
from typing import List, Dict, Optional
|
||||
from ..base import SourceProvider, Repository, ProviderError, ConfigurationError
|
||||
|
||||
|
||||
class GitLabSourceProvider(SourceProvider):
|
||||
"""GitLab source provider implementation"""
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate GitLab-specific configuration"""
|
||||
required_keys = ['url', 'token', 'username']
|
||||
missing = [key for key in required_keys if not self.config.get(key)]
|
||||
|
||||
if missing:
|
||||
raise ConfigurationError(f"Missing GitLab configuration: {', '.join(missing)}")
|
||||
|
||||
self.base_url = self.config['url'].rstrip('/')
|
||||
self.token = self.config['token']
|
||||
self.username = self.config['username']
|
||||
|
||||
# Setup HTTP session
|
||||
self.session = requests.Session()
|
||||
# GitLab uses different token formats - try Private-Token first
|
||||
if self.token.startswith('glpat-'):
|
||||
# Personal Access Token
|
||||
self.session.headers.update({
|
||||
'Private-Token': self.token,
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
else:
|
||||
# OAuth token or other format
|
||||
self.session.headers.update({
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
|
||||
def get_user_repositories(self) -> List[Repository]:
|
||||
"""Get repositories owned by the authenticated user"""
|
||||
repositories = self.get_accessible_repositories()
|
||||
return [repo for repo in repositories if repo.owner == self.username]
|
||||
|
||||
def get_accessible_repositories(self) -> List[Repository]:
|
||||
"""Get all repositories accessible to the authenticated user"""
|
||||
url = f"{self.base_url}/api/v4/projects"
|
||||
params = {
|
||||
'membership': 'true',
|
||||
'per_page': 100,
|
||||
'page': 1
|
||||
}
|
||||
|
||||
all_repos = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
repos_data = response.json()
|
||||
|
||||
if not repos_data:
|
||||
break
|
||||
|
||||
repos = [self._parse_repository(repo_data) for repo_data in repos_data]
|
||||
all_repos.extend(repos)
|
||||
|
||||
# Check if there are more pages
|
||||
if 'X-Next-Page' not in response.headers:
|
||||
break
|
||||
|
||||
params['page'] += 1
|
||||
|
||||
except requests.RequestException as e:
|
||||
raise ProviderError(f"Failed to fetch repositories from GitLab: {e}")
|
||||
|
||||
return all_repos
|
||||
|
||||
def get_repository_info(self, owner: str, name: str) -> Optional[Repository]:
|
||||
"""Get information about a specific repository"""
|
||||
# GitLab uses project ID or namespace/project format
|
||||
project_path = f"{owner}/{name}"
|
||||
url = f"{self.base_url}/api/v4/projects/{requests.utils.quote(project_path, safe='')}"
|
||||
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return self._parse_repository(response.json())
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
def get_authenticated_clone_url(self, repository: Repository) -> str:
|
||||
"""Get authenticated clone URL for a repository"""
|
||||
base_url = self.base_url.replace('https://', '').replace('http://', '')
|
||||
return f"https://oauth2:{self.token}@{base_url}/{repository.owner}/{repository.name}.git"
|
||||
|
||||
def _parse_repository(self, repo_data: Dict) -> Repository:
|
||||
"""Parse repository data from GitLab API response"""
|
||||
namespace = repo_data['namespace']
|
||||
owner = namespace['path'] if namespace['kind'] == 'user' else namespace['full_path']
|
||||
|
||||
return Repository(
|
||||
name=repo_data['name'],
|
||||
owner=owner,
|
||||
description=repo_data.get('description', ''),
|
||||
private=repo_data.get('visibility') == 'private',
|
||||
clone_url=repo_data['http_url_to_repo'],
|
||||
ssh_url=repo_data.get('ssh_url_to_repo'),
|
||||
web_url=repo_data.get('web_url'),
|
||||
default_branch=repo_data.get('default_branch', 'main')
|
||||
)
|
@ -0,0 +1,3 @@
|
||||
"""
|
||||
User interface components
|
||||
"""
|
Loading…
Reference in new issue