mirror of https://github.com/go-gitea/gitea
chore(actions): support cron schedule task (#26655)
Replace #22751 1. only support the default branch in the repository setting. 2. autoload schedule data from the schedule table after starting the service. 3. support specific syntax like `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly` ## How to use See the [GitHub Actions document](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule) for getting more detailed information. ```yaml on: schedule: - cron: '30 5 * * 1,3' - cron: '30 5 * * 2,4' jobs: test_schedule: runs-on: ubuntu-latest steps: - name: Not on Monday or Wednesday if: github.event.schedule != '30 5 * * 1,3' run: echo "This step will be skipped on Monday and Wednesday" - name: Every time run: echo "This step will always run" ``` Signed-off-by: Bo-Yi.Wu <appleboy.tw@gmail.com> --------- Co-authored-by: Jason Song <i@wolfogre.com> Co-authored-by: techknowlogick <techknowlogick@gitea.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>pull/26658/head^2
parent
b62c8e7765
commit
0d55f64e6c
@ -0,0 +1,120 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
webhook_module "code.gitea.io/gitea/modules/webhook" |
||||
|
||||
"github.com/robfig/cron/v3" |
||||
) |
||||
|
||||
// ActionSchedule represents a schedule of a workflow file
|
||||
type ActionSchedule struct { |
||||
ID int64 |
||||
Title string |
||||
Specs []string |
||||
RepoID int64 `xorm:"index"` |
||||
Repo *repo_model.Repository `xorm:"-"` |
||||
OwnerID int64 `xorm:"index"` |
||||
WorkflowID string |
||||
TriggerUserID int64 |
||||
TriggerUser *user_model.User `xorm:"-"` |
||||
Ref string |
||||
CommitSHA string |
||||
Event webhook_module.HookEventType |
||||
EventPayload string `xorm:"LONGTEXT"` |
||||
Content []byte |
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(ActionSchedule)) |
||||
} |
||||
|
||||
// GetSchedulesMapByIDs returns the schedules by given id slice.
|
||||
func GetSchedulesMapByIDs(ids []int64) (map[int64]*ActionSchedule, error) { |
||||
schedules := make(map[int64]*ActionSchedule, len(ids)) |
||||
return schedules, db.GetEngine(db.DefaultContext).In("id", ids).Find(&schedules) |
||||
} |
||||
|
||||
// GetReposMapByIDs returns the repos by given id slice.
|
||||
func GetReposMapByIDs(ids []int64) (map[int64]*repo_model.Repository, error) { |
||||
repos := make(map[int64]*repo_model.Repository, len(ids)) |
||||
return repos, db.GetEngine(db.DefaultContext).In("id", ids).Find(&repos) |
||||
} |
||||
|
||||
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) |
||||
|
||||
// CreateScheduleTask creates new schedule task.
|
||||
func CreateScheduleTask(ctx context.Context, rows []*ActionSchedule) error { |
||||
// Return early if there are no rows to insert
|
||||
if len(rows) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
// Begin transaction
|
||||
ctx, committer, err := db.TxContext(ctx) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer committer.Close() |
||||
|
||||
// Loop through each schedule row
|
||||
for _, row := range rows { |
||||
// Create new schedule row
|
||||
if err = db.Insert(ctx, row); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Loop through each schedule spec and create a new spec row
|
||||
now := time.Now() |
||||
|
||||
for _, spec := range row.Specs { |
||||
// Parse the spec and check for errors
|
||||
schedule, err := cronParser.Parse(spec) |
||||
if err != nil { |
||||
continue // skip to the next spec if there's an error
|
||||
} |
||||
|
||||
// Insert the new schedule spec row
|
||||
if err = db.Insert(ctx, &ActionScheduleSpec{ |
||||
RepoID: row.RepoID, |
||||
ScheduleID: row.ID, |
||||
Spec: spec, |
||||
Next: timeutil.TimeStamp(schedule.Next(now).Unix()), |
||||
}); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Commit transaction
|
||||
return committer.Commit() |
||||
} |
||||
|
||||
func DeleteScheduleTaskByRepo(ctx context.Context, id int64) error { |
||||
ctx, committer, err := db.TxContext(ctx) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer committer.Close() |
||||
|
||||
if _, err := db.GetEngine(ctx).Delete(&ActionSchedule{RepoID: id}); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if _, err := db.GetEngine(ctx).Delete(&ActionScheduleSpec{RepoID: id}); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return committer.Commit() |
||||
} |
@ -0,0 +1,94 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/container" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
type ScheduleList []*ActionSchedule |
||||
|
||||
// GetUserIDs returns a slice of user's id
|
||||
func (schedules ScheduleList) GetUserIDs() []int64 { |
||||
ids := make(container.Set[int64], len(schedules)) |
||||
for _, schedule := range schedules { |
||||
ids.Add(schedule.TriggerUserID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (schedules ScheduleList) GetRepoIDs() []int64 { |
||||
ids := make(container.Set[int64], len(schedules)) |
||||
for _, schedule := range schedules { |
||||
ids.Add(schedule.RepoID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (schedules ScheduleList) LoadTriggerUser(ctx context.Context) error { |
||||
userIDs := schedules.GetUserIDs() |
||||
users := make(map[int64]*user_model.User, len(userIDs)) |
||||
if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil { |
||||
return err |
||||
} |
||||
for _, schedule := range schedules { |
||||
if schedule.TriggerUserID == user_model.ActionsUserID { |
||||
schedule.TriggerUser = user_model.NewActionsUser() |
||||
} else { |
||||
schedule.TriggerUser = users[schedule.TriggerUserID] |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (schedules ScheduleList) LoadRepos() error { |
||||
repoIDs := schedules.GetRepoIDs() |
||||
repos, err := repo_model.GetRepositoriesMapByIDs(repoIDs) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
for _, schedule := range schedules { |
||||
schedule.Repo = repos[schedule.RepoID] |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
type FindScheduleOptions struct { |
||||
db.ListOptions |
||||
RepoID int64 |
||||
OwnerID int64 |
||||
} |
||||
|
||||
func (opts FindScheduleOptions) toConds() builder.Cond { |
||||
cond := builder.NewCond() |
||||
if opts.RepoID > 0 { |
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) |
||||
} |
||||
if opts.OwnerID > 0 { |
||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) |
||||
} |
||||
|
||||
return cond |
||||
} |
||||
|
||||
func FindSchedules(ctx context.Context, opts FindScheduleOptions) (ScheduleList, int64, error) { |
||||
e := db.GetEngine(ctx).Where(opts.toConds()) |
||||
if !opts.ListAll && opts.PageSize > 0 && opts.Page >= 1 { |
||||
e.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) |
||||
} |
||||
var schedules ScheduleList |
||||
total, err := e.Desc("id").FindAndCount(&schedules) |
||||
return schedules, total, err |
||||
} |
||||
|
||||
func CountSchedules(ctx context.Context, opts FindScheduleOptions) (int64, error) { |
||||
return db.GetEngine(ctx).Where(opts.toConds()).Count(new(ActionSchedule)) |
||||
} |
@ -0,0 +1,50 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
|
||||
"github.com/robfig/cron/v3" |
||||
) |
||||
|
||||
// ActionScheduleSpec represents a schedule spec of a workflow file
|
||||
type ActionScheduleSpec struct { |
||||
ID int64 |
||||
RepoID int64 `xorm:"index"` |
||||
Repo *repo_model.Repository `xorm:"-"` |
||||
ScheduleID int64 `xorm:"index"` |
||||
Schedule *ActionSchedule `xorm:"-"` |
||||
|
||||
// Next time the job will run, or the zero time if Cron has not been
|
||||
// started or this entry's schedule is unsatisfiable
|
||||
Next timeutil.TimeStamp `xorm:"index"` |
||||
// Prev is the last time this job was run, or the zero time if never.
|
||||
Prev timeutil.TimeStamp |
||||
Spec string |
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
func (s *ActionScheduleSpec) Parse() (cron.Schedule, error) { |
||||
return cronParser.Parse(s.Spec) |
||||
} |
||||
|
||||
func init() { |
||||
db.RegisterModel(new(ActionScheduleSpec)) |
||||
} |
||||
|
||||
func UpdateScheduleSpec(ctx context.Context, spec *ActionScheduleSpec, cols ...string) error { |
||||
sess := db.GetEngine(ctx).ID(spec.ID) |
||||
if len(cols) > 0 { |
||||
sess.Cols(cols...) |
||||
} |
||||
_, err := sess.Update(spec) |
||||
return err |
||||
} |
@ -0,0 +1,106 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
"code.gitea.io/gitea/modules/container" |
||||
|
||||
"xorm.io/builder" |
||||
) |
||||
|
||||
type SpecList []*ActionScheduleSpec |
||||
|
||||
func (specs SpecList) GetScheduleIDs() []int64 { |
||||
ids := make(container.Set[int64], len(specs)) |
||||
for _, spec := range specs { |
||||
ids.Add(spec.ScheduleID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (specs SpecList) LoadSchedules() error { |
||||
scheduleIDs := specs.GetScheduleIDs() |
||||
schedules, err := GetSchedulesMapByIDs(scheduleIDs) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
for _, spec := range specs { |
||||
spec.Schedule = schedules[spec.ScheduleID] |
||||
} |
||||
|
||||
repoIDs := specs.GetRepoIDs() |
||||
repos, err := GetReposMapByIDs(repoIDs) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
for _, spec := range specs { |
||||
spec.Repo = repos[spec.RepoID] |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (specs SpecList) GetRepoIDs() []int64 { |
||||
ids := make(container.Set[int64], len(specs)) |
||||
for _, spec := range specs { |
||||
ids.Add(spec.RepoID) |
||||
} |
||||
return ids.Values() |
||||
} |
||||
|
||||
func (specs SpecList) LoadRepos() error { |
||||
repoIDs := specs.GetRepoIDs() |
||||
repos, err := repo_model.GetRepositoriesMapByIDs(repoIDs) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
for _, spec := range specs { |
||||
spec.Repo = repos[spec.RepoID] |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
type FindSpecOptions struct { |
||||
db.ListOptions |
||||
RepoID int64 |
||||
Next int64 |
||||
} |
||||
|
||||
func (opts FindSpecOptions) toConds() builder.Cond { |
||||
cond := builder.NewCond() |
||||
if opts.RepoID > 0 { |
||||
cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) |
||||
} |
||||
|
||||
if opts.Next > 0 { |
||||
cond = cond.And(builder.Lte{"next": opts.Next}) |
||||
} |
||||
|
||||
return cond |
||||
} |
||||
|
||||
func FindSpecs(ctx context.Context, opts FindSpecOptions) (SpecList, int64, error) { |
||||
e := db.GetEngine(ctx).Where(opts.toConds()) |
||||
if opts.PageSize > 0 && opts.Page >= 1 { |
||||
e.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) |
||||
} |
||||
var specs SpecList |
||||
total, err := e.Desc("id").FindAndCount(&specs) |
||||
if err != nil { |
||||
return nil, 0, err |
||||
} |
||||
|
||||
if err := specs.LoadSchedules(); err != nil { |
||||
return nil, 0, err |
||||
} |
||||
return specs, total, nil |
||||
} |
||||
|
||||
func CountSpecs(ctx context.Context, opts FindSpecOptions) (int64, error) { |
||||
return db.GetEngine(ctx).Where(opts.toConds()).Count(new(ActionScheduleSpec)) |
||||
} |
@ -0,0 +1,45 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_21 //nolint
|
||||
import ( |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
|
||||
"xorm.io/xorm" |
||||
) |
||||
|
||||
func AddActionScheduleTable(x *xorm.Engine) error { |
||||
type ActionSchedule struct { |
||||
ID int64 |
||||
Title string |
||||
Specs []string |
||||
RepoID int64 `xorm:"index"` |
||||
OwnerID int64 `xorm:"index"` |
||||
WorkflowID string |
||||
TriggerUserID int64 |
||||
Ref string |
||||
CommitSHA string |
||||
Event string |
||||
EventPayload string `xorm:"LONGTEXT"` |
||||
Content []byte |
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
type ActionScheduleSpec struct { |
||||
ID int64 |
||||
RepoID int64 `xorm:"index"` |
||||
ScheduleID int64 `xorm:"index"` |
||||
Spec string |
||||
Next timeutil.TimeStamp `xorm:"index"` |
||||
Prev timeutil.TimeStamp |
||||
|
||||
Created timeutil.TimeStamp `xorm:"created"` |
||||
Updated timeutil.TimeStamp `xorm:"updated"` |
||||
} |
||||
|
||||
return x.Sync( |
||||
new(ActionSchedule), |
||||
new(ActionScheduleSpec), |
||||
) |
||||
} |
@ -0,0 +1,135 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"time" |
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions" |
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/timeutil" |
||||
webhook_module "code.gitea.io/gitea/modules/webhook" |
||||
|
||||
"github.com/nektos/act/pkg/jobparser" |
||||
) |
||||
|
||||
// StartScheduleTasks start the task
|
||||
func StartScheduleTasks(ctx context.Context) error { |
||||
return startTasks(ctx) |
||||
} |
||||
|
||||
// startTasks retrieves specifications in pages, creates a schedule task for each specification,
|
||||
// and updates the specification's next run time and previous run time.
|
||||
// The function returns an error if there's an issue with finding or updating the specifications.
|
||||
func startTasks(ctx context.Context) error { |
||||
// Set the page size
|
||||
pageSize := 50 |
||||
|
||||
// Retrieve specs in pages until all specs have been retrieved
|
||||
now := time.Now() |
||||
for page := 1; ; page++ { |
||||
// Retrieve the specs for the current page
|
||||
specs, _, err := actions_model.FindSpecs(ctx, actions_model.FindSpecOptions{ |
||||
ListOptions: db.ListOptions{ |
||||
Page: page, |
||||
PageSize: pageSize, |
||||
}, |
||||
Next: now.Unix(), |
||||
}) |
||||
if err != nil { |
||||
return fmt.Errorf("find specs: %w", err) |
||||
} |
||||
|
||||
// Loop through each spec and create a schedule task for it
|
||||
for _, row := range specs { |
||||
// cancel running jobs if the event is push
|
||||
if row.Schedule.Event == webhook_module.HookEventPush { |
||||
// cancel running jobs of the same workflow
|
||||
if err := actions_model.CancelRunningJobs( |
||||
ctx, |
||||
row.RepoID, |
||||
row.Schedule.Ref, |
||||
row.Schedule.WorkflowID, |
||||
); err != nil { |
||||
log.Error("CancelRunningJobs: %v", err) |
||||
} |
||||
} |
||||
|
||||
if err := CreateScheduleTask(ctx, row.Schedule); err != nil { |
||||
log.Error("CreateScheduleTask: %v", err) |
||||
return err |
||||
} |
||||
|
||||
// Parse the spec
|
||||
schedule, err := row.Parse() |
||||
if err != nil { |
||||
log.Error("Parse: %v", err) |
||||
return err |
||||
} |
||||
|
||||
// Update the spec's next run time and previous run time
|
||||
row.Prev = row.Next |
||||
row.Next = timeutil.TimeStamp(schedule.Next(now.Add(1 * time.Minute)).Unix()) |
||||
if err := actions_model.UpdateScheduleSpec(ctx, row, "prev", "next"); err != nil { |
||||
log.Error("UpdateScheduleSpec: %v", err) |
||||
return err |
||||
} |
||||
} |
||||
|
||||
// Stop if all specs have been retrieved
|
||||
if len(specs) < pageSize { |
||||
break |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// CreateScheduleTask creates a scheduled task from a cron action schedule.
|
||||
// It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job.
|
||||
func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) error { |
||||
// Create a new action run based on the schedule
|
||||
run := &actions_model.ActionRun{ |
||||
Title: cron.Title, |
||||
RepoID: cron.RepoID, |
||||
OwnerID: cron.OwnerID, |
||||
WorkflowID: cron.WorkflowID, |
||||
TriggerUserID: cron.TriggerUserID, |
||||
Ref: cron.Ref, |
||||
CommitSHA: cron.CommitSHA, |
||||
Event: cron.Event, |
||||
EventPayload: cron.EventPayload, |
||||
Status: actions_model.StatusWaiting, |
||||
} |
||||
|
||||
// Parse the workflow specification from the cron schedule
|
||||
workflows, err := jobparser.Parse(cron.Content) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Insert the action run and its associated jobs into the database
|
||||
if err := actions_model.InsertRun(ctx, run, workflows); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Retrieve the jobs for the newly created action run
|
||||
jobs, _, err := actions_model.FindRunJobs(ctx, actions_model.FindRunJobOptions{RunID: run.ID}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Create commit statuses for each job
|
||||
for _, job := range jobs { |
||||
if err := createCommitStatus(ctx, job); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
// Return nil if no errors occurred
|
||||
return nil |
||||
} |
Loading…
Reference in new issue