diff --git a/internal/app/app.go b/internal/app/app.go index b283065a..82003143 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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]() diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 5d28e38b..302ca7f7 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 ` diff --git a/internal/app/args_test.go b/internal/app/args_test.go index a2b5aa64..003bf0ff 100644 --- a/internal/app/args_test.go +++ b/internal/app/args_test.go @@ -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 [flags]"}, {[]string{"run", "view"}, "missing run ID\nUsage: tea run view [] [flags]"}, - {[]string{"release", "asset", "download", "v1"}, "missing asset ID\nUsage: tea release asset download [flags]"}, + {[]string{"release", "asset", "edit", "v1"}, "missing asset\nUsage: tea release asset edit [flags]"}, {[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone [] [-- ...] [flags]"}, } { requireUsageBeforeRequests(t, tc.want, tc.args...) diff --git a/internal/app/editor_test.go b/internal/app/editor_test.go index 1309dc63..1691488d 100644 --- a/internal/app/editor_test.go +++ b/internal/app/editor_test.go @@ -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")...) } diff --git a/internal/app/release_assets.go b/internal/app/release_assets.go index 1e907197..50da5374 100644 --- a/internal/app/release_assets.go +++ b/internal/app/release_assets.go @@ -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 ", 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 ...", 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 + " ", Short: titleCase(verb) + " release assets", Args: argsExact(2)} - if verb == "delete" { - command.Use = "delete ..." - 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 ", + 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 ...", + 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 [] [...]", + 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 ", 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 ", + 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 ...", + 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 } diff --git a/internal/app/release_assets_test.go b/internal/app/release_assets_test.go index e424646b..d03f0d1b 100644 --- a/internal/app/release_assets_test.go +++ b/internal/app/release_assets_test.go @@ -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") } diff --git a/internal/app/releases.go b/internal/app/releases.go index 2d9cafc7..08743d76 100644 --- a/internal/app/releases.go +++ b/internal/app/releases.go @@ -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 + " ", Short: titleCase(verb) + " repository releases", Args: argsExact(1)} - if verb == "edit" || verb == "delete" || verb == "publish" { - command.Use = verb + " ..." - 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 []", + 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 [] [...]", + 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 ...", + 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 ...", + 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 ...", + 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 } diff --git a/internal/app/releases_test.go b/internal/app/releases_test.go index 7a21da5d..dc4fe07b 100644 --- a/internal/app/releases_test.go +++ b/internal/app/releases_test.go @@ -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()) +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index dc320da2..5cca1e52 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -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) { diff --git a/internal/services/releases.go b/internal/services/releases.go index 3017060d..78dda95e 100644 --- a/internal/services/releases.go +++ b/internal/services/releases.go @@ -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) { diff --git a/internal/services/releases_test.go b/internal/services/releases_test.go index 2b628366..6fb45c9d 100644 --- a/internal/services/releases_test.go +++ b/internal/services/releases_test.go @@ -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)