Files
Gitea/routers/web/repo/actions/view_test.go
T
Giteabot 7199547218 fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
Backport #38476 by @bircni

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.

Co-authored-by: bircni <bircni@icloud.com>
2026-07-16 21:45:11 +00:00

186 lines
6.0 KiB
Go

// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
repo_model "gitea.dev/models/repo"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/translation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestViewPullRequestFromRun(t *testing.T) {
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
t.Run("pull ref", func(t *testing.T) {
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/123/head"}
assert.Equal(t, &ViewPullRequest{Index: "#123", Link: "/owner/repo/pulls/123"}, viewPullRequestFromRun(t.Context(), run, nil))
})
t.Run("pull request event payload", func(t *testing.T) {
// a non-pull ref forces the payload branch instead of the ref branch
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/heads/feature"}
payload := &api.PullRequestPayload{Index: 42}
assert.Equal(t, &ViewPullRequest{Index: "#42", Link: "/owner/repo/pulls/42"}, viewPullRequestFromRun(t.Context(), run, payload))
})
t.Run("nil repo", func(t *testing.T) {
run := &actions_model.ActionRun{Ref: "refs/pull/1/head"}
assert.Nil(t, viewPullRequestFromRun(t.Context(), run, nil))
})
}
func TestViewSummaryBranchFromRun(t *testing.T) {
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
t.Run("pull request event same repo", func(t *testing.T) {
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/7/head"}
payload := &api.PullRequestPayload{
PullRequest: &api.PullRequest{Head: &api.PRBranchInfo{
Name: "feature",
Ref: "refs/heads/feature",
RepoID: 1,
Repository: &api.Repository{Link: "/owner/repo"},
}},
}
assert.Equal(t, ViewBranch{Name: "feature", Link: "/owner/repo/src/branch/feature"}, viewSummaryBranchFromRun(t.Context(), run, payload))
})
t.Run("pull request event from fork prefixes owner", func(t *testing.T) {
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/pull/7/head"}
payload := &api.PullRequestPayload{
PullRequest: &api.PullRequest{Head: &api.PRBranchInfo{
Name: "feature",
Ref: "refs/heads/feature",
RepoID: 2,
Repository: &api.Repository{
Link: "/forkowner/repo",
Owner: &api.User{UserName: "forkowner"},
},
}},
}
assert.Equal(t, ViewBranch{Name: "forkowner:feature", Link: "/forkowner/repo/src/branch/feature"}, viewSummaryBranchFromRun(t.Context(), run, payload))
})
t.Run("push to tag does not query branch", func(t *testing.T) {
// a tag ref is not a branch, so no GetBranch DB lookup happens
run := &actions_model.ActionRun{Repo: repo, Ref: "refs/tags/v1.0.0"}
assert.Equal(t, ViewBranch{Name: "v1.0.0", Link: "/owner/repo/src/tag/v1.0.0"}, viewSummaryBranchFromRun(t.Context(), run, nil))
})
}
func TestConvertToViewModel(t *testing.T) {
task := &actions_model.ActionTask{
Status: actions_model.StatusSuccess,
Steps: []*actions_model.ActionTaskStep{
{Name: "Run step-name", Index: 0, Status: actions_model.StatusSuccess, LogLength: 1, Started: timeutil.TimeStamp(1), Stopped: timeutil.TimeStamp(5)},
},
Stopped: timeutil.TimeStamp(20),
}
viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task)
require.NoError(t, err)
expectedViewJobs := []*ViewJobStep{
{
Summary: "Set up job",
Duration: "0s",
Status: "success",
},
{
Summary: "Run step-name",
Duration: "4s",
Status: "success",
},
{
Summary: "Complete job",
Duration: "15s",
Status: "success",
},
}
assert.Equal(t, expectedViewJobs, viewJobSteps)
}
func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T) {
task := &actions_model.ActionTask{
Status: actions_model.StatusCancelling,
Steps: []*actions_model.ActionTaskStep{
{Name: "Run step-name", Index: 0, Status: actions_model.StatusRunning, LogLength: 1},
},
}
viewJobSteps, _, err := convertToViewModel(t.Context(), translation.MockLocale{}, nil, task)
require.NoError(t, err)
expectedViewJobs := []*ViewJobStep{
{
Summary: "Set up job",
Duration: "0s",
Status: "success",
},
{
Summary: "Run step-name",
Duration: "0s",
Status: "cancelling",
},
{
Summary: "Complete job",
Duration: "0s",
Status: "waiting",
},
}
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))
})
}