feat: Allow automatic project actions on events (#30205)

This PR introduces project workflows, allowing users to configure
event-based automations directly from the UI.
Users can define filters and actions for each workflow event, and
duplicate existing events when needed.

### Concepts

Every workflow is composed of `event`, `filters`, and `actions`.

- **Event** - An Event defines when a workflow should be triggered. It
represents specific activities such as an issue being added to the
project, a pull request being merged, or a project column being changed.
- **Filter** - A Filter refines when an event should take effect. It
allows users to specify conditions like issue type, labels, or project
columns to ensure actions only run in targeted scenarios.
- **Action** - An Action defines what happens when the event and filters
match. It performs operations such as adding labels, moving issues
between columns, or changing the issue or pull request state.

### Available Events

Currently, the following eight events are supported:

- On issues/pull requests closed
- On issues/pull requests reopened
- On issues/pull requests was added to the project, including when
created in it
- On issues/pull requests was removed from the project
- On issues/pull requests' column was changed
- On code changes requested
- On code review approved
- On pull request merged

Each event type supports its own set of filters and actions.

### Available Filters

There are four types of filters that can be configured for an event:

- **Apply to** - Specify the target type: `issues only`, `pull requests
only`, or `issues and pull requests`.
- **Labels** - Trigger the event only if the issue or pull request
contains one or more specified labels.
- **Move from column** - Filter events based on the original project
column.
- **Move to column** - Filter events based on the destination project
column.

### Available Actions

There are four types of actions that can be executed when an event is
triggered:

- **Add labels** - Automatically add one or more labels to the issue or
pull request.
- **Remove labels** - Remove one or more specified labels.
- **Move to column** - Move the issue or pull request to a designated
project column.
- **Issue state** - Close or reopen the issue. Pull requests can only be
closed.

### Enable/Disable an event workflow

Each event workflow can be individually enabled or disabled. When
disabled, the workflow will be ignored even if the event occurs. This
feature is useful when you want to temporarily stop certain automated
actions without deleting the workflow configuration. Workflows of closed
projects don't run either.

### Clone an event workflow

When you need multiple workflows for the same event, you can clone an
existing event to create a new one with different filters and actions.
This allows you to customize how the same event behaves under different
conditions, making workflows more flexible and powerful.

### Permissions

Permissions are checked when workflows are managed, not when they run:

- Viewing a project's workflows needs read access to the project
- Managing them needs write access to the project plus write access to
both issues and pull requests of a repository project's repository,
being an owner of an organization project's organization or a site
administrator, or being the user of a user project. Tokens limited to
public repositories can't manage owner-level projects' workflows
- A workflow runs when its event happens to an item of an open project
that can hold the item's repository, unless that repository is archived,
no matter who caused the event, and applies its actions without further
permission checks. Pull requests are never reopened
- Changes show as made by "Project workflow" on behalf of the user who
triggered the event and don't trigger other workflows
- Only users who can read an item can put it on a board or move its card

The management rule is also documented on `CanManageWorkflows` in
`services/projects/workflow.go`.

Fixes:  #14359
Fixes: #26704
Fixes: #27990
Replaces:  #28745
Documentation PR: https://gitea.com/gitea/docs/pulls/308
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Lunny Xiao authored and GitHub committed 2026-10-23 01:53:45 +00:00
1 parent a17f326c17
commit c45af635e4
71 files changed
+5667 -183

No files matched your search

+4
View File
@@ -185,6 +185,10 @@ var (
ProjectUpdate = define("project:update", "Updated project {project} in {scope}.")
ProjectDelete = define("project:delete", "Deleted project {project} from {scope}.")
ProjectWorkflowCreate = define("project:workflow:create", "Created workflow {workflow_id} for {event} in project {project} of {scope}.")
ProjectWorkflowUpdate = define("project:workflow:update", "Updated workflow {workflow_id} for {event} in project {project} of {scope}.")
ProjectWorkflowDelete = define("project:workflow:delete", "Deleted workflow {workflow_id} for {event} from project {project} of {scope}.")
WikiPageCreate = define("wiki:page:create", "Created wiki page {page} in repository {scope}.")
WikiPageUpdate = define("wiki:page:update", "Updated wiki page {page} in repository {scope}.")
WikiPageDelete = define("wiki:page:delete", "Deleted wiki page {page} from repository {scope}.")
+46 -18
View File
@@ -240,7 +240,10 @@ func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
type SpecialDoerNameType string
const SpecialDoerNameCodeOwners SpecialDoerNameType = "CODEOWNERS"
const (
SpecialDoerNameCodeOwners SpecialDoerNameType = "CODEOWNERS"
SpecialDoerNameProjectWorkflow SpecialDoerNameType = "ProjectWorkflow"
)
// CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database
type CommentMetaData struct {
@@ -248,9 +251,24 @@ type CommentMetaData struct {
ProjectColumnTitle string `json:"project_column_title,omitempty"`
ProjectTitle string `json:"project_title,omitempty"`
ProjectWorkflowEvent project_model.WorkflowEvent `json:"project_workflow_event,omitempty"`
SpecialDoerName SpecialDoerNameType `json:"special_doer_name,omitempty"` // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests
}
type projectWorkflowContextKey struct{}
func WithProjectWorkflow(ctx context.Context, event project_model.WorkflowEvent) context.Context {
return context.WithValue(ctx, projectWorkflowContextKey{}, &CommentMetaData{
SpecialDoerName: SpecialDoerNameProjectWorkflow,
ProjectWorkflowEvent: event,
})
}
func IsProjectWorkflowContext(ctx context.Context) bool {
return ctx.Value(projectWorkflowContextKey{}) != nil
}
// Comment represents a comment in commit and issue page.
type Comment struct {
ID int64 `xorm:"pk autoincr"`
@@ -789,10 +807,15 @@ func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML {
if c.CommentMetaData == nil {
return ""
}
if c.CommentMetaData.SpecialDoerName == SpecialDoerNameCodeOwners {
switch c.CommentMetaData.SpecialDoerName {
case SpecialDoerNameCodeOwners:
return locale.Tr("repo.issues.review.codeowners_rules")
case SpecialDoerNameProjectWorkflow:
return locale.Tr("repo.issues.project_workflow", htmlutil.HTMLFormat(`<span class="tw-font-semibold">%s</span>`, locale.Tr(c.CommentMetaData.ProjectWorkflowEvent.LangKey())))
default:
// don't trust the content of SpecialDoerName, it might not be fully controlled by us
return htmlutil.HTMLFormat("%s", c.CommentMetaData.SpecialDoerName)
}
return htmlutil.HTMLFormat("%s", c.CommentMetaData.SpecialDoerName)
}
func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML {
@@ -821,6 +844,25 @@ func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdSt
return locale.Tr("repo.issues.review.add_review_request", assigneePrompt, createdStr)
}
func buildCreateCommentMetaData(ctx context.Context, opts *CreateCommentOptions) *CommentMetaData {
var meta CommentMetaData
if workflowMeta, ok := ctx.Value(projectWorkflowContextKey{}).(*CommentMetaData); ok {
meta = *workflowMeta
}
if opts.ProjectColumnTitle != "" {
meta.ProjectColumnID = opts.ProjectColumnID
meta.ProjectColumnTitle = opts.ProjectColumnTitle
meta.ProjectTitle = opts.ProjectTitle
}
if opts.SpecialDoerName != "" {
meta.SpecialDoerName = opts.SpecialDoerName
}
if meta == (CommentMetaData{}) {
return nil
}
return &meta
}
// CreateComment creates comment with context
func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) {
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
@@ -829,20 +871,6 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
LabelID = opts.Label.ID
}
var commentMetaData *CommentMetaData
if opts.ProjectColumnTitle != "" {
commentMetaData = &CommentMetaData{
ProjectColumnID: opts.ProjectColumnID,
ProjectColumnTitle: opts.ProjectColumnTitle,
ProjectTitle: opts.ProjectTitle,
}
}
if opts.SpecialDoerName != "" {
commentMetaData = &CommentMetaData{
SpecialDoerName: opts.SpecialDoerName,
}
}
comment := &Comment{
Type: opts.Type,
PosterID: opts.Doer.ID,
@@ -875,7 +903,7 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
RefIsPull: opts.RefIsPull,
IsForcePush: opts.IsForcePush,
Invalidated: opts.Invalidated,
CommentMetaData: commentMetaData,
CommentMetaData: buildCreateCommentMetaData(ctx, opts),
}
if err = db.Insert(ctx, comment); err != nil {
return nil, err
+21
View File
@@ -9,6 +9,7 @@ import (
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
project_model "gitea.dev/models/project"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
@@ -44,6 +45,26 @@ func TestCreateComment(t *testing.T) {
updatedIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID})
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
assert.Nil(t, comment.CommentMetaData)
ctx := issues_model.WithProjectWorkflow(t.Context(), project_model.WorkflowEventItemClosed)
comment, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeProjectColumn,
Doer: doer,
Repo: repo,
Issue: issue,
ProjectColumnID: 5,
ProjectColumnTitle: "Done",
ProjectTitle: "Board",
})
require.NoError(t, err)
assert.Equal(t, &issues_model.CommentMetaData{
ProjectColumnID: 5,
ProjectColumnTitle: "Done",
ProjectTitle: "Board",
ProjectWorkflowEvent: project_model.WorkflowEventItemClosed,
SpecialDoerName: issues_model.SpecialDoerNameProjectWorkflow,
}, unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}).CommentMetaData)
}
func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) {
+10 -9
View File
@@ -67,8 +67,8 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i
// and removes projects that are currently assigned but not in newProjectIDs.
// If newProjectIDs is empty, all projects are removed from the issue.
// When adding an issue to a project, it is placed in the project's default column.
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectIDs []int64) (added, removed []int64, err error) {
err = db.WithTx(ctx, func(ctx context.Context) error {
if err := issue.LoadRepo(ctx); err != nil {
return err
}
@@ -78,15 +78,15 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
return err
}
projectsToAdd, projectsToRemove := util.DiffSlice(oldProjectIDs, newProjectIDs)
added, removed = util.DiffSlice(oldProjectIDs, newProjectIDs)
issue.isProjectsLoaded = false
issue.Projects = nil
if len(projectsToRemove) > 0 {
if _, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).In("project_id", projectsToRemove).Delete(&project_model.ProjectIssue{}); err != nil {
if len(removed) > 0 {
if _, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).In("project_id", removed).Delete(&project_model.ProjectIssue{}); err != nil {
return err
}
for _, projectID := range projectsToRemove {
for _, projectID := range removed {
if _, err := CreateComment(ctx, &CreateCommentOptions{
Type: CommentTypeProject,
Doer: doer,
@@ -100,13 +100,13 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
}
}
if len(projectsToAdd) > 0 {
projectMap, err := project_model.GetProjectsMapByIDs(ctx, projectsToAdd)
if len(added) > 0 {
projectMap, err := project_model.GetProjectsMapByIDs(ctx, added)
if err != nil {
return err
}
for _, projectID := range projectsToAdd {
for _, projectID := range added {
newProject, ok := projectMap[projectID]
if !ok {
return util.NewNotExistErrorf("project %d not found", projectID)
@@ -149,4 +149,5 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
}
return nil
})
return added, removed, err
}
+9 -9
View File
@@ -37,21 +37,21 @@ func TestIssueMultipleProjects(t *testing.T) {
_ = project_model.DeleteProjectByID(t.Context(), project2.ID)
}()
err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
_, _, err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
require.NoError(t, err)
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
require.Empty(t, issue1.Projects)
// assign issue to both projects (each project uses its own default column)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID})
_, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID})
require.NoError(t, err)
assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data")
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
require.Len(t, issue1.Projects, 1)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID, project2.ID})
_, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project1.ID, project2.ID})
require.NoError(t, err)
assert.Nilf(t, issue1.Projects, "Issue's Projects should be nil after IssueAssignOrRemoveProject to ensure it reloads fresh data")
err = issue1.LoadProjects(t.Context())
@@ -68,7 +68,7 @@ func TestIssueMultipleProjects(t *testing.T) {
assert.Equal(t, p2Col.ID, projectColumnMap[project2.ID])
// only keep project2
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project2.ID})
_, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{project2.ID})
require.NoError(t, err)
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
@@ -84,7 +84,7 @@ func TestIssueMultipleProjects(t *testing.T) {
assert.Equal(t, project2.ID, issue1.Projects[0].ID)
// remove issue's projects
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
_, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
require.NoError(t, err)
err = issue1.LoadProjects(t.Context())
require.NoError(t, err)
@@ -114,11 +114,11 @@ func TestIssueMultipleProjects(t *testing.T) {
}
// Assign issue1 to projects 1 and 2
err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{projects[0].ID, projects[1].ID})
_, _, err := issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{projects[0].ID, projects[1].ID})
require.NoError(t, err)
// Assign issue2 to project 3
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{projects[2].ID})
_, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{projects[2].ID})
require.NoError(t, err)
// Query for issues in project 3 only (should find issue2)
@@ -141,9 +141,9 @@ func TestIssueMultipleProjects(t *testing.T) {
// FIXME: ISSUE-MULTIPLE-PROJECTS-FILTER: no multiple project filter support yet. Search logic is wrong. It should use "AND" but not "OR".
// Clean up
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
_, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue1, user2, []int64{})
require.NoError(t, err)
err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{})
_, _, err = issues_model.IssueAssignOrRemoveProject(t.Context(), issue2, user2, []int64{})
require.NoError(t, err)
})
}
+1 -1
View File
@@ -189,7 +189,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error {
return errColumnIsDefault
}
// move all issues to the default column
// move all issues to the default column, bulk moves intentionally don't trigger project workflows
project, err := GetProjectByID(ctx, column.ProjectID)
if err != nil {
return err
+7
View File
@@ -435,6 +435,10 @@ func DeleteProjectByID(ctx context.Context, id int64) error {
return err
}
if _, err := db.Delete[Workflow](ctx, FindWorkflowsOptions{ProjectIDs: []int64{id}}); err != nil {
return err
}
if _, err = db.GetEngine(ctx).ID(p.ID).Delete(new(Project)); err != nil {
return err
}
@@ -444,6 +448,9 @@ func DeleteProjectByID(ctx context.Context, id int64) error {
}
func DeleteProjectByRepoID(ctx context.Context, repoID int64) error {
if _, err := db.GetEngine(ctx).In("project_id", builder.Select("id").From("project").Where(builder.Eq{"repo_id": repoID})).Delete(new(Workflow)); err != nil {
return err
}
switch {
case setting.Database.Type.IsSQLite3():
if _, err := db.GetEngine(ctx).Exec("DELETE FROM project_issue WHERE project_issue.id IN (SELECT project_issue.id FROM project_issue INNER JOIN project WHERE project.id = project_issue.project_id AND project.repo_id = ?)", repoID); err != nil {
+256
View File
@@ -0,0 +1,256 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"context"
"slices"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/optional"
"gitea.dev/modules/timeutil"
"xorm.io/builder"
)
type WorkflowEvent string
const (
WorkflowEventItemAddedToProject WorkflowEvent = "item_added_to_project"
WorkflowEventItemRemovedFromProject WorkflowEvent = "item_removed_from_project"
WorkflowEventItemReopened WorkflowEvent = "item_reopened"
WorkflowEventItemClosed WorkflowEvent = "item_closed"
WorkflowEventItemColumnChanged WorkflowEvent = "item_column_changed"
WorkflowEventCodeChangesRequested WorkflowEvent = "code_changes_requested"
WorkflowEventCodeReviewApproved WorkflowEvent = "code_review_approved"
WorkflowEventPullRequestMerged WorkflowEvent = "pull_request_merged"
)
type WorkflowFilterType string
const (
WorkflowFilterTypeIssueType WorkflowFilterType = "issue_type"
WorkflowFilterTypeSourceColumn WorkflowFilterType = "source_column"
WorkflowFilterTypeTargetColumn WorkflowFilterType = "target_column"
WorkflowFilterTypeLabels WorkflowFilterType = "labels"
)
const (
WorkflowIssueTypeIssue = "issue"
WorkflowIssueTypePullRequest = "pull_request"
)
type WorkflowFilters struct {
IssueType string `json:"issue_type,omitempty"`
SourceColumnID int64 `json:"source_column_id,omitempty"`
TargetColumnID int64 `json:"target_column_id,omitempty"`
LabelIDs []int64 `json:"label_ids,omitempty"`
}
func (f WorkflowFilters) Types() (types []WorkflowFilterType) {
if f.IssueType != "" {
types = append(types, WorkflowFilterTypeIssueType)
}
if f.SourceColumnID != 0 {
types = append(types, WorkflowFilterTypeSourceColumn)
}
if f.TargetColumnID != 0 {
types = append(types, WorkflowFilterTypeTargetColumn)
}
if len(f.LabelIDs) > 0 {
types = append(types, WorkflowFilterTypeLabels)
}
return types
}
type WorkflowActionType string
const (
WorkflowActionTypeColumn WorkflowActionType = "column"
WorkflowActionTypeAddLabels WorkflowActionType = "add_labels"
WorkflowActionTypeRemoveLabels WorkflowActionType = "remove_labels"
WorkflowActionTypeIssueState WorkflowActionType = "issue_state"
)
const (
WorkflowIssueStateClose = "close"
WorkflowIssueStateReopen = "reopen"
)
type WorkflowActions struct {
ColumnID int64 `json:"column_id,omitempty"`
AddLabelIDs []int64 `json:"add_label_ids,omitempty"`
RemoveLabelIDs []int64 `json:"remove_label_ids,omitempty"`
IssueState string `json:"issue_state,omitempty"`
}
func (a WorkflowActions) Types() (types []WorkflowActionType) {
if a.ColumnID != 0 {
types = append(types, WorkflowActionTypeColumn)
}
if len(a.AddLabelIDs) > 0 {
types = append(types, WorkflowActionTypeAddLabels)
}
if len(a.RemoveLabelIDs) > 0 {
types = append(types, WorkflowActionTypeRemoveLabels)
}
if a.IssueState != "" {
types = append(types, WorkflowActionTypeIssueState)
}
return types
}
type WorkflowEventCapabilities struct {
Filters []WorkflowFilterType
Actions []WorkflowActionType
}
var (
itemFilters = []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels}
pullFilters = []WorkflowFilterType{WorkflowFilterTypeLabels}
pullActions = []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}
)
var workflowEvents = []struct {
event WorkflowEvent
filters []WorkflowFilterType
actions []WorkflowActionType
}{
{WorkflowEventItemAddedToProject, itemFilters, []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState}},
{WorkflowEventItemRemovedFromProject, itemFilters, []WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState}},
{WorkflowEventItemReopened, itemFilters, pullActions},
{WorkflowEventItemClosed, itemFilters, pullActions},
{
WorkflowEventItemColumnChanged,
[]WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeSourceColumn, WorkflowFilterTypeTargetColumn, WorkflowFilterTypeLabels},
[]WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState},
},
{WorkflowEventCodeChangesRequested, pullFilters, pullActions},
{WorkflowEventCodeReviewApproved, pullFilters, pullActions},
{WorkflowEventPullRequestMerged, pullFilters, pullActions},
}
func WorkflowEvents() []WorkflowEvent {
events := make([]WorkflowEvent, len(workflowEvents))
for i, event := range workflowEvents {
events[i] = event.event
}
return events
}
func (we WorkflowEvent) IsValid() bool {
return slices.Contains(WorkflowEvents(), we)
}
func (we WorkflowEvent) Capabilities() WorkflowEventCapabilities {
for _, event := range workflowEvents {
if event.event == we {
return WorkflowEventCapabilities{Filters: event.filters, Actions: event.actions}
}
}
return WorkflowEventCapabilities{}
}
func (we WorkflowEvent) LangKey() string {
return "projects.workflows.event." + string(we)
}
type Workflow struct {
ID int64 `xorm:"pk autoincr"`
ProjectID int64 `xorm:"INDEX"`
WorkflowEvent WorkflowEvent
WorkflowFilters WorkflowFilters `xorm:"TEXT JSON"`
WorkflowActions WorkflowActions `xorm:"TEXT JSON"`
Enabled bool `xorm:"DEFAULT true NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
func (wf *Workflow) UnsupportedRule() string {
capabilities := wf.WorkflowEvent.Capabilities()
for _, typ := range wf.WorkflowFilters.Types() {
if !slices.Contains(capabilities.Filters, typ) {
return string(typ)
}
}
for _, typ := range wf.WorkflowActions.Types() {
if !slices.Contains(capabilities.Actions, typ) {
return string(typ)
}
}
return ""
}
func (Workflow) TableName() string {
return "project_workflow"
}
func init() {
db.RegisterModel(new(Workflow))
}
type FindWorkflowsOptions struct {
db.ListOptions
ProjectIDs []int64
Event WorkflowEvent
Enabled optional.Option[bool]
}
func (opts FindWorkflowsOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if len(opts.ProjectIDs) > 0 {
cond = cond.And(builder.In("project_id", opts.ProjectIDs))
}
if opts.Event != "" {
cond = cond.And(builder.Eq{"workflow_event": opts.Event})
}
if opts.Enabled.Has() {
cond = cond.And(builder.Eq{"enabled": opts.Enabled.Value()})
}
return cond
}
func (opts FindWorkflowsOptions) ToOrders() string {
return "project_id ASC, id ASC" // keep execution order stable across database engines
}
// FindEnabledWorkflows returns the workflows of open projects among projectIDs that can hold items of repo, see CanBeAccessedByOwnerRepo
func FindEnabledWorkflows(ctx context.Context, repo *repo_model.Repository, projectIDs []int64, event WorkflowEvent) ([]*Workflow, error) {
if len(projectIDs) == 0 {
return nil, nil
}
opts := FindWorkflowsOptions{ProjectIDs: projectIDs, Event: event, Enabled: optional.Some(true)}
holdsRepoItems := builder.Eq{"repo_id": repo.ID}.Or(builder.Eq{"owner_id": repo.OwnerID, "repo_id": 0})
workflows := make([]*Workflow, 0)
err := db.GetEngine(ctx).Where(opts.ToConds()).
In("project_id", builder.Select("id").From("project").Where(builder.Eq{"is_closed": false}.And(holdsRepoItems))).
OrderBy(opts.ToOrders()).Find(&workflows)
return workflows, err
}
func GetWorkflowByProjectAndID(ctx context.Context, projectID, workflowID int64) (*Workflow, error) {
workflow, exist, err := db.Get[Workflow](ctx, builder.Eq{"project_id": projectID, "id": workflowID})
if err != nil {
return nil, err
}
if !exist {
return nil, db.ErrNotExist{Resource: "ProjectWorkflow", ID: workflowID}
}
return workflow, nil
}
func CreateWorkflow(ctx context.Context, wf *Workflow) error {
return db.Insert(ctx, wf)
}
func UpdateWorkflow(ctx context.Context, wf *Workflow, cols ...string) error {
_, err := db.GetEngine(ctx).ID(wf.ID).Where("project_id=?", wf.ProjectID).Cols(cols...).Update(wf)
return err
}
func DeleteWorkflow(ctx context.Context, projectID, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Where("project_id=?", projectID).Delete(&Workflow{})
return err
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"testing"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowCRUD(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
newWorkflow := func(event WorkflowEvent) *Workflow {
wf := &Workflow{ProjectID: 1, WorkflowEvent: event, Enabled: true}
require.NoError(t, CreateWorkflow(ctx, wf))
return wf
}
closed := newWorkflow(WorkflowEventItemClosed)
added := newWorkflow(WorkflowEventItemAddedToProject)
closed2 := newWorkflow(WorkflowEventItemClosed)
closed.Enabled = false
require.NoError(t, UpdateWorkflow(ctx, closed, "enabled"))
require.NoError(t, UpdateWorkflow(ctx, &Workflow{ID: added.ID, ProjectID: 2}, "enabled"))
require.NoError(t, DeleteWorkflow(ctx, 2, added.ID))
assert.True(t, unittest.AssertExistsAndLoadBean(t, &Workflow{ID: added.ID}).Enabled, "other projects cannot modify it")
workflows, err := db.Find[Workflow](ctx, FindWorkflowsOptions{ProjectIDs: []int64{1}})
require.NoError(t, err)
require.Len(t, workflows, 3)
assert.Equal(t, []int64{closed.ID, added.ID, closed2.ID}, []int64{workflows[0].ID, workflows[1].ID, workflows[2].ID})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
enabled, err := FindEnabledWorkflows(ctx, repo1, []int64{1, 2}, WorkflowEventItemClosed)
require.NoError(t, err)
require.Len(t, enabled, 1)
assert.Equal(t, closed2.ID, enabled[0].ID)
enabled, err = FindEnabledWorkflows(ctx, unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}), []int64{1}, WorkflowEventItemClosed)
require.NoError(t, err)
assert.Empty(t, enabled)
require.NoError(t, ChangeProjectStatus(ctx, unittest.AssertExistsAndLoadBean(t, &Project{ID: 1}), true))
enabled, err = FindEnabledWorkflows(ctx, repo1, []int64{1, 2}, WorkflowEventItemClosed)
require.NoError(t, err)
assert.Empty(t, enabled)
require.NoError(t, DeleteWorkflow(ctx, 1, closed2.ID))
unittest.AssertNotExistsBean(t, &Workflow{ID: closed2.ID})
require.NoError(t, DeleteProjectByID(ctx, 1))
unittest.AssertNotExistsBean(t, &Workflow{ProjectID: 1})
}