mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
feat(run): show runs like gh, watch them live, and download artifacts
run list prints a table with a status icon, the workflow name, title, branch, event, elapsed time and age, and the total from the server; runs carry workflow, workflow_path and run_number in JSON. run view shows the run's state and trigger, its jobs with their steps and durations, the next steps and the run's URL; --json includes the jobs, --log prints their logs and --web opens the run. Run commands accept #N and run URLs, and ask for a recent run in a terminal when the argument is left out. run watch redraws the run on the alternate screen, then prints it with a summary; an expired --timeout names the run and suspects a missing runner when no job started. run cancel no longer asks for confirmation, rerun refuses unfinished runs up front, reruns a single job with --job and reports the new attempt, and delete names the run in its prompt. run logs follows several jobs with --follow, prefixing lines with the job's name, selects jobs by name or with --failed, and downloads the archive with --output or --dir. run download extracts the artifacts of a run by name, and artifact list shows sizes and expiry. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
19 files changed
+2636
-646
No files matched your search
+50
-17
@@ -4,7 +4,9 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/services"
|
||||
"gitea.dev/tea/internal/terminal"
|
||||
@@ -13,25 +15,56 @@ import (
|
||||
|
||||
func (a *application) runJobsCommand() *cobra.Command {
|
||||
var limit, page int
|
||||
cmd := &cobra.Command{Use: "jobs <run-id>", Short: "List jobs belonging to a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
run, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := (services.Actions{Client: target.client, Repository: target.Repo}).JobsPage(cmd.Context(), run, limit, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeList(a, cmd, items, jobRow)
|
||||
}}
|
||||
cmd := &cobra.Command{
|
||||
Use: "jobs [<run-id>]",
|
||||
Short: "List the jobs of a workflow run",
|
||||
Example: `# list the jobs of a run
|
||||
$ tea run jobs 34
|
||||
# list the IDs of the jobs that failed
|
||||
$ tea run jobs 34 --json id,conclusion --jq '.[] | select(.conclusion == "failure") | .id'`,
|
||||
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
|
||||
}
|
||||
jobs, err := (services.Actions{Client: t.client, Repository: t.Repo}).JobsPage(cmd.Context(), run, limit, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := listSpec[services.Job]{Columns: jobColumns, Row: jobRow(a.deps.Now()), Empty: fmt.Sprintf("no jobs in run %d", run), Context: fmt.Sprintf("jobs in run %d", run)}
|
||||
return writeTable(a, cmd, jobs.Items, spec, listPage{Number: page, More: jobs.More, Total: jobs.Total, Counted: jobs.Counted})
|
||||
},
|
||||
}
|
||||
pageFlags(cmd, &limit, &page)
|
||||
cmd.ValidArgsFunction = a.completeRunArg()
|
||||
return cmd
|
||||
}
|
||||
|
||||
func jobRow(item services.Job) []terminal.Cell {
|
||||
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, item.Status, item.Conclusion)
|
||||
// jobColumns head jobRow: an icon on a terminal, the status and conclusion words for scripts.
|
||||
var jobColumns = []terminal.Column{
|
||||
{Header: "Status", TTYOnly: true},
|
||||
{Header: "Status", PipedOnly: true},
|
||||
{Header: "Conclusion", PipedOnly: true},
|
||||
{Header: "ID"},
|
||||
{Header: "Name", Flex: true},
|
||||
{Header: "Elapsed"},
|
||||
{Header: "URL", PipedOnly: true},
|
||||
}
|
||||
|
||||
func jobRow(now time.Time) func(services.Job) []terminal.Cell {
|
||||
return func(job services.Job) []terminal.Cell {
|
||||
icon := terminal.StatusIcon(job.Status, job.Conclusion)
|
||||
icon.Link = job.URL
|
||||
return []terminal.Cell{
|
||||
icon,
|
||||
{Text: job.Status},
|
||||
{Text: job.Conclusion},
|
||||
{Text: strconv.FormatInt(job.ID, 10)},
|
||||
{Text: job.Name},
|
||||
{Text: runElapsedText(job.StartedAt, job.CompletedAt, now)},
|
||||
{Text: job.URL},
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
-93
@@ -4,11 +4,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
@@ -18,112 +20,196 @@ import (
|
||||
)
|
||||
|
||||
func (a *application) runLogsCommand() *cobra.Command {
|
||||
var output string
|
||||
var selected int64
|
||||
var follow bool
|
||||
var archive downloadFlags
|
||||
var job string
|
||||
var follow, failed bool
|
||||
var interval, timeout time.Duration
|
||||
cmd := &cobra.Command{Use: "logs <run-id>", Short: "Stream logs for a run or a selected job", Annotations: textOutput("run logs emits text"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if interval <= 0 || timeout <= 0 {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
|
||||
}
|
||||
run, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("job") && selected < 1 {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--job must be a positive ID"))
|
||||
}
|
||||
if output != "" && (follow || cmd.Flags().Changed("job")) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--output-file downloads a run archive and cannot be combined with --job or --follow"))
|
||||
}
|
||||
if output != "" {
|
||||
if err := validateDownloadDestination(output); err != nil {
|
||||
return err
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs [<run-id>]",
|
||||
Short: "Print the logs of a run's jobs",
|
||||
Long: `Print the logs of a run's jobs, one after another; the name of each job goes to stderr.
|
||||
|
||||
With --follow, tea waits for jobs to start and prints new log lines until they complete,
|
||||
prefixing each line with its job's name when it follows several jobs. With --output or --dir,
|
||||
it downloads the logs of the whole run as a ZIP archive instead.`,
|
||||
Example: `# print the logs of every job of a run
|
||||
$ tea run logs 34
|
||||
# print the logs of the failed jobs
|
||||
$ tea run logs 34 --failed
|
||||
# follow the logs of one job while it runs
|
||||
$ tea run logs 34 --job build --follow
|
||||
# download the logs as a ZIP archive
|
||||
$ tea run logs 34 --output logs.zip`,
|
||||
Args: usageArgs(cobra.MaximumNArgs(1)),
|
||||
Annotations: map[string]string{"tea.arguments": runArguments, "tea.text": "run logs emits text"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if interval <= 0 || timeout <= 0 {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
|
||||
}
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service := services.Actions{Client: target.client, Repository: target.Repo}
|
||||
if output != "" {
|
||||
body, err := service.RunLogsArchive(cmd.Context(), run)
|
||||
download := archive.output != "" || archive.dir != ""
|
||||
if download && (follow || failed || job != "") {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--output and --dir download the whole run and cannot be combined with --job, --failed or --follow"))
|
||||
}
|
||||
if follow && failed {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--follow and --failed cannot be combined"))
|
||||
}
|
||||
t, run, err := a.runArg(cmd, args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
return writeDownload(cmd, body, output)
|
||||
}
|
||||
jobs, err := service.Jobs(cmd.Context(), run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if selected != 0 {
|
||||
found := false
|
||||
for _, job := range jobs {
|
||||
if job.ID == selected {
|
||||
jobs = []services.Job{job}
|
||||
found = true
|
||||
break
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
if download {
|
||||
path, skip, err := archive.destination(fmt.Sprintf("run-%d-logs.zip", run))
|
||||
if err != nil || skip {
|
||||
return err
|
||||
}
|
||||
body, err := service.RunLogsArchive(cmd.Context(), run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
if err := a.download(cmd, archive, path, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if path != "-" {
|
||||
a.done(cmd, "Downloaded the logs of run %d to %s", run, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
jobs, err := service.Jobs(cmd.Context(), run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case job != "":
|
||||
selected, err := findJob(jobs, job, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs = []services.Job{selected}
|
||||
case failed:
|
||||
jobs = slices.DeleteFunc(jobs, func(job services.Job) bool { return job.Conclusion != "failure" })
|
||||
if len(jobs) == 0 {
|
||||
return fmt.Errorf("run %d has no failed jobs", run)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("job %d does not belong to run %d", selected, run)
|
||||
}
|
||||
}
|
||||
out := terminal.SafeWriter(cmd.OutOrStdout(), a.deps.Streams.OutTTY, true)
|
||||
if follow {
|
||||
if len(jobs) != 1 {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--follow requires exactly one job; select --job"))
|
||||
if !follow {
|
||||
return a.printJobLogs(cmd, service, jobs)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
|
||||
defer cancel()
|
||||
return service.FollowJobLogs(ctx, run, jobs[0].ID, interval, func(chunk []byte) error {
|
||||
n, err := out.Write(chunk)
|
||||
if err == nil && n != len(chunk) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
for _, job := range jobs {
|
||||
label := fmt.Sprintf("%s (job %d)", terminal.SafeLine(job.Name), job.ID)
|
||||
var logs io.ReadCloser
|
||||
if job.Started() {
|
||||
logs, err = service.JobLogs(cmd.Context(), job.ID)
|
||||
}
|
||||
if !job.Started() || errors.Is(err, services.ErrJobNotStarted) {
|
||||
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %s\n", label, unstarted(job)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(cmd.ErrOrStderr(), label); err != nil {
|
||||
_ = logs.Close()
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, logs)
|
||||
closeErr := logs.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
cmd.Flags().StringVar(&output, "output-file", "", "Download run logs as ZIP to a new file, or - for stdout")
|
||||
cmd.Flags().Int64Var(&selected, "job", 0, "Only stream this job's logs (must belong to the run)")
|
||||
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow newly appended logs until the selected job completes")
|
||||
cmd.Flags().DurationVar(&interval, "interval", 5*time.Second, "Log polling interval")
|
||||
cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum follow duration")
|
||||
return timeoutError(a.followJobLogs(ctx, cmd, service, run, jobs, interval), timeout, "following the logs of run %d", run)
|
||||
},
|
||||
}
|
||||
archive.register(cmd)
|
||||
cmd.Flags().StringVarP(&job, "job", "j", "", "Only the logs of this job, by `name or ID`")
|
||||
cmd.Flags().BoolVar(&failed, "failed", false, "Only the logs of failed jobs")
|
||||
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Wait for jobs to start and print new lines until they complete")
|
||||
cmd.Flags().DurationVarP(&interval, "interval", "i", 5*time.Second, "Polling interval with --follow")
|
||||
cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum time to follow")
|
||||
cmd.ValidArgsFunction = a.completeRunArg()
|
||||
_ = cmd.RegisterFlagCompletionFunc("job", a.completeJobs())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printJobLogs prints the logs of jobs on stdout, each headed by its name on stderr. Jobs that
|
||||
// have not started are reported on stderr and skipped. A terminal keeps only the colors of logs.
|
||||
func (a *application) printJobLogs(cmd *cobra.Command, service services.Actions, jobs []services.Job) error {
|
||||
out := terminal.SafeWriter(cmd.OutOrStdout(), a.deps.Streams.OutTTY, true)
|
||||
for _, job := range jobs {
|
||||
label := fmt.Sprintf("%s (job %d)", terminal.SafeLine(job.Name), job.ID)
|
||||
var logs io.ReadCloser
|
||||
var err error
|
||||
if job.Started() {
|
||||
logs, err = service.JobLogs(cmd.Context(), job.ID)
|
||||
}
|
||||
if !job.Started() || errors.Is(err, services.ErrJobNotStarted) {
|
||||
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %s\n", label, unstarted(job)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(cmd.ErrOrStderr(), label); err != nil {
|
||||
_ = logs.Close()
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, logs)
|
||||
closeErr := logs.Close()
|
||||
if err := cmp.Or(copyErr, closeErr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// followJobLogs follows the logs of jobs one after another until each completes, waiting for
|
||||
// those that have not started. Following several jobs prefixes each line with its job's name.
|
||||
func (a *application) followJobLogs(ctx context.Context, cmd *cobra.Command, service services.Actions, run int64, jobs []services.Job, interval time.Duration) error {
|
||||
out := terminal.SafeWriter(cmd.OutOrStdout(), a.deps.Streams.OutTTY, true)
|
||||
for _, job := range jobs {
|
||||
label := fmt.Sprintf("%s (job %d)", terminal.SafeLine(job.Name), job.ID)
|
||||
if job.Started() {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), label)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "%s: waiting for it to start…\n", label)
|
||||
}
|
||||
lines := &prefixWriter{w: out}
|
||||
if len(jobs) > 1 {
|
||||
lines.prefix = []byte(terminal.SafeLine(job.Name) + "\t")
|
||||
}
|
||||
err := service.FollowJobLogs(ctx, run, job.ID, interval, func(chunk []byte) error {
|
||||
_, err := lines.Write(chunk)
|
||||
return err
|
||||
})
|
||||
if errors.Is(err, services.ErrJobNotStarted) {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "%s: never started\n", label)
|
||||
continue
|
||||
}
|
||||
if err := cmp.Or(err, lines.Flush()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// prefixWriter writes prefix before every line it passes to w; Flush ends a last unterminated line.
|
||||
type prefixWriter struct {
|
||||
w io.Writer
|
||||
prefix []byte
|
||||
partial bool
|
||||
}
|
||||
|
||||
func (p *prefixWriter) Write(data []byte) (int, error) {
|
||||
if len(p.prefix) == 0 {
|
||||
return p.w.Write(data)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
for line := range bytes.Lines(data) {
|
||||
if !p.partial {
|
||||
b.Write(p.prefix)
|
||||
}
|
||||
b.Write(line)
|
||||
p.partial = !bytes.HasSuffix(line, []byte("\n"))
|
||||
}
|
||||
if _, err := p.w.Write(b.Bytes()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
// Flush terminates a last line that has no line break.
|
||||
func (p *prefixWriter) Flush() error {
|
||||
if !p.partial {
|
||||
return nil
|
||||
}
|
||||
p.partial = false
|
||||
_, err := p.w.Write([]byte("\n"))
|
||||
return err
|
||||
}
|
||||
|
||||
// unstarted explains why a job has no logs: it is waiting for a runner, or it completed, such
|
||||
// as by cancellation, before one started it.
|
||||
func unstarted(job services.Job) string {
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestRunLogsStreamWithoutMixingDiagnosticsAndVerifyJobMembership(t *testing.
|
||||
diagnostics.Reset()
|
||||
args[5] = "--job=10"
|
||||
err := Execute(context.Background(), deps, args)
|
||||
require.ErrorContains(t, err, "does not belong")
|
||||
require.EqualError(t, err, "job 10 not found in run 7")
|
||||
require.Equal(t, 1, downloads)
|
||||
require.Empty(t, out.String())
|
||||
path := filepath.Join(t.TempDir(), "logs.zip")
|
||||
@@ -121,3 +121,34 @@ func TestRunLogsContinuePastJobsThatHaveNotStarted(t *testing.T) {
|
||||
require.Equal(t, "test output\n", out.String())
|
||||
require.Equal(t, "lint (job 8): never started (skipped)\nbuild (job 9): not started yet\ntest (job 10)\ndeploy (job 11): not started yet\n", diagnostics.String())
|
||||
}
|
||||
|
||||
func TestRunLogsSelectJobsAndFollowSeveral(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{
|
||||
"GET actions/runs/29/jobs": failedJobsFixture,
|
||||
"GET actions/jobs/30/logs": "compiled\nlinked",
|
||||
"GET actions/jobs/31/logs": "boom\n",
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
out, progress string
|
||||
err string
|
||||
}{
|
||||
{"failed jobs", []string{"--failed"}, "boom\n", "test (job 31)\n", ""},
|
||||
{"a job by name", []string{"--job", "Build"}, "compiled\nlinked", "build (job 30)\n", ""},
|
||||
{"follow several jobs", []string{"--follow", "--interval=1ms"}, "build\tcompiled\nbuild\tlinked\ntest\tboom\n", "build (job 30)\ntest (job 31)\n", ""},
|
||||
{"unknown job", []string{"--job", "deploy"}, "", "", `job "deploy" not found in run 29`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deps, out, diagnostics := actionsDeps(t, server.URL, false)
|
||||
err := Execute(context.Background(), deps, append([]string{"run", "logs", "29", "-R", "alice/project"}, tc.args...))
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tc.out, out.String())
|
||||
require.Equal(t, tc.progress, diagnostics.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
"gitea.dev/tea/internal/services"
|
||||
"gitea.dev/tea/internal/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runArguments describes the run references that run commands accept.
|
||||
const runArguments = `A run can be supplied as its ID ("34", "#34") or its URL
|
||||
("https://gitea.example.com/OWNER/REPO/actions/runs/34"). Without an argument,
|
||||
tea asks for one of the repository's recent runs in a terminal.`
|
||||
|
||||
func (a *application) runCommand() *cobra.Command {
|
||||
group := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "View and manage Actions workflow runs",
|
||||
Long: `List, view and watch the runs of a repository's Actions workflows, read their logs, download
|
||||
their artifacts, and cancel, rerun or delete them.
|
||||
|
||||
Start a run with tea workflow run.`,
|
||||
}
|
||||
group.AddCommand(a.runListCommand(), a.runViewCommand(), a.runWatchCommand(), a.runCancelCommand(), a.runRerunCommand(), a.runDeleteCommand(),
|
||||
a.runJobsCommand(), a.runLogsCommand(), a.runDownloadCommand(), a.artifactCommand())
|
||||
return group
|
||||
}
|
||||
|
||||
func (a *application) runListCommand() *cobra.Command {
|
||||
var filters services.RunListOptions
|
||||
var created timeRange
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List workflow runs",
|
||||
Long: `List the workflow runs of a repository, newest first.
|
||||
|
||||
--since and --until compare the time a run was created; tea scans up to 5000 runs to apply them.`,
|
||||
Example: `# list recent runs
|
||||
$ tea run list
|
||||
# list failed runs of the CI workflow on main
|
||||
$ tea run list --workflow CI --branch main --status failure
|
||||
# list the runs created in the last day as JSON
|
||||
$ tea run list --since 24h --json id,status,conclusion,workflow`,
|
||||
Args: argsExact(0),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var err error
|
||||
if filters.Since, filters.Until, err = created.parse(a.deps.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := filters.Validate(); err != nil {
|
||||
return clierr.Wrap(clierr.Usage, err)
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.accountNote(cmd, t, "to list the runs someone triggered, use --actor")
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
opts := filters
|
||||
opts.Actor = t.expandMe(opts.Actor)
|
||||
if opts.Workflow != "" {
|
||||
workflow, err := service.FindWorkflow(cmd.Context(), opts.Workflow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Workflow = workflow.ID
|
||||
}
|
||||
page, err := service.RunsPage(cmd.Context(), opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
empty := "no runs in " + t.Repo
|
||||
if filters.Workflow+filters.Branch+filters.Event+filters.Actor+filters.Commit != "" || len(filters.Statuses) > 0 || filters.Since != nil || filters.Until != nil {
|
||||
empty = "no runs match your filters in " + t.Repo
|
||||
}
|
||||
spec := listSpec[services.Run]{Columns: runColumns, Row: runRow(a.deps.Now()), Empty: empty, Context: "runs in " + t.Repo}
|
||||
return writeTable(a, cmd, page.Items, spec, listPage{Number: filters.Page, More: page.More, Total: page.Total, Counted: page.Counted})
|
||||
},
|
||||
}
|
||||
pageFlags(cmd, &filters.Limit, &filters.Page)
|
||||
created.register(cmd, "since", "Runs created at or after a date, RFC3339 timestamp, or duration ago (24h)", "until", "Runs created at or before a date, RFC3339 timestamp, or duration ago")
|
||||
cmd.Flags().StringVarP(&filters.Workflow, "workflow", "w", "", "Filter by workflow file or name")
|
||||
cmd.Flags().StringVarP(&filters.Branch, "branch", "b", "", "Filter by branch")
|
||||
cmd.Flags().StringVarP(&filters.Event, "event", "e", "", "Filter by the event that triggered the run, such as push")
|
||||
cmd.Flags().StringVarP(&filters.Actor, "actor", "u", "", "Filter by the user who triggered the run (@me for yourself)")
|
||||
cmd.Flags().StringVarP(&filters.Commit, "commit", "c", "", "Filter by the SHA of the commit that triggered the run")
|
||||
cmd.Flags().StringSliceVarP(&filters.Statuses, "status", "s", nil, "Filter by status or conclusion")
|
||||
enumValues(cmd, "status", services.RunStatuses...)
|
||||
_ = cmd.RegisterFlagCompletionFunc("workflow", a.completeWorkflows())
|
||||
_ = cmd.RegisterFlagCompletionFunc("branch", a.completeBranches())
|
||||
_ = cmd.RegisterFlagCompletionFunc("event", completeValues(runEvents...))
|
||||
_ = cmd.RegisterFlagCompletionFunc("actor", a.completeUsers())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runEvents are the events that commonly trigger runs, for completion.
|
||||
var runEvents = []string{"push", "pull_request", "pull_request_target", "workflow_dispatch", "schedule", "release", "create", "delete", "issues", "issue_comment"}
|
||||
|
||||
// runColumns head runRow: an icon on a terminal, the status and conclusion words for scripts.
|
||||
var runColumns = []terminal.Column{
|
||||
{Header: "Status", TTYOnly: true},
|
||||
{Header: "Status", PipedOnly: true},
|
||||
{Header: "Conclusion", PipedOnly: true},
|
||||
{Header: "ID"},
|
||||
{Header: "Workflow"},
|
||||
{Header: "Title", Flex: true},
|
||||
{Header: "Branch", Flex: true},
|
||||
{Header: "Event"},
|
||||
{Header: "Elapsed"},
|
||||
{Header: "Age"},
|
||||
{Header: "URL", PipedOnly: true},
|
||||
}
|
||||
|
||||
func runRow(now time.Time) func(services.Run) []terminal.Cell {
|
||||
return func(run services.Run) []terminal.Cell {
|
||||
icon := terminal.StatusIcon(run.Status, run.Conclusion)
|
||||
icon.Link = run.URL
|
||||
return []terminal.Cell{
|
||||
icon,
|
||||
{Text: run.Status},
|
||||
{Text: run.Conclusion},
|
||||
{Text: strconv.FormatInt(run.ID, 10)},
|
||||
{Text: run.Workflow},
|
||||
{Text: run.Title},
|
||||
{Text: run.Branch},
|
||||
{Text: run.Event},
|
||||
{Text: runElapsedText(run.StartedAt, run.CompletedAt, now)},
|
||||
{Time: terminal.ParseTime(run.CreatedAt)},
|
||||
{Text: run.URL},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runElapsed is how long a run, job or step has been running: until it completed, or until now. ok
|
||||
// is false before it starts.
|
||||
func runElapsed(started, completed *string, now time.Time) (d time.Duration, ok bool) {
|
||||
if started == nil {
|
||||
return 0, false
|
||||
}
|
||||
start := terminal.ParseTime(*started)
|
||||
if start.IsZero() {
|
||||
return 0, false
|
||||
}
|
||||
end := now
|
||||
if completed != nil {
|
||||
if t := terminal.ParseTime(*completed); !t.IsZero() {
|
||||
end = t
|
||||
}
|
||||
}
|
||||
return max(end.Sub(start), 0), true
|
||||
}
|
||||
|
||||
func runElapsedText(started, completed *string, now time.Time) string {
|
||||
if d, ok := runElapsed(started, completed, now); ok {
|
||||
return terminal.Duration(d)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runStateCell names the state of a run, job or step, its conclusion once it completed, colored
|
||||
// like its status icon.
|
||||
func runStateCell(status, conclusion string) terminal.Cell {
|
||||
cell := terminal.StatusIcon(status, conclusion)
|
||||
cell.Text = cmp.Or(conclusion, status)
|
||||
return cell
|
||||
}
|
||||
|
||||
func (a *application) runViewCommand() *cobra.Command {
|
||||
var noJobs, web, log bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "view [<run-id>]",
|
||||
Short: "View a workflow run",
|
||||
Long: "View a workflow run with its jobs and their steps.",
|
||||
Example: `# view a run with its jobs and steps
|
||||
$ tea run view 34
|
||||
# view a run from its URL
|
||||
$ tea run view https://gitea.example.com/alice/project/actions/runs/34
|
||||
# print the logs of every job of a run
|
||||
$ tea run view 34 --log
|
||||
# get the conclusion of each job as JSON
|
||||
$ tea run view 34 --json jobs --jq '.jobs[] | {name, conclusion}'`,
|
||||
Args: usageArgs(cobra.MaximumNArgs(1)),
|
||||
Annotations: map[string]string{"tea.arguments": runArguments},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if log && (web || a.options.json != "") {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--log cannot be combined with --web, --json or --jq"))
|
||||
}
|
||||
t, id, err := a.runArg(cmd, args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if web {
|
||||
return a.openBrowser(cmd, fmt.Sprintf("%s/%s/actions/runs/%d", strings.TrimSuffix(t.Host, "/"), t.Repo, id))
|
||||
}
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
if log {
|
||||
jobs, err := service.Jobs(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.printJobLogs(cmd, service, jobs)
|
||||
}
|
||||
run, err := service.RunDetails(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if noJobs {
|
||||
run.Jobs = nil
|
||||
}
|
||||
if a.options.json != "" {
|
||||
return a.writeJSON(cmd, run)
|
||||
}
|
||||
return a.writeHuman(cmd, renderRun(run, a.display(), a.runHints(t, run)))
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&noJobs, "no-jobs", false, "Leave out the jobs")
|
||||
cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the run in the browser")
|
||||
cmd.Flags().BoolVar(&log, "log", false, "Print the logs of every job instead, like tea run logs")
|
||||
cmd.ValidArgsFunction = a.completeRunArg()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runHints are the next steps shown under a run: watching it while it runs, and reading the logs
|
||||
// of its jobs once one started.
|
||||
func (a *application) runHints(t target, run services.Run) []string {
|
||||
repo := a.suggestedRepo(t)
|
||||
var hints []string
|
||||
if !run.Completed() {
|
||||
hints = append(hints, fmt.Sprintf("Watch it: tea run watch %d%s", run.ID, repo))
|
||||
}
|
||||
if slices.ContainsFunc(run.Jobs, services.Job.Started) {
|
||||
hints = append(hints, fmt.Sprintf("Logs of a job: tea run logs %d%s --job <id>", run.ID, repo))
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
// suggestedRepo repeats --repo for commands that tea suggests, unless the repository came from the
|
||||
// working directory, where the suggestion finds it again.
|
||||
func (a *application) suggestedRepo(t target) string {
|
||||
if strings.HasPrefix(t.RepoSource, "remote ") || strings.HasPrefix(t.RepoSource, "git config") {
|
||||
return ""
|
||||
}
|
||||
return " -R " + t.Repo
|
||||
}
|
||||
|
||||
// renderRun shows a run with its jobs: on a terminal its state, trigger and jobs with their
|
||||
// steps, followed by hints and the run's URL; for scripts "key:\tvalue" lines and the jobs.
|
||||
func renderRun(run services.Run, d terminal.Display, hints []string) string {
|
||||
now := cmp.Or(d.Now, time.Now())
|
||||
if !d.TTY {
|
||||
return runRecord(run, d, now)
|
||||
}
|
||||
style := func(cell terminal.Cell) string { return paintActionCell(cell, d.Color) }
|
||||
muted := func(text string) string { return style(terminal.Cell{Text: text, Muted: true}) }
|
||||
var b strings.Builder
|
||||
heading := run.Workflow
|
||||
if run.RunNumber > 0 {
|
||||
heading += fmt.Sprintf(" #%d", run.RunNumber)
|
||||
}
|
||||
icon := terminal.StatusIcon(run.Status, run.Conclusion)
|
||||
b.WriteString(style(icon) + " " + style(terminal.Cell{Text: run.Title, Bold: true}) + " " + muted(fmt.Sprintf("%s · run %d", heading, run.ID)) + "\n")
|
||||
|
||||
facts := []string{style(runStateCell(run.Status, run.Conclusion))}
|
||||
if run.Branch != "" {
|
||||
facts = append(facts, terminal.SafeLine(run.Branch))
|
||||
}
|
||||
if len(run.Commit) >= 7 {
|
||||
facts = append(facts, run.Commit[:7])
|
||||
}
|
||||
trigger := "triggered"
|
||||
if run.Actor != "" {
|
||||
trigger = run.Actor + " triggered it"
|
||||
}
|
||||
if run.Event != "" {
|
||||
trigger += " via " + run.Event
|
||||
}
|
||||
if created := terminal.ParseTime(run.CreatedAt); !created.IsZero() {
|
||||
trigger += " " + terminal.RelativeTime(now, created)
|
||||
}
|
||||
facts = append(facts, muted(trigger))
|
||||
if d, ok := runElapsed(run.StartedAt, run.CompletedAt, now); ok {
|
||||
if run.Completed() {
|
||||
facts = append(facts, muted("took "+terminal.Duration(d)))
|
||||
} else {
|
||||
facts = append(facts, muted("running for "+terminal.Duration(d)))
|
||||
}
|
||||
}
|
||||
if run.Attempt > 1 {
|
||||
facts = append(facts, muted(fmt.Sprintf("attempt %d", run.Attempt)))
|
||||
}
|
||||
b.WriteString(strings.Join(facts, " • ") + "\n")
|
||||
|
||||
if len(run.Jobs) > 0 {
|
||||
b.WriteString("\n" + style(terminal.Cell{Text: "JOBS", Bold: true}) + "\n")
|
||||
b.WriteString(terminal.Table(nil, jobLines(run.Jobs, now), d))
|
||||
}
|
||||
if waiting := waitingForRunner(run); waiting != "" {
|
||||
b.WriteString("\n" + muted(waiting) + "\n")
|
||||
}
|
||||
if len(hints) > 0 || run.URL != "" {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
for _, hint := range hints {
|
||||
b.WriteString(muted(hint) + "\n")
|
||||
}
|
||||
if run.URL != "" {
|
||||
b.WriteString(muted("View this run on Gitea: "+terminal.SafeLine(run.URL)) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// paintActionCell styles a cell's safe text outside a table: its color, muted or bold.
|
||||
func paintActionCell(cell terminal.Cell, color bool) string {
|
||||
name := cell.Color
|
||||
if cell.Muted {
|
||||
name = "muted"
|
||||
}
|
||||
return terminal.Emphasis(terminal.Colorize(name, terminal.SafeLine(cell.Text), color), cell.Bold && color)
|
||||
}
|
||||
|
||||
// jobLines lists jobs, each followed by its steps, for the run view.
|
||||
func jobLines(jobs []services.Job, now time.Time) [][]terminal.Cell {
|
||||
var rows [][]terminal.Cell
|
||||
for _, job := range jobs {
|
||||
icon := terminal.StatusIcon(job.Status, job.Conclusion)
|
||||
icon.Link = job.URL
|
||||
rows = append(rows, []terminal.Cell{icon, {Text: job.Name, Bold: true}, runStateCell(job.Status, job.Conclusion), {Text: runElapsedText(job.StartedAt, job.CompletedAt, now)}, {Text: fmt.Sprintf("(ID %d)", job.ID), Muted: true}})
|
||||
for _, step := range job.Steps {
|
||||
stepIcon := terminal.StatusIcon(step.Status, step.Conclusion)
|
||||
row := []terminal.Cell{{}, {Text: " " + stepIcon.Text + " " + step.Name, Muted: true}, {}, {Text: runElapsedText(step.StartedAt, step.CompletedAt, now), Muted: true}}
|
||||
if step.Conclusion == "failure" {
|
||||
row[1].Muted, row[1].Color = false, "red"
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// waitingForRunner explains a run that waits for a runner: none of its jobs started yet.
|
||||
func waitingForRunner(run services.Run) string {
|
||||
if run.Completed() || len(run.Jobs) == 0 || slices.ContainsFunc(run.Jobs, services.Job.Started) {
|
||||
return ""
|
||||
}
|
||||
return "Waiting for a runner" + runnerLabels(run)
|
||||
}
|
||||
|
||||
// runnerLabels names the runner labels that the run's jobs need: ` for "ubuntu-latest"`.
|
||||
func runnerLabels(run services.Run) string {
|
||||
var labels []string
|
||||
for _, job := range run.Jobs {
|
||||
for _, label := range job.Labels {
|
||||
if label = strconv.Quote(terminal.SafeLine(label)); !slices.Contains(labels, label) {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " for " + strings.Join(labels, ", ")
|
||||
}
|
||||
|
||||
// runRecord shows a run for scripts: "key:\tvalue" lines, then the jobs tab-separated.
|
||||
func runRecord(run services.Run, d terminal.Display, now time.Time) string {
|
||||
optional := func(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
fields := []terminal.Field{
|
||||
{Label: "ID", Value: strconv.FormatInt(run.ID, 10)},
|
||||
{Label: "Workflow", Value: run.Workflow},
|
||||
{Label: "Workflow_path", Value: run.WorkflowPath},
|
||||
{Label: "Run_number", Value: strconv.FormatInt(run.RunNumber, 10)},
|
||||
{Label: "Status", Value: run.Status},
|
||||
{Label: "Conclusion", Value: run.Conclusion},
|
||||
{Label: "Branch", Value: run.Branch},
|
||||
{Label: "Commit", Value: run.Commit},
|
||||
{Label: "Event", Value: run.Event},
|
||||
{Label: "Actor", Value: run.Actor},
|
||||
{Label: "Attempt", Value: strconv.FormatInt(run.Attempt, 10)},
|
||||
{Label: "Created_at", Value: run.CreatedAt},
|
||||
{Label: "Started_at", Value: optional(run.StartedAt)},
|
||||
{Label: "Completed_at", Value: optional(run.CompletedAt)},
|
||||
{Label: "Elapsed", Value: runElapsedText(run.StartedAt, run.CompletedAt, now)},
|
||||
}
|
||||
rows := make([][]terminal.Cell, len(run.Jobs))
|
||||
for i, job := range run.Jobs {
|
||||
rows[i] = jobRow(now)(job)
|
||||
}
|
||||
return terminal.View{Title: run.Title, Fields: fields, URL: run.URL, Body: terminal.Table(jobColumns, rows, d)}.Render(d)
|
||||
}
|
||||
|
||||
// runArg resolves the run that a command addresses: args[0], a run ID or URL, or else, in a
|
||||
// terminal, one of the repository's recent runs that the user picks; active offers only runs
|
||||
// that have not completed.
|
||||
func (a *application) runArg(cmd *cobra.Command, args []string, active bool) (target, int64, error) {
|
||||
if len(args) > 0 {
|
||||
return a.resolveRef(cmd, args[0], runRef)
|
||||
}
|
||||
missing := clierr.Wrap(clierr.Usage, &argumentError{problem: "missing run ID", cmd: cmd})
|
||||
chooser, ok := a.runPicker()
|
||||
if !ok {
|
||||
return target{}, 0, missing
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return t, 0, err
|
||||
}
|
||||
page, err := services.Actions{Client: t.client, Repository: t.Repo}.RunsPage(cmd.Context(), services.RunListOptions{Limit: 30, Page: 1})
|
||||
if err != nil {
|
||||
return t, 0, err
|
||||
}
|
||||
runs := page.Items
|
||||
if active {
|
||||
runs = slices.DeleteFunc(runs, services.Run.Completed)
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
if active {
|
||||
return t, 0, fmt.Errorf("no runs in progress in %s; pass a run ID", t.Repo)
|
||||
}
|
||||
return t, 0, fmt.Errorf("no runs in %s", t.Repo)
|
||||
}
|
||||
now := a.deps.Now()
|
||||
choices := make([]terminal.Choice, 0, 10)
|
||||
for _, run := range runs[:min(len(runs), 10)] {
|
||||
label := fmt.Sprintf("%s %s · %s · %s (run %d, %s)", terminal.StatusIcon(run.Status, run.Conclusion).Text, run.Title, run.Workflow, run.Branch, run.ID, terminal.RelativeTime(now, terminal.ParseTime(run.CreatedAt)))
|
||||
choices = append(choices, terminal.Choice{Label: label, Value: strconv.FormatInt(run.ID, 10)})
|
||||
}
|
||||
chosen, err := chooser.Choose(cmd.Context(), "Select a run", choices, "")
|
||||
if err != nil {
|
||||
return t, 0, err
|
||||
}
|
||||
id, err := strconv.ParseInt(chosen, 10, 64)
|
||||
return t, id, err
|
||||
}
|
||||
|
||||
// runPicker returns the prompter's selection prompt when prompts are allowed.
|
||||
func (a *application) runPicker() (terminal.Chooser, bool) {
|
||||
chooser, ok := a.deps.Prompter.(terminal.Chooser)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
s, err := a.deps.Settings.Load()
|
||||
return chooser, err == nil && a.policy(s).CanPrompt(a.deps.Streams)
|
||||
}
|
||||
|
||||
// completeRunArg completes the run argument of a run command with recent runs.
|
||||
func (a *application) completeRunArg() cobra.CompletionFunc {
|
||||
runs := a.completeRuns()
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return runs(cmd, args, toComplete)
|
||||
}
|
||||
}
|
||||
|
||||
// accountNote reminds people who pass the global --user, which selects the account tea acts as,
|
||||
// of the flag they may have meant, as gh's --user means something else.
|
||||
func (a *application) accountNote(cmd *cobra.Command, t target, hint string) {
|
||||
if flag := cmd.Flag("user"); flag != nil && flag.Changed && a.deps.Streams.ErrTTY {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "note: --user selects the account tea acts as (%s); %s\n", terminal.SafeLine(t.User), hint)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *application) runWatchCommand() *cobra.Command {
|
||||
var interval, timeout time.Duration
|
||||
cmd := &cobra.Command{
|
||||
Use: "watch [<run-id>]",
|
||||
Short: "Watch a run until it completes",
|
||||
Long: `Watch a run until it completes, refreshing its jobs and steps on a terminal.
|
||||
|
||||
The command fails when the run completes without success.`,
|
||||
Example: `# watch a run
|
||||
$ tea run watch 34
|
||||
# pick one of the runs in progress
|
||||
$ tea run watch
|
||||
# wait for a run in a script, for at most 10 minutes
|
||||
$ tea run watch 34 --timeout 10m && echo deployed`,
|
||||
Args: usageArgs(cobra.MaximumNArgs(1)),
|
||||
Annotations: map[string]string{"tea.arguments": runArguments},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if interval <= 0 || timeout <= 0 {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
|
||||
}
|
||||
t, id, err := a.runArg(cmd, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.watchRun(cmd, t, id, interval, timeout)
|
||||
},
|
||||
}
|
||||
cmd.Flags().DurationVarP(&interval, "interval", "i", 3*time.Second, "Refresh interval")
|
||||
cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum time to wait")
|
||||
cmd.ValidArgsFunction = a.completeRunArg()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// watchRun polls run id until it completes. A terminal shows the run view, redrawn in place on
|
||||
// the alternate screen, then the final view and a summary; otherwise each change of state is
|
||||
// reported on stderr, and the completed run printed. It fails when the run does not succeed.
|
||||
func (a *application) watchRun(cmd *cobra.Command, t target, id int64, interval, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
|
||||
defer cancel()
|
||||
out := cmd.OutOrStdout()
|
||||
live := a.deps.Streams.OutTTY && a.options.json == ""
|
||||
if live {
|
||||
fmt.Fprint(out, "\x1b[?1049h")
|
||||
}
|
||||
leave := func() {
|
||||
if live {
|
||||
fmt.Fprint(out, "\x1b[?1049l")
|
||||
live = false
|
||||
}
|
||||
}
|
||||
defer leave()
|
||||
last := ""
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
run, err := service.Watch(ctx, id, interval, func(run services.Run) error {
|
||||
if live {
|
||||
d := a.display()
|
||||
_, err := fmt.Fprintf(out, "\x1b[H\x1b[J%s\n\n%s", terminal.Colorize("muted", fmt.Sprintf("Refreshing run status every %s. Press Ctrl+C to quit.", shortDuration(interval)), d.Color), renderRun(run, d, nil))
|
||||
return err
|
||||
}
|
||||
if state := runProgress(run); state != last {
|
||||
last = state
|
||||
_, err := fmt.Fprintln(cmd.ErrOrStderr(), state)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
leave()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) && run.ID != 0 {
|
||||
if a.options.json == "" {
|
||||
_ = a.writeHuman(cmd, renderRun(run, a.display(), nil))
|
||||
}
|
||||
return timeoutError(err, timeout, "waiting for run %d (%s)", id, stillRunning(run))
|
||||
}
|
||||
return timeoutError(err, timeout, "waiting for run %d", id)
|
||||
}
|
||||
if a.options.json != "" {
|
||||
if err := a.writeJSON(cmd, run); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := a.writeHuman(cmd, renderRun(run, a.display(), nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
conclusion := cmp.Or(run.Conclusion, run.Status)
|
||||
if !services.SuccessfulRun(run) {
|
||||
return fmt.Errorf("run %d (%s) completed with %s", run.ID, run.Workflow, conclusion)
|
||||
}
|
||||
took := ""
|
||||
if d, ok := runElapsed(run.StartedAt, run.CompletedAt, a.deps.Now()); ok {
|
||||
took = " in " + terminal.Duration(d)
|
||||
}
|
||||
a.done(cmd, "Run %d (%s) completed with %s%s", run.ID, run.Workflow, conclusion, took)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runProgress is one line about the state of a run and its jobs, for watching without a
|
||||
// terminal: "run 34: in_progress (build: success, test: in_progress)".
|
||||
func runProgress(run services.Run) string {
|
||||
jobs := make([]string, len(run.Jobs))
|
||||
for i, job := range run.Jobs {
|
||||
jobs[i] = terminal.SafeLine(job.Name) + ": " + cmp.Or(job.Conclusion, job.Status)
|
||||
}
|
||||
line := fmt.Sprintf("run %d: %s", run.ID, cmp.Or(run.Conclusion, run.Status))
|
||||
if len(jobs) > 0 {
|
||||
line += " (" + strings.Join(jobs, ", ") + ")"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// stillRunning says how far a run got when a wait for it expired, suspecting a missing runner
|
||||
// when no job started.
|
||||
func stillRunning(run services.Run) string {
|
||||
if waitingForRunner(run) != "" {
|
||||
return "still " + run.Status + "; is a runner registered" + runnerLabels(run) + "?"
|
||||
}
|
||||
return "still " + run.Status
|
||||
}
|
||||
|
||||
func (a *application) runCancelCommand() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel [<run-id>]",
|
||||
Short: "Cancel a workflow run",
|
||||
Long: "Cancel a workflow run. The run and its logs are kept; rerun it with tea run rerun.",
|
||||
Example: `# cancel a run
|
||||
$ tea run cancel 34
|
||||
# cancel a run whose runner no longer responds
|
||||
$ tea run cancel 34 --force`,
|
||||
Args: usageArgs(cobra.MaximumNArgs(1)),
|
||||
Annotations: map[string]string{"tea.arguments": runArguments},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
t, id, err := a.runArg(cmd, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run, err := services.Actions{Client: t.client, Repository: t.Repo}.CancelRun(cmd.Context(), id, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.options.json != "" {
|
||||
return a.writeJSON(cmd, run)
|
||||
}
|
||||
a.done(cmd, "Requested cancellation of run %d", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&force, "force", false, "Cancel without waiting for the runner to acknowledge")
|
||||
cmd.ValidArgsFunction = a.completeRunArg()
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *application) runRerunCommand() *cobra.Command {
|
||||
var failed bool
|
||||
var job string
|
||||
cmd := &cobra.Command{
|
||||
Use: "rerun [<run-id>]",
|
||||
Short: "Rerun a workflow run",
|
||||
Long: "Rerun all jobs of a completed workflow run, only its failed jobs, or a single job.",
|
||||
Example: `# rerun a run
|
||||
$ tea run rerun 34
|
||||
# rerun only the jobs that failed
|
||||
$ tea run rerun 34 --failed
|
||||
# rerun one job, by name or ID
|
||||
$ tea run rerun 34 --job build`,
|
||||
Args: usageArgs(cobra.MaximumNArgs(1)),
|
||||
Annotations: map[string]string{"tea.arguments": runArguments},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if failed && job != "" {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--failed and --job cannot be combined"))
|
||||
}
|
||||
t, id, err := a.runArg(cmd, args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
run, err := service.Run(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !run.Completed() {
|
||||
return a.unfinished(t, run)
|
||||
}
|
||||
var jobID int64
|
||||
if job != "" {
|
||||
jobs, err := service.Jobs(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selected, err := findJob(jobs, job, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobID = selected.ID
|
||||
}
|
||||
if err := service.Rerun(cmd.Context(), id, jobID, failed); err != nil {
|
||||
return err
|
||||
}
|
||||
if run, err = service.Run(cmd.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.options.json != "" {
|
||||
return a.writeJSON(cmd, run)
|
||||
}
|
||||
a.done(cmd, "Requested rerun of run %d (attempt %d); watch it with: tea run watch %d%s", id, run.Attempt, id, a.suggestedRepo(t))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&failed, "failed", false, "Rerun only the failed jobs")
|
||||
cmd.Flags().StringVarP(&job, "job", "j", "", "Rerun only this job, by `name or ID`")
|
||||
cmd.ValidArgsFunction = a.completeRunArg()
|
||||
_ = cmd.RegisterFlagCompletionFunc("job", a.completeJobs())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// unfinished refuses to act on a run that has not completed, as Gitea does, before asking anything.
|
||||
func (a *application) unfinished(t target, run services.Run) error {
|
||||
return fmt.Errorf("run %d is still %s; cancel it first: tea run cancel %d%s", run.ID, run.Status, run.ID, a.suggestedRepo(t))
|
||||
}
|
||||
|
||||
// findJob returns the job of run that raw names by ID or name.
|
||||
func findJob(jobs []services.Job, raw string, run int64) (services.Job, error) {
|
||||
return services.NameOrID[services.Job]{
|
||||
Kind: "job", Scope: fmt.Sprintf("run %d", run),
|
||||
ID: func(job services.Job) int64 { return job.ID }, Name: func(job services.Job) string { return job.Name },
|
||||
}.Find(jobs, raw)
|
||||
}
|
||||
|
||||
func (a *application) runDeleteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete [<run-id>]",
|
||||
Short: "Delete a workflow run",
|
||||
Long: "Delete a completed workflow run with its logs and artifacts. Cancel a run before deleting it.",
|
||||
Example: `# delete a run
|
||||
$ tea run delete 34
|
||||
# delete a run without confirmation
|
||||
$ tea run delete 34 --yes`,
|
||||
Args: usageArgs(cobra.MaximumNArgs(1)),
|
||||
Annotations: map[string]string{"tea.arguments": runArguments, "tea.confirm": "true", "tea.text": "run deletion does not return a resource"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
t, id, err := a.runArg(cmd, args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
run, err := service.RunDetails(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !run.Completed() {
|
||||
return a.unfinished(t, run)
|
||||
}
|
||||
if err := a.confirmAction(cmd, terminal.Confirmation{
|
||||
Prompt: fmt.Sprintf("Delete run %d (%s · %s) from %s?", id, run.Workflow, run.Title, t.Repo),
|
||||
NonTTY: fmt.Sprintf("delete run %d from %s", id, t.Repo),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.DeleteRun(cmd.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
a.done(cmd, "Deleted run %d from %s", id, t.Repo)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.ValidArgsFunction = a.completeRunArg()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeJobs completes the jobs of the run given as the first argument with their names.
|
||||
func (a *application) completeJobs() 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) {
|
||||
jobs, err := services.Actions{Client: t.client, Repository: t.Repo}.Jobs(ctx, run.Number)
|
||||
return describe(jobs, func(job services.Job) (string, string) {
|
||||
return strconv.FormatInt(job.ID, 10), job.Name + " · " + cmp.Or(job.Conclusion, job.Status)
|
||||
}), err
|
||||
})(cmd, args, toComplete)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
"gitea.dev/tea/internal/terminal"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// actionsNow is the clock of the Actions fixtures.
|
||||
var actionsNow = time.Date(2028, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
const (
|
||||
workflowsFixture = `{"total_count":2,"workflows":[
|
||||
{"id":"ci.yml","name":"CI","path":".gitea/workflows/ci.yml","state":"active","html_url":"https://gitea.test/alice/project/src/branch/main/.gitea/workflows/ci.yml"},
|
||||
{"id":"nightly.yaml","name":"Nightly","path":".gitea/workflows/nightly.yaml","state":"disabled_manually"}]}`
|
||||
queuedRunFixture = `{"id":47,"run_number":10,"display_title":"Add workflows","path":"ci.yml@refs/heads/main","event":"workflow_dispatch",
|
||||
"head_branch":"main","head_sha":"f0c151dde0c0ef6c","status":"queued","run_attempt":1,"actor":{"login":"alice"},
|
||||
"created_at":"2028-01-01T11:58:00Z","started_at":"1970-01-01T00:00:00Z","completed_at":"1970-01-01T00:00:00Z",
|
||||
"html_url":"https://gitea.test/alice/project/actions/runs/47"}`
|
||||
failedRunFixture = `{"id":29,"run_number":9,"display_title":"Refactor the deployment pipeline","path":"nightly.yaml@refs/heads/feature/x",
|
||||
"event":"push","head_branch":"feature/x","head_sha":"209d545ac554","status":"completed","conclusion":"failure","run_attempt":2,
|
||||
"actor":{"login":"bob"},"created_at":"2028-01-01T11:00:00Z","started_at":"2028-01-01T11:00:05Z","completed_at":"2028-01-01T11:01:17Z",
|
||||
"html_url":"https://gitea.test/alice/project/actions/runs/29"}`
|
||||
queuedJobsFixture = `{"total_count":2,"jobs":[
|
||||
{"id":48,"name":"build","status":"queued","labels":["ubuntu-latest"],"html_url":"https://gitea.test/alice/project/actions/runs/47/jobs/48","started_at":"1970-01-01T00:00:00Z","completed_at":"1970-01-01T00:00:00Z"},
|
||||
{"id":49,"name":"test","status":"waiting","labels":["ubuntu-latest"],"started_at":"1970-01-01T00:00:00Z","completed_at":"1970-01-01T00:00:00Z"}]}`
|
||||
failedJobsFixture = `{"total_count":2,"jobs":[
|
||||
{"id":30,"name":"build","status":"completed","conclusion":"success","started_at":"2028-01-01T11:00:05Z","completed_at":"2028-01-01T11:00:50Z",
|
||||
"steps":[{"number":1,"name":"Set up job","status":"completed","conclusion":"success","started_at":"2028-01-01T11:00:05Z","completed_at":"2028-01-01T11:00:07Z"}]},
|
||||
{"id":31,"name":"test","status":"completed","conclusion":"failure","started_at":"2028-01-01T11:00:50Z","completed_at":"2028-01-01T11:01:17Z",
|
||||
"steps":[{"number":1,"name":"Run tests","status":"completed","conclusion":"failure","started_at":"2028-01-01T11:00:51Z","completed_at":"2028-01-01T11:01:16Z"}]}]}`
|
||||
)
|
||||
|
||||
// actionsAPI serves canned responses for alice/project, keyed by method and path below the
|
||||
// repository ("GET actions/runs"). A response that starts with a status code and a space, such as
|
||||
// "404 {...}", is sent with that status. It records the keys of all requests, followed by their
|
||||
// bodies, and fails the test on requests without a response.
|
||||
func actionsAPI(t *testing.T, responses map[string]string) (*httptest.Server, *[]string) {
|
||||
t.Helper()
|
||||
var requests []string
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/api/v1/repos/alice/project"), "/")
|
||||
if payload, _ := io.ReadAll(r.Body); len(payload) > 0 {
|
||||
requests = append(requests, key+" "+string(payload))
|
||||
} else {
|
||||
requests = append(requests, key)
|
||||
}
|
||||
body, ok := responses[key]
|
||||
if !ok {
|
||||
t.Errorf("unexpected request %s", key)
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
return
|
||||
}
|
||||
if status, rest, found := strings.Cut(body, " "); found && len(status) == 3 {
|
||||
if code, err := strconv.Atoi(status); err == nil {
|
||||
w.WriteHeader(code)
|
||||
body = rest
|
||||
}
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server, &requests
|
||||
}
|
||||
|
||||
// actionsDeps returns dependencies for an actionsAPI server at the fixtures' clock, on a terminal
|
||||
// without colors or links when tty is set.
|
||||
func actionsDeps(t *testing.T, serverURL string, tty bool) (Dependencies, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
deps, out, diagnostics := serverDeps(t, serverURL)
|
||||
deps.Getenv = serverEnv(serverURL, map[string]string{"TERM": "dumb"})
|
||||
deps.Now = func() time.Time { return actionsNow }
|
||||
deps.Streams.OutTTY = tty
|
||||
return deps, out, diagnostics
|
||||
}
|
||||
|
||||
func TestRunTimeBoundsUseOneClockReading(t *testing.T) {
|
||||
deps, _, _ := testDeps(t)
|
||||
reads := 0
|
||||
deps.Now = func() time.Time { reads++; return actionsNow }
|
||||
err := Execute(context.Background(), deps, []string{"run", "list", "--since=1h", "--until=2h"})
|
||||
require.ErrorContains(t, err, "--since must not be later than --until")
|
||||
require.Equal(t, 1, reads)
|
||||
}
|
||||
|
||||
func TestRunListShowsStatusWorkflowAndTiming(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{
|
||||
"GET actions/runs": `{"total_count":2,"workflow_runs":[` + queuedRunFixture + `,` + failedRunFixture + `]}`,
|
||||
"GET actions/workflows": workflowsFixture,
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tty bool
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"terminal", true, nil, `Showing 2 of 2 runs in alice/project
|
||||
|
||||
STATUS ID WORKFLOW TITLE BRANCH EVENT ELAPSED AGE
|
||||
* 47 CI Add workflows main workflow_dispatch about 2 minutes ago
|
||||
X 29 Nightly Refactor the deployment pipeline feature/x push 1m 12s about 1 hour ago
|
||||
`},
|
||||
{"piped", false, nil, "queued\t\t47\tCI\tAdd workflows\tmain\tworkflow_dispatch\t\t2028-01-01T11:58:00Z\thttps://gitea.test/alice/project/actions/runs/47\n" +
|
||||
"completed\tfailure\t29\tNightly\tRefactor the deployment pipeline\tfeature/x\tpush\t1m 12s\t2028-01-01T11:00:00Z\thttps://gitea.test/alice/project/actions/runs/29\n"},
|
||||
{"json", false, []string{"--json", "id,workflow,workflow_path,run_number,started_at"}, `[{"id":47,"workflow":"CI","workflow_path":"ci.yml","run_number":10,"started_at":null},` +
|
||||
`{"id":29,"workflow":"Nightly","workflow_path":"nightly.yaml","run_number":9,"started_at":"2028-01-01T11:00:05Z"}]` + "\n"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deps, out, diagnostics := actionsDeps(t, server.URL, tc.tty)
|
||||
require.NoError(t, Execute(context.Background(), deps, append([]string{"run", "list", "-R", "alice/project"}, tc.args...)))
|
||||
if strings.HasPrefix(tc.want, "[") {
|
||||
require.JSONEq(t, tc.want, out.String())
|
||||
} else {
|
||||
require.Equal(t, tc.want, out.String())
|
||||
}
|
||||
require.Empty(t, diagnostics.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunListResolvesWorkflowNamesAndReportsEmptyFilters(t *testing.T) {
|
||||
server, requests := actionsAPI(t, map[string]string{
|
||||
"GET actions/workflows": workflowsFixture,
|
||||
"GET actions/workflows/ci.yml/runs": `{"total_count":0,"workflow_runs":[]}`,
|
||||
})
|
||||
deps, out, diagnostics := actionsDeps(t, server.URL, true)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "list", "-R", "alice/project", "-w", "ci", "-s", "FAILURE"}))
|
||||
require.Empty(t, out.String())
|
||||
require.Equal(t, "no runs match your filters in alice/project\n", diagnostics.String())
|
||||
require.Contains(t, *requests, "GET actions/workflows/ci.yml/runs")
|
||||
requireUsageBeforeRequests(t, "--status must be queued, in_progress, waiting", "run", "list", "--status", "nope")
|
||||
}
|
||||
|
||||
func TestRunListExplainsTheAccountFlag(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{"GET actions/runs": `{"workflow_runs":[]}`})
|
||||
deps, _, diagnostics := actionsDeps(t, server.URL, true)
|
||||
deps.Streams.ErrTTY = true
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "list", "-R", "alice/project", "--user", "alice"}))
|
||||
require.Equal(t, "note: --user selects the account tea acts as (alice); to list the runs someone triggered, use --actor\nno runs in alice/project\n", diagnostics.String())
|
||||
}
|
||||
|
||||
func TestRunViewShowsJobsStepsAndNextSteps(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{
|
||||
"GET actions/workflows": workflowsFixture,
|
||||
"GET actions/runs/47": queuedRunFixture,
|
||||
"GET actions/runs/47/jobs": queuedJobsFixture,
|
||||
"GET actions/runs/29": failedRunFixture,
|
||||
"GET actions/runs/29/jobs": failedJobsFixture,
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tty bool
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"queued", true, []string{"47"}, `* Add workflows CI #10 · run 47
|
||||
queued • main • f0c151d • alice triggered it via workflow_dispatch about 2 minutes ago
|
||||
|
||||
JOBS
|
||||
* build queued (ID 48)
|
||||
* test waiting (ID 49)
|
||||
|
||||
Waiting for a runner for "ubuntu-latest"
|
||||
|
||||
Watch it: tea run watch 47 -R alice/project
|
||||
View this run on Gitea: https://gitea.test/alice/project/actions/runs/47
|
||||
`},
|
||||
{"completed from its URL", true, []string{"{server}/alice/project/actions/runs/29"}, `X Refactor the deployment pipeline Nightly #9 · run 29
|
||||
failure • feature/x • 209d545 • bob triggered it via push about 1 hour ago • took 1m 12s • attempt 2
|
||||
|
||||
JOBS
|
||||
✓ build success 45s (ID 30)
|
||||
✓ Set up job 2s
|
||||
X test failure 27s (ID 31)
|
||||
X Run tests 25s
|
||||
|
||||
Logs of a job: tea run logs 29 -R alice/project --job <id>
|
||||
View this run on Gitea: https://gitea.test/alice/project/actions/runs/29
|
||||
`},
|
||||
{"piped", false, []string{"47"}, "title:\tAdd workflows\nid:\t47\nworkflow:\tCI\nworkflow_path:\tci.yml\nrun_number:\t10\nstatus:\tqueued\nconclusion:\t\n" +
|
||||
"branch:\tmain\ncommit:\tf0c151dde0c0ef6c\nevent:\tworkflow_dispatch\nactor:\talice\nattempt:\t1\ncreated_at:\t2028-01-01T11:58:00Z\nstarted_at:\t\n" +
|
||||
"completed_at:\t\nelapsed:\t\nurl:\thttps://gitea.test/alice/project/actions/runs/47\n--\n" +
|
||||
"queued\t\t48\tbuild\t\thttps://gitea.test/alice/project/actions/runs/47/jobs/48\nwaiting\t\t49\ttest\t\t\n"},
|
||||
{"json with jobs", false, []string{"29", "--json", "id,jobs", "--jq", "[.id, [.jobs[] | .name, .conclusion, .steps[0].name]]"}, `[29,["build","success","Set up job","test","failure","Run tests"]]` + "\n"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deps, out, _ := actionsDeps(t, server.URL, tc.tty)
|
||||
args := append([]string{"run", "view", strings.ReplaceAll(tc.args[0], "{server}", server.URL)}, tc.args[1:]...)
|
||||
if !strings.Contains(tc.args[0], "{server}") {
|
||||
args = append(args, "-R", "alice/project")
|
||||
}
|
||||
require.NoError(t, Execute(context.Background(), deps, args))
|
||||
require.Equal(t, tc.want, out.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// choosingPrompter answers selections with the choice at index choose.
|
||||
type choosingPrompter struct {
|
||||
scriptedPrompter
|
||||
choose int
|
||||
choices []terminal.Choice
|
||||
titles []string
|
||||
}
|
||||
|
||||
func (p *choosingPrompter) Choose(_ context.Context, title string, choices []terminal.Choice, _ string) (string, error) {
|
||||
p.titles = append(p.titles, title)
|
||||
p.choices = choices
|
||||
if p.choose >= len(choices) {
|
||||
return "", errors.New("no such choice")
|
||||
}
|
||||
return choices[p.choose].Value, nil
|
||||
}
|
||||
|
||||
func TestRunCommandsAskForARunWithoutAnArgument(t *testing.T) {
|
||||
server, requests := actionsAPI(t, map[string]string{
|
||||
"GET actions/runs": `{"workflow_runs":[` + failedRunFixture + `,` + queuedRunFixture + `]}`,
|
||||
"GET actions/workflows": workflowsFixture,
|
||||
"GET actions/runs/29/jobs": failedJobsFixture,
|
||||
})
|
||||
deps, out, _ := actionsDeps(t, server.URL, false)
|
||||
prompter := &choosingPrompter{}
|
||||
deps.Prompter = prompter
|
||||
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "jobs", "-R", "alice/project", "--json", "id", "--interactive"}))
|
||||
require.JSONEq(t, `[{"id":30},{"id":31}]`, out.String())
|
||||
require.Equal(t, []string{"Select a run"}, prompter.titles)
|
||||
require.Equal(t, []terminal.Choice{
|
||||
{Label: "X Refactor the deployment pipeline · Nightly · feature/x (run 29, about 1 hour ago)", Value: "29"},
|
||||
{Label: "* Add workflows · CI · main (run 47, about 2 minutes ago)", Value: "47"},
|
||||
}, prompter.choices)
|
||||
|
||||
*requests = nil
|
||||
prompter.choose = 1
|
||||
require.ErrorContains(t, Execute(context.Background(), deps, []string{"run", "watch", "-R", "alice/project"}), "no such choice")
|
||||
require.Equal(t, []string{"GET actions/runs", "GET actions/workflows"}, *requests)
|
||||
require.Equal(t, []terminal.Choice{{Label: "* Add workflows · CI · main (run 47, about 2 minutes ago)", Value: "47"}}, prompter.choices, "watch offers runs in progress")
|
||||
|
||||
deps.Streams.InTTY = false
|
||||
err := Execute(context.Background(), deps, []string{"run", "view", "-R", "alice/project"})
|
||||
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
|
||||
require.ErrorContains(t, err, "missing run ID\nUsage: tea run view [<run-id>] [flags]")
|
||||
}
|
||||
|
||||
func TestRunWatchExitStatusAndTimeout(t *testing.T) {
|
||||
succeeded := `{"id":7,"path":"ci.yml@refs/heads/main","status":"completed","conclusion":"success","created_at":"2028-01-01T11:59:00Z",
|
||||
"started_at":"2028-01-01T11:59:00Z","completed_at":"2028-01-01T11:59:30Z"}`
|
||||
for _, tc := range []struct {
|
||||
name, run, jobs, want, err, progress string
|
||||
code int
|
||||
}{
|
||||
{"success", succeeded, `{"jobs":[]}`, `{"id":7,"conclusion":"success","workflow":"CI"}`, "", "run 7: success\n", 0},
|
||||
{"failure", strings.Replace(succeeded, `"success"`, `"failure"`, 1), `{"jobs":[]}`, `{"id":7,"conclusion":"failure","workflow":"CI"}`, "run 7 (CI) completed with failure", "run 7: failure\n", clierr.Failure},
|
||||
{
|
||||
"timeout", strings.Replace(queuedRunFixture, `"id":47`, `"id":7`, 1), queuedJobsFixture, "",
|
||||
`timed out after 100ms waiting for run 7 (still queued; is a runner registered for "ubuntu-latest"?)`, "run 7: queued (build: queued, test: waiting)\n", clierr.Canceled,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{"GET actions/workflows": workflowsFixture, "GET actions/runs/7": tc.run, "GET actions/runs/7/jobs": tc.jobs})
|
||||
deps, out, diagnostics := actionsDeps(t, server.URL, false)
|
||||
err := Execute(context.Background(), deps, []string{"run", "watch", "7", "-R", "alice/project", "--json=id,conclusion,workflow", "--interval=1ms", "--timeout=100ms"})
|
||||
require.Equal(t, tc.code, clierr.ExitCode(err), "%v", err)
|
||||
if tc.err == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.EqualError(t, err, tc.err)
|
||||
}
|
||||
if tc.want == "" {
|
||||
require.Empty(t, out.String())
|
||||
} else {
|
||||
require.JSONEq(t, tc.want, out.String())
|
||||
}
|
||||
require.Equal(t, tc.progress, diagnostics.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWatchRedrawsOnATerminalAndSummarizes(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{
|
||||
"GET actions/workflows": workflowsFixture,
|
||||
"GET actions/runs/7": `{"id":7,"display_title":"Ship it","path":"ci.yml@refs/heads/main","status":"completed","conclusion":"success",
|
||||
"created_at":"2028-01-01T11:59:00Z","started_at":"2028-01-01T11:59:00Z","completed_at":"2028-01-01T11:59:30Z"}`,
|
||||
"GET actions/runs/7/jobs": `{"jobs":[]}`,
|
||||
})
|
||||
deps, out, diagnostics := actionsDeps(t, server.URL, true)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "watch", "7", "-R", "alice/project", "--interval=2s"}))
|
||||
frame := "✓ Ship it CI · run 7\nsuccess • triggered about 1 minute ago • took 30s\n"
|
||||
require.Equal(t, "\x1b[?1049h\x1b[H\x1b[JRefreshing run status every 2s. Press Ctrl+C to quit.\n\n"+frame+"\x1b[?1049l"+frame, out.String())
|
||||
require.Equal(t, "✓ Run 7 (CI) completed with success in 30s\n", diagnostics.String())
|
||||
}
|
||||
|
||||
func TestRunMutationsReportWhatHappened(t *testing.T) {
|
||||
completedRun := failedRunFixture
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string]string
|
||||
requests []string
|
||||
out, err string
|
||||
prompts []string
|
||||
}{
|
||||
{
|
||||
"cancel without confirmation",
|
||||
[]string{"run", "cancel", "47"},
|
||||
map[string]string{"POST actions/runs/47/cancel": queuedRunFixture},
|
||||
[]string{"POST actions/runs/47/cancel"},
|
||||
"", "✓ Requested cancellation of run 47\n", nil,
|
||||
},
|
||||
{
|
||||
"cancel returns the run",
|
||||
[]string{"run", "cancel", "47", "--json", "id,status"},
|
||||
map[string]string{"POST actions/runs/47/cancel": queuedRunFixture},
|
||||
[]string{"POST actions/runs/47/cancel"},
|
||||
`{"id":47,"status":"queued"}`, "", nil,
|
||||
},
|
||||
{
|
||||
"rerun",
|
||||
[]string{"run", "rerun", "29"},
|
||||
map[string]string{"GET actions/runs/29": completedRun, "POST actions/runs/29/rerun": completedRun},
|
||||
[]string{"GET actions/runs/29", "POST actions/runs/29/rerun", "GET actions/runs/29"},
|
||||
"", "✓ Requested rerun of run 29 (attempt 2); watch it with: tea run watch 29 -R alice/project\n", nil,
|
||||
},
|
||||
{
|
||||
"rerun a job by name",
|
||||
[]string{"run", "rerun", "29", "--job", "TEST"},
|
||||
map[string]string{"GET actions/runs/29": completedRun, "GET actions/runs/29/jobs": failedJobsFixture, "POST actions/runs/29/jobs/31/rerun": `{"id":31}`},
|
||||
[]string{"GET actions/runs/29", "GET actions/runs/29/jobs", "POST actions/runs/29/jobs/31/rerun", "GET actions/runs/29"},
|
||||
"", "✓ Requested rerun of run 29 (attempt 2); watch it with: tea run watch 29 -R alice/project\n", nil,
|
||||
},
|
||||
{
|
||||
"rerun refuses an unfinished run",
|
||||
[]string{"run", "rerun", "47"},
|
||||
map[string]string{"GET actions/runs/47": queuedRunFixture},
|
||||
[]string{"GET actions/runs/47"},
|
||||
"", "", nil,
|
||||
},
|
||||
{
|
||||
"delete after confirmation",
|
||||
[]string{"run", "delete", "29"},
|
||||
map[string]string{"GET actions/workflows": workflowsFixture, "GET actions/runs/29": completedRun, "GET actions/runs/29/jobs": failedJobsFixture, "DELETE actions/runs/29": "204 "},
|
||||
[]string{"GET actions/workflows", "GET actions/runs/29", "GET actions/runs/29/jobs", "DELETE actions/runs/29"},
|
||||
"", "✓ Deleted run 29 from alice/project\n",
|
||||
[]string{"Delete run 29 (Nightly · Refactor the deployment pipeline) from alice/project?"},
|
||||
},
|
||||
{
|
||||
"delete refuses an unfinished run before asking",
|
||||
[]string{"run", "delete", "47"},
|
||||
map[string]string{"GET actions/workflows": workflowsFixture, "GET actions/runs/47": queuedRunFixture, "GET actions/runs/47/jobs": queuedJobsFixture},
|
||||
[]string{"GET actions/workflows", "GET actions/runs/47", "GET actions/runs/47/jobs"},
|
||||
"", "", nil,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server, requests := actionsAPI(t, tc.responses)
|
||||
deps, out, diagnostics := actionsDeps(t, server.URL, true)
|
||||
prompter := &scriptedPrompter{accept: true}
|
||||
deps.Prompter = prompter
|
||||
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
|
||||
err := Execute(context.Background(), deps, append(tc.args, "-R", "alice/project"))
|
||||
if strings.Contains(tc.name, "unfinished") {
|
||||
require.EqualError(t, err, "run 47 is still queued; cancel it first: tea run cancel 47 -R alice/project")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tc.requests, *requests)
|
||||
if strings.HasPrefix(tc.out, "{") {
|
||||
require.JSONEq(t, tc.out, out.String())
|
||||
} else {
|
||||
require.Equal(t, tc.out, out.String())
|
||||
}
|
||||
require.Equal(t, tc.err, diagnostics.String())
|
||||
require.Equal(t, tc.prompts, prompter.confirmations)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobsListsStatusAndElapsedTime(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{"GET actions/runs/29/jobs": failedJobsFixture})
|
||||
deps, out, _ := actionsDeps(t, server.URL, true)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "jobs", "#29", "-R", "alice/project"}))
|
||||
require.Equal(t, "Showing 2 of 2 jobs in run 29\n\nSTATUS ID NAME ELAPSED\n✓ 30 build 45s\nX 31 test 27s\n", out.String())
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -129,161 +128,3 @@ func (a *application) workflowCommand() *cobra.Command {
|
||||
func workflowRow(item services.Workflow) []terminal.Cell {
|
||||
return terminal.Row(item.ID, item.Name, item.State)
|
||||
}
|
||||
|
||||
func runRow(item services.Run) []terminal.Cell {
|
||||
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10), Link: item.URL}, {Text: item.Title}, {Text: item.Status}, {Text: item.Conclusion}, {Text: item.Branch}, {Text: item.Event}}
|
||||
}
|
||||
|
||||
func (a *application) runCommand() *cobra.Command {
|
||||
group := &cobra.Command{Use: "run", Short: "Inspect and watch Actions runs"}
|
||||
group.AddCommand(a.runLogsCommand(), a.artifactCommand(), a.runJobsCommand())
|
||||
var filters services.RunListOptions
|
||||
var started timeRange
|
||||
list := &cobra.Command{Use: "list", Short: "List workflow runs", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var err error
|
||||
if filters.Since, filters.Until, err = started.parse(a.deps.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := filters.Validate(); err != nil {
|
||||
return clierr.Wrap(clierr.Usage, err)
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Runs(cmd.Context(), filters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeList(a, cmd, items, runRow)
|
||||
}}
|
||||
pageFlags(list, &filters.Limit, &filters.Page)
|
||||
started.register(list, "since", "Runs created at or after a date, RFC3339 timestamp, or duration ago (24h)", "until", "Runs created at or before a date, RFC3339 timestamp, or duration ago")
|
||||
list.Flags().StringVar(&filters.Branch, "branch", "", "Filter by branch name")
|
||||
list.Flags().StringVar(&filters.Event, "event", "", "Filter by triggering event")
|
||||
list.Flags().StringVar(&filters.Actor, "actor", "", "Filter by triggering username")
|
||||
list.Flags().StringVar(&filters.Commit, "commit", "", "Filter by triggering commit SHA")
|
||||
list.Flags().StringVar(&filters.Workflow, "workflow", "", "Filter by workflow file name")
|
||||
list.Flags().StringSliceVar(&filters.Statuses, "status", nil, "Filter status: pending, waiting, requested, action_required, queued, in_progress, completed, success, failure, skipped, neutral, cancelled, timed_out") //nolint:misspell // Gitea API spelling.
|
||||
group.AddCommand(list)
|
||||
var includeJobs bool
|
||||
viewRun := &cobra.Command{Use: "view <id>", Short: "View a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
item, err := service.Run(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !includeJobs || a.options.json != "" {
|
||||
return writeItem(a, cmd, item, runRow)
|
||||
}
|
||||
jobs, err := service.Jobs(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobLine := func(job services.Job) []terminal.Cell { return append(terminal.Row("job"), jobRow(job)...) }
|
||||
color := a.colorEnabled()
|
||||
return a.writeHuman(cmd, table([]services.Run{item}, runRow, color)+table(jobs, jobLine, color))
|
||||
}}
|
||||
viewRun.Flags().BoolVar(&includeJobs, "jobs", true, "Include job summaries after the run details")
|
||||
group.AddCommand(viewRun)
|
||||
var interval, timeout time.Duration
|
||||
watch := &cobra.Command{Use: "watch <id>", Short: "Wait for completion; fail if the run fails", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if interval <= 0 || timeout <= 0 {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
|
||||
}
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
|
||||
defer cancel()
|
||||
result, err := (services.Actions{Client: t.client, Repository: t.Repo}).Watch(ctx, id, interval, func(item services.Run) error {
|
||||
if a.options.json != "" {
|
||||
_, err := fmt.Fprintf(cmd.ErrOrStderr(), "run %d: %s %s\n", item.ID, item.Status, item.Conclusion)
|
||||
return err
|
||||
}
|
||||
return writeItem(a, cmd, item, runRow)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.options.json != "" {
|
||||
if err := writeItem(a, cmd, result, runRow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !services.SuccessfulRun(result) {
|
||||
return errors.New("workflow run did not succeed")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
watch.Flags().DurationVar(&interval, "interval", 5*time.Second, "Polling interval")
|
||||
watch.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum time to wait")
|
||||
group.AddCommand(watch)
|
||||
var forceCancel bool
|
||||
cancelRun := &cobra.Command{Use: "cancel <id>", Short: "Cancel a workflow run while preserving its history", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := fmt.Sprintf("Cancel run %d", id)
|
||||
if forceCancel {
|
||||
message = fmt.Sprintf("Force-cancel run %d without waiting for runners", id)
|
||||
}
|
||||
if err := a.confirm(cmd, message); err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := (services.Actions{Client: target.client, Repository: target.Repo}).CancelRun(cmd.Context(), id, forceCancel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeItem(a, cmd, item, runRow)
|
||||
}}
|
||||
cancelRun.Flags().BoolVar(&forceCancel, "force", false, "Cancel without waiting for runner acknowledgment")
|
||||
group.AddCommand(cancelRun)
|
||||
var failedOnly bool
|
||||
rerun := &cobra.Command{Use: "rerun <id>", Short: "Rerun a workflow run", Annotations: textOutput("rerun does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return (services.Actions{Client: t.client, Repository: t.Repo}).Rerun(cmd.Context(), id, failedOnly)
|
||||
}}
|
||||
rerun.Flags().BoolVar(&failedOnly, "failed", false, "Rerun failed jobs only")
|
||||
group.AddCommand(rerun)
|
||||
group.AddCommand(&cobra.Command{Use: "delete <id>", Short: "Delete a workflow run", Annotations: textOutput("run deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.confirm(cmd, fmt.Sprintf("Delete run %d", id)); err != nil {
|
||||
return err
|
||||
}
|
||||
return (services.Actions{Client: t.client, Repository: t.Repo}).DeleteRun(cmd.Context(), id)
|
||||
}})
|
||||
return group
|
||||
}
|
||||
@@ -7,51 +7,11 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunTimeBoundsUseOneClockReading(t *testing.T) {
|
||||
now := time.Date(2028, 2, 1, 12, 0, 0, 0, time.UTC)
|
||||
deps, _, _ := testDeps(t)
|
||||
reads := 0
|
||||
deps.Now = func() time.Time { reads++; return now }
|
||||
err := Execute(context.Background(), deps, []string{"run", "list", "--since=1h", "--until=2h"})
|
||||
require.ErrorContains(t, err, "--since must not be later than --until")
|
||||
require.Equal(t, 1, reads)
|
||||
}
|
||||
|
||||
func TestRunWatchExitAndOutputContracts(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, status, conclusion string
|
||||
code int
|
||||
}{
|
||||
{"success", "completed", "success", 0},
|
||||
{"failed run", "completed", "failure", clierr.Failure},
|
||||
{"timeout", "in_progress", "", clierr.Canceled},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v1/repos/alice/project/actions/runs/7", r.URL.Path)
|
||||
_, _ = w.Write([]byte(`{"id":7,"status":"` + test.status + `","conclusion":"` + test.conclusion + `"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, diagnostics := serverDeps(t, server.URL)
|
||||
err := Execute(context.Background(), deps, []string{"run", "watch", "7", "--repo", "alice/project", "--json=id,status,conclusion", "--interval=1ms", "--timeout=100ms", "--no-input"})
|
||||
require.Equal(t, test.code, clierr.ExitCode(err))
|
||||
if test.code == clierr.Canceled {
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.Empty(t, out.String())
|
||||
} else {
|
||||
require.JSONEq(t, `{"id":7,"status":"completed","conclusion":"`+test.conclusion+`"}`, out.String())
|
||||
}
|
||||
require.Contains(t, diagnostics.String(), "run 7:")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowRunFollowStreamsFirstJobLogs(t *testing.T) {
|
||||
jobPolls := 0
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestSudoSendsHeaderAfterIdentityCheck(t *testing.T) {
|
||||
|
||||
func TestJSONFieldErrorsListAvailableFields(t *testing.T) {
|
||||
requireUsageBeforeRequests(t, "unknown JSON field \"titel\"\navailable fields: assignees, author, body", "issue", "list", "--json", "number,titel")
|
||||
requireUsageBeforeRequests(t, "unknown JSON field \"workflow\"\navailable fields: actor, attempt, branch", "run", "list", "--json", "workflow")
|
||||
requireUsageBeforeRequests(t, "unknown JSON field \"workflw\"\navailable fields: actor, attempt, branch", "run", "list", "--json", "workflw")
|
||||
requireUsageBeforeRequests(t, "--json= needs field names; use --json alone for every field", "pr", "list", "--json=")
|
||||
requireUsageBeforeRequests(t, "version prints text; --jq is not supported", "version", "--jq", ".")
|
||||
}
|
||||
@@ -541,16 +541,6 @@ const (
|
||||
repo set-default
|
||||
repo sync
|
||||
repo view
|
||||
run artifact download
|
||||
run artifact list
|
||||
run cancel
|
||||
run delete
|
||||
run jobs
|
||||
run list
|
||||
run logs
|
||||
run rerun
|
||||
run view
|
||||
run watch
|
||||
search issue
|
||||
search pr
|
||||
search repo
|
||||
@@ -595,8 +585,6 @@ const (
|
||||
release
|
||||
release asset
|
||||
repo
|
||||
run
|
||||
run artifact
|
||||
search
|
||||
secret
|
||||
time
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestArgumentErrorsShowTheUsageLine(t *testing.T) {
|
||||
{[]string{"issue", "view", "1", "2"}, "too many arguments\nUsage: tea issue view <number> [flags]"},
|
||||
{[]string{"issue", "list", "extra"}, "unexpected argument \"extra\"\nUsage: tea issue list [flags]"},
|
||||
{[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout <number> [flags]"},
|
||||
{[]string{"run", "view"}, "missing run ID\nUsage: tea run view <id> [flags]"},
|
||||
{[]string{"run", "view"}, "missing run ID\nUsage: tea run view [<run-id>] [flags]"},
|
||||
{[]string{"release", "asset", "download", "v1"}, "missing asset ID\nUsage: tea release asset download <tag> <asset-id> [flags]"},
|
||||
{[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone <repository> [directory] [flags]"},
|
||||
} {
|
||||
|
||||
+331
-39
@@ -4,58 +4,350 @@
|
||||
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: "Inspect and download Actions artifacts"}
|
||||
group := &cobra.Command{
|
||||
Use: "artifact",
|
||||
Short: "List and download the artifacts of runs",
|
||||
Long: `List the artifacts that a run uploaded and download one as a ZIP archive.
|
||||
|
||||
To download and extract the artifacts of a run by name, use tea run download.`,
|
||||
}
|
||||
var limit, page int
|
||||
list := &cobra.Command{Use: "list <run-id>", Short: "List artifacts belonging to a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
run, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := (services.Actions{Client: target.client, Repository: target.Repo}).Artifacts(cmd.Context(), run, limit, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeList(a, cmd, items, artifactRow)
|
||||
}}
|
||||
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, listPage{Number: page, More: items.More, Total: items.Total, Counted: items.Counted})
|
||||
},
|
||||
}
|
||||
pageFlags(list, &limit, &page)
|
||||
list.ValidArgsFunction = a.completeRunArg()
|
||||
group.AddCommand(list)
|
||||
var output string
|
||||
download := &cobra.Command{Use: "download <artifact-id>", Short: "Download an artifact ZIP without extracting it", Annotations: textOutput("artifact downloads are binary"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
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
|
||||
}
|
||||
body, err := (services.Actions{Client: target.client, Repository: target.Repo}).DownloadArtifact(cmd.Context(), 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")
|
||||
|
||||
var destination downloadFlags
|
||||
download := &cobra.Command{
|
||||
Use: "download <artifact-id>",
|
||||
Short: "Download an artifact as a ZIP archive",
|
||||
Long: "Download an artifact as a ZIP archive without extracting it, named after the artifact by default.",
|
||||
Example: `# download artifact 7 to <name>.zip
|
||||
$ tea run artifact download 7
|
||||
# write it to standard output
|
||||
$ tea run artifact download 7 --output - > build.zip`,
|
||||
Annotations: textOutput("artifact downloads are binary"),
|
||||
Args: argsExact(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
name := ""
|
||||
if destination.output == "" || isDirectory(destination.output) {
|
||||
artifact, err := service.Artifact(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = artifact.Name + ".zip"
|
||||
}
|
||||
path, skip, err := destination.destination(name)
|
||||
if err != nil || skip {
|
||||
return err
|
||||
}
|
||||
body, err := service.DownloadArtifact(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
if err := a.download(cmd, destination, path, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if path != "-" {
|
||||
a.done(cmd, "Downloaded artifact %d to %s", id, path)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
destination.register(download)
|
||||
group.AddCommand(download)
|
||||
return group
|
||||
}
|
||||
|
||||
func artifactRow(item services.Artifact) []terminal.Cell {
|
||||
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), strconv.FormatBool(item.Expired))
|
||||
// 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 = a.completeRunArg()
|
||||
_ = 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)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -18,6 +21,8 @@ func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/repo/actions/artifacts/9":
|
||||
_, _ = w.Write([]byte(`{"id":9,"name":"build"}`))
|
||||
case "/api/v1/repos/owner/repo/actions/artifacts/9/zip":
|
||||
require.Equal(t, "token token", r.Header.Get("Authorization"))
|
||||
http.Redirect(w, r, "/signed?signature=private", http.StatusFound)
|
||||
@@ -31,24 +36,130 @@ func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, diagnostics := serverDeps(t, server.URL)
|
||||
args := []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output-file", "-"}
|
||||
require.NoError(t, Execute(context.Background(), deps, args))
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output-file", "-"}))
|
||||
require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, out.Bytes())
|
||||
require.Empty(t, diagnostics.String())
|
||||
output := filepath.Join(t.TempDir(), "artifact.zip")
|
||||
args[len(args)-1] = output
|
||||
|
||||
dir := t.TempDir()
|
||||
out.Reset()
|
||||
args := []string{"run", "artifact", "download", "#9", "-R", "owner/repo", "--dir", dir}
|
||||
require.NoError(t, Execute(context.Background(), deps, args))
|
||||
data, err := os.ReadFile(output)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "build.zip"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, data)
|
||||
require.Empty(t, out.String())
|
||||
before := requests
|
||||
err = Execute(context.Background(), deps, args)
|
||||
require.ErrorContains(t, err, "already exists")
|
||||
err = Execute(context.Background(), deps, append(args, "--output", filepath.Join(dir, "build.zip")))
|
||||
require.ErrorContains(t, err, "--output and --dir cannot be combined")
|
||||
err = Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output", filepath.Join(dir, "build.zip")})
|
||||
require.ErrorContains(t, err, "already exists; pass --clobber")
|
||||
require.Equal(t, before, requests)
|
||||
args[len(args)-1] = "-"
|
||||
err = Execute(context.Background(), deps, append(args, "--json"))
|
||||
err = Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "-O", "-", "--json"})
|
||||
require.ErrorContains(t, err, "binary")
|
||||
require.Equal(t, before, requests)
|
||||
}
|
||||
|
||||
// zipOf returns a ZIP archive of files, by name.
|
||||
func zipOf(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
var archive bytes.Buffer
|
||||
writer := zip.NewWriter(&archive)
|
||||
for name, content := range files {
|
||||
file, err := writer.Create(name)
|
||||
require.NoError(t, err)
|
||||
_, err = file.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, writer.Close())
|
||||
return archive.String()
|
||||
}
|
||||
|
||||
func TestRunDownloadExtractsArtifactsByName(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{
|
||||
"GET actions/runs/29/artifacts": `{"total_count":3,"artifacts":[{"id":1,"name":"build","size_in_bytes":3},{"id":2,"name":"coverage"},{"id":3,"name":"old","expired":true}]}`,
|
||||
"GET actions/artifacts/1/zip": zipOf(t, map[string]string{"bin/tea": "tea", "README": "read me"}),
|
||||
"GET actions/artifacts/2/zip": zipOf(t, map[string]string{"cover.out": "100%"}),
|
||||
"GET actions/artifacts/4/zip": zipOf(t, map[string]string{"../escape": "x"}),
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
files map[string]string
|
||||
err string
|
||||
out string
|
||||
}{
|
||||
{
|
||||
"every artifact in its own directory", nil,
|
||||
map[string]string{"build/bin/tea": "tea", "build/README": "read me", "coverage/cover.out": "100%"},
|
||||
"",
|
||||
"✓ Downloaded artifact build to {dir}/build\n✓ Downloaded artifact coverage to {dir}/coverage\n! Artifact old has expired; skipped\n",
|
||||
},
|
||||
{"one artifact into the directory", []string{"-n", "coverage"}, map[string]string{"cover.out": "100%"}, "", "✓ Downloaded artifact coverage to {dir}\n"},
|
||||
{
|
||||
"archives",
|
||||
[]string{"--zip", "--name", "build,coverage"},
|
||||
map[string]string{"build.zip": "", "coverage.zip": ""},
|
||||
"",
|
||||
"✓ Downloaded artifact build to {dir}/build.zip\n✓ Downloaded artifact coverage to {dir}/coverage.zip\n",
|
||||
},
|
||||
{"unknown name", []string{"-n", "docs"}, nil, `artifact "docs" not found in run 29 (artifacts: build, coverage, old)`, ""},
|
||||
{"expired", []string{"-n", "old"}, nil, "the artifacts of run 29 have expired", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
deps, _, diagnostics := actionsDeps(t, server.URL, true)
|
||||
err := Execute(context.Background(), deps, append([]string{"run", "download", "29", "-R", "alice/project", "-D", dir}, tc.args...))
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, filepathReplace(tc.out, dir), diagnostics.String())
|
||||
for name, content := range tc.files {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
require.NoError(t, err, name)
|
||||
if content != "" {
|
||||
require.Equal(t, content, string(data), name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("existing files and unsafe paths", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "cover.out"), []byte("mine"), 0o600))
|
||||
deps, _, _ := actionsDeps(t, server.URL, false)
|
||||
err := Execute(context.Background(), deps, []string{"run", "download", "29", "-R", "alice/project", "-D", dir, "-n", "coverage"})
|
||||
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
|
||||
require.ErrorContains(t, err, "cover.out already exists; pass --clobber to replace it or --skip-existing to keep it")
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "download", "29", "-R", "alice/project", "-D", dir, "-n", "coverage", "--skip-existing"}))
|
||||
data, err := os.ReadFile(filepath.Join(dir, "cover.out"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "mine", string(data))
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "download", "29", "-R", "alice/project", "-D", dir, "-n", "coverage", "--clobber"}))
|
||||
data, err = os.ReadFile(filepath.Join(dir, "cover.out"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "100%", string(data))
|
||||
require.ErrorContains(t, extractZip(context.Background(), bytes.NewReader([]byte(zipOf(t, map[string]string{"../escape": "x"}))), dir, false, false), `refusing to extract "../escape"`)
|
||||
_, err = os.Stat(filepath.Join(filepath.Dir(dir), "escape"))
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
}
|
||||
|
||||
// filepathReplace puts dir in place of {dir} in text.
|
||||
func filepathReplace(text, dir string) string {
|
||||
return string(bytes.ReplaceAll([]byte(text), []byte("{dir}"), []byte(dir)))
|
||||
}
|
||||
|
||||
func TestArtifactListShowsSizesAndExpiry(t *testing.T) {
|
||||
server, _ := actionsAPI(t, map[string]string{
|
||||
"GET actions/runs/29/artifacts": `{"total_count":2,"artifacts":[{"id":1,"name":"build","size_in_bytes":3145728,"expires_at":"2028-03-01T12:00:00Z"},{"id":3,"name":"old","size_in_bytes":13,"expired":true,"expires_at":"2027-12-01T12:00:00Z"}]}`,
|
||||
})
|
||||
for tty, want := range map[bool]string{
|
||||
true: "Showing 2 of 2 artifacts of run 29\n\nID NAME SIZE EXPIRES\n1 build 3.0 MiB in about 2 months\n3 old 13 B expired\n",
|
||||
false: "1\tbuild\t3145728\t2028-03-01T12:00:00Z\tfalse\n3\told\t13\t2027-12-01T12:00:00Z\ttrue\n",
|
||||
} {
|
||||
deps, out, _ := actionsDeps(t, server.URL, tty)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "artifact", "list", "29", "-R", "alice/project"}))
|
||||
require.Equal(t, want, out.String(), "tty=%t", tty)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user