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

+64 -19
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@@ -21,8 +22,22 @@ import (
// Job identifies one workflow job without exposing server-specific metadata. StartedAt and
// CompletedAt are nil until the job starts and completes.
type Job struct {
RunAttempt int64 `json:"run_attempt"`
ID int64 `json:"id"`
RunAttempt int64 `json:"run_attempt"`
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
URL string `json:"url"`
Labels []string `json:"labels"`
CreatedAt string `json:"created_at"`
StartedAt *string `json:"started_at"`
CompletedAt *string `json:"completed_at"`
Steps []Step `json:"steps"`
}
// Step is one step of a job. StartedAt and CompletedAt are nil until the step starts and completes.
type Step struct {
Number int64 `json:"number"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
@@ -30,14 +45,44 @@ type Job struct {
CompletedAt *string `json:"completed_at"`
}
// Jobs lists every page of jobs in the selected run.
// Completed reports whether the job finished, whatever its conclusion.
func (j Job) Completed() bool { return completedActionStatus(j.Status) }
// Jobs lists every job in the selected run.
func (s Actions) Jobs(ctx context.Context, run int64) ([]Job, error) {
return listAll(func(page int) ([]Job, error) { return s.jobsPage(ctx, run, 100, page) })
jobs := []Job{}
for page := 1; page <= maxListPages; page++ {
window, err := s.JobsPage(ctx, run, 100, page)
if err != nil {
return nil, err
}
jobs = append(jobs, window.Items...)
if !window.More || len(window.Items) == 0 {
return jobs, nil
}
}
return nil, fmt.Errorf("listing exceeded %d pages", maxListPages)
}
// JobsPage lists a page of jobs in the selected run.
func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) {
return s.jobsPage(ctx, run, limit, page)
func (s Actions) JobsPage(ctx context.Context, run int64, limit, page int) (Page[Job], error) {
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.ActionsWorkflowJob, http.Header, error) {
response, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) {
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return nil, nil, err
}
return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)})
})
if err != nil {
return nil, headerOf(resp), err
}
return response.Jobs, headerOf(resp), nil
})
if err != nil {
return Page[Job]{}, err
}
return Page[Job]{Items: convertAll(window.Items, jobFrom), More: window.More, Total: window.Total, Counted: window.Counted}, nil
}
// WaitForFirstJob polls a freshly dispatched run until the server has created its first job.
@@ -59,20 +104,18 @@ func (s Actions) WaitForFirstJob(ctx context.Context, run int64, interval time.D
}
}
func (s Actions) jobsPage(ctx context.Context, run int64, limit, page int) ([]Job, error) {
response, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsWorkflowJobsResponse, *gitea.Response, error) {
return c.Actions.ListRepoJobsByRun(ctx, owner, repo, run, gitea.ListRepoActionsJobsOptions{ListOptions: listOptions(limit, page)})
})
if err != nil {
return nil, err
}
return convertAll(response.Jobs, jobFrom), nil
}
func jobFrom(job *gitea.ActionsWorkflowJob) Job {
return Job{
RunAttempt: job.RunAttempt, ID: job.ID, Name: job.Name, Status: job.Status, Conclusion: job.Conclusion,
StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt),
URL: job.HTMLURL, Labels: append([]string{}, job.Labels...), CreatedAt: timestamp(job.CreatedAt),
StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt), Steps: convertAll(job.Steps, stepFrom),
}
}
func stepFrom(step *gitea.ActionsWorkflowStep) Step {
return Step{
Number: step.Number, Name: step.Name, Status: step.Status, Conclusion: step.Conclusion,
StartedAt: actionTime(step.StartedAt), CompletedAt: actionTime(step.CompletedAt),
}
}
@@ -187,9 +230,11 @@ func (s Actions) FollowJobLogs(ctx context.Context, run, id int64, interval time
}
}
// RunLogsArchive streams the server's ZIP archive without extracting its entries.
// RunLogsArchive streams the server's ZIP archive without extracting its entries. A run without
// logs, such as one that never started, is reported as not found.
func (s Actions) RunLogsArchive(ctx context.Context, run int64) (io.ReadCloser, error) {
return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) {
body, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (io.ReadCloser, *gitea.Response, error) {
return c.Actions.GetRepoRunLogsReader(ctx, owner, repo, run)
})
return body, Classify(err, "logs of run", strconv.FormatInt(run, 10), s.Repository)
}