feat: add the actions CancelRun and ApproveRun helpers

CancelRun Cancels all the jobs of a given run.

It is very similar to the less generic web/repo/actions/view.go with
two differences:

- It updates NeedApproval
- The commit status are created within the transaction

It is also very similar to cancelJobsForRun in services/actions/schedule_tasks.go

Keeping those DRY would require a small refactor that does not feel
necessary at this moment.

ApproveRun Approves all the jobs of a given run.
This commit is contained in:
Earl Warren
2025-11-06 11:07:38 +01:00
parent 5da1d8dcd7
commit 71439965d6
5 changed files with 294 additions and 0 deletions
@@ -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
@@ -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
@@ -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
+70
View File
@@ -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)
})
}
+104
View File
@@ -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)
})
}