package migrator import ( "fmt" "strings" "sync" "vix.ro/sonix/azure_migrate/internal/azure" "vix.ro/sonix/azure_migrate/internal/config" "vix.ro/sonix/azure_migrate/internal/converter" "vix.ro/sonix/azure_migrate/internal/forgejo" ) type AzureClient interface { GetRepositories() ([]azure.Repository, error) GetAuthenticatedURL(remoteURL, pat string) string GetBuildDefinitions(project string) ([]azure.BuildDefinition, error) GetPipelineVariables(project string, definitionID int) (map[string]string, error) GetPipelineContent(repoURL, branch, filePath string) (string, error) } type ForgejoClient interface { GetUser(username string) (*forgejo.User, error) RepositoryExists(owner, name string) (bool, error) CreateMirror(req forgejo.MigrateRequest) (*forgejo.Repository, error) CreateFile(owner, repo, path, content, message string) error CreateSecret(owner, repo, name, data string) error } type Migrator struct { config *config.Config azureClient AzureClient forgejoClient ForgejoClient errors []error mu sync.Mutex } type MigrationResult struct { RepoName string Success bool Error error ForgejoURL string } func New(cfg *config.Config) (*Migrator, error) { azClient := azure.NewClient(cfg.AzureOrg, cfg.AzurePAT) fjClient := forgejo.NewClient(cfg.ForgejoURL, cfg.ForgejoToken) return &Migrator{ config: cfg, azureClient: azClient, forgejoClient: fjClient, errors: make([]error, 0), }, nil } func (m *Migrator) ListRepositories() error { fmt.Println("Fetching repositories from Azure DevOps...") repos, err := m.azureClient.GetRepositories() if err != nil { return fmt.Errorf("failed to list repositories: %w", err) } fmt.Printf("\nFound %d repositories:\n", len(repos)) fmt.Println(strings.Repeat("-", 80)) fmt.Printf("%-40s %-20s %s\n", "NAME", "PROJECT", "URL") fmt.Println(strings.Repeat("-", 80)) for _, repo := range repos { fmt.Printf("%-40s %-20s %s\n", truncate(repo.Name, 40), truncate(repo.Project.Name, 20), repo.WebURL) } return nil } func (m *Migrator) Run() error { fmt.Println("Starting migration process...") if m.config.DryRun { fmt.Println("*** DRY RUN MODE - No changes will be made ***") } // Get Azure repos repos, err := m.azureClient.GetRepositories() if err != nil { return fmt.Errorf("failed to fetch Azure repositories: %w", err) } fmt.Printf("Found %d repositories to process\n\n", len(repos)) // Get Forgejo user/org ID user, err := m.forgejoClient.GetUser(m.config.ForgejoOwner) if err != nil { return fmt.Errorf("failed to get Forgejo user/org '%s': %w", m.config.ForgejoOwner, err) } // Process with concurrency semaphore := make(chan struct{}, m.config.Concurrent) var wg sync.WaitGroup results := make(chan MigrationResult, len(repos)) for _, repo := range repos { wg.Add(1) semaphore <- struct{}{} // Acquire go func(r azure.Repository) { defer wg.Done() defer func() { <-semaphore }() // Release result := m.migrateRepository(r, user.ID) results <- result if result.Error != nil { m.addError(result.Error) } }(repo) } // Close results channel when done go func() { wg.Wait() close(results) }() // Print results var successCount, failCount int for result := range results { if result.Success { successCount++ fmt.Printf("✓ Migrated: %s -> %s\n", result.RepoName, result.ForgejoURL) } else { failCount++ fmt.Printf("✗ Failed: %s - Error: %v\n", result.RepoName, result.Error) } } // Summary fmt.Println(strings.Repeat("=", 80)) fmt.Printf("Migration Complete: %d succeeded, %d failed\n", successCount, failCount) if len(m.errors) > 0 { fmt.Println("\nDetailed errors:") for i, err := range m.errors { fmt.Printf("%d. %v\n", i+1, err) } return fmt.Errorf("migration completed with %d errors", len(m.errors)) } return nil } func (m *Migrator) addError(err error) { m.mu.Lock() defer m.mu.Unlock() m.errors = append(m.errors, err) } func (m *Migrator) migrateRepository(repo azure.Repository, uid int64) MigrationResult { result := MigrationResult{ RepoName: repo.Name, } // Check if already exists exists, err := m.forgejoClient.RepositoryExists(m.config.ForgejoOwner, repo.Name) if err != nil { result.Error = fmt.Errorf("failed to check if repository exists: %w", err) return result } if exists { result.Error = fmt.Errorf("repository already exists in Forgejo") return result } // Prepare authenticated URL for Azure cloneURL := m.azureClient.GetAuthenticatedURL(repo.RemoteURL, m.config.AzurePAT) // Prepare migration request req := forgejo.MigrateRequest{ CloneAddr: cloneURL, AuthUsername: "", // Azure uses PAT as password with empty username AuthPassword: m.config.AzurePAT, UID: int(uid), RepoName: repo.Name, RepoOwner: m.config.ForgejoOwner, Mirror: true, LFS: true, // Enable LFS support Interval: m.config.MirrorInterval, Description: fmt.Sprintf("Mirrored from Azure DevOps: %s", repo.WebURL), Private: true, // Default to private, adjust as needed } if m.config.DryRun { result.Success = true result.ForgejoURL = fmt.Sprintf("%s/%s/%s", m.config.ForgejoURL, m.config.ForgejoOwner, repo.Name) // Show what would be migrated for pipelines fmt.Printf(" [DRY-RUN] Would migrate pipeline for %s\n", repo.Name) return result } // Create mirror createdRepo, err := m.forgejoClient.CreateMirror(req) if err != nil { // Provide detailed error context result.Error = fmt.Errorf("failed to create mirror for '%s' (project: %s): %w", repo.Name, repo.Project.Name, err) return result } // Migrate pipelines after repo creation if err := m.migratePipeline(repo, createdRepo); err != nil { // Log warning but don't fail the whole migration fmt.Printf(" Warning: Pipeline migration failed for %s: %v\n", repo.Name, err) } result.Success = true result.ForgejoURL = createdRepo.HTMLURL return result } func (m *Migrator) migratePipeline(repo azure.Repository, forgejoRepo *forgejo.Repository) error { fmt.Printf(" Checking for pipelines in %s...\n", repo.Name) // Get build definitions for this repo's project defs, err := m.azureClient.GetBuildDefinitions(repo.Project.Name) if err != nil { return fmt.Errorf("failed to get pipeline definitions: %w", err) } // Find pipelines associated with this repository for _, def := range defs { // Check if this pipeline belongs to the current repo if !strings.Contains(def.Repository.URL, repo.Name) { continue } fmt.Printf(" Found pipeline: %s (ID: %d)\n", def.Name, def.ID) // Get variables vars, err := m.azureClient.GetPipelineVariables(repo.Project.Name, def.ID) if err != nil { fmt.Printf(" Warning: Could not get variables: %v\n", err) } // Migrate variables as secrets for key, value := range vars { secretName := "AZURE_" + strings.ToUpper(key) if err := m.forgejoClient.CreateSecret(m.config.ForgejoOwner, repo.Name, secretName, value); err != nil { fmt.Printf(" Warning: Failed to create secret %s: %v\n", secretName, err) } else { fmt.Printf(" Migrated secret: %s\n", secretName) } } // Try to get YAML content var yamlContent string if def.Process.YamlFilename != "" { // YAML pipeline content, err := m.azureClient.GetPipelineContent(repo.RemoteURL, "master", def.Process.YamlFilename) if err != nil { content, err = m.azureClient.GetPipelineContent(repo.RemoteURL, "main", def.Process.YamlFilename) } if err == nil { yamlContent = content } } if yamlContent != "" { // Convert YAML conv := converter.New() forgejoYAML, err := conv.ConvertAzureToForgejo(yamlContent, repo.Name) if err != nil { return fmt.Errorf("failed to convert pipeline YAML: %w", err) } // Create workflow file workflowPath := fmt.Sprintf(".forgejo/workflows/%s.yml", strings.ReplaceAll(def.Name, " ", "-")) // Also create .github/workflows for compatibility githubPath := fmt.Sprintf(".github/workflows/%s.yml", strings.ReplaceAll(def.Name, " ", "-")) commitMsg := fmt.Sprintf("Migrate Azure Pipeline: %s", def.Name) // Try Forgejo path first err = m.forgejoClient.CreateFile(m.config.ForgejoOwner, repo.Name, workflowPath, forgejoYAML, commitMsg) if err != nil { // If .forgejo doesn't work (older versions), try .github err = m.forgejoClient.CreateFile(m.config.ForgejoOwner, repo.Name, githubPath, forgejoYAML, commitMsg) } if err != nil { return fmt.Errorf("failed to create workflow file: %w", err) } fmt.Printf(" Created workflow: %s\n", workflowPath) // Print conversion warnings for _, warning := range conv.Warnings { fmt.Printf(" ⚠️ %s\n", warning) } } else { // Classic pipeline (UI-based) - create placeholder placeholder := m.generatePlaceholderWorkflow(def) workflowPath := fmt.Sprintf(".forgejo/workflows/%s-placeholder.yml", def.ID) err = m.forgejoClient.CreateFile(m.config.ForgejoOwner, repo.Name, workflowPath, placeholder, "Placeholder for classic Azure Pipeline") if err != nil { fmt.Printf(" Warning: Could not create placeholder: %v\n", err) } fmt.Printf(" Created placeholder for classic pipeline (manual conversion required)\n") } } return nil } func (m *Migrator) generatePlaceholderWorkflow(def azure.BuildDefinition) string { return fmt.Sprintf(`# Placeholder for Classic Azure Pipeline: %s # Pipeline ID: %d # # This pipeline was defined in Azure DevOps UI (Classic) and cannot be automatically converted. # Please manually recreate this workflow using Forgejo Actions syntax. # # Variables defined in this pipeline: name: Azure Pipeline Placeholder - %s on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: TODO - Add steps from Azure Pipeline run: | echo "Original Azure Pipeline: %s" echo "Review the Azure DevOps UI for this pipeline's tasks and recreate them here" `, def.Name, def.ID, def.Name, def.Name) } func truncate(s string, length int) string { if len(s) <= length { return s } return s[:length-3] + "..." }