From afcd11c77f91828700ea1761c338d744b6951b8b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 13 Feb 2026 09:16:43 +0100 Subject: [PATCH] BUG: Fix workflow run jobs API returning null steps (#36603) ## Problem `GET /api/v1/repos/{owner}/{repo}/actions/runs/{runId}/jobs` was always returning `steps: null` for each job. ## Cause In `convert.ToActionWorkflowJob`, when the job had a `TaskID` we loaded the task with `db.GetByID` but never loaded `task.Steps`. `ActionTask.Steps` is not stored in the task row (`xorm:"-"`); it comes from `action_task_step` and is only filled by `task.LoadAttributes()` / `GetTaskStepsByTaskID()`. So the conversion loop over `task.Steps` always saw nil and produced no steps in the API response. ## Solution After resolving the task (by ID when the caller passes `nil`), we now load its steps with `GetTaskStepsByTaskID(ctx, task.ID)` and set `task.Steps` before building the API steps slice. No other behavior is changed. ## Testing - New integration test `TestAPIListWorkflowRunJobsReturnsSteps`: calls the runs/{runId}/jobs endpoint, inserts a task step for a fixture job, and asserts that the response includes non-null, non-empty `steps` with the expected step data. - `make test-sqlite#TestAPIListWorkflowRunJobsReturnsSteps` passes with this fix. --------- Co-authored-by: Manav --- services/convert/convert.go | 39 ++++++++++------- tests/integration/api_actions_run_test.go | 53 +++++++++++++++++++---- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/services/convert/convert.go b/services/convert/convert.go index c081aec771b..e1cd30705e7 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -349,20 +349,29 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task } } - runnerID = task.RunnerID - if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok { - runnerName = runner.Name - } - for i, step := range task.Steps { - stepStatus, stepConclusion := ToActionsStatus(job.Status) - steps = append(steps, &api.ActionWorkflowStep{ - Name: step.Name, - Number: int64(i), - Status: stepStatus, - Conclusion: stepConclusion, - StartedAt: step.Started.AsTime().UTC(), - CompletedAt: step.Stopped.AsTime().UTC(), - }) + if task != nil { + if task.Steps == nil { + task.Steps, err = actions_model.GetTaskStepsByTaskID(ctx, task.ID) + if err != nil { + return nil, err + } + task.Steps = util.SliceNilAsEmpty(task.Steps) + } + runnerID = task.RunnerID + if runner, ok, _ := db.GetByID[actions_model.ActionRunner](ctx, runnerID); ok { + runnerName = runner.Name + } + for i, step := range task.Steps { + stepStatus, stepConclusion := ToActionsStatus(job.Status) + steps = append(steps, &api.ActionWorkflowStep{ + Name: step.Name, + Number: int64(i), + Status: stepStatus, + Conclusion: stepConclusion, + StartedAt: step.Started.AsTime().UTC(), + CompletedAt: step.Stopped.AsTime().UTC(), + }) + } } } @@ -383,7 +392,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task Conclusion: conclusion, RunnerID: runnerID, RunnerName: runnerName, - Steps: steps, + Steps: util.SliceNilAsEmpty(steps), CreatedAt: job.Created.AsTime().UTC(), StartedAt: job.Started.AsTime().UTC(), CompletedAt: job.Stopped.AsTime().UTC(), diff --git a/tests/integration/api_actions_run_test.go b/tests/integration/api_actions_run_test.go index a0292f8f8b5..48384095602 100644 --- a/tests/integration/api_actions_run_test.go +++ b/tests/integration/api_actions_run_test.go @@ -6,16 +6,21 @@ package integration import ( "fmt" "net/http" + "slices" "testing" + actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/json" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAPIActionsGetWorkflowRun(t *testing.T) { @@ -26,15 +31,45 @@ func TestAPIActionsGetWorkflowRun(t *testing.T) { session := loginUser(t, user.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/802802", repo.FullName())). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusNotFound) - req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/802", repo.FullName())). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusNotFound) - req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/803", repo.FullName())). - AddTokenAuth(token) - MakeRequest(t, req, http.StatusOK) + t.Run("GetRun", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/802802", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/802", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/803", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("GetJobSteps", func(t *testing.T) { + // Insert task steps for task_id 53 (job 198) so the API can return them once the backend loads them + _, err := db.GetEngine(t.Context()).Insert(&actions_model.ActionTaskStep{ + Name: "main", + TaskID: 53, + Index: 0, + RepoID: repo.ID, + Status: actions_model.StatusSuccess, + Started: timeutil.TimeStamp(1683636528), + Stopped: timeutil.TimeStamp(1683636626), + }) + require.NoError(t, err) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var jobList api.ActionWorkflowJobsResponse + err = json.Unmarshal(resp.Body.Bytes(), &jobList) + require.NoError(t, err) + + job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 }) + require.NotEqual(t, -1, job198Idx, "expected to find job 198 in run 795 jobs list") + job198 := jobList.Entries[job198Idx] + require.NotEmpty(t, job198.Steps, "job must return at least one step when task has steps") + assert.Equal(t, "main", job198.Steps[0].Name, "first step name") + }) } func TestAPIActionsGetWorkflowJob(t *testing.T) {