enhance(actions): add pending job status and align job statuses with GitHub (#39376)

This commit is contained in:
silverwind
2026-09-27 18:30:42 +00:00
committed by GitHub
parent 0d09986790
commit 9e7b302bd3
45 changed files with 397 additions and 189 deletions
+1 -1
View File
@@ -3072,7 +3072,7 @@ LEVEL = Info
;ZOMBIE_TASK_TIMEOUT = 10m
;; Timeout to stop the tasks which have running status and continuous updates, but don't end for a long time
;ENDLESS_TASK_TIMEOUT = 3h
;; Timeout to cancel the jobs which have waiting status, but haven't been picked by a runner for a long time
;; Timeout to cancel the jobs which haven't started and haven't been updated for a long time
;ABANDONED_JOB_TIMEOUT = 24h
;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow
;SKIP_WORKFLOW_STRINGS = [skip ci],[ci skip],[no ci],[skip actions],[actions skip]
+5 -2
View File
@@ -622,7 +622,7 @@ func RefreshReusableCallerStatus(ctx context.Context, caller *ActionRunJob) erro
func AggregateJobStatus(jobs []*ActionRunJob) Status {
allSuccessOrSkipped := len(jobs) != 0
allSkipped := len(jobs) != 0
var hasFailure, hasCancelled, hasCancelling, hasWaiting, hasRunning, hasBlocked bool
var hasFailure, hasCancelled, hasCancelling, hasWaiting, hasRunning, hasBlocked, hasPending bool
for _, job := range jobs {
// A failed job with continue-on-error:true does not fail the workflow run.
// It counts as a "continued failure" and is treated like success for aggregation.
@@ -635,6 +635,7 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
hasWaiting = hasWaiting || job.Status == StatusWaiting
hasRunning = hasRunning || job.Status == StatusRunning
hasBlocked = hasBlocked || job.Status == StatusBlocked
hasPending = hasPending || job.Status == StatusPending
}
switch {
case allSkipped:
@@ -648,9 +649,11 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
case hasWaiting:
return StatusWaiting
case hasBlocked:
// Blocked is still a pending state, so it should outrank terminal
// Blocked is still an unfinished state, so it should outrank terminal
// statuses like cancelled/failure when no job is waiting or running.
return StatusBlocked
case hasPending:
return StatusRunning // a run with only pending jobs left is still in progress
case hasCancelled:
if hasFailure && hasFailFastMatrixFailure(jobs) {
return StatusFailure
+2
View File
@@ -76,6 +76,8 @@ func TestAggregateJobStatus(t *testing.T) {
{[]Status{StatusFailure, StatusWaiting}, StatusWaiting},
{[]Status{StatusFailure, StatusRunning}, StatusRunning},
{[]Status{StatusFailure, StatusBlocked}, StatusBlocked},
{[]Status{StatusFailure, StatusPending}, StatusRunning},
{[]Status{StatusBlocked, StatusPending}, StatusBlocked},
// skipped with other status
// "all skipped" is also considered as "mergeable" by "services/actions.toCommitStatus", the same as GitHub
+6
View File
@@ -23,6 +23,7 @@ const (
StatusRunning // 6, isn't a runnerv1.Result
StatusBlocked // 7, isn't a runnerv1.Result
StatusCancelling // 8, isn't a runnerv1.Result
StatusPending // 9, isn't a runnerv1.Result
)
var statusNames = map[Status]string{
@@ -35,6 +36,7 @@ var statusNames = map[Status]string{
StatusCancelling: "cancelling",
StatusSkipped: "skipped",
StatusBlocked: "blocked",
StatusPending: "pending",
}
// String returns the string name of the Status
@@ -93,6 +95,10 @@ func (s Status) IsCancelling() bool {
return s == StatusCancelling
}
func (s Status) IsPending() bool {
return s == StatusPending
}
// In returns whether s is one of the given statuses
func (s Status) In(statuses ...Status) bool {
return slices.Contains(statuses, s)
+1
View File
@@ -3783,6 +3783,7 @@
"actions.status.cancelling": "Canceling",
"actions.status.skipped": "Skipped",
"actions.status.blocked": "Blocked",
"actions.status.pending": "Pending",
"actions.runners": "Runners",
"actions.runners.runner_manage_panel": "Runners Management",
"actions.runners.new": "Create new Runner",
+2 -2
View File
@@ -18,7 +18,7 @@ func ListWorkflowJobs(ctx *context.APIContext) {
// parameters:
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
@@ -70,7 +70,7 @@ func ListWorkflowRuns(ctx *context.APIContext) {
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
+2 -2
View File
@@ -581,7 +581,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// required: true
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
@@ -626,7 +626,7 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) {
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
+5 -5
View File
@@ -673,7 +673,7 @@ func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// required: true
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
@@ -737,7 +737,7 @@ func (Action) ListWorkflowRuns(ctx *context.APIContext) {
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
@@ -984,7 +984,7 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) {
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
@@ -1624,7 +1624,7 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
// required: true
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
@@ -1701,7 +1701,7 @@ func ListWorkflowRunAttemptJobs(ctx *context.APIContext) {
// required: true
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
+1 -1
View File
@@ -120,7 +120,7 @@ func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64, runAttemptI
func convertToInternal(s string) ([]actions_model.Status, error) {
switch s {
case "pending", "waiting", "requested", "action_required":
return []actions_model.Status{actions_model.StatusBlocked}, nil
return []actions_model.Status{actions_model.StatusBlocked, actions_model.StatusPending}, nil
case "queued":
return []actions_model.Status{actions_model.StatusWaiting}, nil
case "in_progress":
+2 -2
View File
@@ -352,7 +352,7 @@ func ListWorkflowRuns(ctx *context.APIContext) {
// required: false
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: actor
@@ -393,7 +393,7 @@ func ListWorkflowJobs(ctx *context.APIContext) {
// parameters:
// - name: status
// in: query
// description: workflow status (pending, queued, in_progress, failure, success, skipped)
// description: workflow status (requested, pending, queued, in_progress, failure, success, skipped)
// type: string
// required: false
// - name: page
+1 -1
View File
@@ -556,7 +556,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
{
ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final",
Status: actions_model.StatusBlocked.String(), Duration: "0s",
Status: actions_model.StatusPending.String(), Duration: "0s",
Needs: []string{"local_caller", "cross_caller"},
},
}
+1 -1
View File
@@ -556,7 +556,7 @@ func (data *actionRunListData) processActionRuns(ctx *context.Context) bool {
for _, job := range jobs {
// 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) {
if job.IsMatrixDeferred || !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusPending) {
continue
}
if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil {
+3 -7
View File
@@ -766,14 +766,10 @@ 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).
// describePendingJobDetail explains why a pending or waiting job has not started, or returns an empty string when it can't tell
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`.
case current.Status.IsPending():
if pending := pendingNeeds(current, jobs); len(pending) > 0 {
return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", "))
}
@@ -852,7 +848,7 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors
viewJobs = append(viewJobs, &ViewJobStep{
Summary: v.Name,
Duration: v.Duration().String(),
Status: status.String(),
Status: util.Iif(status.IsWaiting(), actions_model.StatusPending, status).String(),
})
}
+1 -1
View File
@@ -133,7 +133,7 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T)
{
Summary: "Complete job",
Duration: "0s",
Status: "waiting",
Status: "pending",
},
}
assert.Equal(t, expectedViewJobs, viewJobSteps)
+1 -1
View File
@@ -174,7 +174,7 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
// CancelAbandonedJobs cancels jobs that have not been picked by any runner for a long time
func CancelAbandonedJobs(ctx context.Context) error {
abandonedJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked},
Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusPending},
UpdatedBefore: timeutil.TimeStampNow().AddDuration(-setting.Actions.AbandonedJobTimeout),
})
if err != nil {
+49 -6
View File
@@ -53,6 +53,10 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action
scopedPrefix = actions_model.ScopedStatusContextPrefix(ctx, run.WorkflowRepoID)
}
var pendingFilter *pendingJobFilter
if slices.ContainsFunc(jobs, func(job *actions_model.ActionRunJob) bool { return job.Status.IsPending() && !job.IsMatrixDeferred }) {
pendingFilter = newPendingJobFilter(ctx, run)
}
for _, job := range jobs {
// A deferred-matrix placeholder's name changes when it expands, so a status created while it
// waits would be orphaned. The emitter reloads the jobs after expanding and creates them
@@ -61,7 +65,7 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action
if job.IsMatrixDeferred && !job.Status.IsDone() {
continue
}
if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job); err != nil {
if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job, pendingFilter); err != nil {
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
}
}
@@ -151,7 +155,7 @@ func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, c
return event, commitID, nil
}
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID, scopedPrefix string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID, scopedPrefix string, run *actions_model.ActionRun, job *actions_model.ActionRunJob, pendingFilter *pendingJobFilter) error {
displayName := actions_module.WorkflowDisplayName(run.WorkflowID, job.WorkflowPayload)
ctxName := actions_module.WorkflowStatusContextName(displayName, job.Name, event) // git_model.NewCommitStatus also trims spaces
if run.IsScopedRun {
@@ -160,7 +164,35 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event)
}
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
return createWorkflowCommitStatus(ctx, repo, commitID, ctxName, run.WorkflowID, toCommitStatus(job.Status), targetURL, toCommitStatusDescription(job))
return createWorkflowCommitStatus(ctx, repo, commitID, ctxName, run.WorkflowID, toCommitStatus(job.Status), targetURL, toCommitStatusDescription(job), pendingFilter.onlyReplace(job, ctxName))
}
// pendingJobFilter keeps optional Pending jobs from posting new statuses
type pendingJobFilter struct {
requiredGlobs []glob.Glob
}
func newPendingJobFilter(ctx context.Context, run *actions_model.ActionRun) *pendingJobFilter {
rules, err := git_model.FindRepoProtectedBranchRules(ctx, run.RepoID)
if err != nil {
log.Error("FindRepoProtectedBranchRules: %v", err)
return nil
}
if slices.ContainsFunc(rules, func(rule *git_model.ProtectedBranch) bool {
return rule.EnableStatusCheck && len(rule.StatusCheckContexts) == 0
}) {
return nil
}
requiredGlobs, err := requiredStatusContextGlobs(ctx, run.Repo, rules)
if err != nil {
log.Error("requiredStatusContextGlobs: %v", err)
return nil
}
return &pendingJobFilter{requiredGlobs: requiredGlobs}
}
func (f *pendingJobFilter) onlyReplace(job *actions_model.ActionRunJob, ctxName string) bool {
return f != nil && job.Status.IsPending() && !slices.ContainsFunc(f.requiredGlobs, func(gp glob.Glob) bool { return gp.Match(ctxName) })
}
// getAllRequiredStatusContextGlobs returns the compiled globs of every status-check context required in the repo:
@@ -170,6 +202,10 @@ func getAllRequiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repo
if err != nil {
return nil, fmt.Errorf("FindRepoProtectedBranchRules: %w", err)
}
return requiredStatusContextGlobs(ctx, repo, rules)
}
func requiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repository, rules git_model.ProtectedBranchRules) ([]glob.Glob, error) {
required, err := pull_service.EffectiveRequiredContexts(ctx, repo, rules...)
if err != nil {
return nil, fmt.Errorf("EffectiveRequiredContexts: %w", err)
@@ -246,7 +282,7 @@ func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *rep
continue
}
// "Skipped" mirrors toCommitStatusDescription for StatusSkipped.
if err := createWorkflowCommitStatus(ctx, repo, commitID, ctxName, workflowID, commitstatus.CommitStatusSkipped, "", "Skipped"); err != nil {
if err := createWorkflowCommitStatus(ctx, repo, commitID, ctxName, workflowID, commitstatus.CommitStatusSkipped, "", "Skipped", false); err != nil {
return err
}
}
@@ -254,7 +290,7 @@ func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *rep
}
// createWorkflowCommitStatus posts the commit status for one workflow-job context.
func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository, commitID, ctxName, workflowID string, state commitstatus.CommitStatusState, targetURL, description string) error {
func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository, commitID, ctxName, workflowID string, state commitstatus.CommitStatusState, targetURL, description string, onlyReplace bool) error {
// Mix the workflow file path into the hash so two workflow files that
// share the same `name:` and job name produce distinct commit statuses
// even though they render identically — matching GitHub's behavior
@@ -280,14 +316,19 @@ func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository
break
}
}
hasPrevious := false
for _, v := range statuses {
if v.ContextHash == ctxHash {
if v.State == state && v.TargetURL == targetURL && v.Description == description {
return nil
}
hasPrevious = true
break
}
}
if onlyReplace && !hasPrevious {
return nil
}
creator := user_model.NewActionsUser()
status := git_model.CommitStatus{
@@ -322,6 +363,8 @@ func toCommitStatusDescription(job *actions_model.ActionRunJob) string {
return "Waiting to run"
case actions_model.StatusBlocked:
return "Blocked by required conditions"
case actions_model.StatusPending:
return "Waiting for needed jobs"
default:
return fmt.Sprintf("Unknown status: %d", job.Status)
}
@@ -333,7 +376,7 @@ func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState
return commitstatus.CommitStatusSuccess
case actions_model.StatusFailure, actions_model.StatusCancelled:
return commitstatus.CommitStatusFailure
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning, actions_model.StatusCancelling:
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusPending, actions_model.StatusRunning, actions_model.StatusCancelling:
return commitstatus.CommitStatusPending
case actions_model.StatusSkipped:
return commitstatus.CommitStatusSkipped
+40 -10
View File
@@ -35,6 +35,7 @@ func TestCommitStatusDescription(t *testing.T) {
{actions_model.StatusRunning, 0, 0, "In progress"},
{actions_model.StatusWaiting, 0, 0, "Waiting to run"},
{actions_model.StatusBlocked, 0, 0, "Blocked by required conditions"},
{actions_model.StatusPending, 0, 0, "Waiting for needed jobs"},
{actions_model.StatusUnknown, 0, 0, "Unknown status: 0"},
}
for _, tc := range cases {
@@ -71,7 +72,7 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)"
expectedTargetURL := run.Link() + "/jobs/99002"
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job, nil))
statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 1)
@@ -80,7 +81,7 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.Equal(t, expectedTargetURL, statuses[0].TargetURL)
job.Status = actions_model.StatusRunning
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job, nil))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 2)
@@ -89,17 +90,46 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.Equal(t, "In progress", statuses[1].Description)
assert.Equal(t, expectedTargetURL, statuses[1].TargetURL)
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job, nil))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
assert.Len(t, statuses, 2)
job.Status = actions_model.StatusSuccess
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job, nil))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 3)
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
}
func TestCreateCommitStatus_HidesOptionalPendingJobs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
run := &actions_model.ActionRun{ID: 99101, RepoID: repo.ID, Repo: repo, WorkflowID: "ci.yaml"}
deploy := &actions_model.ActionRunJob{ID: 99102, RunID: run.ID, RepoID: repo.ID, Name: "deploy", Status: actions_model.StatusPending}
postDeploy := func(pending *pendingJobFilter) []*git_model.CommitStatus {
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, deploy, pending))
return findCommitStatusesForContext(t, repo.ID, branch.CommitID, "ci.yaml / deploy (push)")
}
pending := newPendingJobFilter(t.Context(), run)
assert.Empty(t, postDeploy(pending))
deploy.Status = actions_model.StatusSuccess
assert.Len(t, postDeploy(nil), 1)
deploy.Status = actions_model.StatusPending
assert.Len(t, postDeploy(pending), 2)
require.NoError(t, db.Insert(t.Context(), &git_model.ProtectedBranch{RepoID: repo.ID, RuleName: "main", EnableStatusCheck: true, StatusCheckContexts: []string{"ci.yaml / deploy*"}}))
pending = newPendingJobFilter(t.Context(), run)
assert.False(t, pending.onlyReplace(deploy, "ci.yaml / deploy (push)"))
assert.True(t, pending.onlyReplace(deploy, "other / deploy (push)"))
require.NoError(t, db.Insert(t.Context(), &git_model.ProtectedBranch{RepoID: repo.ID, RuleName: "release", EnableStatusCheck: true}))
assert.Nil(t, newPendingJobFilter(t.Context(), run))
}
func TestGetCommitActionsStatusMap(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@@ -125,7 +155,7 @@ func TestGetCommitActionsStatusMap(t *testing.T) {
RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID, Name: tc.jobName, Status: tc.status,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job, nil))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
@@ -184,7 +214,7 @@ jobs:
WorkflowPayload: payload,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, "", run, job, nil))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
@@ -242,7 +272,7 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job, nil))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
@@ -299,7 +329,7 @@ func TestCreateCommitStatus_LegacyHashExternalNotAdopted(t *testing.T) {
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job, nil))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
@@ -351,7 +381,7 @@ func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
`),
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job, nil))
statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)")
require.Len(t, statuses, 1)
@@ -406,7 +436,7 @@ jobs:
if run.IsScopedRun {
scopedPrefix = actions_model.ScopedStatusContextPrefix(t.Context(), run.WorkflowRepoID)
}
require.NoError(t, createCommitStatus(t.Context(), consumer, "push", branch.CommitID, scopedPrefix, run, job))
require.NoError(t, createCommitStatus(t.Context(), consumer, "push", branch.CommitID, scopedPrefix, run, job, nil))
}
// repo-level Context is the bare "<display name> / <job> (<event>)"; the scoped one is the same but sets off the source repo with a colon,
+1 -1
View File
@@ -63,7 +63,7 @@ func handleInvalidWorkflows(ctx context.Context, input *notifyInput, ref git.Ref
continue
}
if err := createWorkflowCommitStatus(ctx, run.Repo, run.CommitSHA, entryName+" ("+run.TriggerEvent+")", run.WorkflowID,
commitstatus.CommitStatusFailure, run.Link(), "Invalid workflow file"); err != nil {
commitstatus.CommitStatusFailure, run.Link(), "Invalid workflow file", false); err != nil {
log.Error("create commit status for invalid workflow %q: %v", entryName, err)
}
NotifyWorkflowRunStatusUpdate(ctx, run)
+30 -5
View File
@@ -244,9 +244,9 @@ func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (*jo
return result, nil
}
// checkJobsOfCurrentRunAttempt resolves blocked jobs of the run's latest attempt.
// checkJobsOfCurrentRunAttempt resolves pending and blocked jobs of the run's latest attempt.
func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) {
// Approval is the only transition allowed to release an approval-pending run.
// Approval is the only transition allowed to release a run awaiting approval.
if run.NeedApproval {
return &jobsCheckResult{}, nil
}
@@ -366,7 +366,7 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
}
result.UpdatedJobs = append(result.UpdatedJobs, resolver.matrixUpdatedJobs...)
// Caller and matrix expansion both insert Blocked jobs, which only a follow-up pass resolves.
// Caller and matrix expansion both insert Pending or Blocked jobs, which only a follow-up pass resolves.
// Like the caller's children, matrix siblings are left out of result.Jobs and picked up there.
if expandedAnyCaller || resolver.matrixChanged {
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
@@ -398,7 +398,7 @@ func cancelFailedMatrixSiblings(ctx context.Context, jobs actions_model.ActionJo
type jobStatusResolver struct {
statuses map[int64]actions_model.Status
// sortedIDs are the keys of statuses, so blocked jobs are resolved in insertion order.
// sortedIDs are the keys of statuses, so jobs are resolved in insertion order.
// Resolve only ever rewrites statuses values, never its key set.
sortedIDs []int64
needs map[int64][]int64
@@ -498,6 +498,20 @@ func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed boo
return allDone, allSucceed
}
func (r *jobStatusResolver) updateStatus(ctx context.Context, job *actions_model.ActionRunJob, status actions_model.Status) error {
cond := builder.Eq{"status": job.Status}
job.Status = status
affected, err := actions_model.UpdateRunJob(ctx, job, cond, "status")
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("no affected for updating job %d", job.ID)
}
r.statuses[job.ID] = status
return nil
}
func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_model.Status, error) {
ret := map[int64]actions_model.Status{}
@@ -509,7 +523,7 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
for _, id := range r.sortedIDs {
status := r.statuses[id]
actionRunJob := r.jobMap[id]
if status != actions_model.StatusBlocked {
if !status.In(actions_model.StatusPending, actions_model.StatusBlocked) {
continue
}
// An expanded caller has been resolved in an earlier pass, skip.
@@ -522,7 +536,18 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
continue
}
}
// past the run's own holds, a job is Pending exactly while its needs are unfinished
allDone, allSucceed := r.resolveCheckNeeds(id)
var err error
switch {
case status.IsPending() && allDone:
err = r.updateStatus(ctx, actionRunJob, actions_model.StatusBlocked)
case status.IsBlocked() && !allDone:
err = r.updateStatus(ctx, actionRunJob, actions_model.StatusPending)
}
if err != nil {
return nil, err
}
if !allDone {
continue
}
+43 -32
View File
@@ -58,11 +58,11 @@ func Test_jobStatusResolver_Resolve(t *testing.T) {
},
},
{
name: "multiple blocked",
name: "multiple pending",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
{ID: 2, JobID: "2", Status: actions_model.StatusPending, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusPending, Needs: []string{"1"}},
},
want: map[int64]actions_model.Status{
2: actions_model.StatusWaiting,
@@ -70,11 +70,11 @@ func Test_jobStatusResolver_Resolve(t *testing.T) {
},
},
{
name: "chain blocked",
name: "chain pending",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "1", Status: actions_model.StatusFailure, Needs: []string{}},
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
{ID: 2, JobID: "2", Status: actions_model.StatusPending, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusPending, Needs: []string{"2"}},
},
want: map[int64]actions_model.Status{
2: actions_model.StatusSkipped,
@@ -93,9 +93,9 @@ func Test_jobStatusResolver_Resolve(t *testing.T) {
{
name: "loop need",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "1", Status: actions_model.StatusBlocked, Needs: []string{"3"}},
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
{ID: 1, JobID: "1", Status: actions_model.StatusPending, Needs: []string{"3"}},
{ID: 2, JobID: "2", Status: actions_model.StatusPending, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusPending, Needs: []string{"2"}},
},
want: map[int64]actions_model.Status{},
},
@@ -103,7 +103,7 @@ func Test_jobStatusResolver_Resolve(t *testing.T) {
name: "`if` is not empty and all jobs in `needs` completed successfully",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
{ID: 2, JobID: "job2", Status: actions_model.StatusPending, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
@@ -122,7 +122,7 @@ jobs:
name: "`if` is not empty and not all jobs in `needs` completed successfully",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
{ID: 2, JobID: "job2", Status: actions_model.StatusPending, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
@@ -141,7 +141,7 @@ jobs:
name: "`if` is empty and not all jobs in `needs` completed successfully",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
{ID: 2, JobID: "job2", Status: actions_model.StatusPending, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
@@ -239,7 +239,7 @@ jobs:
name: "`if` is empty and a failed need has continue-on-error",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, ContinueOnError: true, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
{ID: 2, JobID: "job2", Status: actions_model.StatusPending, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
@@ -263,7 +263,7 @@ jobs:
},
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
{ID: 2, JobID: "job2", Status: actions_model.StatusPending, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
on:
workflow_dispatch:
@@ -288,10 +288,13 @@ jobs:
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries
runID := int64(9001 + i)
attemptID := int64(9001 + i)
repoID := tt.jobs[0].RepoID
dbRun := &actions_model.ActionRun{RepoID: repoID, Index: int64(9001 + i)}
require.NoError(t, db.Insert(ctx, dbRun))
attempt := &actions_model.ActionRunAttempt{RepoID: repoID, RunID: dbRun.ID, Attempt: 1}
require.NoError(t, db.Insert(ctx, attempt))
runID, attemptID := dbRun.ID, attempt.ID
run := util.IfZero(tt.run, stubRun)
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunAttempt{ID: attemptID, RepoID: 1, RunID: runID}))
// Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map.
idMap := make(map[int64]int64, len(tt.jobs))
@@ -302,9 +305,7 @@ jobs:
j.RunAttemptID = attemptID
j.Run = run
// The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload;
// supply a minimal one when the case didn't.
if j.Status == actions_model.StatusBlocked && len(j.WorkflowPayload) == 0 {
if j.Status.In(actions_model.StatusPending, actions_model.StatusBlocked) && len(j.WorkflowPayload) == 0 {
j.WorkflowPayload = minimalWorkflowPayload(j.JobID)
}
@@ -764,34 +765,44 @@ func Test_maxParallelReusableCallerLifecycle(t *testing.T) {
// dependents honest: a round resolved after an insert would judge them against a job set that is
// missing the siblings. See Resolve for why that is wrong.
func Test_jobStatusResolverStopsAfterMatrixInsert(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// build (2) stands for the expanded anchor: it reaches a terminal status this round, which is
// what would let report (3) resolve in the next one.
newChain := func() actions_model.ActionJobList {
return actions_model.ActionJobList{
{ID: 1, JobID: "generate", Status: actions_model.StatusFailure, WorkflowPayload: minimalWorkflowPayload("generate")},
{ID: 2, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{"generate"}, WorkflowPayload: minimalWorkflowPayload("build")},
{ID: 3, JobID: "report", Status: actions_model.StatusBlocked, Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report")},
newChain := func(index int64) actions_model.ActionJobList {
run := &actions_model.ActionRun{Index: index}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{RunID: run.ID, Attempt: 1}
require.NoError(t, db.Insert(ctx, attempt))
jobs := actions_model.ActionJobList{
{JobID: "generate", Status: actions_model.StatusFailure, WorkflowPayload: minimalWorkflowPayload("generate")},
{JobID: "build", Status: actions_model.StatusPending, Needs: []string{"generate"}, WorkflowPayload: minimalWorkflowPayload("build")},
{JobID: "report", Status: actions_model.StatusPending, Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report")},
}
for _, job := range jobs {
job.RunID, job.RunAttemptID = run.ID, attempt.ID
require.NoError(t, db.Insert(ctx, job))
}
return jobs
}
t.Run("without an insert the whole chain resolves in one pass", func(t *testing.T) {
got, err := newJobStatusResolver(newChain(), nil).Resolve(ctx)
chain := newChain(9201)
got, err := newJobStatusResolver(chain, nil).Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, map[int64]actions_model.Status{
2: actions_model.StatusSkipped,
3: actions_model.StatusSkipped,
chain[1].ID: actions_model.StatusSkipped,
chain[2].ID: actions_model.StatusSkipped,
}, got)
})
t.Run("an insert stops the pass before the dependents are resolved", func(t *testing.T) {
r := newJobStatusResolver(newChain(), nil)
chain := newChain(9202)
r := newJobStatusResolver(chain, nil)
r.matrixInserted = true // as resolve() sets it once expansion has inserted siblings
got, err := r.Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, map[int64]actions_model.Status{2: actions_model.StatusSkipped}, got,
assert.Equal(t, map[int64]actions_model.Status{chain[1].ID: actions_model.StatusSkipped}, got,
"report must wait for the re-emit, which sees the sibling combinations too")
})
}
+47
View File
@@ -4,6 +4,7 @@
package actions
import (
"slices"
"testing"
actions_model "gitea.dev/models/actions"
@@ -103,6 +104,52 @@ func TestPrepareRunAndInsert_MaxParallel(t *testing.T) {
}
}
func TestCheckJobs_ApprovedRunWaitsOnNeedsThenMaxParallel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(int64) error { return nil })()
run := insertMaxParallelRun(t, `name: max-parallel
on: push
jobs:
setup:
runs-on: ubuntu-latest
steps:
- run: echo hi
build:
needs: setup
runs-on: ubuntu-latest
strategy:
max-parallel: 2
matrix:
version: [1, 2, 3]
steps:
- run: echo hi
`, true)
assert.Equal(t, map[actions_model.Status]int{actions_model.StatusBlocked: 4}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
_, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
require.NoError(t, err)
checkJobs := func() actions_model.ActionJobList {
_, err := checkJobsOfCurrentRunAttempt(t.Context(), unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}))
require.NoError(t, err)
return runJobs(t, run.ID, run.LatestAttemptID)
}
jobs := checkJobs()
assert.Equal(t, map[actions_model.Status]int{actions_model.StatusWaiting: 1, actions_model.StatusPending: 3}, statusCounts(jobs))
setup := jobs[slices.IndexFunc(jobs, func(job *actions_model.ActionRunJob) bool { return job.JobID == "setup" })]
setup.Status = actions_model.StatusSuccess
_, err = actions_model.UpdateRunJob(t.Context(), setup, nil, "status")
require.NoError(t, err)
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusSuccess: 1,
actions_model.StatusWaiting: 2,
actions_model.StatusBlocked: 1,
}, statusCounts(checkJobs()))
}
// A reusable workflow declares its own strategy, so the limit must reach the child jobs.
func TestInsertCallerChildren_MaxParallel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
+1 -1
View File
@@ -28,7 +28,6 @@ func NotifyWorkflowJobsAndRunsStatusUpdate(ctx context.Context, jobs []*actions_
log.Error("Failed to load job attributes: %v", err)
continue
}
CreateCommitStatusForRunJobs(ctx, job.Run, job)
runRepoIDs[job.RunID] = job.RepoID
if _, ok := jobsByRunID[job.RunID]; !ok {
@@ -42,6 +41,7 @@ func NotifyWorkflowJobsAndRunsStatusUpdate(ctx context.Context, jobs []*actions_
}
for _, jobs := range jobsByRunID {
CreateCommitStatusForRunJobs(ctx, jobs[0].Run, jobs...)
NotifyWorkflowJobsStatusUpdate(ctx, jobs...)
}
}
+4 -3
View File
@@ -286,9 +286,10 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
var invalidIf error
if plan.rerunAttemptJobIDs.Contains(templateJob.AttemptJobID) {
// the emitter decides `if:` once all needs have results, and is the only place expanding a deferred matrix
shouldBlockJob := shouldBlock || len(newJob.Needs) > 0 || newJob.IsMatrixDeferred
newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting)
newJob.Status = util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting)
if newJob.Status.IsWaiting() && len(newJob.Needs) > 0 {
newJob.Status = actions_model.StatusPending
}
newJob.TaskID = 0
newJob.SourceTaskID = 0
newJob.Started = 0
+3 -2
View File
@@ -25,6 +25,7 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/convert"
"xorm.io/builder"
@@ -187,7 +188,7 @@ func canonicalCallUses(job *actions_model.ActionRunJob) string {
}
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
// It expands only ONE level: a child that is itself a reusable caller is inserted Blocked and expanded later by a subsequent resolver pass.
// It expands only ONE level: a child that is itself a reusable caller is inserted Blocked or Pending and expanded later by a subsequent resolver pass.
// It does NOT schedule a follow-up resolver pass; the caller of this function is responsible for emitting.
//
// All call sites (PrepareRunAndInsert, execRerunPlan, checkJobsOfCurrentRunAttempt, ApproveRuns) invoke this inside their enclosing write transaction,
@@ -404,7 +405,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
RunsOn: parsedChild.RunsOn(),
ContinueOnError: parsedChild.GetContinueOnError(),
MaxParallel: parseMaxParallel(jobID, parsedChild.Strategy.MaxParallelString),
Status: actions_model.StatusBlocked,
Status: util.Iif(len(needs) > 0, actions_model.StatusPending, actions_model.StatusBlocked),
ParentJobID: caller.ID,
WorkflowSourceRepoID: sourceRepoID,
WorkflowSourceCommitSHA: sourceCommitSHA,
+6 -5
View File
@@ -191,7 +191,10 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
payload, _ := workflowJob.Marshal()
isReusableWorkflowCaller := job.Uses != ""
shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval
status := util.Iif(runAttempt.Status == actions_model.StatusBlocked || run.NeedApproval, actions_model.StatusBlocked, actions_model.StatusWaiting)
if status.IsWaiting() && len(needs) > 0 {
status = actions_model.StatusPending
}
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
if err != nil {
@@ -213,7 +216,7 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
AttemptJobID: attemptJobID,
Needs: needs,
RunsOn: job.RunsOn(),
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
Status: status,
WorkflowSourceRepoID: run.WorkflowRepoID,
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
ContinueOnError: job.GetContinueOnError(),
@@ -256,9 +259,7 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
}
}
// If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop
// No need to check job concurrency for a blocked job (it will be checked by job emitter later)
// A slot-starved job skips the check too: it will not start, so it must not cancel its group peers.
// A slot-starved job skips the check: it will not start, so it must not cancel its group peers.
if runJob.Status == actions_model.StatusWaiting && slots.available(runJob) {
var jobsToCancel []*actions_model.ActionRunJob
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
+13 -4
View File
@@ -178,10 +178,19 @@ func TestToActionWorkflowJob_StepStatusIsIndependentOfJobStatus(t *testing.T) {
assert.Equal(t, "failure", apiJob.Steps[1].Conclusion, "step 1 conclusion")
}
func TestToActionsStatus_Cancelling(t *testing.T) {
action, conclusion := ToActionsStatus(actions_model.StatusCancelling)
assert.Equal(t, "in_progress", action)
assert.Empty(t, conclusion)
func TestToActionsStatus(t *testing.T) {
for status, expected := range map[actions_model.Status]string{
actions_model.StatusWaiting: "queued",
actions_model.StatusBlocked: "pending",
actions_model.StatusPending: "requested",
actions_model.StatusCancelling: "in_progress",
} {
action, conclusion := ToActionsStatus(status)
assert.Equal(t, expected, action, status.String())
assert.Empty(t, conclusion)
}
action, _ := ToRunActionsStatus(&actions_model.ActionRun{NeedApproval: true}, actions_model.StatusBlocked)
assert.Equal(t, "waiting", action)
}
func TestToWorkflowRunAction_Cancelling(t *testing.T) {
+14 -5
View File
@@ -273,7 +273,7 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte
}
runAttempt := int64(0)
status, conclusion := ToActionsStatus(run.Status)
status, conclusion := ToRunActionsStatus(run, run.Status)
startedAt := run.Started.AsLocalTime()
completedAt := run.Stopped.AsLocalTime()
actor := run.TriggerUser // The username of the user that triggered the initial workflow run.
@@ -289,7 +289,7 @@ func ToActionWorkflowRun(ctx context.Context, run *actions_model.ActionRun, atte
return nil, err
}
runAttempt = attempt.Attempt
status, conclusion = ToActionsStatus(attempt.Status)
status, conclusion = ToRunActionsStatus(run, attempt.Status)
startedAt = attempt.Started.AsLocalTime()
completedAt = attempt.Stopped.AsLocalTime()
triggerUser = attempt.TriggerUser
@@ -435,12 +435,21 @@ func ToWorkflowRunAction(status actions_model.Status) (action string) {
return action
}
func ToRunActionsStatus(run *actions_model.ActionRun, status actions_model.Status) (action, conclusion string) {
if status.IsBlocked() && run.NeedApproval {
return "waiting", ""
}
return ToActionsStatus(status)
}
func ToActionsStatus(status actions_model.Status) (action, conclusion string) {
switch status {
case actions_model.StatusWaiting:
action = "queued" // "waiting" is a naming conflict of the webhook between Gitea and GitHub Actions
action = "queued"
case actions_model.StatusBlocked:
action = "waiting" // naming conflict (as above)
action = "pending"
case actions_model.StatusPending:
action = "requested"
case actions_model.StatusRunning, actions_model.StatusCancelling:
action = "in_progress"
default:
@@ -469,7 +478,7 @@ func ToActionWorkflowJob(ctx context.Context, repo *repo_model.Repository, task
return nil, err
}
status, conclusion := ToActionsStatus(job.Status)
status, conclusion := ToRunActionsStatus(job.Run, job.Status)
var runnerID int64
var runnerName string
var steps []*api.ActionWorkflowStep
+1 -1
View File
@@ -343,7 +343,7 @@ func getWorkflowRunPayloadInfo(p *api.WorkflowRunPayload, linkFormatter linkForm
text = fmt.Sprintf("Workflow Run %s: %s", p.Action, refLink)
switch description {
case "waiting":
case "waiting", "pending":
color = orangeColor
case "queued":
color = orangeColorLight
+8 -2
View File
@@ -998,6 +998,14 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo
}
func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, job *actions_model.ActionRunJob, task *actions_model.ActionTask) {
if err := job.LoadRun(ctx); err != nil {
log.Error("LoadRun: %v", err)
return
}
status, _ := convert.ToRunActionsStatus(job.Run, job.Status)
if status == "requested" || status == "pending" {
return // announce a job only once it is queued
}
source := EventSource{
Repository: repo,
Owner: repo.Owner,
@@ -1008,8 +1016,6 @@ func (*webhookNotifier) WorkflowJobStatusUpdate(ctx context.Context, repo *repo_
org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
}
status, _ := convert.ToActionsStatus(job.Status)
convertedJob, err := convert.ToActionWorkflowJob(ctx, repo, task, job)
if err != nil {
log.Error("ToActionWorkflowJob: %v", err)
@@ -36,6 +36,7 @@
data-locale-status-cancelled="{{ctx.Locale.Tr "actions.status.cancelled"}}"
data-locale-status-skipped="{{ctx.Locale.Tr "actions.status.skipped"}}"
data-locale-status-blocked="{{ctx.Locale.Tr "actions.status.blocked"}}"
data-locale-status-pending="{{ctx.Locale.Tr "actions.status.pending"}}"
data-locale-artifacts-title="{{ctx.Locale.Tr "artifacts"}}"
data-locale-artifact-expired="{{ctx.Locale.Tr "expired"}}"
data-locale-artifact-expires-at="{{ctx.Locale.Tr "artifact_expires_at"}}"
+3 -1
View File
@@ -1,7 +1,7 @@
{{/* Status icons used for runs, jobs and steps.
Template Attributes:
* Status: one of success, skipped, waiting, blocked, running, failure, cancelled, cancelling, unknown
* Status: one of success, skipped, waiting, pending, blocked, running, failure, cancelled, cancelling, unknown
* Size: icon size in pixels (default 16)
* ClassName: additional CSS classes
* IconVariant: "circle-fill" → octicon-check-circle-fill / octicon-x-circle-fill
@@ -18,6 +18,8 @@ Keep this template in sync with web_src/js/components/ActionStatusIcon.vue.
{{else if eq .Status "cancelled"}}
{{svg "octicon-stop" $size (printf "tw-text-text-light %s" $className)}}
{{else if eq .Status "waiting"}}
{{svg "octicon-dot-fill" $size (printf "tw-text-yellow %s" $className)}}
{{else if eq .Status "pending"}}
{{svg "octicon-circle" $size (printf "tw-text-text-light %s" $className)}}
{{else if eq .Status "blocked"}}
{{svg "octicon-blocked" $size (printf "tw-text-yellow %s" $className)}}
+11 -11
View File
@@ -11128,7 +11128,7 @@
"operationId": "listAdminWorkflowJobs",
"parameters": [
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -11355,7 +11355,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -13141,7 +13141,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -13405,7 +13405,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -17183,7 +17183,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -17618,7 +17618,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -17990,7 +17990,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -18174,7 +18174,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -19321,7 +19321,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -34138,7 +34138,7 @@
"operationId": "getUserWorkflowJobs",
"parameters": [
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
@@ -34365,7 +34365,7 @@
}
},
{
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"in": "query",
"name": "status",
"schema": {
+11 -11
View File
@@ -34,7 +34,7 @@
"parameters": [
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -257,7 +257,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -2040,7 +2040,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -2292,7 +2292,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -5784,7 +5784,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -6185,7 +6185,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -6521,7 +6521,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -6690,7 +6690,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -7747,7 +7747,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -21414,7 +21414,7 @@
"parameters": [
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -21637,7 +21637,7 @@
},
{
"type": "string",
"description": "workflow status (pending, queued, in_progress, failure, success, skipped)",
"description": "workflow status (requested, pending, queued, in_progress, failure, success, skipped)",
"name": "status",
"in": "query"
},
@@ -603,6 +603,11 @@ jobs:
})
// cannot fetch wf2-job2 because wf1-job1 is running
runner1.fetchNoTask(t)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/jobs?status=pending", user2.Name, repo.Name)).AddTokenAuth(token)
pendingJobs := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.ActionWorkflowJobsResponse{})
require.Len(t, pendingJobs.Entries, 1)
assert.Equal(t, "wf2-job2", pendingJobs.Entries[0].Name)
assert.Equal(t, "pending", pendingJobs.Entries[0].Status)
// exec wf1-job1
runner1.execTask(t, wf1Job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
+1 -1
View File
@@ -411,7 +411,7 @@ jobs:
require.Contains(t, rerunJobsByJobID, "job1")
require.Contains(t, rerunJobsByJobID, "job2")
assert.Equal(t, actions_model.StatusWaiting, rerunJobsByJobID["job1"].Status)
assert.Equal(t, actions_model.StatusBlocked, rerunJobsByJobID["job2"].Status)
assert.Equal(t, actions_model.StatusPending, rerunJobsByJobID["job2"].Status)
// fetch job1 rerun task
job1TaskR1 := runner.fetchTask(t)
@@ -184,13 +184,13 @@ jobs:
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job2"})
callerJob2ID = callerJob2.ID
callerJob2AttemptJobID = callerJob2.AttemptJobID
assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status)
assert.Equal(t, actions_model.StatusPending, callerJob2.Status)
assert.True(t, callerJob2.IsReusableCaller)
// caller_job3
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job3"})
callerJob3AttemptJobID = callerJob3.AttemptJobID
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
assert.Equal(t, actions_model.StatusPending, callerJob3.Status)
assert.False(t, callerJob3.IsReusableCaller)
})
@@ -230,9 +230,8 @@ jobs:
result: runnerv1.Result_RESULT_SUCCESS,
})
// reusable1_job3 (a nested caller) needs reusable1_job2, so it stays Blocked until r1j2 succeeds.
r1Job3Pre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
assert.Equal(t, actions_model.StatusBlocked, r1Job3Pre.Status)
assert.Equal(t, actions_model.StatusPending, r1Job3Pre.Status)
assert.False(t, r1Job3Pre.IsExpanded)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"}))
@@ -308,11 +307,11 @@ jobs:
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob2AttemptJobID})
assert.Equal(t, actions_model.StatusWaiting, callerJob2.Status)
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob3AttemptJobID})
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
assert.Equal(t, actions_model.StatusPending, callerJob3.Status)
// reusable1_job3 needs reusable1_job2, so rerunning r1j2 pulls r1j3 (and its subtree) into the rerun set
r1Job3Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
assert.Equal(t, actions_model.StatusBlocked, r1Job3Attempt2.Status)
assert.Equal(t, actions_model.StatusPending, r1Job3Attempt2.Status)
assert.True(t, r1Job3Attempt2.IsReusableCaller)
assert.False(t, r1Job3Attempt2.IsExpanded)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"}))
@@ -606,12 +605,11 @@ jobs:
uses: ./.gitea/workflows/does-not-exist.yml
`)
// plain_job runs first; bad_caller is Blocked on needs and is NOT expanded at creation.
plainTask := runner.fetchTask(t)
_, plainJob, run := getTaskAndJobAndRunByTaskID(t, plainTask.Id)
assert.Equal(t, "plain_job", plainJob.JobID)
badCallerPre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "bad_caller"})
assert.Equal(t, actions_model.StatusBlocked, badCallerPre.Status)
assert.Equal(t, actions_model.StatusPending, badCallerPre.Status)
assert.False(t, badCallerPre.IsExpanded)
runner.execTask(t, plainTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
@@ -975,7 +973,6 @@ jobs:
innerChild := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "inner"})
assert.Equal(t, willRun.ID, innerChild.ParentJobID)
// after_skip: a dependent of the skipped caller resolves to Skipped instead of staying Blocked.
afterSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "after_skip"})
assert.Equal(t, actions_model.StatusSkipped, afterSkip.Status)
})
+38 -38
View File
@@ -1045,7 +1045,7 @@ jobs:
// 3. validate the webhook is triggered
assert.Equal(t, "workflow_job", triggeredEvent)
assert.Len(t, payloads, 2)
assert.Len(t, payloads, 1)
assert.Equal(t, "queued", payloads[0].Action)
assert.Equal(t, "queued", payloads[0].WorkflowJob.Status)
assert.Equal(t, []string{"ubuntu-latest"}, payloads[0].WorkflowJob.Labels)
@@ -1053,11 +1053,11 @@ jobs:
assert.Equal(t, "repo1", payloads[0].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[0].Repo.FullName)
assert.Equal(t, "waiting", payloads[1].Action)
assert.Equal(t, "waiting", payloads[1].WorkflowJob.Status)
assert.Equal(t, commitID, payloads[1].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[1].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[1].Repo.FullName)
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/actions/jobs?status=requested").AddTokenAuth(token)
requestedJobs := DecodeJSON(t, MakeRequest(t, req, http.StatusOK), &api.ActionWorkflowJobsResponse{})
require.Len(t, requestedJobs.Entries, 1)
assert.Equal(t, "wf2-job", requestedJobs.Entries[0].Name)
assert.Equal(t, "requested", requestedJobs.Entries[0].Status)
// 4. Execute a single Job
task := runner.fetchTask(t)
@@ -1068,31 +1068,31 @@ jobs:
// 5. validate the webhook is triggered
assert.Equal(t, "workflow_job", triggeredEvent)
assert.Len(t, payloads, 5)
assert.Equal(t, "in_progress", payloads[2].Action)
assert.Equal(t, "in_progress", payloads[2].WorkflowJob.Status)
assert.Len(t, payloads, 4)
assert.Equal(t, "in_progress", payloads[1].Action)
assert.Equal(t, "in_progress", payloads[1].WorkflowJob.Status)
assert.Equal(t, "mock-runner", payloads[1].WorkflowJob.RunnerName)
assert.Equal(t, commitID, payloads[1].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[1].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[1].Repo.FullName)
assert.Equal(t, "completed", payloads[2].Action)
assert.Equal(t, "completed", payloads[2].WorkflowJob.Status)
assert.Equal(t, "mock-runner", payloads[2].WorkflowJob.RunnerName)
assert.Equal(t, "success", payloads[2].WorkflowJob.Conclusion)
assert.Equal(t, commitID, payloads[2].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[2].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[2].Repo.FullName)
assert.Contains(t, payloads[2].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[2].WorkflowJob.ID))
assert.Contains(t, payloads[2].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", payloads[2].WorkflowJob.ID))
assert.Len(t, payloads[2].WorkflowJob.Steps, 1)
assert.Equal(t, "completed", payloads[3].Action)
assert.Equal(t, "completed", payloads[3].WorkflowJob.Status)
assert.Equal(t, "mock-runner", payloads[3].WorkflowJob.RunnerName)
assert.Equal(t, "success", payloads[3].WorkflowJob.Conclusion)
assert.Equal(t, "queued", payloads[3].Action)
assert.Equal(t, "queued", payloads[3].WorkflowJob.Status)
assert.Equal(t, []string{"ubuntu-latest"}, payloads[3].WorkflowJob.Labels)
assert.Equal(t, commitID, payloads[3].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[3].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[3].Repo.FullName)
assert.Contains(t, payloads[3].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[3].WorkflowJob.ID))
assert.Contains(t, payloads[3].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", payloads[3].WorkflowJob.ID))
assert.Len(t, payloads[3].WorkflowJob.Steps, 1)
assert.Equal(t, "queued", payloads[4].Action)
assert.Equal(t, "queued", payloads[4].WorkflowJob.Status)
assert.Equal(t, []string{"ubuntu-latest"}, payloads[4].WorkflowJob.Labels)
assert.Equal(t, commitID, payloads[4].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[4].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[4].Repo.FullName)
// 6. Execute a single Job
task = runner.fetchTask(t)
@@ -1103,25 +1103,25 @@ jobs:
// 7. validate the webhook is triggered
assert.Equal(t, "workflow_job", triggeredEvent)
assert.Len(t, payloads, 7)
assert.Equal(t, "in_progress", payloads[5].Action)
assert.Equal(t, "in_progress", payloads[5].WorkflowJob.Status)
assert.Equal(t, "mock-runner", payloads[5].WorkflowJob.RunnerName)
assert.Len(t, payloads, 6)
assert.Equal(t, "in_progress", payloads[4].Action)
assert.Equal(t, "in_progress", payloads[4].WorkflowJob.Status)
assert.Equal(t, "mock-runner", payloads[4].WorkflowJob.RunnerName)
assert.Equal(t, commitID, payloads[4].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[4].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[4].Repo.FullName)
assert.Equal(t, "completed", payloads[5].Action)
assert.Equal(t, "completed", payloads[5].WorkflowJob.Status)
assert.Equal(t, "failure", payloads[5].WorkflowJob.Conclusion)
assert.Equal(t, "mock-runner", payloads[5].WorkflowJob.RunnerName)
assert.Equal(t, commitID, payloads[5].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[5].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[5].Repo.FullName)
assert.Equal(t, "completed", payloads[6].Action)
assert.Equal(t, "completed", payloads[6].WorkflowJob.Status)
assert.Equal(t, "failure", payloads[6].WorkflowJob.Conclusion)
assert.Equal(t, "mock-runner", payloads[6].WorkflowJob.RunnerName)
assert.Equal(t, commitID, payloads[6].WorkflowJob.HeadSha)
assert.Equal(t, "repo1", payloads[6].Repo.Name)
assert.Equal(t, "user2/repo1", payloads[6].Repo.FullName)
assert.Contains(t, payloads[6].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[6].WorkflowJob.ID))
assert.Contains(t, payloads[6].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", payloads[6].WorkflowJob.ID))
assert.Len(t, payloads[6].WorkflowJob.Steps, 2)
assert.Contains(t, payloads[5].WorkflowJob.URL, fmt.Sprintf("/actions/jobs/%d", payloads[5].WorkflowJob.ID))
assert.Contains(t, payloads[5].WorkflowJob.HTMLURL, fmt.Sprintf("/jobs/%d", payloads[5].WorkflowJob.ID))
assert.Len(t, payloads[5].WorkflowJob.Steps, 2)
})
}
+3 -4
View File
@@ -1,13 +1,12 @@
<!-- Keep in sync with templates/repo/icons/action_status.tmpl.
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, cancelling, unknown.
-->
<!-- Keep in sync with templates/repo/icons/action_status.tmpl. -->
<script lang="ts" setup>
import {computed} from 'vue';
import SvgIcon from './SvgIcon.vue';
import {getActionStatusIcon, type ActionStatusIconVariant} from '../modules/action-status-icon.ts';
import type {ActionsStatus} from '../modules/gitea-actions.ts';
const props = withDefaults(defineProps<{
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'cancelling' | 'unknown',
status: ActionsStatus,
size?: number,
className?: string,
localeStatus?: string,
+1 -1
View File
@@ -92,7 +92,7 @@ function isJobCollapsed(jobID: number) {
}
const visibleJobListItems = computed<JobListItem[]>(() => {
const jobs = [...(run.value.jobs || [])].sort((a, b) => a.id - b.id);
const jobs = (run.value.jobs || []).filter((job) => job.status !== 'pending' || job.id === props.jobId).sort((a, b) => a.id - b.id);
const childrenByParent = buildJobsByParentJobID(jobs);
const result: JobListItem[] = [];
@@ -160,7 +160,7 @@ test('reusable callers with identical dependency signature are kept as separate
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
{id: 2, link: '', jobId: 'local_caller', name: 'local caller', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '5m', needs: ['prepare'], callUses: './.gitea/workflows/lib.yml'},
{id: 3, link: '', jobId: 'cross_caller', name: 'cross-repo caller', status: 'waiting', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '0s', needs: ['prepare'], callUses: 'user2/lib/.gitea/workflows/ext.yml@main'},
{id: 4, link: '', jobId: 'final', name: 'final', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '0s', needs: ['local_caller', 'cross_caller']},
{id: 4, link: '', jobId: 'final', name: 'final', status: 'pending', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '0s', needs: ['local_caller', 'cross_caller']},
];
const graph = createWorkflowGraphModel(jobs);
expect(graph.nodes.find((n) => n.type === 'group')).toBeUndefined();
+4 -3
View File
@@ -122,9 +122,10 @@ function compareStatusWorstFirst(a: ActionsStatus, b: ActionsStatus): number {
if (s === 'running') return 2;
if (s === 'waiting') return 3;
if (s === 'blocked') return 4;
if (s === 'success') return 5;
if (s === 'skipped') return 6;
return 7;
if (s === 'pending') return 5;
if (s === 'success') return 6;
if (s === 'skipped') return 7;
return 8;
};
return rank(a) - rank(b);
}
+14 -5
View File
@@ -228,11 +228,13 @@ const splitRoutedEdges = computed(() => {
const nodesWithIncomingEdge = computed(() => new Set(graphModel.value.adjacency.incomingByNodeId.keys()));
const nodesWithOutgoingEdge = computed(() => new Set(graphModel.value.adjacency.outgoingByNodeId.keys()));
function isJobLinked(job: ActionsJob) {
return !job.isReusableCaller && job.status !== 'pending'; // callers have no detail page, pending jobs have nothing to show yet
}
function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
const target = 'jobs' in job ? job.jobs[0]! : job;
// Reusable callers have no per-job detail page; clicking them is a no-op so the graph
// doesn't lead users to a dead destination.
if (target.isReusableCaller) return;
if (!isJobLinked(target)) return;
const link = `${props.runLink}/jobs/${target.id}`;
if (event.ctrlKey || event.metaKey) {
window.open(link, '_blank');
@@ -342,6 +344,7 @@ function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
v-for="ch in job.jobs"
:key="ch.id"
class="graph-list-row"
:class="{ 'unlinked-node': !isJobLinked(ch) }"
@mouseenter="handleNodeMouseEnter(job.id)"
@click.stop="onNodeClick(ch, $event)"
>
@@ -373,6 +376,7 @@ function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
v-for="ch in job.jobs"
:key="ch.id"
class="graph-list-row"
:class="{ 'unlinked-node': !isJobLinked(ch) }"
@mouseenter="handleNodeMouseEnter(job.id)"
@click="onNodeClick(ch, $event)"
>
@@ -391,7 +395,7 @@ function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
<g
v-else
class="job-node-group"
:class="{ 'related-node': isNodeHighlighted(job.id), 'caller-node': job.jobs[0]!.isReusableCaller }"
:class="{ 'related-node': isNodeHighlighted(job.id), 'unlinked-node': !isJobLinked(job.jobs[0]!) }"
@click="onNodeClick(job, $event)"
@mouseenter="handleNodeMouseEnter(job.id)"
@mouseleave="handleNodeMouseLeave"
@@ -523,10 +527,15 @@ function onNodeClick(job: GraphNode | ActionsJob, event: MouseEvent) {
transition: opacity 0.15s ease;
}
.job-node-group.caller-node {
.job-node-group.unlinked-node,
.graph-list-row.unlinked-node {
cursor: default;
}
.graph-list-row.unlinked-node:hover {
background: none;
}
.job-node-group:hover .job-rect,
.job-node-group.related-node .job-rect {
stroke: var(--color-primary);
+1
View File
@@ -89,6 +89,7 @@ function initRepositoryActionsView() {
cancelled: el.getAttribute('data-locale-status-cancelled'),
skipped: el.getAttribute('data-locale-status-skipped'),
blocked: el.getAttribute('data-locale-status-blocked'),
pending: el.getAttribute('data-locale-status-pending'),
},
logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'),
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
+2
View File
@@ -19,6 +19,8 @@ export function getActionStatusIcon(status: ActionsStatus, iconVariant: ActionSt
case 'cancelled':
return {name: 'octicon-stop', colorClass: 'tw-text-text-light'};
case 'waiting':
return {name: 'octicon-dot-fill', colorClass: 'tw-text-yellow'};
case 'pending':
return {name: 'octicon-circle', colorClass: 'tw-text-text-light'};
case 'blocked':
return {name: 'octicon-blocked', colorClass: 'tw-text-yellow'};
+1 -1
View File
@@ -1,5 +1,5 @@
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
export type ActionsStatus = 'unknown' | 'waiting' | 'running' | 'cancelling' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
export type ActionsStatus = 'unknown' | 'waiting' | 'running' | 'cancelling' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked' | 'pending';
export type ActionsArtifactStatus = 'expired' | 'completed';
export type ActionsRun = {