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
+2636 -646

No files matched your search

+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())
}