diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 587d166caee..cab8162a620 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3783,6 +3783,9 @@ "actions.runs.pushed_by": "pushed by", "actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s", "actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s", + "actions.runs.no_runner_online": "No runner is online to pick up this job.", + "actions.runs.waiting_for_available_runner": "Waiting for a matching runner to become available.", + "actions.runs.waiting_for_dependent_jobs": "Waiting for the following jobs to complete: %s", "actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.", "actions.runs.no_job": "The workflow must contain at least one job", "actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s", diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 4c79e83617f..6f55bb1c6a0 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -32,6 +32,7 @@ import ( "gitea.dev/modules/httplib" "gitea.dev/modules/json" "gitea.dev/modules/log" + "gitea.dev/modules/optional" "gitea.dev/modules/storage" api "gitea.dev/modules/structs" "gitea.dev/modules/templates" @@ -753,6 +754,8 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale) if run.NeedApproval { resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc") + } else if detail := describePendingJobDetail(ctx, current, jobs); detail != "" { + resp.State.CurrentJob.Detail = detail } resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json @@ -767,6 +770,78 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon } } +// describePendingJobDetail explains why a blocked or waiting job has not started +// yet, so the user can tell whether it is waiting on its dependencies or on an +// available runner. It returns an empty string when the job is not pending or the +// cause can't be determined (the caller keeps the generic status label then). +func describePendingJobDetail(ctx *context_module.Context, current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) string { + switch { + case current.Status.IsBlocked(): + // A blocked job is held back by the jobs listed in its `needs`. + if pending := pendingNeeds(current, jobs); len(pending) > 0 { + return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", ")) + } + case current.Status.IsWaiting(): + // A waiting job has no runner to pick it up yet. A busy runner is still + // "online", so distinguish three cases: no runner online at all, online + // runners but none match the labels, and a matching runner that is busy. + runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ + RepoID: current.RepoID, + IsOnline: optional.Some(true), + WithAvailable: true, + }) + if err != nil { + log.Error("FindRunners for job %d: %v", current.ID, err) + return "" + } + hasOnlineRunner, hasMatchingRunner := false, false + for _, runner := range runners { + if runner.IsDisabled { + continue + } + hasOnlineRunner = true + if runner.CanMatchLabels(current.RunsOn) { + hasMatchingRunner = true + break + } + } + switch { + case !hasOnlineRunner: + return ctx.Locale.TrString("actions.runs.no_runner_online") + case !hasMatchingRunner: + return ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(current.RunsOn, ", ")) + default: + // A matching runner exists but hasn't claimed the job, so it is busy. + return ctx.Locale.TrString("actions.runs.waiting_for_available_runner") + } + } + return "" +} + +// pendingNeeds returns the `needs` keys of jobs the given job depends on that +// have not finished yet, scoped to the same parent job (matrix expansions of a +// need are all required to be done). Unresolved needs are treated as pending. +func pendingNeeds(current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) []string { + var pending []string + for _, need := range current.Needs { + found, allDone := false, true + for _, job := range jobs { + if job.ParentJobID != current.ParentJobID || job.JobID != need { + continue + } + found = true + if !job.Status.IsDone() { + allDone = false + break + } + } + if !found || !allDone { + pending = append(pending, need) + } + } + return pending +} + func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) { var viewJobs []*ViewJobStep var logs []*ViewStepLog diff --git a/routers/web/repo/actions/view_test.go b/routers/web/repo/actions/view_test.go index 020930eb38c..ac93c8f6122 100644 --- a/routers/web/repo/actions/view_test.go +++ b/routers/web/repo/actions/view_test.go @@ -138,3 +138,48 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T) } assert.Equal(t, expectedViewJobs, viewJobSteps) } + +func TestPendingNeeds(t *testing.T) { + current := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build", "test"}} + jobs := []*actions_model.ActionRunJob{ + current, + {JobID: "build", Status: actions_model.StatusSuccess}, + {JobID: "test", Status: actions_model.StatusRunning}, + } + // "test" is not done yet, "build" succeeded, so only "test" blocks. + assert.Equal(t, []string{"test"}, pendingNeeds(current, jobs)) + + t.Run("all needs done", func(t *testing.T) { + done := []*actions_model.ActionRunJob{ + current, + {JobID: "build", Status: actions_model.StatusSuccess}, + {JobID: "test", Status: actions_model.StatusSkipped}, + } + assert.Empty(t, pendingNeeds(current, done)) + }) + + t.Run("matrix expansion all required", func(t *testing.T) { + matrix := []*actions_model.ActionRunJob{ + current, + {JobID: "build", Status: actions_model.StatusSuccess}, + {JobID: "build", Status: actions_model.StatusRunning}, + {JobID: "test", Status: actions_model.StatusSuccess}, + } + assert.Equal(t, []string{"build"}, pendingNeeds(current, matrix)) + }) + + t.Run("unresolved need treated as pending", func(t *testing.T) { + missing := []*actions_model.ActionRunJob{current} + assert.Equal(t, []string{"build", "test"}, pendingNeeds(current, missing)) + }) + + t.Run("parent job scope", func(t *testing.T) { + // a same-named job under a different parent must not satisfy the need + scoped := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build"}, ParentJobID: 5} + jobs := []*actions_model.ActionRunJob{ + scoped, + {JobID: "build", Status: actions_model.StatusSuccess, ParentJobID: 0}, + } + assert.Equal(t, []string{"build"}, pendingNeeds(scoped, jobs)) + }) +}