summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-19 10:10:20 +0300
committerPaul Buetow <paul@buetow.org>2025-08-19 10:10:20 +0300
commite5cf30e8df255fe4d4d34db7fc076f26a2b84fee (patch)
tree64b625140b2db4a53f6de5cabe692f2d65272d58
parenta8db7af2a094a16393f0060e628310d4161b154f (diff)
feat(sync): sync repository descriptions across Codeberg and GitHub\n\nfeat(version): bump to v0.9.0v0.9.0
-rw-r--r--.gitignore1
-rw-r--r--internal/cli/description_cache.go38
-rw-r--r--internal/cli/description_sync.go113
-rw-r--r--internal/cli/sync_handlers.go142
-rw-r--r--internal/codeberg/codeberg.go69
-rw-r--r--internal/github/github.go74
-rw-r--r--internal/version/version.go2
7 files changed, 387 insertions, 52 deletions
diff --git a/.gitignore b/.gitignore
index 4c860b6..2e39ff6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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"