summaryrefslogtreecommitdiff
path: root/internal
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
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')
-rw-r--r--internal/cli/flags.go4
-rw-r--r--internal/cli/release.go239
-rw-r--r--internal/release/release.go276
3 files changed, 493 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
diff --git a/internal/release/release.go b/internal/release/release.go
index c66d5f9..67350cf 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -230,6 +230,138 @@ func (m *Manager) GenerateReleaseNotes(repoPath, tag string, allTags []string) s
return notes.String()
}
+// GetDiffBetweenTags gets the diff between two tags
+func (m *Manager) GetDiffBetweenTags(repoPath, fromTag, toTag string) (string, error) {
+ // Use git diff to get changes between tags
+ // If fromTag is empty, get all changes up to toTag
+ var cmd *exec.Cmd
+ if fromTag == "" {
+ // Get diff from the beginning to toTag
+ cmd = exec.Command("git", "-C", repoPath, "show", "--format=", "--no-patch", toTag)
+ // This won't work well, so let's get the first commit
+ firstCommitCmd := exec.Command("git", "-C", repoPath, "rev-list", "--max-parents=0", "HEAD")
+ firstCommitOutput, err := firstCommitCmd.Output()
+ if err != nil {
+ // Fallback to just showing the tag
+ cmd = exec.Command("git", "-C", repoPath, "diff", "--stat", toTag)
+ } else {
+ firstCommit := strings.TrimSpace(string(firstCommitOutput))
+ cmd = exec.Command("git", "-C", repoPath, "diff", "--stat", fmt.Sprintf("%s..%s", firstCommit, toTag))
+ }
+ } else {
+ cmd = exec.Command("git", "-C", repoPath, "diff", "--stat", fmt.Sprintf("%s..%s", fromTag, toTag))
+ }
+
+ output, err := cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("failed to get diff: %w", err)
+ }
+
+ // Also get the actual diff for key files (limit to prevent huge outputs)
+ var diffCmd *exec.Cmd
+ if fromTag == "" {
+ diffCmd = exec.Command("git", "-C", repoPath, "show", toTag, "--", "*.go", "*.js", "*.py", "*.rs", "*.c", "*.cpp", "*.java", "*.ts", "*.jsx", "*.tsx", "README*", "*.md")
+ } else {
+ diffCmd = exec.Command("git", "-C", repoPath, "diff", fmt.Sprintf("%s..%s", fromTag, toTag), "--", "*.go", "*.js", "*.py", "*.rs", "*.c", "*.cpp", "*.java", "*.ts", "*.jsx", "*.tsx", "README*", "*.md")
+ }
+
+ diffOutput, err := diffCmd.Output()
+ if err != nil {
+ // If error, just use the stat output
+ return string(output), nil
+ }
+
+ // Combine stat and limited diff (truncate if too long)
+ fullOutput := string(output) + "\n\n" + string(diffOutput)
+ maxLength := 50000 // Limit to 50KB to avoid overwhelming Claude
+ if len(fullOutput) > maxLength {
+ fullOutput = fullOutput[:maxLength] + "\n\n... (diff truncated)"
+ }
+
+ return fullOutput, nil
+}
+
+// GenerateAIReleaseNotes generates prose release notes using Claude CLI
+func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags []string, commits []string) (string, error) {
+ // 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 the diff between tags
+ diff, err := m.GetDiffBetweenTags(repoPath, prevTag, tag)
+ if err != nil {
+ return "", fmt.Errorf("failed to get diff: %w", err)
+ }
+
+ // Prepare the prompt for Claude
+ var prompt strings.Builder
+ prompt.WriteString(fmt.Sprintf("Generate professional release notes for %s version %s.\n\n", repoName, tag))
+
+ if prevTag != "" {
+ prompt.WriteString(fmt.Sprintf("Previous version: %s\n", prevTag))
+ }
+
+ prompt.WriteString("\nCommit messages:\n")
+ for _, commit := range commits {
+ prompt.WriteString(fmt.Sprintf("- %s\n", commit))
+ }
+
+ prompt.WriteString("\nCode changes:\n")
+ prompt.WriteString(diff)
+ prompt.WriteString("\n\nBased on the commits and code changes above, write professional release notes that:\n")
+ prompt.WriteString("1. Start with a brief overview of what this release accomplishes\n")
+ prompt.WriteString("2. Group changes into logical sections (Features, Improvements, Bug Fixes, etc.)\n")
+ prompt.WriteString("3. Explain WHY each change is useful to users, not just what changed\n")
+ prompt.WriteString("4. Use clear, non-technical language where possible\n")
+ prompt.WriteString("5. Highlight any breaking changes or migration steps\n")
+ prompt.WriteString("6. Keep it concise but informative\n")
+ prompt.WriteString("7. Format using Markdown\n")
+ prompt.WriteString("\nDo not include the version number in the title as it will be added automatically.")
+
+ // Run Claude CLI
+ cmd := exec.Command("claude", "--model", "sonnet-3.5", prompt.String())
+ output, err := cmd.Output()
+ if err != nil {
+ // Try with sonnet-4 model
+ cmd = exec.Command("claude", "--model", "sonnet-4", prompt.String())
+ output, err = cmd.Output()
+ if err != nil {
+ // Try with default model
+ cmd = exec.Command("claude", prompt.String())
+ output, err = cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("failed to run claude: %w", err)
+ }
+ }
+ }
+
+ releaseNotes := strings.TrimSpace(string(output))
+ if releaseNotes == "" {
+ return "", fmt.Errorf("received empty release notes from claude")
+ }
+
+ // Add header and footer
+ var finalNotes strings.Builder
+ finalNotes.WriteString(fmt.Sprintf("# Release %s\n\n", tag))
+ finalNotes.WriteString(releaseNotes)
+ finalNotes.WriteString("\n\n---\n\n")
+ if prevTag != "" {
+ finalNotes.WriteString(fmt.Sprintf("**Full Changelog**: %s...%s", prevTag, tag))
+ }
+
+ return finalNotes.String(), nil
+}
+
// 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)
@@ -458,4 +590,148 @@ func PromptConfirmationWithNotes(message, releaseNotes string) bool {
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
+}
+
+// UpdateGitHubRelease updates an existing release on GitHub
+func (m *Manager) UpdateGitHubRelease(owner, repo, tag, releaseNotes string) error {
+ if m.githubToken == "" {
+ return fmt.Errorf("GitHub token is required for updating releases")
+ }
+
+ // First, get the release ID
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return err
+ }
+
+ 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 err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("failed to get release: %s - %s", resp.Status, string(body))
+ }
+
+ var releaseInfo struct {
+ ID int64 `json:"id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&releaseInfo); err != nil {
+ return err
+ }
+
+ // Now update the release
+ updateURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/%d", owner, repo, releaseInfo.ID)
+
+ release := Release{
+ TagName: tag,
+ Name: tag,
+ Body: releaseNotes,
+ }
+
+ jsonData, err := json.Marshal(release)
+ if err != nil {
+ return err
+ }
+
+ updateReq, err := http.NewRequest("PATCH", updateURL, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return err
+ }
+
+ updateReq.Header.Set("Authorization", "Bearer "+m.githubToken)
+ updateReq.Header.Set("Content-Type", "application/json")
+ updateReq.Header.Set("Accept", "application/vnd.github.v3+json")
+
+ updateResp, err := client.Do(updateReq)
+ if err != nil {
+ return err
+ }
+ defer updateResp.Body.Close()
+
+ if updateResp.StatusCode != 200 {
+ body, _ := io.ReadAll(updateResp.Body)
+ return fmt.Errorf("failed to update GitHub release: %s - %s", updateResp.Status, string(body))
+ }
+
+ return nil
+}
+
+// UpdateCodebergRelease updates an existing release on Codeberg
+func (m *Manager) UpdateCodebergRelease(owner, repo, tag, releaseNotes string) error {
+ if m.codebergToken == "" {
+ return fmt.Errorf("Codeberg token is required for updating releases")
+ }
+
+ // First, get the release ID
+ url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Authorization", "token "+m.codebergToken)
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("failed to get release: %s - %s", resp.Status, string(body))
+ }
+
+ var releaseInfo struct {
+ ID int64 `json:"id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&releaseInfo); err != nil {
+ return err
+ }
+
+ // Now update the release
+ updateURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases/%d", owner, repo, releaseInfo.ID)
+
+ release := Release{
+ TagName: tag,
+ Name: tag,
+ Body: releaseNotes,
+ }
+
+ jsonData, err := json.Marshal(release)
+ if err != nil {
+ return err
+ }
+
+ updateReq, err := http.NewRequest("PATCH", updateURL, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return err
+ }
+
+ updateReq.Header.Set("Authorization", "token "+m.codebergToken)
+ updateReq.Header.Set("Content-Type", "application/json")
+
+ updateResp, err := client.Do(updateReq)
+ if err != nil {
+ return err
+ }
+ defer updateResp.Body.Close()
+
+ if updateResp.StatusCode != 200 {
+ body, _ := io.ReadAll(updateResp.Body)
+ return fmt.Errorf("failed to update Codeberg release: %s - %s", updateResp.Status, string(body))
+ }
+
+ return nil
} \ No newline at end of file