summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-12 13:51:48 +0300
committerPaul Buetow <paul@buetow.org>2025-07-12 13:51:48 +0300
commitceb164c6a8826db1e763aecca49098a8c2584b7f (patch)
treea228040641666f4a6567e2d2e4544fd7daecc2e7
parent9d171e6b30a589240a16d8fef15e2195189e52a9 (diff)
feat: add automatic release checking and creation
- Add --check-releases flag for manual release checking - Enable automatic release checking after sync operations by default - Add --no-check-releases flag to disable automatic checking - Add --auto-create-releases flag for unattended release creation - Generate release notes from commit history - Support version tag formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X - Use tokens from gitsyncer config (with fallback to env vars and files) - Show release notes preview before creating releases - Group commits by type (features, fixes, other) in release notes
-rw-r--r--CLAUDE.md23
-rw-r--r--cmd/gitsyncer/main.go69
-rw-r--r--internal/cli/flags.go6
-rw-r--r--internal/cli/release.go315
-rw-r--r--internal/cli/sync_handlers.go81
-rw-r--r--internal/release/release.go461
-rw-r--r--internal/sync/branch_analyzer.go401
7 files changed, 1316 insertions, 40 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 2a5b22e..efcee08 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,8 +30,20 @@ task clean
# Delete a repository from all configured organizations (with confirmation)
./gitsyncer --delete-repo <repository-name>
+
+# Manually check for version tags without releases
+./gitsyncer --check-releases
+
+# Disable automatic release checking during sync operations
+./gitsyncer --sync-all --no-check-releases
+
+# Automatically create releases without confirmation prompts
+./gitsyncer --check-releases --auto-create-releases
+./gitsyncer --sync-all --auto-create-releases
```
+Note: Release checking is enabled by default after sync operations. It will check for version tags (formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X) that don't have corresponding releases on GitHub/Codeberg and prompt for confirmation before creating them.
+
Note: The Taskfile.yaml is configured for [go-task](https://taskfile.dev/). Install with:
```bash
# macOS
@@ -68,7 +80,9 @@ This follows the standard Go project layout with:
The application currently provides:
- Version information system (internal/version)
- CLI flag parsing for --version
-- Placeholder for main gitsyncer functionality
+- Automatic release checking and creation (internal/release)
+- Repository syncing across GitHub, Codeberg, and other platforms
+- Project showcase generation
## Next Steps
@@ -76,4 +90,9 @@ The project needs:
1. Support for other platforms (GitLab, Gitea, etc.)
2. Webhook support for automatic syncing
3. Conflict resolution strategies
-4. Better handling of large repositories \ No newline at end of file
+4. Better handling of large repositories
+
+## Release Process
+
+- When releasing a version, increment the version in version.go, commit all changes to git and push. and tag the version and push to git.
+- Gitsyncer will automatically detect the new version tag and prompt to create releases on GitHub and Codeberg using the tokens from your gitsyncer configuration file. \ No newline at end of file
diff --git a/cmd/gitsyncer/main.go b/cmd/gitsyncer/main.go
index f27cff6..d4a3e6f 100644
--- a/cmd/gitsyncer/main.go
+++ b/cmd/gitsyncer/main.go
@@ -6,6 +6,7 @@ import (
"path/filepath"
"codeberg.org/snonux/gitsyncer/internal/cli"
+ "codeberg.org/snonux/gitsyncer/internal/config"
"codeberg.org/snonux/gitsyncer/internal/state"
)
@@ -23,6 +24,15 @@ func saveBatchRunState(flags *cli.Flags) {
}
}
+// runReleaseCheckIfEnabled runs release checking after successful sync operations
+func runReleaseCheckIfEnabled(cfg *config.Config, flags *cli.Flags) {
+ // Run release checks automatically unless disabled
+ if !flags.NoCheckReleases {
+ fmt.Println("\nChecking for missing releases...")
+ cli.HandleCheckReleases(cfg, flags)
+ }
+}
+
func main() {
// Parse command-line flags
flags := cli.ParseFlags()
@@ -102,10 +112,13 @@ func main() {
// Handle sync operation
if flags.SyncRepo != "" {
exitCode := cli.HandleSync(cfg, flags)
- if exitCode == 0 && flags.Showcase {
- showcaseCode := cli.HandleShowcase(cfg, flags)
- if showcaseCode != 0 {
- os.Exit(showcaseCode)
+ if exitCode == 0 {
+ runReleaseCheckIfEnabled(cfg, flags)
+ if flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
}
}
os.Exit(exitCode)
@@ -114,10 +127,13 @@ func main() {
// Handle sync all operation
if flags.SyncAll {
exitCode := cli.HandleSyncAll(cfg, flags)
- if exitCode == 0 && flags.Showcase {
- showcaseCode := cli.HandleShowcase(cfg, flags)
- if showcaseCode != 0 {
- os.Exit(showcaseCode)
+ if exitCode == 0 {
+ runReleaseCheckIfEnabled(cfg, flags)
+ if flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
}
}
os.Exit(exitCode)
@@ -127,10 +143,13 @@ func main() {
if flags.SyncCodebergPublic {
exitCode := cli.HandleSyncCodebergPublic(cfg, flags)
if exitCode != 0 || !flags.SyncGitHubPublic {
- if exitCode == 0 && flags.Showcase && !flags.SyncGitHubPublic {
- showcaseCode := cli.HandleShowcase(cfg, flags)
- if showcaseCode != 0 {
- os.Exit(showcaseCode)
+ if exitCode == 0 {
+ runReleaseCheckIfEnabled(cfg, flags)
+ if flags.Showcase && !flags.SyncGitHubPublic {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
}
}
os.Exit(exitCode)
@@ -141,22 +160,30 @@ func main() {
if flags.SyncGitHubPublic {
exitCode := cli.HandleSyncGitHubPublic(cfg, flags)
- // Run showcase generation if requested and sync was successful
- if exitCode == 0 && flags.Showcase {
- showcaseCode := cli.HandleShowcase(cfg, flags)
- if showcaseCode != 0 {
- os.Exit(showcaseCode)
- }
- }
-
- // Save batch run state if this was a successful batch run
if exitCode == 0 {
+ // Run release checks after successful sync
+ runReleaseCheckIfEnabled(cfg, flags)
+
+ // Run showcase generation if requested
+ if flags.Showcase {
+ showcaseCode := cli.HandleShowcase(cfg, flags)
+ if showcaseCode != 0 {
+ os.Exit(showcaseCode)
+ }
+ }
+
+ // Save batch run state if this was a successful batch run
saveBatchRunState(flags)
}
os.Exit(exitCode)
}
+ // Handle check releases flag
+ if flags.CheckReleases {
+ os.Exit(cli.HandleCheckReleases(cfg, flags))
+ }
+
// Handle standalone showcase mode (no sync operations specified)
if flags.Showcase {
fmt.Println("Running showcase generation for all repositories (clone-only mode)...")
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index 3a9990f..72ffa1a 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -30,6 +30,9 @@ type Flags struct {
Showcase bool
Force bool
BatchRun bool
+ CheckReleases bool
+ NoCheckReleases bool
+ AutoCreateReleases bool
// Internal fields for batch run state management (not set by flags)
BatchRunStateManager *state.Manager
@@ -62,6 +65,9 @@ func ParseFlags() *Flags {
flag.BoolVar(&f.Showcase, "showcase", false, "generate project showcase using Claude after syncing")
flag.BoolVar(&f.Force, "force", false, "force regeneration of cached data")
flag.BoolVar(&f.BatchRun, "batch-run", false, "enable --full and --showcase (runs only once per week)")
+ flag.BoolVar(&f.CheckReleases, "check-releases", false, "manually check for version tags without releases and create them (with confirmation)")
+ flag.BoolVar(&f.NoCheckReleases, "no-check-releases", false, "disable automatic release checking after sync operations")
+ flag.BoolVar(&f.AutoCreateReleases, "auto-create-releases", false, "automatically create releases without confirmation prompts")
flag.Parse()
diff --git a/internal/cli/release.go b/internal/cli/release.go
new file mode 100644
index 0000000..a83283d
--- /dev/null
+++ b/internal/cli/release.go
@@ -0,0 +1,315 @@
+package cli
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "codeberg.org/snonux/gitsyncer/internal/config"
+ "codeberg.org/snonux/gitsyncer/internal/release"
+)
+
+// HandleCheckReleases checks for version tags without releases and creates them with confirmation
+func HandleCheckReleases(cfg *config.Config, flags *Flags) int {
+ releaseManager := release.NewManager(flags.WorkDir)
+
+ // Set tokens from config with fallback to environment variables and files
+ githubOrg := cfg.FindGitHubOrg()
+ if githubOrg != nil {
+ fmt.Printf("Found GitHub org: %s\n", githubOrg.Name)
+
+ // Try config token first, then fallback to env var and file
+ token := githubOrg.GitHubToken
+ if token == "" {
+ fmt.Println("No GitHub token in config, checking environment variable...")
+ token = os.Getenv("GITHUB_TOKEN")
+ }
+ if token == "" {
+ fmt.Println("No GITHUB_TOKEN env var, checking ~/.gitsyncer_github_token file...")
+ home, err := os.UserHomeDir()
+ if err == nil {
+ tokenFile := filepath.Join(home, ".gitsyncer_github_token")
+ data, err := os.ReadFile(tokenFile)
+ if err == nil {
+ token = strings.TrimSpace(string(data))
+ }
+ }
+ }
+
+ if token != "" {
+ fmt.Printf("Setting GitHub token (length: %d)\n", len(token))
+ releaseManager.SetGitHubToken(token)
+ } else {
+ fmt.Println("No GitHub token found")
+ }
+ } else {
+ fmt.Println("No GitHub organization found in config")
+ }
+
+ codebergOrg := cfg.FindCodebergOrg()
+ if codebergOrg != nil {
+ fmt.Printf("Found Codeberg org: %s\n", codebergOrg.Name)
+
+ // Try config token first, then fallback to env var and file
+ token := codebergOrg.CodebergToken
+ if token == "" {
+ fmt.Println("No Codeberg token in config, checking environment variable...")
+ token = os.Getenv("CODEBERG_TOKEN")
+ }
+ if token == "" {
+ fmt.Println("No CODEBERG_TOKEN env var, checking ~/.gitsyncer_codeberg_token file...")
+ home, err := os.UserHomeDir()
+ if err == nil {
+ tokenFile := filepath.Join(home, ".gitsyncer_codeberg_token")
+ data, err := os.ReadFile(tokenFile)
+ if err == nil {
+ token = strings.TrimSpace(string(data))
+ }
+ }
+ }
+
+ if token != "" {
+ fmt.Printf("Setting Codeberg token (length: %d)\n", len(token))
+ releaseManager.SetCodebergToken(token)
+ } else {
+ fmt.Println("No Codeberg token found")
+ }
+ } else {
+ fmt.Println("No Codeberg organization found in config")
+ }
+
+ // Process all configured repositories
+ for _, repoName := range cfg.Repositories {
+ fmt.Printf("\nChecking releases for repository: %s\n", repoName)
+
+ // Check if the repository is cloned locally
+ repoPath := filepath.Join(flags.WorkDir, repoName)
+ if _, err := os.Stat(repoPath); os.IsNotExist(err) {
+ fmt.Printf(" Repository not found locally at %s, skipping...\n", repoPath)
+ continue
+ }
+
+ // Get local tags
+ localTags, err := releaseManager.GetLocalTags(repoPath)
+ if err != nil {
+ fmt.Printf(" Error getting local tags: %v\n", err)
+ continue
+ }
+
+ if len(localTags) == 0 {
+ fmt.Println(" No version tags found")
+ continue
+ }
+
+ fmt.Printf(" Found %d version tags: %s\n", len(localTags), strings.Join(localTags, ", "))
+
+ // Check GitHub releases if GitHub is configured
+ var missingGitHub []string
+ githubOrg := cfg.FindGitHubOrg()
+ if githubOrg != nil && githubOrg.Name != "" {
+ githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName)
+ if err != nil {
+ fmt.Printf(" Error checking GitHub releases: %v\n", err)
+ } else {
+ missingGitHub = releaseManager.FindMissingReleases(localTags, githubReleases)
+ if len(missingGitHub) > 0 {
+ fmt.Printf(" Missing GitHub releases: %s\n", strings.Join(missingGitHub, ", "))
+ }
+ }
+ }
+
+ // Check Codeberg releases if Codeberg is configured
+ var missingCodeberg []string
+ codebergOrg := cfg.FindCodebergOrg()
+ if codebergOrg != nil && codebergOrg.Name != "" {
+ codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName)
+ if err != nil {
+ fmt.Printf(" Error checking Codeberg releases: %v\n", err)
+ } else {
+ missingCodeberg = releaseManager.FindMissingReleases(localTags, codebergReleases)
+ if len(missingCodeberg) > 0 {
+ fmt.Printf(" Missing Codeberg releases: %s\n", strings.Join(missingCodeberg, ", "))
+ }
+ }
+ }
+
+ // Create missing releases with confirmation
+ if len(missingGitHub) > 0 && githubOrg != nil {
+ for _, tag := range missingGitHub {
+ // Generate release notes
+ releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags)
+
+ msg := fmt.Sprintf("Create GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag)
+
+ // Check if auto-create is enabled
+ createRelease := false
+ if flags.AutoCreateReleases {
+ fmt.Printf(" Auto-creating GitHub release for %s/%s tag %s\n", githubOrg.Name, repoName, tag)
+ createRelease = true
+ } else {
+ createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes)
+ }
+
+ if createRelease {
+ if err := releaseManager.CreateGitHubRelease(githubOrg.Name, repoName, tag, releaseNotes); err != nil {
+ fmt.Printf(" Error creating GitHub release: %v\n", err)
+ } else {
+ fmt.Printf(" Created GitHub release for tag %s\n", tag)
+ }
+ }
+ }
+ }
+
+ if len(missingCodeberg) > 0 && codebergOrg != nil {
+ for _, tag := range missingCodeberg {
+ // Generate release notes
+ releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags)
+
+ msg := fmt.Sprintf("Create Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag)
+
+ // Check if auto-create is enabled
+ createRelease := false
+ if flags.AutoCreateReleases {
+ fmt.Printf(" Auto-creating Codeberg release for %s/%s tag %s\n", codebergOrg.Name, repoName, tag)
+ createRelease = true
+ } else {
+ createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes)
+ }
+
+ if createRelease {
+ if err := releaseManager.CreateCodebergRelease(codebergOrg.Name, repoName, tag, releaseNotes); err != nil {
+ fmt.Printf(" Error creating Codeberg release: %v\n", err)
+ } else {
+ fmt.Printf(" Created Codeberg release for tag %s\n", tag)
+ }
+ }
+ }
+ }
+ }
+
+ // Also check public repositories if they're synced
+ if flags.SyncGitHubPublic || flags.SyncCodebergPublic {
+ fmt.Println("\nChecking public repositories...")
+
+ // Process synced public repos from work directory
+ entries, err := os.ReadDir(flags.WorkDir)
+ if err == nil {
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ repoName := entry.Name()
+ repoPath := filepath.Join(flags.WorkDir, repoName)
+
+ // Skip if it's already in configured repositories
+ isConfigured := false
+ for _, configuredRepo := range cfg.Repositories {
+ if configuredRepo == repoName {
+ isConfigured = true
+ break
+ }
+ }
+
+ if isConfigured {
+ continue
+ }
+
+ // Check if it's a git repository
+ if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) {
+ continue
+ }
+
+ fmt.Printf("\nChecking releases for public repository: %s\n", repoName)
+
+ // Get local tags
+ localTags, err := releaseManager.GetLocalTags(repoPath)
+ if err != nil {
+ fmt.Printf(" Error getting local tags: %v\n", err)
+ continue
+ }
+
+ if len(localTags) == 0 {
+ fmt.Println(" No version tags found")
+ continue
+ }
+
+ fmt.Printf(" Found %d version tags: %s\n", len(localTags), strings.Join(localTags, ", "))
+
+ // Check releases on both platforms
+ githubOrg := cfg.FindGitHubOrg()
+ if githubOrg != nil && githubOrg.Name != "" {
+ githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName)
+ if err == nil {
+ missingGitHub := releaseManager.FindMissingReleases(localTags, githubReleases)
+ if len(missingGitHub) > 0 {
+ fmt.Printf(" Missing GitHub releases: %s\n", strings.Join(missingGitHub, ", "))
+
+ for _, tag := range missingGitHub {
+ // Generate release notes
+ releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags)
+
+ msg := fmt.Sprintf("Create GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag)
+
+ // Check if auto-create is enabled
+ createRelease := false
+ if flags.AutoCreateReleases {
+ fmt.Printf(" Auto-creating GitHub release for %s/%s tag %s\n", githubOrg.Name, repoName, tag)
+ createRelease = true
+ } else {
+ createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes)
+ }
+
+ if createRelease {
+ if err := releaseManager.CreateGitHubRelease(githubOrg.Name, repoName, tag, releaseNotes); err != nil {
+ fmt.Printf(" Error creating GitHub release: %v\n", err)
+ } else {
+ fmt.Printf(" Created GitHub release for tag %s\n", tag)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ codebergOrg := cfg.FindCodebergOrg()
+ if codebergOrg != nil && codebergOrg.Name != "" {
+ codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName)
+ if err == nil {
+ missingCodeberg := releaseManager.FindMissingReleases(localTags, codebergReleases)
+ if len(missingCodeberg) > 0 {
+ fmt.Printf(" Missing Codeberg releases: %s\n", strings.Join(missingCodeberg, ", "))
+
+ for _, tag := range missingCodeberg {
+ // Generate release notes
+ releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags)
+
+ msg := fmt.Sprintf("Create Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag)
+
+ // Check if auto-create is enabled
+ createRelease := false
+ if flags.AutoCreateReleases {
+ fmt.Printf(" Auto-creating Codeberg release for %s/%s tag %s\n", codebergOrg.Name, repoName, tag)
+ createRelease = true
+ } else {
+ createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes)
+ }
+
+ if createRelease {
+ if err := releaseManager.CreateCodebergRelease(codebergOrg.Name, repoName, tag, releaseNotes); err != nil {
+ fmt.Printf(" Error creating Codeberg release: %v\n", err)
+ } else {
+ fmt.Printf(" Created Codeberg release for tag %s\n", tag)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return 0
+} \ No newline at end of file
diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go
index 619b01e..d87d771 100644
--- a/internal/cli/sync_handlers.go
+++ b/internal/cli/sync_handlers.go
@@ -78,6 +78,33 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int {
fmt.Print(summary)
}
+ // Generate script for abandoned branches
+ if scriptPath, err := syncer.GenerateDeleteScript(); err != nil {
+ fmt.Printf("\n⚠️ Failed to generate script: %v\n", err)
+ } else if scriptPath != "" {
+ fmt.Printf("\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n📋 ABANDONED BRANCH MANAGEMENT SCRIPT\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n")
+ fmt.Printf("Generated script: %s\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("Usage:\n")
+ fmt.Printf(" bash %s --review # Review diffs before deletion\n", scriptPath)
+ fmt.Printf(" bash %s --review-full # Review full diffs\n", scriptPath)
+ fmt.Printf(" bash %s --dry-run # Preview what will be deleted\n", scriptPath)
+ fmt.Printf(" bash %s # Delete branches (with confirmation)\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("💡 Recommended workflow:\n")
+ fmt.Printf(" 1. Review branches: bash %s --review\n", scriptPath)
+ fmt.Printf(" 2. Dry-run delete: bash %s --dry-run\n", scriptPath)
+ fmt.Printf(" 3. Delete branches: bash %s\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("⚠️ WARNING: Review carefully before deleting branches!\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n")
+ }
+
return 0
}
@@ -309,6 +336,33 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi
fmt.Print(summary)
}
+ // Generate script for abandoned branches
+ if scriptPath, err := syncer.GenerateDeleteScript(); err != nil {
+ fmt.Printf("\n⚠️ Failed to generate script: %v\n", err)
+ } else if scriptPath != "" {
+ fmt.Printf("\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n📋 ABANDONED BRANCH MANAGEMENT SCRIPT\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n")
+ fmt.Printf("Generated script: %s\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("Usage:\n")
+ fmt.Printf(" bash %s --review # Review diffs before deletion\n", scriptPath)
+ fmt.Printf(" bash %s --review-full # Review full diffs\n", scriptPath)
+ fmt.Printf(" bash %s --dry-run # Preview what will be deleted\n", scriptPath)
+ fmt.Printf(" bash %s # Delete branches (with confirmation)\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("💡 Recommended workflow:\n")
+ fmt.Printf(" 1. Review branches: bash %s --review\n", scriptPath)
+ fmt.Printf(" 2. Dry-run delete: bash %s --dry-run\n", scriptPath)
+ fmt.Printf(" 3. Delete branches: bash %s\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("⚠️ WARNING: Review carefully before deleting branches!\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n")
+ }
+
if !flags.SyncGitHubPublic {
return 0
}
@@ -374,6 +428,33 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository
if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" {
fmt.Print(summary)
}
+
+ // Generate script for abandoned branches
+ if scriptPath, err := syncer.GenerateDeleteScript(); err != nil {
+ fmt.Printf("\n⚠️ Failed to generate script: %v\n", err)
+ } else if scriptPath != "" {
+ fmt.Printf("\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n📋 ABANDONED BRANCH MANAGEMENT SCRIPT\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n")
+ fmt.Printf("Generated script: %s\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("Usage:\n")
+ fmt.Printf(" bash %s --review # Review diffs before deletion\n", scriptPath)
+ fmt.Printf(" bash %s --review-full # Review full diffs\n", scriptPath)
+ fmt.Printf(" bash %s --dry-run # Preview what will be deleted\n", scriptPath)
+ fmt.Printf(" bash %s # Delete branches (with confirmation)\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("💡 Recommended workflow:\n")
+ fmt.Printf(" 1. Review branches: bash %s --review\n", scriptPath)
+ fmt.Printf(" 2. Dry-run delete: bash %s --dry-run\n", scriptPath)
+ fmt.Printf(" 3. Delete branches: bash %s\n", scriptPath)
+ fmt.Printf("\n")
+ fmt.Printf("⚠️ WARNING: Review carefully before deleting branches!\n")
+ fmt.Printf(strings.Repeat("=", 70))
+ fmt.Printf("\n")
+ }
return 0
}
diff --git a/internal/release/release.go b/internal/release/release.go
new file mode 100644
index 0000000..c66d5f9
--- /dev/null
+++ b/internal/release/release.go
@@ -0,0 +1,461 @@
+package release
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os/exec"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// Tag represents a git tag
+type Tag struct {
+ Name string
+}
+
+// Release represents a release on a platform
+type Release struct {
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ Body string `json:"body"`
+}
+
+// Manager handles release operations
+type Manager struct {
+ workDir string
+ githubToken string
+ codebergToken string
+}
+
+// NewManager creates a new release manager
+func NewManager(workDir string) *Manager {
+ return &Manager{
+ workDir: workDir,
+ }
+}
+
+// SetGitHubToken sets the GitHub token for API authentication
+func (m *Manager) SetGitHubToken(token string) {
+ m.githubToken = token
+}
+
+// SetCodebergToken sets the Codeberg token for API authentication
+func (m *Manager) SetCodebergToken(token string) {
+ m.codebergToken = token
+}
+
+// isVersionTag checks if a tag name is a version tag
+// Supports formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X
+func isVersionTag(tag string) bool {
+ // Pattern matches version tags with optional 'v' prefix
+ pattern := `^v?\d+(\.\d+)?(\.\d+)?$`
+ matched, _ := regexp.MatchString(pattern, tag)
+ return matched
+}
+
+// GetLocalTags returns all version tags from the local git repository
+func (m *Manager) GetLocalTags(repoPath string) ([]string, error) {
+ cmd := exec.Command("git", "-C", repoPath, "tag", "--list")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get git tags: %w", err)
+ }
+
+ var versionTags []string
+ tags := strings.Split(strings.TrimSpace(string(output)), "\n")
+
+ for _, tag := range tags {
+ tag = strings.TrimSpace(tag)
+ if tag != "" && isVersionTag(tag) {
+ versionTags = append(versionTags, tag)
+ }
+ }
+
+ // Sort tags by version
+ sort.Slice(versionTags, func(i, j int) bool {
+ return compareVersions(versionTags[i], versionTags[j]) < 0
+ })
+
+ return versionTags, nil
+}
+
+// compareVersions compares two version strings
+// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
+func compareVersions(v1, v2 string) int {
+ // Remove 'v' prefix if present
+ v1 = strings.TrimPrefix(v1, "v")
+ v2 = strings.TrimPrefix(v2, "v")
+
+ parts1 := strings.Split(v1, ".")
+ parts2 := strings.Split(v2, ".")
+
+ // Pad with zeros to make equal length
+ maxLen := len(parts1)
+ if len(parts2) > maxLen {
+ maxLen = len(parts2)
+ }
+
+ for i := 0; i < maxLen; i++ {
+ var n1, n2 int
+
+ if i < len(parts1) {
+ fmt.Sscanf(parts1[i], "%d", &n1)
+ }
+ if i < len(parts2) {
+ fmt.Sscanf(parts2[i], "%d", &n2)
+ }
+
+ if n1 < n2 {
+ return -1
+ } else if n1 > n2 {
+ return 1
+ }
+ }
+
+ return 0
+}
+
+// GetCommitsSinceTag gets all commits since a specific tag
+func (m *Manager) GetCommitsSinceTag(repoPath, fromTag, toTag string) ([]string, error) {
+ // Use git log to get commits between tags
+ // If fromTag is empty, get all commits up to toTag
+ var cmd *exec.Cmd
+ if fromTag == "" {
+ cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", toTag)
+ } else {
+ cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", fmt.Sprintf("%s..%s", fromTag, toTag))
+ }
+
+ output, err := cmd.Output()
+ if err != nil {
+ // If error, it might be because fromTag doesn't exist, try without it
+ cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", toTag)
+ output, err = cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get commits: %w", err)
+ }
+ }
+
+ if len(output) == 0 {
+ return []string{}, nil
+ }
+
+ commits := strings.Split(strings.TrimSpace(string(output)), "\n")
+ // Reverse to show oldest first
+ for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
+ commits[i], commits[j] = commits[j], commits[i]
+ }
+
+ return commits, nil
+}
+
+// GenerateReleaseNotes generates release notes from commits
+func (m *Manager) GenerateReleaseNotes(repoPath, tag string, allTags []string) string {
+ // Find the previous tag
+ var prevTag string
+ tagIndex := -1
+ for i, t := range allTags {
+ if t == tag {
+ tagIndex = i
+ break
+ }
+ }
+
+ if tagIndex > 0 {
+ prevTag = allTags[tagIndex-1]
+ }
+
+ // Get commits since previous tag
+ commits, err := m.GetCommitsSinceTag(repoPath, prevTag, tag)
+ if err != nil {
+ return fmt.Sprintf("Release %s", tag)
+ }
+
+ if len(commits) == 0 {
+ return fmt.Sprintf("Release %s", tag)
+ }
+
+ // Group commits by type
+ var features, fixes, other []string
+
+ for _, commit := range commits {
+ lower := strings.ToLower(commit)
+ if strings.HasPrefix(lower, "feat:") || strings.HasPrefix(lower, "feature:") {
+ features = append(features, commit)
+ } else if strings.HasPrefix(lower, "fix:") || strings.HasPrefix(lower, "bugfix:") {
+ fixes = append(fixes, commit)
+ } else {
+ other = append(other, commit)
+ }
+ }
+
+ // Build release notes
+ var notes strings.Builder
+ notes.WriteString(fmt.Sprintf("Release %s\n\n", tag))
+
+ if prevTag != "" {
+ notes.WriteString(fmt.Sprintf("Changes since %s:\n\n", prevTag))
+ }
+
+ if len(features) > 0 {
+ notes.WriteString("## New Features\n\n")
+ for _, feat := range features {
+ notes.WriteString(fmt.Sprintf("- %s\n", feat))
+ }
+ notes.WriteString("\n")
+ }
+
+ if len(fixes) > 0 {
+ notes.WriteString("## Bug Fixes\n\n")
+ for _, fix := range fixes {
+ notes.WriteString(fmt.Sprintf("- %s\n", fix))
+ }
+ notes.WriteString("\n")
+ }
+
+ if len(other) > 0 {
+ notes.WriteString("## Other Changes\n\n")
+ for _, commit := range other {
+ notes.WriteString(fmt.Sprintf("- %s\n", commit))
+ }
+ notes.WriteString("\n")
+ }
+
+ notes.WriteString(fmt.Sprintf("\n**Full Changelog**: %s...%s", prevTag, tag))
+
+ return notes.String()
+}
+
+// GetGitHubReleases fetches releases from GitHub
+func (m *Manager) GetGitHubReleases(owner, repo string) ([]string, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // Add GitHub token if available
+ if m.githubToken != "" {
+ req.Header.Set("Authorization", "Bearer "+m.githubToken)
+ }
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 404 {
+ // Repository might not exist on GitHub
+ return []string{}, nil
+ }
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(body))
+ }
+
+ var releases []Release
+ if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
+ return nil, err
+ }
+
+ var tags []string
+ for _, release := range releases {
+ tags = append(tags, release.TagName)
+ }
+
+ return tags, nil
+}
+
+// GetCodebergReleases fetches releases from Codeberg
+func (m *Manager) GetCodebergReleases(owner, repo string) ([]string, error) {
+ url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases", owner, repo)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // Add Codeberg token if available
+ if m.codebergToken != "" {
+ req.Header.Set("Authorization", "token "+m.codebergToken)
+ }
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 404 {
+ // Repository might not exist on Codeberg
+ return []string{}, nil
+ }
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("Codeberg API error: %s - %s", resp.Status, string(body))
+ }
+
+ var releases []Release
+ if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
+ return nil, err
+ }
+
+ var tags []string
+ for _, release := range releases {
+ tags = append(tags, release.TagName)
+ }
+
+ return tags, nil
+}
+
+// FindMissingReleases finds tags that don't have releases
+func (m *Manager) FindMissingReleases(localTags, releaseTags []string) []string {
+ releaseMap := make(map[string]bool)
+ for _, tag := range releaseTags {
+ releaseMap[tag] = true
+ }
+
+ var missing []string
+ for _, tag := range localTags {
+ if !releaseMap[tag] {
+ missing = append(missing, tag)
+ }
+ }
+
+ return missing
+}
+
+// CreateGitHubRelease creates a release on GitHub
+func (m *Manager) CreateGitHubRelease(owner, repo, tag, releaseNotes string) error {
+ if m.githubToken == "" {
+ return fmt.Errorf("GitHub token is required for creating releases")
+ }
+
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
+
+ // Use provided release notes or default
+ body := releaseNotes
+ if body == "" {
+ body = fmt.Sprintf("Release %s", tag)
+ }
+
+ release := Release{
+ TagName: tag,
+ Name: tag,
+ Body: body,
+ }
+
+ jsonData, err := json.Marshal(release)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+m.githubToken)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("failed to create GitHub release: %s - %s", resp.Status, string(body))
+ }
+
+ return nil
+}
+
+// CreateCodebergRelease creates a release on Codeberg
+func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) error {
+ if m.codebergToken == "" {
+ return fmt.Errorf("Codeberg token is required for creating releases")
+ }
+
+ url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases", owner, repo)
+
+ // Use provided release notes or default
+ body := releaseNotes
+ if body == "" {
+ body = fmt.Sprintf("Release %s", tag)
+ }
+
+ release := Release{
+ TagName: tag,
+ Name: tag,
+ Body: body,
+ }
+
+ jsonData, err := json.Marshal(release)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return err
+ }
+
<