package release import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "regexp" "sort" "strings" "codeberg.org/snonux/gitsyncer/internal/httpclient" ) // Tag represents a git tag type Tag struct { Name string } // Release represents a release on a platform type Release struct { TagName string `json:"tag_name"` Name string `json:"name"` Body string `json:"body"` } // Manager handles release operations type Manager struct { workDir string githubToken string codebergToken string aiTool string } // NewManager creates a new release manager func NewManager(workDir string) *Manager { return &Manager{ workDir: workDir, } } // SetGitHubToken sets the GitHub token for API authentication func (m *Manager) SetGitHubToken(token string) { m.githubToken = token } // SetCodebergToken sets the Codeberg token for API authentication func (m *Manager) SetCodebergToken(token string) { m.codebergToken = token } // SetAITool sets the AI tool to use for release notes generation func (m *Manager) SetAITool(tool string) { m.aiTool = tool } // EnsureCodebergReleasesEnabled ensures that the Codeberg repository has the // Releases feature enabled. If it's disabled, attempts to enable it via API. func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error { if m.codebergToken == "" { return fmt.Errorf("Codeberg token is required to manage repository settings") } // Fetch repository metadata infoURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo) getReq, cancel, err := httpclient.NewRequest(http.MethodGet, infoURL, nil) if err != nil { return err } defer cancel() getReq.Header.Set("Authorization", "token "+m.codebergToken) resp, err := httpclient.Do(getReq) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to get repo info: %s - %s", resp.Status, string(body)) } var repoInfo struct { HasReleases bool `json:"has_releases"` } if err := json.NewDecoder(resp.Body).Decode(&repoInfo); err != nil { return fmt.Errorf("failed to parse repo info: %w", err) } if repoInfo.HasReleases { return nil } // Enable releases via PATCH payload := map[string]any{"has_releases": true} body, err := json.Marshal(payload) if err != nil { return err } patchReq, patchCancel, err := httpclient.NewRequest(http.MethodPatch, infoURL, bytes.NewBuffer(body)) if err != nil { return err } defer patchCancel() patchReq.Header.Set("Authorization", "token "+m.codebergToken) patchReq.Header.Set("Content-Type", "application/json") patchResp, err := httpclient.Do(patchReq) if err != nil { return err } defer patchResp.Body.Close() if patchResp.StatusCode != 200 { pbody, _ := io.ReadAll(patchResp.Body) return fmt.Errorf("failed to enable releases: %s - %s", patchResp.Status, string(pbody)) } return nil } // 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 } // GetLocalTags returns all version tags from the local git repository func (m *Manager) GetLocalTags(repoPath string) ([]string, error) { cmd := exec.Command("git", "-C", repoPath, "tag", "--list") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to get git tags: %w", err) } var versionTags []string tags := strings.Split(strings.TrimSpace(string(output)), "\n") for _, tag := range tags { tag = strings.TrimSpace(tag) if tag != "" && isVersionTag(tag) { versionTags = append(versionTags, tag) } } // Sort tags by version sort.Slice(versionTags, func(i, j int) bool { return compareVersions(versionTags[i], versionTags[j]) < 0 }) return versionTags, nil } // compareVersions compares two version strings // Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 func compareVersions(v1, v2 string) int { // Remove 'v' prefix if present v1 = strings.TrimPrefix(v1, "v") v2 = strings.TrimPrefix(v2, "v") parts1 := strings.Split(v1, ".") parts2 := strings.Split(v2, ".") // Pad with zeros to make equal length maxLen := len(parts1) if len(parts2) > maxLen { maxLen = len(parts2) } for i := 0; i < maxLen; i++ { var n1, n2 int if i < len(parts1) { fmt.Sscanf(parts1[i], "%d", &n1) } if i < len(parts2) { fmt.Sscanf(parts2[i], "%d", &n2) } if n1 < n2 { return -1 } else if n1 > n2 { return 1 } } return 0 } // GetCommitsSinceTag gets all commits since a specific tag func (m *Manager) GetCommitsSinceTag(repoPath, fromTag, toTag string) ([]string, error) { // Use git log to get commits between tags // If fromTag is empty, get all commits up to toTag var cmd *exec.Cmd if fromTag == "" { cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", toTag) } else { cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", fmt.Sprintf("%s..%s", fromTag, toTag)) } output, err := cmd.Output() if err != nil { // If error, it might be because fromTag doesn't exist, try without it cmd = exec.Command("git", "-C", repoPath, "log", "--pretty=format:%s", toTag) output, err = cmd.Output() if err != nil { return nil, fmt.Errorf("failed to get commits: %w", err) } } if len(output) == 0 { return []string{}, nil } commits := strings.Split(strings.TrimSpace(string(output)), "\n") // Reverse to show oldest first for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 { commits[i], commits[j] = commits[j], commits[i] } return commits, nil } // GenerateReleaseNotes generates release notes from commits func (m *Manager) GenerateReleaseNotes(repoPath, tag string, allTags []string) string { // 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 commits since previous tag commits, err := m.GetCommitsSinceTag(repoPath, prevTag, tag) if err != nil { return fmt.Sprintf("Release %s", tag) } if len(commits) == 0 { return fmt.Sprintf("Release %s", tag) } // Group commits by type var features, fixes, other []string for _, commit := range commits { lower := strings.ToLower(commit) if strings.HasPrefix(lower, "feat:") || strings.HasPrefix(lower, "feature:") { features = append(features, commit) } else if strings.HasPrefix(lower, "fix:") || strings.HasPrefix(lower, "bugfix:") { fixes = append(fixes, commit) } else { other = append(other, commit) } } // Build release notes var notes strings.Builder notes.WriteString(fmt.Sprintf("Release %s\n\n", tag)) if prevTag != "" { notes.WriteString(fmt.Sprintf("Changes since %s:\n\n", prevTag)) } if len(features) > 0 { notes.WriteString("## New Features\n\n") for _, feat := range features { notes.WriteString(fmt.Sprintf("- %s\n", feat)) } notes.WriteString("\n") } if len(fixes) > 0 { notes.WriteString("## Bug Fixes\n\n") for _, fix := range fixes { notes.WriteString(fmt.Sprintf("- %s\n", fix)) } notes.WriteString("\n") } if len(other) > 0 { notes.WriteString("## Other Changes\n\n") for _, commit := range other { notes.WriteString(fmt.Sprintf("- %s\n", commit)) } notes.WriteString("\n") } 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 } // executeAICommand executes an AI command and returns the output or an error func (m *Manager) executeAICommand(cmd *exec.Cmd, toolName string) (string, error) { output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("%s command failed: %w. Output: %s", toolName, err, string(output)) } content := strings.TrimSpace(string(output)) if content == "" { return "", fmt.Errorf("received empty output from %s", toolName) } // Check for common error indicators in the output if strings.HasPrefix(content, "Error:") || (toolName == "claude" && strings.Contains(content, "API Error")) || (toolName == "claude" && strings.Contains(content, "authentication_error")) { return "", fmt.Errorf("%s returned an error: %s", toolName, content) } return content, nil } // GenerateAIReleaseNotes generates prose release notes using an AI tool, with fallback. 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 prompt/instructions and input payload var instr strings.Builder instr.WriteString(fmt.Sprintf("Generate professional release notes for %s version %s.\n", repoName, tag)) if prevTag != "" { instr.WriteString(fmt.Sprintf("Previous version: %s\n", prevTag)) } instr.WriteString("\nBased on the provided commits and code changes, write professional release notes that:\n") instr.WriteString("1. Start with a brief overview of what this release accomplishes\n") instr.WriteString("2. Group changes into logical sections (Features, Improvements, Bug Fixes, etc.)\n") instr.WriteString("3. Explain WHY each change is useful to users, not just what changed\n") instr.WriteString("4. Use clear, non-technical language where possible\n") instr.WriteString("5. Highlight any breaking changes or migration steps\n") instr.WriteString("6. Keep it concise but informative\n") instr.WriteString("7. Format using Markdown\n") instr.WriteString("\nDo not include the version number in the title as it will be added automatically.") var input strings.Builder input.WriteString("Commit messages:\n") for _, commit := range commits { input.WriteString(fmt.Sprintf("- %s\n", commit)) } input.WriteString("\nCode changes:\n") input.WriteString(diff) fmt.Printf(" Prompt: Generate release notes for %s %s\n", repoName, tag) fmt.Printf(" Prompt includes: %d commits, %.1fKB of code changes\n", len(commits), float64(len(diff))/1024) fmt.Printf(" Total prompt length: %d characters\n", len(instr.String())+len(input.String())) // Determine which AI tool to use (default to opencode if not set) aiTool := m.aiTool if aiTool == "" { aiTool = "opencode" } // Build a full prompt string for tools that read a single argument fullPrompt := instr.String() + "\n\n" + input.String() var releaseNotes string // 0) Try opencode first (local Ollama with gpt-oss:120b) if _, err := exec.LookPath("opencode"); err == nil { fmt.Println(" Running opencode CLI command (stdin payload)...") cmd := exec.Command("opencode", "run", "--model", "ollama/gpt-oss:120b", instr.String()) cmd.Stdin = strings.NewReader(input.String()) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { fmt.Printf(" opencode CLI failed: %v\n", err) } else { notes := strings.TrimSpace(string(out)) if notes == "" { fmt.Println(" opencode returned empty output; will try fallbacks...") } else { releaseNotes = notes } } } // 1) Try amp as fallback: echo input to stdin and pass instructions as argument // Note: print stderr to console, but only use stdout for notes if releaseNotes == "" { if _, err := exec.LookPath("amp"); err == nil { fmt.Println(" Running amp CLI command (stdin payload)...") cmd := exec.Command("amp", "--execute", instr.String()) cmd.Stdin = strings.NewReader(input.String()) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { fmt.Printf(" amp CLI failed: %v\n", err) } else { notes := strings.TrimSpace(string(out)) if notes == "" { fmt.Println(" amp returned empty output; will try fallbacks...") } else { releaseNotes = notes } } } } // 2) Try hexai as fallback if releaseNotes == "" { if _, err := exec.LookPath("hexai"); err == nil { fmt.Println(" Running hexai CLI command (stdin payload)...") cmd := exec.Command("hexai", instr.String()) cmd.Stdin = strings.NewReader(input.String()) cmd.Stderr = os.Stderr out, err := cmd.Output() if err != nil { fmt.Printf(" hexai CLI failed: %v\n", err) } else { notes := strings.TrimSpace(string(out)) if notes == "" { fmt.Println(" hexai returned empty output; will try fallbacks...") } else { releaseNotes = notes } } } } if releaseNotes == "" && aiTool == "claude" { fmt.Println(" Running claude CLI command...") if _, err := exec.LookPath("claude"); err != nil { fmt.Println(" claude CLI not found, falling back to aichat...") aiTool = "aichat" } else { cmd := exec.Command("claude", "--model", "sonnet", fullPrompt) cmd.Env = append(os.Environ(), "CLAUDE_DEBUG=1") notes, err := m.executeAICommand(cmd, "claude") if err != nil { fmt.Printf(" Claude CLI failed: %v\n", err) fmt.Println(" Falling back to aichat...") aiTool = "aichat" } else { releaseNotes = notes } } } if releaseNotes == "" && aiTool == "aichat" { fmt.Println(" Running aichat CLI command...") if _, err := exec.LookPath("aichat"); err != nil { return "", fmt.Errorf("aichat CLI not found in PATH and fallbacks failed") } cmd := exec.Command("aichat", fullPrompt) notes, err := m.executeAICommand(cmd, "aichat") if err != nil { return "", fmt.Errorf("aichat CLI failed: %w", err) } releaseNotes = notes } if releaseNotes == "" && (aiTool == "opencode" || aiTool == "amp") { return "", fmt.Errorf("opencode/amp CLI not found in PATH and fallbacks failed") } if releaseNotes == "" { return "", fmt.Errorf("all AI tools failed to generate release notes") } // Add header var finalNotes strings.Builder finalNotes.WriteString(fmt.Sprintf("# Release %s\n\n", tag)) finalNotes.WriteString(releaseNotes) 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) req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } defer cancel() // Add GitHub token if available if m.githubToken != "" { req.Header.Set("Authorization", "Bearer "+m.githubToken) } req.Header.Set("Accept", "application/vnd.github.v3+json") resp, err := httpclient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == 404 { // Repository might not exist on GitHub return []string{}, nil } if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("GitHub API error: %s - %s", resp.Status, string(body)) } var releases []Release if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { return nil, err } var tags []string for _, release := range releases { tags = append(tags, release.TagName) } return tags, nil } // GetCodebergReleases fetches releases from Codeberg func (m *Manager) GetCodebergReleases(owner, repo string) ([]string, error) { url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases", owner, repo) req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } defer cancel() // Add Codeberg token if available if m.codebergToken != "" { req.Header.Set("Authorization", "token "+m.codebergToken) } resp, err := httpclient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == 404 { // Repository might not exist on Codeberg return []string{}, nil } if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("Codeberg API error: %s - %s", resp.Status, string(body)) } var releases []Release if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { return nil, err } var tags []string for _, release := range releases { tags = append(tags, release.TagName) } return tags, nil } // FindMissingReleases finds tags that don't have releases func (m *Manager) FindMissingReleases(localTags, releaseTags []string) []string { releaseMap := make(map[string]bool) for _, tag := range releaseTags { releaseMap[tag] = true } var missing []string for _, tag := range localTags { if !releaseMap[tag] { missing = append(missing, tag) } } return missing } // CreateGitHubRelease creates a release on GitHub func (m *Manager) CreateGitHubRelease(owner, repo, tag, releaseNotes string) error { if m.githubToken == "" { return fmt.Errorf("GitHub token is required for creating releases") } url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) // Use provided release notes or default body := releaseNotes if body == "" { body = fmt.Sprintf("Release %s", tag) } release := Release{ TagName: tag, Name: tag, Body: body, } jsonData, err := json.Marshal(release) if err != nil { return err } req, cancel, err := httpclient.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return err } defer cancel() req.Header.Set("Authorization", "Bearer "+m.githubToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/vnd.github.v3+json") resp, err := httpclient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to create GitHub release: %s - %s", resp.Status, string(body)) } return nil } // CreateCodebergRelease creates a release on Codeberg func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) error { if m.codebergToken == "" { return fmt.Errorf("Codeberg token is required for creating releases") } url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases", owner, repo) // Use provided release notes or default body := releaseNotes if body == "" { body = fmt.Sprintf("Release %s", tag) } // Codeberg uses Gitea API // According to Gitea API docs, only tag_name is required release := map[string]interface{}{ "tag_name": tag, "name": tag, // Use simple tag name like working releases "body": body, "draft": false, "prerelease": false, } jsonData, err := json.Marshal(release) if err != nil { return err } req, cancel, err := httpclient.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return err } defer cancel() req.Header.Set("Authorization", "token "+m.codebergToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := httpclient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 { body, _ := io.ReadAll(resp.Body) // Provide a more actionable hint when the repository is missing or owner/repo is wrong if resp.StatusCode == 404 { // Probe repository details to distinguish scenarios probeURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo) probeReq, probeCancel, perr := httpclient.NewRequest(http.MethodGet, probeURL, nil) if perr == nil { defer probeCancel() // Prefer probing with the same token if m.codebergToken != "" { probeReq.Header.Set("Authorization", "token "+m.codebergToken) } if probeResp, perr2 := httpclient.Do(probeReq); perr2 == nil { defer probeResp.Body.Close() if probeResp.StatusCode == 200 { // Try to detect if releases are disabled var repoInfo struct { HasReleases bool `json:"has_releases"` } if data, rerr := io.ReadAll(probeResp.Body); rerr == nil { _ = json.Unmarshal(data, &repoInfo) if !repoInfo.HasReleases { // Try to enable releases automatically and retry creation if err := m.EnsureCodebergReleasesEnabled(owner, repo); err != nil { return fmt.Errorf( "failed to create Codeberg release: releases are disabled for %s/%s and enabling via API failed: %v. Raw response: %s", owner, repo, err, string(body), ) } // Retry POST after enabling retryReq, retryCancel, rerr := httpclient.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if rerr != nil { return rerr } defer retryCancel() retryReq.Header.Set("Authorization", "token "+m.codebergToken) retryReq.Header.Set("Content-Type", "application/json") retryReq.Header.Set("Accept", "application/json") retryResp, rerr := httpclient.Do(retryReq) if rerr != nil { return rerr } defer retryResp.Body.Close() if retryResp.StatusCode != 201 { rbody, _ := io.ReadAll(retryResp.Body) return fmt.Errorf("failed to create Codeberg release after enabling releases: %s - %s", retryResp.Status, string(rbody)) } // Success after enabling return nil } } // Repo exists and has releases; likely permission/scope issue return fmt.Errorf( "failed to create Codeberg release: repo %s/%s exists but returned 404 on release creation. This usually indicates the token lacks write permissions to this repository or owner. Ensure the token belongs to '%s' (or a collaborator/maintainer) and has repository write access. Raw response: %s", owner, repo, owner, string(body), ) } } } return fmt.Errorf( "failed to create Codeberg release: repository %s/%s not found (404). Verify your Codeberg owner ('organizations[].name') matches the actual owner for this repo and that the repository exists. If needed, create it first, e.g.: gitsyncer sync repo %s --create-codeberg-repos. Raw response: %s", owner, repo, repo, string(body), ) } // Special handling for known Gitea issue if resp.StatusCode == 409 && strings.Contains(string(body), "Release is has no Tag") { // This is a known Gitea bug - the tag exists but Gitea can't create a release for it // Check if it's one of the problematic old tags fmt.Printf("\nWARNING: Codeberg/Gitea returned 'Release is has no Tag' error for tag %s\n", tag) fmt.Printf("This is a known issue with some old tags. The tag exists but cannot have a release created via API.\n") fmt.Printf("You may need to create this release manually through the Codeberg web interface.\n\n") return fmt.Errorf("cannot create release for tag %s due to Gitea API limitation", tag) } return fmt.Errorf("failed to create Codeberg release: %s - %s", resp.Status, string(body)) } return nil } // PromptConfirmation asks for user confirmation func PromptConfirmation(message string) bool { fmt.Printf("%s [y/N]: ", message) var response string fmt.Scanln(&response) response = strings.ToLower(strings.TrimSpace(response)) return response == "y" || response == "yes" } // PromptConfirmationWithNotes asks for user confirmation and shows release notes func PromptConfirmationWithNotes(message, releaseNotes string) bool { fmt.Printf("\n%s\n", strings.Repeat("-", 70)) fmt.Printf("Release Notes:\n%s\n", strings.Repeat("-", 70)) fmt.Println(releaseNotes) fmt.Printf("%s\n\n", strings.Repeat("-", 70)) fmt.Printf("%s [y/N]: ", message) var response string fmt.Scanln(&response) 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, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return err } defer cancel() req.Header.Set("Authorization", "Bearer "+m.githubToken) req.Header.Set("Accept", "application/vnd.github.v3+json") resp, err := httpclient.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, updateCancel, err := httpclient.NewRequest(http.MethodPatch, updateURL, bytes.NewBuffer(jsonData)) if err != nil { return err } defer updateCancel() 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 := httpclient.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, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return err } defer cancel() req.Header.Set("Authorization", "token "+m.codebergToken) resp, err := httpclient.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, updateCancel, err := httpclient.NewRequest(http.MethodPatch, updateURL, bytes.NewBuffer(jsonData)) if err != nil { return err } defer updateCancel() updateReq.Header.Set("Authorization", "token "+m.codebergToken) updateReq.Header.Set("Content-Type", "application/json") updateResp, err := httpclient.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 }