diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-12 13:51:48 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-12 13:51:48 +0300 |
| commit | ceb164c6a8826db1e763aecca49098a8c2584b7f (patch) | |
| tree | a228040641666f4a6567e2d2e4544fd7daecc2e7 /internal/cli | |
| parent | 9d171e6b30a589240a16d8fef15e2195189e52a9 (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.go | 6 | ||||
| -rw-r--r-- | internal/cli/release.go | 315 | ||||
| -rw-r--r-- | internal/cli/sync_handlers.go | 81 |
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 } |
