mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01: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
+2569
-579
No files matched your search
+43
-10
@@ -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])
|
||||
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
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), true)
|
||||
jobs, err := (services.Actions{Client: t.client, Repository: t.Repo}).JobsPage(cmd.Context(), run, limit, page)
|
||||
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
|
||||
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})
|
||||
},
|
||||
}
|
||||
return writeList(a, cmd, items, jobRow)
|
||||
}}
|
||||
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},
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
-54
@@ -4,11 +4,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
@@ -18,77 +20,106 @@ 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 {
|
||||
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"))
|
||||
}
|
||||
run, err := number(args[0])
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
return writeDownload(cmd, body, output)
|
||||
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
|
||||
}
|
||||
if selected != 0 {
|
||||
found := false
|
||||
for _, job := range jobs {
|
||||
if job.ID == selected {
|
||||
jobs = []services.Job{job}
|
||||
found = true
|
||||
break
|
||||
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 timeoutError(a.followJobLogs(ctx, cmd, service, run, jobs, interval), timeout, "following the logs of run %d", run)
|
||||
},
|
||||
}
|
||||
return err
|
||||
})
|
||||
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)
|
||||
}
|
||||
@@ -107,21 +138,76 @@ func (a *application) runLogsCommand() *cobra.Command {
|
||||
}
|
||||
_, copyErr := io.Copy(out, logs)
|
||||
closeErr := logs.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
if err := cmp.Or(copyErr, closeErr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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 cmd
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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]"},
|
||||
} {
|
||||
|
||||
+313
-21
@@ -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])
|
||||
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
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), true)
|
||||
items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Artifacts(cmd.Context(), run, limit, page)
|
||||
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
|
||||
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})
|
||||
},
|
||||
}
|
||||
return writeList(a, cmd, items, artifactRow)
|
||||
}}
|
||||
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 {
|
||||
|
||||
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
|
||||
}
|
||||
if err := validateDownloadDestination(output); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), true)
|
||||
t, 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)
|
||||
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()
|
||||
return writeDownload(cmd, body, output)
|
||||
}}
|
||||
download.Flags().StringVar(&output, "output-file", "", "Destination file (must not exist), or - for stdout")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,18 +27,62 @@ type Job struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
URL string `json:"url"`
|
||||
Labels []string `json:"labels"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Steps []Step `json:"steps"`
|
||||
}
|
||||
|
||||
// Step is one step of a job. StartedAt and CompletedAt are nil until the step starts and completes.
|
||||
type Step struct {
|
||||
Number int64 `json:"number"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
}
|
||||
|
||||
// Jobs lists every page of jobs in the selected run.
|
||||
// Completed reports whether the job finished, whatever its conclusion.
|
||||
func (j Job) Completed() bool { return completedActionStatus(j.Status) }
|
||||
|
||||
// Jobs lists every job in the selected run.
|
||||
func (s Actions) Jobs(ctx context.Context, run int64) ([]Job, error) {
|
||||
return listAll(func(page int) ([]Job, error) { return s.jobsPage(ctx, run, 100, page) })
|
||||
jobs := []Job{}
|
||||
for page := 1; page <= maxListPages; page++ {
|
||||
window, err := s.JobsPage(ctx, run, 100, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, window.Items...)
|
||||
if !window.More || len(window.Items) == 0 {
|
||||
return jobs, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("listing exceeded %d pages", maxListPages)
|
||||
}
|
||||
|
||||
// JobsPage lists a page of jobs in the selected run.
|
||||
func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) {
|
||||
return s.jobsPage(ctx, run, limit, page)
|
||||
func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) (Page[Job], error) {
|
||||
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.ActionsWorkflowJob, http.Header, error) {
|
||||
response, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) {
|
||||
owner, repo, err := splitRepository(s.Repository)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, headerOf(resp), err
|
||||
}
|
||||
return response.Jobs, headerOf(resp), nil
|
||||
})
|
||||
if err != nil {
|
||||
return Page[Job]{}, err
|
||||
}
|
||||
return Page[Job]{Items: convertAll(window.Items, jobFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil
|
||||
}
|
||||
|
||||
// WaitForFirstJob polls a freshly dispatched run until the server has created its first job.
|
||||
@@ -59,20 +104,18 @@ func (s Actions) WaitForFirstJob(ctx context.Context, run int64, interval time.D
|
||||
}
|
||||
}
|
||||
|
||||
func (s Actions) jobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) {
|
||||
response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) {
|
||||
return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertAll(response.Jobs, jobFrom), nil
|
||||
}
|
||||
|
||||
func jobFrom(job *gitea.ActionsWorkflowJob) Job {
|
||||
return Job{
|
||||
RunAttempt: job.RunAttempt, ID: job.ID, Name: job.Name, Status: job.Status, Conclusion: job.Conclusion,
|
||||
StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt),
|
||||
URL: job.HTMLURL, Labels: append([]string{}, job.Labels...), CreatedAt: timestamp(job.CreatedAt),
|
||||
StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt), Steps: convertAll(job.Steps, stepFrom),
|
||||
}
|
||||
}
|
||||
|
||||
func stepFrom(step *gitea.ActionsWorkflowStep) Step {
|
||||
return Step{
|
||||
Number: step.Number, Name: step.Name, Status: step.Status, Conclusion: step.Conclusion,
|
||||
StartedAt: actionTime(step.StartedAt), CompletedAt: actionTime(step.CompletedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,9 +230,11 @@ func (s Actions) FollowJobLogs(ctx context.Context, run, id int64, interval time
|
||||
}
|
||||
}
|
||||
|
||||
// RunLogsArchive streams the server's ZIP archive without extracting its entries.
|
||||
// RunLogsArchive streams the server's ZIP archive without extracting its entries. A run without
|
||||
// logs, such as one that never started, is reported as not found.
|
||||
func (s Actions) RunLogsArchive(ctx context.Context, run int64) (io.ReadCloser, error) {
|
||||
return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) {
|
||||
body, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) {
|
||||
return c.Actions.GetRepoRunLogsReader(ctx, owner, repo, run)
|
||||
})
|
||||
return body, Classify(err, "logs of run", strconv.FormatInt(run, 10), s.Repository)
|
||||
}
|
||||
@@ -110,11 +110,20 @@ func TestJobsPageMapsJobsAndSendsPagination(t *testing.T) {
|
||||
require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/jobs", r.URL.Path)
|
||||
require.Equal(t, "20", r.URL.Query().Get("limit"))
|
||||
require.Equal(t, "3", r.URL.Query().Get("page"))
|
||||
_, _ = w.Write([]byte(`{"total_count":1,"jobs":[{"id":9,"run_attempt":2,"name":"build","status":"completed","conclusion":"success","runner_name":"excluded"}]}`))
|
||||
w.Header().Set("X-Total-Count", "41")
|
||||
_, _ = w.Write([]byte(`{"total_count":41,"jobs":[{"id":9,"run_attempt":2,"name":"build","status":"completed","conclusion":"success","runner_name":"excluded",
|
||||
"labels":["ubuntu-latest"],"html_url":"https://gitea.test/owner/repo/actions/runs/7/jobs/9","created_at":"2028-01-01T00:00:00Z",
|
||||
"started_at":"2028-01-01T00:00:01Z","completed_at":"1970-01-01T00:00:00Z",
|
||||
"steps":[{"number":1,"name":"Set up job","status":"completed","conclusion":"success","started_at":"2028-01-01T00:00:01Z","completed_at":"2028-01-01T00:00:02Z"}]}]}`))
|
||||
}))
|
||||
jobs, err := (Actions{Client: client, Repository: "owner/repo"}).JobsPage(context.Background(), 7, 20, 3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []Job{{RunAttempt: 2, ID: 9, Name: "build", Status: "completed", Conclusion: "success"}}, jobs)
|
||||
started, stepStarted, stepCompleted := "2028-01-01T00:00:01Z", "2028-01-01T00:00:01Z", "2028-01-01T00:00:02Z"
|
||||
require.Equal(t, Page[Job]{Items: []Job{{
|
||||
RunAttempt: 2, ID: 9, Name: "build", Status: "completed", Conclusion: "success", URL: "https://gitea.test/owner/repo/actions/runs/7/jobs/9",
|
||||
Labels: []string{"ubuntu-latest"}, CreatedAt: "2028-01-01T00:00:00Z", StartedAt: &started,
|
||||
Steps: []Step{{Number: 1, Name: "Set up job", Status: "completed", Conclusion: "success", StartedAt: &stepStarted, CompletedAt: &stepCompleted}},
|
||||
}}, Total: 41, Counted: true}, jobs)
|
||||
}
|
||||
|
||||
func TestRunLogsArchiveStreams(t *testing.T) {
|
||||
@@ -151,7 +160,8 @@ func TestWaitForFirstJobPollsUntilJobsExist(t *testing.T) {
|
||||
defer cancel()
|
||||
job, err := service.WaitForFirstJob(ctx, 7, time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Job{ID: 11, Name: "build"}, job)
|
||||
require.Equal(t, int64(11), job.ID)
|
||||
require.Equal(t, "build", job.Name)
|
||||
_, err = service.WaitForFirstJob(ctx, 7, 0)
|
||||
require.ErrorContains(t, err, "interval must be positive")
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
)
|
||||
|
||||
// Run is the stable command representation of an Actions run. StartedAt and CompletedAt are nil
|
||||
// until the run starts and completes. Workflow is the workflow's name and WorkflowPath its file
|
||||
// name; Jobs is only filled where commands show a single run.
|
||||
type Run struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
ID int64 `json:"id"`
|
||||
RunNumber int64 `json:"run_number"`
|
||||
Title string `json:"title"`
|
||||
Workflow string `json:"workflow"`
|
||||
WorkflowPath string `json:"workflow_path"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
URL string `json:"url"`
|
||||
Branch string `json:"branch"`
|
||||
Event string `json:"event,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Attempt int64 `json:"attempt,omitempty"`
|
||||
Jobs []Job `json:"jobs,omitempty"`
|
||||
}
|
||||
|
||||
// Completed reports whether the run finished, whatever its conclusion.
|
||||
func (r Run) Completed() bool { return completedActionStatus(r.Status) }
|
||||
|
||||
type runResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
ID int64 `json:"id"`
|
||||
RunNumber int64 `json:"run_number"`
|
||||
DisplayTitle string `json:"display_title"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
Event string `json:"event"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
RunAttempt int64 `json:"run_attempt"`
|
||||
Actor struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"actor"`
|
||||
}
|
||||
|
||||
// result converts the response; until the run is named after its workflow, Workflow is the
|
||||
// workflow's file name without extension.
|
||||
func (r runResponse) result() Run {
|
||||
file, _, _ := strings.Cut(r.Path, "@")
|
||||
return Run{
|
||||
ID: r.ID, RunNumber: r.RunNumber, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL,
|
||||
Branch: r.HeadBranch, Workflow: strings.TrimSuffix(file, path.Ext(file)), WorkflowPath: file,
|
||||
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: runTime(r.StartedAt), CompletedAt: runTime(r.CompletedAt),
|
||||
Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt,
|
||||
}
|
||||
}
|
||||
|
||||
// runTime returns an Actions timestamp, or nil for the zero or Unix epoch time Gitea reports
|
||||
// for a run or job that has not started or completed.
|
||||
func runTime(raw string) *string {
|
||||
t, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if raw == "" || (err == nil && t.Unix() <= 0) {
|
||||
return nil
|
||||
}
|
||||
return &raw
|
||||
}
|
||||
|
||||
// RunListOptions supplies server-side run filters and pagination. Workflow is a workflow file
|
||||
// name.
|
||||
type RunListOptions struct {
|
||||
Since, Until *time.Time
|
||||
Limit, Page int
|
||||
Branch, Event, Actor, Commit, Workflow string
|
||||
Statuses []string
|
||||
}
|
||||
|
||||
// RunStatuses are the values Gitea accepts as run status filters.
|
||||
var RunStatuses = []string{"queued", "in_progress", "waiting", "requested", "pending", "action_required", "completed", "success", "failure", "cancelled", "skipped", "timed_out", "neutral"} //nolint:misspell // Gitea API spelling.
|
||||
|
||||
// Validate rejects unsupported status filters before performing API requests.
|
||||
func (o RunListOptions) Validate() error {
|
||||
if o.Limit < 1 || o.Limit > 1000 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit {
|
||||
return errors.New("invalid pagination")
|
||||
}
|
||||
for _, status := range o.Statuses {
|
||||
if !slices.Contains(RunStatuses, status) {
|
||||
return fmt.Errorf("unsupported run status %q; see tea run list --help", status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxRunScanPages = 50
|
||||
|
||||
// Runs lists a page of runs, newest first.
|
||||
func (s Actions) Runs(ctx context.Context, opts RunListOptions) ([]Run, error) {
|
||||
page, err := s.runs(ctx, opts)
|
||||
return page.Items, err
|
||||
}
|
||||
|
||||
// RunsPage lists a page of runs like Runs, named after their workflows, with the number of runs
|
||||
// the filters match when the server counts them.
|
||||
func (s Actions) RunsPage(ctx context.Context, opts RunListOptions) (Page[Run], error) {
|
||||
page, err := s.runs(ctx, opts)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
return page, s.nameRuns(ctx, page.Items)
|
||||
}
|
||||
|
||||
func (s Actions) runs(ctx context.Context, opts RunListOptions) (Page[Run], error) {
|
||||
if err := opts.Validate(); err != nil {
|
||||
return Page[Run]{}, err
|
||||
}
|
||||
if opts.Since == nil && opts.Until == nil {
|
||||
return s.runsPage(ctx, opts)
|
||||
}
|
||||
first, last := (opts.Page-1)*opts.Limit, opts.Page*opts.Limit
|
||||
result := []Run{}
|
||||
matched := 0
|
||||
for page := 1; page <= maxRunScanPages; page++ {
|
||||
scan := opts
|
||||
scan.Limit = 100
|
||||
scan.Page = page
|
||||
window, err := s.runsPage(ctx, scan)
|
||||
if err != nil {
|
||||
return Page[Run]{}, err
|
||||
}
|
||||
for _, item := range window.Items {
|
||||
created, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
|
||||
if err != nil {
|
||||
return Page[Run]{}, errors.New("server returned an invalid run creation timestamp")
|
||||
}
|
||||
// Runs are listed newest first, so the rest were created earlier still.
|
||||
if opts.Since != nil && created.Before(*opts.Since) {
|
||||
return Page[Run]{Items: result}, nil
|
||||
}
|
||||
if opts.Until != nil && created.After(*opts.Until) {
|
||||
continue
|
||||
}
|
||||
if matched >= last {
|
||||
return Page[Run]{Items: result, More: true}, nil
|
||||
}
|
||||
if matched >= first {
|
||||
result = append(result, item)
|
||||
}
|
||||
matched++
|
||||
}
|
||||
if len(window.Items) == 0 || !window.More {
|
||||
return Page[Run]{Items: result}, nil
|
||||
}
|
||||
}
|
||||
return Page[Run]{}, fmt.Errorf("time filters scanned %d runs without exhausting the history; narrow the search with --branch, --workflow or --status", maxRunScanPages*100)
|
||||
}
|
||||
|
||||
func (s Actions) runsPage(ctx context.Context, opts RunListOptions) (Page[Run], error) {
|
||||
query := url.Values{}
|
||||
for key, value := range map[string]string{"branch": opts.Branch, "event": opts.Event, "actor": opts.Actor, "head_sha": opts.Commit} {
|
||||
if value != "" {
|
||||
query.Set(key, value)
|
||||
}
|
||||
}
|
||||
for _, status := range opts.Statuses {
|
||||
query.Add("status", status)
|
||||
}
|
||||
endpoint := s.endpoint() + "runs"
|
||||
if opts.Workflow != "" {
|
||||
endpoint = s.endpoint() + "workflows/" + url.PathEscape(opts.Workflow) + "/runs"
|
||||
}
|
||||
return listPage(opts.Limit, opts.Page, func(limit, page int) ([]Run, http.Header, error) {
|
||||
query.Set("limit", strconv.Itoa(limit))
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
var response struct {
|
||||
Runs []runResponse `json:"workflow_runs"`
|
||||
}
|
||||
// TODO(sdk): ListRepoActionsRunsOptions takes one status and ActionsWorkflowRun lacks created_at/updated_at.
|
||||
headers, err := rawJSON(ctx, s.Client, http.MethodGet, endpoint+"?"+query.Encode(), nil, &response)
|
||||
runs := make([]Run, len(response.Runs))
|
||||
for i, item := range response.Runs {
|
||||
runs[i] = item.result()
|
||||
}
|
||||
return runs, headers, err
|
||||
})
|
||||
}
|
||||
|
||||
// nameRuns names runs after their workflows.
|
||||
func (s Actions) nameRuns(ctx context.Context, runs []Run) error {
|
||||
if len(runs) == 0 {
|
||||
return nil
|
||||
}
|
||||
names, err := s.workflowNames(ctx)
|
||||
for i := range runs {
|
||||
if name := names[runs[i].WorkflowPath]; name != "" {
|
||||
runs[i].Workflow = name
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Actions) workflowNames(ctx context.Context) (map[string]string, error) {
|
||||
workflows, err := s.Workflows(ctx)
|
||||
names := make(map[string]string, len(workflows))
|
||||
for _, workflow := range workflows {
|
||||
names[workflow.ID] = workflow.Name
|
||||
}
|
||||
return names, err
|
||||
}
|
||||
|
||||
// Run retrieves one Actions run.
|
||||
func (s Actions) Run(ctx context.Context, id int64) (Run, error) {
|
||||
var response runResponse
|
||||
// TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at.
|
||||
err := getJSON(ctx, s.Client, fmt.Sprintf("%sruns/%d", s.endpoint(), id), &response)
|
||||
return response.result(), err
|
||||
}
|
||||
|
||||
// RunDetails retrieves one run with its jobs, named after its workflow.
|
||||
func (s Actions) RunDetails(ctx context.Context, id int64) (Run, error) {
|
||||
names, err := s.workflowNames(ctx)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return s.runDetails(ctx, id, names)
|
||||
}
|
||||
|
||||
func (s Actions) runDetails(ctx context.Context, id int64, names map[string]string) (Run, error) {
|
||||
run, err := s.Run(ctx, id)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if run.Jobs, err = s.Jobs(ctx, id); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if name := names[run.WorkflowPath]; name != "" {
|
||||
run.Workflow = name
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// Rerun schedules another attempt of a run, restricted to its failed jobs with failedOnly, or of
|
||||
// one job when job is not 0.
|
||||
func (s Actions) Rerun(ctx context.Context, id, job int64, failedOnly bool) error {
|
||||
err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
|
||||
switch {
|
||||
case job != 0:
|
||||
_, resp, err := c.Actions.RerunRepoRunJob(ctx, owner, repo, id, job)
|
||||
return resp, err
|
||||
case failedOnly:
|
||||
return c.Actions.RerunRepoRunFailedJobs(ctx, owner, repo, id)
|
||||
}
|
||||
_, resp, err := c.Actions.RerunRepoRun(ctx, owner, repo, id)
|
||||
return resp, err
|
||||
})
|
||||
return Classify(err, "run", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
|
||||
// DeleteRun removes the selected run.
|
||||
func (s Actions) DeleteRun(ctx context.Context, id int64) error {
|
||||
err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
|
||||
return c.Actions.DeleteRepoRun(ctx, owner, repo, id)
|
||||
})
|
||||
return Classify(err, "run", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
|
||||
func completedActionStatus(status string) bool {
|
||||
switch status {
|
||||
case "completed", "success", "failure", "canceled", "cancelled", "skipped": //nolint:misspell // Gitea API spelling.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SuccessfulRun recognizes a successful terminal run result.
|
||||
func SuccessfulRun(run Run) bool {
|
||||
return completedActionStatus(run.Status) && (run.Conclusion == "success" || run.Status == "success")
|
||||
}
|
||||
|
||||
// Watch polls a run with its jobs every interval until it completes, reporting each poll to
|
||||
// update, and returns the completed run. When it fails, such as when ctx expires, it returns the
|
||||
// run as last polled.
|
||||
func (s Actions) Watch(ctx context.Context, id int64, interval time.Duration, update func(Run) error) (Run, error) {
|
||||
if interval <= 0 {
|
||||
return Run{}, errors.New("watch interval must be positive")
|
||||
}
|
||||
names, err := s.workflowNames(ctx)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
var last Run
|
||||
for {
|
||||
current, err := s.runDetails(ctx, id, names)
|
||||
if err != nil {
|
||||
return last, err
|
||||
}
|
||||
last = current
|
||||
if update != nil {
|
||||
if err := update(current); err != nil {
|
||||
return last, err
|
||||
}
|
||||
}
|
||||
if current.Completed() {
|
||||
return current, nil
|
||||
}
|
||||
if err := sleepCtx(ctx, interval); err != nil {
|
||||
return last, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CancelRun requests cancellation without deleting the run or its history.
|
||||
// Force cancellation is an explicit operation for unresponsive runners.
|
||||
func (s Actions) CancelRun(ctx context.Context, id int64, force bool) (Run, error) {
|
||||
action := "cancel"
|
||||
if force {
|
||||
action = "force-cancel"
|
||||
}
|
||||
var response runResponse
|
||||
// TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at.
|
||||
err := sendJSON(ctx, s.Client, http.MethodPost, fmt.Sprintf("%sruns/%d/%s", s.endpoint(), id, action), nil, &response)
|
||||
if err != nil {
|
||||
return Run{}, Classify(err, "run", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
return response.result(), nil
|
||||
}
|
||||
+154
-228
@@ -5,82 +5,43 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/internal/transport"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Workflow is the stable command representation of an Actions workflow.
|
||||
// Workflow is the stable command representation of an Actions workflow. ID is its file name,
|
||||
// such as ci.yml.
|
||||
type Workflow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
State string `json:"state"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func workflowFrom(workflow *gitea.ActionsWorkflow) Workflow {
|
||||
return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State}
|
||||
return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State, URL: workflow.HTMLURL}
|
||||
}
|
||||
|
||||
// Run is the stable command representation of an Actions run. StartedAt and CompletedAt are nil
|
||||
// until the run starts and completes.
|
||||
type Run struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
URL string `json:"url"`
|
||||
Branch string `json:"branch"`
|
||||
Event string `json:"event,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Attempt int64 `json:"attempt,omitempty"`
|
||||
}
|
||||
type runResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
ID int64 `json:"id"`
|
||||
DisplayTitle string `json:"display_title"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
Event string `json:"event"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
RunAttempt int64 `json:"run_attempt"`
|
||||
Actor struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"actor"`
|
||||
}
|
||||
// Enabled reports whether the workflow runs; Gitea reports a disabled one as disabled_manually.
|
||||
func (w Workflow) Enabled() bool { return !strings.HasPrefix(w.State, "disabled") }
|
||||
|
||||
func (r runResponse) result() Run {
|
||||
return Run{
|
||||
ID: r.ID, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL, Branch: r.HeadBranch,
|
||||
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: runTime(r.StartedAt), CompletedAt: runTime(r.CompletedAt),
|
||||
Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt,
|
||||
// Label names the workflow for people: "CI (ci.yml)".
|
||||
func (w Workflow) Label() string {
|
||||
if w.Name == "" || w.Name == w.ID {
|
||||
return w.ID
|
||||
}
|
||||
}
|
||||
|
||||
// runTime returns an Actions timestamp, or nil for the zero or Unix epoch time Gitea reports
|
||||
// for a run or job that has not started or completed.
|
||||
func runTime(raw string) *string {
|
||||
t, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if raw == "" || (err == nil && t.Unix() <= 0) {
|
||||
return nil
|
||||
}
|
||||
return &raw
|
||||
return w.Name + " (" + w.ID + ")"
|
||||
}
|
||||
|
||||
// DispatchResult identifies a workflow run created by dispatch when the server returns details.
|
||||
@@ -108,20 +69,62 @@ func (s Actions) Workflows(ctx context.Context) ([]Workflow, error) {
|
||||
return convertAll(response.Workflows, workflowFrom), nil
|
||||
}
|
||||
|
||||
// Workflow retrieves one workflow definition.
|
||||
// Workflow retrieves one workflow definition by its file name.
|
||||
func (s Actions) Workflow(ctx context.Context, id string) (Workflow, error) {
|
||||
return repoConvert(ctx, s.Client, s.Repository, workflowFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflow, *gitea.Response, error) {
|
||||
return c.Actions.GetRepoWorkflow(ctx, owner, repo, id)
|
||||
})
|
||||
}
|
||||
|
||||
// FindWorkflow returns the workflow that raw names in the repository: its file name, path, file
|
||||
// name without extension, or name, matched exactly and then case-insensitively.
|
||||
func (s Actions) FindWorkflow(ctx context.Context, raw string) (Workflow, error) {
|
||||
items, err := s.Workflows(ctx)
|
||||
if err != nil {
|
||||
return Workflow{}, err
|
||||
}
|
||||
return FindWorkflow(items, raw, s.Repository)
|
||||
}
|
||||
|
||||
// FindWorkflow returns the workflow among items that raw names, like Actions.FindWorkflow. It
|
||||
// fails with a *NotFoundError when none matches and lists the files when several names match.
|
||||
func FindWorkflow(items []Workflow, raw, scope string) (Workflow, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
keys := func(w Workflow) []string {
|
||||
return []string{w.ID, w.Path, strings.TrimSuffix(w.ID, path.Ext(w.ID)), w.Name}
|
||||
}
|
||||
for _, fold := range []bool{false, true} {
|
||||
var matches []Workflow
|
||||
for _, item := range items {
|
||||
if slices.ContainsFunc(keys(item), func(key string) bool { return key == raw || (fold && strings.EqualFold(key, raw)) }) {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
continue
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
}
|
||||
files := make([]string, len(matches))
|
||||
for i, match := range matches {
|
||||
files[i] = match.ID
|
||||
}
|
||||
return Workflow{}, fmt.Errorf("workflow %q is ambiguous in %s (%s); pass the file name", raw, scope, strings.Join(files, ", "))
|
||||
}
|
||||
return Workflow{}, NotFound("workflow", strconv.Quote(raw), scope)
|
||||
}
|
||||
|
||||
// Dispatch triggers a workflow and reports the run it created.
|
||||
func (s Actions) Dispatch(ctx context.Context, id, ref string, inputs map[string]string) (DispatchResult, error) {
|
||||
details, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.RunDetails, *gitea.Response, error) {
|
||||
return c.Actions.DispatchRepoWorkflow(ctx, owner, repo, id, gitea.CreateActionsWorkflowDispatchOption{Ref: ref, Inputs: inputs}, true)
|
||||
})
|
||||
if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound && strings.Contains(api.Message, "ref") {
|
||||
return DispatchResult{}, &NotFoundError{Kind: "ref", Key: strconv.Quote(ref), Scope: s.Repository, Err: err}
|
||||
}
|
||||
if err != nil {
|
||||
return DispatchResult{}, err
|
||||
return DispatchResult{}, Classify(err, "workflow", id, s.Repository)
|
||||
}
|
||||
return DispatchResult{ID: details.WorkflowRunID, URL: details.HTMLURL}, nil
|
||||
}
|
||||
@@ -136,181 +139,104 @@ func (s Actions) SetWorkflowEnabled(ctx context.Context, id string, enabled bool
|
||||
})
|
||||
}
|
||||
|
||||
// RunListOptions supplies server-side run filters and pagination.
|
||||
type RunListOptions struct {
|
||||
Since, Until *time.Time
|
||||
Limit, Page int
|
||||
Branch, Event, Actor, Commit, Workflow string
|
||||
Statuses []string
|
||||
// WorkflowFile returns the workflow's YAML definition at ref, a branch, tag or commit, or on the
|
||||
// default branch when ref is empty.
|
||||
func (s Actions) WorkflowFile(ctx context.Context, workflow Workflow, ref string) ([]byte, error) {
|
||||
contents, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ContentsResponse, *gitea.Response, error) {
|
||||
return c.Repositories.GetContents(ctx, owner, repo, ref, workflow.Path)
|
||||
})
|
||||
if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound {
|
||||
// Gitea reports an unknown ref as "object does not exist [id: REF, rel_path: ]".
|
||||
if ref != "" && strings.Contains(api.Message, "[id: "+ref+",") {
|
||||
return nil, &NotFoundError{Kind: "ref", Key: strconv.Quote(ref), Scope: s.Repository, Err: err}
|
||||
}
|
||||
return nil, &NotFoundError{Kind: "workflow file", Key: workflow.Path, Scope: s.Repository, Err: err}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contents.Content == nil {
|
||||
return nil, fmt.Errorf("%s is not a file in %s", workflow.Path, s.Repository)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(*contents.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid contents of %s: %w", workflow.Path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Validate rejects unsupported status filters before performing API requests.
|
||||
func (o RunListOptions) Validate() error {
|
||||
if o.Limit < 1 || o.Limit > 100 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit {
|
||||
return errors.New("invalid pagination")
|
||||
// WorkflowInput is one input that a workflow's workflow_dispatch trigger declares.
|
||||
type WorkflowInput struct {
|
||||
Name, Description string
|
||||
// Type is string, choice, boolean, number or environment; empty means string.
|
||||
Type, Default string
|
||||
Required bool
|
||||
Options []string
|
||||
}
|
||||
for _, status := range o.Statuses {
|
||||
switch status {
|
||||
case "pending", "waiting", "requested", "action_required", "queued", "in_progress", "completed", "failure", "success", "skipped", "neutral", "cancelled", "timed_out": //nolint:misspell // Gitea API spelling.
|
||||
default:
|
||||
return fmt.Errorf("unsupported run status %q; see tea run list --help", status)
|
||||
|
||||
// ErrNoDispatch reports a workflow that has no workflow_dispatch trigger.
|
||||
var ErrNoDispatch = errors.New("workflow has no workflow_dispatch trigger")
|
||||
|
||||
// DispatchInputs parses the inputs that the workflow_dispatch trigger of a workflow definition
|
||||
// declares, in their order. It fails with ErrNoDispatch when the workflow cannot be dispatched.
|
||||
func DispatchInputs(definition []byte) ([]WorkflowInput, error) {
|
||||
var workflow struct {
|
||||
On yaml.Node `yaml:"on"`
|
||||
}
|
||||
if err := yaml.Unmarshal(definition, &workflow); err != nil {
|
||||
return nil, fmt.Errorf("invalid workflow definition: %w", err)
|
||||
}
|
||||
on := workflow.On
|
||||
switch on.Kind {
|
||||
case yaml.ScalarNode:
|
||||
if on.Value == "workflow_dispatch" {
|
||||
return []WorkflowInput{}, nil
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
if slices.ContainsFunc(on.Content, func(event *yaml.Node) bool { return event.Value == "workflow_dispatch" }) {
|
||||
return []WorkflowInput{}, nil
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
if trigger := mappingValue(&on, "workflow_dispatch"); trigger != nil {
|
||||
return dispatchInputs(mappingValue(trigger, "inputs"))
|
||||
}
|
||||
}
|
||||
return nil, ErrNoDispatch
|
||||
}
|
||||
|
||||
func dispatchInputs(node *yaml.Node) ([]WorkflowInput, error) {
|
||||
inputs := []WorkflowInput{}
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return inputs, nil
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
var spec struct {
|
||||
Description string `yaml:"description"`
|
||||
Type string `yaml:"type"`
|
||||
Default yaml.Node `yaml:"default"`
|
||||
Required bool `yaml:"required"`
|
||||
Options []string `yaml:"options"`
|
||||
}
|
||||
if err := node.Content[i+1].Decode(&spec); err != nil {
|
||||
return nil, fmt.Errorf("invalid workflow input %s: %w", node.Content[i].Value, err)
|
||||
}
|
||||
inputs = append(inputs, WorkflowInput{
|
||||
Name: node.Content[i].Value, Description: spec.Description, Type: spec.Type,
|
||||
Default: spec.Default.Value, Required: spec.Required, Options: spec.Options,
|
||||
})
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
// mappingValue returns the value of key in a YAML mapping, or nil.
|
||||
func mappingValue(node *yaml.Node, key string) *yaml.Node {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
return node.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxRunScanPages = 50
|
||||
|
||||
// Runs lists a page of runs.
|
||||
func (s Actions) Runs(ctx context.Context, opts RunListOptions) ([]Run, error) {
|
||||
if err := opts.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.Since != nil || opts.Until != nil {
|
||||
first, last := (opts.Page-1)*opts.Limit, opts.Page*opts.Limit
|
||||
result := []Run{}
|
||||
matched := 0
|
||||
for page := 1; page <= maxRunScanPages; page++ {
|
||||
scan := opts
|
||||
scan.Limit = 100
|
||||
scan.Page = page
|
||||
items, err := s.runsPage(ctx, scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
for _, item := range items {
|
||||
created, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, errors.New("server returned an invalid run creation timestamp")
|
||||
}
|
||||
// Runs are listed newest first, so the rest were created earlier still.
|
||||
if opts.Since != nil && created.Before(*opts.Since) {
|
||||
return result, nil
|
||||
}
|
||||
if opts.Until != nil && created.After(*opts.Until) {
|
||||
continue
|
||||
}
|
||||
if matched >= first {
|
||||
result = append(result, item)
|
||||
}
|
||||
matched++
|
||||
if matched >= last {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("time filters scanned %d runs without exhausting the history; narrow the search with --branch, --workflow or --status", maxRunScanPages*100)
|
||||
}
|
||||
return s.runsPage(ctx, opts)
|
||||
}
|
||||
|
||||
func (s Actions) runsPage(ctx context.Context, opts RunListOptions) ([]Run, error) {
|
||||
var response struct {
|
||||
Runs []runResponse `json:"workflow_runs"`
|
||||
}
|
||||
query := url.Values{"limit": {strconv.Itoa(opts.Limit)}, "page": {strconv.Itoa(opts.Page)}}
|
||||
for key, value := range map[string]string{"branch": opts.Branch, "event": opts.Event, "actor": opts.Actor, "head_sha": opts.Commit} {
|
||||
if value != "" {
|
||||
query.Set(key, value)
|
||||
}
|
||||
}
|
||||
for _, status := range opts.Statuses {
|
||||
query.Add("status", status)
|
||||
}
|
||||
endpoint := s.endpoint() + "runs"
|
||||
if opts.Workflow != "" {
|
||||
endpoint = s.endpoint() + "workflows/" + url.PathEscape(opts.Workflow) + "/runs"
|
||||
}
|
||||
// TODO(sdk): ListRepoActionsRunsOptions takes one status and ActionsWorkflowRun lacks created_at/updated_at.
|
||||
if err := getJSON(ctx, s.Client, endpoint+"?"+query.Encode(), &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]Run, 0, len(response.Runs))
|
||||
for _, item := range response.Runs {
|
||||
result = append(result, item.result())
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Run retrieves one Actions run.
|
||||
func (s Actions) Run(ctx context.Context, id int64) (Run, error) {
|
||||
var response runResponse
|
||||
// TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at.
|
||||
err := getJSON(ctx, s.Client, fmt.Sprintf("%sruns/%d", s.endpoint(), id), &response)
|
||||
return response.result(), err
|
||||
}
|
||||
|
||||
// Rerun schedules another attempt, optionally restricted to failed jobs.
|
||||
func (s Actions) Rerun(ctx context.Context, id int64, failedOnly bool) error {
|
||||
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
|
||||
if failedOnly {
|
||||
return c.Actions.RerunRepoRunFailedJobs(ctx, owner, repo, id)
|
||||
}
|
||||
_, resp, err := c.Actions.RerunRepoRun(ctx, owner, repo, id)
|
||||
return resp, err
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRun removes the selected run.
|
||||
func (s Actions) DeleteRun(ctx context.Context, id int64) error {
|
||||
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
|
||||
return c.Actions.DeleteRepoRun(ctx, owner, repo, id)
|
||||
})
|
||||
}
|
||||
|
||||
func completedActionStatus(status string) bool {
|
||||
switch status {
|
||||
case "completed", "success", "failure", "canceled", "cancelled", "skipped": //nolint:misspell // Gitea API spelling.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SuccessfulRun recognizes a successful terminal run result.
|
||||
func SuccessfulRun(run Run) bool {
|
||||
return completedActionStatus(run.Status) && (run.Conclusion == "success" || run.Status == "success")
|
||||
}
|
||||
|
||||
// Watch polls with cancellation and reports only changed run state.
|
||||
func (s Actions) Watch(ctx context.Context, id int64, interval time.Duration, changed func(Run) error) (Run, error) {
|
||||
if interval <= 0 {
|
||||
return Run{}, errors.New("watch interval must be positive")
|
||||
}
|
||||
last := Run{}
|
||||
for {
|
||||
current, err := s.Run(ctx, id)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if current != last {
|
||||
if changed != nil {
|
||||
if err := changed(current); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
}
|
||||
last = current
|
||||
}
|
||||
if completedActionStatus(current.Status) {
|
||||
return current, nil
|
||||
}
|
||||
if err := sleepCtx(ctx, interval); err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CancelRun requests cancellation without deleting the run or its history.
|
||||
// Force cancellation is an explicit operation for unresponsive runners.
|
||||
func (s Actions) CancelRun(ctx context.Context, id int64, force bool) (Run, error) {
|
||||
action := "cancel"
|
||||
if force {
|
||||
action = "force-cancel"
|
||||
}
|
||||
var response runResponse
|
||||
// TODO(sdk): ActionsWorkflowRun lacks created_at/updated_at.
|
||||
err := sendJSON(ctx, s.Client, http.MethodPost, fmt.Sprintf("%sruns/%d/%s", s.endpoint(), id, action), nil, &response)
|
||||
return response.result(), err
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -75,22 +76,34 @@ func TestWorkflowsMapDefinitions(t *testing.T) {
|
||||
require.Equal(t, "disabled_manually", item.State)
|
||||
}
|
||||
|
||||
func TestWatchChangesAndFailureConclusion(t *testing.T) {
|
||||
calls := 0
|
||||
func TestWatchReportsEveryPollWithJobsUntilTheRunCompletes(t *testing.T) {
|
||||
polls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls <= 2 {
|
||||
_, _ = w.Write([]byte(`{"id":1,"status":"in_progress"}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"id":1,"status":"completed","conclusion":"failure"}`))
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/repo/actions/workflows":
|
||||
_, _ = w.Write([]byte(`{"workflows":[{"id":"ci.yml","name":"CI"}]}`))
|
||||
case "/api/v1/repos/owner/repo/actions/runs/1":
|
||||
polls++
|
||||
if polls < 3 {
|
||||
_, _ = w.Write([]byte(`{"id":1,"path":"ci.yml@refs/heads/main","status":"in_progress"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":1,"path":"ci.yml@refs/heads/main","status":"completed","conclusion":"failure"}`))
|
||||
case "/api/v1/repos/owner/repo/actions/runs/1/jobs":
|
||||
_, _ = w.Write([]byte(`{"jobs":[{"id":2,"name":"build","status":"in_progress"}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
changes := []Run{}
|
||||
var updates []string
|
||||
service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}
|
||||
final, err := service.Watch(context.Background(), 1, time.Millisecond, func(run Run) error { changes = append(changes, run); return nil })
|
||||
final, err := service.Watch(context.Background(), 1, time.Millisecond, func(run Run) error {
|
||||
updates = append(updates, run.Workflow+" "+run.Status+" "+run.Jobs[0].Name)
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes, 2)
|
||||
require.Equal(t, []string{"CI in_progress build", "CI in_progress build", "CI completed build"}, updates)
|
||||
require.False(t, SuccessfulRun(final))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -137,10 +150,10 @@ func TestActionMutationsUseTheirEndpoints(t *testing.T) {
|
||||
return s.SetWorkflowEnabled(context.Background(), "ci.yml", false)
|
||||
}},
|
||||
{"rerun", "/api/v1/repos/owner/repo/actions/runs/7/rerun", http.MethodPost, func(s Actions) error {
|
||||
return s.Rerun(context.Background(), 7, false)
|
||||
return s.Rerun(context.Background(), 7, 0, false)
|
||||
}},
|
||||
{"rerun-failed", "/api/v1/repos/owner/repo/actions/runs/7/rerun-failed-jobs", http.MethodPost, func(s Actions) error {
|
||||
return s.Rerun(context.Background(), 7, true)
|
||||
return s.Rerun(context.Background(), 7, 0, true)
|
||||
}},
|
||||
{"delete", "/api/v1/repos/owner/repo/actions/runs/7", http.MethodDelete, func(s Actions) error {
|
||||
return s.DeleteRun(context.Background(), 7)
|
||||
@@ -209,6 +222,7 @@ func TestRunTimeFilteringPaginatesMatchingResults(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("page") {
|
||||
case "1":
|
||||
w.Header().Set("Link", `<`+r.URL.Path+`?page=2>; rel="next"`)
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":4,"created_at":"2028-01-03T00:00:00Z"},{"id":3,"created_at":"2028-01-01T12:00:00Z"}]}`))
|
||||
case "2":
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":2,"created_at":"2028-01-01T00:00:00Z"}]}`))
|
||||
@@ -254,7 +268,9 @@ func TestRunTimeFilteringStopsAtScanCap(t *testing.T) {
|
||||
pages := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pages++
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":1,"created_at":"2028-12-31T00:00:00Z"}]}`))
|
||||
w.Header().Set("Link", `<`+r.URL.Path+`?page=2>; rel="next"`)
|
||||
run := `{"id":1,"created_at":"2028-12-31T00:00:00Z"}`
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[` + strings.Repeat(run+",", 99) + run + `]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}
|
||||
@@ -262,3 +278,78 @@ func TestRunTimeFilteringStopsAtScanCap(t *testing.T) {
|
||||
require.ErrorContains(t, err, "narrow the search")
|
||||
require.Equal(t, maxRunScanPages, pages)
|
||||
}
|
||||
|
||||
func TestFindWorkflowByFileStemPathOrName(t *testing.T) {
|
||||
workflows := []Workflow{
|
||||
{ID: "ci.yml", Name: "CI", Path: ".gitea/workflows/ci.yml"},
|
||||
{ID: "nightly.yaml", Name: "Nightly build", Path: ".gitea/workflows/nightly.yaml"},
|
||||
{ID: "deploy.yml", Name: "Deploy", Path: ".gitea/workflows/deploy.yml"},
|
||||
{ID: "deploy.yaml", Name: "Deploy staging", Path: ".gitea/workflows/deploy.yaml"},
|
||||
}
|
||||
for raw, want := range map[string]string{
|
||||
"ci.yml": "ci.yml", "ci": "ci.yml", "CI": "ci.yml", ".gitea/workflows/nightly.yaml": "nightly.yaml",
|
||||
"nightly build": "nightly.yaml", "Deploy": "deploy.yml", "deploy.yaml": "deploy.yaml",
|
||||
} {
|
||||
workflow, err := FindWorkflow(workflows, raw, "alice/x")
|
||||
require.NoError(t, err, raw)
|
||||
require.Equal(t, want, workflow.ID, raw)
|
||||
}
|
||||
_, err := FindWorkflow(workflows, "deploy", "alice/x")
|
||||
require.EqualError(t, err, `workflow "deploy" is ambiguous in alice/x (deploy.yml, deploy.yaml); pass the file name`)
|
||||
_, err = FindWorkflow(workflows, "release", "alice/x")
|
||||
require.EqualError(t, err, `workflow "release" not found in alice/x`)
|
||||
}
|
||||
|
||||
func TestDispatchInputsReadTheWorkflowDispatchTrigger(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, definition string
|
||||
want []WorkflowInput
|
||||
err error
|
||||
}{
|
||||
{"event name", "on: workflow_dispatch\n", []WorkflowInput{}, nil},
|
||||
{"event list", "on: [push, workflow_dispatch]\n", []WorkflowInput{}, nil},
|
||||
{"trigger without inputs", "on:\n workflow_dispatch:\n", []WorkflowInput{}, nil},
|
||||
{"inputs in order", `on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: Deploy target
|
||||
required: true
|
||||
default: staging
|
||||
type: choice
|
||||
options: [staging, production]
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: true
|
||||
`, []WorkflowInput{
|
||||
{Name: "target", Description: "Deploy target", Type: "choice", Default: "staging", Required: true, Options: []string{"staging", "production"}},
|
||||
{Name: "dry_run", Type: "boolean", Default: "true"},
|
||||
}, nil},
|
||||
{"no trigger", "on:\n push:\n", nil, ErrNoDispatch},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inputs, err := DispatchInputs([]byte(tc.definition))
|
||||
require.ErrorIs(t, err, tc.err)
|
||||
require.Equal(t, tc.want, inputs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowFileTellsAMissingRefFromAMissingFile(t *testing.T) {
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v1/repos/owner/repo/contents/.gitea/workflows/ci.yml", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
ref := r.URL.Query().Get("ref")
|
||||
if ref == "gone" {
|
||||
_, _ = fmt.Fprintf(w, `{"message":"object does not exist [id: %s, rel_path: ]"}`, ref)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"message":"object does not exist [id: 0123abc, rel_path: .gitea/workflows/ci.yml]"}`))
|
||||
}))
|
||||
service := Actions{Client: client, Repository: "owner/repo"}
|
||||
workflow := Workflow{ID: "ci.yml", Path: ".gitea/workflows/ci.yml"}
|
||||
_, err := service.WorkflowFile(context.Background(), workflow, "gone")
|
||||
require.EqualError(t, err, `ref "gone" not found in owner/repo`)
|
||||
_, err = service.WorkflowFile(context.Background(), workflow, "main")
|
||||
require.EqualError(t, err, "workflow file .gitea/workflows/ci.yml not found in owner/repo")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
)
|
||||
@@ -30,15 +31,40 @@ func artifactFrom(artifact *gitea.ActionsArtifact) Artifact {
|
||||
}
|
||||
}
|
||||
|
||||
// Artifacts lists one page of artifacts belonging to a run.
|
||||
func (s Actions) Artifacts(ctx context.Context, run int64, limit, page int) ([]Artifact, error) {
|
||||
// Artifacts lists one page of artifacts belonging to a run, with their total number.
|
||||
func (s Actions) Artifacts(ctx context.Context, run int64, limit, page int) (Page[Artifact], error) {
|
||||
response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsArtifactsResponse, *gitea.Response, error) {
|
||||
return c.Actions.ListRepoRunArtifacts(ctx, owner, repo, run, gitea.ListActionsArtifactsOptions{ListOptions: listOptions(limit, page)})
|
||||
})
|
||||
if err != nil {
|
||||
return Page[Artifact]{}, Classify(err, "run", strconv.FormatInt(run, 10), s.Repository)
|
||||
}
|
||||
total := int(response.TotalCount)
|
||||
return Page[Artifact]{Items: convertAll(response.Artifacts, artifactFrom), More: total > page*limit, Total: total, Counted: true}, nil
|
||||
}
|
||||
|
||||
// AllArtifacts lists every artifact belonging to a run.
|
||||
func (s Actions) AllArtifacts(ctx context.Context, run int64) ([]Artifact, error) {
|
||||
artifacts := []Artifact{}
|
||||
for page := 1; page <= maxListPages; page++ {
|
||||
window, err := s.Artifacts(ctx, run, 100, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertAll(response.Artifacts, artifactFrom), nil
|
||||
artifacts = append(artifacts, window.Items...)
|
||||
if !window.More || len(window.Items) == 0 {
|
||||
return artifacts, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("listing exceeded %d pages", maxListPages)
|
||||
}
|
||||
|
||||
// Artifact retrieves one artifact.
|
||||
func (s Actions) Artifact(ctx context.Context, id int64) (Artifact, error) {
|
||||
artifact, err := repoConvert(ctx, s.Client, s.Repository, artifactFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsArtifact, *gitea.Response, error) {
|
||||
return c.Actions.GetRepoArtifact(ctx, owner, repo, id)
|
||||
})
|
||||
return artifact, Classify(err, "artifact", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
|
||||
// DownloadArtifact streams an artifact ZIP, accepting a scoped signed redirect.
|
||||
@@ -49,7 +75,7 @@ func (s Actions) DownloadArtifact(ctx context.Context, id int64) (io.ReadCloser,
|
||||
}
|
||||
response, err := s.Client.DownloadSigned(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, Classify(err, "artifact", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
return response.Body, nil
|
||||
}
|
||||
@@ -25,16 +25,16 @@ func TestArtifactListingProjectsMetadataAndEmptyArrays(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"artifacts":[{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":false,"expires_at":null,"workflow_run":{"head_sha":"excluded"},"archive_download_url":"excluded"}]}`))
|
||||
}))
|
||||
service := Actions{Client: client, Repository: "owner/repo"}
|
||||
items, err := service.Artifacts(context.Background(), 7, 20, 1)
|
||||
page, err := service.Artifacts(context.Background(), 7, 20, 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, items, 1)
|
||||
data, err := json.Marshal(items)
|
||||
require.Len(t, page.Items, 1)
|
||||
data, err := json.Marshal(page.Items)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `[{"id":9,"name":"build","size_in_bytes":128,"url":"https://example.test/artifact","expired":false,"created_at":"","updated_at":"","expires_at":null}]`, string(data))
|
||||
items, err = service.Artifacts(context.Background(), 7, 20, 2)
|
||||
page, err = service.Artifacts(context.Background(), 7, 20, 2)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, items)
|
||||
require.NotNil(t, items)
|
||||
require.Empty(t, page.Items)
|
||||
require.NotNil(t, page.Items)
|
||||
}
|
||||
|
||||
func TestArtifactFromMapsServerFields(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// Choice is one option of a selection prompt: Label is shown and Value returned.
|
||||
type Choice struct {
|
||||
Label, Value string
|
||||
}
|
||||
|
||||
// Chooser asks for one of several options. FormPrompter implements it; a Prompter without it
|
||||
// cannot offer a selection.
|
||||
type Chooser interface {
|
||||
// Choose asks for one of choices, starting at the one whose value is initial, else the first.
|
||||
Choose(ctx context.Context, title string, choices []Choice, initial string) (string, error)
|
||||
}
|
||||
|
||||
// Choose asks on stderr for one of choices and returns its value.
|
||||
func (p *FormPrompter) Choose(ctx context.Context, title string, choices []Choice, initial string) (string, error) {
|
||||
if len(choices) == 0 {
|
||||
return "", errors.New("nothing to choose from")
|
||||
}
|
||||
options := make([]huh.Option[string], len(choices))
|
||||
value := choices[0].Value
|
||||
for i, choice := range choices {
|
||||
options[i] = huh.NewOption(SafeLine(choice.Label), choice.Value)
|
||||
if choice.Value == initial {
|
||||
value = initial
|
||||
}
|
||||
}
|
||||
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title(title).Options(options...).Height(min(len(choices), 10)+2).Value(&value))))
|
||||
return value, err
|
||||
}
|
||||
Reference in new issue
Block a user