From 3058f5ddf9bb01fed65210b1778bea29def24fb7 Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 17 Oct 2026 09:47:17 +0200 Subject: [PATCH] perf(actions): batch per-row queries in run, job and cancellation paths (#39723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several Actions code paths issued one or more queries per listed row. They now load in batch, with unchanged output. - API job list: tasks, steps and runners load in batch - API run list: latest attempts and their trigger users load in batch - Repository runs page: the jobs of all active runs load in one query - Cancelling previous runs and replacing concurrency group entries fetch their jobs in one query Per call with 50 rows on SQLite, base vs head: | Path | Queries | Time | | --- | --- | --- | | API job list | 254 → 6 | 12.9 → 1.9 ms | | API run list | 305 → 207 | 11.7 → 6.2 ms | | Runs page job loading | 100 → 1 | 6.5 → 0.47 ms | | `CancelPreviousJobs` | 546 → 496 | ~120 ms, per-job writes dominate | The remaining run list queries come from per-run repository conversion, which this PR doesn't change. --------- Co-authored-by: Zettat123 Co-authored-by: silverwind --- models/actions/run.go | 30 +++++----- models/actions/run_job.go | 72 ++++++++++------------- models/actions/run_job_test.go | 45 +++++++++++++++ models/actions/run_list.go | 17 ++++++ models/actions/runner.go | 8 +++ models/actions/task_list.go | 24 ++++++++ routers/api/v1/shared/action.go | 36 +++++------- routers/web/repo/actions/actions.go | 21 +++---- services/convert/action_test.go | 19 ++++-- services/convert/convert.go | 90 ++++++++++++++++++++--------- 10 files changed, 241 insertions(+), 121 deletions(-) diff --git a/models/actions/run.go b/models/actions/run.go index 2a693af9bf6..9895b418f30 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -15,6 +15,7 @@ import ( "gitea.dev/models/db" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" + "gitea.dev/modules/container" "gitea.dev/modules/git" "gitea.dev/modules/json" "gitea.dev/modules/log" @@ -408,26 +409,27 @@ func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunA return nil, nil } - var jobsToCancel []*ActionRunJob - attempts, jobs, err := getConcurrencyEntriesToReplace(ctx, attempt.RepoID, attempt.ConcurrencyGroup, attempt.ConcurrencyCancel) if err != nil { return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) } jobs = slices.DeleteFunc(jobs, func(job *ActionRunJob) bool { return job.RunID == attempt.RunID }) - jobsToCancel = append(jobsToCancel, jobs...) // cancel runs in the same concurrency group - for _, concurrentAttempt := range attempts { - if concurrentAttempt.RunID == attempt.RunID { - continue - } - jobs, err := GetRunJobsByRunAndAttemptID(ctx, concurrentAttempt.RunID, concurrentAttempt.ID) - if err != nil { - return nil, fmt.Errorf("find run %d attempt %d jobs: %w", concurrentAttempt.RunID, concurrentAttempt.ID, err) - } - jobsToCancel = append(jobsToCancel, jobs...) + attempts = slices.DeleteFunc(attempts, func(concurrentAttempt *ActionRunAttempt) bool { return concurrentAttempt.RunID == attempt.RunID }) + attemptJobs, err := getRunJobsByAttempts(ctx, attempts) + if err != nil { + return nil, fmt.Errorf("find concurrent attempt jobs: %w", err) } - - return CancelJobs(ctx, jobsToCancel, false) + return CancelJobs(ctx, append(jobs, attemptJobs...), false) +} + +func getRunJobsByAttempts(ctx context.Context, attempts []*ActionRunAttempt) (jobs []*ActionRunJob, _ error) { + for chunk := range slices.Chunk(attempts, db.DefaultMaxInSize) { + attemptIDs := container.FilterSlice(chunk, func(attempt *ActionRunAttempt) (int64, bool) { return attempt.ID, true }) + if err := db.GetEngine(ctx).In("run_attempt_id", attemptIDs).Find(&jobs); err != nil { + return nil, err + } + } + return jobs, nil } diff --git a/models/actions/run_job.go b/models/actions/run_job.go index e8a83a04e27..57cca20a848 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -271,6 +271,24 @@ func GetLatestAttemptJobsByRun(ctx context.Context, run *ActionRun) (ActionJobLi return jobs, nil } +func GetLatestAttemptJobsByRuns(ctx context.Context, runs []*ActionRun) (map[int64]ActionJobList, error) { + result := make(map[int64]ActionJobList, len(runs)) + if len(runs) == 0 { + return result, nil + } + attemptIDs := container.FilterSlice(runs, func(run *ActionRun) (int64, bool) { return run.LatestAttemptID, run.LatestAttemptID != 0 }) + legacyRunIDs := container.FilterSlice(runs, func(run *ActionRun) (int64, bool) { return run.ID, run.LatestAttemptID == 0 }) + cond := builder.In("run_attempt_id", attemptIDs).Or(builder.Eq{"run_attempt_id": 0}.And(builder.In("run_id", legacyRunIDs))) + var jobs []*ActionRunJob + if err := db.GetEngine(ctx).Where(cond).OrderBy("id").Find(&jobs); err != nil { + return nil, err + } + for _, job := range jobs { + result[job.RunID] = append(result[job.RunID], job) + } + return result, nil +} + // GetAllRunJobsByRepoAndRunID returns all jobs for a run across all attempts. func GetAllRunJobsByRepoAndRunID(ctx context.Context, repoID, runID int64) (ActionJobList, error) { var jobs []*ActionRunJob @@ -685,43 +703,18 @@ func hasFailFastMatrixFailure(jobs []*ActionRunJob) bool { // It's useful when a new run is triggered, and all previous runs needn't be continued anymore. func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) ([]*ActionRunJob, error) { // Find all runs in the specified repository, reference, and workflow with non-final status - runs, total, err := db.FindAndCount[ActionRun](ctx, FindRunOptions{ + runIDs := builder.Select("id").From("action_run").Where(FindRunOptions{ RepoID: repoID, Ref: ref, WorkflowID: workflowID, TriggerEvent: event, Status: []Status{StatusRunning, StatusWaiting, StatusBlocked, StatusCancelling}, - }) - if err != nil { + }.ToConds()) + var jobs []*ActionRunJob + if err := db.GetEngine(ctx).Where(builder.In("run_id", runIDs)).Find(&jobs); err != nil { return nil, err } - - // If there are no runs found, there's no need to proceed with cancellation, so return nil. - if total == 0 { - return nil, nil - } - - cancelledJobs := make([]*ActionRunJob, 0, total) - - // Iterate over each found run and cancel its associated jobs. - for _, run := range runs { - // Find all jobs associated with the current run. - jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ - RunID: run.ID, - }) - if err != nil { - return cancelledJobs, err - } - - cjs, err := CancelJobs(ctx, jobs, false) - if err != nil { - return cancelledJobs, err - } - cancelledJobs = append(cancelledJobs, cjs...) - } - - // Return nil to indicate successful cancellation of all running and waiting jobs. - return cancelledJobs, nil + return CancelJobs(ctx, jobs, false) } // GetAncestorCallerIDs returns the IDs of the reusable workflow callers the job is nested in. @@ -738,7 +731,7 @@ func GetAncestorCallerIDs(ctx context.Context, job *ActionRunJob) (container.Set return ids, nil } -func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) (jobsToCancel []*ActionRunJob, _ error) { +func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) ([]*ActionRunJob, error) { if job.RawConcurrency == "" { return nil, nil } @@ -758,21 +751,14 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) return nil, err } jobs = slices.DeleteFunc(jobs, func(j *ActionRunJob) bool { return j.ID == job.ID || callerIDs.Contains(j.ID) }) - jobsToCancel = append(jobsToCancel, jobs...) // cancel runs in the same concurrency group - for _, attempt := range attempts { - if attempt.ID == job.RunAttemptID { - continue - } - jobs, err := GetRunJobsByRunAndAttemptID(ctx, attempt.RunID, attempt.ID) - if err != nil { - return nil, fmt.Errorf("find run %d attempt %d jobs: %w", attempt.RunID, attempt.ID, err) - } - jobsToCancel = append(jobsToCancel, jobs...) + attempts = slices.DeleteFunc(attempts, func(attempt *ActionRunAttempt) bool { return attempt.ID == job.RunAttemptID }) + attemptJobs, err := getRunJobsByAttempts(ctx, attempts) + if err != nil { + return nil, fmt.Errorf("find concurrent attempt jobs: %w", err) } - - return CancelJobs(ctx, jobsToCancel, false) + return CancelJobs(ctx, append(jobs, attemptJobs...), false) } // CancelJobs cancels every cancellable job it is given, force skipping the graceful cancelling diff --git a/models/actions/run_job_test.go b/models/actions/run_job_test.go index 144ea141381..9ab0babee24 100644 --- a/models/actions/run_job_test.go +++ b/models/actions/run_job_test.go @@ -467,3 +467,48 @@ func TestCancelJobs_CallerWaitsForCancellingChild(t *testing.T) { } assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID}).Status) } + +func TestBatchLoadAndCancelRunJobs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + legacyRun := &ActionRun{RepoID: 4, OwnerID: 1, Index: 9600, TriggerUserID: 1, Ref: "refs/heads/batch", Status: StatusRunning} + run := &ActionRun{RepoID: 4, OwnerID: 1, Index: 9601, TriggerUserID: 1, Ref: "refs/heads/batch", Status: StatusRunning} + require.NoError(t, db.Insert(ctx, legacyRun, run)) + require.NoError(t, db.Insert(ctx, &ActionRunJob{RunID: legacyRun.ID, RepoID: legacyRun.RepoID, OwnerID: legacyRun.OwnerID})) + oldAttempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1} + latestAttempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 2, TriggerUserID: 2} + require.NoError(t, db.Insert(ctx, oldAttempt, latestAttempt)) + for _, attemptID := range []int64{oldAttempt.ID, latestAttempt.ID, latestAttempt.ID} { + require.NoError(t, db.Insert(ctx, &ActionRunJob{RunID: run.ID, RunAttemptID: attemptID, RepoID: run.RepoID, OwnerID: run.OwnerID})) + } + run.LatestAttemptID = latestAttempt.ID + require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id")) + + runs := RunList{legacyRun, run} + jobsByRun, err := GetLatestAttemptJobsByRuns(ctx, runs) + require.NoError(t, err) + for _, listedRun := range runs { + expected, err := GetLatestAttemptJobsByRun(ctx, listedRun) + require.NoError(t, err) + require.NotEmpty(t, expected) + assert.Equal(t, expected, jobsByRun[listedRun.ID]) + } + + attempts, err := runs.GetLatestAttempts(ctx) + require.NoError(t, err) + require.Len(t, attempts, 1) + assert.EqualValues(t, 2, attempts[latestAttempt.ID].TriggerUser.ID) + + chunkedAttempts := []*ActionRunAttempt{oldAttempt} + for i := range db.DefaultMaxInSize { + chunkedAttempts = append(chunkedAttempts, &ActionRunAttempt{ID: -int64(i + 1)}) + } + attemptJobs, err := getRunJobsByAttempts(ctx, append(chunkedAttempts, latestAttempt)) + require.NoError(t, err) + assert.Len(t, attemptJobs, 3) + + cancelled, err := CancelPreviousJobs(ctx, run.RepoID, run.Ref, "", "") + require.NoError(t, err) + assert.Len(t, cancelled, 4) +} diff --git a/models/actions/run_list.go b/models/actions/run_list.go index b752f25b0d1..d16508868ce 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -5,6 +5,8 @@ package actions import ( "context" + "maps" + "slices" "gitea.dev/models/db" repo_model "gitea.dev/models/repo" @@ -42,6 +44,21 @@ func (runs RunList) LoadTriggerUser(ctx context.Context) error { return nil } +// GetLatestAttempts returns the latest attempts of the runs keyed by attempt ID +func (runs RunList) GetLatestAttempts(ctx context.Context) (map[int64]*ActionRunAttempt, error) { + attemptIDs := container.FilterSlice(runs, func(run *ActionRun) (int64, bool) { + return run.LatestAttemptID, run.LatestAttemptID != 0 + }) + attempts := make(map[int64]*ActionRunAttempt, len(attemptIDs)) + if len(attemptIDs) == 0 { + return attempts, nil + } + if err := db.GetEngine(ctx).In("id", attemptIDs).Find(&attempts); err != nil { + return nil, err + } + return attempts, ActionRunAttemptList(slices.Collect(maps.Values(attempts))).LoadTriggerUser(ctx) +} + func (runs RunList) LoadRepos(ctx context.Context) error { repoIDs := container.FilterSlice(runs, func(run *ActionRun) (int64, bool) { return run.RepoID, run.Repo == nil diff --git a/models/actions/runner.go b/models/actions/runner.go index 4a9a1ac0b50..1494061d898 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -207,6 +207,14 @@ func init() { db.RegisterModel(&ActionRunner{}) } +func GetRunnersMapByIDs(ctx context.Context, ids []int64) (map[int64]*ActionRunner, error) { + runners := make(map[int64]*ActionRunner, len(ids)) + if len(ids) == 0 { + return runners, nil + } + return runners, db.GetEngine(ctx).In("id", ids).Find(&runners) +} + // FindRunnerOptions // ownerID == 0 and repoID == 0 means any runner including global runners // repoID != 0 and WithAvailable == false means any runner for the given repo diff --git a/models/actions/task_list.go b/models/actions/task_list.go index 5df0f3bfa58..ee0a6b83abd 100644 --- a/models/actions/task_list.go +++ b/models/actions/task_list.go @@ -9,6 +9,7 @@ import ( "gitea.dev/models/db" "gitea.dev/modules/container" "gitea.dev/modules/timeutil" + "gitea.dev/modules/util" "xorm.io/builder" ) @@ -41,6 +42,29 @@ func (tasks TaskList) LoadJobs(ctx context.Context) error { return jobsList.LoadAttributes(ctx, true) } +func (tasks TaskList) LoadSteps(ctx context.Context) error { + taskIDs := container.FilterSlice(tasks, func(task *ActionTask) (int64, bool) { + return task.ID, task.Steps == nil + }) + if len(taskIDs) == 0 { + return nil + } + var steps []*ActionTaskStep + if err := db.GetEngine(ctx).In("task_id", taskIDs).OrderBy("task_id, `index`").Find(&steps); err != nil { + return err + } + stepsByTask := make(map[int64][]*ActionTaskStep, len(taskIDs)) + for _, step := range steps { + stepsByTask[step.TaskID] = append(stepsByTask[step.TaskID], step) + } + for _, task := range tasks { + if task.Steps == nil { + task.Steps = util.SliceNilAsEmpty(stepsByTask[task.ID]) + } + } + return nil +} + func (tasks TaskList) LoadAttributes(ctx context.Context) error { return tasks.LoadJobs(ctx) } diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index ac6c88bc188..7d8ca3485e9 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -90,27 +90,16 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptI return } - res.Entries = make([]*api.ActionWorkflowJob, len(jobs)) - - isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID - for i := range jobs { - var repository *repo_model.Repository - if isRepoLevel { - repository = ctx.Repo.Repository - } else { - if jobs[i].Run == nil || jobs[i].Run.Repo == nil { - ctx.APIErrorInternal(fmt.Errorf("job %d is missing its run or repository", jobs[i].ID)) - return - } - repository = jobs[i].Run.Repo - } - - convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, repository, nil, jobs[i]) - if err != nil { - ctx.APIErrorInternal(err) + for _, job := range jobs { + if job.Run == nil || job.Run.Repo == nil { + ctx.APIErrorInternal(fmt.Errorf("job %d is missing its run or repository", job.ID)) return } - res.Entries[i] = convertedWorkflowJob + } + res.Entries, err = convert.ToActionWorkflowJobs(ctx, jobs) + if err != nil { + ctx.APIErrorInternal(err) + return } ctx.SetLinkHeader(total, listOptions.PageSize) ctx.SetTotalCountHeader(total) @@ -224,6 +213,12 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) return } + latestAttempts, err := runList.GetLatestAttempts(ctx) + if err != nil { + ctx.APIErrorInternal(err) + return + } + res.Entries = make([]*api.ActionWorkflowRun, 0, len(runs)) for _, run := range runs { if run.Repo == nil { @@ -232,8 +227,7 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) total-- continue } - // TODO: load run attempts in batch - convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, excludePullRequests) + convertedRun, err := convert.ToActionWorkflowRun(ctx, run, latestAttempts[run.LatestAttemptID], excludePullRequests) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 0fb986d0bc2..1d1ee7bff63 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -543,17 +543,18 @@ func (data *actionRunListData) processActionRuns(ctx *context.Context) bool { return false } + activeRuns := slices.DeleteFunc(slices.Clone(data.ActionRuns), func(run *actions_model.ActionRun) bool { + return !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) + }) + jobsByRun, err := actions_model.GetLatestAttemptJobsByRuns(ctx, activeRuns) + if err != nil { + ctx.ServerError("GetLatestAttemptJobsByRuns", err) + return false + } + data.RunErrors = make(map[int64]string) - for _, run := range data.ActionRuns { - if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { - continue - } - jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) - if err != nil { - ctx.ServerError("GetRunJobsByRunID", err) - return false - } - for _, job := range jobs { + for _, run := range activeRuns { + for _, job := range jobsByRun[run.ID] { // A deferred matrix is unresolvable until its needs finish, so the whole per-job block // is skipped: parsing the payload would report a valid workflow as invalid. if job.IsMatrixDeferred || !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusPending) { diff --git a/services/convert/action_test.go b/services/convert/action_test.go index 2b47f1d3690..9faefc671d7 100644 --- a/services/convert/action_test.go +++ b/services/convert/action_test.go @@ -15,6 +15,7 @@ import ( "gitea.dev/models/unittest" "gitea.dev/modules/git" "gitea.dev/modules/git/gitcmd" + api "gitea.dev/modules/structs" "gitea.dev/modules/util" "github.com/stretchr/testify/assert" @@ -126,7 +127,7 @@ func TestToActionWorkflowRun_UsesTriggerEvent(t *testing.T) { assert.Equal(t, "schedule", apiRun.Event) } -func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) { +func TestToActionWorkflowJob_StepStatusAndBatchParity(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) ctx := t.Context() @@ -142,10 +143,11 @@ func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) { require.NoError(t, db.Insert(ctx, run)) task := &actions_model.ActionTask{ - ID: 900102, - JobID: 9001, - RepoID: 2, - Status: actions_model.StatusFailure, + ID: 900102, + JobID: 9001, + RepoID: 2, + RunnerID: 34346, + Status: actions_model.StatusFailure, } require.NoError(t, db.Insert(ctx, task)) @@ -162,8 +164,8 @@ func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) { require.NoError(t, db.Insert(ctx, job)) require.NoError(t, db.Insert(ctx, - &actions_model.ActionTaskStep{TaskID: task.ID, RepoID: 2, Index: 0, Name: "step-success", Status: actions_model.StatusSuccess}, &actions_model.ActionTaskStep{TaskID: task.ID, RepoID: 2, Index: 1, Name: "step-failure", Status: actions_model.StatusFailure}, + &actions_model.ActionTaskStep{TaskID: task.ID, RepoID: 2, Index: 0, Name: "step-success", Status: actions_model.StatusSuccess}, )) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) @@ -172,10 +174,15 @@ func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) { require.NoError(t, err) require.Len(t, apiJob.Steps, 2) + assert.Equal(t, "runner_to_be_deleted-user", apiJob.RunnerName) assert.Equal(t, "completed", apiJob.Steps[0].Status, "step 0 status") assert.Equal(t, "success", apiJob.Steps[0].Conclusion, "step 0 conclusion (succeeded before the failure)") assert.Equal(t, "completed", apiJob.Steps[1].Status, "step 1 status") assert.Equal(t, "failure", apiJob.Steps[1].Conclusion, "step 1 conclusion") + + apiJobs, err := ToActionWorkflowJobs(ctx, []*actions_model.ActionRunJob{job}) + require.NoError(t, err) + assert.Equal(t, []*api.ActionWorkflowJob{apiJob}, apiJobs) } func TestToActionsStatus(t *testing.T) { diff --git a/services/convert/convert.go b/services/convert/convert.go index f9df55bf429..f89b1a604c4 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -491,11 +491,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task return nil, err } - status, conclusion := ToRunActionsStatus(job.Run, job.Status) - var runnerID int64 - var runnerName string - var steps []*api.ActionWorkflowStep - + var runner *actions_model.ActionRunner if effectiveTaskID := job.EffectiveTaskID(); effectiveTaskID != 0 { if task == nil { task, _, err = db.GetByID[actions_model.ActionTask](ctx, effectiveTaskID) @@ -505,28 +501,68 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task } 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(step.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 err := (actions_model.TaskList{task}).LoadSteps(ctx); err != nil { + return nil, err } + runner, _, _ = db.GetByID[actions_model.ActionRunner](ctx, task.RunnerID) + } + } + return toActionWorkflowJob(ctx, repo, task, runner, job), nil +} + +// ToActionWorkflowJobs requires job.Run.Repo to be loaded +func ToActionWorkflowJobs(ctx context.Context, jobs []*actions_model.ActionRunJob) ([]*api.ActionWorkflowJob, error) { + tasks, err := actions_model.GetTasksMapByIDs(ctx, container.FilterSlice(jobs, func(job *actions_model.ActionRunJob) (int64, bool) { + taskID := job.EffectiveTaskID() + return taskID, taskID != 0 + })) + if err != nil { + return nil, err + } + taskList := actions_model.TaskList(slices.Collect(maps.Values(tasks))) + if err := taskList.LoadSteps(ctx); err != nil { + return nil, err + } + runners, err := actions_model.GetRunnersMapByIDs(ctx, container.FilterSlice(taskList, func(task *actions_model.ActionTask) (int64, bool) { + return task.RunnerID, task.RunnerID != 0 + })) + if err != nil { + return nil, err + } + + result := make([]*api.ActionWorkflowJob, len(jobs)) + for i, job := range jobs { + task := tasks[job.EffectiveTaskID()] + var runner *actions_model.ActionRunner + if task != nil { + runner = runners[task.RunnerID] + } + result[i] = toActionWorkflowJob(ctx, job.Run.Repo, task, runner, job) + } + return result, nil +} + +func toActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task *actions_model.ActionTask, runner *actions_model.ActionRunner, job *actions_model.ActionRunJob) *api.ActionWorkflowJob { + status, conclusion := ToRunActionsStatus(job.Run, job.Status) + var runnerID int64 + var runnerName string + var steps []*api.ActionWorkflowStep + + if task != nil && job.EffectiveTaskID() != 0 { + runnerID = task.RunnerID + if runner != nil { + runnerName = runner.Name + } + for i, step := range task.Steps { + stepStatus, stepConclusion := ToActionsStatus(step.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(), + }) } } @@ -551,7 +587,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task CreatedAt: job.Created.AsTime().UTC(), StartedAt: job.Started.AsTime().UTC(), CompletedAt: job.Stopped.AsTime().UTC(), - }, nil + } } func getActionWorkflowEntry(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, commit *git.Commit, refName git.RefName, folder string, entry *git.TreeEntry) *api.ActionWorkflow {