// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions import ( "testing" actions_model "gitea.dev/models/actions" user_model "gitea.dev/models/user" "gitea.dev/modules/container" "gitea.dev/modules/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetFailedJobsForRerun(t *testing.T) { makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob { return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs} } t.Run("no failed jobs returns empty", func(t *testing.T) { jobs := []*actions_model.ActionRunJob{ makeJob(1, "job1", actions_model.StatusSuccess), makeJob(2, "job2", actions_model.StatusSkipped, "job1"), } assert.Empty(t, GetFailedJobsForRerun(jobs)) }) t.Run("single failed job with no dependents", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusSuccess) jobs := []*actions_model.ActionRunJob{job1, job2} result := GetFailedJobsForRerun(jobs) assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) t.Run("failed job does not pull in downstream dependents", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1") job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2") job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} result := GetFailedJobsForRerun(jobs) assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) t.Run("multiple failed jobs are returned directly", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusFailure) job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1") job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2") jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} result := GetFailedJobsForRerun(jobs) assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) }) t.Run("shared downstream dependent is not included", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusFailure) job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2") jobs := []*actions_model.ActionRunJob{job1, job2, job3} result := GetFailedJobsForRerun(jobs) assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) assert.Len(t, result, 2) }) t.Run("successful downstream job of a failed job is not included", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1") jobs := []*actions_model.ActionRunJob{job1, job2} result := GetFailedJobsForRerun(jobs) assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) } func TestRerunValidation(t *testing.T) { runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning} t.Run("RerunWorkflowRunJobs rejects a non-done run", func(t *testing.T) { jobs := []*actions_model.ActionRunJob{ {ID: 1, JobID: "job1"}, } _, err := RerunWorkflowRunJobs(t.Context(), nil, runningRun, &user_model.User{ID: 1}, jobs) require.Error(t, err) assert.ErrorIs(t, err, util.ErrInvalidArgument) }) t.Run("RerunWorkflowRunJobs rejects a non-done run when failed jobs exist", func(t *testing.T) { jobs := []*actions_model.ActionRunJob{ {ID: 1, JobID: "job1", Status: actions_model.StatusFailure}, } _, err := RerunWorkflowRunJobs(t.Context(), nil, runningRun, &user_model.User{ID: 1}, GetFailedJobsForRerun(jobs)) require.Error(t, err) assert.ErrorIs(t, err, util.ErrInvalidArgument) }) } func TestRerunPlan(t *testing.T) { // "verify" appears in two scopes (inner caller under deploy, and top-level) so scope-blind matching would fail here. // build id=101, attemptJobID=1 // test id=102, attemptJobID=2, needs=[build] // deploy id=103, attemptJobID=3, caller // ├── validate id=104, attemptJobID=4, parent=103 // ├── push id=105, attemptJobID=5, parent=103, needs=[validate] // ├── verify id=106, attemptJobID=6, parent=103, caller, needs=[push] // │ ├── smoke-test id=107, attemptJobID=7, parent=106 // │ └── cleanup id=108, attemptJobID=8, parent=106, needs=[smoke-test] // └── finish-deploy id=109, attemptJobID=9, parent=103, needs=[verify] // verify id=110, attemptJobID=10, needs=[deploy] (top-level, same JobID) buildJob := templateJob(101, 1, "build", 0, false) testJob := templateJob(102, 2, "test", 0, false, "build") deployJob := templateJob(103, 3, "deploy", 0, true) validateJob := templateJob(104, 4, "validate", 103, false) pushJob := templateJob(105, 5, "push", 103, false, "validate") verifyInnerJob := templateJob(106, 6, "verify", 103, true, "push") smokeTestJob := templateJob(107, 7, "smoke-test", 106, false) cleanupJob := templateJob(108, 8, "cleanup", 106, false, "smoke-test") finishDeployJob := templateJob(109, 9, "finish-deploy", 103, false, "verify") verifyTopJob := templateJob(110, 10, "verify", 0, false, "deploy") jobs := []*actions_model.ActionRunJob{ buildJob, testJob, deployJob, validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob, } t.Run("ExpandRerunJobIDs", func(t *testing.T) { t.Run("empty jobsToRerun reruns every template job, no ancestors", func(t *testing.T) { plan := &rerunPlan{templateJobs: jobs} require.NoError(t, plan.expandRerunJobIDs(nil)) assert.ElementsMatch(t, attemptJobIDsOf(jobs...), plan.rerunAttemptJobIDs.Values()) assert.Empty(t, plan.ancestorAttemptJobIDs) }) t.Run("same-scope downstream BFS pulls in dependents", func(t *testing.T) { // a -> b -> c (chain), d unrelated. a := templateJob(101, 1, "a", 0, false) b := templateJob(102, 2, "b", 0, false, "a") c := templateJob(103, 3, "c", 0, false, "b") d := templateJob(104, 4, "d", 0, false) plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{a, b, c, d}} require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{a})) assert.ElementsMatch(t, attemptJobIDsOf(a, b, c), plan.rerunAttemptJobIDs.Values()) assert.Empty(t, plan.ancestorAttemptJobIDs) }) t.Run("rerun a deep child escalates across reusable scopes", func(t *testing.T) { plan := &rerunPlan{templateJobs: jobs} require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{smokeTestJob})) // rerun: smoke-test (selected), cleanup (same-scope downstream), // finish-deploy (deploy-scope sibling of inner verify ancestor), // top-level verify (top-scope sibling of deploy ancestor). assert.ElementsMatch(t, attemptJobIDsOf(smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values()) // ancestors: inner verify and deploy assert.ElementsMatch(t, attemptJobIDsOf(verifyInnerJob, deployJob), plan.ancestorAttemptJobIDs.Values()) }) t.Run("rerun a top-level caller resets only itself and same-scope dependents", func(t *testing.T) { plan := &rerunPlan{templateJobs: jobs} require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{deployJob})) // rerun: deploy (selected) + top-level verify (needs:[deploy]). assert.ElementsMatch(t, attemptJobIDsOf(deployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values()) // deploy is top-level so no ancestors. assert.Empty(t, plan.ancestorAttemptJobIDs) }) t.Run("rerun a nested caller escalates one level", func(t *testing.T) { plan := &rerunPlan{templateJobs: jobs} require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{verifyInnerJob})) // inner verify (selected) -> finish-deploy (deploy-scope dep) -> top-level verify (top-scope dep of deploy). assert.ElementsMatch(t, attemptJobIDsOf(verifyInnerJob, finishDeployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values()) // deploy is the only ancestor (one level up from inner verify). assert.ElementsMatch(t, attemptJobIDsOf(deployJob), plan.ancestorAttemptJobIDs.Values()) }) t.Run("selecting one same-name job leaves the other-scope same-name job alone", func(t *testing.T) { // Selecting the top-level "verify" must not pull in the same-named inner one or its descendants. plan := &rerunPlan{templateJobs: jobs} require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{verifyTopJob})) // Only the top-level verify is rerun. assert.ElementsMatch(t, attemptJobIDsOf(verifyTopJob), plan.rerunAttemptJobIDs.Values()) assert.Empty(t, plan.ancestorAttemptJobIDs) }) t.Run("a caller is rerun when a sibling it needs is selected", func(t *testing.T) { plan := &rerunPlan{templateJobs: jobs} require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{pushJob})) assert.ElementsMatch(t, attemptJobIDsOf(pushJob, verifyInnerJob, finishDeployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values()) assert.ElementsMatch(t, attemptJobIDsOf(deployJob), plan.ancestorAttemptJobIDs.Values()) // Confirm the downstream effect: verify(inner) is a reset caller, so its children's DB row IDs are marked for skip-clone. assert.ElementsMatch(t, rowIDsOf(smokeTestJob, cleanupJob), plan.collectResetCallerDescendants().Values()) }) t.Run("multiple selections converge", func(t *testing.T) { plan := &rerunPlan{templateJobs: jobs} require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{deployJob, smokeTestJob})) assert.ElementsMatch(t, attemptJobIDsOf(deployJob, smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values()) assert.Empty(t, plan.ancestorAttemptJobIDs) assert.ElementsMatch(t, rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob), plan.collectResetCallerDescendants().Values()) }) }) t.Run("CollectResetCallerDescendants", func(t *testing.T) { planWith := func(rerunJobs ...*actions_model.ActionRunJob) *rerunPlan { set := make(container.Set[int64]) for _, j := range rerunJobs { set.Add(j.AttemptJobID) } return &rerunPlan{templateJobs: jobs, rerunAttemptJobIDs: set} } t.Run("non-caller in reset set is ignored", func(t *testing.T) { assert.Empty(t, planWith(smokeTestJob).collectResetCallerDescendants()) }) t.Run("caller in reset set returns transitive descendants", func(t *testing.T) { out := planWith(deployJob).collectResetCallerDescendants() assert.ElementsMatch(t, rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob), out.Values()) }) t.Run("multiple reset callers union their descendants", func(t *testing.T) { out := planWith(deployJob, verifyInnerJob).collectResetCallerDescendants() assert.ElementsMatch(t, rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob), out.Values()) }) t.Run("nested-only reset returns just the nested subtree", func(t *testing.T) { out := planWith(verifyInnerJob).collectResetCallerDescendants() assert.ElementsMatch(t, rowIDsOf(smokeTestJob, cleanupJob), out.Values()) }) }) t.Run("HasRerunDependency", func(t *testing.T) { t.Run("no needs returns false", func(t *testing.T) { plan := &rerunPlan{ templateJobs: []*actions_model.ActionRunJob{buildJob}, rerunAttemptJobIDs: make(container.Set[int64]), ancestorAttemptJobIDs: make(container.Set[int64]), } assert.False(t, plan.hasRerunDependency(buildJob)) }) t.Run("dependency in rerun set returns true", func(t *testing.T) { plan := &rerunPlan{ templateJobs: jobs, rerunAttemptJobIDs: container.SetOf(smokeTestJob.AttemptJobID), ancestorAttemptJobIDs: make(container.Set[int64]), } // cleanup `needs: [smoke-test]`, both in inner verify scope. assert.True(t, plan.hasRerunDependency(cleanupJob)) }) t.Run("dependency in ancestor set returns true", func(t *testing.T) { plan := &rerunPlan{ templateJobs: jobs, rerunAttemptJobIDs: container.SetOf(attemptJobIDsOf(smokeTestJob, cleanupJob)...), ancestorAttemptJobIDs: container.SetOf(verifyInnerJob.AttemptJobID), } assert.True(t, plan.hasRerunDependency(finishDeployJob)) }) t.Run("dependency on unrelated sibling returns false", func(t *testing.T) { plan := &rerunPlan{ templateJobs: jobs, rerunAttemptJobIDs: container.SetOf(smokeTestJob.AttemptJobID), ancestorAttemptJobIDs: make(container.Set[int64]), } assert.False(t, plan.hasRerunDependency(pushJob)) }) t.Run("scope-bound: same JobID in another scope does not match", func(t *testing.T) { plan := &rerunPlan{ templateJobs: jobs, rerunAttemptJobIDs: container.SetOf(verifyTopJob.AttemptJobID), ancestorAttemptJobIDs: make(container.Set[int64]), } assert.False(t, plan.hasRerunDependency(finishDeployJob)) // Sanity: swap to the inner verify and the same target now sees it. plan.rerunAttemptJobIDs = container.SetOf(verifyInnerJob.AttemptJobID) assert.True(t, plan.hasRerunDependency(finishDeployJob)) }) }) } // templateJob is a small constructor for fixture jobs used by the rerunPlan unit tests. func templateJob(id, attemptJobID int64, jobID string, parentID int64, isCaller bool, needs ...string) *actions_model.ActionRunJob { return &actions_model.ActionRunJob{ ID: id, AttemptJobID: attemptJobID, JobID: jobID, ParentJobID: parentID, IsReusableCaller: isCaller, Needs: needs, } } func attemptJobIDsOf(jobs ...*actions_model.ActionRunJob) []int64 { out := make([]int64, len(jobs)) for i, j := range jobs { out[i] = j.AttemptJobID } return out } func rowIDsOf(jobs ...*actions_model.ActionRunJob) []int64 { out := make([]int64, len(jobs)) for i, j := range jobs { out[i] = j.ID } return out }