azure_migrate/internal/converter/pipeline.go

295 lines
8.1 KiB
Go

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{}
}
}