package converter import ( "fmt" "strings" "gopkg.in/yaml.v3" ) // AzurePipeline represents the structure of azure-pipelines.yml type AzurePipeline struct { Trigger interface{} `yaml:"trigger"` PR interface{} `yaml:"pr"` Pool interface{} `yaml:"pool"` Variables map[string]interface{} `yaml:"variables"` Stages []AzureStage `yaml:"stages"` Jobs []AzureJob `yaml:"jobs"` Steps []AzureStep `yaml:"steps"` } type AzureStage struct { Stage string `yaml:"stage"` Jobs []AzureJob `yaml:"jobs"` } type AzureJob struct { Job string `yaml:"job"` Pool interface{} `yaml:"pool"` Steps []AzureStep `yaml:"steps"` } type AzureStep struct { Task string `yaml:"task,omitempty"` Script string `yaml:"script,omitempty"` Powershell string `yaml:"powershell,omitempty"` Bash string `yaml:"bash,omitempty"` DisplayName string `yaml:"displayName,omitempty"` Inputs map[string]string `yaml:"inputs,omitempty"` Env map[string]string `yaml:"env,omitempty"` } // ForgejoWorkflow represents GitHub Actions compatible workflow type ForgejoWorkflow struct { Name string `yaml:"name"` On interface{} `yaml:"on"` Jobs map[string]ForgejoJob `yaml:"jobs"` Env map[string]string `yaml:"env,omitempty"` } type ForgejoJob struct { RunsOn string `yaml:"runs-on"` Steps []ForgejoStep `yaml:"steps"` Env map[string]string `yaml:"env,omitempty"` Needs []string `yaml:"needs,omitempty"` Timeout int `yaml:"timeout-minutes,omitempty"` } type ForgejoStep struct { Uses string `yaml:"uses,omitempty"` Name string `yaml:"name,omitempty"` Run string `yaml:"run,omitempty"` With map[string]string `yaml:"with,omitempty"` Env map[string]string `yaml:"env,omitempty"` } // Converter handles the transformation type Converter struct { Warnings []string } func New() *Converter { return &Converter{ Warnings: make([]string, 0), } } func (c *Converter) ConvertAzureToForgejo(azureYAML string, repoName string) (string, error) { var azure AzurePipeline if err := yaml.Unmarshal([]byte(azureYAML), &azure); err != nil { return "", fmt.Errorf("failed to parse azure-pipelines.yml: %w", err) } forgejo := ForgejoWorkflow{ Name: fmt.Sprintf("Migrated Azure Pipeline - %s", repoName), Jobs: make(map[string]ForgejoJob), Env: make(map[string]string), } // Convert trigger forgejo.On = c.convertTrigger(azure.Trigger) // Convert variables for k, v := range azure.Variables { if str, ok := v.(string); ok { forgejo.Env[k] = str } else if m, ok := v.(map[string]interface{}); ok { if val, exists := m["value"]; exists { forgejo.Env[k] = fmt.Sprintf("%v", val) } } } // Handle different structures: steps at root, jobs at root, or stages if len(azure.Steps) > 0 { // Simple pipeline with just steps job := c.convertStepsToJob(azure.Steps, azure.Pool) forgejo.Jobs["build"] = job } else if len(azure.Jobs) > 0 { // Multiple jobs for _, job := range azure.Jobs { fjJob := c.convertStepsToJob(job.Steps, job.Pool) fjJob.Env = job.Steps[0].Env // Simplified forgejo.Jobs[job.Job] = fjJob } } else if len(azure.Stages) > 0 { // Stages become job dependencies prevJob := "" for _, stage := range azure.Stages { for _, job := range stage.Jobs { fjJob := c.convertStepsToJob(job.Steps, job.Pool) if prevJob != "" { fjJob.Needs = []string{prevJob} } jobName := fmt.Sprintf("%s_%s", stage.Stage, job.Job) forgejo.Jobs[jobName] = fjJob prevJob = jobName } } } // Serialize out, err := yaml.Marshal(forgejo) if err != nil { return "", err } // Add header comment header := fmt.Sprintf("# Migrated from Azure DevOps Pipeline\n# Repository: %s\n# WARNING: Review this file carefully. Azure-specific tasks require manual conversion.\n\n", repoName) return header + string(out), nil } func (c *Converter) convertTrigger(trigger interface{}) interface{} { // Simple conversion: Azure trigger -> GitHub on.push if trigger == nil { return map[string]interface{}{ "push": map[string]interface{}{ "branches": []string{"main", "master"}, }, } } // If trigger is a list (branches), convert to push.branches if branches, ok := trigger.([]interface{}); ok { return map[string]interface{}{ "push": map[string]interface{}{ "branches": branches, }, "pull_request": map[string]interface{}{ "branches": branches, }, } } return trigger } func (c *Converter) convertStepsToJob(steps []AzureStep, pool interface{}) ForgejoJob { job := ForgejoJob{ RunsOn: c.convertPool(pool), Steps: make([]ForgejoStep, 0), } // Add checkout step job.Steps = append(job.Steps, ForgejoStep{ Uses: "actions/checkout@v3", Name: "Checkout", }) for _, step := range steps { fjStep := ForgejoStep{} if step.Script != "" { fjStep.Run = step.Script fjStep.Name = step.DisplayName } else if step.Bash != "" { fjStep.Run = step.Bash fjStep.Name = step.DisplayName fjStep.Run = "bash -c '" + strings.ReplaceAll(step.Bash, "'", "'\\''") + "'" } else if step.Powershell != "" { fjStep.Run = step.Powershell fjStep.Name = step.DisplayName c.Warnings = append(c.Warnings, fmt.Sprintf("PowerShell script detected: %s", step.DisplayName)) } else if step.Task != "" { // Convert known Azure tasks to actions fjStep = c.convertAzureTask(step) if fjStep.Uses == "" && fjStep.Run == "" { fjStep.Run = fmt.Sprintf("# TODO: Convert Azure Task '%s' manually", step.Task) fjStep.Name = fmt.Sprintf("[TODO] %s", step.DisplayName) c.Warnings = append(c.Warnings, fmt.Sprintf("Cannot automatically convert task: %s", step.Task)) } } if fjStep.Name == "" { fjStep.Name = step.DisplayName } job.Steps = append(job.Steps, fjStep) } return job } func (c *Converter) convertPool(pool interface{}) string { if pool == nil { return "ubuntu-latest" } // Pool can be a string or a map with vmImage if str, ok := pool.(string); ok { return c.mapAgent(str) } if m, ok := pool.(map[string]interface{}); ok { if vm, exists := m["vmImage"]; exists { return c.mapAgent(vm.(string)) } if name, exists := m["name"]; exists { // Self-hosted pool return fmt.Sprintf("self-hosted,%s", name) } } return "ubuntu-latest" } func (c *Converter) mapAgent(azureImage string) string { // Map Azure VM images to Forgejo/Gitea runner labels mappings := map[string]string{ "ubuntu-latest": "ubuntu-latest", "ubuntu-20.04": "ubuntu-20.04", "ubuntu-22.04": "ubuntu-22.04", "windows-latest": "windows-latest", "macOS-latest": "macos-latest", "macOS-11": "macos-11", } if mapped, ok := mappings[azureImage]; ok { return mapped } c.Warnings = append(c.Warnings, fmt.Sprintf("Unknown VM image '%s', defaulting to ubuntu-latest", azureImage)) return "ubuntu-latest" } func (c *Converter) convertAzureTask(step AzureStep) ForgejoStep { // Map common Azure tasks to GitHub Actions switch step.Task { case "AzureCLI@2", "AzureCLI@1": return ForgejoStep{ Name: step.DisplayName, Run: "# Azure CLI step - configure azure/login action manually\n# az login ...\n" + step.Inputs["script"], } case "Docker@2": return ForgejoStep{ Uses: "docker/build-push-action@v4", Name: step.DisplayName, With: map[string]string{ "push": step.Inputs["push"], "tags": step.Inputs["containerRegistry"] + "/" + step.Inputs["repository"], }, } case "PublishTestResults@2": return ForgejoStep{ Uses: "actions/upload-artifact@v3", Name: step.DisplayName, With: map[string]string{ "name": "test-results", "path": step.Inputs["testResultsFiles"], }, } case "CopyFiles@2": return ForgejoStep{ Run: fmt.Sprintf("cp -r %s %s", step.Inputs["SourceFolder"], step.Inputs["TargetFolder"]), Name: step.DisplayName, } case "ArchiveFiles@2": return ForgejoStep{ Run: fmt.Sprintf("zip -r %s %s", step.Inputs["archiveFile"], step.Inputs["rootFolderOrFile"]), Name: step.DisplayName, } default: return ForgejoStep{} } }