diff --git a/cmd/actions/runs/list.go b/cmd/actions/runs/list.go index 40f6fc1c..0506ad28 100644 --- a/cmd/actions/runs/list.go +++ b/cmd/actions/runs/list.go @@ -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() { diff --git a/cmd/actions/runs/list_test.go b/cmd/actions/runs/list_test.go index 82258996..bf86ed3a 100644 --- a/cmd/actions/runs/list_test.go +++ b/cmd/actions/runs/list_test.go @@ -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{ diff --git a/cmd/actions/runs/view.go b/cmd/actions/runs/view.go index efd4dfa6..9f19a808 100644 --- a/cmd/actions/runs/view.go +++ b/cmd/actions/runs/view.go @@ -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) } diff --git a/docs/CLI.md b/docs/CLI.md index 3f74a38e..491759c7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 diff --git a/modules/print/actions_runs.go b/modules/print/actions_runs.go index 7e0809c4..9b650d8d 100644 --- a/modules/print/actions_runs.go +++ b/modules/print/actions_runs.go @@ -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 } diff --git a/modules/print/actions_runs_test.go b/modules/print/actions_runs_test.go index fe9dff2d..22312d3a 100644 --- a/modules/print/actions_runs_test.go +++ b/modules/print/actions_runs_test.go @@ -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 {