feat(actions): add structured run output

Signed-off-by: Noel <n@noeljackson.com>
This commit is contained in:
Noel 2026-08-03 15:53:31 +02:00
parent f34697c5ed
commit 7246c8ec1e
No known key found for this signature in database
GPG key ID: ACB59B76E29853EA
6 changed files with 435 additions and 51 deletions

View file

@ -42,6 +42,10 @@ var CmdRunsList = cli.Command{
Name: "actor",
Usage: "Filter by actor username (who triggered the run)",
},
&cli.StringFlag{
Name: "head-sha",
Usage: "Filter by head commit SHA",
},
&cli.StringFlag{
Name: "since",
Usage: "Show runs started after this time (e.g., '24h', '2024-01-01')",
@ -106,13 +110,7 @@ func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error {
// Build list options
listOpts := flags.GetListOptions(cmd)
runs, _, err := client.Actions.ListRepoRuns(ctx, c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{
ListOptions: listOpts,
Status: cmd.String("status"),
Branch: cmd.String("branch"),
Event: cmd.String("event"),
Actor: cmd.String("actor"),
})
runs, _, err := client.Actions.ListRepoRuns(ctx, c.Owner, c.Repo, listRepoActionRunsOptions(cmd, listOpts))
if err != nil {
return err
}
@ -127,6 +125,17 @@ func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error {
return print.ActionRunsList(filteredRuns, c.Output)
}
func listRepoActionRunsOptions(cmd *cli.Command, listOpts gitea.ListOptions) gitea.ListRepoActionRunsOptions {
return gitea.ListRepoActionRunsOptions{
ListOptions: listOpts,
Status: cmd.String("status"),
Branch: cmd.String("branch"),
Event: cmd.String("event"),
Actor: cmd.String("actor"),
HeadSHA: cmd.String("head-sha"),
}
}
// filterRunsByTime filters runs based on time range
func filterRunsByTime(runs []*gitea.ActionWorkflowRun, since, until time.Time) []*gitea.ActionWorkflowRun {
if since.IsZero() && until.IsZero() {

View file

@ -15,6 +15,31 @@ import (
"gitea.dev/tea/modules/config"
)
func TestRunRunsListPassesHeadSHAFilter(t *testing.T) {
const headSHA = "0123456789abcdef"
cmd := &cli.Command{Flags: []cli.Flag{
&cli.StringFlag{Name: "status"},
&cli.StringFlag{Name: "branch"},
&cli.StringFlag{Name: "event"},
&cli.StringFlag{Name: "actor"},
&cli.StringFlag{Name: "head-sha"},
}}
require.NoError(t, cmd.Set("head-sha", headSHA))
options := listRepoActionRunsOptions(cmd, gitea.ListOptions{Page: 2, PageSize: 50})
require.Equal(t, headSHA, options.HeadSHA)
require.Equal(t, 2, options.Page)
require.Equal(t, 50, options.PageSize)
var found bool
for _, flag := range CmdRunsList.Flags {
if flag.Names()[0] == "head-sha" {
found = true
break
}
}
require.True(t, found)
}
func TestFilterRunsByTime(t *testing.T) {
now := time.Now()
runs := []*gitea.ActionWorkflowRun{

View file

@ -58,10 +58,7 @@ func runRunsView(ctx stdctx.Context, cmd *cli.Command) error {
return fmt.Errorf("failed to get run: %w", err)
}
// Print run details
print.ActionRunDetails(run)
// Fetch and print jobs if requested
var runJobs []*gitea.ActionWorkflowJob
if cmd.Bool("jobs") {
jobs, _, err := client.Actions.ListRepoJobsByRun(ctx, c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{
ListOptions: flags.GetListOptions(cmd),
@ -70,13 +67,10 @@ func runRunsView(ctx stdctx.Context, cmd *cli.Command) error {
return fmt.Errorf("failed to get jobs: %w", err)
}
if jobs != nil && len(jobs.Jobs) > 0 {
fmt.Printf("\nJobs:\n\n")
if err := print.ActionWorkflowJobsList(jobs.Jobs, c.Output); err != nil {
return err
}
if jobs != nil {
runJobs = jobs.Jobs
}
}
return nil
return print.ActionRunView(run, runJobs, c.Output)
}

View file

@ -1575,6 +1575,8 @@ List workflow runs
**--event**="": Filter by event type (push, pull_request, etc.)
**--head-sha**="": Filter by head commit SHA
**--limit, --lm**="": specify limit of items per page (default: 30)
**--login, -l**="": Use a different Gitea Login. Optional

View file

@ -4,10 +4,14 @@
package print
import (
"encoding/json"
"fmt"
"io"
"os"
"time"
"gitea.dev/sdk"
"gopkg.in/yaml.v3"
)
// formatDurationMinutes formats duration in a human-readable way
@ -22,6 +26,9 @@ func formatDurationMinutes(started, completed time.Time) string {
}
duration := end.Sub(started)
if duration < 0 {
return ""
}
if duration < time.Minute {
return fmt.Sprintf("%ds", int(duration.Seconds()))
}
@ -33,6 +40,16 @@ func formatDurationMinutes(started, completed time.Time) string {
return fmt.Sprintf("%dh%dm", hours, minutes)
}
// validCompletion returns a completion timestamp only when it can represent a
// real completion for the supplied start time. Some Gitea versions serialize
// an absent completion as the Unix epoch.
func validCompletion(started, completed time.Time) time.Time {
if completed.IsZero() || (!started.IsZero() && completed.Before(started)) {
return time.Time{}
}
return completed
}
// getWorkflowDisplayName returns the display title or falls back to path
func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string {
if run.DisplayTitle != "" {
@ -43,36 +60,63 @@ func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string {
// ActionRunsList prints a list of workflow runs
func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) error {
t := table{
headers: []string{
machineReadable := isMachineReadable(output)
var headers []string
if machineReadable {
headers = []string{
"ID",
"Status",
"Conclusion",
"Workflow",
"Path",
"Branch",
"Event",
"Started",
"HeadSHA",
"StartedAt",
"CompletedAt",
"Duration",
},
}
} else {
headers = []string{"ID", "Status", "Workflow", "Branch", "Event", "Started", "Duration"}
}
machineReadable := isMachineReadable(output)
t := table{headers: headers}
for _, run := range runs {
workflowName := getWorkflowDisplayName(run)
duration := formatDurationMinutes(run.StartedAt, run.CompletedAt)
completedAt := validCompletion(run.StartedAt, run.CompletedAt)
duration := formatDurationMinutes(run.StartedAt, completedAt)
t.addRow(
fmt.Sprintf("%d", run.ID),
run.Status,
workflowName,
run.HeadBranch,
run.Event,
FormatTime(run.StartedAt, machineReadable),
duration,
)
if machineReadable {
t.addRow(
fmt.Sprintf("%d", run.ID),
run.Status,
run.Conclusion,
workflowName,
run.Path,
run.HeadBranch,
run.Event,
run.HeadSha,
FormatTime(run.StartedAt, true),
FormatTime(completedAt, true),
duration,
)
} else {
t.addRow(
fmt.Sprintf("%d", run.ID),
run.Status,
workflowName,
run.HeadBranch,
run.Event,
FormatTime(run.StartedAt, false),
duration,
)
}
}
if len(runs) == 0 {
if machineReadable {
return t.print(output)
}
fmt.Printf("No workflow runs found\n")
return nil
}
@ -97,9 +141,10 @@ func ActionRunDetails(run *gitea.ActionWorkflowRun) {
fmt.Printf("Event: %s\n", run.Event)
fmt.Printf("Head SHA: %s\n", run.HeadSha)
fmt.Printf("Started: %s\n", FormatTime(run.StartedAt, false))
if !run.CompletedAt.IsZero() {
fmt.Printf("Completed: %s\n", FormatTime(run.CompletedAt, false))
duration := formatDurationMinutes(run.StartedAt, run.CompletedAt)
completedAt := validCompletion(run.StartedAt, run.CompletedAt)
if !completedAt.IsZero() {
fmt.Printf("Completed: %s\n", FormatTime(completedAt, false))
duration := formatDurationMinutes(run.StartedAt, completedAt)
fmt.Printf("Duration: %s\n", duration)
}
if run.RunAttempt > 1 {
@ -113,39 +158,182 @@ func ActionRunDetails(run *gitea.ActionWorkflowRun) {
}
}
type actionRunOutput struct {
ID int64 `json:"id" yaml:"id"`
RunNumber int64 `json:"run_number" yaml:"run_number"`
Status string `json:"status" yaml:"status"`
Conclusion string `json:"conclusion" yaml:"conclusion"`
Workflow string `json:"workflow" yaml:"workflow"`
Path string `json:"path" yaml:"path"`
Branch string `json:"branch" yaml:"branch"`
Event string `json:"event" yaml:"event"`
HeadSHA string `json:"head_sha" yaml:"head_sha"`
StartedAt string `json:"started_at" yaml:"started_at"`
CompletedAt string `json:"completed_at" yaml:"completed_at"`
Duration string `json:"duration" yaml:"duration"`
Attempt int64 `json:"attempt" yaml:"attempt"`
Actor string `json:"actor" yaml:"actor"`
URL string `json:"url" yaml:"url"`
}
type actionJobOutput struct {
ID int64 `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
Status string `json:"status" yaml:"status"`
Conclusion string `json:"conclusion" yaml:"conclusion"`
Runner string `json:"runner" yaml:"runner"`
HeadSHA string `json:"head_sha" yaml:"head_sha"`
StartedAt string `json:"started_at" yaml:"started_at"`
CompletedAt string `json:"completed_at" yaml:"completed_at"`
Duration string `json:"duration" yaml:"duration"`
}
type actionRunViewOutput struct {
Run actionRunOutput `json:"run" yaml:"run"`
Jobs []actionJobOutput `json:"jobs" yaml:"jobs"`
}
func newActionRunOutput(run *gitea.ActionWorkflowRun) actionRunOutput {
completedAt := validCompletion(run.StartedAt, run.CompletedAt)
actor := ""
if run.Actor != nil {
actor = run.Actor.UserName
}
return actionRunOutput{
ID: run.ID,
RunNumber: run.RunNumber,
Status: run.Status,
Conclusion: run.Conclusion,
Workflow: getWorkflowDisplayName(run),
Path: run.Path,
Branch: run.HeadBranch,
Event: run.Event,
HeadSHA: run.HeadSha,
StartedAt: FormatTime(run.StartedAt, true),
CompletedAt: FormatTime(completedAt, true),
Duration: formatDurationMinutes(run.StartedAt, completedAt),
Attempt: run.RunAttempt,
Actor: actor,
URL: run.HTMLURL,
}
}
func newActionJobOutput(job *gitea.ActionWorkflowJob) actionJobOutput {
completedAt := validCompletion(job.StartedAt, job.CompletedAt)
runner := job.RunnerName
if runner == "" {
runner = "-"
}
return actionJobOutput{
ID: job.ID,
Name: job.Name,
Status: job.Status,
Conclusion: job.Conclusion,
Runner: runner,
HeadSHA: job.HeadSha,
StartedAt: FormatTime(job.StartedAt, true),
CompletedAt: FormatTime(completedAt, true),
Duration: formatDurationMinutes(job.StartedAt, completedAt),
}
}
// ActionRunView prints a workflow run and its jobs as a single document for
// machine-readable output formats.
func ActionRunView(run *gitea.ActionWorkflowRun, jobs []*gitea.ActionWorkflowJob, output string) error {
switch output {
case "json", "yaml", "yml":
return fprintActionRunView(os.Stdout, run, jobs, output)
case "csv", "tsv":
return fmt.Errorf("workflow run view does not support %s output; use json or yaml for structured output", output)
default:
ActionRunDetails(run)
if len(jobs) > 0 {
fmt.Printf("\nJobs:\n\n")
return ActionWorkflowJobsList(jobs, output)
}
return nil
}
}
func fprintActionRunView(w io.Writer, run *gitea.ActionWorkflowRun, jobs []*gitea.ActionWorkflowJob, output string) error {
view := actionRunViewOutput{
Run: newActionRunOutput(run),
Jobs: make([]actionJobOutput, 0, len(jobs)),
}
for _, job := range jobs {
view.Jobs = append(view.Jobs, newActionJobOutput(job))
}
if output == "json" {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(view)
}
encoder := yaml.NewEncoder(w)
if err := encoder.Encode(view); err != nil {
_ = encoder.Close()
return err
}
return encoder.Close()
}
// ActionWorkflowJobsList prints a list of workflow jobs
func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) error {
t := table{
headers: []string{
machineReadable := isMachineReadable(output)
var headers []string
if machineReadable {
headers = []string{
"ID",
"Name",
"Status",
"Conclusion",
"Runner",
"Started",
"HeadSHA",
"StartedAt",
"CompletedAt",
"Duration",
},
}
} else {
headers = []string{"ID", "Name", "Status", "Runner", "Started", "Duration"}
}
machineReadable := isMachineReadable(output)
t := table{headers: headers}
for _, job := range jobs {
duration := formatDurationMinutes(job.StartedAt, job.CompletedAt)
completedAt := validCompletion(job.StartedAt, job.CompletedAt)
duration := formatDurationMinutes(job.StartedAt, completedAt)
runner := job.RunnerName
if runner == "" {
runner = "-"
}
t.addRow(
fmt.Sprintf("%d", job.ID),
job.Name,
job.Status,
runner,
FormatTime(job.StartedAt, machineReadable),
duration,
)
if machineReadable {
t.addRow(
fmt.Sprintf("%d", job.ID),
job.Name,
job.Status,
job.Conclusion,
runner,
job.HeadSha,
FormatTime(job.StartedAt, true),
FormatTime(completedAt, true),
duration,
)
} else {
t.addRow(
fmt.Sprintf("%d", job.ID),
job.Name,
job.Status,
runner,
FormatTime(job.StartedAt, false),
duration,
)
}
}
if len(jobs) == 0 {
if machineReadable {
return t.print(output)
}
fmt.Printf("No jobs found\n")
return nil
}

View file

@ -4,11 +4,15 @@
package print
import (
"bytes"
"encoding/json"
"testing"
"time"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestActionRunsListEmpty(t *testing.T) {
@ -53,6 +57,58 @@ func TestActionRunsListWithData(t *testing.T) {
require.NoError(t, ActionRunsList(runs, ""))
}
func TestActionRunsListJSONHasStableFields(t *testing.T) {
startedAt := time.Date(2026, time.August, 2, 10, 0, 0, 0, time.UTC)
runs := []*gitea.ActionWorkflowRun{{
ID: 7,
Status: "in_progress",
DisplayTitle: "CI",
Path: ".gitea/workflows/ci.yml",
HeadBranch: "main",
HeadSha: "abc123",
Event: "push",
StartedAt: startedAt,
CompletedAt: time.Unix(0, 0).UTC(),
}}
out := captureStdout(t, func() {
require.NoError(t, ActionRunsList(runs, "json"))
})
var rows []map[string]string
require.NoError(t, json.Unmarshal([]byte(out), &rows))
require.Len(t, rows, 1)
assert.Equal(t, "abc123", rows[0]["head_sha"])
assert.Equal(t, ".gitea/workflows/ci.yml", rows[0]["path"])
assert.Equal(t, "in_progress", rows[0]["status"])
assert.Equal(t, "", rows[0]["conclusion"])
assert.Equal(t, "2026-08-02T10:00:00Z", rows[0]["started_at"])
assert.Equal(t, "", rows[0]["completed_at"])
assert.NotContains(t, rows[0]["duration"], "-")
}
func TestActionRunsListEmptyJSONIsValid(t *testing.T) {
out := captureStdout(t, func() {
require.NoError(t, ActionRunsList(nil, "json"))
})
var rows []map[string]string
require.NoError(t, json.Unmarshal([]byte(out), &rows))
assert.Empty(t, rows)
}
func TestActionRunsListHumanColumnsRemainCompatible(t *testing.T) {
out := captureStdout(t, func() {
require.NoError(t, ActionRunsList([]*gitea.ActionWorkflowRun{{
ID: 1,
Status: "completed",
Conclusion: "success",
DisplayTitle: "CI",
}}, "table"))
})
assert.Contains(t, out, "WORKFLOW")
assert.NotContains(t, out, "CONCLUSION")
assert.NotContains(t, out, "HEAD SHA")
}
func TestActionRunDetails(t *testing.T) {
run := &gitea.ActionWorkflowRun{
ID: 123,
@ -83,6 +139,96 @@ func TestActionRunDetails(t *testing.T) {
ActionRunDetails(run)
}
func TestActionRunViewMachineOutputIsSingleDocument(t *testing.T) {
startedAt := time.Date(2026, time.August, 2, 10, 0, 0, 0, time.UTC)
completedAt := startedAt.Add(5 * time.Minute)
run := &gitea.ActionWorkflowRun{
ID: 123,
RunNumber: 42,
Status: "completed",
Conclusion: "failure",
DisplayTitle: "CI",
Path: ".gitea/workflows/ci.yml",
HeadBranch: "main",
HeadSha: "abc123",
Event: "push",
StartedAt: startedAt,
CompletedAt: completedAt,
RunAttempt: 2,
}
jobs := []*gitea.ActionWorkflowJob{{
ID: 456,
Name: "test",
Status: "completed",
Conclusion: "failure",
HeadSha: "abc123",
StartedAt: startedAt,
CompletedAt: completedAt,
RunnerName: "runner-1",
}}
for _, output := range []string{"json", "yaml"} {
t.Run(output, func(t *testing.T) {
buf := &bytes.Buffer{}
require.NoError(t, fprintActionRunView(buf, run, jobs, output))
var document struct {
Run struct {
HeadSHA string `json:"head_sha" yaml:"head_sha"`
Path string `json:"path" yaml:"path"`
Status string `json:"status" yaml:"status"`
Conclusion string `json:"conclusion" yaml:"conclusion"`
StartedAt string `json:"started_at" yaml:"started_at"`
CompletedAt string `json:"completed_at" yaml:"completed_at"`
} `json:"run" yaml:"run"`
Jobs []struct {
Conclusion string `json:"conclusion" yaml:"conclusion"`
} `json:"jobs" yaml:"jobs"`
}
if output == "json" {
require.NoError(t, json.Unmarshal(buf.Bytes(), &document))
} else {
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &document))
}
assert.Equal(t, "abc123", document.Run.HeadSHA)
assert.Equal(t, ".gitea/workflows/ci.yml", document.Run.Path)
assert.Equal(t, "completed", document.Run.Status)
assert.Equal(t, "failure", document.Run.Conclusion)
assert.Equal(t, "2026-08-02T10:00:00Z", document.Run.StartedAt)
assert.Equal(t, "2026-08-02T10:05:00Z", document.Run.CompletedAt)
require.Len(t, document.Jobs, 1)
assert.Equal(t, "failure", document.Jobs[0].Conclusion)
})
}
}
func TestActionRunViewNormalizesEpochCompletion(t *testing.T) {
startedAt := time.Date(2026, time.August, 2, 10, 0, 0, 0, time.UTC)
buf := &bytes.Buffer{}
require.NoError(t, fprintActionRunView(buf, &gitea.ActionWorkflowRun{
ID: 1,
Status: "in_progress",
StartedAt: startedAt,
CompletedAt: time.Unix(0, 0).UTC(),
}, nil, "json"))
var document actionRunViewOutput
require.NoError(t, json.Unmarshal(buf.Bytes(), &document))
assert.Empty(t, document.Run.CompletedAt)
assert.NotContains(t, document.Run.Duration, "-")
assert.Empty(t, document.Jobs)
}
func TestActionRunViewRejectsFlatStructuredFormats(t *testing.T) {
run := &gitea.ActionWorkflowRun{ID: 1}
for _, output := range []string{"csv", "tsv"} {
t.Run(output, func(t *testing.T) {
err := ActionRunView(run, nil, output)
require.ErrorContains(t, err, "use json or yaml")
})
}
}
func TestActionWorkflowJobsListEmpty(t *testing.T) {
// Test with empty jobs - should not panic
defer func() {
@ -123,6 +269,20 @@ func TestActionWorkflowJobsListWithData(t *testing.T) {
require.NoError(t, ActionWorkflowJobsList(jobs, ""))
}
func TestActionWorkflowJobsListHumanColumnsRemainCompatible(t *testing.T) {
out := captureStdout(t, func() {
require.NoError(t, ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{{
ID: 1,
Name: "test",
Status: "completed",
Conclusion: "success",
}}, "table"))
})
assert.Contains(t, out, "RUNNER")
assert.NotContains(t, out, "CONCLUSION")
assert.NotContains(t, out, "HEAD SHA")
}
func TestActionWorkflowsListEmpty(t *testing.T) {
defer func() {
if r := recover(); r != nil {
@ -243,6 +403,12 @@ func TestFormatDurationMinutes(t *testing.T) {
completed: now,
expected: "2h30m",
},
{
name: "completion before start",
started: now,
completed: now.Add(-time.Hour),
expected: "",
},
}
for _, test := range tests {