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
+905 -278

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")...)
} }
+329 -101
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,9 +22,46 @@ 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{
Use: "asset",
Short: "Manage release assets",
Long: "List, upload, download, rename and delete the files attached to a release. Assets are named by name or ID.",
}
group.AddCommand(a.assetListCommand(), a.releaseUploadCommand(), a.releaseDownloadCommand(), a.assetEditCommand(), a.assetDeleteCommand())
return group
}
// assetFinder addresses the assets of the release tagged tag by name or ID.
func assetFinder(tag string, byName bool) services.NameOrID[services.ReleaseAsset] {
return services.NameOrID[services.ReleaseAsset]{
Kind: "asset", Scope: "release " + tag, ByName: byName,
ID: func(asset services.ReleaseAsset) int64 { return asset.ID }, Name: func(asset services.ReleaseAsset) string { return asset.Name },
}
}
// completeReleaseAssets completes a release tag, then the asset names of the release it tags.
func (a *application) completeReleaseAssets() cobra.CompletionFunc {
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)
}
tag := args[0]
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
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 var limit, page int
list := &cobra.Command{Use: "list <tag>", Short: "List release assets", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { 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) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
@@ -35,12 +75,37 @@ func (a *application) releaseAssetCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
return writeList(a, cmd, items, assetRow) spec := listSpec[services.ReleaseAsset]{
}} Columns: []terminal.Column{{Header: "ID"}, {Header: "Name", Flex: true}, {Header: "Size"}, {Header: "Downloads"}, {Header: "Created"}, {Header: "URL", PipedOnly: true}},
pageFlags(list, &limit, &page) Row: func(item services.ReleaseAsset) []terminal.Cell {
group.AddCommand(list) 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 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 { 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) == "") { 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")) return clierr.Wrap(clierr.Usage, errors.New("--name requires exactly one file and a nonempty name"))
} }
@@ -49,49 +114,12 @@ func (a *application) releaseAssetCommand() *cobra.Command {
if err != nil { if err != nil {
return err 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 { filename := func(file *os.File) string {
if cmd.Flags().Changed("name") { if cmd.Flags().Changed("name") {
return name return name
} }
return filepath.Base(file.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)")
group.AddCommand(upload)
for _, verb := range []string{"edit", "delete"} {
var newName string
var byName bool
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))
command.Flags().BoolVar(&byName, "by-name", false, "Select assets by exact name instead of ID")
command.Annotations = textOutput("asset deletion does not return a resource")
}
command.RunE = func(cmd *cobra.Command, args []string) error {
if verb == "edit" && strings.TrimSpace(newName) == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --name for asset rename"))
}
var ids []int64
if !byName {
var err error
if ids, err = resourceNumbers(args[1:]); err != nil {
return err
}
}
target, err := a.resolve(cmd.Context(), true) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
@@ -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 {
for _, asset := range release.Assets {
if asset.Name == filename(file) {
if !clobber {
return clierr.Wrap(clierr.Usage, fmt.Errorf("asset %s already exists in release %s; pass --clobber to replace it", asset.Name, release.Tag))
}
replaced[asset.Name] = asset
}
}
}
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 { if err != nil {
return err return err
} }
return writeItem(a, cmd, item, assetRow) service := services.Releases{Client: target.client, Repository: target.Repo}
} release, err := viewRelease(cmd, service, tag)
if byName {
assets, err := service.AllAssets(cmd.Context(), release.ID)
if err != nil { if err != nil {
return err return err
} }
for _, name := range args[1:] { if archive != "" {
var matches []int64 return a.downloadArchive(cmd, service, flags, release.Tag, archive)
for _, asset := range assets {
if asset.Name == name {
matches = append(matches, asset.ID)
} }
} assets, err := selectAssets(release, names, patterns)
if len(matches) != 1 { if err != nil {
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 err return err
} }
var failures []error if len(assets) > 1 && flags.output != "" && !isDirectory(flags.output) {
for _, id := range ids { return clierr.Wrap(clierr.Usage, fmt.Errorf("--output names one file, but %d assets match; pass --dir", len(assets)))
if err := service.DeleteAsset(cmd.Context(), release.ID, id); err != nil { }
failures = append(failures, fmt.Errorf("delete asset %d: %w", id, err)) 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
}
}
for i, asset := range assets {
if skipped[i] {
a.warn(cmd, "Skipped %s; %s already exists", asset.Name, paths[i])
continue continue
} }
if len(ids) > 1 { body, err := service.DownloadAsset(cmd.Context(), release.ID, asset.ID)
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Deleted asset %d\n", id); err != nil { 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 return err
} }
if paths[i] != "-" {
a.done(cmd, "Downloaded %s (%s) to %s", asset.Name, terminal.Bytes(asset.Size), paths[i])
} }
} }
return errors.Join(failures...) return nil
} },
if verb == "edit" {
command.Flags().StringVar(&newName, "name", "", "New asset name")
}
group.AddCommand(command)
} }
flags.register(command)
command.Flags().StringSliceVarP(&patterns, "pattern", "p", nil, "Download the assets whose names match this glob (repeatable)")
enumFlag(command, &archive, "archive", "A", "", "Download the source code archive", "zip", "tar.gz")
command.ValidArgsFunction = a.completeReleaseAssets()
return command
}
var output string // selectAssets returns the assets of release that names (names or IDs) and patterns (globs)
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 { // select, or every asset when there are neither, each once.
id, err := number(args[1]) 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 { 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 return err
} }
if err := validateDownloadDestination(output); err != nil { body, err := service.Archive(cmd.Context(), tag, format)
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 { if err != nil {
return err return err
} }
defer body.Close() defer body.Close()
return writeDownload(cmd, body, output) counter := &countingReader{reader: body}
}} if err := a.download(cmd, flags, destination, counter); err != nil {
download.Flags().StringVar(&output, "output-file", "", "Destination file (must not exist), or - for stdout") return err
group.AddCommand(download) }
return group if destination != "-" {
a.done(cmd, "Downloaded %s (%s) to %s", name, terminal.Bytes(counter.n), destination)
}
return nil
} }
func assetRow(item services.ReleaseAsset) []terminal.Cell { // countingReader counts the bytes read through it.
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), item.URL) 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")
} }
+379 -140
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{
Use: "list",
Short: "List releases",
Example: `$ tea release list
$ tea release list -R alice/project --json tag,title,draft`,
Args: argsExact(0),
RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), true) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
} }
items, err := (services.Releases{Client: target.client, Repository: target.Repo}).List(cmd.Context(), limit, page) result, err := (services.Releases{Client: target.client, Repository: target.Repo}).ListPage(cmd.Context(), limit, page)
if err != nil { if err != nil {
return err return err
} }
return writeList(a, cmd, items, releaseRow) // Releases come newest first, so the first published one of the first page is the latest.
}} var latest int64
pageFlags(list, &limit, &page) for _, item := range result.Items {
group.AddCommand(list) if page == 1 && !item.Draft && !item.Prerelease {
for _, verb := range []string{"view", "create", "edit", "publish", "delete"} { latest = item.ID
var tag, title, body, file, targetName string break
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" { spec := listSpec[services.Release]{
if verb == "edit" { Columns: []terminal.Column{{Header: "Title", Flex: true}, {Header: "Type"}, {Header: "Tag"}, {Header: "Published"}, {Header: "URL", PipedOnly: true}},
command.Flags().StringVar(&tag, "tag", "", "Change the release tag (one release only)") 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,
} }
command.Flags().StringVar(&title, "title", "", "Release title (creation defaults to tag)") return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted})
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" { pageFlags(command, &limit, &page)
command.Flags().BoolVar(&deleteTag, "delete-tag", false, "Delete the associated Git tag too") 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())
} }
command.RunE = func(cmd *cobra.Command, args []string) (runErr error) { 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 var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr) defer finishDraft(&editorDraft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
if useEditor && verb == "edit" && len(args) != 1 {
return clierr.Wrap(clierr.Usage, errors.New("--editor requires one release"))
}
opts := services.ReleaseOptions{}
files, err := openRegularFiles(assetPaths)
defer closeFiles(files)
if err != nil {
return err
}
prompted := false
if verb == "create" {
if len(args) > 0 { if len(args) > 0 {
if cmd.Flags().Changed("tag") { if cmd.Flags().Changed("tag") {
return clierr.Wrap(clierr.Usage, errors.New("supply a tag argument or --tag, not both")) return clierr.Wrap(clierr.Usage, errors.New("supply a tag argument or --tag, not both"))
} }
tag = args[0] fields.tag, assets = args[0], append(args[1:], assets...)
} }
if tag == "" && file == "-" { if err := validateTextSources(cmd, "notes", "notes-file"); err != nil {
return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires a tag; stdin cannot also supply a prompt")) return err
} }
prompted, err = a.require(cmd, &tag, "tag") 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)
if err != nil { if err != nil {
return err return err
} }
if title == "" && !cmd.Flags().Changed("title") { opts, err := fields.options(a, cmd)
title = tag if err != nil {
return err
} }
opts.Tag = &tag prompted, err := a.requireInput(cmd, &fields.tag, "tag", "Tag", nil)
opts.Title = &title if err != nil {
opts.Draft = &draft return err
opts.Prerelease = &prerelease
} }
if verb == "create" || verb == "edit" { title := cmp.Or(fields.title, fields.tag)
if verb == "edit" && cmd.Flags().Changed("tag") { opts.Tag, opts.Title, opts.Draft, opts.Prerelease = &fields.tag, &title, &fields.draft, &fields.prerelease
if len(args) != 1 || strings.TrimSpace(tag) == "" { target, err := a.resolve(cmd.Context(), true)
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
}
opts.Body = &editorDraft.Body
}
if prompted {
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Create release " + fields.tag + " in " + target.Repo + "?", DefaultYes: true}); err != nil {
return err
}
}
item, err := service.Create(cmd.Context(), opts)
if err != nil {
return err
}
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
}
item.Assets = append(item.Assets, asset)
a.done(cmd, "Uploaded %s (%s)", asset.Name, terminal.Bytes(asset.Size))
}
if a.options.json != "" {
failures = append(failures, a.writeJSON(cmd, item))
} else {
failures = append(failures, a.printURL(cmd, item.URL))
}
return errors.Join(failures...)
},
}
fields.register(a, command)
command.Flags().StringVar(&fields.tag, "tag", "", "Release tag")
_ = command.Flags().MarkHidden("tag")
command.Flags().StringSliceVar(&assets, "asset", nil, "File to upload as an asset (repeatable)")
command.ValidArgsFunction = a.completeTags()
return command
}
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")) return clierr.Wrap(clierr.Usage, errors.New("--tag requires one release and a nonempty new tag"))
} }
opts.Tag = &tag opts.Tag = &fields.tag
} }
if cmd.Flags().Changed("title") { if !fields.editor && opts == (services.ReleaseOptions{}) {
if strings.TrimSpace(title) == "" {
return clierr.Wrap(clierr.Usage, errors.New("release title cannot be empty"))
}
opts.Title = &title
}
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
var err error
body, err = a.readBody(cmd, body, file)
if err != nil {
return err
}
opts.Body = &body
}
if cmd.Flags().Changed("target") {
opts.Target = &targetName
}
if cmd.Flags().Changed("draft") {
opts.Draft = &draft
}
if cmd.Flags().Changed("prerelease") {
opts.Prerelease = &prerelease
}
if verb == "edit" && !useEditor && opts == (services.ReleaseOptions{}) {
return clierr.Wrap(clierr.Usage, errors.New("supply release fields to edit")) 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]) item, err := service.View(cmd.Context(), args[0])
if err != nil { if err != nil {
return err return err
} }
initial = item.Body if editorDraft, err = a.editDraft(cmd, item.Body); err != nil {
}
editorDraft, err = a.editDraft(cmd, initial)
if err != nil {
return err return err
} }
opts.Body = &editorDraft.Body opts.Body = &editorDraft.Body
} }
if verb == "delete" || prompted { return batchDone(a, cmd, args, func(tag string) string { return "release " + tag }, func(tag string) (services.Release, error) {
subject := strings.Join(args, " ") item, err := service.View(cmd.Context(), tag)
if verb == "create" { if err != nil {
subject = tag return item, err
} }
if err := a.confirm(cmd, fmt.Sprintf("%s release %s on %s", verb, subject, target.Repo)); err != nil { return service.Edit(cmd.Context(), item.ID, opts)
return err }, func(item services.Release) string { return "Edited release " + item.Tag + " in " + target.Repo })
},
} }
} fields.register(a, command)
if verb == "create" { command.Flags().StringVar(&fields.tag, "tag", "", "Change the release's tag (one release only)")
item, err := service.Create(cmd.Context(), opts) 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 { if err != nil {
return err return err
} }
var failures []error service := services.Releases{Client: target.client, Repository: target.Repo}
for _, file := range files { results, failures := applyBatch(cmd, args, func(tag string) string { return "release " + tag }, func(tag string) (services.Release, error) {
if _, err := service.UploadAsset(cmd.Context(), item.ID, filepath.Base(file.Name()), file); err != nil { item, err := service.View(cmd.Context(), tag)
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])
if err != nil { if err != nil {
return err return item, err
} }
return writeRecord(a, cmd, item, releaseRecord) if !item.Draft {
a.warn(cmd, "Release %s is already published", item.Tag)
return item, nil
} }
return batch(a, cmd, args, func(name string) string { return "release " + name }, func(name string) (services.Release, error) {
item, err := service.View(cmd.Context(), name)
if err == nil {
switch verb {
case "edit":
item, err = service.Edit(cmd.Context(), item.ID, opts)
case "publish":
published := false published := false
item, err = service.Edit(cmd.Context(), item.ID, services.ReleaseOptions{Draft: &published}) if item, err = service.Edit(cmd.Context(), item.ID, services.ReleaseOptions{Draft: &published}); err == nil {
case "delete": a.done(cmd, "Published release %s in %s", item.Tag, target.Repo)
err = service.Delete(cmd.Context(), item, deleteTag)
}
} }
return item, err return item, err
}, releaseRow) }, func(services.Release) {})
if a.options.json != "" {
failures = append(failures, writeBatchJSON(a, cmd, len(args), results))
} }
group.AddCommand(command) 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) {
+35 -4
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"
@@ -22,6 +23,8 @@ type Release struct {
URL string `json:"url"` URL string `json:"url"`
Draft bool `json:"draft"` Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"` Prerelease bool `json:"prerelease"`
Author string `json:"author"`
Assets []ReleaseAsset `json:"assets"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
PublishedAt *string `json:"published_at"` PublishedAt *string `json:"published_at"`
} }
@@ -29,7 +32,8 @@ type Release struct {
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) {
window, err := s.ListPage(ctx, 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, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Release, *gitea.Response, error) {
return c.Releases.ListReleases(ctx, owner, repo, gitea.ListReleasesOptions{ListOptions: listOptions(limit, page)}) 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)