mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
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>
303 lines
10 KiB
Go
303 lines
10 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"archive/zip"
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.dev/tea/internal/clierr"
|
|
"gitea.dev/tea/internal/services"
|
|
"gitea.dev/tea/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func (a *application) artifactCommand() *cobra.Command {
|
|
group := &cobra.Command{
|
|
Use: "artifact",
|
|
Short: "List the artifacts of runs",
|
|
Long: "List the artifacts that a run uploaded. To download them, use tea run download.",
|
|
}
|
|
var limit, page int
|
|
list := &cobra.Command{
|
|
Use: "list [<run-id>]",
|
|
Short: "List the artifacts of a run",
|
|
Example: `# list the artifacts of a run
|
|
$ tea run artifact list 34`,
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
Annotations: map[string]string{"tea.arguments": runArguments},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
t, run, err := a.runArg(cmd, args, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Artifacts(cmd.Context(), run, limit, page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
spec := listSpec[services.Artifact]{Columns: artifactColumns, Row: artifactRow, Empty: fmt.Sprintf("no artifacts in run %d", run), Context: fmt.Sprintf("artifacts of run %d", run)}
|
|
return writeTable(a, cmd, items.Items, spec, servicePage(items, page))
|
|
},
|
|
}
|
|
pageFlags(list, &limit, &page)
|
|
list.ValidArgsFunction = firstArg(a.completeRuns())
|
|
group.AddCommand(list)
|
|
|
|
return group
|
|
}
|
|
|
|
// artifactColumns head artifactRow: sizes and expiry for people on a terminal, bytes and times
|
|
// for scripts.
|
|
var artifactColumns = []terminal.Column{
|
|
{Header: "ID"},
|
|
{Header: "Name", Flex: true},
|
|
{Header: "Size", TTYOnly: true},
|
|
{Header: "Size", PipedOnly: true},
|
|
{Header: "Expires", TTYOnly: true},
|
|
{Header: "Expires", PipedOnly: true},
|
|
{Header: "Expired", PipedOnly: true},
|
|
}
|
|
|
|
func artifactRow(item services.Artifact) []terminal.Cell {
|
|
expires := terminal.Cell{}
|
|
expiresAt := ""
|
|
if item.ExpiresAt != nil {
|
|
expiresAt = *item.ExpiresAt
|
|
expires.Time = terminal.ParseTime(expiresAt)
|
|
}
|
|
if item.Expired {
|
|
expires = terminal.Cell{Text: "expired", Color: "red"}
|
|
}
|
|
return []terminal.Cell{
|
|
{Text: strconv.FormatInt(item.ID, 10)},
|
|
{Text: item.Name},
|
|
{Text: terminal.Bytes(item.Size)},
|
|
{Text: strconv.FormatInt(item.Size, 10)},
|
|
expires,
|
|
{Text: expiresAt},
|
|
{Text: strconv.FormatBool(item.Expired)},
|
|
}
|
|
}
|
|
|
|
func (a *application) runDownloadCommand() *cobra.Command {
|
|
var names []string
|
|
var asZip bool
|
|
var destination downloadFlags
|
|
cmd := &cobra.Command{
|
|
Use: "download [<run-id>]",
|
|
Short: "Download the artifacts of a run",
|
|
Long: `Download the artifacts of a run and extract them.
|
|
|
|
Each artifact is extracted into a directory named after it, except a single artifact selected
|
|
with --name, which is extracted into the destination directory itself. With --zip, the
|
|
artifacts are saved as ZIP archives instead.`,
|
|
Example: `# download every artifact of a run into ./<artifact name>/
|
|
$ tea run download 34
|
|
# extract one artifact into dist/
|
|
$ tea run download 34 --name build --dir dist
|
|
# save the artifacts as ZIP archives
|
|
$ tea run download 34 --zip`,
|
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
Annotations: map[string]string{"tea.arguments": runArguments, "tea.text": "run download writes files"},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if destination.output != "" && (!asZip || len(names) != 1) {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--output saves one archive; combine it with --zip and a single --name, or use --dir"))
|
|
}
|
|
t, run, err := a.runArg(cmd, args, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
service := services.Actions{Client: t.client, Repository: t.Repo}
|
|
artifacts, err := service.AllArtifacts(cmd.Context(), run)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
selected, err := selectArtifacts(artifacts, names, run)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !slices.ContainsFunc(selected, func(artifact services.Artifact) bool { return !artifact.Expired }) {
|
|
return fmt.Errorf("the artifacts of run %d have expired", run)
|
|
}
|
|
for _, artifact := range selected {
|
|
if artifact.Expired {
|
|
a.warn(cmd, "Artifact %s has expired; skipped", artifact.Name)
|
|
continue
|
|
}
|
|
path, err := a.downloadArtifact(cmd, service, artifact, destination, asZip, len(names) != 1)
|
|
if err != nil {
|
|
return fmt.Errorf("artifact %s: %w", terminal.SafeLine(artifact.Name), err)
|
|
}
|
|
if path != "" {
|
|
a.done(cmd, "Downloaded artifact %s to %s", artifact.Name, path)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
destination.register(cmd)
|
|
cmd.Flags().StringSliceVarP(&names, "name", "n", nil, "Only download the artifacts with these names")
|
|
cmd.Flags().BoolVar(&asZip, "zip", false, "Save each artifact as a ZIP archive instead of extracting it")
|
|
cmd.ValidArgsFunction = firstArg(a.completeRuns())
|
|
_ = cmd.RegisterFlagCompletionFunc("name", commaSeparated(a.completeArtifactNames()))
|
|
return cmd
|
|
}
|
|
|
|
// selectArtifacts returns the artifacts of a run with the given names, or all of them.
|
|
func selectArtifacts(artifacts []services.Artifact, names []string, run int64) ([]services.Artifact, error) {
|
|
if len(artifacts) == 0 {
|
|
return nil, fmt.Errorf("run %d has no artifacts", run)
|
|
}
|
|
if len(names) == 0 {
|
|
return artifacts, nil
|
|
}
|
|
selected := make([]services.Artifact, 0, len(names))
|
|
for _, name := range names {
|
|
index := slices.IndexFunc(artifacts, func(artifact services.Artifact) bool { return artifact.Name == name })
|
|
if index < 0 {
|
|
available := make([]string, len(artifacts))
|
|
for i, artifact := range artifacts {
|
|
available[i] = artifact.Name
|
|
}
|
|
return nil, fmt.Errorf("artifact %q not found in run %d (artifacts: %s)", name, run, strings.Join(available, ", "))
|
|
}
|
|
selected = append(selected, artifacts[index])
|
|
}
|
|
return selected, nil
|
|
}
|
|
|
|
// downloadArtifact saves an artifact as a ZIP archive, or extracts it into the destination
|
|
// directory, or into a directory named after it inside, with isolate. It returns where the
|
|
// artifact went, or "" when --skip-existing kept an existing archive.
|
|
func (a *application) downloadArtifact(cmd *cobra.Command, service services.Actions, artifact services.Artifact, f downloadFlags, asZip, isolate bool) (string, error) {
|
|
if asZip {
|
|
path, skip, err := f.destination(artifact.Name + ".zip")
|
|
if err != nil || skip {
|
|
return "", err
|
|
}
|
|
body, err := service.DownloadArtifact(cmd.Context(), artifact.ID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer body.Close()
|
|
return path, a.download(cmd, f, path, body)
|
|
}
|
|
if f.clobber && f.skipExisting {
|
|
return "", clierr.Wrap(clierr.Usage, errors.New("--clobber and --skip-existing cannot be combined"))
|
|
}
|
|
dir := cmp.Or(f.dir, ".")
|
|
if isolate {
|
|
if !filepath.IsLocal(artifact.Name) || strings.ContainsAny(artifact.Name, `/\`) {
|
|
return "", fmt.Errorf("cannot use %q as a directory name", artifact.Name)
|
|
}
|
|
dir = filepath.Join(dir, artifact.Name)
|
|
}
|
|
body, err := service.DownloadArtifact(cmd.Context(), artifact.ID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer body.Close()
|
|
return dir, extractZip(cmd.Context(), body, dir, f.clobber, f.skipExisting)
|
|
}
|
|
|
|
// extractZip extracts the ZIP archive in body into dir. Entries outside dir, links and other
|
|
// special files are refused, and an existing file stops the extraction before anything is
|
|
// written, unless clobber replaces or skipExisting keeps it.
|
|
func extractZip(ctx context.Context, body io.Reader, dir string, clobber, skipExisting bool) error {
|
|
archive, err := os.CreateTemp("", "tea-artifact-*.zip")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(archive.Name())
|
|
defer archive.Close()
|
|
size, err := io.Copy(archive, body)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return err
|
|
}
|
|
reader, err := zip.NewReader(archive, size)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid ZIP archive: %w", err)
|
|
}
|
|
for _, entry := range reader.File {
|
|
name := filepath.FromSlash(entry.Name)
|
|
mode := entry.Mode()
|
|
switch {
|
|
case !filepath.IsLocal(name):
|
|
return fmt.Errorf("refusing to extract %q outside %s", entry.Name, dir)
|
|
case !mode.IsRegular() && !mode.IsDir():
|
|
return fmt.Errorf("refusing to extract %q: not a regular file", entry.Name)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil && !mode.IsDir() && !clobber && !skipExisting {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s already exists; pass --clobber to replace it or --skip-existing to keep it", filepath.Join(dir, name)))
|
|
}
|
|
}
|
|
for _, entry := range reader.File {
|
|
path := filepath.Join(dir, filepath.FromSlash(entry.Name))
|
|
if entry.Mode().IsDir() {
|
|
if err := os.MkdirAll(path, 0o777); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := extractFile(entry, path, clobber, skipExisting); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractFile writes one archive entry to path with the permissions the umask allows.
|
|
func extractFile(entry *zip.File, path string, clobber, skipExisting bool) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil {
|
|
return err
|
|
}
|
|
flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL
|
|
if clobber {
|
|
flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
|
|
}
|
|
file, err := os.OpenFile(path, flags, 0o666)
|
|
if errors.Is(err, os.ErrExist) && skipExisting {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
content, err := entry.Open()
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(file, content)
|
|
return cmp.Or(copyErr, content.Close(), file.Close())
|
|
}
|
|
|
|
// completeArtifactNames completes the names of the artifacts of the run given as the first argument.
|
|
func (a *application) completeArtifactNames() cobra.CompletionFunc {
|
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
|
if len(args) == 0 {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
run, err := parseRef(args[0], runRef)
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
}
|
|
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
|
|
artifacts, err := services.Actions{Client: t.client, Repository: t.Repo}.AllArtifacts(ctx, run.Number)
|
|
return describe(artifacts, func(artifact services.Artifact) (string, string) { return artifact.Name, terminal.Bytes(artifact.Size) }), err
|
|
})(cmd, args, toComplete)
|
|
}
|
|
}
|