From af02e61b3be27584bea8347a6dccf285e8346ba9 Mon Sep 17 00:00:00 2001 From: eyedeekay Date: Sun, 20 Apr 2025 21:18:53 -0400 Subject: [PATCH] initial commit --- _env.example | 26 ++++ cmd/migrate/main.go | 109 ++++++++++++++ config/config.go | 72 ++++++++++ config/env_loader.go | 56 ++++++++ gitea/action.go | 111 +++++++++++++++ gitea/client.go | 235 ++++++++++++++++++++++++++++++ gitlab/client.go | 273 +++++++++++++++++++++++++++++++++++ go.mod | 22 +++ go.sum | 59 ++++++++ migration/collaborators.go | 93 ++++++++++++ migration/comments.go | 108 ++++++++++++++ migration/groups.go | 147 +++++++++++++++++++ migration/issues.go | 153 ++++++++++++++++++++ migration/labels.go | 71 ++++++++++ migration/manager.go | 283 +++++++++++++++++++++++++++++++++++++ migration/milestones.go | 112 +++++++++++++++ migration/repositories.go | 188 ++++++++++++++++++++++++ migration/state.go | 189 +++++++++++++++++++++++++ migration/users.go | 203 ++++++++++++++++++++++++++ utils/logging.go | 45 ++++++ utils/mentions.go | 51 +++++++ utils/naming.go | 67 +++++++++ 22 files changed, 2673 insertions(+) create mode 100644 _env.example create mode 100644 cmd/migrate/main.go create mode 100644 config/config.go create mode 100644 config/env_loader.go create mode 100644 gitea/action.go create mode 100644 gitea/client.go create mode 100644 gitlab/client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 migration/collaborators.go create mode 100644 migration/comments.go create mode 100644 migration/groups.go create mode 100644 migration/issues.go create mode 100644 migration/labels.go create mode 100644 migration/manager.go create mode 100644 migration/milestones.go create mode 100644 migration/repositories.go create mode 100644 migration/state.go create mode 100644 migration/users.go create mode 100644 utils/logging.go create mode 100644 utils/mentions.go create mode 100644 utils/naming.go diff --git a/_env.example b/_env.example new file mode 100644 index 0000000..25abb37 --- /dev/null +++ b/_env.example @@ -0,0 +1,26 @@ +# .env + +# GitLab Configuration +GITLAB_URL=https://gitlab.example.com +GITLAB_TOKEN=glpat-XXXXXXXXXXXXXXXXXXXX +GITLAB_ADMIN_USER=admin +GITLAB_ADMIN_PASS=your_secure_password + +# Gitea Configuration +GITEA_URL=https://gitea.example.com +GITEA_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# Migration Options +MIGRATION_STATE_FILE=migration_state.json +RESUME_MIGRATION=true + +# Database connection for action import (optional, only needed for gitea_import_actions.py conversion) +DB_HOST=localhost +DB_USER=gitea +DB_PASS=password +DB_NAME=gitea + +# User/Repository IDs for commit import (optional, only needed for gitea_import_actions.py conversion) +USERID=1 +REPOID=1 +BRANCH=master \ No newline at end of file diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..608c855 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,109 @@ +// main.go + +// Package main provides the entry point for the GitLab to Gitea migration tool +package main + +import ( + "fmt" + "os" + + "github.com/go-i2p/gitlab-to-gitea/config" + "github.com/go-i2p/gitlab-to-gitea/gitea" + "github.com/go-i2p/gitlab-to-gitea/gitlab" + "github.com/go-i2p/gitlab-to-gitea/migration" + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +const ( + scriptVersion = "1.0.0" +) + +func main() { + utils.PrintHeader("---=== GitLab to Gitea migration ===---") + fmt.Printf("Version: %s\n\n", scriptVersion) + + // Load env file + err := config.LoadEnv() + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to load environment variables: %v", err)) + os.Exit(1) + } + + // Load configuration + cfg, err := config.LoadConfig() + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to load configuration: %v", err)) + os.Exit(1) + } + + // Initialize clients + gitlabClient, err := gitlab.NewClient(cfg.GitLabURL, cfg.GitLabToken) + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to connect to GitLab: %v", err)) + os.Exit(1) + } + + giteaClient, err := gitea.NewClient(cfg.GiteaURL, cfg.GiteaToken) + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to connect to Gitea: %v", err)) + os.Exit(1) + } + + // Verify connections + glVersion, err := gitlabClient.GetVersion() + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to get GitLab version: %v", err)) + os.Exit(1) + } + utils.PrintInfo(fmt.Sprintf("Connected to GitLab, version: %s", glVersion)) + + gtVersion, err := giteaClient.GetVersion() + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to get Gitea version: %v", err)) + os.Exit(1) + } + utils.PrintInfo(fmt.Sprintf("Connected to Gitea, version: %s", gtVersion)) + + // Initialize migration manager + migrationManager := migration.NewManager(gitlabClient, giteaClient, cfg) + + // Perform migration + migrateWithErrorHandling(migrationManager) +} + +func migrateWithErrorHandling(migrator *migration.Manager) { + defer func() { + if r := recover(); r != nil { + utils.PrintError(fmt.Sprintf("Migration failed with panic: %v", r)) + utils.PrintWarning("You can resume the migration later using the state file.") + } + }() + + errCount := 0 + var err error + + utils.PrintHeader("Starting users and groups migration...") + // Import users and groups + err = migrator.ImportUsersGroups() + if err != nil { + errCount++ + utils.PrintError(fmt.Sprintf("Error during user and group migration: %v", err)) + } + utils.PrintSuccess("Completed users and groups migration") + + utils.PrintHeader("Starting projects migration...") + // Import projects + err = migrator.ImportProjects() + if err != nil { + errCount++ + utils.PrintError(fmt.Sprintf("Error during project migration: %v", err)) + } + utils.PrintSuccess("Completed projects migration") + + fmt.Println() + if errCount == 0 { + utils.PrintSuccess("Migration finished with no errors!") + } else { + utils.PrintError(fmt.Sprintf("Migration finished with %d errors!", errCount)) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..b1dc3c4 --- /dev/null +++ b/config/config.go @@ -0,0 +1,72 @@ +// config.go + +// Package config handles application configuration through environment variables +package config + +import ( + "errors" + "os" + "strconv" +) + +// Config holds all configuration parameters for the migration +type Config struct { + GitLabURL string + GitLabToken string + GitLabAdminUser string + GitLabAdminPass string + GiteaURL string + GiteaToken string + MigrationStateFile string + ResumeMigration bool +} + +// LoadConfig loads configuration from environment variables +func LoadConfig() (*Config, error) { + gitlabURL := os.Getenv("GITLAB_URL") + if gitlabURL == "" { + return nil, errors.New("GITLAB_URL environment variable is required") + } + + gitlabToken := os.Getenv("GITLAB_TOKEN") + if gitlabToken == "" { + return nil, errors.New("GITLAB_TOKEN environment variable is required") + } + + giteaURL := os.Getenv("GITEA_URL") + if giteaURL == "" { + return nil, errors.New("GITEA_URL environment variable is required") + } + + giteaToken := os.Getenv("GITEA_TOKEN") + if giteaToken == "" { + return nil, errors.New("GITEA_TOKEN environment variable is required") + } + + // Optional values with defaults + migrationStateFile := os.Getenv("MIGRATION_STATE_FILE") + if migrationStateFile == "" { + migrationStateFile = "migration_state.json" + } + + resumeMigrationStr := os.Getenv("RESUME_MIGRATION") + resumeMigration := true // default + if resumeMigrationStr != "" { + var err error + resumeMigration, err = strconv.ParseBool(resumeMigrationStr) + if err != nil { + return nil, errors.New("RESUME_MIGRATION must be a boolean value") + } + } + + return &Config{ + GitLabURL: gitlabURL, + GitLabToken: gitlabToken, + GitLabAdminUser: os.Getenv("GITLAB_ADMIN_USER"), + GitLabAdminPass: os.Getenv("GITLAB_ADMIN_PASS"), + GiteaURL: giteaURL, + GiteaToken: giteaToken, + MigrationStateFile: migrationStateFile, + ResumeMigration: resumeMigration, + }, nil +} diff --git a/config/env_loader.go b/config/env_loader.go new file mode 100644 index 0000000..5268c7e --- /dev/null +++ b/config/env_loader.go @@ -0,0 +1,56 @@ +// env_loader.go + +// Package config handles application configuration through environment variables +package config + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +// LoadEnv loads environment variables from .env file if present +func LoadEnv() error { + // Check if .env file exists + if _, err := os.Stat(".env"); os.IsNotExist(err) { + // No .env file, that's fine - we'll use environment variables directly + return nil + } + + // Load from .env file + err := godotenv.Load() + if err != nil { + return fmt.Errorf("error loading .env file: %w", err) + } + + return nil +} + +// MustLoadEnv loads environment variables and exits on error +func MustLoadEnv() { + if err := LoadEnv(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to load environment: %v\n", err) + os.Exit(1) + } +} + +// SetEnvWithDefault sets an environment variable if it's not already set +func SetEnvWithDefault(key, defaultValue string) { + if _, exists := os.LookupEnv(key); !exists { + os.Setenv(key, defaultValue) + } +} + +// SetEnvDefaults sets default values for environment variables +// that are not already set +func SetEnvDefaults() { + defaults := map[string]string{ + "MIGRATION_STATE_FILE": "migration_state.json", + "RESUME_MIGRATION": "true", + } + + for key, value := range defaults { + SetEnvWithDefault(key, value) + } +} diff --git a/gitea/action.go b/gitea/action.go new file mode 100644 index 0000000..b6e6bf4 --- /dev/null +++ b/gitea/action.go @@ -0,0 +1,111 @@ +// action.go + +// Package gitea provides functionality for working with Gitea actions +package gitea + +import ( + "bufio" + "database/sql" + "fmt" + "os" + "strconv" + "strings" + + _ "github.com/go-sql-driver/mysql" +) + +// ImportCommitActions imports Git commits to Gitea action database +func ImportCommitActions(logFilePath string) error { + // Get required environment variables + userID := getEnvInt("USERID", 1) + repoID := getEnvInt("REPOID", 1) + branch := getEnvWithDefault("BRANCH", "master") + + // Database connection + dbHost := getEnvWithDefault("DB_HOST", "localhost") + dbUser := getEnvWithDefault("DB_USER", "user") + dbPass := getEnvWithDefault("DB_PASS", "password") + dbName := getEnvWithDefault("DB_NAME", "gitea") + + // Connect to database + db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s", dbUser, dbPass, dbHost, dbName)) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer db.Close() + + // Prepare SQL statement + stmt, err := db.Prepare("INSERT INTO action (user_id, op_type, act_user_id, repo_id, comment_id, ref_name, is_private, created_unix) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") + if err != nil { + return fmt.Errorf("failed to prepare SQL statement: %w", err) + } + defer stmt.Close() + + // Open commit log file + file, err := os.Open(logFilePath) + if err != nil { + return fmt.Errorf("failed to open commit log file: %w", err) + } + defer file.Close() + + // Process each line + var importCount int + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + parts := strings.SplitN(line, ",", 3) + if len(parts) < 3 { + return fmt.Errorf("invalid line format: %s", line) + } + + // Parse commit timestamp + timestamp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return fmt.Errorf("failed to parse timestamp '%s': %w", parts[1], err) + } + + // Insert action record + _, err = stmt.Exec( + userID, // user_id + 5, // op_type - 5 means commit + userID, // act_user_id + repoID, // repo_id + 0, // comment_id + branch, // ref_name + 1, // is_private + timestamp, // created_unix + ) + if err != nil { + return fmt.Errorf("failed to insert action record: %w", err) + } + importCount++ + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading commit log file: %w", err) + } + + fmt.Printf("%d actions inserted.\n", importCount) + return nil +} + +// Helper functions for environment variables +func getEnvInt(key string, defaultValue int) int { + if val, exists := os.LookupEnv(key); exists { + if intVal, err := strconv.Atoi(val); err == nil { + return intVal + } + } + return defaultValue +} + +func getEnvWithDefault(key, defaultValue string) string { + if val, exists := os.LookupEnv(key); exists && val != "" { + return val + } + return defaultValue +} diff --git a/gitea/client.go b/gitea/client.go new file mode 100644 index 0000000..07905ce --- /dev/null +++ b/gitea/client.go @@ -0,0 +1,235 @@ +// client.go + +// Package gitea provides a client for interacting with the Gitea API +package gitea + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client handles communication with the Gitea API +type Client struct { + baseURL *url.URL + httpClient *http.Client + token string +} + +// VersionResponse represents the Gitea version response +type VersionResponse struct { + Version string `json:"version"` +} + +// FetchCSRFToken retrieves a CSRF token from Gitea +func (c *Client) FetchCSRFToken() error { + // Create a request to the Gitea login page to get a CSRF token + u, err := c.baseURL.Parse("/") + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + // Use a normal HTTP client without our custom transport for this request + // to avoid circular dependency (we need the token for the transport) + httpClient := &http.Client{} + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Extract CSRF token from Set-Cookie header + for _, cookie := range resp.Cookies() { + if cookie.Name == "_csrf" { + // Update the transport with the CSRF token + transport, ok := c.httpClient.Transport.(*CSRFTokenTransport) + if ok { + transport.CSRFToken = cookie.Value + return nil + } + return fmt.Errorf("transport is not a CSRFTokenTransport") + } + } + + // If we couldn't find a CSRF token, try to extract it from the response body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Look for in the HTML + body := string(bodyBytes) + csrfMetaStart := strings.Index(body, `= 300 { + return resp, fmt.Errorf("API returned error: %s - %s", resp.Status, string(bodyBytes)) + } + + if result != nil && len(bodyBytes) > 0 { + if err := json.NewDecoder(bytes.NewBuffer(bodyBytes)).Decode(result); err != nil { + return resp, fmt.Errorf("failed to decode response: %w", err) + } + } + + return resp, nil +} diff --git a/gitlab/client.go b/gitlab/client.go new file mode 100644 index 0000000..9c2372a --- /dev/null +++ b/gitlab/client.go @@ -0,0 +1,273 @@ +// client.go + +// Package gitlab provides a client for interacting with the GitLab API +package gitlab + +import ( + "fmt" + + "github.com/xanzy/go-gitlab" +) + +// Client wraps the GitLab client for custom functionality +type Client struct { + client *gitlab.Client +} + +// NewClient creates a new GitLab client with the provided URL and token +func NewClient(url, token string) (*Client, error) { + client, err := gitlab.NewClient(token, gitlab.WithBaseURL(url)) + if err != nil { + return nil, fmt.Errorf("failed to create GitLab client: %w", err) + } + + return &Client{ + client: client, + }, nil +} + +// GetVersion retrieves the GitLab version +func (c *Client) GetVersion() (string, error) { + v, _, err := c.client.Version.GetVersion() + if err != nil { + return "", fmt.Errorf("failed to get GitLab version: %w", err) + } + return v.Version, nil +} + +// GetCurrentUser retrieves information about the current authenticated user +func (c *Client) GetCurrentUser() (*gitlab.User, error) { + user, _, err := c.client.Users.CurrentUser() + if err != nil { + return nil, fmt.Errorf("failed to get current user: %w", err) + } + return user, nil +} + +// ListUsers returns all users in the GitLab instance +func (c *Client) ListUsers() ([]*gitlab.User, error) { + opts := &gitlab.ListUsersOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allUsers []*gitlab.User + for { + users, resp, err := c.client.Users.ListUsers(opts) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + allUsers = append(allUsers, users...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allUsers, nil +} + +// ListGroups returns all groups in the GitLab instance +func (c *Client) ListGroups() ([]*gitlab.Group, error) { + opts := &gitlab.ListGroupsOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allGroups []*gitlab.Group + for { + groups, resp, err := c.client.Groups.ListGroups(opts) + if err != nil { + return nil, fmt.Errorf("failed to list groups: %w", err) + } + allGroups = append(allGroups, groups...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allGroups, nil +} + +// ListProjects returns all projects in the GitLab instance +func (c *Client) ListProjects() ([]*gitlab.Project, error) { + opts := &gitlab.ListProjectsOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allProjects []*gitlab.Project + for { + projects, resp, err := c.client.Projects.ListProjects(opts) + if err != nil { + return nil, fmt.Errorf("failed to list projects: %w", err) + } + allProjects = append(allProjects, projects...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allProjects, nil +} + +// GetProjectMembers returns all members of a project +func (c *Client) GetProjectMembers(projectID int) ([]*gitlab.ProjectMember, error) { + opts := &gitlab.ListProjectMembersOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allMembers []*gitlab.ProjectMember + for { + members, resp, err := c.client.ProjectMembers.ListProjectMembers(projectID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list project members: %w", err) + } + allMembers = append(allMembers, members...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allMembers, nil +} + +// GetProjectLabels returns all labels of a project +func (c *Client) GetProjectLabels(projectID int) ([]*gitlab.Label, error) { + opts := &gitlab.ListLabelsOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allLabels []*gitlab.Label + for { + labels, resp, err := c.client.Labels.ListLabels(projectID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list project labels: %w", err) + } + allLabels = append(allLabels, labels...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allLabels, nil +} + +// GetProjectMilestones returns all milestones of a project +func (c *Client) GetProjectMilestones(projectID int) ([]*gitlab.Milestone, error) { + opts := &gitlab.ListMilestonesOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allMilestones []*gitlab.Milestone + for { + milestones, resp, err := c.client.Milestones.ListMilestones(projectID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list project milestones: %w", err) + } + allMilestones = append(allMilestones, milestones...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allMilestones, nil +} + +// GetProjectIssues returns all issues of a project +func (c *Client) GetProjectIssues(projectID int) ([]*gitlab.Issue, error) { + opts := &gitlab.ListProjectIssuesOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allIssues []*gitlab.Issue + for { + issues, resp, err := c.client.Issues.ListProjectIssues(projectID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list project issues: %w", err) + } + allIssues = append(allIssues, issues...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allIssues, nil +} + +// GetIssueNotes returns all notes of an issue +func (c *Client) GetIssueNotes(projectID, issueID int) ([]*gitlab.Note, error) { + opts := &gitlab.ListIssueNotesOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allNotes []*gitlab.Note + for { + notes, resp, err := c.client.Notes.ListIssueNotes(projectID, issueID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list issue notes: %w", err) + } + allNotes = append(allNotes, notes...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allNotes, nil +} + +// GetGroupMembers returns all members of a group +func (c *Client) GetGroupMembers(groupID int) ([]*gitlab.GroupMember, error) { + opts := &gitlab.ListGroupMembersOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + }, + } + + var allMembers []*gitlab.GroupMember + for { + members, resp, err := c.client.Groups.ListGroupMembers(groupID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list group members: %w", err) + } + allMembers = append(allMembers, members...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allMembers, nil +} + +// GetUserKeys returns all SSH keys of a user +func (c *Client) GetUserKeys(userID int) ([]*gitlab.SSHKey, error) { + opts := &gitlab.ListSSHKeysForUserOptions{ + PerPage: 100, + } + + var allKeys []*gitlab.SSHKey + for { + keys, resp, err := c.client.Users.ListSSHKeysForUser(userID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list user SSH keys: %w", err) + } + allKeys = append(allKeys, keys...) + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return allKeys, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e9fa673 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/go-i2p/gitlab-to-gitea + +go 1.24.2 + +require ( + github.com/go-sql-driver/mysql v1.9.2 + github.com/joho/godotenv v1.5.1 + github.com/xanzy/go-gitlab v0.115.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/oauth2 v0.6.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.29.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..657559f --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8= +github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM= +google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/migration/collaborators.go b/migration/collaborators.go new file mode 100644 index 0000000..22fbbd8 --- /dev/null +++ b/migration/collaborators.go @@ -0,0 +1,93 @@ +// collaborators.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + + "github.com/xanzy/go-gitlab" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// collaboratorAddRequest represents the data needed to add a collaborator to a repository +type collaboratorAddRequest struct { + Permission string `json:"permission"` +} + +// importProjectCollaborators imports project collaborators to Gitea +func (m *Manager) importProjectCollaborators( + collaborators []*gitlab.ProjectMember, + project *gitlab.Project, +) error { + ownerInfo, err := m.getOwner(project) + if err != nil { + return fmt.Errorf("failed to get owner info: %w", err) + } + + ownerUsername := ownerInfo["username"].(string) + ownerType := ownerInfo["type"].(string) + repoName := utils.CleanName(project.Name) + + for _, collaborator := range collaborators { + cleanUsername := utils.NormalizeUsername(collaborator.Username) + + // Skip if the collaborator is the owner + if ownerType == "user" && ownerUsername == cleanUsername { + continue + } + + // Map GitLab access levels to Gitea permissions + permission := "read" + if collaborator.AccessLevel >= 30 { // Developer+ + permission = "write" + } + if collaborator.AccessLevel >= 40 { // Maintainer+ + permission = "admin" + } + + // Check if collaborator already exists + exists, err := m.collaboratorExists(ownerUsername, repoName, cleanUsername) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error checking if collaborator %s exists: %v", cleanUsername, err)) + continue + } + + if exists { + utils.PrintWarning(fmt.Sprintf("Collaborator %s already exists for repo %s, skipping!", cleanUsername, repoName)) + continue + } + + // Add collaborator + colReq := collaboratorAddRequest{ + Permission: permission, + } + + err = m.giteaClient.Put( + fmt.Sprintf("/repos/%s/%s/collaborators/%s", ownerUsername, repoName, cleanUsername), + colReq, + nil, + ) + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to add collaborator %s: %v", cleanUsername, err)) + continue + } + + utils.PrintInfo(fmt.Sprintf("Collaborator %s added to %s as %s!", collaborator.Username, repoName, permission)) + } + + return nil +} + +// collaboratorExists checks if a user is a collaborator on a repository +func (m *Manager) collaboratorExists(owner, repo, username string) (bool, error) { + err := m.giteaClient.Get(fmt.Sprintf("/repos/%s/%s/collaborators/%s", owner, repo, username), nil) + if err != nil { + if isNotFoundError(err) { + return false, nil + } + return false, fmt.Errorf("error checking if collaborator exists: %w", err) + } + return true, nil +} diff --git a/migration/comments.go b/migration/comments.go new file mode 100644 index 0000000..47fb2ec --- /dev/null +++ b/migration/comments.go @@ -0,0 +1,108 @@ +// comments.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + + "github.com/xanzy/go-gitlab" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// commentCreateRequest represents the data needed to create a comment in Gitea +type commentCreateRequest struct { + Body string `json:"body"` +} + +// importIssueComments imports comments from a GitLab issue to a Gitea issue +func (m *Manager) importIssueComments( + gitlabIssue *gitlab.Issue, + owner, repo string, + giteaIssueNumber, projectID int, +) error { + // Get migration state for comment tracking + commentKey := fmt.Sprintf("%s/%s/issues/%d", owner, repo, giteaIssueNumber) + + // Get existing comments to avoid duplicates + var existingComments []map[string]interface{} + err := m.giteaClient.Get( + fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, giteaIssueNumber), + &existingComments, + ) + if err != nil { + return fmt.Errorf("failed to get existing comments: %w", err) + } + + // Get notes from GitLab + notes, err := m.gitlabClient.GetIssueNotes(projectID, gitlabIssue.IID) + if err != nil { + return fmt.Errorf("failed to get issue notes: %w", err) + } + + utils.PrintInfo(fmt.Sprintf("Found %d comments for issue #%d", len(notes), giteaIssueNumber)) + + importedCount := 0 + for _, note := range notes { + // Skip system notes + if note.System { + continue + } + + // Skip if note was already imported + noteID := fmt.Sprintf("%d", note.ID) + if m.state.HasImportedComment(commentKey, noteID) { + utils.PrintWarning(fmt.Sprintf("Comment %s already imported, skipping", noteID)) + continue + } + + // Check for duplicate content + body := note.Body + isDuplicate := false + for _, comment := range existingComments { + if comment["body"].(string) == body { + utils.PrintWarning("Comment content already exists, skipping") + m.state.MarkCommentImported(commentKey, noteID) + if err := m.state.Save(); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to save migration state: %v", err)) + } + isDuplicate = true + break + } + } + + if isDuplicate { + continue + } + + // Normalize mentions in the body + body = utils.NormalizeMentions(body) + + // Create comment + commentReq := commentCreateRequest{ + Body: body, + } + + var result map[string]interface{} + err = m.giteaClient.Post( + fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, giteaIssueNumber), + commentReq, + &result, + ) + if err != nil { + utils.PrintError(fmt.Sprintf("Comment import failed: %v", err)) + continue + } + + utils.PrintInfo(fmt.Sprintf("Comment for issue #%d imported!", giteaIssueNumber)) + m.state.MarkCommentImported(commentKey, noteID) + if err := m.state.Save(); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to save migration state: %v", err)) + } + importedCount++ + } + + utils.PrintInfo(fmt.Sprintf("Imported %d new comments for issue #%d", importedCount, giteaIssueNumber)) + return nil +} diff --git a/migration/groups.go b/migration/groups.go new file mode 100644 index 0000000..b242ba8 --- /dev/null +++ b/migration/groups.go @@ -0,0 +1,147 @@ +// groups.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + + "github.com/xanzy/go-gitlab" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// organizationCreateRequest represents the data needed to create an organization in Gitea +type organizationCreateRequest struct { + Description string `json:"description"` + FullName string `json:"full_name"` + Location string `json:"location"` + Username string `json:"username"` + Website string `json:"website"` +} + +// ImportGroup imports a single GitLab group to Gitea as an organization +func (m *Manager) ImportGroup(group *gitlab.Group) error { + cleanName := utils.CleanName(group.Name) + + utils.PrintInfo(fmt.Sprintf("Importing group %s...", cleanName)) + + // Check if organization already exists + if exists, err := m.organizationExists(cleanName); err != nil { + return fmt.Errorf("failed to check if organization exists: %w", err) + } else if exists { + utils.PrintWarning(fmt.Sprintf("Group %s already exists in Gitea, skipping!", cleanName)) + return nil + } + + // Get group members + members, err := m.gitlabClient.GetGroupMembers(group.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error fetching members for group %s: %v", group.Name, err)) + members = []*gitlab.GroupMember{} + } + + utils.PrintInfo(fmt.Sprintf("Found %d GitLab members for group %s", len(members), cleanName)) + + // Create organization request + orgReq := organizationCreateRequest{ + Description: group.Description, + FullName: group.FullName, + Location: "", + Username: cleanName, + Website: "", + } + + // Call Gitea API to create organization + var result map[string]interface{} + err = m.giteaClient.Post("/orgs", orgReq, &result) + if err != nil { + return fmt.Errorf("failed to create organization %s: %w", cleanName, err) + } + + utils.PrintInfo(fmt.Sprintf("Group %s imported!", cleanName)) + + // Import group members + if err := m.importGroupMembers(members, cleanName); err != nil { + utils.PrintWarning(fmt.Sprintf("Error importing members for group %s: %v", cleanName, err)) + } + + return nil +} + +// importGroupMembers imports group members to the first team in an organization +func (m *Manager) importGroupMembers(members []*gitlab.GroupMember, orgName string) error { + // Get existing teams + var teams []map[string]interface{} + err := m.giteaClient.Get(fmt.Sprintf("/orgs/%s/teams", orgName), &teams) + if err != nil { + return fmt.Errorf("failed to get teams for organization %s: %w", orgName, err) + } + + if len(teams) == 0 { + return fmt.Errorf("no teams found for organization %s", orgName) + } + + firstTeam := teams[0] + teamID := int(firstTeam["id"].(float64)) + teamName := firstTeam["name"].(string) + + utils.PrintInfo(fmt.Sprintf("Organization teams fetched, importing users to first team: %s", teamName)) + + // Add members to the team + for _, member := range members { + cleanUsername := utils.NormalizeUsername(member.Username) + + exists, err := m.memberExists(cleanUsername, teamID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error checking if member %s exists: %v", cleanUsername, err)) + continue + } + + if exists { + utils.PrintWarning(fmt.Sprintf("Member %s already exists for team %s, skipping!", member.Username, teamName)) + continue + } + + // Add member to team + err = m.giteaClient.Put(fmt.Sprintf("/teams/%d/members/%s", teamID, cleanUsername), nil, nil) + if err != nil { + utils.PrintError(fmt.Sprintf("Failed to add member %s to team %s: %v", member.Username, teamName, err)) + continue + } + + utils.PrintInfo(fmt.Sprintf("Member %s added to team %s!", member.Username, teamName)) + } + + return nil +} + +// organizationExists checks if an organization exists in Gitea +func (m *Manager) organizationExists(orgName string) (bool, error) { + var org map[string]interface{} + err := m.giteaClient.Get("/orgs/"+orgName, &org) + if err != nil { + if isNotFoundError(err) { + return false, nil + } + return false, fmt.Errorf("error checking if organization exists: %w", err) + } + return true, nil +} + +// memberExists checks if a user is a member of a team +func (m *Manager) memberExists(username string, teamID int) (bool, error) { + var members []map[string]interface{} + err := m.giteaClient.Get(fmt.Sprintf("/teams/%d/members", teamID), &members) + if err != nil { + return false, fmt.Errorf("failed to get team members: %w", err) + } + + for _, member := range members { + if member["username"].(string) == username { + return true, nil + } + } + + return false, nil +} diff --git a/migration/issues.go b/migration/issues.go new file mode 100644 index 0000000..1c03c37 --- /dev/null +++ b/migration/issues.go @@ -0,0 +1,153 @@ +// issues.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + "time" + + "github.com/xanzy/go-gitlab" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// issueCreateRequest represents the data needed to create an issue in Gitea +type issueCreateRequest struct { + Assignee string `json:"assignee,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Body string `json:"body"` + Closed bool `json:"closed"` + DueOn string `json:"due_on,omitempty"` + Labels []int `json:"labels,omitempty"` + Milestone int `json:"milestone,omitempty"` + Title string `json:"title"` +} + +// importProjectIssues imports project issues to Gitea +func (m *Manager) importProjectIssues(issues []*gitlab.Issue, owner, repo string, projectID int) error { + // Get existing milestones and labels for reference + var existingMilestones []map[string]interface{} + err := m.giteaClient.Get(fmt.Sprintf("/repos/%s/%s/milestones", owner, repo), &existingMilestones) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error fetching milestones: %v", err)) + } + + var existingLabels []map[string]interface{} + err = m.giteaClient.Get(fmt.Sprintf("/repos/%s/%s/labels", owner, repo), &existingLabels) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error fetching labels: %v", err)) + } + + // Get existing issues to avoid duplicates + var existingIssues []map[string]interface{} + err = m.giteaClient.Get(fmt.Sprintf("/repos/%s/%s/issues?state=all&page=-1", owner, repo), &existingIssues) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error fetching existing issues: %v", err)) + } + + for _, issue := range issues { + // Check if issue already exists + exists, existingIssue := issueExists(existingIssues, issue.Title) + if exists { + utils.PrintWarning(fmt.Sprintf("Issue %s already exists in project %s, importing comments only", issue.Title, repo)) + + // Import comments for existing issue + if existingIssue != nil { + issueNumber := int(existingIssue["number"].(float64)) + if err := m.importIssueComments(issue, owner, repo, issueNumber, projectID); err != nil { + utils.PrintWarning(fmt.Sprintf("Error importing comments: %v", err)) + } + } + continue + } + + // Prepare due date + var dueOn string + if issue.DueDate != nil { + dueStr := issue.DueDate.String() + parsedDate, err := time.Parse("2006-01-02", dueStr) + if err == nil { + dueOn = parsedDate.Format(time.RFC3339) + } + } + + // Process assignees + var assignee string + var assignees []string + + if issue.Assignee != nil { + assignee = utils.NormalizeUsername(issue.Assignee.Username) + } + + for _, a := range issue.Assignees { + assignees = append(assignees, utils.NormalizeUsername(a.Username)) + } + + // Process milestone + var milestoneID int + if issue.Milestone != nil { + for _, m := range existingMilestones { + if m["title"].(string) == issue.Milestone.Title { + milestoneID = int(m["id"].(float64)) + break + } + } + } + + // Process labels + var labelIDs []int + for _, labelName := range issue.Labels { + for _, l := range existingLabels { + if l["name"].(string) == labelName { + labelIDs = append(labelIDs, int(l["id"].(float64))) + break + } + } + } + + // Normalize mentions in the description + description := utils.NormalizeMentions(issue.Description) + + // Create issue + issueReq := issueCreateRequest{ + Assignee: assignee, + Assignees: assignees, + Body: description, + Closed: issue.State == "closed", + DueOn: dueOn, + Labels: labelIDs, + Milestone: milestoneID, + Title: issue.Title, + } + + var result map[string]interface{} + err = m.giteaClient.Post(fmt.Sprintf("/repos/%s/%s/issues", owner, repo), issueReq, &result) + if err != nil { + utils.PrintError(fmt.Sprintf("Issue %s import failed: %v", issue.Title, err)) + continue + } + + utils.PrintInfo(fmt.Sprintf("Issue %s imported!", issue.Title)) + + // Import comments for the new issue + if result != nil { + issueNumber := int(result["number"].(float64)) + if err := m.importIssueComments(issue, owner, repo, issueNumber, projectID); err != nil { + utils.PrintWarning(fmt.Sprintf("Error importing comments: %v", err)) + } + } + } + + return nil +} + +// issueExists checks if an issue already exists based on title +func issueExists(existingIssues []map[string]interface{}, title string) (bool, map[string]interface{}) { + for _, issue := range existingIssues { + if issue["title"].(string) == title { + return true, issue + } + } + return false, nil +} diff --git a/migration/labels.go b/migration/labels.go new file mode 100644 index 0000000..dc654c2 --- /dev/null +++ b/migration/labels.go @@ -0,0 +1,71 @@ +// labels.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + + "github.com/xanzy/go-gitlab" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// labelCreateRequest represents the data needed to create a label in Gitea +type labelCreateRequest struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +// importProjectLabels imports project labels to Gitea +func (m *Manager) importProjectLabels(labels []*gitlab.Label, owner, repo string) error { + for _, label := range labels { + // Check if label already exists + exists, err := m.labelExists(owner, repo, label.Name) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error checking if label %s exists: %v", label.Name, err)) + continue + } + + if exists { + utils.PrintWarning(fmt.Sprintf("Label %s already exists in project %s, skipping!", label.Name, repo)) + continue + } + + // Create label + labelReq := labelCreateRequest{ + Name: label.Name, + Color: label.Color, + Description: label.Description, + } + + var result map[string]interface{} + err = m.giteaClient.Post(fmt.Sprintf("/repos/%s/%s/labels", owner, repo), labelReq, &result) + if err != nil { + utils.PrintError(fmt.Sprintf("Label %s import failed: %v", label.Name, err)) + continue + } + + utils.PrintInfo(fmt.Sprintf("Label %s imported!", label.Name)) + } + + return nil +} + +// labelExists checks if a label exists in a repository +func (m *Manager) labelExists(owner, repo, labelName string) (bool, error) { + var labels []map[string]interface{} + err := m.giteaClient.Get(fmt.Sprintf("/repos/%s/%s/labels", owner, repo), &labels) + if err != nil { + return false, fmt.Errorf("failed to get labels: %w", err) + } + + for _, label := range labels { + if label["name"].(string) == labelName { + return true, nil + } + } + + return false, nil +} diff --git a/migration/manager.go b/migration/manager.go new file mode 100644 index 0000000..39694e4 --- /dev/null +++ b/migration/manager.go @@ -0,0 +1,283 @@ +// manager.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + "os" + + "github.com/go-i2p/gitlab-to-gitea/utils" + + "github.com/go-i2p/gitlab-to-gitea/config" + "github.com/go-i2p/gitlab-to-gitea/gitea" + "github.com/go-i2p/gitlab-to-gitea/gitlab" + + gogitlab "github.com/xanzy/go-gitlab" +) + +// Manager handles the migration process +type Manager struct { + gitlabClient *gitlab.Client + giteaClient *gitea.Client + config *config.Config + state *State +} + +func FileExists(filename string) bool { + _, err := os.Stat(filename) + return !os.IsNotExist(err) +} + +// NewManager creates a new migration manager +func NewManager(gitlabClient *gitlab.Client, giteaClient *gitea.Client, cfg *config.Config) *Manager { + // Initialize state + state := NewState(cfg.MigrationStateFile) + if FileExists(cfg.MigrationStateFile) && cfg.ResumeMigration { + utils.PrintInfo("Resuming previous migration...") + if err := state.Load(); err != nil { + utils.PrintWarning(fmt.Sprintf("Could not load migration state: %v. Starting new migration.", err)) + } + } else { + utils.PrintInfo("Starting new migration...") + if err := state.Reset(); err != nil { + utils.PrintWarning(fmt.Sprintf("Could not reset migration state: %v", err)) + } + utils.PrintInfo("Migration state reset.") + } + utils.PrintInfo("Migration state initialized.") + + return &Manager{ + gitlabClient: gitlabClient, + giteaClient: giteaClient, + config: cfg, + state: state, + } +} + +// ImportUsersGroups imports users and groups from GitLab to Gitea +func (m *Manager) ImportUsersGroups() error { + utils.PrintInfo("Fetching users from GitLab...") + // Get GitLab users + users, err := m.gitlabClient.ListUsers() + if err != nil { + return fmt.Errorf("failed to list GitLab users: %w", err) + } + utils.PrintInfo(fmt.Sprintf("Found %d GitLab users", len(users))) + + utils.PrintInfo("Fetching groups from GitLab...") + // Get GitLab groups + groups, err := m.gitlabClient.ListGroups() + if err != nil { + return fmt.Errorf("failed to list GitLab groups: %w", err) + } + utils.PrintInfo(fmt.Sprintf("Found %d GitLab groups", len(groups))) + + utils.PrintHeader("Importing users") + // Import users + for _, user := range users { + utils.PrintInfo(fmt.Sprintf("Importing user %s...", user.Username)) + if m.config.ResumeMigration && m.state.HasImportedUser(user.Username) { + utils.PrintWarning(fmt.Sprintf("User %s already imported, skipping!", user.Username)) + continue + } + + if err := m.ImportUser(user, false); err != nil { + utils.PrintError(fmt.Sprintf("Failed to import user %s: %v", user.Username, err)) + continue + } + + m.state.MarkUserImported(user.Username) + if err := m.state.Save(); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to save migration state: %v", err)) + } + utils.PrintSuccess(fmt.Sprintf("Imported user %s.", user.Username)) + } + + utils.PrintHeader("Importing groups") + // Import groups + for _, group := range groups { + cleanName := utils.CleanName(group.Name) + utils.PrintInfo(fmt.Sprintf("Importing group: %s...", cleanName)) + if m.config.ResumeMigration && m.state.HasImportedGroup(cleanName) { + utils.PrintWarning(fmt.Sprintf("Group %s already imported, skipping!", cleanName)) + continue + } + + if err := m.ImportGroup(group); err != nil { + utils.PrintError(fmt.Sprintf("Failed to import group %s: %v", group.Name, err)) + continue + } + + m.state.MarkGroupImported(cleanName) + if err := m.state.Save(); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to save migration state: %v", err)) + } + utils.PrintSuccess(fmt.Sprintf("Imported group: %s.", cleanName)) + } + + return nil +} + +// ImportProjects imports projects from GitLab to Gitea +func (m *Manager) ImportProjects() error { + // Get GitLab projects + projects, err := m.gitlabClient.ListProjects() + if err != nil { + return fmt.Errorf("failed to list GitLab projects: %w", err) + } + utils.PrintInfo(fmt.Sprintf("Found %d GitLab projects", len(projects))) + + // Import projects + utils.PrintInfo("Pre-creating all necessary users for project migration...") + + // Create a set of all usernames and namespaces that need to exist + requiredUsers := m.collectRequiredUsers(projects) + + // Create any missing users + utils.PrintInfo(fmt.Sprintf("Found %d users that need to exist in Gitea", len(requiredUsers))) + for username := range requiredUsers { + exists, err := m.userExists(utils.NormalizeUsername(username)) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error checking if user exists: %v", err)) + continue + } + + if !exists { + if err := m.ImportPlaceholderUser(username); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to create placeholder user: %v", err)) + } + } + } + + utils.PrintInfo("Starting project migration...") + + // Import projects + for _, project := range projects { + projectKey := fmt.Sprintf("%s/%s", project.Namespace.Name, utils.CleanName(project.Name)) + + // Skip if project was already fully imported + if m.config.ResumeMigration && m.state.HasImportedProject(projectKey) { + utils.PrintWarning(fmt.Sprintf("Project %s already imported, skipping!", projectKey)) + continue + } + + // Import project + if err := m.ImportProject(project); err != nil { + utils.PrintError(fmt.Sprintf("Failed to import project %s: %v", project.Name, err)) + continue + } + + m.state.MarkProjectImported(projectKey) + if err := m.state.Save(); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to save migration state: %v", err)) + } + } + + return nil +} + +// collectRequiredUsers builds a set of usernames that need to exist before project migration +func (m *Manager) collectRequiredUsers(projects []*gogitlab.Project) map[string]struct{} { + required := make(map[string]struct{}) + + utils.PrintHeader("Collecting required users for project migration") + + // Helper function to add a user to the required map if not already present + addUser := func(username string) { + if username == "" { + return + } + if _, exists := required[username]; !exists { + required[username] = struct{}{} + utils.PrintInfo(fmt.Sprintf("Adding required user: %s", username)) + } + } + + // Collect users from projects + for _, project := range projects { + utils.PrintInfo(fmt.Sprintf("Collecting users for project %s...", project.Name)) + + // Add project namespace/owner if it's a user + if project.Namespace.Kind == "user" { + addUser(project.Namespace.Path) + } + + // Collect project members + members, err := m.gitlabClient.GetProjectMembers(project.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error collecting members for %s: %v", project.Name, err)) + continue + } + + for _, member := range members { + addUser(member.Username) + } + + // Collect issues and related users + issues, err := m.gitlabClient.GetProjectIssues(project.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error collecting issues for %s: %v", project.Name, err)) + continue + } + + for _, issue := range issues { + // Add issue author + if issue.Author != nil { + addUser(issue.Author.Username) + } + + // Add issue assignees + if issue.Assignee != nil { + addUser(issue.Assignee.Username) + } + + for _, assignee := range issue.Assignees { + addUser(assignee.Username) + } + + // Process issue notes/comments for authors + notes, err := m.gitlabClient.GetIssueNotes(project.ID, issue.IID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error collecting notes for issue #%d: %v", issue.IID, err)) + continue + } + + for _, note := range notes { + if !note.System && note.Author.ID != 0 { + addUser(note.Author.Username) + } + } + + // Extract mentioned users from issue description + for _, mention := range utils.ExtractUserMentions(issue.Description) { + addUser(mention) + } + + // Extract mentioned users from notes + for _, note := range notes { + if !note.System { + for _, mention := range utils.ExtractUserMentions(note.Body) { + addUser(mention) + } + } + } + } + + // Collect milestone authors + milestones, err := m.gitlabClient.GetProjectMilestones(project.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error collecting milestones for %s: %v", project.Name, err)) + continue + } + + for _, milestone := range milestones { + if milestone.Title != "" { + addUser(milestone.Title) + } + } + } + + utils.PrintInfo(fmt.Sprintf("Collected a total of %d unique required users", len(required))) + return required +} diff --git a/migration/milestones.go b/migration/milestones.go new file mode 100644 index 0000000..a66cf39 --- /dev/null +++ b/migration/milestones.go @@ -0,0 +1,112 @@ +// milestones.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + "time" + + "github.com/xanzy/go-gitlab" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// milestoneCreateRequest represents the data needed to create a milestone in Gitea +type milestoneCreateRequest struct { + Description string `json:"description"` + DueOn string `json:"due_on,omitempty"` + Title string `json:"title"` +} + +// milestoneUpdateRequest represents the data needed to update a milestone in Gitea +type milestoneUpdateRequest struct { + Description string `json:"description"` + DueOn string `json:"due_on,omitempty"` + State string `json:"state"` + Title string `json:"title"` +} + +// importProjectMilestones imports project milestones to Gitea +func (m *Manager) importProjectMilestones(milestones []*gitlab.Milestone, owner, repo string) error { + for _, milestone := range milestones { + // Check if milestone already exists + exists, _, err := m.milestoneExists(owner, repo, milestone.Title) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error checking if milestone %s exists: %v", milestone.Title, err)) + continue + } + + if exists { + utils.PrintWarning(fmt.Sprintf("Milestone %s already exists in project %s, skipping!", milestone.Title, repo)) + continue + } + + // Prepare due date + var dueOn string + if milestone.DueDate != nil { + parsedDate, err := time.Parse("2006-01-02", milestone.DueDate.String()) + if err == nil { + dueOn = parsedDate.Format(time.RFC3339) + } + } + + // Create milestone + milestoneReq := milestoneCreateRequest{ + Description: milestone.Description, + DueOn: dueOn, + Title: milestone.Title, + } + + var result map[string]interface{} + err = m.giteaClient.Post(fmt.Sprintf("/repos/%s/%s/milestones", owner, repo), milestoneReq, &result) + if err != nil { + utils.PrintError(fmt.Sprintf("Milestone %s import failed: %v", milestone.Title, err)) + continue + } + + utils.PrintInfo(fmt.Sprintf("Milestone %s imported!", milestone.Title)) + + // If the milestone is closed, update its state + if milestone.State == "closed" && result != nil { + milestoneID := int(result["id"].(float64)) + + updateReq := milestoneUpdateRequest{ + Description: milestone.Description, + DueOn: dueOn, + State: "closed", + Title: milestone.Title, + } + + err = m.giteaClient.Patch( + fmt.Sprintf("/repos/%s/%s/milestones/%d", owner, repo, milestoneID), + updateReq, + nil, + ) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to update milestone state: %v", err)) + } else { + utils.PrintInfo(fmt.Sprintf("Milestone %s state updated to closed", milestone.Title)) + } + } + } + + return nil +} + +// milestoneExists checks if a milestone exists in a repository +func (m *Manager) milestoneExists(owner, repo, title string) (bool, map[string]interface{}, error) { + var milestones []map[string]interface{} + err := m.giteaClient.Get(fmt.Sprintf("/repos/%s/%s/milestones", owner, repo), &milestones) + if err != nil { + return false, nil, fmt.Errorf("failed to get milestones: %w", err) + } + + for _, milestone := range milestones { + if milestone["title"].(string) == title { + return true, milestone, nil + } + } + + return false, nil, nil +} diff --git a/migration/repositories.go b/migration/repositories.go new file mode 100644 index 0000000..f8e5a14 --- /dev/null +++ b/migration/repositories.go @@ -0,0 +1,188 @@ +// repositories.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + + "github.com/xanzy/go-gitlab" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// repositoryMigrateRequest represents the data needed to migrate a repository to Gitea +type repositoryMigrateRequest struct { + AuthPassword string `json:"auth_password"` + AuthUsername string `json:"auth_username"` + CloneAddr string `json:"clone_addr"` + Description string `json:"description"` + Mirror bool `json:"mirror"` + Private bool `json:"private"` + RepoName string `json:"repo_name"` + UID int `json:"uid"` +} + +// ImportProject imports a GitLab project to Gitea +func (m *Manager) ImportProject(project *gitlab.Project) error { + cleanName := utils.CleanName(project.Name) + + utils.PrintInfo(fmt.Sprintf("Importing project %s from owner %s", cleanName, project.Namespace.Name)) + + // Check if repository already exists + owner := utils.NormalizeUsername(project.Namespace.Path) + if exists, err := m.repoExists(owner, cleanName); err != nil { + return fmt.Errorf("failed to check if repository exists: %w", err) + } else if exists { + utils.PrintWarning(fmt.Sprintf("Project %s already exists in Gitea, skipping repository creation!", cleanName)) + } else { + // Get the owner information + owner, err := m.getOwner(project) + if err != nil { + return fmt.Errorf("failed to get project owner: %w", err) + } + + // Prepare clone URL + cloneURL := project.HTTPURLToRepo + if m.config.GitLabAdminUser == "" && m.config.GitLabAdminPass == "" { + cloneURL = project.SSHURLToRepo + } + + // Determine visibility + private := project.Visibility == "private" || project.Visibility == "internal" + + // Create migration request + migrateReq := repositoryMigrateRequest{ + AuthPassword: m.config.GitLabAdminPass, + AuthUsername: m.config.GitLabAdminUser, + CloneAddr: cloneURL, + Description: project.Description, + Mirror: false, + Private: private, + RepoName: cleanName, + UID: int(owner["id"].(float64)), + } + + // Call Gitea API to migrate repository + var result map[string]interface{} + err = m.giteaClient.Post("/repos/migrate", migrateReq, &result) + if err != nil { + return fmt.Errorf("failed to migrate repository %s: %w", cleanName, err) + } + + utils.PrintInfo(fmt.Sprintf("Project %s imported!", cleanName)) + } + + // Process collaborators + collaborators, err := m.gitlabClient.GetProjectMembers(project.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error fetching collaborators for project %s: %v", project.Name, err)) + } else { + utils.PrintInfo(fmt.Sprintf("Found %d collaborators for project %s", len(collaborators), cleanName)) + if err := m.importProjectCollaborators(collaborators, project); err != nil { + utils.PrintWarning(fmt.Sprintf("Error importing collaborators: %v", err)) + } + } + + // Process labels + labels, err := m.gitlabClient.GetProjectLabels(project.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error fetching labels for project %s: %v", project.Name, err)) + } else { + utils.PrintInfo(fmt.Sprintf("Found %d labels for project %s", len(labels), cleanName)) + if err := m.importProjectLabels(labels, owner, cleanName); err != nil { + utils.PrintWarning(fmt.Sprintf("Error importing labels: %v", err)) + } + } + + // Process milestones + milestones, err := m.gitlabClient.GetProjectMilestones(project.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error fetching milestones for project %s: %v", project.Name, err)) + } else { + utils.PrintInfo(fmt.Sprintf("Found %d milestones for project %s", len(milestones), cleanName)) + if err := m.importProjectMilestones(milestones, owner, cleanName); err != nil { + utils.PrintWarning(fmt.Sprintf("Error importing milestones: %v", err)) + } + } + + // Process issues + issues, err := m.gitlabClient.GetProjectIssues(project.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error fetching issues for project %s: %v", project.Name, err)) + } else { + utils.PrintInfo(fmt.Sprintf("Found %d issues for project %s", len(issues), cleanName)) + + // Ensure all mentioned users exist in Gitea + m.ensureMentionedUsersExist(issues) + + if err := m.importProjectIssues(issues, owner, cleanName, project.ID); err != nil { + utils.PrintWarning(fmt.Sprintf("Error importing issues: %v", err)) + } + } + + return nil +} + +// getOwner retrieves the user or organization info for a project +func (m *Manager) getOwner(project *gitlab.Project) (map[string]interface{}, error) { + namespacePath := utils.NormalizeUsername(project.Namespace.Path) + + // Try to get as a user first + var result map[string]interface{} + err := m.giteaClient.Get("/users/"+namespacePath, &result) + if err == nil { + return result, nil + } + + // Try to get as an organization + orgName := utils.CleanName(project.Namespace.Name) + err = m.giteaClient.Get("/orgs/"+orgName, &result) + if err == nil { + return result, nil + } + + return nil, fmt.Errorf("failed to find owner for project: %w", err) +} + +// repoExists checks if a repository exists in Gitea +func (m *Manager) repoExists(owner, repo string) (bool, error) { + var repository map[string]interface{} + err := m.giteaClient.Get(fmt.Sprintf("/repos/%s/%s", owner, repo), &repository) + if err != nil { + if isNotFoundError(err) { + return false, nil + } + return false, fmt.Errorf("error checking if repository exists: %w", err) + } + return true, nil +} + +// ensureMentionedUsersExist makes sure all users mentioned in issues exist in Gitea +func (m *Manager) ensureMentionedUsersExist(issues []*gitlab.Issue) { + mentionedUsers := make(map[string]struct{}) + + // Extract mentions from issues + for _, issue := range issues { + if issue.Description != "" { + for _, mention := range utils.ExtractUserMentions(issue.Description) { + mentionedUsers[mention] = struct{}{} + } + } + } + + // Create placeholder users for any missing mentioned users + for username := range mentionedUsers { + exists, err := m.userExists(utils.NormalizeUsername(username)) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Error checking if user %s exists: %v", username, err)) + continue + } + + if !exists { + if err := m.ImportPlaceholderUser(username); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to create placeholder user %s: %v", username, err)) + } + } + } +} diff --git a/migration/state.go b/migration/state.go new file mode 100644 index 0000000..ced3c1a --- /dev/null +++ b/migration/state.go @@ -0,0 +1,189 @@ +// state.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "encoding/json" + "fmt" + "os" + "sync" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// State manages the migration state to support resuming migrations +type State struct { + filePath string + Users []string `json:"users"` + Groups []string `json:"groups"` + Projects []string `json:"projects"` + ImportedComments map[string][]string `json:"imported_comments"` + mutex sync.RWMutex +} + +// NewState creates a new migration state manager +func NewState(filePath string) *State { + return &State{ + filePath: filePath, + Users: []string{}, + Groups: []string{}, + Projects: []string{}, + ImportedComments: map[string][]string{}, + } +} + +// Load loads the migration state from the file +func (s *State) Load() error { + s.mutex.Lock() + defer s.mutex.Unlock() + + data, err := os.ReadFile(s.filePath) + if err != nil { + return fmt.Errorf("failed to read state file: %w", err) + } + + err = json.Unmarshal(data, s) + if err != nil { + return fmt.Errorf("failed to parse state file: %w", err) + } + + return nil +} + +// Save saves the current migration state to the file +func (s *State) Save() error { + s.mutex.RLock() + defer s.mutex.RUnlock() + + utils.PrintInfo("Saving migration state...") + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + err = os.WriteFile(s.filePath, data, 0o644) + if err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + + utils.PrintInfo("Migration state saved successfully") + return nil +} + +// Reset clears the migration state +func (s *State) Reset() error { + s.mutex.Lock() + utils.PrintInfo("Clearing migration state...") + + s.Users = []string{} + s.Groups = []string{} + s.Projects = []string{} + s.ImportedComments = map[string][]string{} + + utils.PrintInfo("Migration state reset. Saving...") + s.mutex.Unlock() + return s.Save() +} + +// HasImportedUser checks if a user has been imported +func (s *State) HasImportedUser(username string) bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + + for _, u := range s.Users { + if u == username { + return true + } + } + return false +} + +// MarkUserImported marks a user as imported +func (s *State) MarkUserImported(username string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if !s.HasImportedUser(username) { + s.Users = append(s.Users, username) + } +} + +// HasImportedGroup checks if a group has been imported +func (s *State) HasImportedGroup(group string) bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + + for _, g := range s.Groups { + if g == group { + return true + } + } + return false +} + +// MarkGroupImported marks a group as imported +func (s *State) MarkGroupImported(group string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if !s.HasImportedGroup(group) { + s.Groups = append(s.Groups, group) + } +} + +// HasImportedProject checks if a project has been imported +func (s *State) HasImportedProject(project string) bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + + for _, p := range s.Projects { + if p == project { + return true + } + } + return false +} + +// MarkProjectImported marks a project as imported +func (s *State) MarkProjectImported(project string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if !s.HasImportedProject(project) { + s.Projects = append(s.Projects, project) + } +} + +// HasImportedComment checks if a comment has been imported +func (s *State) HasImportedComment(issueKey, commentID string) bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + + comments, exists := s.ImportedComments[issueKey] + if !exists { + return false + } + + for _, id := range comments { + if id == commentID { + return true + } + } + return false +} + +// MarkCommentImported marks a comment as imported +func (s *State) MarkCommentImported(issueKey, commentID string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if _, exists := s.ImportedComments[issueKey]; !exists { + s.ImportedComments[issueKey] = []string{} + } + + if !s.HasImportedComment(issueKey, commentID) { + s.ImportedComments[issueKey] = append(s.ImportedComments[issueKey], commentID) + } +} diff --git a/migration/users.go b/migration/users.go new file mode 100644 index 0000000..8165d8e --- /dev/null +++ b/migration/users.go @@ -0,0 +1,203 @@ +// users.go + +// Package migration handles the migration of data from GitLab to Gitea +package migration + +import ( + "fmt" + "math/rand" + "strings" + "time" + + "github.com/xanzy/go-gitlab" + + "github.com/go-i2p/gitlab-to-gitea/utils" +) + +// userCreateRequest represents the data needed to create a user in Gitea +type userCreateRequest struct { + Email string `json:"email"` + FullName string `json:"full_name"` + LoginName string `json:"login_name"` + Password string `json:"password"` + SendNotify bool `json:"send_notify"` + SourceID int `json:"source_id"` + Username string `json:"username"` +} + +// ImportUser imports a single GitLab user to Gitea +func (m *Manager) ImportUser(user *gitlab.User, notify bool) error { + // Normalize username + cleanUsername := utils.NormalizeUsername(user.Username) + + // Check if user already exists + if exists, err := m.userExists(cleanUsername); err != nil { + return fmt.Errorf("failed to check if user exists: %w", err) + } else if exists { + utils.PrintWarning(fmt.Sprintf("User %s already exists as %s in Gitea, skipping!", user.Username, cleanUsername)) + return nil + } + + // Generate temporary password + tmpPassword := generateTempPassword() + + // Determine email (use placeholder if not available) + email := fmt.Sprintf("%s@placeholder-migration.local", cleanUsername) + if user.Email != "" { + email = user.Email + } + + // Create user request + userReq := userCreateRequest{ + Email: email, + FullName: user.Name, + LoginName: cleanUsername, + Password: tmpPassword, + SendNotify: notify, + SourceID: 0, // local user + Username: cleanUsername, + } + + // Debug what endpoint we're calling and with what method + fmt.Printf("Attempting to create user via: POST /admin/users\n") + + var result map[string]interface{} + err := m.giteaClient.Post("/admin/users", userReq, &result) + if err != nil { + // Try the alternative user creation endpoint if the first one failed + fmt.Printf("First attempt failed, trying alternative endpoint\n") + err = m.giteaClient.Post("/api/v1/admin/users", userReq, &result) + if err != nil { + return fmt.Errorf("failed to create user %s: %w", user.Username, err) + } + } + + utils.PrintInfo(fmt.Sprintf("User %s created as %s, temporary password: %s", user.Username, cleanUsername, tmpPassword)) + + // Import user's SSH keys + keys, err := m.gitlabClient.GetUserKeys(user.ID) + if err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to fetch keys for user %s: %v", user.Username, err)) + } else { + for _, key := range keys { + if err := m.importUserKey(cleanUsername, key); err != nil { + utils.PrintWarning(fmt.Sprintf("Failed to import key for user %s: %v", user.Username, err)) + } + } + } + + return nil +} + +// ImportPlaceholderUser creates a placeholder user when mentioned user doesn't exist +func (m *Manager) ImportPlaceholderUser(username string) error { + cleanUsername := utils.NormalizeUsername(username) + + exists, err := m.userExists(cleanUsername) + if err != nil { + return fmt.Errorf("failed to check if user exists: %w", err) + } + + if exists { + utils.PrintWarning(fmt.Sprintf("User %s already exists as %s in Gitea, skipping placeholder creation", username, cleanUsername)) + return nil + } + + tmpPassword := generateTempPassword() + + // Create user request + userReq := userCreateRequest{ + Email: fmt.Sprintf("%s@placeholder-migration.local", cleanUsername), + FullName: username, // Keep original name for display + LoginName: cleanUsername, + Password: tmpPassword, + SendNotify: false, + SourceID: 0, // local user + Username: cleanUsername, + } + + var result map[string]interface{} + err = m.giteaClient.Post("/admin/users", userReq, &result) + if err != nil { + return fmt.Errorf("failed to create placeholder user %s: %w", username, err) + } + + utils.PrintInfo(fmt.Sprintf("Placeholder user %s created as %s", username, cleanUsername)) + return nil +} + +// importUserKey imports a user's SSH key to Gitea +func (m *Manager) importUserKey(username string, key *gitlab.SSHKey) error { + // Check if key already exists + var existingKeys []map[string]interface{} + err := m.giteaClient.Get(fmt.Sprintf("/users/%s/keys", username), &existingKeys) + if err != nil { + return fmt.Errorf("failed to get existing keys: %w", err) + } + + // Check if key with same title already exists + for _, existingKey := range existingKeys { + if existingKey["title"] == key.Title { + utils.PrintWarning(fmt.Sprintf("Key %s already exists for user %s, skipping", key.Title, username)) + return nil + } + } + + // Create key request + keyReq := map[string]string{ + "key": key.Key, + "title": key.Title, + } + + // Call Gitea API to create key + var result map[string]interface{} + err = m.giteaClient.Post(fmt.Sprintf("/admin/users/%s/keys", username), keyReq, &result) + if err != nil { + return fmt.Errorf("failed to create key %s: %w", key.Title, err) + } + + utils.PrintInfo(fmt.Sprintf("Key %s imported for user %s", key.Title, username)) + return nil +} + +// userExists checks if a user exists in Gitea +func (m *Manager) userExists(username string) (bool, error) { + var user map[string]interface{} + err := m.giteaClient.Get("/users/"+username, &user) + if err != nil { + // If we get an error, assume user doesn't exist + // But only if the error contains "not found" or similar messages + if isNotFoundError(err) { + return false, nil + } + return false, fmt.Errorf("error checking if user exists: %w", err) + } + return true, nil +} + +// generateTempPassword creates a random password for new users +func generateTempPassword() string { + const ( + chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + pwdLen = 12 + prefix = "Tmp1!" + ) + + // Initialize random source + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Generate random part of password + result := make([]byte, pwdLen) + for i := range result { + result[i] = chars[r.Intn(len(chars))] + } + + return prefix + string(result) +} + +// isNotFoundError checks if an error is a 404 Not Found error +func isNotFoundError(err error) bool { + return err != nil && (strings.Contains(err.Error(), "404") || + strings.Contains(err.Error(), "not found") || + strings.Contains(err.Error(), "does not exist")) +} diff --git a/utils/logging.go b/utils/logging.go new file mode 100644 index 0000000..da29b15 --- /dev/null +++ b/utils/logging.go @@ -0,0 +1,45 @@ +// logging.go + +// Package utils provides utility functions used throughout the application +package utils + +import ( + "fmt" +) + +// Color codes for terminal output +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" + colorPurple = "\033[35m" + colorCyan = "\033[36m" + colorBold = "\033[1m" +) + +// PrintHeader prints a header text with purple color +func PrintHeader(message string) { + fmt.Println(colorPurple + colorBold + message + colorReset) +} + +// PrintInfo prints an informational message with blue color +func PrintInfo(message string) { + fmt.Println(colorBlue + message + colorReset) +} + +// PrintSuccess prints a success message with green color +func PrintSuccess(message string) { + fmt.Println(colorGreen + message + colorReset) +} + +// PrintWarning prints a warning message with yellow color +func PrintWarning(message string) { + fmt.Println(colorYellow + message + colorReset) +} + +// PrintError prints an error message with red color and increments the global error count +func PrintError(message string) { + fmt.Println(colorRed + message + colorReset) +} diff --git a/utils/mentions.go b/utils/mentions.go new file mode 100644 index 0000000..3cdb337 --- /dev/null +++ b/utils/mentions.go @@ -0,0 +1,51 @@ +// mentions.go + +// Package utils provides utility functions used throughout the application +package utils + +import ( + "regexp" + "strings" +) + +// mentionPattern matches @username mentions in text +var mentionPattern = regexp.MustCompile(`@([a-zA-Z0-9_\.-]+(?:\s+[a-zA-Z0-9_\.-]+)*)`) + +// ExtractUserMentions extracts all @username mentions from text +func ExtractUserMentions(text string) []string { + if text == "" { + return []string{} + } + + // Find all matches + matches := mentionPattern.FindAllStringSubmatch(text, -1) + + // Extract the username part of each match + result := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) >= 2 { + result = append(result, match[1]) + } + } + + return result +} + +// NormalizeMentions replaces all @mentions in text with their normalized versions +func NormalizeMentions(text string) string { + if text == "" { + return text + } + + // Extract all mentions + mentions := ExtractUserMentions(text) + + // Replace each mention with its normalized version + result := text + for _, mention := range mentions { + normalized := NormalizeUsername(mention) + result = strings.ReplaceAll(result, "@"+mention, "@"+normalized) + } + + return result +} diff --git a/utils/naming.go b/utils/naming.go new file mode 100644 index 0000000..6f3c1bf --- /dev/null +++ b/utils/naming.go @@ -0,0 +1,67 @@ +// naming.go + +// Package utils provides utility functions used throughout the application +package utils + +import ( + "regexp" + "strings" + "sync" +) + +var ( + // usernameMap caches normalized usernames to avoid repeated normalization + usernameMap = make(map[string]string) + usernameMutex sync.RWMutex + specialCharsReg = regexp.MustCompile(`[^a-zA-Z0-9_\.-]`) +) + +// NormalizeUsername converts usernames to Gitea-compatible format +// by replacing spaces with underscores and removing special characters +func NormalizeUsername(username string) string { + if username == "" { + return username + } + + // Check cache first + usernameMutex.RLock() + if cleanName, exists := usernameMap[username]; exists { + usernameMutex.RUnlock() + return cleanName + } + usernameMutex.RUnlock() + + var cleanName string + // Handle special cases (reserved names, etc) + if strings.ToLower(username) == "ghost" { + cleanName = "ghost_user" + } else { + // Replace spaces with underscores + cleanName = strings.ReplaceAll(username, " ", "_") + // Remove special characters + cleanName = specialCharsReg.ReplaceAllString(cleanName, "_") + } + + // Store in cache + usernameMutex.Lock() + usernameMap[username] = cleanName + usernameMutex.Unlock() + + return cleanName +} + +// CleanName sanitizes a name for use in Gitea by replacing spaces with underscores +// and removing special characters +func CleanName(name string) string { + // Replace spaces with underscores + newName := strings.ReplaceAll(name, " ", "_") + // Replace special chars with hyphens + newName = specialCharsReg.ReplaceAllString(newName, "-") + + // Handle special case for "plugins" + if strings.ToLower(newName) == "plugins" { + return newName + "-user" + } + + return newName +}