mirror of
https://github.com/go-i2p/gitlab-to-gitea.git
synced 2025-06-07 18:24:23 -04:00
initial commit
This commit is contained in:
26
_env.example
Normal file
26
_env.example
Normal 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
109
cmd/migrate/main.go
Normal 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
72
config/config.go
Normal 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
56
config/env_loader.go
Normal 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
111
gitea/action.go
Normal 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
235
gitea/client.go
Normal 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
273
gitlab/client.go
Normal 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
22
go.mod
Normal 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
59
go.sum
Normal 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=
|
93
migration/collaborators.go
Normal file
93
migration/collaborators.go
Normal 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
108
migration/comments.go
Normal 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
147
migration/groups.go
Normal 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
153
migration/issues.go
Normal 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
71
migration/labels.go
Normal 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
283
migration/manager.go
Normal 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
112
migration/milestones.go
Normal 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
188
migration/repositories.go
Normal 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
189
migration/state.go
Normal 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
203
migration/users.go
Normal 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
45
utils/logging.go
Normal 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
51
utils/mentions.go
Normal 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
67
utils/naming.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user