fj/cmd/release.go
sid 113505de95 feat: v0.3.0d — add PR checks, iostreams, aliases, and broad enhancements
Add PR checks command, iostreams/text packages for colored table output,
top-level run/workflow aliases matching gh CLI structure. Enhance actions,
issues, PRs, releases, repos, labels, milestones, and wiki commands with
improved flags, JSON output, and error handling.
2026-03-23 12:42:24 -06:00

673 lines
17 KiB
Go

package cmd
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/text"
"github.com/spf13/cobra"
)
var releaseCmd = &cobra.Command{
Use: "release",
Aliases: []string{"releases"},
Short: "Manage releases",
Long: "Create, view, list, upload assets, and delete releases.",
}
var releaseListCmd = &cobra.Command{
Use: "list",
Short: "List releases",
Long: "List releases in a repository.",
Example: ` # List releases
fgj release list
# List only draft releases
fgj release list --draft
# Output as JSON with a custom limit
fgj release list --json --limit 10`,
RunE: runReleaseList,
}
var releaseViewCmd = &cobra.Command{
Use: "view <tag|latest>",
Short: "View a release",
Long: "Display detailed information about a release.",
Example: ` # View a release by tag
fgj release view v1.0.0
# View the latest release
fgj release view latest
# Open in browser
fgj release view v1.0.0 --web
# Output as JSON
fgj release view v1.0.0 --json`,
Args: cobra.ExactArgs(1),
RunE: runReleaseView,
}
var releaseCreateCmd = &cobra.Command{
Use: "create <tag> [files...]",
Short: "Create a release",
Long: "Create a new release and optionally upload assets.",
Example: ` # Create a release
fgj release create v1.0.0
# Create with title and notes
fgj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements"
# Create a draft prerelease with assets
fgj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
# Create from release notes file
fgj release create v1.0.0 -F CHANGELOG.md`,
Args: cobra.MinimumNArgs(1),
RunE: runReleaseCreate,
}
var releaseUploadCmd = &cobra.Command{
Use: "upload <tag|latest> <files...>",
Short: "Upload release assets",
Long: "Upload assets to an existing release.",
Example: ` # Upload assets to a release
fgj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64
# Upload to the latest release, overwriting existing assets
fgj release upload latest build/output.zip --clobber`,
Args: cobra.MinimumNArgs(2),
RunE: runReleaseUpload,
}
var releaseDownloadCmd = &cobra.Command{
Use: "download <tag>",
Short: "Download release assets",
Long: "Download assets from a release.",
Example: ` # Download all assets from a release
fgj release download v1.0.0
# Download to a specific directory
fgj release download v1.0.0 -D ./downloads
# Download a specific asset by name pattern
fgj release download v1.0.0 -p "*.tar.gz"`,
Args: cobra.ExactArgs(1),
RunE: runReleaseDownload,
}
var releaseDeleteCmd = &cobra.Command{
Use: "delete <tag|latest>",
Short: "Delete a release",
Long: "Delete a release by tag, keeping its Git tag intact.",
Example: ` # Delete a release by tag
fgj release delete v1.0.0
# Delete the latest release
fgj release delete latest
# Delete without confirmation
fgj release delete v1.0.0 -y`,
Args: cobra.ExactArgs(1),
RunE: runReleaseDelete,
}
func init() {
rootCmd.AddCommand(releaseCmd)
releaseCmd.AddCommand(releaseListCmd)
releaseCmd.AddCommand(releaseViewCmd)
releaseCmd.AddCommand(releaseCreateCmd)
releaseCmd.AddCommand(releaseUploadCmd)
releaseCmd.AddCommand(releaseDownloadCmd)
releaseCmd.AddCommand(releaseDeleteCmd)
releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch")
addJSONFlags(releaseListCmd, "Output releases as JSON")
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(releaseViewCmd, "Output release as JSON")
releaseViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)")
releaseCreateCmd.Flags().StringP("notes", "n", "", "Release notes")
releaseCreateCmd.Flags().StringP("notes-file", "F", "", "Read release notes from file")
releaseCreateCmd.Flags().Bool("draft", false, "Create a draft release")
releaseCreateCmd.Flags().Bool("prerelease", false, "Mark the release as prerelease")
releaseCreateCmd.Flags().String("target", "", "Target commitish (branch or SHA)")
releaseUploadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name")
releaseDownloadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseDownloadCmd.Flags().StringP("dir", "D", ".", "Directory to download files into")
releaseDownloadCmd.Flags().StringP("pattern", "p", "", "Glob pattern to filter assets by name")
releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
}
func runReleaseList(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
limit, _ := cmd.Flags().GetInt("limit")
draftValue, _ := cmd.Flags().GetBool("draft")
prereleaseValue, _ := cmd.Flags().GetBool("prerelease")
draftSet := cmd.Flags().Changed("draft")
prereleaseSet := cmd.Flags().Changed("prerelease")
if limit <= 0 {
return fmt.Errorf("limit must be greater than 0")
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
pageSize := limit
if pageSize > 50 {
pageSize = 50
}
opts := gitea.ListReleasesOptions{}
if draftSet {
opts.IsDraft = &draftValue
}
if prereleaseSet {
opts.IsPreRelease = &prereleaseValue
}
ios.StartSpinner("Fetching releases...")
var releases []*gitea.Release
for page := 1; len(releases) < limit; page++ {
opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize}
batch, _, err := client.ListReleases(owner, name, opts)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list releases: %w", err)
}
if len(batch) == 0 {
break
}
releases = append(releases, batch...)
}
ios.StopSpinner()
if len(releases) > limit {
releases = releases[:limit]
}
if wantJSON(cmd) {
return outputJSON(cmd, releases)
}
if len(releases) == 0 {
fmt.Fprintf(ios.Out, "No releases in %s/%s\n", owner, name)
return nil
}
isTTY := ios.IsStdoutTTY()
tp := ios.NewTablePrinter()
tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED")
for _, rel := range releases {
published := text.FormatDate(releaseTimestamp(rel), isTTY)
tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published)
}
return tp.Render()
}
func runReleaseView(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
tag := args[0]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
ios.StopSpinner()
return err
}
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
ios.StopSpinner()
if err != nil {
return err
}
if web, _ := cmd.Flags().GetBool("web"); web {
if release.HTMLURL != "" {
return ios.OpenInBrowser(release.HTMLURL)
}
return fmt.Errorf("release has no HTML URL")
}
if wantJSON(cmd) {
payload := struct {
Release *gitea.Release `json:"release"`
Assets []*gitea.Attachment `json:"assets,omitempty"`
}{
Release: release,
Assets: attachments,
}
return outputJSON(cmd, payload)
}
if err := ios.StartPager(); err != nil {
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
}
defer ios.StopPager()
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "Release %s\n", cs.Bold(release.TagName))
fmt.Fprintf(ios.Out, "Title: %s\n", release.Title)
fmt.Fprintf(ios.Out, "Type: %s\n", releaseType(release))
if release.Target != "" {
fmt.Fprintf(ios.Out, "Target: %s\n", release.Target)
}
if release.Publisher != nil {
fmt.Fprintf(ios.Out, "Author: %s\n", release.Publisher.UserName)
}
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(release.CreatedAt, isTTY))
if !release.PublishedAt.IsZero() {
fmt.Fprintf(ios.Out, "Published: %s\n", text.FormatDate(release.PublishedAt, isTTY))
}
if release.HTMLURL != "" {
fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL)
}
if release.Note != "" {
fmt.Fprintf(ios.Out, "\n%s\n", release.Note)
}
if len(attachments) > 0 {
fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments))
for _, asset := range attachments {
fmt.Fprintf(ios.Out, "- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
}
}
return nil
}
func runReleaseCreate(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
title, _ := cmd.Flags().GetString("title")
notes, _ := cmd.Flags().GetString("notes")
notesFile, _ := cmd.Flags().GetString("notes-file")
draft, _ := cmd.Flags().GetBool("draft")
prerelease, _ := cmd.Flags().GetBool("prerelease")
target, _ := cmd.Flags().GetString("target")
if notes != "" && notesFile != "" {
return fmt.Errorf("use either --notes or --notes-file, not both")
}
tag := args[0]
files := args[1:]
if notesFile != "" {
content, err := os.ReadFile(notesFile)
if err != nil {
return fmt.Errorf("failed to read notes file: %w", err)
}
notes = string(content)
}
if title == "" {
title = tag
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Creating release...")
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
TagName: tag,
Target: target,
Title: title,
Note: notes,
IsDraft: draft,
IsPrerelease: prerelease,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create release: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Release created: %s\n", cs.SuccessIcon(), release.TagName)
if release.HTMLURL != "" {
fmt.Fprintf(ios.Out, "View at: %s\n", release.HTMLURL)
}
if len(files) == 0 {
return nil
}
ios.StartSpinner("Uploading assets...")
if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil {
ios.StopSpinner()
return err
}
ios.StopSpinner()
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
return nil
}
func runReleaseUpload(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
clobber, _ := cmd.Flags().GetBool("clobber")
tag := args[0]
files := args[1:]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
ios.StopSpinner()
if err != nil {
return err
}
ios.StartSpinner("Uploading assets...")
if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil {
ios.StopSpinner()
return err
}
ios.StopSpinner()
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
return nil
}
func runReleaseDownload(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
dir, _ := cmd.Flags().GetString("dir")
pattern, _ := cmd.Flags().GetString("pattern")
tag := args[0]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
ios.StopSpinner()
return err
}
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
ios.StopSpinner()
if err != nil {
return err
}
if len(attachments) == 0 {
fmt.Fprintf(ios.Out, "No assets found for release %s\n", release.TagName)
return nil
}
// Filter by pattern if provided
var toDownload []*gitea.Attachment
for _, a := range attachments {
if pattern != "" {
matched, matchErr := path.Match(pattern, a.Name)
if matchErr != nil {
return fmt.Errorf("invalid glob pattern %q: %w", pattern, matchErr)
}
if !matched {
continue
}
}
toDownload = append(toDownload, a)
}
if len(toDownload) == 0 {
fmt.Fprintf(ios.Out, "No assets matching pattern %q in release %s\n", pattern, release.TagName)
return nil
}
// Ensure download directory exists
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
for _, a := range toDownload {
destPath := filepath.Join(dir, a.Name)
f, createErr := os.Create(destPath)
if createErr != nil {
return fmt.Errorf("failed to create file %s: %w", destPath, createErr)
}
dlErr := client.DownloadFile(a.DownloadURL, f)
closeErr := f.Close()
if dlErr != nil {
return fmt.Errorf("failed to download %s: %w", a.Name, dlErr)
}
if closeErr != nil {
return fmt.Errorf("failed to close %s: %w", destPath, closeErr)
}
fmt.Fprintf(ios.Out, "Downloaded %s (%d bytes)\n", a.Name, a.Size)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "\n%s %s downloaded to %s\n", cs.SuccessIcon(), text.Pluralize(len(toDownload), "asset"), dir)
return nil
}
func runReleaseDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
tag := args[0]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
ios.StopSpinner()
if err != nil {
return err
}
if !yes {
confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete release %s?", release.TagName))
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Deleting release...")
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to delete release: %w", err)
}
ios.StopSpinner()
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Release %s deleted\n", cs.SuccessIcon(), release.TagName)
return nil
}
func getReleaseByTagOrLatest(client *api.Client, owner, name, tag string) (*gitea.Release, error) {
if strings.EqualFold(tag, "latest") {
release, _, err := client.GetLatestRelease(owner, name)
if err != nil {
return nil, fmt.Errorf("failed to get latest release: %w", err)
}
return release, nil
}
release, _, err := client.GetReleaseByTag(owner, name, tag)
if err != nil {
return nil, fmt.Errorf("failed to get release: %w", err)
}
return release, nil
}
func uploadReleaseAssets(client *api.Client, owner, name string, releaseID int64, files []string, clobber bool) error {
existing := map[string]int64{}
if clobber {
attachments, err := listReleaseAttachments(client, owner, name, releaseID)
if err != nil {
return err
}
for _, attachment := range attachments {
existing[attachment.Name] = attachment.ID
}
}
for _, file := range files {
filename := filepath.Base(file)
if clobber {
if attachmentID, ok := existing[filename]; ok {
if _, err := client.DeleteReleaseAttachment(owner, name, releaseID, attachmentID); err != nil {
return fmt.Errorf("failed to delete existing asset %s: %w", filename, err)
}
}
}
handle, err := os.Open(file)
if err != nil {
return fmt.Errorf("failed to open %s: %w", file, err)
}
_, _, err = client.CreateReleaseAttachment(owner, name, releaseID, handle, filename)
closeErr := handle.Close()
if err != nil {
return fmt.Errorf("failed to upload %s: %w", file, err)
}
if closeErr != nil {
return fmt.Errorf("failed to close %s: %w", file, closeErr)
}
}
return nil
}
func listReleaseAttachments(client *api.Client, owner, name string, releaseID int64) ([]*gitea.Attachment, error) {
var all []*gitea.Attachment
for page := 1; ; page++ {
attachments, _, err := client.ListReleaseAttachments(owner, name, releaseID, gitea.ListReleaseAttachmentsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, fmt.Errorf("failed to list release assets: %w", err)
}
if len(attachments) == 0 {
break
}
all = append(all, attachments...)
}
return all, nil
}
func releaseType(release *gitea.Release) string {
if release.IsDraft {
return "draft"
}
if release.IsPrerelease {
return "prerelease"
}
return "release"
}
func releaseTimestamp(release *gitea.Release) time.Time {
if !release.PublishedAt.IsZero() {
return release.PublishedAt
}
return release.CreatedAt
}