Initial commit
This commit is contained in:
commit
309b240cdc
12 changed files with 738 additions and 0 deletions
81
internal/azure/client.go
Normal file
81
internal/azure/client.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package azure
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *resty.Client
|
||||
organization string
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Project struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"project"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
SSHURL string `json:"sshUrl"`
|
||||
WebURL string `json:"webUrl"`
|
||||
}
|
||||
|
||||
type RepositoriesResponse struct {
|
||||
Count int `json:"count"`
|
||||
Value []Repository `json:"value"`
|
||||
}
|
||||
|
||||
func NewClient(org, pat string) *Client {
|
||||
c := resty.New()
|
||||
c.SetTimeout(30 * time.Second)
|
||||
|
||||
// Azure DevOps uses Basic auth with empty username and PAT as password
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(":" + pat))
|
||||
c.SetHeader("Authorization", "Basic "+auth)
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
|
||||
return &Client{
|
||||
client: c,
|
||||
organization: org,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetRepositories() ([]Repository, error) {
|
||||
url := fmt.Sprintf("https://dev.azure.com/%s/_apis/git/repositories", c.organization)
|
||||
|
||||
var result RepositoriesResponse
|
||||
resp, err := c.client.R().
|
||||
SetQueryParam("api-version", "7.0").
|
||||
SetResult(&result).
|
||||
Get(url)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch repositories: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return nil, fmt.Errorf("Azure API returned status %d: %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
// Handle pagination if needed (continuationToken)
|
||||
// For simplicity, this example handles single page
|
||||
if result.Count != len(result.Value) {
|
||||
fmt.Printf("Warning: Repository count mismatch, expected %d got %d\n", result.Count, len(result.Value))
|
||||
}
|
||||
|
||||
return result.Value, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetAuthenticatedURL(remoteURL, pat string) string {
|
||||
// Convert https://dev.azure.com/org/project/_git/repo
|
||||
// to https://pat@dev.azure.com/org/project/_git/repo
|
||||
if len(remoteURL) > 8 && remoteURL[:8] == "https://" {
|
||||
return "https://:" + pat + "@" + remoteURL[8:]
|
||||
}
|
||||
return remoteURL
|
||||
}
|
||||
84
internal/config/config.go
Normal file
84
internal/config/config.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Azure
|
||||
AzureOrg string
|
||||
AzurePAT string
|
||||
|
||||
// Forgejo
|
||||
ForgejoURL string
|
||||
ForgejoToken string
|
||||
ForgejoOwner string
|
||||
|
||||
// Migration
|
||||
MirrorInterval string
|
||||
Concurrent int
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
var globalConfig *Config
|
||||
|
||||
func Load(path string) error {
|
||||
if err := godotenv.Load(path); err != nil {
|
||||
// Try to load from environment if file doesn't exist
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error loading env file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
concurrent := 3
|
||||
if c := os.Getenv("CONCURRENT_MIGRATIONS"); c != "" {
|
||||
if v, err := strconv.Atoi(c); err == nil {
|
||||
concurrent = v
|
||||
}
|
||||
}
|
||||
|
||||
globalConfig = &Config{
|
||||
AzureOrg: getEnvOrError("AZURE_ORG"),
|
||||
AzurePAT: getEnvOrError("AZURE_PAT"),
|
||||
ForgejoURL: getEnvOrError("FORGEJO_URL"),
|
||||
ForgejoToken: getEnvOrError("FORGEJO_TOKEN"),
|
||||
ForgejoOwner: getEnvOrError("FORGEJO_OWNER"),
|
||||
MirrorInterval: getEnv("MIRROR_INTERVAL", "8h"),
|
||||
Concurrent: concurrent,
|
||||
}
|
||||
|
||||
return validate(globalConfig)
|
||||
}
|
||||
|
||||
func Get() *Config {
|
||||
return globalConfig
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvOrError(key string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
panic(fmt.Sprintf("Required environment variable %s is not set", key))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validate(c *Config) error {
|
||||
if c.AzureOrg == "" || c.AzurePAT == "" {
|
||||
return fmt.Errorf("Azure organization and PAT must be configured")
|
||||
}
|
||||
if c.ForgejoURL == "" || c.ForgejoToken == "" {
|
||||
return fmt.Errorf("Forgejo URL and token must be configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
135
internal/forgejo/client.go
Normal file
135
internal/forgejo/client.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package forgejo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *resty.Client
|
||||
token string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type MigrateRequest struct {
|
||||
CloneAddr string `json:"clone_addr"`
|
||||
AuthUsername string `json:"auth_username"`
|
||||
AuthPassword string `json:"auth_password"`
|
||||
UID int `json:"uid"`
|
||||
RepoName string `json:"repo_name"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
Mirror bool `json:"mirror"`
|
||||
LFS bool `json:"lfs"`
|
||||
Interval string `json:"interval"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Private bool `json:"private"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
Mirror bool `json:"mirror"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
UserName string `json:"login"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Message string `json:"message"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
c := resty.New()
|
||||
c.SetTimeout(60 * time.Second) // Longer timeout for migration
|
||||
c.SetHeader("Authorization", "token "+token)
|
||||
c.SetHeader("Content-Type", "application/json")
|
||||
c.SetBaseURL(baseURL + "/api/v1")
|
||||
|
||||
return &Client{
|
||||
client: c,
|
||||
token: token,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetUser(username string) (*User, error) {
|
||||
var user User
|
||||
resp, err := c.client.R().
|
||||
SetResult(&user).
|
||||
Get("/users/" + username)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get user: status %d - %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (c *Client) RepositoryExists(owner, name string) (bool, error) {
|
||||
resp, err := c.client.R().
|
||||
Head(fmt.Sprintf("/repos/%s/%s", owner, name))
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if resp.StatusCode() == http.StatusOK {
|
||||
return true, nil
|
||||
} else if resp.StatusCode() == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode())
|
||||
}
|
||||
|
||||
func (c *Client) CreateMirror(req MigrateRequest) (*Repository, error) {
|
||||
var repo Repository
|
||||
var apiErr APIError
|
||||
|
||||
resp, err := c.client.R().
|
||||
SetBody(req).
|
||||
SetResult(&repo).
|
||||
SetError(&apiErr).
|
||||
Post("/repos/migrate")
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusCreated && resp.StatusCode() != http.StatusOK {
|
||||
if apiErr.Message != "" {
|
||||
return nil, fmt.Errorf("Forgejo API error: %s (status %d)", apiErr.Message, resp.StatusCode())
|
||||
}
|
||||
return nil, fmt.Errorf("Forgejo API returned status %d: %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
return &repo, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteRepository(owner, name string) error {
|
||||
resp, err := c.client.R().
|
||||
Delete(fmt.Sprintf("/repos/%s/%s", owner, name))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode() != http.StatusNoContent && resp.StatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("failed to delete repository: status %d", resp.StatusCode())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
204
internal/migrator/migrator.go
Normal file
204
internal/migrator/migrator.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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/forgejo"
|
||||
)
|
||||
|
||||
type Migrator struct {
|
||||
config *config.Config
|
||||
azureClient *azure.Client
|
||||
forgejoClient *forgejo.Client
|
||||
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) 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)
|
||||
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
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.ForgejoURL = createdRepo.HTMLURL
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *Migrator) addError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.errors = append(m.errors, err)
|
||||
}
|
||||
|
||||
func truncate(s string, length int) string {
|
||||
if len(s) <= length {
|
||||
return s
|
||||
}
|
||||
return s[:length-3] + "..."
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue