summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-12 14:21:06 +0300
committerPaul Buetow <paul@buetow.org>2025-07-12 14:21:06 +0300
commitb3b599b7b645f7fab6fd8c22e8475a054b137225 (patch)
treec9a1631309292e8b7d2785edf9fce990e8308823 /internal/cli
parentceb164c6a8826db1e763aecca49098a8c2584b7f (diff)
feat: add AI-powered release notes generation
- 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 <noreply@anthropic.com>
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/flags.go4
-rw-r--r--internal/cli/release.go239
2 files changed, 217 insertions, 26 deletions
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