mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-20 01:08:36 +00:00
fix(actions): explain why a blocked or waiting job has not started (#38476)
When an Actions job is blocked or waiting, the job view only shows the generic **Blocked** / **Waiting** label. Users have no way to tell *why* a job is stuck — whether it's waiting on dependencies, waiting for a runner that doesn't exist, waiting for a runner whose labels don't match, or simply queued behind busy runners. ## Change The current-job detail line now surfaces the actual cause: - **Blocked** → lists the dependency jobs (`needs`) that haven't finished yet, e.g. *"Waiting for the following jobs to complete: build."* - **Waiting**, no online runner → *"No runner is online to pick up this job."* - **Waiting**, online runners but none match `runs-on` → *"No matching online runner with label: X"* (reuses the existing string) - **Waiting**, a matching runner exists but hasn't claimed the job → *"Waiting for a matching runner to become available."* The runner lookup reuses the same available-online-runner query the run list already performs, and only runs while a selected job is actually pending. Dependency resolution is scoped to the same parent job and treats matrix expansions of a `need` as pending until all of them complete.
This commit is contained in:
@@ -3783,6 +3783,9 @@
|
|||||||
"actions.runs.pushed_by": "pushed by",
|
"actions.runs.pushed_by": "pushed by",
|
||||||
"actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s",
|
"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_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_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.no_job": "The workflow must contain at least one job",
|
||||||
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"gitea.dev/modules/httplib"
|
"gitea.dev/modules/httplib"
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/storage"
|
"gitea.dev/modules/storage"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/templates"
|
"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)
|
resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
|
||||||
if run.NeedApproval {
|
if run.NeedApproval {
|
||||||
resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
|
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.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
|
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) {
|
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
|
||||||
var viewJobs []*ViewJobStep
|
var viewJobs []*ViewJobStep
|
||||||
var logs []*ViewStepLog
|
var logs []*ViewStepLog
|
||||||
|
|||||||
@@ -138,3 +138,48 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T)
|
|||||||
}
|
}
|
||||||
assert.Equal(t, expectedViewJobs, viewJobSteps)
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user