diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 836dbc96714..a15b04fda50 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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] diff --git a/models/actions/run_job.go b/models/actions/run_job.go index bcf111473a6..98247accd69 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -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 diff --git a/models/actions/run_job_status_test.go b/models/actions/run_job_status_test.go index 74b4cd8a105..90cb4afd436 100644 --- a/models/actions/run_job_status_test.go +++ b/models/actions/run_job_status_test.go @@ -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 diff --git a/models/actions/status.go b/models/actions/status.go index f89461eb44e..1a63aeb7b31 100644 --- a/models/actions/status.go +++ b/models/actions/status.go @@ -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) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index fbdfe2eb965..5f1de7335e9 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/api/v1/admin/action.go b/routers/api/v1/admin/action.go index ad92bb1f80c..38e9cc314ed 100644 --- a/routers/api/v1/admin/action.go +++ b/routers/api/v1/admin/action.go @@ -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 diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 92eeb6bdbb4..5c55763380f 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -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 diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index f2cf6834e09..94ca4096c00 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -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 diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index 134d336b2aa..5b4ac8f4516 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -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": diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index 13507bf614d..fd7eac2f646 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -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 diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 6a9f68a525b..f61becd65fd 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -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"}, }, } diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index c73f413c953..57cb60e8b91 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -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 { diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index af8676602ca..9aff66aba56 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -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(), }) } diff --git a/routers/web/repo/actions/view_test.go b/routers/web/repo/actions/view_test.go index ac93c8f6122..9e36b8a62f2 100644 --- a/routers/web/repo/actions/view_test.go +++ b/routers/web/repo/actions/view_test.go @@ -133,7 +133,7 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T) { Summary: "Complete job", Duration: "0s", - Status: "waiting", + Status: "pending", }, } assert.Equal(t, expectedViewJobs, viewJobSteps) diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index 4f1bc8d61ae..c7e2e8c0ec0 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -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 { diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index fd5d794334d..8eddeb03b5a 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -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 diff --git a/services/actions/commit_status_test.go b/services/actions/commit_status_test.go index eebfb8c4d06..b595602cfd1 100644 --- a/services/actions/commit_status_test.go +++ b/services/actions/commit_status_test.go @@ -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 " / ()"; the scoped one is the same but sets off the source repo with a colon, diff --git a/services/actions/invalid_workflow.go b/services/actions/invalid_workflow.go index 172de91b4d0..fc211fbdcbc 100644 --- a/services/actions/invalid_workflow.go +++ b/services/actions/invalid_workflow.go @@ -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) diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index b4a3156572f..b9e53044319 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -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 } diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go index 9e2edc6d244..0fc4255cc40 100644 --- a/services/actions/job_emitter_test.go +++ b/services/actions/job_emitter_test.go @@ -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") }) } diff --git a/services/actions/max_parallel_test.go b/services/actions/max_parallel_test.go index 7d054c97dd9..88bab1f8c61 100644 --- a/services/actions/max_parallel_test.go +++ b/services/actions/max_parallel_test.go @@ -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()) diff --git a/services/actions/notify.go b/services/actions/notify.go index 8d356960bae..040ddec98eb 100644 --- a/services/actions/notify.go +++ b/services/actions/notify.go @@ -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...) } } diff --git a/services/actions/rerun.go b/services/actions/rerun.go index f516e67cafd..59d63f83dff 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -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 diff --git a/services/actions/reusable_workflow.go b/services/actions/reusable_workflow.go index 0f1fbb4ffbd..f2a6f56e67a 100644 --- a/services/actions/reusable_workflow.go +++ b/services/actions/reusable_workflow.go @@ -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, diff --git a/services/actions/run.go b/services/actions/run.go index a589ccdf9fa..6762fda027b 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -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) diff --git a/services/convert/action_test.go b/services/convert/action_test.go index 2584eaf4a49..2b47f1d3690 100644 --- a/services/convert/action_test.go +++ b/services/convert/action_test.go @@ -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) { diff --git a/services/convert/convert.go b/services/convert/convert.go index ad7cfcac5c7..c3c710951e7 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -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 diff --git a/services/webhook/general.go b/services/webhook/general.go index 127df37bdd8..82d56d03556 100644 --- a/services/webhook/general.go +++ b/services/webhook/general.go @@ -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 diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index 44cbe7747da..944044618f5 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -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) diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index e72f8f2864a..b855257ecc0 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -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"}}" diff --git a/templates/repo/icons/action_status.tmpl b/templates/repo/icons/action_status.tmpl index 1711c61b2bf..4a1ec773fe7 100644 --- a/templates/repo/icons/action_status.tmpl +++ b/templates/repo/icons/action_status.tmpl @@ -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)}} diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index 1370e3ecaa0..c1ac350b400 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -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": { diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index ad6a8db84c9..f7128397a3e 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -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" }, diff --git a/tests/integration/actions_concurrency_test.go b/tests/integration/actions_concurrency_test.go index 7adfd593cca..50f93959ac3 100644 --- a/tests/integration/actions_concurrency_test.go +++ b/tests/integration/actions_concurrency_test.go @@ -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, diff --git a/tests/integration/actions_rerun_test.go b/tests/integration/actions_rerun_test.go index dbe09176b24..660a4f2f29c 100644 --- a/tests/integration/actions_rerun_test.go +++ b/tests/integration/actions_rerun_test.go @@ -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) diff --git a/tests/integration/actions_reusable_workflow_test.go b/tests/integration/actions_reusable_workflow_test.go index e1de6a7ca94..77c6a0895ce 100644 --- a/tests/integration/actions_reusable_workflow_test.go +++ b/tests/integration/actions_reusable_workflow_test.go @@ -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) }) diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index aea66d5b14c..edd8b5850b1 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -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) }) } diff --git a/web_src/js/components/ActionStatusIcon.vue b/web_src/js/components/ActionStatusIcon.vue index 2ebfeb0dd83..93d1c5bdb66 100644 --- a/web_src/js/components/ActionStatusIcon.vue +++ b/web_src/js/components/ActionStatusIcon.vue @@ -1,13 +1,12 @@ - +