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 ", 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 ", 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 ...", 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]) }