mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
feat(attachment): download attachments under their own name
issue attachment download (and pr attachment download) required --output-file. It now writes ./<name> like gh release download, refusing to replace an existing file unless --clobber (or --skip-existing to keep it), with -O/--output and -D/--dir from the shared download flags. attachment list shows ID NAME SIZE DOWNLOADS CREATED with readable sizes, attachment view is a view, and upload, rename and delete report ✓ lines; delete names the attachment in its confirmation. The commands accept issue and pull request URLs and have help text with examples. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
5 files changed
+192
-126
No files matched your search
@@ -418,23 +418,9 @@ const (
|
|||||||
shortsToFix = `
|
shortsToFix = `
|
||||||
`
|
`
|
||||||
examplesToWrite = `
|
examplesToWrite = `
|
||||||
issue attachment delete
|
|
||||||
issue attachment download
|
|
||||||
issue attachment edit
|
|
||||||
issue attachment list
|
|
||||||
issue attachment upload
|
|
||||||
issue attachment view
|
|
||||||
pr attachment delete
|
|
||||||
pr attachment download
|
|
||||||
pr attachment edit
|
|
||||||
pr attachment list
|
|
||||||
pr attachment upload
|
|
||||||
pr attachment view
|
|
||||||
search issue
|
search issue
|
||||||
search pr
|
search pr
|
||||||
`
|
`
|
||||||
longsToWrite = `
|
longsToWrite = `
|
||||||
issue attachment
|
|
||||||
pr attachment
|
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
+135
-106
@@ -16,104 +16,81 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// attachmentName names an attachment in messages: "attachment 9 (log.txt) on alice/x#1".
|
||||||
|
func attachmentName(t target, n int64, item services.IssueAttachment) string {
|
||||||
|
return fmt.Sprintf("attachment %d (%s) on %s#%d", item.ID, item.Name, t.Repo, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachmentRow(item services.IssueAttachment) []terminal.Cell {
|
||||||
|
return []terminal.Cell{
|
||||||
|
{Text: strconv.FormatInt(item.ID, 10), Link: item.URL},
|
||||||
|
{Text: item.Name},
|
||||||
|
{Text: terminal.Bytes(item.Size)},
|
||||||
|
{Text: strconv.FormatInt(item.DownloadCount, 10)},
|
||||||
|
{Time: terminal.ParseTime(item.CreatedAt)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachmentView(item services.IssueAttachment) terminal.View {
|
||||||
|
return terminal.View{
|
||||||
|
Title: item.Name, ID: "#" + strconv.FormatInt(item.ID, 10),
|
||||||
|
Fields: []terminal.Field{
|
||||||
|
{Label: "Size", Value: terminal.Bytes(item.Size)},
|
||||||
|
{Label: "Downloads", Value: strconv.FormatInt(item.DownloadCount, 10)},
|
||||||
|
{Label: "Created", Value: item.CreatedAt},
|
||||||
|
},
|
||||||
|
Kind: "attachment", URL: item.URL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *application) issueAttachmentCommand(kind refKind) *cobra.Command {
|
func (a *application) issueAttachmentCommand(kind refKind) *cobra.Command {
|
||||||
group := &cobra.Command{Use: "attachment", Short: "Inspect issue or pull request attachments"}
|
words := commentWords(kind)
|
||||||
|
group := &cobra.Command{Use: "attachment", Short: "Manage the attachments of " + words.noun + "s", Long: fmt.Sprintf(`List, view, upload, download, rename and delete the files attached to %s. Attachments
|
||||||
|
are addressed by the %s number and the attachment ID that attachment list shows.`, article(words.noun), words.noun)}
|
||||||
|
annotations := map[string]string{"tea.arguments": words.arguments}
|
||||||
|
// discussionTarget resolves the issue or pull request argument of an attachment command.
|
||||||
|
discussionTarget := func(cmd *cobra.Command, raw string) (target, int64, error) {
|
||||||
|
t, n, err := a.resolveRef(cmd, raw, kind)
|
||||||
|
if err == nil && kind == pullRef {
|
||||||
|
err = requirePull(cmd, t, n)
|
||||||
|
}
|
||||||
|
return t, n, err
|
||||||
|
}
|
||||||
|
completeFirst := a.completeDiscussions(kind)
|
||||||
|
|
||||||
var limit, page int
|
var limit, page int
|
||||||
list := &cobra.Command{Use: "list <number>", Short: "List attachments", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
list := &cobra.Command{Use: "list {<number> | <url>}", Short: "List attachments", Example: fmt.Sprintf("$ tea %s attachment list %s", words.group, words.example), Annotations: annotations, Args: argsExact(1), ValidArgsFunction: completeFirst, RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
n, err := number(args[0])
|
t, n, err := discussionTarget(cmd, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
target, err := a.resolve(cmd.Context(), true)
|
items, err := (services.Issues{Client: t.client, Repository: t.Repo}).Attachments(cmd.Context(), n, limit, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if kind == pullRef {
|
where := fmt.Sprintf("on %s#%d", t.Repo, n)
|
||||||
if err := requirePull(cmd, target, n); err != nil {
|
return writeTable(a, cmd, items, listSpec[services.IssueAttachment]{
|
||||||
return err
|
Columns: []terminal.Column{{Header: "ID"}, {Header: "Name", Flex: true}, {Header: "Size"}, {Header: "Downloads"}, {Header: "Created"}},
|
||||||
}
|
Row: attachmentRow, Empty: "no attachments " + where, Context: "attachments " + where,
|
||||||
}
|
}, listPage{Number: page})
|
||||||
items, err := (services.Issues{Client: target.client, Repository: target.Repo}).Attachments(cmd.Context(), n, limit, page)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeList(a, cmd, items, attachmentRow)
|
|
||||||
}}
|
}}
|
||||||
pageFlags(list, &limit, &page)
|
pageFlags(list, &limit, &page)
|
||||||
view := &cobra.Command{Use: "view <number> <attachment-id>", Short: "View attachment metadata", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
n, err := number(args[0])
|
view := &cobra.Command{Use: "view {<number> | <url>} <attachment-id>", Short: "View an attachment", Example: fmt.Sprintf("$ tea %s attachment view %s 9", words.group, words.example), Annotations: annotations, Args: argsExact(2), ValidArgsFunction: completeFirst, RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
t, n, id, err := a.attachmentArgs(cmd, args, discussionTarget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
id, err := number(args[1])
|
item, err := (services.Issues{Client: t.client, Repository: t.Repo}).Attachment(cmd.Context(), n, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
target, err := a.resolve(cmd.Context(), true)
|
return writeView(a, cmd, item, attachmentView)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if kind == pullRef {
|
|
||||||
if err := requirePull(cmd, target, n); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item, err := (services.Issues{Client: target.client, Repository: target.Repo}).Attachment(cmd.Context(), n, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeItem(a, cmd, item, attachmentRow)
|
|
||||||
}}
|
}}
|
||||||
for _, verb := range []string{"edit", "delete"} {
|
|
||||||
var name string
|
|
||||||
command := &cobra.Command{Use: verb + " <number> <attachment-id>", Short: titleCase(verb) + " an attachment", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
n, err := number(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
id, err := number(args[1])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if verb == "edit" && strings.TrimSpace(name) == "" {
|
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("supply a nonempty --name"))
|
|
||||||
}
|
|
||||||
target, err := a.resolve(cmd.Context(), true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if kind == pullRef {
|
|
||||||
if err := requirePull(cmd, target, n); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
service := services.Issues{Client: target.client, Repository: target.Repo}
|
|
||||||
if verb == "delete" {
|
|
||||||
if err := a.confirm(cmd, fmt.Sprintf("Delete attachment %d on %s#%d", id, target.Repo, n)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return service.DeleteAttachment(cmd.Context(), n, id)
|
|
||||||
}
|
|
||||||
item, err := service.RenameAttachment(cmd.Context(), n, id, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeItem(a, cmd, item, attachmentRow)
|
|
||||||
}}
|
|
||||||
if verb == "edit" {
|
|
||||||
command.Flags().StringVar(&name, "name", "", "New attachment filename")
|
|
||||||
}
|
|
||||||
if verb == "delete" {
|
|
||||||
command.Annotations = textOutput("attachment deletion does not return a resource")
|
|
||||||
}
|
|
||||||
group.AddCommand(command)
|
|
||||||
}
|
|
||||||
var uploadName string
|
var uploadName string
|
||||||
upload := &cobra.Command{Use: "upload <number> <file>", Short: "Upload one attachment", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
|
upload := &cobra.Command{Use: "upload {<number> | <url>} <file>", Short: "Attach a file", Example: fmt.Sprintf(`$ tea %[1]s attachment upload %[2]s crash.log
|
||||||
n, err := number(args[0])
|
$ tea %[1]s attachment upload %[2]s screenshot.png --name before.png`, words.group, words.example), Annotations: annotations, Args: argsExact(2), ValidArgsFunction: completeFirst, RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
name := uploadName
|
name := uploadName
|
||||||
if cmd.Flags().Changed("name") && strings.TrimSpace(name) == "" {
|
if cmd.Flags().Changed("name") && strings.TrimSpace(name) == "" {
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("--name cannot be empty"))
|
return clierr.Wrap(clierr.Usage, errors.New("--name cannot be empty"))
|
||||||
@@ -126,56 +103,108 @@ func (a *application) issueAttachmentCommand(kind refKind) *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
target, err := a.resolve(cmd.Context(), true)
|
t, n, err := discussionTarget(cmd, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if kind == pullRef {
|
item, err := (services.Issues{Client: t.client, Repository: t.Repo}).UploadAttachment(cmd.Context(), n, name, files[0])
|
||||||
if err := requirePull(cmd, target, n); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item, err := (services.Issues{Client: target.client, Repository: target.Repo}).UploadAttachment(cmd.Context(), n, name, files[0])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeItem(a, cmd, item, attachmentRow)
|
if a.options.json != "" {
|
||||||
|
return a.writeJSON(cmd, item)
|
||||||
|
}
|
||||||
|
a.done(cmd, "Uploaded %s (%s) to %s#%d", item.Name, terminal.Bytes(item.Size), t.Repo, n)
|
||||||
|
return a.printURL(cmd, item.URL)
|
||||||
}}
|
}}
|
||||||
upload.Flags().StringVar(&uploadName, "name", "", "Uploaded filename (default: local filename)")
|
upload.Flags().StringVar(&uploadName, "name", "", "File `name` of the attachment (default: the local file name)")
|
||||||
var output string
|
|
||||||
download := &cobra.Command{Use: "download <number> <attachment-id>", Short: "Download an attachment to a file or stdout", Annotations: textOutput("attachment downloads are binary"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
|
var newName string
|
||||||
n, err := number(args[0])
|
edit := &cobra.Command{Use: "edit {<number> | <url>} <attachment-id>", Short: "Rename an attachment", Example: fmt.Sprintf("$ tea %s attachment edit %s 9 --name crash-2026-10-11.log", words.group, words.example), Annotations: annotations, Args: argsExact(2), ValidArgsFunction: completeFirst, RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if strings.TrimSpace(newName) == "" {
|
||||||
|
return clierr.Wrap(clierr.Usage, errors.New("supply a nonempty --name"))
|
||||||
|
}
|
||||||
|
t, n, id, err := a.attachmentArgs(cmd, args, discussionTarget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
id, err := number(args[1])
|
item, err := (services.Issues{Client: t.client, Repository: t.Repo}).RenameAttachment(cmd.Context(), n, id, newName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateDownloadDestination(output); err != nil {
|
if a.options.json != "" {
|
||||||
return err
|
return a.writeJSON(cmd, item)
|
||||||
}
|
}
|
||||||
target, err := a.resolve(cmd.Context(), true)
|
a.done(cmd, "Renamed %s", attachmentName(t, n, item))
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
edit.Flags().StringVar(&newName, "name", "", "New file `name`")
|
||||||
|
|
||||||
|
remove := &cobra.Command{Use: "delete {<number> | <url>} <attachment-id>", Short: "Delete an attachment", Example: fmt.Sprintf("$ tea %s attachment delete %s 9 --yes", words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments, "tea.confirm": "true", "tea.text": "attachment deletion does not return a resource"}, Args: argsExact(2), ValidArgsFunction: completeFirst, RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
t, n, id, err := a.attachmentArgs(cmd, args, discussionTarget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if kind == pullRef {
|
service := services.Issues{Client: t.client, Repository: t.Repo}
|
||||||
if err := requirePull(cmd, target, n); err != nil {
|
item, err := service.Attachment(cmd.Context(), n, id)
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
body, err := (services.Issues{Client: target.client, Repository: target.Repo}).DownloadAttachment(cmd.Context(), n, id)
|
name := attachmentName(t, n, item)
|
||||||
|
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + name + "?", NonTTY: "delete " + name}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := service.DeleteAttachment(cmd.Context(), n, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.done(cmd, "Deleted %s", name)
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
var destination downloadFlags
|
||||||
|
download := &cobra.Command{Use: "download {<number> | <url>} <attachment-id>", Short: "Download an attachment", Long: `Download an attachment into the current directory under its own name, or where -O/--output
|
||||||
|
or -D/--dir say. An existing file is kept unless --clobber replaces it or --skip-existing
|
||||||
|
skips the download.`, Example: fmt.Sprintf(`$ tea %[1]s attachment download %[2]s 9
|
||||||
|
$ tea %[1]s attachment download %[2]s 9 --output crash.log --clobber
|
||||||
|
# Write the file to standard output
|
||||||
|
$ tea %[1]s attachment download %[2]s 9 -O - | less`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments, "tea.text": "attachment downloads are binary"}, Args: argsExact(2), ValidArgsFunction: completeFirst, RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
t, n, id, err := a.attachmentArgs(cmd, args, discussionTarget)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
item, body, err := (services.Issues{Client: t.client, Repository: t.Repo}).DownloadAttachment(cmd.Context(), n, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer body.Close()
|
defer body.Close()
|
||||||
return writeDownload(cmd, body, output)
|
path, skip, err := destination.destination(item.Name)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
case skip:
|
||||||
|
a.warn(cmd, "Skipped %s: %s exists", attachmentName(t, n, item), path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.download(cmd, destination, path, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if path != "-" {
|
||||||
|
a.done(cmd, "Downloaded %s (%s) to %s", item.Name, terminal.Bytes(item.Size), path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}}
|
}}
|
||||||
download.Flags().StringVar(&output, "output-file", "", "Destination file (must not exist), or - for stdout")
|
destination.register(download)
|
||||||
group.AddCommand(list, view, upload, download)
|
group.AddCommand(list, view, upload, edit, remove, download)
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachmentRow(item services.IssueAttachment) []terminal.Cell {
|
// attachmentArgs resolves the issue or pull request and attachment ID arguments of an attachment
|
||||||
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), item.URL)
|
// command, checking the ID before any request.
|
||||||
|
func (a *application) attachmentArgs(cmd *cobra.Command, args []string, resolve func(*cobra.Command, string) (target, int64, error)) (target, int64, int64, error) {
|
||||||
|
id, err := number(args[1])
|
||||||
|
if err != nil {
|
||||||
|
return target{}, 0, 0, err
|
||||||
|
}
|
||||||
|
t, n, err := resolve(cmd, args[0])
|
||||||
|
return t, n, id, err
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.dev/tea/internal/clierr"
|
"gitea.dev/tea/internal/clierr"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -49,6 +51,10 @@ func TestAttachmentRenameAndConfirmedDeletion(t *testing.T) {
|
|||||||
_, _ = w.Write([]byte(`{"number":1,"pull_request":{}}`))
|
_, _ = w.Write([]byte(`{"number":1,"pull_request":{}}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/issues/1/assets/1" {
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"name":"log.txt"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
require.Equal(t, "/api/v1/repos/alice/project/issues/1/assets/1", r.URL.Path)
|
require.Equal(t, "/api/v1/repos/alice/project/issues/1/assets/1", r.URL.Path)
|
||||||
mutations++
|
mutations++
|
||||||
if r.Method == http.MethodPatch {
|
if r.Method == http.MethodPatch {
|
||||||
@@ -81,3 +87,48 @@ func TestAttachmentRenameAndConfirmedDeletion(t *testing.T) {
|
|||||||
server.Close()
|
server.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAttachmentTablesDownloadsAndDoneLines(t *testing.T) {
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method + " " + r.URL.Path {
|
||||||
|
case "GET /api/v1/repos/alice/project/issues/1/assets":
|
||||||
|
_, _ = w.Write([]byte(`[{"id":9,"name":"crash.log","size":2048,"download_count":3,"created_at":"2026-10-11T18:00:00Z","browser_download_url":"https://example.org/a/9"}]`))
|
||||||
|
case "GET /api/v1/repos/alice/project/issues/1/assets/9":
|
||||||
|
_, _ = w.Write([]byte(`{"id":9,"name":"crash.log","size":5,"uuid":"abc"}`))
|
||||||
|
case "GET /attachments/abc":
|
||||||
|
_, _ = w.Write([]byte("hello"))
|
||||||
|
case "DELETE /api/v1/repos/alice/project/issues/1/assets/9":
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, out, diagnostics := serverDeps(t, server.URL)
|
||||||
|
deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) }
|
||||||
|
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1", "TEA_HYPERLINKS": "never"})
|
||||||
|
run := func(tty bool, args ...string) error {
|
||||||
|
out.Reset()
|
||||||
|
diagnostics.Reset()
|
||||||
|
deps.Streams.OutTTY = tty
|
||||||
|
return Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input"))
|
||||||
|
}
|
||||||
|
require.NoError(t, run(true, "issue", "attachment", "list", "1"))
|
||||||
|
require.Equal(t, "Showing 1 of 1 attachments on alice/project#1\n\n"+
|
||||||
|
"ID NAME SIZE DOWNLOADS CREATED\n"+
|
||||||
|
"9 crash.log 2.0 KiB 3 about 2 hours ago\n", out.String())
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
require.NoError(t, run(true, "issue", "attachment", "download", "1", "9"))
|
||||||
|
require.Equal(t, "✓ Downloaded crash.log (5 B) to crash.log\n", diagnostics.String())
|
||||||
|
data, err := os.ReadFile("crash.log")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "hello", string(data))
|
||||||
|
require.ErrorContains(t, run(true, "issue", "attachment", "download", "1", "9"), "crash.log already exists; pass --clobber to replace it or --skip-existing to keep it")
|
||||||
|
require.NoError(t, run(true, "issue", "attachment", "download", "1", "9", "--skip-existing"))
|
||||||
|
require.Equal(t, "! Skipped attachment 9 (crash.log) on alice/project#1: crash.log exists\n", diagnostics.String())
|
||||||
|
require.NoError(t, run(false, "issue", "attachment", "download", "1", "9", "-O", "-"))
|
||||||
|
require.Equal(t, "hello", out.String())
|
||||||
|
require.EqualError(t, run(true, "issue", "attachment", "delete", "1", "9"), "confirmation required to delete attachment 9 (crash.log) on alice/project#1; rerun with --yes")
|
||||||
|
require.NoError(t, run(true, "issue", "attachment", "delete", "1", "9", "--yes"))
|
||||||
|
require.Equal(t, "✓ Deleted attachment 9 (crash.log) on alice/project#1\n", diagnostics.String())
|
||||||
|
}
|
||||||
@@ -40,7 +40,6 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
|||||||
{[]string{"auth", "status", "--json=token"}, "unknown JSON field \"token\""},
|
{[]string{"auth", "status", "--json=token"}, "unknown JSON field \"token\""},
|
||||||
{[]string{"config", "set", "prompt", "invalid"}, "prompt must be enabled or disabled"},
|
{[]string{"config", "set", "prompt", "invalid"}, "prompt must be enabled or disabled"},
|
||||||
{[]string{"doctor", "--json=secret"}, "unknown JSON field \"secret\""},
|
{[]string{"doctor", "--json=secret"}, "unknown JSON field \"secret\""},
|
||||||
{[]string{"issue", "attachment", "download", "1", "1"}, "supply --output-file, or --output-file - for stdout"},
|
|
||||||
{[]string{"issue", "attachment", "download", "1", "1", "--json"}, "attachment downloads are binary"},
|
{[]string{"issue", "attachment", "download", "1", "1", "--json"}, "attachment downloads are binary"},
|
||||||
{[]string{"issue", "attachment", "download", "1", "1", "--output-file", "-", "--json=id"}, "attachment downloads are binary"},
|
{[]string{"issue", "attachment", "download", "1", "1", "--output-file", "-", "--json=id"}, "attachment downloads are binary"},
|
||||||
{[]string{"issue", "close", "1", "bad"}, `invalid issue "bad": use a number, #number, OWNER/REPO#number or URL`},
|
{[]string{"issue", "close", "1", "bad"}, `invalid issue "bad": use a number, #number, OWNER/REPO#number or URL`},
|
||||||
@@ -100,7 +99,6 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
|||||||
{[]string{"org", "edit", "team"}, "supply organization fields to edit"},
|
{[]string{"org", "edit", "team"}, "supply organization fields to edit"},
|
||||||
{[]string{"org", "list", "--all", "--username", "alice"}, "--all and --username are mutually exclusive"},
|
{[]string{"org", "list", "--all", "--username", "alice"}, "--all and --username are mutually exclusive"},
|
||||||
{[]string{"org", "member", "list", "team", "--limit", "0"}, `"-L, --limit" flag: must be 1..1000`},
|
{[]string{"org", "member", "list", "team", "--limit", "0"}, `"-L, --limit" flag: must be 1..1000`},
|
||||||
{[]string{"pr", "attachment", "download", "1", "1"}, "supply --output-file, or --output-file - for stdout"},
|
|
||||||
{[]string{"pr", "attachment", "download", "1", "1", "--json"}, "attachment downloads are binary"},
|
{[]string{"pr", "attachment", "download", "1", "1", "--json"}, "attachment downloads are binary"},
|
||||||
{[]string{"pr", "attachment", "download", "1", "1", "--output-file", "-", "--json=id"}, "attachment downloads are binary"},
|
{[]string{"pr", "attachment", "download", "1", "1", "--output-file", "-", "--json=id"}, "attachment downloads are binary"},
|
||||||
{[]string{"pr", "checkout", "7", "--detach", "--branch", "name"}, "--detach and --branch/--update are mutually exclusive"},
|
{[]string{"pr", "checkout", "7", "--detach", "--branch", "name"}, "--detach and --branch/--update are mutually exclusive"},
|
||||||
|
|||||||
@@ -65,11 +65,13 @@ func (s Issues) UploadAttachment(ctx context.Context, number int64, name string,
|
|||||||
return IssueAttachment(asset), err
|
return IssueAttachment(asset), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadAttachment opens an attachment stream scoped to its instance and issue.
|
// DownloadAttachment opens an attachment stream scoped to its instance and issue, and returns the
|
||||||
func (s Issues) DownloadAttachment(ctx context.Context, number, id int64) (io.ReadCloser, error) {
|
// attachment's metadata.
|
||||||
|
func (s Issues) DownloadAttachment(ctx context.Context, number, id int64) (IssueAttachment, io.ReadCloser, error) {
|
||||||
attachment, err := s.attachment(ctx, number, id)
|
attachment, err := s.attachment(ctx, number, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return IssueAttachment{}, nil, err
|
||||||
}
|
}
|
||||||
return downloadAttachment(ctx, s.Client, attachment)
|
body, err := downloadAttachment(ctx, s.Client, attachment)
|
||||||
|
return IssueAttachment(assetFrom(attachment)), body, err
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user