From 79e882713b195e4674dc693169ecf7dbf956a91e Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 27 Jun 2025 23:24:36 +0300 Subject: feat: implement --create-codeberg-repos --- README.md | 2 +- doc/configuration.md | 50 ++++++++++++++++++++ internal/cli/sync_handlers.go | 63 +++++++++++++++++++++---- internal/codeberg/codeberg.go | 105 +++++++++++++++++++++++++++++++++++++++++- internal/config/config.go | 7 +-- 5 files changed, 211 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b44ef36..a0b5334 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ GitSyncer is a tool for synchronizing git repositories between multiple organiza - Batch sync multiple repositories with a single command - Sync all public repositories from Codeberg to GitHub - Sync all public repositories from GitHub to Codeberg -- Automatic repository creation on GitHub (Codeberg support planned) +- Automatic repository creation on GitHub and Codeberg - Merge conflict detection with clear error messages - Never deletes branches (only adds/updates) - GitHub token validation tool diff --git a/doc/configuration.md b/doc/configuration.md index c4dcc50..e3b94cb 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -55,6 +55,9 @@ Array of organization objects. At least one organization must be configured. - **github_token** (string, optional): GitHub personal access token - Only needed for GitHub organizations - Can also be set via environment variable or file +- **codeberg_token** (string, optional): Codeberg personal access token + - Only needed for Codeberg organizations + - Can also be set via environment variable or file #### repositories (optional) Array of repository names to sync. If empty, use `--sync-codeberg-public` or `--sync-github-public` to discover repositories. @@ -192,6 +195,53 @@ chmod 600 ~/.gitsyncer_github_token gitsyncer --test-github-token ``` +## Codeberg Token Configuration + +Codeberg tokens are required for: +- Creating repositories (`--create-codeberg-repos`) +- Listing private repositories + +### Token Sources (in order of precedence) + +1. **Configuration file**: `codeberg_token` field in organization object +2. **Environment variable**: `CODEBERG_TOKEN` +3. **Token file**: `~/.gitsyncer_codeberg_token` + +### Creating a Codeberg Token + +1. Go to Codeberg Settings → Applications → Manage Access Tokens +2. Click "Generate New Token" +3. Select scopes: + - `repository` (full control of repositories) +4. Save the token securely + +### Setting the Token + +#### Method 1: Configuration File +```json +{ + "organizations": [ + { + "host": "git@codeberg.org", + "name": "myorg", + "codeberg_token": "xxxxxxxxxxxx" + } + ] +} +``` + +#### Method 2: Environment Variable +```bash +export CODEBERG_TOKEN="xxxxxxxxxxxx" +gitsyncer --sync-all +``` + +#### Method 3: Token File +```bash +echo "xxxxxxxxxxxx" > ~/.gitsyncer_codeberg_token +chmod 600 ~/.gitsyncer_codeberg_token +``` + ## Branch Exclusion Patterns The `exclude_branches` field accepts regular expressions to filter out branches from synchronization. diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go index 4fbb6cf..c1fefb6 100644 --- a/internal/cli/sync_handlers.go +++ b/internal/cli/sync_handlers.go @@ -89,7 +89,7 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int { fmt.Printf("Fetching public repositories from Codeberg user/org: %s...\n", codebergOrg.Name) - client := codeberg.NewClient(codebergOrg.Name) + client := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken) // Try fetching as organization first, then as user repos, err := client.ListPublicRepos() @@ -219,6 +219,24 @@ func createRepoWithClient(client *github.Client, repoName, description string) e return client.CreateRepo(repoName, description, false) } +func initCodebergClient(cfg *config.Config) *codeberg.Client { + codebergOrg := cfg.FindCodebergOrg() + if codebergOrg == nil { + fmt.Println("Warning: --create-codeberg-repos specified but no Codeberg organization found in config") + return nil + } + + fmt.Printf("Initializing Codeberg client for organization: %s\n", codebergOrg.Name) + codebergClient := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken) + if !codebergClient.HasToken() { + fmt.Println("Warning: No Codeberg token found. Cannot create repositories.") + return nil + } + + fmt.Println("Codeberg client initialized successfully with token") + return &codebergClient +} + func showReposToSync(repoNames []string) { fmt.Println("\nRepositories to sync:") for _, name := range repoNames { @@ -298,20 +316,45 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi } func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository, repoNames []string) int { - // TODO: Add Codeberg API client for repo creation + // Initialize Codeberg client if needed + var codebergClient codeberg.Client + var hasCodebergClient bool if flags.CreateCodebergRepos { - fmt.Println("WARNING: --create-codeberg-repos is not yet implemented") - fmt.Println(" Repositories must exist on Codeberg before syncing") + if client := initCodebergClient(cfg); client != nil { + codebergClient = *client + hasCodebergClient = true + } } - + fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) - + syncer := sync.New(cfg, flags.WorkDir) successCount := 0 - + + // Create map for descriptions + repoMap := make(map[string]github.Repository) + for _, repo := range repos { + repoMap[repo.Name] = repo + } + for i, repoName := range repoNames { fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) - + + // Create Codeberg repo if needed + if hasCodebergClient && flags.CreateCodebergRepos { + githubRepo := repoMap[repoName] + description := githubRepo.Description + if description == "" { + description = fmt.Sprintf("Mirror of %s from GitHub", repoName) + } + + fmt.Printf("Checking/creating Codeberg repository %s...\n", repoName) + err := codebergClient.CreateRepo(repoName, description, false) + if err != nil { + fmt.Printf("Warning: Failed to create Codeberg repo %s: %v\n", repoName, err) + } + } + if err := syncer.SyncRepository(repoName); err != nil { fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err) fmt.Printf("Stopping sync due to error.\n") @@ -322,12 +365,12 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository fmt.Printf("\n=== Summary ===\n") fmt.Printf("Successfully synced: %d repositories\n", successCount) - + // Print abandoned branches summary if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" { fmt.Print(summary) } - + return 0 } diff --git a/internal/codeberg/codeberg.go b/internal/codeberg/codeberg.go index 4f0a76d..1cf5659 100644 --- a/internal/codeberg/codeberg.go +++ b/internal/codeberg/codeberg.go @@ -1,9 +1,12 @@ package codeberg import ( + "bytes" "encoding/json" "fmt" "net/http" + "os" + "path/filepath" "time" ) @@ -28,14 +31,45 @@ type Repository struct { type Client struct { baseURL string org string + token string } // NewClient creates a new Codeberg API client -func NewClient(org string) Client { - return Client{ +func NewClient(org, token string) Client { + c := Client{ baseURL: "https://codeberg.org/api/v1", org: org, } + c.loadToken(token) + return c +} + +// loadToken loads the Codeberg API token from config, env, or file +func (c *Client) loadToken(tokenFromConfig string) { + if tokenFromConfig != "" { + c.token = tokenFromConfig + return + } + + // Check environment variable + if token := os.Getenv("CODEBERG_TOKEN"); token != "" { + c.token = token + return + } + + // Check token file + home, err := os.UserHomeDir() + if err == nil { + tokenFile := filepath.Join(home, ".gitsyncer_codeberg_token") + if data, err := os.ReadFile(tokenFile); err == nil { + c.token = string(data) + } + } +} + +// HasToken returns true if a token is loaded +func (c *Client) HasToken() bool { + return c.token != "" } // ListPublicRepos lists all public repositories for an organization @@ -130,3 +164,70 @@ func GetRepoNames(repos []Repository) []string { } return names } + +// RepoExists checks if a repository exists on Codeberg +func (c *Client) RepoExists(repoName string) (bool, error) { + url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + if c.HasToken() { + req.Header.Set("Authorization", "token "+c.token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + return resp.StatusCode == 200, nil +} + +// CreateRepo creates a new repository on Codeberg +func (c *Client) CreateRepo(repoName, description string, private bool) error { + exists, err := c.RepoExists(repoName) + if err != nil { + return fmt.Errorf("failed to check if repo exists: %w", err) + } + if exists { + return nil // Repository already exists + } + + url := fmt.Sprintf("%s/user/repos", c.baseURL) + + payload := map[string]interface{}{ + "name": repoName, + "description": description, + "private": private, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + if c.HasToken() { + req.Header.Set("Authorization", "token "+c.token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("failed to create repository: status code %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 92d2c86..ef8e3b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,9 +10,10 @@ import ( // Organization represents a git organization with its host and name type Organization struct { - Host string `json:"host"` - Name string `json:"name"` - GitHubToken string `json:"github_token,omitempty"` + Host string `json:"host"` + Name string `json:"name"` + GitHubToken string `json:"github_token,omitempty"` + CodebergToken string `json:"codeberg_token,omitempty"` } // Config holds the application configuration -- cgit v1.2.3