350 lines
10 KiB
Go
350 lines
10 KiB
Go
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] + "..."
|
|
}
|