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:
bircni committed 2026-10-11 23:46:26 +02:00
1 parent b09c1aab4a
commit 9f69a89537
19 files changed
+2569 -579

No files matched your search

+43 -10
View File
@@ -4,7 +4,9 @@
package app package app
import ( import (
"fmt"
"strconv" "strconv"
"time"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
@@ -13,25 +15,56 @@ import (
func (a *application) runJobsCommand() *cobra.Command { func (a *application) runJobsCommand() *cobra.Command {
var limit, page int 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 { cmd := &cobra.Command{
run, err := number(args[0]) 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 { if err != nil {
return err 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 { if err != nil {
return err return err
} }
items, err := (services.Actions{Client: target.client, Repository: target.Repo}).JobsPage(cmd.Context(), run, limit, page) 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)}
if err != nil { return writeTable(a, cmd, jobs.Items, spec, listPage{Number: page, More: jobs.More, Total: jobs.Total, Counted: jobs.Counted})
return err },
} }
return writeList(a, cmd, items, jobRow)
}}
pageFlags(cmd, &limit, &page) pageFlags(cmd, &limit, &page)
cmd.ValidArgsFunction = a.completeRunArg()
return cmd return cmd
} }
func jobRow(item services.Job) []terminal.Cell { // jobColumns head jobRow: an icon on a terminal, the status and conclusion words for scripts.
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, item.Status, item.Conclusion) 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
View File
@@ -4,11 +4,13 @@
package app package app
import ( import (
"bytes"
"cmp" "cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"slices"
"time" "time"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
@@ -18,77 +20,106 @@ import (
) )
func (a *application) runLogsCommand() *cobra.Command { func (a *application) runLogsCommand() *cobra.Command {
var output string var archive downloadFlags
var selected int64 var job string
var follow bool var follow, failed bool
var interval, timeout time.Duration 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 { if interval <= 0 || timeout <= 0 {
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive")) 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 { if err != nil {
return err return err
} }
if cmd.Flags().Changed("job") && selected < 1 { service := services.Actions{Client: t.client, Repository: t.Repo}
return clierr.Wrap(clierr.Usage, errors.New("--job must be a positive ID")) if download {
} path, skip, err := archive.destination(fmt.Sprintf("run-%d-logs.zip", run))
if output != "" && (follow || cmd.Flags().Changed("job")) { if err != nil || skip {
return clierr.Wrap(clierr.Usage, errors.New("--output-file downloads a run archive and cannot be combined with --job or --follow"))
}
if output != "" {
if err := validateDownloadDestination(output); err != nil {
return err 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) body, err := service.RunLogsArchive(cmd.Context(), run)
if err != nil { if err != nil {
return err return err
} }
defer body.Close() 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) jobs, err := service.Jobs(cmd.Context(), run)
if err != nil { if err != nil {
return err return err
} }
if selected != 0 { switch {
found := false case job != "":
for _, job := range jobs { selected, err := findJob(jobs, job, run)
if job.ID == selected { if err != nil {
jobs = []services.Job{job} return err
found = true }
break 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 { if !follow {
return fmt.Errorf("job %d does not belong to run %d", selected, run) return a.printJobLogs(cmd, service, jobs)
}
}
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"))
} }
ctx, cancel := context.WithTimeout(cmd.Context(), timeout) ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel() defer cancel()
return service.FollowJobLogs(ctx, run, jobs[0].ID, interval, func(chunk []byte) error { return timeoutError(a.followJobLogs(ctx, cmd, service, run, jobs, interval), timeout, "following the logs of run %d", run)
n, err := out.Write(chunk) },
if err == nil && n != len(chunk) {
return io.ErrShortWrite
} }
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 { for _, job := range jobs {
label := fmt.Sprintf("%s (job %d)", terminal.SafeLine(job.Name), job.ID) label := fmt.Sprintf("%s (job %d)", terminal.SafeLine(job.Name), job.ID)
var logs io.ReadCloser var logs io.ReadCloser
var err error
if job.Started() { if job.Started() {
logs, err = service.JobLogs(cmd.Context(), job.ID) logs, err = service.JobLogs(cmd.Context(), job.ID)
} }
@@ -107,21 +138,76 @@ func (a *application) runLogsCommand() *cobra.Command {
} }
_, copyErr := io.Copy(out, logs) _, copyErr := io.Copy(out, logs)
closeErr := logs.Close() closeErr := logs.Close()
if copyErr != nil { if err := cmp.Or(copyErr, closeErr); err != nil {
return copyErr return err
}
if closeErr != nil {
return closeErr
} }
} }
return nil 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)") // followJobLogs follows the logs of jobs one after another until each completes, waiting for
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow newly appended logs until the selected job completes") // those that have not started. Following several jobs prefixes each line with its job's name.
cmd.Flags().DurationVar(&interval, "interval", 5*time.Second, "Log polling interval") func (a *application) followJobLogs(ctx context.Context, cmd *cobra.Command, service services.Actions, run int64, jobs []services.Job, interval time.Duration) error {
cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum follow duration") out := terminal.SafeWriter(cmd.OutOrStdout(), a.deps.Streams.OutTTY, true)
return cmd 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 // unstarted explains why a job has no logs: it is waiting for a runner, or it completed, such
+32 -1
View File
@@ -53,7 +53,7 @@ func TestRunLogsStreamWithoutMixingDiagnosticsAndVerifyJobMembership(t *testing.
diagnostics.Reset() diagnostics.Reset()
args[5] = "--job=10" args[5] = "--job=10"
err := Execute(context.Background(), deps, args) 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.Equal(t, 1, downloads)
require.Empty(t, out.String()) require.Empty(t, out.String())
path := filepath.Join(t.TempDir(), "logs.zip") 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, "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()) 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())
})
}
}
+766
View File
@@ -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)
}
}
+397
View File
@@ -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())
}
-159
View File
@@ -7,7 +7,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@@ -129,161 +128,3 @@ func (a *application) workflowCommand() *cobra.Command {
func workflowRow(item services.Workflow) []terminal.Cell { func workflowRow(item services.Workflow) []terminal.Cell {
return terminal.Row(item.ID, item.Name, item.State) 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
}
-40
View File
@@ -7,51 +7,11 @@ import (
"context" "context"
"net/http" "net/http"
"testing" "testing"
"time"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require" "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) { func TestWorkflowRunFollowStreamsFirstJobLogs(t *testing.T) {
jobPolls := 0 jobPolls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1 -13
View File
@@ -161,7 +161,7 @@ func TestSudoSendsHeaderAfterIdentityCheck(t *testing.T) {
func TestJSONFieldErrorsListAvailableFields(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 \"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, "--json= needs field names; use --json alone for every field", "pr", "list", "--json=")
requireUsageBeforeRequests(t, "version prints text; --jq is not supported", "version", "--jq", ".") requireUsageBeforeRequests(t, "version prints text; --jq is not supported", "version", "--jq", ".")
} }
@@ -541,16 +541,6 @@ const (
repo set-default repo set-default
repo sync repo sync
repo view 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 issue
search pr search pr
search repo search repo
@@ -595,8 +585,6 @@ const (
release release
release asset release asset
repo repo
run
run artifact
search search
secret secret
time time
+1 -1
View File
@@ -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", "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{"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{"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{"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]"}, {[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone <repository> [directory] [flags]"},
} { } {
+313 -21
View File
@@ -4,58 +4,350 @@
package app package app
import ( import (
"archive/zip"
"cmp"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv" "strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func (a *application) artifactCommand() *cobra.Command { 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 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 { list := &cobra.Command{
run, err := number(args[0]) 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 { if err != nil {
return err 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 { if err != nil {
return err return err
} }
items, err := (services.Actions{Client: target.client, Repository: target.Repo}).Artifacts(cmd.Context(), run, limit, page) 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)}
if err != nil { return writeTable(a, cmd, items.Items, spec, listPage{Number: page, More: items.More, Total: items.Total, Counted: items.Counted})
return err },
} }
return writeList(a, cmd, items, artifactRow)
}}
pageFlags(list, &limit, &page) pageFlags(list, &limit, &page)
list.ValidArgsFunction = a.completeRunArg()
group.AddCommand(list) 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]) id, err := number(args[0])
if err != nil { if err != nil {
return err return err
} }
if err := validateDownloadDestination(output); err != nil { t, err := a.resolve(cmd.Context(), true)
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
defer body.Close() defer body.Close()
return writeDownload(cmd, body, output) if err := a.download(cmd, destination, path, body); err != nil {
}} return err
download.Flags().StringVar(&output, "output-file", "", "Destination file (must not exist), or - for stdout") }
if path != "-" {
a.done(cmd, "Downloaded artifact %d to %s", id, path)
}
return nil
},
}
destination.register(download)
group.AddCommand(download) group.AddCommand(download)
return group return group
} }
func artifactRow(item services.Artifact) []terminal.Cell { // artifactColumns head artifactRow: sizes and expiry for people on a terminal, bytes and times
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), strconv.FormatBool(item.Expired)) // 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)
}
} }
+120 -9
View File
@@ -4,12 +4,15 @@
package app package app
import ( import (
"archive/zip"
"bytes"
"context" "context"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -18,6 +21,8 @@ func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++ requests++
switch r.URL.Path { 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": case "/api/v1/repos/owner/repo/actions/artifacts/9/zip":
require.Equal(t, "token token", r.Header.Get("Authorization")) require.Equal(t, "token token", r.Header.Get("Authorization"))
http.Redirect(w, r, "/signed?signature=private", http.StatusFound) http.Redirect(w, r, "/signed?signature=private", http.StatusFound)
@@ -31,24 +36,130 @@ func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing
})) }))
defer server.Close() defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL) 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, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output-file", "-"}))
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, out.Bytes()) require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, out.Bytes())
require.Empty(t, diagnostics.String()) require.Empty(t, diagnostics.String())
output := filepath.Join(t.TempDir(), "artifact.zip")
args[len(args)-1] = output dir := t.TempDir()
out.Reset() out.Reset()
args := []string{"run", "artifact", "download", "#9", "-R", "owner/repo", "--dir", dir}
require.NoError(t, Execute(context.Background(), deps, args)) 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.NoError(t, err)
require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, data) require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, data)
require.Empty(t, out.String()) require.Empty(t, out.String())
before := requests before := requests
err = Execute(context.Background(), deps, args) err = Execute(context.Background(), deps, append(args, "--output", filepath.Join(dir, "build.zip")))
require.ErrorContains(t, err, "already exists") 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) require.Equal(t, before, requests)
args[len(args)-1] = "-" err = Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "-O", "-", "--json"})
err = Execute(context.Background(), deps, append(args, "--json"))
require.ErrorContains(t, err, "binary") require.ErrorContains(t, err, "binary")
require.Equal(t, before, requests) 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)
}
}
+62 -17
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -26,18 +27,62 @@ type Job struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
Conclusion string `json:"conclusion"` 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"` StartedAt *string `json:"started_at"`
CompletedAt *string `json:"completed_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) { 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. // JobsPage lists a page of jobs in the selected run.
func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) { func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) (Page[Job], error) {
return s.jobsPage(ctx, run, limit, page) 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. // 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 { func jobFrom(job *gitea.ActionsWorkflowJob) Job {
return Job{ return Job{
RunAttempt: job.RunAttempt, ID: job.ID, Name: job.Name, Status: job.Status, Conclusion: job.Conclusion, 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) { 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 c.Actions.GetRepoRunLogsReader(ctx, owner, repo, run)
}) })
return body, Classify(err, "logs of run", strconv.FormatInt(run, 10), s.Repository)
} }
+13 -3
View File
@@ -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, "/api/v1/repos/owner/repo/actions/runs/7/jobs", r.URL.Path)
require.Equal(t, "20", r.URL.Query().Get("limit")) require.Equal(t, "20", r.URL.Query().Get("limit"))
require.Equal(t, "3", r.URL.Query().Get("page")) 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) jobs, err := (Actions{Client: client, Repository: "owner/repo"}).JobsPage(context.Background(), 7, 20, 3)
require.NoError(t, err) 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) { func TestRunLogsArchiveStreams(t *testing.T) {
@@ -151,7 +160,8 @@ func TestWaitForFirstJobPollsUntilJobsExist(t *testing.T) {
defer cancel() defer cancel()
job, err := service.WaitForFirstJob(ctx, 7, time.Millisecond) job, err := service.WaitForFirstJob(ctx, 7, time.Millisecond)
require.NoError(t, err) 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) _, err = service.WaitForFirstJob(ctx, 7, 0)
require.ErrorContains(t, err, "interval must be positive") require.ErrorContains(t, err, "interval must be positive")
} }
+347
View File
@@ -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
View File
@@ -5,82 +5,43 @@ package services
import ( import (
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "path"
"slices"
"strconv" "strconv"
"time" "strings"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport" "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 { type Workflow struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
State string `json:"state"` State string `json:"state"`
URL string `json:"url"`
} }
func workflowFrom(workflow *gitea.ActionsWorkflow) Workflow { 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 // Enabled reports whether the workflow runs; Gitea reports a disabled one as disabled_manually.
// until the run starts and completes. func (w Workflow) Enabled() bool { return !strings.HasPrefix(w.State, "disabled") }
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"`
}
func (r runResponse) result() Run { // Label names the workflow for people: "CI (ci.yml)".
return Run{ func (w Workflow) Label() string {
ID: r.ID, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL, Branch: r.HeadBranch, if w.Name == "" || w.Name == w.ID {
CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: runTime(r.StartedAt), CompletedAt: runTime(r.CompletedAt), return w.ID
Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt,
} }
} return w.Name + " (" + 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
} }
// DispatchResult identifies a workflow run created by dispatch when the server returns details. // 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 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) { 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 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) 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. // 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) { 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) { 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) 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 { if err != nil {
return DispatchResult{}, err return DispatchResult{}, Classify(err, "workflow", id, s.Repository)
} }
return DispatchResult{ID: details.WorkflowRunID, URL: details.HTMLURL}, nil 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. // WorkflowFile returns the workflow's YAML definition at ref, a branch, tag or commit, or on the
type RunListOptions struct { // default branch when ref is empty.
Since, Until *time.Time func (s Actions) WorkflowFile(ctx context.Context, workflow Workflow, ref string) ([]byte, error) {
Limit, Page int contents, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ContentsResponse, *gitea.Response, error) {
Branch, Event, Actor, Commit, Workflow string return c.Repositories.GetContents(ctx, owner, repo, ref, workflow.Path)
Statuses []string })
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. // WorkflowInput is one input that a workflow's workflow_dispatch trigger declares.
func (o RunListOptions) Validate() error { type WorkflowInput struct {
if o.Limit < 1 || o.Limit > 100 || o.Page < 1 || o.Page > int(^uint(0)>>1)/o.Limit { Name, Description string
return errors.New("invalid pagination") // 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 { // ErrNoDispatch reports a workflow that has no workflow_dispatch trigger.
case "pending", "waiting", "requested", "action_required", "queued", "in_progress", "completed", "failure", "success", "skipped", "neutral", "cancelled", "timed_out": //nolint:misspell // Gitea API spelling. var ErrNoDispatch = errors.New("workflow has no workflow_dispatch trigger")
default:
return fmt.Errorf("unsupported run status %q; see tea run list --help", status) // 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 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
}
+104 -13
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
@@ -75,22 +76,34 @@ func TestWorkflowsMapDefinitions(t *testing.T) {
require.Equal(t, "disabled_manually", item.State) require.Equal(t, "disabled_manually", item.State)
} }
func TestWatchChangesAndFailureConclusion(t *testing.T) { func TestWatchReportsEveryPollWithJobsUntilTheRunCompletes(t *testing.T) {
calls := 0 polls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++ switch r.URL.Path {
if calls <= 2 { case "/api/v1/repos/owner/repo/actions/workflows":
_, _ = w.Write([]byte(`{"id":1,"status":"in_progress"}`)) _, _ = w.Write([]byte(`{"workflows":[{"id":"ci.yml","name":"CI"}]}`))
} else { case "/api/v1/repos/owner/repo/actions/runs/1":
_, _ = w.Write([]byte(`{"id":1,"status":"completed","conclusion":"failure"}`)) 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() defer server.Close()
changes := []Run{} var updates []string
service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} 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.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)) require.False(t, SuccessfulRun(final))
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
@@ -137,10 +150,10 @@ func TestActionMutationsUseTheirEndpoints(t *testing.T) {
return s.SetWorkflowEnabled(context.Background(), "ci.yml", false) return s.SetWorkflowEnabled(context.Background(), "ci.yml", false)
}}, }},
{"rerun", "/api/v1/repos/owner/repo/actions/runs/7/rerun", http.MethodPost, func(s Actions) error { {"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 { {"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 { {"delete", "/api/v1/repos/owner/repo/actions/runs/7", http.MethodDelete, func(s Actions) error {
return s.DeleteRun(context.Background(), 7) 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) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("page") { switch r.URL.Query().Get("page") {
case "1": 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"}]}`)) _, _ = 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": case "2":
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":2,"created_at":"2028-01-01T00:00:00Z"}]}`)) _, _ = 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 pages := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++ 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() defer server.Close()
service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} 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.ErrorContains(t, err, "narrow the search")
require.Equal(t, maxRunScanPages, pages) 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")
}
+30 -4
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"strconv"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
) )
@@ -30,15 +31,40 @@ func artifactFrom(artifact *gitea.ActionsArtifact) Artifact {
} }
} }
// Artifacts lists one page of artifacts belonging to a run. // 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) ([]Artifact, error) { 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) { 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)}) 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 { if err != nil {
return nil, err 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. // 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) response, err := s.Client.DownloadSigned(ctx, endpoint)
if err != nil { if err != nil {
return nil, err return nil, Classify(err, "artifact", strconv.FormatInt(id, 10), s.Repository)
} }
return response.Body, nil return response.Body, nil
} }
+6 -6
View File
@@ -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"}]}`)) _, _ = 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"} 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.NoError(t, err)
require.Len(t, items, 1) require.Len(t, page.Items, 1)
data, err := json.Marshal(items) data, err := json.Marshal(page.Items)
require.NoError(t, err) 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)) 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.NoError(t, err)
require.Empty(t, items) require.Empty(t, page.Items)
require.NotNil(t, items) require.NotNil(t, page.Items)
} }
func TestArtifactFromMapsServerFields(t *testing.T) { func TestArtifactFromMapsServerFields(t *testing.T) {
+40
View File
@@ -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
}