diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-19 10:10:20 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-19 10:10:20 +0300 |
| commit | e5cf30e8df255fe4d4d34db7fc076f26a2b84fee (patch) | |
| tree | 64b625140b2db4a53f6de5cabe692f2d65272d58 | |
| parent | a8db7af2a094a16393f0060e628310d4161b154f (diff) | |
feat(sync): sync repository descriptions across Codeberg and GitHub\n\nfeat(version): bump to v0.9.0v0.9.0
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | internal/cli/description_cache.go | 38 | ||||
| -rw-r--r-- | internal/cli/description_sync.go | 113 | ||||
| -rw-r--r-- | internal/cli/sync_handlers.go | 142 | ||||
| -rw-r--r-- | internal/codeberg/codeberg.go | 69 | ||||
| -rw-r--r-- | internal/github/github.go | 74 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
7 files changed, 387 insertions, 52 deletions
@@ -37,3 +37,4 @@ test/work/ test/work-*/ test/repos/ .gitsyncer-work/ +tmp-workdir/ diff --git a/internal/cli/description_cache.go b/internal/cli/description_cache.go new file mode 100644 index 0000000..1cfc951 --- /dev/null +++ b/internal/cli/description_cache.go @@ -0,0 +1,38 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// loadDescriptionCache loads the per-repo canonical description cache +func loadDescriptionCache(workDir string) map[string]string { + cache := make(map[string]string) + cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json") + data, err := os.ReadFile(cacheFile) + if err != nil { + return cache + } + if err := json.Unmarshal(data, &cache); err != nil { + fmt.Printf("Warning: Failed to parse descriptions cache: %v\n", err) + return make(map[string]string) + } + fmt.Printf("Loaded descriptions cache with %d entries\n", len(cache)) + return cache +} + +// saveDescriptionCache saves the per-repo canonical description cache +func saveDescriptionCache(workDir string, cache map[string]string) error { + cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json") + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal descriptions cache: %w", err) + } + if err := os.WriteFile(cacheFile, data, 0644); err != nil { + return fmt.Errorf("failed to write descriptions cache: %w", err) + } + return nil +} + diff --git a/internal/cli/description_sync.go b/internal/cli/description_sync.go new file mode 100644 index 0000000..ae275ce --- /dev/null +++ b/internal/cli/description_sync.go @@ -0,0 +1,113 @@ +package cli + +import ( + "fmt" + "strings" + + "codeberg.org/snonux/gitsyncer/internal/codeberg" + "codeberg.org/snonux/gitsyncer/internal/config" + "codeberg.org/snonux/gitsyncer/internal/github" +) + +// syncRepoDescriptions ensures both platforms have the canonical description +// Precedence: Codeberg > GitHub; if Codeberg empty and GitHub has one, use GitHub. +// knownCBDesc and knownGHDesc can be empty; the function fetches as needed. +func syncRepoDescriptions(cfg *config.Config, dryRun bool, repoName, knownCBDesc, knownGHDesc string, cache map[string]string) { + // Load orgs + ghOrg := cfg.FindGitHubOrg() + cbOrg := cfg.FindCodebergOrg() + + var ghClient *github.Client + var cbClient *codeberg.Client + if ghOrg != nil { + c := github.NewClient(ghOrg.GitHubToken, ghOrg.Name) + ghClient = &c + } + if cbOrg != nil { + c := codeberg.NewClient(cbOrg.Name, cbOrg.CodebergToken) + cbClient = &c + } + + // Get current descriptions (use known if provided) + cbDesc := strings.TrimSpace(knownCBDesc) + ghDesc := strings.TrimSpace(knownGHDesc) + var cbExists, ghExists bool + + if cbDesc == "" && cbClient != nil { + if repo, exists, err := cbClient.GetRepo(repoName); err == nil { + cbExists = exists + if exists { + cbDesc = strings.TrimSpace(repo.Description) + } + } else { + fmt.Printf(" Warning: Codeberg repo lookup failed: %v\n", err) + } + } else if cbClient != nil { + cbExists = true + } + + if ghClient != nil { + if ghDesc == "" || !ghExists { + if repo, exists, err := ghClient.GetRepo(repoName); err == nil { + ghExists = exists + if exists { + ghDesc = strings.TrimSpace(repo.Description) + } + } else { + fmt.Printf(" Warning: GitHub repo lookup failed: %v\n", err) + } + } + } + + // Determine canonical description + canonical := cbDesc + if canonical == "" { + canonical = ghDesc + } + canonical = strings.TrimSpace(canonical) + + // If nothing to sync, bail + if canonical == "" { + return + } + + // Update Codeberg if needed + if cbClient != nil && cbExists { + if cbDesc != canonical { + if dryRun { + fmt.Printf(" [DRY RUN] Would update Codeberg description for %s -> %q\n", repoName, canonical) + } else if cbClient.HasToken() { + if err := cbClient.UpdateRepoDescription(repoName, canonical); err != nil { + fmt.Printf(" Warning: Failed to update Codeberg description: %v\n", err) + } else { + fmt.Printf(" Updated Codeberg description for %s\n", repoName) + } + } else { + fmt.Println(" Warning: No Codeberg token; cannot update description") + } + } + } + + // Update GitHub if needed + if ghClient != nil && ghExists { + if ghDesc != canonical { + if dryRun { + fmt.Printf(" [DRY RUN] Would update GitHub description for %s -> %q\n", repoName, canonical) + } else if ghClient.HasToken() { + if err := ghClient.UpdateRepoDescription(repoName, canonical); err != nil { + fmt.Printf(" Warning: Failed to update GitHub description: %v\n", err) + } else { + fmt.Printf(" Updated GitHub description for %s\n", repoName) + } + } else { + fmt.Println(" Warning: No GitHub token; cannot update description") + } + } + } + + // Update cache + if cache != nil { + cache[repoName] = canonical + } +} + diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go index 1878808..09b993a 100644 --- a/internal/cli/sync_handlers.go +++ b/internal/cli/sync_handlers.go @@ -31,11 +31,17 @@ func HandleSync(cfg *config.Config, flags *Flags) int { syncer := sync.New(cfg, flags.WorkDir) syncer.SetBackupEnabled(flags.Backup) - if err := syncer.SyncRepository(flags.SyncRepo); err != nil { - log.Fatal("Sync failed:", err) - return 1 - } - return 0 + if err := syncer.SyncRepository(flags.SyncRepo); err != nil { + log.Fatal("Sync failed:", err) + return 1 + } + // Also sync descriptions for this single repository + descCache := loadDescriptionCache(flags.WorkDir) + syncRepoDescriptions(cfg, flags.DryRun, flags.SyncRepo, "", "", descCache) + if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { + fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) + } + return 0 } // HandleSyncAll handles syncing all configured repositories @@ -65,9 +71,11 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { } } - syncer := sync.New(cfg, flags.WorkDir) - syncer.SetBackupEnabled(flags.Backup) - successCount := 0 + syncer := sync.New(cfg, flags.WorkDir) + syncer.SetBackupEnabled(flags.Backup) + successCount := 0 + // Load descriptions cache + descCache := loadDescriptionCache(flags.WorkDir) for i, repo := range cfg.Repositories { fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(cfg.Repositories), repo) @@ -89,14 +97,20 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { } } - if err := syncer.SyncRepository(repo); err != nil { - fmt.Printf("ERROR: Failed to sync %s: %v\n", repo, err) - fmt.Printf("Stopping sync due to error.\n") - return 1 - } - successCount++ - } - + if err := syncer.SyncRepository(repo); err != nil { + fmt.Printf("ERROR: Failed to sync %s: %v\n", repo, err) + fmt.Printf("Stopping sync due to error.\n") + return 1 + } + successCount++ + // Sync descriptions after repo sync + syncRepoDescriptions(cfg, flags.DryRun, repo, "", "", descCache) + } + // Save descriptions cache + if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { + fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) + } + fmt.Printf("\nSuccessfully synced all %d repositories!\n", successCount) // Print abandoned branches summary @@ -164,8 +178,8 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int { return 0 } - // Show the repositories that will be synced - showReposToSync(repoNames) + // Show the repositories that will be synced + showReposToSync(repoNames) if flags.DryRun { fmt.Printf("\n[DRY RUN] Would sync %d repositories from Codeberg to GitHub\n", len(repoNames)) @@ -177,9 +191,9 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int { } } - if !flags.DryRun { - return syncCodebergRepos(cfg, flags, repos, repoNames) - } + if !flags.DryRun { + return syncCodebergRepos(cfg, flags, repos, repoNames) + } return 0 } @@ -214,8 +228,8 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int { return 0 } - // Show the repositories that will be synced - showReposToSync(repoNames) + // Show the repositories that will be synced + showReposToSync(repoNames) if flags.DryRun { fmt.Printf("\n[DRY RUN] Would sync %d repositories from GitHub to Codeberg\n", len(repoNames)) @@ -225,9 +239,9 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int { return 0 } - if !flags.DryRun { - return syncGitHubRepos(cfg, flags, repos, repoNames) - } + if !flags.DryRun { + return syncGitHubRepos(cfg, flags, repos, repoNames) + } return 0 } @@ -333,7 +347,10 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi } } - fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) + fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) + + // Load descriptions cache + descCache := loadDescriptionCache(flags.WorkDir) syncer := sync.New(cfg, flags.WorkDir) syncer.SetBackupEnabled(flags.Backup) @@ -345,8 +362,8 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi repoMap[repo.Name] = repo } - for i, repoName := range repoNames { - fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) + for i, repoName := range repoNames { + fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) // Create GitHub repo if needed if hasGithubClient && flags.CreateGitHubRepos { @@ -363,15 +380,27 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi } } - 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") - return 1 - } - successCount++ - } - - fmt.Printf("\n=== Summary ===\n") + 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") + return 1 + } + successCount++ + + // After syncing, sync descriptions according to precedence + if cbRepo, ok := repoMap[repoName]; ok { + syncRepoDescriptions(cfg, flags.DryRun, repoName, cbRepo.Description, "", descCache) + } else { + syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache) + } + } + + // Save descriptions cache + if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { + fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) + } + + fmt.Printf("\n=== Summary ===\n") fmt.Printf("Successfully synced: %d repositories\n", successCount) // Print abandoned branches summary @@ -426,7 +455,10 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository } } - fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) + fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) + + // Load descriptions cache + descCache := loadDescriptionCache(flags.WorkDir) syncer := sync.New(cfg, flags.WorkDir) syncer.SetBackupEnabled(flags.Backup) @@ -438,8 +470,8 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository repoMap[repo.Name] = repo } - for i, repoName := range repoNames { - fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) + 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 { @@ -456,13 +488,25 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository } } - 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") - return 1 - } - successCount++ - } + 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") + return 1 + } + successCount++ + + // After syncing, sync descriptions according to precedence + if ghRepo, ok := repoMap[repoName]; ok { + syncRepoDescriptions(cfg, flags.DryRun, repoName, "", ghRepo.Description, descCache) + } else { + syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache) + } + } + + // Save descriptions cache + if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { + fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) + } fmt.Printf("\n=== Summary ===\n") fmt.Printf("Successfully synced: %d repositories\n", successCount) @@ -510,4 +554,4 @@ func ShowFullSyncMessage() { fmt.Println(" - Create missing GitHub repositories") fmt.Println(" - Create missing Codeberg repositories (when implemented)") fmt.Println() -}
\ No newline at end of file +} diff --git a/internal/codeberg/codeberg.go b/internal/codeberg/codeberg.go index e608016..356a14b 100644 --- a/internal/codeberg/codeberg.go +++ b/internal/codeberg/codeberg.go @@ -70,7 +70,74 @@ func (c *Client) loadToken(tokenFromConfig string) { // HasToken returns true if a token is loaded func (c *Client) HasToken() bool { - return c.token != "" + return c.token != "" +} + +// GetRepo fetches a repository by name +func (c *Client) GetRepo(repoName string) (Repository, bool, error) { + var repo Repository + url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return repo, false, err + } + if c.HasToken() { + req.Header.Set("Authorization", "token "+c.token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return repo, false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return repo, false, nil + } + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { + return repo, false, fmt.Errorf("failed to parse response: %w", err) + } + return repo, true, nil +} + +// UpdateRepoDescription updates a repository description on Codeberg +func (c *Client) UpdateRepoDescription(repoName, description string) error { + if !c.HasToken() { + return fmt.Errorf("Codeberg token required to update repository") + } + + url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) + payload := map[string]interface{}{ + "description": description, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + 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.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to update Codeberg description: %s - %s", resp.Status, string(b)) + } + return nil } // ListPublicRepos lists all public repositories for an organization diff --git a/internal/github/github.go b/internal/github/github.go index cee25f8..5bcc4f1 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -196,7 +196,79 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error { // HasToken returns whether a token is configured func (c *Client) HasToken() bool { - return c.token != "" + return c.token != "" +} + +// GetRepo fetches a single repository by name +// Returns the repository, a boolean indicating existence, and an error +func (c *Client) GetRepo(repoName string) (Repository, bool, error) { + var repo Repository + if c.token == "" { + return repo, false, fmt.Errorf("GitHub token required") + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return repo, false, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return repo, false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return repo, false, nil + } + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { + return repo, false, fmt.Errorf("failed to decode repo: %w", err) + } + return repo, true, nil +} + +// UpdateRepoDescription updates the repository description +func (c *Client) UpdateRepoDescription(repoName, description string) error { + if c.token == "" { + return fmt.Errorf("GitHub token required to update repository") + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) + payload := map[string]interface{}{ + "description": description, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to update GitHub description: %s - %s", resp.Status, string(b)) + } + return nil } // Repository represents a GitHub repository diff --git a/internal/version/version.go b/internal/version/version.go index 879c139..19abe3a 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( var ( // Version is the current version of gitsyncer - Version = "0.8.8" + Version = "0.9.0" // GitCommit is the git commit hash at build time GitCommit = "unknown" |
