summaryrefslogtreecommitdiff
path: root/internal
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 /internal
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
Diffstat (limited to 'internal')
-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
5 files changed, 1247 insertions, 17 deletions
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
+ }
+
+ req.Header.Set("Authorization", "token "+m.codebergToken)
+ req.Header.Set("Content-Type", "application/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 Codeberg release: %s - %s", resp.Status, string(body))
+ }
+
+ return nil
+}
+
+// PromptConfirmation asks for user confirmation
+func PromptConfirmation(message string) bool {
+ fmt.Printf("%s [y/N]: ", message)
+
+ var response string
+ fmt.Scanln(&response)
+
+ response = strings.ToLower(strings.TrimSpace(response))
+ return response == "y" || response == "yes"
+}
+
+// PromptConfirmationWithNotes asks for user confirmation and shows release notes
+func PromptConfirmationWithNotes(message, releaseNotes string) bool {
+ fmt.Printf("\n%s\n", strings.Repeat("-", 70))
+ fmt.Printf("Release Notes:\n%s\n", strings.Repeat("-", 70))
+ fmt.Println(releaseNotes)
+ fmt.Printf("%s\n\n", strings.Repeat("-", 70))
+
+ fmt.Printf("%s [y/N]: ", message)
+
+ var response string
+ fmt.Scanln(&response)
+
+ response = strings.ToLower(strings.TrimSpace(response))
+ return response == "y" || response == "yes"
+} \ No newline at end of file
diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go
index f684f60..95827db 100644
--- a/internal/sync/branch_analyzer.go
+++ b/internal/sync/branch_analyzer.go
@@ -2,7 +2,9 @@ package sync
import (
"fmt"
+ "os"
"os/exec"
+ "path/filepath"
"strconv"
"strings"
"time"
@@ -15,6 +17,7 @@ type BranchInfo struct {
Remote string
IsAbandoned bool
AbandonReason string
+ RemotesWithBranch []string // List of remotes that have this branch
}
// AbandonedBranchReport holds the analysis results
@@ -22,13 +25,16 @@ type AbandonedBranchReport struct {
MainBranchUpdated bool
MainBranchLastCommit time.Time
AbandonedBranches []BranchInfo
+ AbandonedIgnoredBranches []BranchInfo // Abandoned branches that match exclusion patterns
TotalBranches int
+ TotalIgnoredBranches int
}
// analyzeAbandonedBranches analyzes branches to find abandoned ones
func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) {
report := &AbandonedBranchReport{
AbandonedBranches: []BranchInfo{},
+ AbandonedIgnoredBranches: []BranchInfo{},
}
// Get all branches
@@ -40,6 +46,10 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) {
// Filter branches based on exclusion patterns
branches := s.branchFilter.FilterBranches(allBranches)
report.TotalBranches = len(branches)
+
+ // Get excluded branches for separate analysis
+ excludedBranches := s.branchFilter.GetExcludedBranches(allBranches)
+ report.TotalIgnoredBranches = len(excludedBranches)
// Check main/master branch status
mainBranch := s.findMainBranch(branches)
@@ -79,6 +89,27 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) {
report.AbandonedBranches = append(report.AbandonedBranches, *branchInfo)
}
}
+
+ // Also analyze ignored branches for abandonment
+ for _, branch := range excludedBranches {
+ // Skip main/master branches even if they match exclusion patterns
+ if branch == "main" || branch == "master" {
+ continue
+ }
+
+ branchInfo, err := s.getBranchInfo(branch)
+ if err != nil {
+ continue
+ }
+
+ // Check if branch is abandoned (no commits for 6+ months)
+ if branchInfo.LastCommit.Before(sixMonthsAgo) {
+ branchInfo.IsAbandoned = true
+ daysSinceCommit := int(time.Since(branchInfo.LastCommit).Hours() / 24)
+ branchInfo.AbandonReason = fmt.Sprintf("No commits for %d days (ignored branch)", daysSinceCommit)
+ report.AbandonedIgnoredBranches = append(report.AbandonedIgnoredBranches, *branchInfo)
+ }
+ }
return report, nil
}
@@ -97,6 +128,7 @@ func (s *Syncer) findMainBranch(branches []string) string {
func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) {
info := &BranchInfo{
Name: branch,
+ RemotesWithBranch: []string{},
}
// Find which remote has this branch and get the latest commit
@@ -105,9 +137,18 @@ func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) {
for i := range s.config.Organizations {
org := &s.config.Organizations[i]
+
+ // Skip backup locations if backup is not enabled
+ if org.BackupLocation && !s.backupEnabled {
+ continue
+ }
+
remoteName := s.getRemoteName(org)
if s.remoteBranchExists(remoteName, branch) {
+ // Add this remote to the list
+ info.RemotesWithBranch = append(info.RemotesWithBranch, remoteName)
+
// Get last commit date for this branch on this remote
commitTime, err := s.getLastCommitTime(remoteName, branch)
if err == nil && (latestCommit.IsZero() || commitTime.After(latestCommit)) {
@@ -163,20 +204,32 @@ func formatAbandonedBranchReport(report *AbandonedBranchReport, repoName string)
return "" // Don't report on inactive repos
}
- if len(report.AbandonedBranches) == 0 {
+ if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 {
return "" // No abandoned branches
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n🔍 Abandoned branches in %s:\n", repoName))
sb.WriteString(fmt.Sprintf(" Main branch last updated: %s\n", report.MainBranchLastCommit.Format("2006-01-02")))
- sb.WriteString(fmt.Sprintf(" Found %d abandoned branches (no commits for 6+ months):\n\n", len(report.AbandonedBranches)))
-
- for _, branch := range report.AbandonedBranches {
- sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n",
- branch.Name,
- branch.LastCommit.Format("2006-01-02"),
- branch.AbandonReason))
+
+ if len(report.AbandonedBranches) > 0 {
+ sb.WriteString(fmt.Sprintf(" Found %d abandoned branches (no commits for 6+ months):\n\n", len(report.AbandonedBranches)))
+ for _, branch := range report.AbandonedBranches {
+ sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n",
+ branc