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

-40
View File
@@ -7,51 +7,11 @@ import (
"context"
"net/http"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestRunTimeBoundsUseOneClockReading(t *testing.T) {
now := time.Date(2028, 2, 1, 12, 0, 0, 0, time.UTC)
deps, _, _ := testDeps(t)
reads := 0
deps.Now = func() time.Time { reads++; return now }
err := Execute(context.Background(), deps, []string{"run", "list", "--since=1h", "--until=2h"})
require.ErrorContains(t, err, "--since must not be later than --until")
require.Equal(t, 1, reads)
}
func TestRunWatchExitAndOutputContracts(t *testing.T) {
for _, test := range []struct {
name, status, conclusion string
code int
}{
{"success", "completed", "success", 0},
{"failed run", "completed", "failure", clierr.Failure},
{"timeout", "in_progress", "", clierr.Canceled},
} {
t.Run(test.name, func(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/actions/runs/7", r.URL.Path)
_, _ = w.Write([]byte(`{"id":7,"status":"` + test.status + `","conclusion":"` + test.conclusion + `"}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"run", "watch", "7", "--repo", "alice/project", "--json=id,status,conclusion", "--interval=1ms", "--timeout=100ms", "--no-input"})
require.Equal(t, test.code, clierr.ExitCode(err))
if test.code == clierr.Canceled {
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Empty(t, out.String())
} else {
require.JSONEq(t, `{"id":7,"status":"completed","conclusion":"`+test.conclusion+`"}`, out.String())
}
require.Contains(t, diagnostics.String(), "run 7:")
})
}
}
func TestWorkflowRunFollowStreamsFirstJobLogs(t *testing.T) {
jobPolls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {