mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-04-25 21:20:48 +03:00
Actions Done Notification (#7491)
Some checks are pending
/ release (push) Waiting to run
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
Some checks are pending
/ release (push) Waiting to run
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
This PR depends on https://codeberg.org/forgejo/forgejo/pulls/7510 This PR renames UpdateRunJob to UpdateRunJobWithoutNotification and UpdateRun to UpdateRunWithoutNotification and implements wrapper functions that also call the new ActionRunNowDone notification when needed. This PR can be reviewed commit-by-commit. # Things to Test - [x] GetRunBefore - [ ] integration test for sendActionRunNowDoneNotificationIfNeeded, UpdateRun and UpdateRunJob ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [ ] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title. Co-authored-by: nobody <nobody@example.com> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7491 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org> Co-authored-by: christopher-besch <mail@chris-besch.com> Co-committed-by: christopher-besch <mail@chris-besch.com>
This commit is contained in:
parent
0f6176470f
commit
05273fa8d2
16 changed files with 425 additions and 14 deletions
|
@ -13,6 +13,7 @@ func TestMain(m *testing.M) {
|
||||||
unittest.MainTest(m, &unittest.TestOptions{
|
unittest.MainTest(m, &unittest.TestOptions{
|
||||||
FixtureFiles: []string{
|
FixtureFiles: []string{
|
||||||
"action_runner.yml",
|
"action_runner.yml",
|
||||||
|
"repository.yml",
|
||||||
"action_runner_token.yml",
|
"action_runner_token.yml",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -187,6 +187,7 @@ func updateRepoRunsNumbers(ctx context.Context, repo *repo_model.Repository) err
|
||||||
|
|
||||||
// InsertRun inserts a run
|
// InsertRun inserts a run
|
||||||
// The title will be cut off at 255 characters if it's longer than 255 characters.
|
// The title will be cut off at 255 characters if it's longer than 255 characters.
|
||||||
|
// We don't have to send the ActionRunNowDone notification here because there are no runs that start in a not done status.
|
||||||
func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error {
|
func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error {
|
||||||
ctx, commiter, err := db.TxContext(ctx)
|
ctx, commiter, err := db.TxContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -272,6 +273,18 @@ func GetLatestRun(ctx context.Context, repoID int64) (*ActionRun, error) {
|
||||||
return &run, nil
|
return &run, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRunBefore returns the last run that completed a given timestamp (not inclusive).
|
||||||
|
func GetRunBefore(ctx context.Context, repoID int64, timestamp timeutil.TimeStamp) (*ActionRun, error) {
|
||||||
|
var run ActionRun
|
||||||
|
has, err := db.GetEngine(ctx).Where("repo_id=? AND stopped IS NOT NULL AND stopped<?", repoID, timestamp).OrderBy("stopped DESC").Limit(1).Get(&run)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !has {
|
||||||
|
return nil, fmt.Errorf("run before: %w", util.ErrNotExist)
|
||||||
|
}
|
||||||
|
return &run, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetLatestRunForBranchAndWorkflow(ctx context.Context, repoID int64, branch, workflowFile, event string) (*ActionRun, error) {
|
func GetLatestRunForBranchAndWorkflow(ctx context.Context, repoID int64, branch, workflowFile, event string) (*ActionRun, error) {
|
||||||
var run ActionRun
|
var run ActionRun
|
||||||
q := db.GetEngine(ctx).Where("repo_id=?", repoID).And("workflow_id=?", workflowFile)
|
q := db.GetEngine(ctx).Where("repo_id=?", repoID).And("workflow_id=?", workflowFile)
|
||||||
|
@ -320,7 +333,9 @@ func GetRunByIndex(ctx context.Context, repoID, index int64) (*ActionRun, error)
|
||||||
// UpdateRun updates a run.
|
// UpdateRun updates a run.
|
||||||
// It requires the inputted run has Version set.
|
// It requires the inputted run has Version set.
|
||||||
// It will return error if the version is not matched (it means the run has been changed after loaded).
|
// It will return error if the version is not matched (it means the run has been changed after loaded).
|
||||||
func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error {
|
// All calls to UpdateRunWithoutNotification that change run.Status from a not done status to a done status must call the ActionRunNowDone notification channel.
|
||||||
|
// Use the wrapper function UpdateRun instead.
|
||||||
|
func UpdateRunWithoutNotification(ctx context.Context, run *ActionRun, cols ...string) error {
|
||||||
sess := db.GetEngine(ctx).ID(run.ID)
|
sess := db.GetEngine(ctx).ID(run.ID)
|
||||||
if len(cols) > 0 {
|
if len(cols) > 0 {
|
||||||
sess.Cols(cols...)
|
sess.Cols(cols...)
|
||||||
|
|
|
@ -101,7 +101,9 @@ func GetRunJobsByRunID(ctx context.Context, runID int64) ([]*ActionRunJob, error
|
||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
// All calls to UpdateRunJobWithoutNotification that change run.Status for any run from a not done status to a done status must call the ActionRunNowDone notification channel.
|
||||||
|
// Use the wrapper function UpdateRunJob instead.
|
||||||
|
func UpdateRunJobWithoutNotification(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
||||||
e := db.GetEngine(ctx)
|
e := db.GetEngine(ctx)
|
||||||
|
|
||||||
sess := e.ID(job.ID)
|
sess := e.ID(job.ID)
|
||||||
|
@ -154,7 +156,8 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
||||||
if run.Stopped.IsZero() && run.Status.IsDone() {
|
if run.Stopped.IsZero() && run.Status.IsDone() {
|
||||||
run.Stopped = timeutil.TimeStampNow()
|
run.Stopped = timeutil.TimeStampNow()
|
||||||
}
|
}
|
||||||
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
|
// As the caller has to ensure the ActionRunNowDone notification is sent we can ignore doing so here.
|
||||||
|
if err := UpdateRunWithoutNotification(ctx, run, "status", "started", "stopped"); err != nil {
|
||||||
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
|
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
96
models/actions/run_test.go
Normal file
96
models/actions/run_test.go
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forgejo.org/models/db"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
"forgejo.org/modules/timeutil"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetRunBefore(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
// this repo is part of the test database requiring loading "repository.yml" in main_test.go
|
||||||
|
var repoID int64 = 1
|
||||||
|
|
||||||
|
workflowID := "test_workflow"
|
||||||
|
|
||||||
|
// third completed run
|
||||||
|
time1, err := time.Parse(time.RFC3339, "2024-07-31T15:47:55+08:00")
|
||||||
|
require.NoError(t, err)
|
||||||
|
timeutil.MockSet(time1)
|
||||||
|
run1 := ActionRun{
|
||||||
|
ID: 1,
|
||||||
|
Index: 1,
|
||||||
|
RepoID: repoID,
|
||||||
|
Stopped: timeutil.TimeStampNow(),
|
||||||
|
WorkflowID: workflowID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// fourth completed run
|
||||||
|
time2, err := time.Parse(time.RFC3339, "2024-08-31T15:47:55+08:00")
|
||||||
|
require.NoError(t, err)
|
||||||
|
timeutil.MockSet(time2)
|
||||||
|
run2 := ActionRun{
|
||||||
|
ID: 2,
|
||||||
|
Index: 2,
|
||||||
|
RepoID: repoID,
|
||||||
|
Stopped: timeutil.TimeStampNow(),
|
||||||
|
WorkflowID: workflowID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// second completed run
|
||||||
|
time3, err := time.Parse(time.RFC3339, "2024-07-31T15:47:54+08:00")
|
||||||
|
require.NoError(t, err)
|
||||||
|
timeutil.MockSet(time3)
|
||||||
|
run3 := ActionRun{
|
||||||
|
ID: 3,
|
||||||
|
Index: 3,
|
||||||
|
RepoID: repoID,
|
||||||
|
Stopped: timeutil.TimeStampNow(),
|
||||||
|
WorkflowID: workflowID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// first completed run
|
||||||
|
time4, err := time.Parse(time.RFC3339, "2024-06-30T15:47:54+08:00")
|
||||||
|
require.NoError(t, err)
|
||||||
|
timeutil.MockSet(time4)
|
||||||
|
run4 := ActionRun{
|
||||||
|
ID: 4,
|
||||||
|
Index: 4,
|
||||||
|
RepoID: repoID,
|
||||||
|
Stopped: timeutil.TimeStampNow(),
|
||||||
|
WorkflowID: workflowID,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Insert(db.DefaultContext, &run1))
|
||||||
|
runBefore, err := GetRunBefore(db.DefaultContext, repoID, run1.Stopped)
|
||||||
|
// there is no run before run1
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, runBefore)
|
||||||
|
|
||||||
|
// now there is only run3 before run1
|
||||||
|
require.NoError(t, db.Insert(db.DefaultContext, &run3))
|
||||||
|
runBefore, err = GetRunBefore(db.DefaultContext, repoID, run1.Stopped)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, run3.ID, runBefore.ID)
|
||||||
|
|
||||||
|
// there still is only run3 before run1
|
||||||
|
require.NoError(t, db.Insert(db.DefaultContext, &run2))
|
||||||
|
runBefore, err = GetRunBefore(db.DefaultContext, repoID, run1.Stopped)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, run3.ID, runBefore.ID)
|
||||||
|
|
||||||
|
// run4 is further away from run1
|
||||||
|
require.NoError(t, db.Insert(db.DefaultContext, &run4))
|
||||||
|
runBefore, err = GetRunBefore(db.DefaultContext, repoID, run1.Stopped)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, run3.ID, runBefore.ID)
|
||||||
|
}
|
|
@ -311,7 +311,8 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||||
}
|
}
|
||||||
|
|
||||||
job.TaskID = task.ID
|
job.TaskID = task.ID
|
||||||
if n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}); err != nil {
|
// We never have to send a notification here because the job is started with a not done status.
|
||||||
|
if n, err := UpdateRunJobWithoutNotification(ctx, job, builder.Eq{"task_id": 0}); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
} else if n != 1 {
|
} else if n != 1 {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
|
|
|
@ -383,7 +383,7 @@ func Rerun(ctx *context_module.Context) {
|
||||||
run.PreviousDuration = run.Duration()
|
run.PreviousDuration = run.Duration()
|
||||||
run.Started = 0
|
run.Started = 0
|
||||||
run.Stopped = 0
|
run.Stopped = 0
|
||||||
if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil {
|
if err := actions_service.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil {
|
||||||
ctx.Error(http.StatusInternalServerError, err.Error())
|
ctx.Error(http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -436,7 +436,7 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou
|
||||||
job.Stopped = 0
|
job.Stopped = 0
|
||||||
|
|
||||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
_, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, "task_id", "status", "started", "stopped")
|
_, err := actions_service.UpdateRunJob(ctx, job, builder.Eq{"status": status}, "task_id", "status", "started", "stopped")
|
||||||
return err
|
return err
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -512,7 +512,7 @@ func Cancel(ctx *context_module.Context) {
|
||||||
if job.TaskID == 0 {
|
if job.TaskID == 0 {
|
||||||
job.Status = actions_model.StatusCancelled
|
job.Status = actions_model.StatusCancelled
|
||||||
job.Stopped = timeutil.TimeStampNow()
|
job.Stopped = timeutil.TimeStampNow()
|
||||||
n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
|
n, err := actions_service.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -549,13 +549,13 @@ func Approve(ctx *context_module.Context) {
|
||||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
run.NeedApproval = false
|
run.NeedApproval = false
|
||||||
run.ApprovedBy = doer.ID
|
run.ApprovedBy = doer.ID
|
||||||
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
|
if err := actions_service.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
if len(job.Needs) == 0 && job.Status.IsBlocked() {
|
if len(job.Needs) == 0 && job.Status.IsBlocked() {
|
||||||
job.Status = actions_model.StatusWaiting
|
job.Status = actions_model.StatusWaiting
|
||||||
_, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
|
_, err := actions_service.UpdateRunJob(ctx, job, nil, "status")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,7 +88,7 @@ func CancelAbandonedJobs(ctx context.Context) error {
|
||||||
job.Status = actions_model.StatusCancelled
|
job.Status = actions_model.StatusCancelled
|
||||||
job.Stopped = now
|
job.Stopped = now
|
||||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
_, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped")
|
_, err := UpdateRunJob(ctx, job, nil, "status", "stopped")
|
||||||
return err
|
return err
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Warn("cancel abandoned job %v: %v", job.ID, err)
|
log.Warn("cancel abandoned job %v: %v", job.ID, err)
|
||||||
|
|
|
@ -59,7 +59,7 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
if status, ok := updates[job.ID]; ok {
|
if status, ok := updates[job.ID]; ok {
|
||||||
job.Status = status
|
job.Status = status
|
||||||
if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil {
|
if n, err := UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if n != 1 {
|
} else if n != 1 {
|
||||||
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
|
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
|
||||||
|
|
|
@ -5,7 +5,9 @@ package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
issues_model "forgejo.org/models/issues"
|
issues_model "forgejo.org/models/issues"
|
||||||
packages_model "forgejo.org/models/packages"
|
packages_model "forgejo.org/models/packages"
|
||||||
perm_model "forgejo.org/models/perm"
|
perm_model "forgejo.org/models/perm"
|
||||||
|
@ -17,9 +19,12 @@ import (
|
||||||
"forgejo.org/modules/repository"
|
"forgejo.org/modules/repository"
|
||||||
"forgejo.org/modules/setting"
|
"forgejo.org/modules/setting"
|
||||||
api "forgejo.org/modules/structs"
|
api "forgejo.org/modules/structs"
|
||||||
|
"forgejo.org/modules/util"
|
||||||
webhook_module "forgejo.org/modules/webhook"
|
webhook_module "forgejo.org/modules/webhook"
|
||||||
"forgejo.org/services/convert"
|
"forgejo.org/services/convert"
|
||||||
notify_service "forgejo.org/services/notify"
|
notify_service "forgejo.org/services/notify"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
type actionsNotifier struct {
|
type actionsNotifier struct {
|
||||||
|
@ -775,3 +780,71 @@ func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_m
|
||||||
Sender: convert.ToUser(ctx, doer, nil),
|
Sender: convert.ToUser(ctx, doer, nil),
|
||||||
}).Notify(ctx)
|
}).Notify(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendActionRunNowDoneNotificationIfNeeded(ctx context.Context, oldRun, newRun *actions_model.ActionRun) error {
|
||||||
|
if !oldRun.Status.IsDone() && newRun.Status.IsDone() {
|
||||||
|
lastRun, err := actions_model.GetRunBefore(ctx, newRun.RepoID, newRun.Stopped)
|
||||||
|
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// when no last run was found lastRun is nil
|
||||||
|
if lastRun != nil {
|
||||||
|
if err = lastRun.LoadAttributes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = newRun.LoadAttributes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
notify_service.ActionRunNowDone(ctx, newRun, oldRun.Status, lastRun)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapper of UpdateRunWithoutNotification with a call to the ActionRunNowDone notification channel
|
||||||
|
func UpdateRun(ctx context.Context, run *actions_model.ActionRun, cols ...string) error {
|
||||||
|
// run.ID is the only thing that must be given
|
||||||
|
oldRun, err := actions_model.GetRunByID(ctx, run.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = actions_model.UpdateRunWithoutNotification(ctx, run, cols...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newRun, err := actions_model.GetRunByID(ctx, run.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sendActionRunNowDoneNotificationIfNeeded(ctx, oldRun, newRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapper of UpdateRunJobWithoutNotification with a call to the ActionRunNowDone notification channel
|
||||||
|
func UpdateRunJob(ctx context.Context, job *actions_model.ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
||||||
|
runID := job.RunID
|
||||||
|
if runID == 0 {
|
||||||
|
// job.ID is the only thing that must be given
|
||||||
|
// Don't overwrite job here, we'd loose the change we need to make.
|
||||||
|
oldJob, err := actions_model.GetRunJobByID(ctx, job.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
runID = oldJob.RunID
|
||||||
|
}
|
||||||
|
oldRun, err := actions_model.GetRunByID(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := actions_model.UpdateRunJobWithoutNotification(ctx, job, cond, cols...)
|
||||||
|
if err != nil {
|
||||||
|
return affected, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newRun, err := actions_model.GetRunByID(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return affected, err
|
||||||
|
}
|
||||||
|
return affected, sendActionRunNowDoneNotificationIfNeeded(ctx, oldRun, newRun)
|
||||||
|
}
|
||||||
|
|
|
@ -198,7 +198,7 @@ func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID strin
|
||||||
job.Stopped = timeutil.TimeStampNow()
|
job.Stopped = timeutil.TimeStampNow()
|
||||||
|
|
||||||
// Update the job's status and stopped time in the database.
|
// Update the job's status and stopped time in the database.
|
||||||
n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
|
n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -128,7 +128,7 @@ func StopTask(ctx context.Context, taskID int64, status actions_model.Status) er
|
||||||
now := timeutil.TimeStampNow()
|
now := timeutil.TimeStampNow()
|
||||||
task.Status = status
|
task.Status = status
|
||||||
task.Stopped = now
|
task.Stopped = now
|
||||||
if _, err := actions_model.UpdateRunJob(ctx, &actions_model.ActionRunJob{
|
if _, err := UpdateRunJob(ctx, &actions_model.ActionRunJob{
|
||||||
ID: task.JobID,
|
ID: task.JobID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
Stopped: task.Stopped,
|
Stopped: task.Stopped,
|
||||||
|
@ -198,7 +198,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||||
if err := actions_model.UpdateTask(ctx, task, "status", "stopped"); err != nil {
|
if err := actions_model.UpdateTask(ctx, task, "status", "stopped"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := actions_model.UpdateRunJob(ctx, &actions_model.ActionRunJob{
|
if _, err := UpdateRunJob(ctx, &actions_model.ActionRunJob{
|
||||||
ID: task.JobID,
|
ID: task.JobID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
Stopped: task.Stopped,
|
Stopped: task.Stopped,
|
||||||
|
|
|
@ -6,6 +6,7 @@ package notify
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
issues_model "forgejo.org/models/issues"
|
issues_model "forgejo.org/models/issues"
|
||||||
packages_model "forgejo.org/models/packages"
|
packages_model "forgejo.org/models/packages"
|
||||||
repo_model "forgejo.org/models/repo"
|
repo_model "forgejo.org/models/repo"
|
||||||
|
@ -76,4 +77,6 @@ type Notifier interface {
|
||||||
PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor)
|
PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor)
|
||||||
|
|
||||||
ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository)
|
ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository)
|
||||||
|
|
||||||
|
ActionRunNowDone(ctx context.Context, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ package notify
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
issues_model "forgejo.org/models/issues"
|
issues_model "forgejo.org/models/issues"
|
||||||
packages_model "forgejo.org/models/packages"
|
packages_model "forgejo.org/models/packages"
|
||||||
repo_model "forgejo.org/models/repo"
|
repo_model "forgejo.org/models/repo"
|
||||||
|
@ -374,3 +375,13 @@ func ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) {
|
||||||
notifier.ChangeDefaultBranch(ctx, repo)
|
notifier.ChangeDefaultBranch(ctx, repo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionRunNowDone notifies that the old status priorStatus with (priorStatus.isDone() == false) of an ActionRun changed to run.Status with (run.Status.isDone() == true)
|
||||||
|
// lastRun might be nil (e.g. when the run is the first for this workflow). It is the last run of the same workflow for the same repo.
|
||||||
|
// It can be used to figure out if a successful run follows a failed one.
|
||||||
|
// Both run and lastRun need their attributes loaded.
|
||||||
|
func ActionRunNowDone(ctx context.Context, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun) {
|
||||||
|
for _, notifier := range notifiers {
|
||||||
|
notifier.ActionRunNowDone(ctx, run, priorStatus, lastRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ package notify
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
issues_model "forgejo.org/models/issues"
|
issues_model "forgejo.org/models/issues"
|
||||||
packages_model "forgejo.org/models/packages"
|
packages_model "forgejo.org/models/packages"
|
||||||
repo_model "forgejo.org/models/repo"
|
repo_model "forgejo.org/models/repo"
|
||||||
|
@ -211,3 +212,7 @@ func (*NullNotifier) PackageDelete(ctx context.Context, doer *user_model.User, p
|
||||||
// ChangeDefaultBranch places a place holder function
|
// ChangeDefaultBranch places a place holder function
|
||||||
func (*NullNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) {
|
func (*NullNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionRunNowDone places a place holder function
|
||||||
|
func (*NullNotifier) ActionRunNowDone(ctx context.Context, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun) {
|
||||||
|
}
|
||||||
|
|
176
tests/integration/actions_run_now_done_notification_test.go
Normal file
176
tests/integration/actions_run_now_done_notification_test.go
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
"forgejo.org/models/db"
|
||||||
|
unit_model "forgejo.org/models/unit"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
"forgejo.org/modules/gitrepo"
|
||||||
|
"forgejo.org/modules/setting"
|
||||||
|
actions_service "forgejo.org/services/actions"
|
||||||
|
notify_service "forgejo.org/services/notify"
|
||||||
|
files_service "forgejo.org/services/repository/files"
|
||||||
|
"forgejo.org/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockNotifier struct {
|
||||||
|
notify_service.NullNotifier
|
||||||
|
testIdx int
|
||||||
|
t *testing.T
|
||||||
|
runID int64
|
||||||
|
lastRunID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ notify_service.Notifier = &mockNotifier{}
|
||||||
|
|
||||||
|
func (m *mockNotifier) ActionRunNowDone(ctx context.Context, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun) {
|
||||||
|
switch m.testIdx {
|
||||||
|
case 0:
|
||||||
|
// we accept the first id as okay and just check that the following ones make sense
|
||||||
|
m.runID = run.ID
|
||||||
|
assert.Equal(m.t, actions_model.StatusSuccess, run.Status)
|
||||||
|
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
|
||||||
|
assert.Nil(m.t, lastRun)
|
||||||
|
case 1:
|
||||||
|
assert.Equal(m.t, m.runID, run.ID)
|
||||||
|
assert.Equal(m.t, actions_model.StatusFailure, run.Status)
|
||||||
|
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
|
||||||
|
assert.Equal(m.t, m.lastRunID, lastRun.ID)
|
||||||
|
assert.Equal(m.t, actions_model.StatusSuccess, lastRun.Status)
|
||||||
|
case 2:
|
||||||
|
assert.Equal(m.t, m.runID, run.ID)
|
||||||
|
assert.Equal(m.t, actions_model.StatusCancelled, run.Status)
|
||||||
|
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
|
||||||
|
assert.Equal(m.t, m.lastRunID, lastRun.ID)
|
||||||
|
assert.Equal(m.t, actions_model.StatusFailure, lastRun.Status)
|
||||||
|
case 3:
|
||||||
|
assert.Equal(m.t, m.runID, run.ID)
|
||||||
|
assert.Equal(m.t, actions_model.StatusSuccess, run.Status)
|
||||||
|
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
|
||||||
|
assert.Equal(m.t, m.lastRunID, lastRun.ID)
|
||||||
|
assert.Equal(m.t, actions_model.StatusCancelled, lastRun.Status)
|
||||||
|
case 4:
|
||||||
|
assert.Equal(m.t, m.runID, run.ID)
|
||||||
|
assert.Equal(m.t, actions_model.StatusSuccess, run.Status)
|
||||||
|
assert.Equal(m.t, actions_model.StatusRunning, priorStatus)
|
||||||
|
assert.Equal(m.t, m.lastRunID, lastRun.ID)
|
||||||
|
assert.Equal(m.t, actions_model.StatusSuccess, lastRun.Status)
|
||||||
|
default:
|
||||||
|
assert.Fail(m.t, "too many notifications")
|
||||||
|
}
|
||||||
|
m.lastRunID = m.runID
|
||||||
|
m.runID++
|
||||||
|
m.testIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure all tests have been run
|
||||||
|
func (m *mockNotifier) complete() {
|
||||||
|
assert.Equal(m.t, 5, m.testIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionNowDoneNotification(t *testing.T) {
|
||||||
|
if !setting.Database.Type.IsSQLite3() {
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
|
||||||
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||||
|
notifier := mockNotifier{t: t, testIdx: 0, lastRunID: -1, runID: -1}
|
||||||
|
notify_service.RegisterNotifier(¬ifier)
|
||||||
|
|
||||||
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
|
||||||
|
// create the repo
|
||||||
|
repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
|
||||||
|
[]unit_model.Type{unit_model.TypeActions}, nil,
|
||||||
|
[]*files_service.ChangeRepoFile{
|
||||||
|
{
|
||||||
|
Operation: "create",
|
||||||
|
TreePath: ".forgejo/workflows/dispatch.yml",
|
||||||
|
ContentReader: strings.NewReader(
|
||||||
|
"name: test\n" +
|
||||||
|
"on: [workflow_dispatch]\n" +
|
||||||
|
"jobs:\n" +
|
||||||
|
" test:\n" +
|
||||||
|
" runs-on: ubuntu-latest\n" +
|
||||||
|
" steps:\n" +
|
||||||
|
" - run: echo helloworld\n",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer f()
|
||||||
|
|
||||||
|
gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yml")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "refs/heads/main", workflow.Ref)
|
||||||
|
assert.Equal(t, sha, workflow.Commit.ID.String())
|
||||||
|
|
||||||
|
inputGetter := func(key string) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
runner := newMockRunner()
|
||||||
|
runner.registerAsRepoRunner(t, user2.Name, repo.Name, "mock-runner", []string{"ubuntu-latest"})
|
||||||
|
|
||||||
|
// 0: first successful run
|
||||||
|
_, _, err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
task := runner.fetchTask(t)
|
||||||
|
runner.succeedAtTask(t, task)
|
||||||
|
|
||||||
|
// we can't differentiate different runs without a delay
|
||||||
|
time.Sleep(time.Millisecond * 2000)
|
||||||
|
|
||||||
|
// 1: failed run
|
||||||
|
_, _, err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
task = runner.fetchTask(t)
|
||||||
|
runner.failAtTask(t, task)
|
||||||
|
|
||||||
|
// we can't differentiate different runs without a delay
|
||||||
|
time.Sleep(time.Millisecond * 2000)
|
||||||
|
|
||||||
|
// 2: canceled run
|
||||||
|
_, _, err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
task = runner.fetchTask(t)
|
||||||
|
require.NoError(t, actions_service.StopTask(db.DefaultContext, task.Id, actions_model.StatusCancelled))
|
||||||
|
|
||||||
|
// we can't differentiate different runs without a delay
|
||||||
|
time.Sleep(time.Millisecond * 2000)
|
||||||
|
|
||||||
|
// 3: successful run after failure
|
||||||
|
_, _, err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
task = runner.fetchTask(t)
|
||||||
|
runner.succeedAtTask(t, task)
|
||||||
|
|
||||||
|
// we can't differentiate different runs without a delay
|
||||||
|
time.Sleep(time.Millisecond * 2000)
|
||||||
|
|
||||||
|
// 4: successful run after success
|
||||||
|
_, _, err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
task = runner.fetchTask(t)
|
||||||
|
runner.succeedAtTask(t, task)
|
||||||
|
|
||||||
|
notifier.complete()
|
||||||
|
})
|
||||||
|
}
|
|
@ -160,3 +160,30 @@ func (r *mockRunner) execTask(t *testing.T, task *runnerv1.Task, outcome *mockTa
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, outcome.result, resp.Msg.State.Result)
|
assert.Equal(t, outcome.result, resp.Msg.State.Result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simply pretend we're running the task and succeed at that.
|
||||||
|
// We're that great!
|
||||||
|
func (r *mockRunner) succeedAtTask(t *testing.T, task *runnerv1.Task) {
|
||||||
|
resp, err := r.client.runnerServiceClient.UpdateTask(t.Context(), connect.NewRequest(&runnerv1.UpdateTaskRequest{
|
||||||
|
State: &runnerv1.TaskState{
|
||||||
|
Id: task.Id,
|
||||||
|
Result: runnerv1.Result_RESULT_SUCCESS,
|
||||||
|
StoppedAt: timestamppb.Now(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, resp.Msg.State.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pretend we're running the task, do nothing and fail at that.
|
||||||
|
func (r *mockRunner) failAtTask(t *testing.T, task *runnerv1.Task) {
|
||||||
|
resp, err := r.client.runnerServiceClient.UpdateTask(t.Context(), connect.NewRequest(&runnerv1.UpdateTaskRequest{
|
||||||
|
State: &runnerv1.TaskState{
|
||||||
|
Id: task.Id,
|
||||||
|
Result: runnerv1.Result_RESULT_FAILURE,
|
||||||
|
StoppedAt: timestamppb.Now(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, runnerv1.Result_RESULT_FAILURE, resp.Msg.State.Result)
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue