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":
return jsonFields[services.ReleaseAsset]()
case "release":
if cmd.Name() == "upload" {
return jsonFields[services.ReleaseAsset]()
}
return jsonFields[services.Release]()
case "secret":
return jsonFields[services.ActionSecret]()
-13
View File
@@ -487,17 +487,6 @@ const (
pr review view
pr status
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 pr
status
@@ -522,8 +511,6 @@ const (
pr
pr attachment
pr comment
release
release asset
time
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{"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{"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]"},
} {
requireUsageBeforeRequests(t, tc.want, tc.args...)
+3
View File
@@ -115,6 +115,9 @@ func TestBodySourcesRejectInvalidInputBeforeAuthentication(t *testing.T) {
want := "mutually exclusive"
if len(input) == 1 {
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")...)
}
+368 -140
View File
@@ -4,9 +4,12 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"slices"
"strconv"
@@ -19,78 +22,103 @@ import (
)
func (a *application) releaseAssetCommand() *cobra.Command {
group := &cobra.Command{Use: "asset", Short: "Manage release assets"}
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 {
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
}
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)
}}
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
}
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")
// 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)
}
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"))
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
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
if !byName {
var err error
if ids, err = resourceNumbers(args[1:]); 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
}
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)
if err != nil {
@@ -101,87 +129,287 @@ func (a *application) releaseAssetCommand() *cobra.Command {
if err != nil {
return err
}
if verb == "edit" {
item, err := service.RenameAsset(cmd.Context(), release.ID, ids[0], newName)
if err != nil {
return err
}
return writeItem(a, cmd, item, assetRow)
}
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 := map[string]services.ReleaseAsset{}
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
}
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
}
var failures []error
for _, id := range ids {
if err := service.DeleteAsset(cmd.Context(), release.ID, id); err != nil {
failures = append(failures, fmt.Errorf("delete asset %d: %w", id, err))
continue
}
if len(ids) > 1 {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Deleted asset %d\n", id); err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
release, err := viewRelease(cmd, service, tag)
if err != nil {
return err
}
if archive != "" {
return a.downloadArchive(cmd, service, flags, release.Tag, archive)
}
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...)
}
if verb == "edit" {
command.Flags().StringVar(&newName, "name", "", "New asset name")
}
group.AddCommand(command)
for i, asset := range assets {
if skipped[i] {
a.warn(cmd, "Skipped %s; %s already exists", asset.Name, paths[i])
continue
}
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
},
}
var output string
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 {
id, err := number(args[1])
if err != nil {
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
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
}
func assetRow(item services.ReleaseAsset) []terminal.Cell {
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), item.URL)
// selectAssets returns the assets of release that names (names or IDs) and patterns (globs)
// 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"
"testing"
"gitea.dev/tea/internal/clierr"
"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"})
require.Error(t, err)
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.Equal(t, 2, uploads)
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) {
var deleted []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -50,20 +78,46 @@ func TestReleaseAssetNameSelectionValidatedBeforeDeletion(t *testing.T) {
w.WriteHeader(http.StatusNoContent)
return
}
if strings.HasSuffix(r.URL.Path, "/assets") {
_, _ = w.Write([]byte(`[{"id":1,"name":"one.txt"},{"id":2,"name":"two.txt"}]`))
return
}
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1"}`))
_, _ = w.Write([]byte(`{"id":1,"tag_name":"v1","assets":[{"id":1,"name":"one.txt"},{"id":2,"name":"2"}]}`))
}))
defer server.Close()
deps, _, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"release", "asset", "delete", "v1", "one.txt", "missing", "--by-name", "--yes", "-R", "owner/repo"})
require.ErrorContains(t, err, "matches 0 assets")
deps, _, diagnostics := repoTTYDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"release", "asset", "delete", "v1", "one.txt", "missing", "--yes", "-R", "owner/repo"})
require.EqualError(t, err, `asset "missing" not found in release v1`)
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, err)
require.NoError(t, Execute(context.Background(), deps, []string{"release", "asset", "delete", "v1", "one.txt", "2", "--by-name", "--yes", "-R", "owner/repo"}))
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.Contains(t, diagnostics.String(), "Deleted asset 2")
require.Equal(t, "✓ Deleted asset one.txt (ID 1) from release v1\n✓ Deleted asset 2 (ID 2) from release v1\n", diagnostics.String())
}
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
import (
"cmp"
"errors"
"fmt"
"path/filepath"
@@ -15,209 +16,447 @@ import (
"github.com/spf13/cobra"
)
func releaseRecord(item services.Release) terminal.Record {
return terminal.Record{Heading: terminal.Row(item.Tag, item.Title), Lines: []string{item.URL}}
func (a *application) releaseCommand() *cobra.Command {
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 {
status := "released"
// releaseKind is the type of a release for people: Draft, Pre-release, or Latest when it is the
// repository's latest release.
func releaseKind(item services.Release, latest int64) terminal.Cell {
switch {
case item.Draft:
status = "draft"
return terminal.Cell{Text: "Draft", Muted: true}
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 {
group := &cobra.Command{Use: "release", Short: "Manage repository releases"}
// releaseTime is when a release was published, or created for a draft.
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
list := &cobra.Command{Use: "list", Short: "List releases", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Releases{Client: target.client, Repository: target.Repo}).List(cmd.Context(), limit, page)
if err != nil {
return err
}
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 {
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)
if err != nil {
return err
}
if useEditor && verb == "edit" && len(args) != 1 {
return clierr.Wrap(clierr.Usage, errors.New("--editor requires one release"))
result, err := (services.Releases{Client: target.client, Repository: target.Repo}).ListPage(cmd.Context(), limit, page)
if err != nil {
return err
}
opts := services.ReleaseOptions{}
files, err := openRegularFiles(assetPaths)
// Releases come newest first, so the first published one of the first page is the latest.
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)
if err != nil {
return err
}
prompted := false
if verb == "create" {
if len(args) > 0 {
if cmd.Flags().Changed("tag") {
return clierr.Wrap(clierr.Usage, errors.New("supply a tag argument or --tag, not both"))
}
tag = args[0]
}
if tag == "" && file == "-" {
return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires a tag; stdin cannot also supply a prompt"))
}
prompted, err = a.require(cmd, &tag, "tag")
if err != nil {
opts, err := fields.options(a, cmd)
if err != nil {
return err
}
prompted, err := a.requireInput(cmd, &fields.tag, "tag", "Tag", nil)
if err != nil {
return err
}
title := cmp.Or(fields.title, fields.tag)
opts.Tag, opts.Title, opts.Draft, opts.Prerelease = &fields.tag, &title, &fields.draft, &fields.prerelease
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
}
if title == "" && !cmd.Flags().Changed("title") {
title = tag
}
opts.Tag = &tag
opts.Title = &title
opts.Draft = &draft
opts.Prerelease = &prerelease
opts.Body = &editorDraft.Body
}
if verb == "create" || verb == "edit" {
if verb == "edit" && cmd.Flags().Changed("tag") {
if len(args) != 1 || strings.TrimSpace(tag) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--tag requires one release and a nonempty new tag"))
}
opts.Tag = &tag
if prompted {
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Create release " + fields.tag + " in " + target.Repo + "?", DefaultYes: true}); err != nil {
return err
}
if cmd.Flags().Changed("title") {
if strings.TrimSpace(title) == "" {
return clierr.Wrap(clierr.Usage, errors.New("release title cannot be empty"))
}
opts.Title = &title
}
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
}
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"))
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"))
}
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)
if err != nil {
return err
}
service := services.Releases{Client: target.client, Repository: target.Repo}
if useEditor {
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" {
if fields.editor {
item, err := service.View(cmd.Context(), args[0])
if err != nil {
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) {
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
item, err = service.Edit(cmd.Context(), item.ID, services.ReleaseOptions{Draft: &published})
case "delete":
err = service.Delete(cmd.Context(), item, deleteTag)
}
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 {
return item, err
}
return service.Edit(cmd.Context(), item.ID, opts)
}, func(item services.Release) string { return "Edited release " + item.Tag + " in " + target.Repo })
},
}
fields.register(a, command)
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
}, releaseRow)
}
group.AddCommand(command)
}, func(services.Release) {})
if a.options.json != "" {
failures = append(failures, writeBatchJSON(a, cmd, len(args), results))
}
return errors.Join(failures...)
},
}
group.AddCommand(a.releaseAssetCommand())
return group
command.ValidArgsFunction = a.completeReleases()
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 (
"context"
"net/http"
"os"
"path/filepath"
"testing"
"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.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", "status", "--limit=101"}, `"--limit" flag: must be 1..100`},
{[]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"}, "supply --output-file, or --output-file - for stdout"},
{[]string{"release", "asset", "download", "v1", "1", "--json"}, "asset downloads are files"},
{[]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", "--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", "v2", "--tag", "v3"}, "--tag requires one release and a nonempty new tag"},
{[]string{"repo", "create"}, "missing required input: supply --name"},
@@ -259,7 +259,7 @@ func TestConfirmationsNameResolvedValues(t *testing.T) {
want string
}{
{[]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"},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
+46 -15
View File
@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"net/http"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
@@ -14,22 +15,25 @@ import (
// Release describes a repository release.
type Release struct {
ID int64 `json:"id"`
Tag string `json:"tag"`
Target string `json:"target"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
CreatedAt string `json:"created_at"`
PublishedAt *string `json:"published_at"`
ID int64 `json:"id"`
Tag string `json:"tag"`
Target string `json:"target"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Author string `json:"author"`
Assets []ReleaseAsset `json:"assets"`
CreatedAt string `json:"created_at"`
PublishedAt *string `json:"published_at"`
}
func releaseFrom(release *gitea.Release) Release {
return Release{
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.
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) {
return c.Releases.ListReleases(ctx, owner, repo, gitea.ListReleasesOptions{ListOptions: listOptions(limit, page)})
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, 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 {
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.
@@ -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.
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) {
+2 -2
View File
@@ -155,7 +155,7 @@ func TestReleaseFromMapsServerFields(t *testing.T) {
published := "2026-10-02T12:00:00Z"
require.Equal(t, Release{
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))
}
@@ -173,7 +173,7 @@ func TestReleaseListAndViewEscapeTags(t *testing.T) {
service := Releases{Client: client, Repository: "alice/project"}
items, err := service.List(context.Background(), 10, 2)
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")
require.NoError(t, err)
require.Equal(t, "v1/beta one", item.Tag)