feat(release): show notes and assets, upload with --clobber, download like gh

release view printed "v0.1.0 v0.1.0" and a URL: the notes and assets
were only in --json. It now shows the title and tag, a Latest,
Pre-release or Draft badge, who released it when and the target, the
assets with their sizes and the notes, rendered on a terminal. Without
a tag it shows the latest release, and --web opens it.

release asset upload silently attached a second asset with the same
name; it now refuses unless --clobber replaces the existing one.
release delete printed the deleted release as a live row; it reports
"✓ Deleted release v2.0.0 ... (tag kept; use --delete-tag)".

release list shows TITLE, TYPE, TAG and PUBLISHED. release create takes
asset files after the tag, -t/-n/-F/-e/-d/-p (--body and --body-file
still work) and the new alias. Downloads select assets by name or ID or
with --pattern, default to every asset of the latest release, write to
./NAME, --dir or --output with --clobber/--skip-existing, and --archive
fetches the source code. release upload and release download are
shortcuts for the asset commands, asset edit and delete take names or
IDs, and the release JSON gains author and assets.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:52:32 +02:00
1 parent 02cf01093f
commit 78b5ff3a19
11 files changed
+984 -357

No files matched your search

+3
View File
@@ -546,6 +546,9 @@ func commandOutputFields(cmd *cobra.Command) []string {
case "asset": case "asset":
return jsonFields[services.ReleaseAsset]() return jsonFields[services.ReleaseAsset]()
case "release": case "release":
if cmd.Name() == "upload" {
return jsonFields[services.ReleaseAsset]()
}
return jsonFields[services.Release]() return jsonFields[services.Release]()
case "secret": case "secret":
return jsonFields[services.ActionSecret]() return jsonFields[services.ActionSecret]()
-13
View File
@@ -487,17 +487,6 @@ const (
pr review view pr review view
pr status pr status
pr view pr view
release asset delete
release asset download
release asset edit
release asset list
release asset upload
release create
release delete
release edit
release list
release publish
release view
search issue search issue
search pr search pr
status status
@@ -522,8 +511,6 @@ const (
pr pr
pr attachment pr attachment
pr comment pr comment
release
release asset
time time
wiki wiki
` `
+1 -1
View File
@@ -90,7 +90,7 @@ func TestArgumentErrorsShowTheUsageLine(t *testing.T) {
{[]string{"issue", "list", "extra"}, "unexpected argument \"extra\"\nUsage: tea issue list [flags]"}, {[]string{"issue", "list", "extra"}, "unexpected argument \"extra\"\nUsage: tea issue list [flags]"},
{[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout <number> [flags]"}, {[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout <number> [flags]"},
{[]string{"run", "view"}, "missing run ID\nUsage: tea run view [<run-id>] [flags]"}, {[]string{"run", "view"}, "missing run ID\nUsage: tea run view [<run-id>] [flags]"},
{[]string{"release", "asset", "download", "v1"}, "missing asset ID\nUsage: tea release asset download <tag> <asset-id> [flags]"}, {[]string{"release", "asset", "edit", "v1"}, "missing asset\nUsage: tea release asset edit <tag> <asset> [flags]"},
{[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone <repository> [<directory>] [-- <gitflags>...] [flags]"}, {[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone <repository> [<directory>] [-- <gitflags>...] [flags]"},
} { } {
requireUsageBeforeRequests(t, tc.want, tc.args...) requireUsageBeforeRequests(t, tc.want, tc.args...)
+3
View File
@@ -115,6 +115,9 @@ func TestBodySourcesRejectInvalidInputBeforeAuthentication(t *testing.T) {
want := "mutually exclusive" want := "mutually exclusive"
if len(input) == 1 { if len(input) == 1 {
want = "--body-file must name a file or -" want = "--body-file must name a file or -"
if command[0] == "release" {
want = "--notes-file must name a file or -"
}
} }
requireUsageBeforeRequests(t, want, append(append(append([]string{}, command...), input...), "--no-input")...) requireUsageBeforeRequests(t, want, append(append(append([]string{}, command...), input...), "--no-input")...)
} }
+368 -140
View File
@@ -4,9 +4,12 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
@@ -19,78 +22,103 @@ import (
) )
func (a *application) releaseAssetCommand() *cobra.Command { func (a *application) releaseAssetCommand() *cobra.Command {
group := &cobra.Command{Use: "asset", Short: "Manage release assets"} group := &cobra.Command{
var limit, page int Use: "asset",
list := &cobra.Command{Use: "list <tag>", Short: "List release assets", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { Short: "Manage release assets",
target, err := a.resolve(cmd.Context(), true) Long: "List, upload, download, rename and delete the files attached to a release. Assets are named by name or ID.",
if err != nil { }
return err group.AddCommand(a.assetListCommand(), a.releaseUploadCommand(), a.releaseDownloadCommand(), a.assetEditCommand(), a.assetDeleteCommand())
} return group
service := services.Releases{Client: target.client, Repository: target.Repo} }
release, err := service.View(cmd.Context(), args[0])
if err != nil {
return err
}
items, err := service.Assets(cmd.Context(), release.ID, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, assetRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
var name string
upload := &cobra.Command{Use: "upload <tag> <file>...", Short: "Upload release assets", Args: usageArgs(cobra.MinimumNArgs(2)), RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("name") && (len(args) != 2 || strings.TrimSpace(name) == "") {
return clierr.Wrap(clierr.Usage, errors.New("--name requires exactly one file and a nonempty name"))
}
files, err := openRegularFiles(args[1:])
defer closeFiles(files)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
release, err := service.View(cmd.Context(), args[0])
if err != nil {
return err
}
filename := func(file *os.File) string {
if cmd.Flags().Changed("name") {
return name
}
return filepath.Base(file.Name())
}
return batch(a, cmd, files, func(file *os.File) string { return "upload asset " + filename(file) }, func(file *os.File) (services.ReleaseAsset, error) {
return service.UploadAsset(cmd.Context(), release.ID, filename(file), file)
}, assetRow)
}}
upload.Flags().StringVar(&name, "name", "", "Uploaded asset name (defaults to filename)") // assetFinder addresses the assets of the release tagged tag by name or ID.
group.AddCommand(upload) func assetFinder(tag string, byName bool) services.NameOrID[services.ReleaseAsset] {
for _, verb := range []string{"edit", "delete"} { return services.NameOrID[services.ReleaseAsset]{
var newName string Kind: "asset", Scope: "release " + tag, ByName: byName,
var byName bool ID: func(asset services.ReleaseAsset) int64 { return asset.ID }, Name: func(asset services.ReleaseAsset) string { return asset.Name },
command := &cobra.Command{Use: verb + " <tag> <asset-id>", Short: titleCase(verb) + " release assets", Args: argsExact(2)} }
if verb == "delete" { }
command.Use = "delete <tag> <asset-id>..."
command.Args = usageArgs(cobra.MinimumNArgs(2)) // completeReleaseAssets completes a release tag, then the asset names of the release it tags.
command.Flags().BoolVar(&byName, "by-name", false, "Select assets by exact name instead of ID") func (a *application) completeReleaseAssets() cobra.CompletionFunc {
command.Annotations = textOutput("asset deletion does not return a resource") releases := a.completeReleases()
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) == 0 {
return releases(cmd, args, toComplete)
} }
command.RunE = func(cmd *cobra.Command, args []string) error { tag := args[0]
if verb == "edit" && strings.TrimSpace(newName) == "" { return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
return clierr.Wrap(clierr.Usage, errors.New("supply --name for asset rename")) release, err := services.Releases{Client: t.client, Repository: t.Repo}.View(ctx, tag)
return describe(release.Assets, func(asset services.ReleaseAsset) (string, string) { return asset.Name, terminal.Bytes(asset.Size) }), err
})(cmd, args, toComplete)
}
}
func (a *application) assetListCommand() *cobra.Command {
var limit, page int
command := &cobra.Command{
Use: "list <tag>",
Short: "List release assets",
Example: "$ tea release asset list v1.0.0",
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
} }
var ids []int64 service := services.Releases{Client: target.client, Repository: target.Repo}
if !byName { release, err := service.View(cmd.Context(), args[0])
var err error if err != nil {
if ids, err = resourceNumbers(args[1:]); err != nil { return err
return err }
items, err := service.Assets(cmd.Context(), release.ID, limit, page)
if err != nil {
return err
}
spec := listSpec[services.ReleaseAsset]{
Columns: []terminal.Column{{Header: "ID"}, {Header: "Name", Flex: true}, {Header: "Size"}, {Header: "Downloads"}, {Header: "Created"}, {Header: "URL", PipedOnly: true}},
Row: func(item services.ReleaseAsset) []terminal.Cell {
size := terminal.Bytes(item.Size)
if !a.deps.Streams.OutTTY {
size = strconv.FormatInt(item.Size, 10)
}
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10), Link: item.URL}, {Text: item.Name}, {Text: size}, {Text: strconv.FormatInt(item.DownloadCount, 10)}, {Time: terminal.ParseTime(item.CreatedAt)}, {Text: item.URL}}
},
Context: "assets of release " + release.Tag + " in " + target.Repo, Empty: "no assets in release " + release.Tag + " of " + target.Repo,
}
return writeTable(a, cmd, items, spec, listPage{Number: page, More: len(release.Assets) > page*limit})
},
}
pageFlags(command, &limit, &page)
command.ValidArgsFunction = a.completeReleases()
return command
}
func (a *application) releaseUploadCommand() *cobra.Command {
var name string
var clobber bool
command := &cobra.Command{
Use: "upload <tag> <file>...",
Short: "Upload release assets",
Long: `Upload files as assets of a release, named after the files unless --name renames a single file.
An asset with the same name is replaced only with --clobber.`,
Example: `$ tea release upload v1.0.0 dist/tea-linux dist/tea-darwin
$ tea release upload v1.0.0 dist/tea-linux --clobber`,
Args: usageArgs(cobra.MinimumNArgs(2)),
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("name") && (len(args) != 2 || strings.TrimSpace(name) == "") {
return clierr.Wrap(clierr.Usage, errors.New("--name requires exactly one file and a nonempty name"))
}
files, err := openRegularFiles(args[1:])
defer closeFiles(files)
if err != nil {
return err
}
filename := func(file *os.File) string {
if cmd.Flags().Changed("name") {
return name
} }
return filepath.Base(file.Name())
} }
target, err := a.resolve(cmd.Context(), true) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
@@ -101,87 +129,287 @@ func (a *application) releaseAssetCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
if verb == "edit" { replaced := map[string]services.ReleaseAsset{}
item, err := service.RenameAsset(cmd.Context(), release.ID, ids[0], newName) for _, file := range files {
if err != nil { for _, asset := range release.Assets {
return err if asset.Name == filename(file) {
} if !clobber {
return writeItem(a, cmd, item, assetRow) return clierr.Wrap(clierr.Usage, fmt.Errorf("asset %s already exists in release %s; pass --clobber to replace it", asset.Name, release.Tag))
}
if byName {
assets, err := service.AllAssets(cmd.Context(), release.ID)
if err != nil {
return err
}
for _, name := range args[1:] {
var matches []int64
for _, asset := range assets {
if asset.Name == name {
matches = append(matches, asset.ID)
} }
replaced[asset.Name] = asset
} }
if len(matches) != 1 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("asset name %q matches %d assets; use an explicit asset ID", name, len(matches)))
}
if slices.Contains(ids, matches[0]) {
return clierr.Wrap(clierr.Usage, errors.New("duplicate asset selections"))
}
ids = append(ids, matches[0])
} }
} }
if err := a.confirm(cmd, "Delete release assets "+strings.Join(args[1:], ", ")+" on "+target.Repo); err != nil { return batchDone(a, cmd, files, func(file *os.File) string { return "upload asset " + filename(file) }, func(file *os.File) (services.ReleaseAsset, error) {
if old, ok := replaced[filename(file)]; ok {
if err := service.DeleteAsset(cmd.Context(), release.ID, old.ID); err != nil {
return services.ReleaseAsset{}, fmt.Errorf("replace asset %s: %w", old.Name, err)
}
}
return service.UploadAsset(cmd.Context(), release.ID, filename(file), file)
}, func(asset services.ReleaseAsset) string {
return fmt.Sprintf("Uploaded %s (%s) to release %s", asset.Name, terminal.Bytes(asset.Size), release.Tag)
})
},
}
command.Flags().StringVar(&name, "name", "", "Asset name (default: the file's name)")
command.Flags().BoolVar(&clobber, "clobber", false, "Replace assets with the same name")
command.ValidArgsFunction = a.completeReleases()
return command
}
func (a *application) releaseDownloadCommand() *cobra.Command {
var flags downloadFlags
var patterns []string
var archive string
command := &cobra.Command{
Use: "download [<tag>] [<asset>...]",
Short: "Download release assets",
Long: `Download assets of a release, by name or ID, or those matching --pattern; without either, every
asset. --archive downloads the source code archive instead. Without a tag, the latest release is used.
Files are written to the current directory under their names, to --dir, or to --output; an existing
file is replaced only with --clobber. --output - writes one asset to standard output.`,
Example: `# Download every asset of the latest release
$ tea release download
# Download the Linux builds of a release into dist/
$ tea release download v1.0.0 --pattern '*linux*' --dir dist
# Download the source code as a zip file
$ tea release download v1.0.0 --archive zip`,
Annotations: textOutput("asset downloads are files"),
Args: usageArgs(cobra.ArbitraryArgs),
RunE: func(cmd *cobra.Command, args []string) error {
for _, pattern := range patterns {
if _, err := path.Match(pattern, ""); err != nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid --pattern %q", pattern))
}
}
tag, names := "", []string{}
if len(args) > 0 {
tag, names = args[0], args[1:]
}
if archive != "" && (len(names) > 0 || len(patterns) > 0) {
return clierr.Wrap(clierr.Usage, errors.New("--archive downloads the source code only; it cannot be combined with assets or --pattern"))
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err return err
} }
var failures []error service := services.Releases{Client: target.client, Repository: target.Repo}
for _, id := range ids { release, err := viewRelease(cmd, service, tag)
if err := service.DeleteAsset(cmd.Context(), release.ID, id); err != nil { if err != nil {
failures = append(failures, fmt.Errorf("delete asset %d: %w", id, err)) return err
continue }
} if archive != "" {
if len(ids) > 1 { return a.downloadArchive(cmd, service, flags, release.Tag, archive)
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Deleted asset %d\n", id); err != nil { }
return err assets, err := selectAssets(release, names, patterns)
} if err != nil {
return err
}
if len(assets) > 1 && flags.output != "" && !isDirectory(flags.output) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--output names one file, but %d assets match; pass --dir", len(assets)))
}
paths := make([]string, len(assets))
skipped := make([]bool, len(assets))
for i, asset := range assets {
if paths[i], skipped[i], err = flags.destination(asset.Name); err != nil {
return err
} }
} }
return errors.Join(failures...) for i, asset := range assets {
} if skipped[i] {
if verb == "edit" { a.warn(cmd, "Skipped %s; %s already exists", asset.Name, paths[i])
command.Flags().StringVar(&newName, "name", "", "New asset name") continue
} }
group.AddCommand(command) body, err := service.DownloadAsset(cmd.Context(), release.ID, asset.ID)
if err != nil {
return fmt.Errorf("download %s: %w", asset.Name, err)
}
err = a.download(cmd, flags, paths[i], body)
_ = body.Close()
if err != nil {
return err
}
if paths[i] != "-" {
a.done(cmd, "Downloaded %s (%s) to %s", asset.Name, terminal.Bytes(asset.Size), paths[i])
}
}
return nil
},
} }
flags.register(command)
var output string command.Flags().StringSliceVarP(&patterns, "pattern", "p", nil, "Download the assets whose names match this glob (repeatable)")
download := &cobra.Command{Use: "download <tag> <asset-id>", Short: "Download an asset to a file or stdout", Annotations: textOutput("asset downloads are binary"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error { enumFlag(command, &archive, "archive", "A", "", "Download the source code archive", "zip", "tar.gz")
id, err := number(args[1]) command.ValidArgsFunction = a.completeReleaseAssets()
if err != nil { return command
return err
}
if err := validateDownloadDestination(output); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
release, err := service.View(cmd.Context(), args[0])
if err != nil {
return err
}
body, err := service.DownloadAsset(cmd.Context(), release.ID, id)
if err != nil {
return err
}
defer body.Close()
return writeDownload(cmd, body, output)
}}
download.Flags().StringVar(&output, "output-file", "", "Destination file (must not exist), or - for stdout")
group.AddCommand(download)
return group
} }
func assetRow(item services.ReleaseAsset) []terminal.Cell { // selectAssets returns the assets of release that names (names or IDs) and patterns (globs)
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), item.URL) // select, or every asset when there are neither, each once.
func selectAssets(release services.Release, names, patterns []string) ([]services.ReleaseAsset, error) {
if len(names) == 0 && len(patterns) == 0 {
if len(release.Assets) == 0 {
return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("release %s has no assets; pass --archive to download its source code", release.Tag))
}
return release.Assets, nil
}
var selected []services.ReleaseAsset
add := func(asset services.ReleaseAsset) {
if !slices.ContainsFunc(selected, func(other services.ReleaseAsset) bool { return other.ID == asset.ID }) {
selected = append(selected, asset)
}
}
finder := assetFinder(release.Tag, false)
for _, name := range names {
asset, err := finder.Find(release.Assets, name)
if err != nil {
return nil, err
}
add(asset)
}
for _, pattern := range patterns {
matched := false
for _, asset := range release.Assets {
if ok, _ := path.Match(pattern, asset.Name); ok {
add(asset)
matched = true
}
}
if !matched {
return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("no asset of release %s matches %q", release.Tag, pattern))
}
}
return selected, nil
}
// downloadArchive downloads the source code of the tag as a zip or tar.gz file.
func (a *application) downloadArchive(cmd *cobra.Command, service services.Releases, flags downloadFlags, tag, format string) error {
_, repo, _ := strings.Cut(service.Repository, "/")
name := repo + "-" + strings.ReplaceAll(tag, "/", "-") + "." + format
destination, skip, err := flags.destination(name)
if err != nil || skip {
if skip {
a.warn(cmd, "Skipped %s; %s already exists", name, destination)
}
return err
}
body, err := service.Archive(cmd.Context(), tag, format)
if err != nil {
return err
}
defer body.Close()
counter := &countingReader{reader: body}
if err := a.download(cmd, flags, destination, counter); err != nil {
return err
}
if destination != "-" {
a.done(cmd, "Downloaded %s (%s) to %s", name, terminal.Bytes(counter.n), destination)
}
return nil
}
// countingReader counts the bytes read through it.
type countingReader struct {
reader io.Reader
n int64
}
func (r *countingReader) Read(p []byte) (int, error) {
n, err := r.reader.Read(p)
r.n += int64(n)
return n, err
}
func (a *application) assetEditCommand() *cobra.Command {
var name string
command := &cobra.Command{
Use: "edit <tag> <asset>",
Short: "Rename a release asset",
Example: "$ tea release asset edit v1.0.0 tea-linux --name tea-linux-amd64",
Args: argsExact(2),
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(name) == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --name for asset rename"))
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
release, err := service.View(cmd.Context(), args[0])
if err != nil {
return err
}
asset, err := assetFinder(release.Tag, false).Find(release.Assets, args[1])
if err != nil {
return err
}
item, err := service.RenameAsset(cmd.Context(), release.ID, asset.ID, name)
if err != nil {
return err
}
a.done(cmd, "Renamed asset %s to %s in release %s", asset.Name, item.Name, release.Tag)
return a.writeMutationJSON(cmd, item)
},
}
command.Flags().StringVar(&name, "name", "", "New asset name")
command.ValidArgsFunction = a.completeReleaseAssets()
return command
}
func (a *application) assetDeleteCommand() *cobra.Command {
var byName bool
command := &cobra.Command{
Use: "delete <tag> <asset>...",
Short: "Delete release assets",
Example: `$ tea release asset delete v1.0.0 tea-linux
$ tea release asset delete v1.0.0 12 13 --yes`,
Annotations: map[string]string{"tea.confirm": "true", "tea.text": "asset deletion does not return a resource"},
Args: usageArgs(cobra.MinimumNArgs(2)),
RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
release, err := service.View(cmd.Context(), args[0])
if err != nil {
return err
}
finder := assetFinder(release.Tag, byName)
var assets []services.ReleaseAsset
for _, raw := range args[1:] {
asset, err := finder.Find(release.Assets, raw)
if err != nil {
return err
}
if slices.ContainsFunc(assets, func(other services.ReleaseAsset) bool { return other.ID == asset.ID }) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("asset %s is selected more than once", asset.Name))
}
assets = append(assets, asset)
}
names := make([]string, len(assets))
for i, asset := range assets {
names[i] = asset.Name
}
subject := "asset " + names[0]
if len(names) > 1 {
subject = fmt.Sprintf("%d assets (%s)", len(names), strings.Join(names, ", "))
}
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + " from release " + release.Tag + " in " + target.Repo + "?", NonTTY: "delete " + subject}); err != nil {
return err
}
return batchDone(a, cmd, assets, func(asset services.ReleaseAsset) string { return "delete asset " + asset.Name }, func(asset services.ReleaseAsset) (services.ReleaseAsset, error) {
return asset, service.DeleteAsset(cmd.Context(), release.ID, asset.ID)
}, func(asset services.ReleaseAsset) string {
return fmt.Sprintf("Deleted asset %s (ID %d) from release %s", asset.Name, asset.ID, release.Tag)
})
},
}
command.Flags().BoolVar(&byName, "by-name", false, "Match assets by name only, also when numeric")
command.ValidArgsFunction = a.completeReleaseAssets()
return command
} }
+67 -13
View File
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -36,12 +37,39 @@ func TestReleaseBulkAssetInputAndPartialFailures(t *testing.T) {
err := Execute(context.Background(), deps, []string{"release", "asset", "upload", "v1", first, second + "missing", "-R", "owner/repo", "--json"}) err := Execute(context.Background(), deps, []string{"release", "asset", "upload", "v1", first, second + "missing", "-R", "owner/repo", "--json"})
require.Error(t, err) require.Error(t, err)
require.Zero(t, uploads) require.Zero(t, uploads)
err = Execute(context.Background(), deps, []string{"release", "asset", "upload", "v1", first, second, "-R", "owner/repo", "--json"}) err = Execute(context.Background(), deps, []string{"release", "upload", "v1", first, second, "-R", "owner/repo", "--json"})
require.ErrorContains(t, err, "first.txt") require.ErrorContains(t, err, "first.txt")
require.Equal(t, 2, uploads) require.Equal(t, 2, uploads)
require.JSONEq(t, `[{"id":2,"name":"second.txt","size":0,"url":"","created_at":"","download_count":0}]`, out.String()) require.JSONEq(t, `[{"id":2,"name":"second.txt","size":0,"url":"","created_at":"","download_count":0}]`, out.String())
} }
func TestReleaseUploadRefusesDuplicateNamesWithoutClobber(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
switch r.Method {
case http.MethodGet:
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1","assets":[{"id":7,"name":"hello.txt"}]}`))
case http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
_, _ = w.Write([]byte(`{"id":8,"name":"hello.txt","size":5}`))
}
}))
defer server.Close()
deps, _, diagnostics := repoTTYDeps(t, server.URL)
file := filepath.Join(t.TempDir(), "hello.txt")
require.NoError(t, os.WriteFile(file, []byte("hello"), 0o600))
err := Execute(context.Background(), deps, []string{"release", "asset", "upload", "v1", file, "-R", "owner/repo"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err)
require.ErrorContains(t, err, "asset hello.txt already exists in release v1; pass --clobber to replace it")
require.Equal(t, []string{"GET /api/v1/repos/owner/repo/releases/tags/v1"}, requests)
requests = nil
require.NoError(t, Execute(context.Background(), deps, []string{"release", "upload", "v1", file, "--clobber", "-R", "owner/repo"}))
require.Equal(t, []string{"GET /api/v1/repos/owner/repo/releases/tags/v1", "DELETE /api/v1/repos/owner/repo/releases/1/assets/7", "POST /api/v1/repos/owner/repo/releases/1/assets"}, requests)
require.Equal(t, "✓ Uploaded hello.txt (5 B) to release v1\n", diagnostics.String())
}
func TestReleaseAssetNameSelectionValidatedBeforeDeletion(t *testing.T) { func TestReleaseAssetNameSelectionValidatedBeforeDeletion(t *testing.T) {
var deleted []string var deleted []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -50,20 +78,46 @@ func TestReleaseAssetNameSelectionValidatedBeforeDeletion(t *testing.T) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
if strings.HasSuffix(r.URL.Path, "/assets") { _, _ = w.Write([]byte(`{"id":1,"tag_name":"v1","assets":[{"id":1,"name":"one.txt"},{"id":2,"name":"2"}]}`))
_, _ = w.Write([]byte(`[{"id":1,"name":"one.txt"},{"id":2,"name":"two.txt"}]`))
return
}
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1"}`))
})) }))
defer server.Close() defer server.Close()
deps, _, diagnostics := serverDeps(t, server.URL) deps, _, diagnostics := repoTTYDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"release", "asset", "delete", "v1", "one.txt", "missing", "--by-name", "--yes", "-R", "owner/repo"}) err := Execute(context.Background(), deps, []string{"release", "asset", "delete", "v1", "one.txt", "missing", "--yes", "-R", "owner/repo"})
require.ErrorContains(t, err, "matches 0 assets") require.EqualError(t, err, `asset "missing" not found in release v1`)
require.Empty(t, deleted) require.Empty(t, deleted)
err = Execute(context.Background(), deps, []string{"release", "asset", "delete", "v1", "one.txt", "two.txt", "--by-name", "--yes", "-R", "owner/repo"}) require.NoError(t, Execute(context.Background(), deps, []string{"release", "asset", "delete", "v1", "one.txt", "2", "--by-name", "--yes", "-R", "owner/repo"}))
require.NoError(t, err)
require.Equal(t, []string{"/api/v1/repos/owner/repo/releases/1/assets/1", "/api/v1/repos/owner/repo/releases/1/assets/2"}, deleted) require.Equal(t, []string{"/api/v1/repos/owner/repo/releases/1/assets/1", "/api/v1/repos/owner/repo/releases/1/assets/2"}, deleted)
require.Contains(t, diagnostics.String(), "Deleted asset 1") require.Equal(t, "✓ Deleted asset one.txt (ID 1) from release v1\n✓ Deleted asset 2 (ID 2) from release v1\n", diagnostics.String())
require.Contains(t, diagnostics.String(), "Deleted asset 2") }
func TestReleaseDownloadSelectsAssetsOfTheLatestRelease(t *testing.T) {
var downloads []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/repos/owner/repo/releases/latest", r.URL.Path == "/api/v1/repos/owner/repo/releases/tags/v1":
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1","assets":[{"id":1,"name":"tea-linux","size":5,"uuid":"aaa"},{"id":2,"name":"tea-darwin","size":5,"uuid":"bbb"},{"id":3,"name":"notes.txt","size":5,"uuid":"ccc"}]}`))
case strings.HasPrefix(r.URL.Path, "/api/v1/repos/owner/repo/releases/1/assets/"):
id := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/owner/repo/releases/1/assets/")
uuid := map[string]string{"1": "aaa", "2": "bbb", "3": "ccc"}[id]
_, _ = w.Write([]byte(`{"id":` + id + `,"uuid":"` + uuid + `"}`))
case strings.HasPrefix(r.URL.Path, "/attachments/"):
downloads = append(downloads, r.URL.Path)
_, _ = w.Write([]byte("bytes"))
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
}))
defer server.Close()
deps, _, diagnostics := repoTTYDeps(t, server.URL)
dir := t.TempDir()
require.NoError(t, Execute(context.Background(), deps, []string{"release", "download", "--pattern", "tea-*", "--dir", dir, "-R", "owner/repo"}))
require.Equal(t, []string{"/attachments/aaa", "/attachments/bbb"}, downloads)
content, err := os.ReadFile(filepath.Join(dir, "tea-darwin"))
require.NoError(t, err)
require.Equal(t, "bytes", string(content))
require.Equal(t, "✓ Downloaded tea-linux (5 B) to "+filepath.Join(dir, "tea-linux")+"\n✓ Downloaded tea-darwin (5 B) to "+filepath.Join(dir, "tea-darwin")+"\n", diagnostics.String())
err = Execute(context.Background(), deps, []string{"release", "asset", "download", "v1", "--pattern", "*.exe", "--dir", dir, "-R", "owner/repo"})
require.Error(t, err)
err = Execute(context.Background(), deps, []string{"release", "download", "v1", "notes.txt", "tea-linux", "-O", filepath.Join(dir, "one"), "-R", "owner/repo"})
require.ErrorContains(t, err, "--output names one file, but 2 assets match; pass --dir")
} }
+408 -169
View File
@@ -4,6 +4,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
@@ -15,209 +16,447 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func releaseRecord(item services.Release) terminal.Record { func (a *application) releaseCommand() *cobra.Command {
return terminal.Record{Heading: terminal.Row(item.Tag, item.Title), Lines: []string{item.URL}} group := &cobra.Command{
Use: "release",
Short: "Manage releases",
Long: `Work with the releases of a repository: list and view them with their notes and assets, create,
edit, publish and delete them, and upload or download their assets.`,
}
group.AddCommand(a.releaseListCommand(), a.releaseViewCommand(), a.releaseCreateCommand(), a.releaseEditCommand(),
a.releasePublishCommand(), a.releaseDeleteCommand(), a.releaseAssetCommand(), a.releaseUploadCommand(), a.releaseDownloadCommand())
return group
} }
func releaseRow(item services.Release) []terminal.Cell { // releaseKind is the type of a release for people: Draft, Pre-release, or Latest when it is the
status := "released" // repository's latest release.
func releaseKind(item services.Release, latest int64) terminal.Cell {
switch { switch {
case item.Draft: case item.Draft:
status = "draft" return terminal.Cell{Text: "Draft", Muted: true}
case item.Prerelease: case item.Prerelease:
status = "prerelease" return terminal.Cell{Text: "Pre-release", Color: "yellow"}
case item.ID == latest:
return terminal.Cell{Text: "Latest", Color: "green"}
} }
return []terminal.Cell{{Text: item.Tag, Link: item.URL}, {Text: item.Title}, {Text: status}} return terminal.Cell{}
} }
func (a *application) releaseCommand() *cobra.Command { // releaseTime is when a release was published, or created for a draft.
group := &cobra.Command{Use: "release", Short: "Manage repository releases"} func releaseTime(item services.Release) string {
if item.PublishedAt != nil && !item.Draft {
return *item.PublishedAt
}
return item.CreatedAt
}
func (a *application) releaseListCommand() *cobra.Command {
var limit, page int var limit, page int
list := &cobra.Command{Use: "list", Short: "List releases", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { command := &cobra.Command{
target, err := a.resolve(cmd.Context(), true) Use: "list",
if err != nil { Short: "List releases",
return err Example: `$ tea release list
} $ tea release list -R alice/project --json tag,title,draft`,
items, err := (services.Releases{Client: target.client, Repository: target.Repo}).List(cmd.Context(), limit, page) Args: argsExact(0),
if err != nil { RunE: func(cmd *cobra.Command, _ []string) error {
return err target, err := a.resolve(cmd.Context(), true)
} if err != nil {
return writeList(a, cmd, items, releaseRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"view", "create", "edit", "publish", "delete"} {
var tag, title, body, file, targetName string
var draft, prerelease, deleteTag, useEditor bool
var assetPaths []string
command := &cobra.Command{Use: verb + " <tag>", Short: titleCase(verb) + " repository releases", Args: argsExact(1)}
if verb == "edit" || verb == "delete" || verb == "publish" {
command.Use = verb + " <tag>..."
command.Args = usageArgs(cobra.MinimumNArgs(1))
}
if verb == "create" {
command.Use = "create [tag]"
command.Args = usageArgs(cobra.MaximumNArgs(1))
command.Flags().StringVar(&tag, "tag", "", "Release tag")
command.Flags().StringSliceVar(&assetPaths, "asset", nil, "Asset file to upload after creation (repeatable)")
}
if verb == "create" || verb == "edit" {
if verb == "edit" {
command.Flags().StringVar(&tag, "tag", "", "Change the release tag (one release only)")
}
command.Flags().StringVar(&title, "title", "", "Release title (creation defaults to tag)")
command.Flags().BoolVar(&useEditor, "editor", false, "Edit release notes in an editor")
command.Flags().StringVar(&body, "body", "", "Release notes")
command.Flags().StringVar(&file, "body-file", "", "Read notes from file, or - for stdin")
command.Flags().StringVar(&targetName, "target", "", "Target branch or commit for the tag")
command.Flags().BoolVar(&draft, "draft", false, "Draft release")
command.Flags().BoolVar(&prerelease, "prerelease", false, "Prerelease")
}
if verb == "delete" {
command.Flags().BoolVar(&deleteTag, "delete-tag", false, "Delete the associated Git tag too")
}
command.RunE = func(cmd *cobra.Command, args []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err return err
} }
if useEditor && verb == "edit" && len(args) != 1 { result, err := (services.Releases{Client: target.client, Repository: target.Repo}).ListPage(cmd.Context(), limit, page)
return clierr.Wrap(clierr.Usage, errors.New("--editor requires one release")) if err != nil {
return err
} }
opts := services.ReleaseOptions{} // Releases come newest first, so the first published one of the first page is the latest.
files, err := openRegularFiles(assetPaths) var latest int64
for _, item := range result.Items {
if page == 1 && !item.Draft && !item.Prerelease {
latest = item.ID
break
}
}
spec := listSpec[services.Release]{
Columns: []terminal.Column{{Header: "Title", Flex: true}, {Header: "Type"}, {Header: "Tag"}, {Header: "Published"}, {Header: "URL", PipedOnly: true}},
Row: func(item services.Release) []terminal.Cell {
return []terminal.Cell{{Text: cmp.Or(item.Title, item.Tag), Link: item.URL}, releaseKind(item, latest), {Text: item.Tag}, {Time: terminal.ParseTime(releaseTime(item))}, {Text: item.URL}}
},
Context: "releases in " + target.Repo, Empty: "no releases in " + target.Repo,
}
return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted})
},
}
pageFlags(command, &limit, &page)
return command
}
// viewRelease returns the release tagged tag, or the latest release without a tag.
func viewRelease(cmd *cobra.Command, service services.Releases, tag string) (services.Release, error) {
if tag == "" {
return service.Latest(cmd.Context())
}
return service.View(cmd.Context(), tag)
}
func (a *application) releaseViewCommand() *cobra.Command {
var web bool
command := &cobra.Command{
Use: "view [<tag>]",
Short: "View a release",
Long: "View a release with its notes, rendered on a terminal, and its assets. Without a tag, view the latest release.",
Example: `# View the latest release
$ tea release view
# View a release in the browser
$ tea release view v1.0.0 --web`,
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
tag := ""
if len(args) > 0 {
tag = args[0]
}
item, err := viewRelease(cmd, service, tag)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, item.URL)
}
latest := item.ID
if tag != "" && a.options.json == "" && !item.Draft && !item.Prerelease {
newest, _ := service.Latest(cmd.Context())
latest = newest.ID
}
return writeView(a, cmd, item, func(item services.Release) terminal.View { return a.releaseView(item, latest) })
},
}
command.Flags().BoolVarP(&web, "web", "w", false, "Open the release in the browser")
command.ValidArgsFunction = a.completeReleases()
return command
}
// releaseView shows a release with its notes as the body.
func (a *application) releaseView(item services.Release, latest int64) terminal.View {
view := terminal.View{Title: cmp.Or(item.Title, item.Tag), Body: item.Body, Kind: "release", URL: item.URL}
if item.Title != "" && item.Title != item.Tag {
view.ID = item.Tag
}
if kind := releaseKind(item, latest); kind.Text != "" {
view.Badges = []terminal.Cell{kind}
}
verb := "released"
if item.Draft {
verb = "drafted"
}
var meta []string
if when := terminal.ParseTime(releaseTime(item)); !when.IsZero() {
meta = append(meta, strings.TrimSpace(item.Author+" "+verb+" this "+terminal.RelativeTime(a.deps.Now(), when)))
}
if item.Target != "" {
meta = append(meta, "target "+item.Target)
}
view.Meta = strings.Join(meta, " · ")
assets := make([]string, len(item.Assets))
for i, asset := range item.Assets {
assets[i] = fmt.Sprintf("%s (%s)", asset.Name, terminal.Bytes(asset.Size))
}
view.Fields = []terminal.Field{{Label: "Tag", Value: item.Tag, PipedOnly: true}, {Label: "Assets", Value: strings.Join(assets, ", ")}}
return view
}
// releaseFields are the flags of release create and release edit.
type releaseFields struct {
tag, title, notes, notesFile, target string
draft, prerelease, editor bool
}
func (f *releaseFields) register(a *application, cmd *cobra.Command) {
flags := cmd.Flags()
flags.StringVarP(&f.title, "title", "t", "", "Release title")
flags.StringVarP(&f.notes, "notes", "n", "", "Release notes")
flags.StringVarP(&f.notesFile, "notes-file", "F", "", "Read release notes from a file, or - for standard input")
flags.BoolVarP(&f.editor, "editor", "e", false, "Write release notes in an editor")
flags.StringVar(&f.target, "target", "", "Branch or commit the tag points to, when it is new")
flags.BoolVarP(&f.draft, "draft", "d", false, "Save the release as a draft")
flags.BoolVarP(&f.prerelease, "prerelease", "p", false, "Mark the release as a prerelease")
flagAliases(cmd, map[string]string{"body": "notes", "body-file": "notes-file"})
_ = cmd.RegisterFlagCompletionFunc("target", a.completeBranches())
}
// options returns the release fields whose flags were passed.
func (f *releaseFields) options(a *application, cmd *cobra.Command) (services.ReleaseOptions, error) {
opts := services.ReleaseOptions{}
changed := cmd.Flags().Changed
if changed("title") {
if strings.TrimSpace(f.title) == "" {
return opts, clierr.Wrap(clierr.Usage, errors.New("release title cannot be empty"))
}
opts.Title = &f.title
}
if changed("notes") || changed("notes-file") {
notes, err := a.readTextSource(cmd, f.notes, f.notesFile, "notes", "notes-file")
if err != nil {
return opts, err
}
opts.Body = &notes
}
for _, field := range []struct {
flag string
value *bool
into **bool
}{{"draft", &f.draft, &opts.Draft}, {"prerelease", &f.prerelease, &opts.Prerelease}} {
if changed(field.flag) {
*field.into = field.value
}
}
if changed("target") {
opts.Target = &f.target
}
return opts, nil
}
func (a *application) releaseCreateCommand() *cobra.Command {
var fields releaseFields
var assets []string
command := &cobra.Command{
Use: "create [<tag>] [<file>...]",
Aliases: []string{"new"},
Short: "Create a release",
Long: `Create a release for a tag, and upload the files that follow the tag as its assets. Gitea creates
the tag at --target (the default branch) when it does not exist yet. The title defaults to the tag.`,
Example: `# Create a release with notes from a file and two assets
$ tea release create v1.0.0 dist/tea-linux dist/tea-darwin --notes-file CHANGELOG.md
# Create a draft prerelease of a new tag on a branch
$ tea release create v1.1.0-rc1 --target release/1.1 --draft --prerelease --title "1.1 RC 1"`,
Args: usageArgs(cobra.ArbitraryArgs),
RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if len(args) > 0 {
if cmd.Flags().Changed("tag") {
return clierr.Wrap(clierr.Usage, errors.New("supply a tag argument or --tag, not both"))
}
fields.tag, assets = args[0], append(args[1:], assets...)
}
if err := validateTextSources(cmd, "notes", "notes-file"); err != nil {
return err
}
if fields.tag == "" && fields.notesFile == "-" {
return clierr.Wrap(clierr.Usage, errors.New("--notes-file - requires a tag; stdin cannot also supply a prompt"))
}
files, err := openRegularFiles(assets)
defer closeFiles(files) defer closeFiles(files)
if err != nil { if err != nil {
return err return err
} }
prompted := false opts, err := fields.options(a, cmd)
if verb == "create" { if err != nil {
if len(args) > 0 { return err
if cmd.Flags().Changed("tag") { }
return clierr.Wrap(clierr.Usage, errors.New("supply a tag argument or --tag, not both")) prompted, err := a.requireInput(cmd, &fields.tag, "tag", "Tag", nil)
} if err != nil {
tag = args[0] return err
} }
if tag == "" && file == "-" { title := cmp.Or(fields.title, fields.tag)
return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires a tag; stdin cannot also supply a prompt")) opts.Tag, opts.Title, opts.Draft, opts.Prerelease = &fields.tag, &title, &fields.draft, &fields.prerelease
} target, err := a.resolve(cmd.Context(), true)
prompted, err = a.require(cmd, &tag, "tag") if err != nil {
if err != nil { return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
if fields.editor {
if editorDraft, err = a.editDraft(cmd, ""); err != nil {
return err return err
} }
if title == "" && !cmd.Flags().Changed("title") { opts.Body = &editorDraft.Body
title = tag
}
opts.Tag = &tag
opts.Title = &title
opts.Draft = &draft
opts.Prerelease = &prerelease
} }
if verb == "create" || verb == "edit" { if prompted {
if verb == "edit" && cmd.Flags().Changed("tag") { if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Create release " + fields.tag + " in " + target.Repo + "?", DefaultYes: true}); err != nil {
if len(args) != 1 || strings.TrimSpace(tag) == "" { return err
return clierr.Wrap(clierr.Usage, errors.New("--tag requires one release and a nonempty new tag"))
}
opts.Tag = &tag
} }
if cmd.Flags().Changed("title") { }
if strings.TrimSpace(title) == "" { item, err := service.Create(cmd.Context(), opts)
return clierr.Wrap(clierr.Usage, errors.New("release title cannot be empty")) if err != nil {
} return err
opts.Title = &title }
kind := "release"
if item.Draft {
kind = "draft release"
}
a.done(cmd, "Created %s %s in %s", kind, item.Tag, target.Repo)
var failures []error
for _, file := range files {
name := filepath.Base(file.Name())
asset, err := service.UploadAsset(cmd.Context(), item.ID, name, file)
if err != nil {
failures = append(failures, fmt.Errorf("release %s created; asset %s upload failed: %w", item.Tag, name, err))
continue
} }
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") { item.Assets = append(item.Assets, asset)
var err error a.done(cmd, "Uploaded %s (%s)", asset.Name, terminal.Bytes(asset.Size))
body, err = a.readBody(cmd, body, file) }
if err != nil { if a.options.json != "" {
return err failures = append(failures, a.writeJSON(cmd, item))
} } else {
opts.Body = &body failures = append(failures, a.printURL(cmd, item.URL))
} }
if cmd.Flags().Changed("target") { return errors.Join(failures...)
opts.Target = &targetName },
} }
if cmd.Flags().Changed("draft") { fields.register(a, command)
opts.Draft = &draft command.Flags().StringVar(&fields.tag, "tag", "", "Release tag")
} _ = command.Flags().MarkHidden("tag")
if cmd.Flags().Changed("prerelease") { command.Flags().StringSliceVar(&assets, "asset", nil, "File to upload as an asset (repeatable)")
opts.Prerelease = &prerelease command.ValidArgsFunction = a.completeTags()
} return command
if verb == "edit" && !useEditor && opts == (services.ReleaseOptions{}) { }
return clierr.Wrap(clierr.Usage, errors.New("supply release fields to edit"))
func (a *application) releaseEditCommand() *cobra.Command {
var fields releaseFields
command := &cobra.Command{
Use: "edit <tag>...",
Short: "Edit releases",
Example: `$ tea release edit v1.0.0 --title "1.0.0" --notes-file CHANGELOG.md
$ tea release edit v1.0.0-rc1 v1.0.0-rc2 --prerelease=false`,
Args: usageArgs(cobra.MinimumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if err := validateTextSources(cmd, "notes", "notes-file"); err != nil {
return err
}
if fields.editor && len(args) != 1 {
return clierr.Wrap(clierr.Usage, errors.New("--editor requires one release"))
}
opts, err := fields.options(a, cmd)
if err != nil {
return err
}
if cmd.Flags().Changed("tag") {
if len(args) != 1 || strings.TrimSpace(fields.tag) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--tag requires one release and a nonempty new tag"))
} }
opts.Tag = &fields.tag
}
if !fields.editor && opts == (services.ReleaseOptions{}) {
return clierr.Wrap(clierr.Usage, errors.New("supply release fields to edit"))
} }
target, err := a.resolve(cmd.Context(), true) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
} }
service := services.Releases{Client: target.client, Repository: target.Repo} service := services.Releases{Client: target.client, Repository: target.Repo}
if useEditor { if fields.editor {
initial := ""
if verb == "edit" {
item, err := service.View(cmd.Context(), args[0])
if err != nil {
return err
}
initial = item.Body
}
editorDraft, err = a.editDraft(cmd, initial)
if err != nil {
return err
}
opts.Body = &editorDraft.Body
}
if verb == "delete" || prompted {
subject := strings.Join(args, " ")
if verb == "create" {
subject = tag
}
if err := a.confirm(cmd, fmt.Sprintf("%s release %s on %s", verb, subject, target.Repo)); err != nil {
return err
}
}
if verb == "create" {
item, err := service.Create(cmd.Context(), opts)
if err != nil {
return err
}
var failures []error
for _, file := range files {
if _, err := service.UploadAsset(cmd.Context(), item.ID, filepath.Base(file.Name()), file); err != nil {
failures = append(failures, fmt.Errorf("release %s created; asset %s upload failed: %w", item.Tag, filepath.Base(file.Name()), err))
}
}
if err := writeRecord(a, cmd, item, releaseRecord); err != nil {
return err
}
return errors.Join(failures...)
}
if verb == "view" {
item, err := service.View(cmd.Context(), args[0]) item, err := service.View(cmd.Context(), args[0])
if err != nil { if err != nil {
return err return err
} }
return writeRecord(a, cmd, item, releaseRecord) if editorDraft, err = a.editDraft(cmd, item.Body); err != nil {
return err
}
opts.Body = &editorDraft.Body
} }
return batch(a, cmd, args, func(name string) string { return "release " + name }, func(name string) (services.Release, error) { return batchDone(a, cmd, args, func(tag string) string { return "release " + tag }, func(tag string) (services.Release, error) {
item, err := service.View(cmd.Context(), name) item, err := service.View(cmd.Context(), tag)
if err == nil { if err != nil {
switch verb { return item, err
case "edit": }
item, err = service.Edit(cmd.Context(), item.ID, opts) return service.Edit(cmd.Context(), item.ID, opts)
case "publish": }, func(item services.Release) string { return "Edited release " + item.Tag + " in " + target.Repo })
published := false },
item, err = service.Edit(cmd.Context(), item.ID, services.ReleaseOptions{Draft: &published}) }
case "delete": fields.register(a, command)
err = service.Delete(cmd.Context(), item, deleteTag) command.Flags().StringVar(&fields.tag, "tag", "", "Change the release's tag (one release only)")
} command.ValidArgsFunction = a.completeReleases()
return command
}
func (a *application) releasePublishCommand() *cobra.Command {
command := &cobra.Command{
Use: "publish <tag>...",
Short: "Publish draft releases",
Example: "$ tea release publish v1.1.0",
Args: usageArgs(cobra.MinimumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
results, failures := applyBatch(cmd, args, func(tag string) string { return "release " + tag }, func(tag string) (services.Release, error) {
item, err := service.View(cmd.Context(), tag)
if err != nil {
return item, err
}
if !item.Draft {
a.warn(cmd, "Release %s is already published", item.Tag)
return item, nil
}
published := false
if item, err = service.Edit(cmd.Context(), item.ID, services.ReleaseOptions{Draft: &published}); err == nil {
a.done(cmd, "Published release %s in %s", item.Tag, target.Repo)
} }
return item, err return item, err
}, releaseRow) }, func(services.Release) {})
} if a.options.json != "" {
group.AddCommand(command) failures = append(failures, writeBatchJSON(a, cmd, len(args), results))
}
return errors.Join(failures...)
},
} }
group.AddCommand(a.releaseAssetCommand()) command.ValidArgsFunction = a.completeReleases()
return group return command
}
func (a *application) releaseDeleteCommand() *cobra.Command {
var deleteTag bool
command := &cobra.Command{
Use: "delete <tag>...",
Short: "Delete releases",
Long: "Delete releases. Their tags stay unless --delete-tag removes them too.",
Example: `$ tea release delete v1.0.0-rc1
$ tea release delete v1.0.0-rc1 v1.0.0-rc2 --delete-tag --yes`,
Annotations: map[string]string{"tea.confirm": "true", "tea.text": "release deletion does not return a resource"},
Args: usageArgs(cobra.MinimumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
subject := "release " + args[0]
if len(args) > 1 {
subject = fmt.Sprintf("%d releases (%s)", len(args), strings.Join(args, ", "))
}
if deleteTag {
subject += " with their tags"
if len(args) == 1 {
subject = "release " + args[0] + " and its tag"
}
}
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + " from " + target.Repo + "?", NonTTY: "delete " + subject + " from " + target.Repo}); err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
return batchDone(a, cmd, args, func(tag string) string { return "release " + tag }, func(tag string) (services.Release, error) {
item, err := service.View(cmd.Context(), tag)
if err == nil {
err = service.Delete(cmd.Context(), item, deleteTag)
}
return item, err
}, func(item services.Release) string {
if deleteTag {
return "Deleted release " + item.Tag + " and its tag from " + target.Repo
}
return "Deleted release " + item.Tag + " from " + target.Repo + " (tag kept; use --delete-tag)"
})
},
}
command.Flags().BoolVar(&deleteTag, "delete-tag", false, "Delete the release's Git tag too")
command.ValidArgsFunction = a.completeReleases()
return command
} }
+82
View File
@@ -6,6 +6,8 @@ package app
import ( import (
"context" "context"
"net/http" "net/http"
"os"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -22,3 +24,83 @@ func TestReleaseCreateWithTagFlagReturnsSingleJSONObject(t *testing.T) {
require.NoError(t, Execute(context.Background(), deps, []string{"release", "create", "--tag", "v1", "-R", "alice/project", "--json=id,tag"})) require.NoError(t, Execute(context.Background(), deps, []string{"release", "create", "--tag", "v1", "-R", "alice/project", "--json=id,tag"}))
require.JSONEq(t, `{"id":7,"tag":"v1"}`, out.String()) require.JSONEq(t, `{"id":7,"tag":"v1"}`, out.String())
} }
const firstRelease = `{"id":1,"tag_name":"v0.1.0","name":"First release","body":"First release.\n\n- login","target_commitish":"main",
"html_url":"https://gitea.test/alice/project/releases/tag/v0.1.0","author":{"login":"alice"},"published_at":"2026-10-11T18:00:00Z",
"assets":[{"id":3,"name":"notes.txt","size":13}]}`
func TestReleaseViewShowsTheLatestReleaseWithNotesAndAssets(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/releases/latest", r.URL.Path)
_, _ = w.Write([]byte(firstRelease))
}))
defer server.Close()
deps, out, _ := repoTTYDeps(t, server.URL)
deps.Getenv = serverEnv(server.URL, map[string]string{"TERM": "dumb", "TEA_MARKDOWN": "raw"})
require.NoError(t, Execute(context.Background(), deps, []string{"release", "view", "-R", "alice/project"}))
require.Equal(t, `First release v0.1.0
Latest • alice released this about 2 hours ago · target main
Assets: notes.txt (13 B)
First release.
- login
View this release on Gitea: https://gitea.test/alice/project/releases/tag/v0.1.0
`, out.String())
}
func TestReleaseListShowsTypes(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[
{"id":4,"tag_name":"v0.3.0","draft":true,"created_at":"2026-10-11T19:00:00Z"},
{"id":3,"tag_name":"v0.2.0-rc1","name":"v0.2.0 RC1","prerelease":true,"published_at":"2026-10-11T18:00:00Z"},
{"id":1,"tag_name":"v0.1.0","name":"First release","published_at":"2026-10-10T18:00:00Z"}]`))
}))
defer server.Close()
deps, out, _ := repoTTYDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"release", "list", "-R", "alice/project"}))
require.Equal(t, `Showing 3 of 3 releases in alice/project
TITLE TYPE TAG PUBLISHED
v0.3.0 Draft v0.3.0 about 1 hour ago
v0.2.0 RC1 Pre-release v0.2.0-rc1 about 2 hours ago
First release Latest v0.1.0 about 1 day ago
`, out.String())
}
func TestReleaseDeleteSaysWhatHappenedToTheTag(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"id":5,"tag_name":"v2.0.0"}`))
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"release", "delete", "v2.0.0", "--yes", "-R", "alice/project"}))
require.Empty(t, out.String())
require.Equal(t, "✓ Deleted release v2.0.0 from alice/project (tag kept; use --delete-tag)\n", diagnostics.String())
}
func TestReleaseCreateUploadsPositionalFiles(t *testing.T) {
var requests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
if r.URL.Path == "/api/v1/repos/alice/project/releases" {
requirePayload(t, `{"tag_name":"v1.1.0","name":"v1.1.0","body":"Notes","draft":true}`, r.Body)
_, _ = w.Write([]byte(`{"id":9,"tag_name":"v1.1.0","draft":true,"html_url":"https://gitea.test/alice/project/releases/tag/v1.1.0"}`))
return
}
_, _ = w.Write([]byte(`{"id":1,"name":"tea.bin","size":2}`))
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
file := filepath.Join(t.TempDir(), "tea.bin")
require.NoError(t, os.WriteFile(file, []byte("hi"), 0o600))
require.NoError(t, Execute(context.Background(), deps, []string{"release", "new", "v1.1.0", file, "-n", "Notes", "-d", "-R", "alice/project"}))
require.Equal(t, []string{"POST /api/v1/repos/alice/project/releases", "POST /api/v1/repos/alice/project/releases/9/assets"}, requests)
require.Equal(t, "https://gitea.test/alice/project/releases/tag/v1.1.0\n", out.String())
require.Equal(t, "✓ Created draft release v1.1.0 in alice/project\n✓ Uploaded tea.bin (2 B)\n", diagnostics.String())
}
+4 -4
View File
@@ -143,10 +143,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"pr", "review", "delete", "1", "2", "--json"}, "review deletion does not return a resource"}, {[]string{"pr", "review", "delete", "1", "2", "--json"}, "review deletion does not return a resource"},
{[]string{"pr", "status", "--limit=101"}, `"--limit" flag: must be 1..100`}, {[]string{"pr", "status", "--limit=101"}, `"--limit" flag: must be 1..100`},
{[]string{"pr", "status", "--json=title"}, "unknown JSON field \"title\""}, {[]string{"pr", "status", "--json=title"}, "unknown JSON field \"title\""},
{[]string{"release", "asset", "download", "v1", "1", "--json"}, "asset downloads are binary"}, {[]string{"release", "asset", "download", "v1", "1", "--json"}, "asset downloads are files"},
{[]string{"release", "asset", "download", "v1", "1"}, "supply --output-file, or --output-file - for stdout"}, {[]string{"release", "asset", "download", "v1", "1", "--archive", "zip"}, "--archive downloads the source code only"},
{[]string{"release", "create", "v1", "--tag", "v2"}, "supply a tag argument or --tag, not both"}, {[]string{"release", "create", "v1", "--tag", "v2"}, "supply a tag argument or --tag, not both"},
{[]string{"release", "create", "--body-file", "-"}, "--body-file - requires a tag"}, {[]string{"release", "create", "--body-file", "-"}, "--notes-file - requires a tag"},
{[]string{"release", "edit", "v1"}, "supply release fields to edit"}, {[]string{"release", "edit", "v1"}, "supply release fields to edit"},
{[]string{"release", "edit", "v1", "v2", "--tag", "v3"}, "--tag requires one release and a nonempty new tag"}, {[]string{"release", "edit", "v1", "v2", "--tag", "v3"}, "--tag requires one release and a nonempty new tag"},
{[]string{"repo", "create"}, "missing required input: supply --name"}, {[]string{"repo", "create"}, "missing required input: supply --name"},
@@ -259,7 +259,7 @@ func TestConfirmationsNameResolvedValues(t *testing.T) {
want string want string
}{ }{
{[]string{"wiki", "create"}, map[string]string{"title": "Home", "body": "text"}, "create wiki page Home on alice/project"}, {[]string{"wiki", "create"}, map[string]string{"title": "Home", "body": "text"}, "create wiki page Home on alice/project"},
{[]string{"release", "create"}, map[string]string{"tag": "v1.0.0"}, "create release v1.0.0 on alice/project"}, {[]string{"release", "create"}, map[string]string{"Tag": "v1.0.0"}, "Create release v1.0.0 in alice/project?"},
{[]string{"time", "delete", "5", "7"}, nil, "delete tracked time entry 7 on alice/project #5"}, {[]string{"time", "delete", "5", "7"}, nil, "delete tracked time entry 7 on alice/project #5"},
} { } {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) { t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
+46 -15
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"net/http"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport" "gitea.dev/tea/internal/transport"
@@ -14,22 +15,25 @@ import (
// Release describes a repository release. // Release describes a repository release.
type Release struct { type Release struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Tag string `json:"tag"` Tag string `json:"tag"`
Target string `json:"target"` Target string `json:"target"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
URL string `json:"url"` URL string `json:"url"`
Draft bool `json:"draft"` Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"` Prerelease bool `json:"prerelease"`
CreatedAt string `json:"created_at"` Author string `json:"author"`
PublishedAt *string `json:"published_at"` Assets []ReleaseAsset `json:"assets"`
CreatedAt string `json:"created_at"`
PublishedAt *string `json:"published_at"`
} }
func releaseFrom(release *gitea.Release) Release { func releaseFrom(release *gitea.Release) Release {
return Release{ return Release{
ID: release.ID, Tag: release.TagName, Target: release.Target, Title: release.Title, Body: release.Note, URL: release.HTMLURL, ID: release.ID, Tag: release.TagName, Target: release.Target, Title: release.Title, Body: release.Note, URL: release.HTMLURL,
Draft: release.IsDraft, Prerelease: release.IsPrerelease, CreatedAt: timestamp(release.CreatedAt), PublishedAt: optionalTimestamp(&release.PublishedAt), Draft: release.IsDraft, Prerelease: release.IsPrerelease, Author: login(release.Publisher), Assets: convertAll(release.Attachments, assetFrom),
CreatedAt: timestamp(release.CreatedAt), PublishedAt: optionalTimestamp(&release.PublishedAt),
} }
} }
@@ -53,13 +57,25 @@ func (s Releases) endpoint() string { return "repos/" + s.Repository + "/release
// List returns a non-nil page of releases. // List returns a non-nil page of releases.
func (s Releases) List(ctx context.Context, limit, page int) ([]Release, error) { func (s Releases) List(ctx context.Context, limit, page int) ([]Release, error) {
releases, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Release, *gitea.Response, error) { window, err := s.ListPage(ctx, limit, page)
return c.Releases.ListReleases(ctx, owner, repo, gitea.ListReleasesOptions{ListOptions: listOptions(limit, page)}) return window.Items, err
}
// ListPage is List that also reports whether more releases follow and how many there are.
func (s Releases) ListPage(ctx context.Context, limit, page int) (Page[Release], error) {
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.Release, http.Header, error) {
var headers http.Header
releases, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Release, *gitea.Response, error) {
releases, resp, err := c.Releases.ListReleases(ctx, owner, repo, gitea.ListReleasesOptions{ListOptions: listOptions(limit, page)})
headers = headerOf(resp)
return releases, resp, err
})
return releases, headers, err
}) })
if err != nil { if err != nil {
return nil, err return Page[Release]{}, err
} }
return convertAll(releases, releaseFrom), nil return Page[Release]{Items: convertAll(window.Items, releaseFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil
} }
// View retrieves a release by its tag, including numeric tags. // View retrieves a release by its tag, including numeric tags.
@@ -69,6 +85,21 @@ func (s Releases) View(ctx context.Context, tag string) (Release, error) {
}) })
} }
// Latest retrieves the most recent release that is neither a draft nor a prerelease.
func (s Releases) Latest(ctx context.Context) (Release, error) {
release, err := repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) {
return c.Releases.GetLatestRelease(ctx, owner, repo)
})
return release, Classify(err, "latest release", "", s.Repository)
}
// Archive opens the source archive of the repository at ref; format is zip or tar.gz.
func (s Releases) Archive(ctx context.Context, ref, format string) (io.ReadCloser, error) {
return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) {
return c.Repositories.GetArchiveReader(ctx, owner, repo, ref, gitea.ArchiveType("."+format))
})
}
// Create creates a release and lets Gitea create a missing tag at the supplied target. // Create creates a release and lets Gitea create a missing tag at the supplied target.
func (s Releases) Create(ctx context.Context, opts ReleaseOptions) (Release, error) { func (s Releases) Create(ctx context.Context, opts ReleaseOptions) (Release, error) {
return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) { return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) {
+2 -2
View File
@@ -155,7 +155,7 @@ func TestReleaseFromMapsServerFields(t *testing.T) {
published := "2026-10-02T12:00:00Z" published := "2026-10-02T12:00:00Z"
require.Equal(t, Release{ require.Equal(t, Release{
ID: 3, Tag: "v1", Target: "main", Title: "One", Body: "Notes", URL: "https://gitea.test/alice/project/releases/tag/v1", ID: 3, Tag: "v1", Target: "main", Title: "One", Body: "Notes", URL: "https://gitea.test/alice/project/releases/tag/v1",
Draft: true, Prerelease: true, CreatedAt: "2026-10-01T12:00:00+02:00", PublishedAt: &published, Draft: true, Prerelease: true, Assets: []ReleaseAsset{}, CreatedAt: "2026-10-01T12:00:00+02:00", PublishedAt: &published,
}, releaseFrom(release)) }, releaseFrom(release))
} }
@@ -173,7 +173,7 @@ func TestReleaseListAndViewEscapeTags(t *testing.T) {
service := Releases{Client: client, Repository: "alice/project"} service := Releases{Client: client, Repository: "alice/project"}
items, err := service.List(context.Background(), 10, 2) items, err := service.List(context.Background(), 10, 2)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []Release{{ID: 1, Tag: "v1"}}, items) require.Equal(t, []Release{{ID: 1, Tag: "v1", Assets: []ReleaseAsset{}}}, items)
item, err := service.View(context.Background(), "v1/beta one") item, err := service.View(context.Background(), "v1/beta one")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "v1/beta one", item.Tag) require.Equal(t, "v1/beta one", item.Tag)