mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
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:
11 files changed
+984
-357
No files matched your search
@@ -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]()
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 = ¬es
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user