311 lines
8.1 KiB
Go
311 lines
8.1 KiB
Go
|
|
package cmd
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strconv"
|
||
|
|
|
||
|
|
"code.gitea.io/sdk/gitea"
|
||
|
|
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
||
|
|
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
||
|
|
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
||
|
|
"github.com/spf13/cobra"
|
||
|
|
)
|
||
|
|
|
||
|
|
var releaseAssetCmd = &cobra.Command{
|
||
|
|
Use: "asset",
|
||
|
|
Aliases: []string{"assets"},
|
||
|
|
Short: "Manage release assets",
|
||
|
|
Long: "List, upload, and delete individual attachments on a release.",
|
||
|
|
}
|
||
|
|
|
||
|
|
var releaseAssetListCmd = &cobra.Command{
|
||
|
|
Use: "list <tag|latest>",
|
||
|
|
Short: "List release assets",
|
||
|
|
Long: "List attachments on a release identified by tag name (or \"latest\").",
|
||
|
|
Example: ` # List assets on a release
|
||
|
|
fgj release asset list v1.0.0
|
||
|
|
|
||
|
|
# List assets on the latest release as JSON
|
||
|
|
fgj release asset list latest --json`,
|
||
|
|
Args: cobra.ExactArgs(1),
|
||
|
|
RunE: runReleaseAssetList,
|
||
|
|
}
|
||
|
|
|
||
|
|
var releaseAssetCreateCmd = &cobra.Command{
|
||
|
|
Use: "create <tag|latest> <files...>",
|
||
|
|
Short: "Upload one or more release assets",
|
||
|
|
Long: "Upload one or more files as attachments to an existing release.",
|
||
|
|
Example: ` # Upload a single asset
|
||
|
|
fgj release asset create v1.0.0 dist/app-linux-amd64
|
||
|
|
|
||
|
|
# Upload multiple assets to the latest release
|
||
|
|
fgj release asset create latest dist/*.tar.gz
|
||
|
|
|
||
|
|
# Upload a single file under a different name
|
||
|
|
fgj release asset create v1.0.0 ./build/out --name app-v1.0.0`,
|
||
|
|
Args: cobra.MinimumNArgs(2),
|
||
|
|
RunE: runReleaseAssetCreate,
|
||
|
|
}
|
||
|
|
|
||
|
|
var releaseAssetDeleteCmd = &cobra.Command{
|
||
|
|
Use: "delete <tag|latest> <asset-id-or-name>...",
|
||
|
|
Short: "Delete one or more release assets",
|
||
|
|
Long: "Delete attachments from a release. Each argument may be a numeric asset ID or an asset filename.",
|
||
|
|
Example: ` # Delete by filename
|
||
|
|
fgj release asset delete v1.0.0 app-linux-amd64
|
||
|
|
|
||
|
|
# Delete multiple by ID
|
||
|
|
fgj release asset delete v1.0.0 42 43
|
||
|
|
|
||
|
|
# Skip confirmation
|
||
|
|
fgj release asset delete latest output.zip --yes`,
|
||
|
|
Args: cobra.MinimumNArgs(2),
|
||
|
|
RunE: runReleaseAssetDelete,
|
||
|
|
}
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
releaseCmd.AddCommand(releaseAssetCmd)
|
||
|
|
releaseAssetCmd.AddCommand(releaseAssetListCmd)
|
||
|
|
releaseAssetCmd.AddCommand(releaseAssetCreateCmd)
|
||
|
|
releaseAssetCmd.AddCommand(releaseAssetDeleteCmd)
|
||
|
|
|
||
|
|
addRepoFlags(releaseAssetListCmd)
|
||
|
|
addJSONFlags(releaseAssetListCmd, "Output assets as JSON")
|
||
|
|
|
||
|
|
addRepoFlags(releaseAssetCreateCmd)
|
||
|
|
releaseAssetCreateCmd.Flags().String("name", "", "Override the uploaded filename (single file only)")
|
||
|
|
|
||
|
|
addRepoFlags(releaseAssetDeleteCmd)
|
||
|
|
releaseAssetDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
||
|
|
}
|
||
|
|
|
||
|
|
func runReleaseAssetList(cmd *cobra.Command, args []string) error {
|
||
|
|
tag := args[0]
|
||
|
|
|
||
|
|
client, owner, name, err := newReleaseAssetClient(cmd)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
ios.StartSpinner("Fetching release...")
|
||
|
|
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||
|
|
if err != nil {
|
||
|
|
ios.StopSpinner()
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
var all []*gitea.Attachment
|
||
|
|
for page := 1; ; page++ {
|
||
|
|
attachments, _, err := client.ListReleaseAttachments(owner, name, release.ID, gitea.ListReleaseAttachmentsOptions{
|
||
|
|
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
ios.StopSpinner()
|
||
|
|
return fmt.Errorf("failed to list release assets: %w", err)
|
||
|
|
}
|
||
|
|
if len(attachments) == 0 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
all = append(all, attachments...)
|
||
|
|
}
|
||
|
|
ios.StopSpinner()
|
||
|
|
|
||
|
|
if wantJSON(cmd) {
|
||
|
|
return outputJSON(cmd, all)
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(all) == 0 {
|
||
|
|
fmt.Fprintf(ios.Out, "No assets on release %s\n", release.TagName)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
isTTY := ios.IsStdoutTTY()
|
||
|
|
tp := ios.NewTablePrinter()
|
||
|
|
tp.AddHeader("ID", "NAME", "SIZE", "DOWNLOADS", "CREATED")
|
||
|
|
for _, a := range all {
|
||
|
|
tp.AddRow(
|
||
|
|
strconv.FormatInt(a.ID, 10),
|
||
|
|
a.Name,
|
||
|
|
humanSize(a.Size),
|
||
|
|
strconv.FormatInt(a.DownloadCount, 10),
|
||
|
|
text.FormatDate(a.Created, isTTY),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
return tp.Render()
|
||
|
|
}
|
||
|
|
|
||
|
|
func runReleaseAssetCreate(cmd *cobra.Command, args []string) error {
|
||
|
|
tag := args[0]
|
||
|
|
files := args[1:]
|
||
|
|
|
||
|
|
nameOverride, _ := cmd.Flags().GetString("name")
|
||
|
|
if nameOverride != "" && len(files) != 1 {
|
||
|
|
return fmt.Errorf("--name may only be used when uploading a single file")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fail early if any file is missing.
|
||
|
|
for _, f := range files {
|
||
|
|
info, err := os.Stat(f)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to stat %s: %w", f, err)
|
||
|
|
}
|
||
|
|
if info.IsDir() {
|
||
|
|
return fmt.Errorf("%s is a directory, not a file", f)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
client, owner, name, err := newReleaseAssetClient(cmd)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
ios.StartSpinner("Fetching release...")
|
||
|
|
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||
|
|
ios.StopSpinner()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
cs := ios.ColorScheme()
|
||
|
|
for _, file := range files {
|
||
|
|
filename := filepath.Base(file)
|
||
|
|
if nameOverride != "" {
|
||
|
|
filename = nameOverride
|
||
|
|
}
|
||
|
|
|
||
|
|
handle, err := os.Open(file)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to open %s: %w", file, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
attachment, _, uploadErr := client.CreateReleaseAttachment(owner, name, release.ID, handle, filename)
|
||
|
|
closeErr := handle.Close()
|
||
|
|
if uploadErr != nil {
|
||
|
|
return fmt.Errorf("failed to upload %s: %w", file, uploadErr)
|
||
|
|
}
|
||
|
|
if closeErr != nil {
|
||
|
|
return fmt.Errorf("failed to close %s: %w", file, closeErr)
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(ios.Out, "%s Uploaded %s (id %d, %s)\n", cs.SuccessIcon(), attachment.Name, attachment.ID, humanSize(attachment.Size))
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func runReleaseAssetDelete(cmd *cobra.Command, args []string) error {
|
||
|
|
tag := args[0]
|
||
|
|
targets := args[1:]
|
||
|
|
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
||
|
|
|
||
|
|
client, owner, name, err := newReleaseAssetClient(cmd)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
ios.StartSpinner("Fetching release...")
|
||
|
|
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||
|
|
ios.StopSpinner()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resolve each target to an attachment (ID + name). List attachments once
|
||
|
|
// if any target is a non-numeric name so we can match by filename.
|
||
|
|
var cached []*gitea.Attachment
|
||
|
|
resolvedIDs := make([]int64, 0, len(targets))
|
||
|
|
resolvedNames := make([]string, 0, len(targets))
|
||
|
|
|
||
|
|
for _, t := range targets {
|
||
|
|
if id, err := strconv.ParseInt(t, 10, 64); err == nil && id > 0 {
|
||
|
|
resolvedIDs = append(resolvedIDs, id)
|
||
|
|
resolvedNames = append(resolvedNames, t)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
if cached == nil {
|
||
|
|
cached, err = listReleaseAttachments(client, owner, name, release.ID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var matched *gitea.Attachment
|
||
|
|
for _, a := range cached {
|
||
|
|
if a.Name == t {
|
||
|
|
matched = a
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if matched == nil {
|
||
|
|
return fmt.Errorf("no asset named %q on release %s", t, release.TagName)
|
||
|
|
}
|
||
|
|
resolvedIDs = append(resolvedIDs, matched.ID)
|
||
|
|
resolvedNames = append(resolvedNames, matched.Name)
|
||
|
|
}
|
||
|
|
|
||
|
|
cs := ios.ColorScheme()
|
||
|
|
for i, id := range resolvedIDs {
|
||
|
|
display := resolvedNames[i]
|
||
|
|
|
||
|
|
if !skipConfirm && ios.IsStdinTTY() {
|
||
|
|
answer, err := promptLine(fmt.Sprintf("Delete asset %s (id %d) from release %s? [y/N]: ", display, id, release.TagName))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if answer != "y" && answer != "Y" && answer != "yes" {
|
||
|
|
fmt.Fprintf(ios.ErrOut, "Skipped %s\n", display)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if _, err := client.DeleteReleaseAttachment(owner, name, release.ID, id); err != nil {
|
||
|
|
return fmt.Errorf("failed to delete asset %s: %w", display, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(ios.Out, "%s Deleted asset %s (id %d)\n", cs.SuccessIcon(), display, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func newReleaseAssetClient(cmd *cobra.Command) (*api.Client, string, string, error) {
|
||
|
|
repo, _ := cmd.Flags().GetString("repo")
|
||
|
|
owner, name, err := parseRepo(repo)
|
||
|
|
if err != nil {
|
||
|
|
return nil, "", "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
cfg, err := config.Load()
|
||
|
|
if err != nil {
|
||
|
|
return nil, "", "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||
|
|
if err != nil {
|
||
|
|
return nil, "", "", err
|
||
|
|
}
|
||
|
|
|
||
|
|
return client, owner, name, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// humanSize formats a byte count using power-of-1024 units.
|
||
|
|
func humanSize(n int64) string {
|
||
|
|
const unit = int64(1024)
|
||
|
|
if n < unit {
|
||
|
|
return fmt.Sprintf("%d B", n)
|
||
|
|
}
|
||
|
|
div, exp := unit, 0
|
||
|
|
for v := n / unit; v >= unit; v /= unit {
|
||
|
|
div *= unit
|
||
|
|
exp++
|
||
|
|
}
|
||
|
|
suffixes := []string{"KB", "MB", "GB", "TB", "PB", "EB"}
|
||
|
|
if exp >= len(suffixes) {
|
||
|
|
exp = len(suffixes) - 1
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), suffixes[exp])
|
||
|
|
}
|