From b3b599b7b645f7fab6fd8c22e8475a054b137225 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 12 Jul 2025 14:21:06 +0300 Subject: feat: add AI-powered release notes generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --ai-release-notes flag to generate prose release notes using Claude CLI - Add --update-releases flag to update existing releases with AI notes - Implement GetDiffBetweenTags to extract code changes between versions - Integrate Claude CLI for intelligent release note generation - Support fallback to multiple Claude models (sonnet-3.5, sonnet-4, default) - Always print release notes to stdout for visibility - Fix token loading messages to only show when falling back from config 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/cli/flags.go | 4 + internal/cli/release.go | 239 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 217 insertions(+), 26 deletions(-) (limited to 'internal/cli') diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 72ffa1a..fb367cb 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -33,6 +33,8 @@ type Flags struct { CheckReleases bool NoCheckReleases bool AutoCreateReleases bool + AIReleaseNotes bool + UpdateReleases bool // Internal fields for batch run state management (not set by flags) BatchRunStateManager *state.Manager @@ -68,6 +70,8 @@ func ParseFlags() *Flags { 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.BoolVar(&f.AIReleaseNotes, "ai-release-notes", false, "generate release notes using Claude AI based on git diff") + flag.BoolVar(&f.UpdateReleases, "update-releases", false, "update existing releases with new AI-generated notes") flag.Parse() diff --git a/internal/cli/release.go b/internal/cli/release.go index a83283d..e861533 100644 --- a/internal/cli/release.go +++ b/internal/cli/release.go @@ -4,14 +4,36 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "codeberg.org/snonux/gitsyncer/internal/config" "codeberg.org/snonux/gitsyncer/internal/release" ) +// 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 +} + // HandleCheckReleases checks for version tags without releases and creates them with confirmation func HandleCheckReleases(cfg *config.Config, flags *Flags) int { + // Check all configured repositories + return HandleCheckReleasesForRepos(cfg, flags, cfg.Repositories) +} + +// HandleCheckReleasesForRepo checks releases for a specific repository +func HandleCheckReleasesForRepo(cfg *config.Config, flags *Flags, repoName string) int { + // Check only the specified repository + return HandleCheckReleasesForRepos(cfg, flags, []string{repoName}) +} + +// HandleCheckReleasesForRepos checks for version tags without releases and creates them with confirmation +func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories []string) int { releaseManager := release.NewManager(flags.WorkDir) // Set tokens from config with fallback to environment variables and files @@ -24,24 +46,23 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { 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 token == "" { + fmt.Println("No GITHUB_TOKEN env var, checking ~/.gitsyncer_github_token file...") + home, err := os.UserHomeDir() if err == nil { - token = strings.TrimSpace(string(data)) + 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") + fmt.Println("WARNING: No GitHub token found - cannot create GitHub releases") } } else { fmt.Println("No GitHub organization found in config") @@ -56,31 +77,30 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { 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 token == "" { + fmt.Println("No CODEBERG_TOKEN env var, checking ~/.gitsyncer_codeberg_token file...") + home, err := os.UserHomeDir() if err == nil { - token = strings.TrimSpace(string(data)) + 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") + fmt.Println("WARNING: No Codeberg token found - cannot create Codeberg releases") } } else { fmt.Println("No Codeberg organization found in config") } - // Process all configured repositories - for _, repoName := range cfg.Repositories { + // Process the specified repositories + for _, repoName := range repositories { fmt.Printf("\nChecking releases for repository: %s\n", repoName) // Check if the repository is cloned locally @@ -137,8 +157,35 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { // Create missing releases with confirmation if len(missingGitHub) > 0 && githubOrg != nil { for _, tag := range missingGitHub { + // Get commits for this tag + commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag) + if err != nil { + commits = []string{} + } + // Generate release notes - releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + var releaseNotes string + if flags.AIReleaseNotes { + fmt.Printf(" Generating AI release notes for %s...\n", tag) + aiNotes, err := releaseManager.GenerateAIReleaseNotes(repoPath, repoName, tag, localTags, commits) + if err != nil { + fmt.Printf(" Warning: Failed to generate AI release notes: %v\n", err) + fmt.Printf(" Falling back to standard release notes\n") + releaseNotes = releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + } else { + releaseNotes = aiNotes + fmt.Printf(" AI release notes generated successfully\n") + } + } else { + releaseNotes = releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + } + + // Print release notes to stdout + fmt.Printf("\n%s\n", strings.Repeat("=", 70)) + fmt.Printf("Release Notes for %s/%s tag %s:\n", githubOrg.Name, repoName, tag) + fmt.Printf("%s\n", strings.Repeat("-", 70)) + fmt.Println(releaseNotes) + fmt.Printf("%s\n\n", strings.Repeat("=", 70)) msg := fmt.Sprintf("Create GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag) @@ -148,7 +195,7 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { fmt.Printf(" Auto-creating GitHub release for %s/%s tag %s\n", githubOrg.Name, repoName, tag) createRelease = true } else { - createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes) + createRelease = release.PromptConfirmation(msg) } if createRelease { @@ -163,8 +210,35 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { if len(missingCodeberg) > 0 && codebergOrg != nil { for _, tag := range missingCodeberg { + // Get commits for this tag + commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag) + if err != nil { + commits = []string{} + } + // Generate release notes - releaseNotes := releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + var releaseNotes string + if flags.AIReleaseNotes { + fmt.Printf(" Generating AI release notes for %s...\n", tag) + aiNotes, err := releaseManager.GenerateAIReleaseNotes(repoPath, repoName, tag, localTags, commits) + if err != nil { + fmt.Printf(" Warning: Failed to generate AI release notes: %v\n", err) + fmt.Printf(" Falling back to standard release notes\n") + releaseNotes = releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + } else { + releaseNotes = aiNotes + fmt.Printf(" AI release notes generated successfully\n") + } + } else { + releaseNotes = releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) + } + + // Print release notes to stdout + fmt.Printf("\n%s\n", strings.Repeat("=", 70)) + fmt.Printf("Release Notes for %s/%s tag %s:\n", codebergOrg.Name, repoName, tag) + fmt.Printf("%s\n", strings.Repeat("-", 70)) + fmt.Println(releaseNotes) + fmt.Printf("%s\n\n", strings.Repeat("=", 70)) msg := fmt.Sprintf("Create Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag) @@ -174,7 +248,7 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { fmt.Printf(" Auto-creating Codeberg release for %s/%s tag %s\n", codebergOrg.Name, repoName, tag) createRelease = true } else { - createRelease = release.PromptConfirmationWithNotes(msg, releaseNotes) + createRelease = release.PromptConfirmation(msg) } if createRelease { @@ -186,6 +260,119 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { } } } + + // Update existing releases if requested + if flags.UpdateReleases { + // Update GitHub releases + if githubOrg != nil && githubOrg.Name != "" { + githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName) + if err == nil && len(githubReleases) > 0 { + fmt.Printf("\n Updating existing GitHub releases...\n") + for _, tag := range githubReleases { + // Check if this is a version tag + if !isVersionTag(tag) { + continue + } + + // Get commits for this tag + commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag) + if err != nil { + commits = []string{} + } + + // Generate AI release notes + if flags.AIReleaseNotes { + fmt.Printf(" Generating AI release notes for existing release %s...\n", tag) + aiNotes, err := releaseManager.GenerateAIReleaseNotes(repoPath, repoName, tag, localTags, commits) + if err != nil { + fmt.Printf(" Warning: Failed to generate AI release notes: %v\n", err) + continue + } + + // Print release notes to stdout + fmt.Printf("\n%s\n", strings.Repeat("=", 70)) + fmt.Printf("Updated Release Notes for %s/%s tag %s:\n", githubOrg.Name, repoName, tag) + fmt.Printf("%s\n", strings.Repeat("-", 70)) + fmt.Println(aiNotes) + fmt.Printf("%s\n\n", strings.Repeat("=", 70)) + + msg := fmt.Sprintf("Update GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag) + + updateRelease := false + if flags.AutoCreateReleases { + fmt.Printf(" Auto-updating GitHub release for %s/%s tag %s\n", githubOrg.Name, repoName, tag) + updateRelease = true + } else { + updateRelease = release.PromptConfirmation(msg) + } + + if updateRelease { + if err := releaseManager.UpdateGitHubRelease(githubOrg.Name, repoName, tag, aiNotes); err != nil { + fmt.Printf(" Error updating GitHub release: %v\n", err) + } else { + fmt.Printf(" Updated GitHub release for tag %s\n", tag) + } + } + } + } + } + } + + // Update Codeberg releases + if codebergOrg != nil && codebergOrg.Name != "" { + codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName) + if err == nil && len(codebergReleases) > 0 { + fmt.Printf("\n Updating existing Codeberg releases...\n") + for _, tag := range codebergReleases { + // Check if this is a version tag + if !isVersionTag(tag) { + continue + } + + // Get commits for this tag + commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag) + if err != nil { + commits = []string{} + } + + // Generate AI release notes + if flags.AIReleaseNotes { + fmt.Printf(" Generating AI release notes for existing release %s...\n", tag) + aiNotes, err := releaseManager.GenerateAIReleaseNotes(repoPath, repoName, tag, localTags, commits) + if err != nil { + fmt.Printf(" Warning: Failed to generate AI release notes: %v\n", err) + continue + } + + // Print release notes to stdout + fmt.Printf("\n%s\n", strings.Repeat("=", 70)) + fmt.Printf("Updated Release Notes for %s/%s tag %s:\n", codebergOrg.Name, repoName, tag) + fmt.Printf("%s\n", strings.Repeat("-", 70)) + fmt.Println(aiNotes) + fmt.Printf("%s\n\n", strings.Repeat("=", 70)) + + msg := fmt.Sprintf("Update Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag) + + updateRelease := false + if flags.AutoCreateReleases { + fmt.Printf(" Auto-updating Codeberg release for %s/%s tag %s\n", codebergOrg.Name, repoName, tag) + updateRelease = true + } else { + updateRelease = release.PromptConfirmation(msg) + } + + if updateRelease { + if err := releaseManager.UpdateCodebergRelease(codebergOrg.Name, repoName, tag, aiNotes); err != nil { + fmt.Printf(" Error updating Codeberg release: %v\n", err) + } else { + fmt.Printf(" Updated Codeberg release for tag %s\n", tag) + } + } + } + } + } + } + } } // Also check public repositories if they're synced -- cgit v1.2.3