initial commit

This commit is contained in:
eyedeekay
2025-04-20 21:18:53 -04:00
parent f360e080cb
commit af02e61b3b
22 changed files with 2673 additions and 0 deletions

26
_env.example Normal file
View File

@ -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

109
cmd/migrate/main.go Normal file
View File

@ -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))
}
}

72
config/config.go Normal file
View File

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

56
config/env_loader.go Normal file
View File

@ -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)
}
}

111
gitea/action.go Normal file
View File

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

235
gitea/client.go Normal file
View File

@ -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 <meta name="_csrf" content="..." /> in the HTML
body := string(bodyBytes)
csrfMetaStart := strings.Index(body, `<meta name="_csrf" content="`)
if csrfMetaStart != -1 {
csrfMetaStart += len(`<meta name="_csrf" content="`)
csrfMetaEnd := strings.Index(body[csrfMetaStart:], `"`)
if csrfMetaEnd != -1 {
csrfToken := body[csrfMetaStart : csrfMetaStart+csrfMetaEnd]
transport, ok := c.httpClient.Transport.(*CSRFTokenTransport)
if ok {
transport.CSRFToken = csrfToken
return nil
}
return fmt.Errorf("transport is not a CSRFTokenTransport")
}
}
return fmt.Errorf("could not find CSRF token in response")
}
func NewClient(baseURL, token string) (*Client, error) {
if !strings.HasSuffix(baseURL, "/") {
baseURL += "/"
}
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
// Create a standard HTTP client without the custom transport
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
return &Client{
baseURL: u,
httpClient: httpClient,
token: token,
}, nil
}
// Add a custom transport to handle CSRF tokens
type CSRFTokenTransport struct {
Token string
CSRFToken string
Base http.RoundTripper
}
func (t *CSRFTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Add Content-Type header
req.Header.Set("Content-Type", "application/json")
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("token %s", t.Token))
// Add Accept header
req.Header.Set("Accept", "application/json")
// Add X-Gitea-CSRF header with the token to bypass CSRF protection
req.Header.Set("X-Gitea-CSRF", t.CSRFToken)
return t.Base.RoundTrip(req)
}
// GetVersion retrieves the Gitea version
func (c *Client) GetVersion() (string, error) {
versionResp := &VersionResponse{}
_, err := c.request("GET", "version", nil, versionResp)
if err != nil {
return "", err
}
return versionResp.Version, nil
}
// Get performs a GET request against the API
func (c *Client) Get(path string, result interface{}) error {
_, err := c.request("GET", path, nil, result)
return err
}
// Post performs a POST request against the API
func (c *Client) Post(path string, data, result interface{}) error {
_, err := c.request("POST", path, data, result)
return err
}
// Put performs a PUT request against the API
func (c *Client) Put(path string, data, result interface{}) error {
_, err := c.request("PUT", path, data, result)
return err
}
// Patch performs a PATCH request against the API
func (c *Client) Patch(path string, data, result interface{}) error {
_, err := c.request("PATCH", path, data, result)
return err
}
// Delete performs a DELETE request against the API
func (c *Client) Delete(path string) error {
_, err := c.request("DELETE", path, nil, nil)
return err
}
// request sends an HTTP request to the Gitea API
func (c *Client) request(method, path string, data, result interface{}) (*http.Response, error) {
if !strings.HasPrefix(path, "/") {
path = "/api/v1/" + path
}
// Debug output to see what endpoint is being called
fmt.Printf("Making %s request to: %s%s\n", method, c.baseURL.String(), path)
u, err := c.baseURL.Parse(path)
if err != nil {
return nil, fmt.Errorf("invalid path: %w", err)
}
var body io.Reader
if data != nil {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
body = bytes.NewBuffer(jsonData)
// Debug output for request body
// fmt.Printf("Request body: %s\n", string(jsonData))
}
req, err := http.NewRequest(method, u.String(), body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers - simplified approach with just basic auth headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("token %s", c.token))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// For debugging: print the raw response
bodyBytes, _ := io.ReadAll(resp.Body)
//fmt.Printf("Response: Status=%s, Body=%s\n", resp.Status, string(bodyBytes))
// Since we've read the body, we need to create a new reader for further processing
//resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// Handle error status codes
if resp.StatusCode < 200 || resp.StatusCode >= 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
}

273
gitlab/client.go Normal file
View File

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

22
go.mod Normal file
View File

@ -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
)

59
go.sum Normal file
View File

@ -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=

View File

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

108
migration/comments.go Normal file
View File

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

147
migration/groups.go Normal file
View File

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

153
migration/issues.go Normal file
View File

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

71
migration/labels.go Normal file
View File

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

283
migration/manager.go Normal file
View File

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

112
migration/milestones.go Normal file
View File

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

188
migration/repositories.go Normal file
View File

@ -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))
}
}
}
}

189
migration/state.go Normal file
View File

@ -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)
}
}

203
migration/users.go Normal file
View File

@ -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"))
}

45
utils/logging.go Normal file
View File

@ -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)
}

51
utils/mentions.go Normal file
View File

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

67
utils/naming.go Normal file
View File

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