Files
gitea-tea/internal/app/issue_attachments.go
T
bircni 29f62da006 refactor: load settings once per run and reuse shared helpers
The application caches the settings snapshot and reloads it only after
an update through the store, so output and prompt helpers no longer
read and parse config.yml and hosts.yml several times per command.
canPrompt and chooser replace the inline prompt-policy checks.

Checks are tallied by one services.Tally, terminal.Plural replaces the
two app copies, refKind.article/noun replace the comment group's own
words, list commands use servicePage, and web URLs are built by
repositoryURL and webURL in browser.go.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 10:57:39 +02:00

198 lines
8.6 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"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 {
words := commentWords(kind)
group := &cobra.Command{Use: "attachment", Short: "Manage the attachments of " + kind.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.`, kind.article(), kind.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
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 {
t, n, err := discussionTarget(cmd, args[0])
if err != nil {
return err
}
items, err := (services.Issues{Client: t.client, Repository: t.Repo}).Attachments(cmd.Context(), n, limit, page)
if err != nil {
return err
}
where := fmt.Sprintf("on %s#%d", t.Repo, n)
return writeTable(a, cmd, items, listSpec[services.IssueAttachment]{
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})
}}
pageFlags(list, &limit, &page)
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 {
return err
}
item, err := (services.Issues{Client: t.client, Repository: t.Repo}).Attachment(cmd.Context(), n, id)
if err != nil {
return err
}
return writeView(a, cmd, item, attachmentView)
}}
var uploadName string
upload := &cobra.Command{Use: "upload {<number> | <url>} <file>", Short: "Attach a file", Example: fmt.Sprintf(`$ tea %[1]s attachment upload %[2]s crash.log
$ tea %[1]s attachment upload %[2]s screenshot.png --name before.png`, words.group, words.example), Annotations: annotations, Args: argsExact(2), ValidArgsFunction: pathArgs(1, false, completeFiles, completeFirst), RunE: func(cmd *cobra.Command, args []string) error {
name := uploadName
if cmd.Flags().Changed("name") && strings.TrimSpace(name) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--name cannot be empty"))
}
if name == "" {
name = filepath.Base(args[1])
}
files, err := openRegularFiles(args[1:])
defer closeFiles(files)
if err != nil {
return err
}
t, n, err := discussionTarget(cmd, args[0])
if err != nil {
return err
}
item, err := (services.Issues{Client: t.client, Repository: t.Repo}).UploadAttachment(cmd.Context(), n, name, files[0])
if err != nil {
return err
}
a.done(cmd, "Uploaded %s (%s) to %s#%d", item.Name, terminal.Bytes(item.Size), t.Repo, n)
return a.created(cmd, item, item.URL)
}}
upload.Flags().StringVar(&uploadName, "name", "", "File `name` of the attachment (default: the local file name)")
var newName string
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 {
return err
}
item, err := (services.Issues{Client: t.client, Repository: t.Repo}).RenameAttachment(cmd.Context(), n, id, newName)
if err != nil {
return err
}
a.done(cmd, "Renamed %s", attachmentName(t, n, item))
return a.jsonResult(cmd, item)
}}
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 {
return err
}
service := services.Issues{Client: t.client, Repository: t.Repo}
item, err := service.Attachment(cmd.Context(), n, id)
if err != nil {
return err
}
name := attachmentName(t, n, item)
return a.deleteConfirmed(cmd, name, func() error { return service.DeleteAttachment(cmd.Context(), n, id) })
}}
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 {
return err
}
defer body.Close()
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
}}
destination.register(download)
group.AddCommand(list, view, upload, edit, remove, download)
return group
}
// attachmentArgs resolves the issue or pull request and attachment ID arguments of an attachment
// 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
}