summaryrefslogtreecommitdiff
path: root/internal/cli
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/cli
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/cli')
-rw-r--r--internal/cli/flags.go6
-rw-r--r--internal/cli/release.go315
-rw-r--r--internal/cli/sync_handlers.go81
3 files changed, 402 insertions, 0 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
}