fj/cmd/release.go
2026-01-18 13:11:41 +01:00

468 lines
12 KiB
Go

package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"code.gitea.io/sdk/gitea"
"codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config"
"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.",
RunE: runReleaseList,
}
var releaseViewCmd = &cobra.Command{
Use: "view <tag|latest>",
Short: "View a release",
Long: "Display detailed information about a release.",
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.",
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.",
Args: cobra.MinimumNArgs(2),
RunE: runReleaseUpload,
}
var releaseDeleteCmd = &cobra.Command{
Use: "delete <tag|latest>",
Short: "Delete a release",
Long: "Delete a release by tag, keeping its Git tag intact.",
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(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")
releaseListCmd.Flags().Bool("json", false, "Output releases as JSON")
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseViewCmd.Flags().Bool("json", false, "Output release as JSON")
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")
releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
}
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
}
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 {
return fmt.Errorf("failed to list releases: %w", err)
}
if len(batch) == 0 {
break
}
releases = append(releases, batch...)
}
if len(releases) > limit {
releases = releases[:limit]
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(releases)
}
if len(releases) == 0 {
fmt.Printf("No releases in %s/%s\n", owner, name)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n")
for _, rel := range releases {
published := releaseTimestamp(rel).Format("2006-01-02")
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published)
}
_ = w.Flush()
return nil
}
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
}
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
return err
}
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
if err != nil {
return err
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
payload := struct {
Release *gitea.Release `json:"release"`
Assets []*gitea.Attachment `json:"assets,omitempty"`
}{
Release: release,
Assets: attachments,
}
return writeJSON(payload)
}
fmt.Printf("Release %s\n", release.TagName)
fmt.Printf("Title: %s\n", release.Title)
fmt.Printf("Type: %s\n", releaseType(release))
if release.Target != "" {
fmt.Printf("Target: %s\n", release.Target)
}
if release.Publisher != nil {
fmt.Printf("Author: %s\n", release.Publisher.UserName)
}
fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05"))
if !release.PublishedAt.IsZero() {
fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05"))
}
if release.HTMLURL != "" {
fmt.Printf("URL: %s\n", release.HTMLURL)
}
if release.Note != "" {
fmt.Printf("\n%s\n", release.Note)
}
if len(attachments) > 0 {
fmt.Printf("\nAssets (%d):\n", len(attachments))
for _, asset := range attachments {
fmt.Printf("- %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
}
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
TagName: tag,
Target: target,
Title: title,
Note: notes,
IsDraft: draft,
IsPrerelease: prerelease,
})
if err != nil {
return fmt.Errorf("failed to create release: %w", err)
}
fmt.Printf("Release created: %s\n", release.TagName)
if release.HTMLURL != "" {
fmt.Printf("View at: %s\n", release.HTMLURL)
}
if len(files) == 0 {
return nil
}
if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil {
return err
}
fmt.Printf("Uploaded %d asset(s)\n", len(files))
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
}
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
return err
}
if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil {
return err
}
fmt.Printf("Uploaded %d asset(s)\n", len(files))
return nil
}
func runReleaseDelete(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
}
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
return err
}
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
return fmt.Errorf("failed to delete release: %w", err)
}
fmt.Printf("Release %s deleted\n", 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
}