diff --git a/internal/app/actions.go b/internal/app/actions.go index 19d9234b..09fe0132 100644 --- a/internal/app/actions.go +++ b/internal/app/actions.go @@ -158,7 +158,7 @@ func (a *application) runCommand() *cobra.Command { return writeList(a, cmd, items, runRow) }} pageFlags(list, &filters.Limit, &filters.Page) - started.register(list, "since", "Runs started at or after a date, RFC3339 timestamp, or duration ago (24h)", "until", "Runs started at or before a date, RFC3339 timestamp, or duration ago") + started.register(list, "since", "Runs created at or after a date, RFC3339 timestamp, or duration ago (24h)", "until", "Runs created at or before a date, RFC3339 timestamp, or duration ago") list.Flags().StringVar(&filters.Branch, "branch", "", "Filter by branch name") list.Flags().StringVar(&filters.Event, "event", "", "Filter by triggering event") list.Flags().StringVar(&filters.Actor, "actor", "", "Filter by triggering username") diff --git a/internal/services/actions.go b/internal/services/actions.go index 21ad5c87..e9a6bcb1 100644 --- a/internal/services/actions.go +++ b/internal/services/actions.go @@ -28,22 +28,23 @@ func workflowFrom(workflow *gitea.ActionsWorkflow) Workflow { return Workflow{ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: workflow.State} } -// Run is the stable command representation of an Actions run. +// Run is the stable command representation of an Actions run. StartedAt and CompletedAt are nil +// until the run starts and completes. type Run struct { - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - StartedAt string `json:"started_at"` - CompletedAt string `json:"completed_at"` - ID int64 `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - URL string `json:"url"` - Branch string `json:"branch"` - Event string `json:"event,omitempty"` - Actor string `json:"actor,omitempty"` - Commit string `json:"commit,omitempty"` - Attempt int64 `json:"attempt,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + ID int64 `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` + Branch string `json:"branch"` + Event string `json:"event,omitempty"` + Actor string `json:"actor,omitempty"` + Commit string `json:"commit,omitempty"` + Attempt int64 `json:"attempt,omitempty"` } type runResponse struct { CreatedAt string `json:"created_at"` @@ -67,11 +68,21 @@ type runResponse struct { func (r runResponse) result() Run { return Run{ ID: r.ID, Title: r.DisplayTitle, Status: r.Status, Conclusion: r.Conclusion, URL: r.HTMLURL, Branch: r.HeadBranch, - CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: r.StartedAt, CompletedAt: r.CompletedAt, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, StartedAt: runTime(r.StartedAt), CompletedAt: runTime(r.CompletedAt), Event: r.Event, Actor: r.Actor.Login, Commit: r.HeadSHA, Attempt: r.RunAttempt, } } +// runTime returns an Actions timestamp, or nil for the zero or Unix epoch time Gitea reports +// for a run or job that has not started or completed. +func runTime(raw string) *string { + t, err := time.Parse(time.RFC3339Nano, raw) + if raw == "" || (err == nil && t.Unix() <= 0) { + return nil + } + return &raw +} + // DispatchResult identifies a workflow run created by dispatch when the server returns details. type DispatchResult struct { ID int64 `json:"id"` @@ -171,14 +182,15 @@ func (s Actions) Runs(ctx context.Context, opts RunListOptions) ([]Run, error) { return result, nil } for _, item := range items { - if item.StartedAt == "" { - continue - } - started, err := time.Parse(time.RFC3339Nano, item.StartedAt) + created, err := time.Parse(time.RFC3339Nano, item.CreatedAt) if err != nil { - return nil, errors.New("server returned an invalid run start timestamp") + return nil, errors.New("server returned an invalid run creation timestamp") } - if opts.Since != nil && started.Before(*opts.Since) || opts.Until != nil && started.After(*opts.Until) { + // Runs are listed newest first, so the rest were created earlier still. + if opts.Since != nil && created.Before(*opts.Since) { + return result, nil + } + if opts.Until != nil && created.After(*opts.Until) { continue } if matched >= first { diff --git a/internal/services/actions_test.go b/internal/services/actions_test.go index 58b7d097..2a794ad3 100644 --- a/internal/services/actions_test.go +++ b/internal/services/actions_test.go @@ -205,35 +205,60 @@ func TestRunFiltersEncodeServerQueriesAndRejectInvalidStatus(t *testing.T) { } func TestRunTimeFilteringPaginatesMatchingResults(t *testing.T) { - since := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC) + until := time.Date(2028, 1, 2, 0, 0, 0, 0, time.UTC) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Query().Get("page") { case "1": - _, _ = w.Write([]byte(`{"workflow_runs":[{"id":1,"started_at":"2027-12-31T00:00:00Z"},{"id":2,"started_at":"2028-01-01T00:00:00Z"}]}`)) + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":4,"created_at":"2028-01-03T00:00:00Z"},{"id":3,"created_at":"2028-01-01T12:00:00Z"}]}`)) case "2": - _, _ = w.Write([]byte(`{"workflow_runs":[{"id":3,"started_at":"2028-01-02T00:00:00Z"}]}`)) + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":2,"created_at":"2028-01-01T00:00:00Z"}]}`)) default: _, _ = w.Write([]byte(`{"workflow_runs":[]}`)) } })) defer server.Close() service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} - items, err := service.Runs(context.Background(), RunListOptions{Limit: 1, Page: 2, Since: &since}) + items, err := service.Runs(context.Background(), RunListOptions{Limit: 1, Page: 2, Until: &until}) require.NoError(t, err) require.Len(t, items, 1) - require.EqualValues(t, 3, items[0].ID) + require.EqualValues(t, 2, items[0].ID) } -func TestRunTimeFilteringStopsAtScanCap(t *testing.T) { +func TestRunTimeFilteringUsesCreationTimeAndStopsAtOlderRuns(t *testing.T) { since := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC) pages := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { pages++ - _, _ = w.Write([]byte(`{"workflow_runs":[{"id":1,"started_at":"2027-12-31T00:00:00Z"}]}`)) + _, _ = w.Write([]byte(`{"workflow_runs":[ + {"id":3,"status":"queued","created_at":"2028-01-02T00:00:00Z","started_at":"1970-01-01T00:00:00Z","completed_at":"1970-01-01T00:00:00Z"}, + {"id":2,"status":"completed","created_at":"2028-01-01T00:00:00Z","started_at":"2028-01-01T00:00:05Z","completed_at":"2028-01-01T00:01:00Z"}, + {"id":1,"status":"completed","created_at":"2027-12-31T00:00:00Z","started_at":"2027-12-31T00:00:05Z"}]}`)) })) defer server.Close() service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} - _, err := service.Runs(context.Background(), RunListOptions{Limit: 1, Page: 1, Since: &since}) + items, err := service.Runs(context.Background(), RunListOptions{Limit: 30, Page: 1, Since: &since}) + require.NoError(t, err) + require.Equal(t, 1, pages) + require.Len(t, items, 2) + require.EqualValues(t, 3, items[0].ID) + require.Nil(t, items[0].StartedAt) + require.Nil(t, items[0].CompletedAt) + require.Equal(t, "2028-01-01T00:00:05Z", *items[1].StartedAt) + data, err := json.Marshal(items[0]) + require.NoError(t, err) + require.Contains(t, string(data), `"started_at":null,"completed_at":null`) +} + +func TestRunTimeFilteringStopsAtScanCap(t *testing.T) { + until := time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC) + pages := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pages++ + _, _ = w.Write([]byte(`{"workflow_runs":[{"id":1,"created_at":"2028-12-31T00:00:00Z"}]}`)) + })) + defer server.Close() + service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} + _, err := service.Runs(context.Background(), RunListOptions{Limit: 1, Page: 1, Until: &until}) require.ErrorContains(t, err, "narrow the search") require.Equal(t, maxRunScanPages, pages) }