diff --git a/services/actions/TestActions_CancelOrApproveRun/action_run.yml b/services/actions/TestActions_CancelOrApproveRun/action_run.yml new file mode 100644 index 0000000000..b084007503 --- /dev/null +++ b/services/actions/TestActions_CancelOrApproveRun/action_run.yml @@ -0,0 +1,38 @@ +- + id: 900 + title: "run running" + repo_id: 63 + owner_id: 2 + workflow_id: "test.yaml" + index: 4 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "synchronized" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 + concurrency_group: abc123 +- + id: 800 + title: "run waiting because jobs need approval" + repo_id: 63 + owner_id: 2 + workflow_id: "test.yaml" + index: 5 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "synchronized" + is_fork_pull_request: 0 + status: 5 # waiting + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 1 + approved_by: 0 + concurrency_group: def435 diff --git a/services/actions/TestActions_CancelOrApproveRun/action_run_job.yml b/services/actions/TestActions_CancelOrApproveRun/action_run_job.yml new file mode 100644 index 0000000000..610f34e274 --- /dev/null +++ b/services/actions/TestActions_CancelOrApproveRun/action_run_job.yml @@ -0,0 +1,42 @@ +- + id: 10900 + run_id: 900 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: job_1 + attempt: 0 + job_id: job_1 + task_id: 0 + status: 1 # success + runs_on: '["docker"]' + started: 1683636528 +- + id: 11900 + run_id: 900 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: job_2 + attempt: 0 + job_id: job_2 + task_id: 711900 + status: 6 # running + runs_on: '["docker"]' + started: 1683636528 +- + id: 10800 + run_id: 800 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: job_1 + attempt: 0 + job_id: job_1 + task_id: 0 + status: 7 # blocked + runs_on: '["docker"]' + started: 1683636528 diff --git a/services/actions/TestActions_CancelOrApproveRun/action_task.yml b/services/actions/TestActions_CancelOrApproveRun/action_task.yml new file mode 100644 index 0000000000..5aaafe97a1 --- /dev/null +++ b/services/actions/TestActions_CancelOrApproveRun/action_task.yml @@ -0,0 +1,40 @@ +- + id: 710900 + job_id: 10900 + attempt: 0 + runner_id: 1 + status: 1 # success + started: 1683636528 + stopped: 1683636626 + repo_id: 63 + owner_id: 2 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + token_hash: 8d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2a867e + token_salt: jVuKnSPGgy + token_last_eight: eeb1a71a + log_filename: artifact-test2/2f/47.log + log_in_storage: 1 + log_length: 707 + log_size: 90179 + log_expired: 0 +- + id: 711900 + job_id: 11900 + attempt: 0 + runner_id: 1 + status: 6 # running + started: 1683636528 + stopped: 1683636626 + repo_id: 63 + owner_id: 2 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + is_fork_pull_request: 0 + token_hash: 7d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4260c64a69a2cc1508825121b7b8394e48e00b1bf8718b2a867e + token_salt: jVuKnSPGgy + token_last_eight: eeb1a71a + log_filename: artifact-test2/2f/47.log + log_in_storage: 1 + log_length: 707 + log_size: 90179 + log_expired: 0 diff --git a/services/actions/run.go b/services/actions/run.go new file mode 100644 index 0000000000..7f64e00b92 --- /dev/null +++ b/services/actions/run.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "context" + + actions_model "forgejo.org/models/actions" + "forgejo.org/models/db" + "forgejo.org/modules/timeutil" +) + +func CancelRun(ctx context.Context, run *actions_model.ActionRun) error { + return db.WithTx(ctx, func(ctx context.Context) error { + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return err + } + for _, job := range jobs { + status := job.Status + if status.IsDone() { + continue + } + if job.TaskID == 0 { + job.Status = actions_model.StatusCancelled + job.Stopped = timeutil.TimeStampNow() + _, err := actions_model.UpdateRunJobWithoutNotification(ctx, job, nil, "status", "stopped") + if err != nil { + return err + } + continue + } + if err := StopTask(ctx, job.TaskID, actions_model.StatusCancelled); err != nil { + return err + } + } + + if run.NeedApproval { + if err := actions_model.UpdateRunApprovalByID(ctx, run.ID, actions_model.DoesNotNeedApproval, 0); err != nil { + return err + } + } + + CreateCommitStatus(ctx, jobs...) + + return nil + }) +} + +func ApproveRun(ctx context.Context, run *actions_model.ActionRun, doerID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return err + } + for _, job := range jobs { + if len(job.Needs) == 0 && job.Status.IsBlocked() { + job.Status = actions_model.StatusWaiting + _, err := UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + } + } + CreateCommitStatus(ctx, jobs...) + + return actions_model.UpdateRunApprovalByID(ctx, run.ID, actions_model.DoesNotNeedApproval, doerID) + }) +} diff --git a/services/actions/run_test.go b/services/actions/run_test.go new file mode 100644 index 0000000000..15d64a9346 --- /dev/null +++ b/services/actions/run_test.go @@ -0,0 +1,104 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "testing" + + actions_model "forgejo.org/models/actions" + "forgejo.org/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActions_CancelOrApproveRun(t *testing.T) { + t.Run("run, job and task Running changes to run, job and task Cancelled", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestActions_CancelOrApproveRun")() + require.NoError(t, unittest.PrepareTestDatabase()) + + taskID := int64(711900) + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskID}) + require.Equal(t, actions_model.StatusRunning.String(), task.Status.String()) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID}) + require.Equal(t, actions_model.StatusRunning.String(), job.Status.String()) + require.Zero(t, job.Stopped) + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID}) + require.Equal(t, actions_model.StatusRunning.String(), run.Status.String()) + + require.NoError(t, CancelRun(t.Context(), run)) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID}) + assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String()) + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID}) + assert.Equal(t, actions_model.StatusCancelled.String(), job.Status.String()) + assert.NotZero(t, job.Stopped) + task = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskID}) + require.Equal(t, actions_model.StatusCancelled.String(), task.Status.String()) + }) + + t.Run("run Running, job and task Success changes to run Cancelled", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestActions_CancelOrApproveRun")() + require.NoError(t, unittest.PrepareTestDatabase()) + + taskID := int64(710900) + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskID}) + require.Equal(t, actions_model.StatusSuccess.String(), task.Status.String()) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID}) + require.Equal(t, actions_model.StatusSuccess.String(), job.Status.String()) + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID}) + require.Equal(t, actions_model.StatusRunning.String(), run.Status.String()) + + require.NoError(t, CancelRun(t.Context(), run)) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID}) + assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String()) + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID}) + assert.Equal(t, actions_model.StatusSuccess, job.Status) + task = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskID}) + require.Equal(t, actions_model.StatusSuccess, task.Status) + }) + + t.Run("run Waiting and job Blocked for Approval changes to run and job Cancelled", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestActions_CancelOrApproveRun")() + require.NoError(t, unittest.PrepareTestDatabase()) + + jobID := int64(10800) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID}) + require.Equal(t, actions_model.StatusBlocked.String(), job.Status.String()) + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID}) + require.Equal(t, actions_model.StatusWaiting.String(), run.Status.String()) + require.True(t, run.NeedApproval) + + require.NoError(t, CancelRun(t.Context(), run)) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID}) + assert.Equal(t, actions_model.StatusCancelled.String(), run.Status.String()) + assert.False(t, run.NeedApproval) + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID}) + assert.Equal(t, actions_model.StatusCancelled, job.Status) + }) + + t.Run("run Waiting and job Blocked for Approval changes to job Waiting", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestActions_CancelOrApproveRun")() + require.NoError(t, unittest.PrepareTestDatabase()) + + jobID := int64(10800) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID}) + require.Equal(t, actions_model.StatusBlocked.String(), job.Status.String()) + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID}) + require.Equal(t, actions_model.StatusWaiting.String(), run.Status.String()) + require.True(t, run.NeedApproval) + + doerID := int64(30) + require.NoError(t, ApproveRun(t.Context(), run, doerID)) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: job.RunID}) + assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String()) + assert.False(t, run.NeedApproval) + assert.Equal(t, doerID, run.ApprovedBy) + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID}) + assert.Equal(t, actions_model.StatusWaiting, job.Status) + }) +}