mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-23 18:54:39 +02:00
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:
1 parent
a17f326c17
commit
c45af635e4
71 files changed
+5667
-183
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in new issue
Block a user