diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 2209822ff0869..fc5d7c7c9f471 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1143,6 +1143,83 @@ LEVEL = Info ;PROJECT_BOARD_BASIC_KANBAN_TYPE = To Do, In Progress, Done ;PROJECT_BOARD_BUG_TRIAGE_TYPE = Needs Triage, High Priority, Low Priority, Closed +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[project.automation] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;ENABLED = false +;MAX_RULES_PER_PROJECT = 25 + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[project.automation.kanban] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; Default rules when creating an automated Kanban project. +;; Each rule can specify a list of key/value pairs: +;; - trigger (required) see commands below +;; - action (required) see commands below +;; - target (optional) the rule is only active for these issue types +;; one of: issue, pr, default +;; can be comma-separated list (e.g. issue,pr) +;; the 'default' depends on the action, but most often +;; it is the same as issue,pr (notable exceptions are +;; commands 'xref', 'approve' and 'assign_reviewer') +;; (default value is target=default) +;; - context (optional) the rule is only active in this context +;; one of: column:{column}, project +;; where {column} is a column title +;; (default value is context=project) +;; +;; Commands can have arguments using the command:argument syntax. +;; They are available both as a trigger and as an action. +;; Available commands: +;; - move:{column} where {column} is a column title +;; - status:{status} where {status} is one of: closed, reopened +;; - assign_project assigns the current project to the issue/pr +;; - assign_reviewer adds the current user as a requested reviewer +;; - assign assigns the current user to the issue/pr +;; when used as a trigger, it triggers when issue/pr is no longer unassigned +;; - unassign unassign the current user from the issue/pr +;; when used as a trigger, it triggers when issue/pr becomes unassigned +;; - approve approves a pull request (default target=pr) +;; - unapprove unapproves a pull request (default target=pr) +;; - xref:{action} a cross-reference is made between issue and pull request +;; where {action} is one of: none, closes, reopens, neutered +;; When xref is used as a trigger, target=pr can target a +;; pull request which will close an issue in the current project, +;; even if that pull request is not currently assigned to the project. +;; (default target=issue) +;; - label:{label} adds a label to the issue/pr, where {label} is a label accessible in the current project +;; - unlabel:{label} remove label from the issue/pr +;RULE1 = trigger=status:closed, action=move:Done +;RULE2 = trigger=status:reopened, action=move:To Do +;RULE3 = trigger=approve, action=move:Done, target=pr, context=column:In Progress +;RULE4 = trigger=xref:closes, action=assign_project, target=pr +;RULE5 = trigger=assign_project, action=move:To Do, target=pr +;RULE6 = trigger=move:To Do, action=unassign +;RULE7 = trigger=move:To Do, action=status:reopened +;RULE8 = trigger=move:To Do, action=clear_reviewers +;RULE9 = trigger=move:In Progress, action=assign +;RULE10 = trigger=move:In Progress, action=status:reopened +;RULE11 = trigger=move:In Progress, action=assign_reviewer +;RULE12 = trigger=move:Done, action=status:closed, target=issue +;RULE13 = trigger=move:Done, action=approve, target=pr +;RULE14 = trigger=assign, action=move:In Progress, target=issue, context=column:To Do +;RULE15 = trigger=assign_reviewer, action=move:In Progress, target=pr, context=column:To Do +;RULE16 = trigger=xref:closes, action=label:Status/Blocked, target=issue, context=column:In Progress +;RULE17 = trigger=approve, action=unlabel:Status/Blocked, target=issue, context=column:In Progress +;RULE18 = +;RULE19 = +;RULE20 = +;RULE21 = +;RULE22 = +;RULE23 = +;RULE24 = +;RULE25 = + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;[cors] diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index 617715fbaa12e..50b37b0970412 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -815,6 +815,11 @@ Default templates for project boards: - `PROJECT_BOARD_BASIC_KANBAN_TYPE`: **To Do, In Progress, Done** - `PROJECT_BOARD_BUG_TRIAGE_TYPE`: **Needs Triage, High Priority, Low Priority, Closed** +## Project Automation (`project.automation`) + +- `ENABLED`: **false**: Whether automation is enabled for projects. +- `MAX_RULE_PER_PROJECT`: **25**: Maximum number of automation rules per project. + ## Issue and pull request attachments (`attachment`) - `ENABLED`: **true**: Whether issue and pull request attachments are enabled. diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index cc7ffb356a6ca..563943a5c97ad 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -47,6 +47,17 @@ func (issue *Issue) ProjectBoardID(ctx context.Context) int64 { return ip.ProjectBoardID } +func (issue *Issue) IsOnProjectBoard(ctx context.Context, board *project_model.Board) bool { + var ip project_model.ProjectIssue + has, err := db.GetEngine(ctx).Table(project_model.ProjectIssue{}). + Where("issue_id=? AND project_board_id=?", issue.ID, board.ID). + Get(&ip) + if err != nil || !has { + return false + } + return true +} + // LoadIssuesFromBoard load issues assigned to this board func LoadIssuesFromBoard(ctx context.Context, b *project_model.Board) (IssueList, error) { issueList := make(IssueList, 0, 10) diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 4a06cdc73a3ac..26b605052ab5d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -548,6 +548,8 @@ var migrations = []Migration{ NewMigration("Rename user themes", v1_22.RenameUserThemes), // v281 -> v282 NewMigration("Add auth_token table", v1_22.CreateAuthTokenTable), + // v282 -> v283 + NewMigration("Add project_automation table", v1_22.AddProjectAutomationTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_22/v282.go b/models/migrations/v1_22/v282.go new file mode 100644 index 0000000000000..5baaae5b9b3c1 --- /dev/null +++ b/models/migrations/v1_22/v282.go @@ -0,0 +1,36 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_22 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func AddProjectAutomationTable(x *xorm.Engine) error { + type ( + AutomationTriggerType uint8 + AutomationActionType uint8 + AutomationActionTargetType uint8 + ) + + type ProjectAutomation struct { + ID int64 `xorm:"pk autoincr"` + Enabled bool `xorm:"INDEX NOT NULL DEFAULT true"` + ProjectID int64 `xorm:"INDEX NOT NULL"` + ProjectBoardID int64 `xorm:"INDEX NOT NULL"` + TriggerType AutomationTriggerType `xorm:"INDEX NOT NULL"` + TriggerData int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + ActionType AutomationActionType `xorm:"NOT NULL DEFAULT 0"` + ActionData int64 `xorm:"NOT NULL DEFAULT 0"` + ActionTarget AutomationActionTargetType `xorm:"NOT NULL DEFAULT 0"` + Sorting int64 `xorm:"NOT NULL DEFAULT 0"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + } + + return x.Sync(new(ProjectAutomation)) +} diff --git a/models/project/automation.go b/models/project/automation.go new file mode 100644 index 0000000000000..a8d5e9d29f089 --- /dev/null +++ b/models/project/automation.go @@ -0,0 +1,389 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/references" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" +) + +type ( + AutomationTriggerType uint8 + AutomationActionType uint8 + AutomationActionTargetType uint8 +) + +const ( + AutomationTriggerTypeMove AutomationTriggerType = iota // 0 + AutomationTriggerTypeStatus // 1 + AutomationTriggerTypeAssign // 2 + AutomationTriggerTypeUnassign // 3 + AutomationTriggerTypeLabel // 4 + AutomationTriggerTypeUnlabel // 5 + AutomationTriggerTypeAssignProject // 6 + AutomationTriggerTypeAssignReviewer // 7 + AutomationTriggerTypeUnassignReviewers // 8 + AutomationTriggerTypeApprove // 9 + AutomationTriggerTypeXRef // 10 +) + +const ( + AutomationActionTypeNoOperation AutomationActionType = iota // 0 + AutomationActionTypeMove // 1 + AutomationActionTypeStatus // 2 + AutomationActionTypeAssign // 3 + AutomationActionTypeUnassign // 4 + AutomationActionTypeLabel // 5 + AutomationActionTypeUnlabel // 6 + AutomationActionTypeAssignProject // 7 + AutomationActionTypeAssignReviewer // 8 + AutomationActionTypeUnassignReviewers // 9 + AutomationActionTypeApprove // 10 +) + +const ( + AutomationActionTargetTypeDefault AutomationActionTargetType = (1 << iota) >> 1 + AutomationActionTargetTypeIssue + AutomationActionTargetTypePullRequest +) + +var automationTriggerTypeStringToID = map[string]AutomationTriggerType{ + "assign": AutomationTriggerTypeAssign, + "assign_project": AutomationTriggerTypeAssignProject, + "approve": AutomationTriggerTypeApprove, + "move": AutomationTriggerTypeMove, + "status": AutomationTriggerTypeStatus, + "label": AutomationTriggerTypeLabel, + "unlabel": AutomationTriggerTypeUnlabel, + "xref": AutomationTriggerTypeXRef, + "assign_reviewer": AutomationTriggerTypeAssignReviewer, + "unassign_reviewers": AutomationTriggerTypeUnassignReviewers, + "unassign": AutomationTriggerTypeUnassign, +} + +var automationActionTypeStringToID = map[string]AutomationActionType{ + "label": AutomationActionTypeLabel, + "unlabel": AutomationActionTypeUnlabel, + "approve": AutomationActionTypeApprove, + "assign": AutomationActionTypeAssign, + "move": AutomationActionTypeMove, + "assign_project": AutomationActionTypeAssignProject, + "assign_reviewer": AutomationActionTypeAssignReviewer, + "status": AutomationActionTypeStatus, + "noop": AutomationActionTypeNoOperation, + "unassign": AutomationActionTypeUnassign, + "unassign_reviewers": AutomationActionTypeUnassignReviewers, +} + +// TODO: add docstrings everywhere +type Automation struct { + ID int64 `xorm:"pk autoincr"` + Enabled bool `xorm:"INDEX NOT NULL DEFAULT true"` + ProjectID int64 `xorm:"INDEX NOT NULL"` + ProjectBoardID int64 `xorm:"INDEX NOT NULL"` + TriggerType AutomationTriggerType `xorm:"INDEX NOT NULL"` + TriggerData int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + ActionType AutomationActionType `xorm:"NOT NULL DEFAULT 0"` + ActionData int64 `xorm:"NOT NULL DEFAULT 0"` + ActionTarget AutomationActionTargetType `xorm:"NOT NULL DEFAULT 0"` + Sorting int64 `xorm:"NOT NULL DEFAULT 0"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +type AutomationTrigger struct { + Type AutomationTriggerType + Data int64 +} + +type AutomationAction struct { + Automation *Automation + Type AutomationActionType + Data int64 +} + +func (Automation) TableName() string { + return "project_automation" +} + +func init() { + db.RegisterModel(new(Automation)) +} + +func (a *Automation) ShouldRunForTarget(target AutomationActionTargetType) bool { + actionTarget := a.ActionTarget + if a.ActionTarget == AutomationActionTargetTypeDefault { + switch a.ActionType { + case AutomationActionTypeAssignReviewer: + fallthrough + case AutomationActionTypeUnassignReviewers: + fallthrough + case AutomationActionTypeApprove: + actionTarget = AutomationActionTargetTypePullRequest + + default: + if a.TriggerType == AutomationTriggerTypeXRef { + actionTarget = AutomationActionTargetTypeIssue + } else { + actionTarget = AutomationActionTargetTypeIssue | AutomationActionTargetTypePullRequest + } + } + } + return actionTarget&target != 0 +} + +func NewAutomation(ctx context.Context, automation *Automation) error { + _, err := db.GetEngine(ctx).Insert(automation) + return err +} + +func FindAutomationsForTrigger(ctx context.Context, issueID int64, triggerType AutomationTriggerType, triggerID int64) ([]AutomationAction, error) { + // Get the projects and the current issue state for those projects + projectState := make([]*ProjectIssue, 0, 10) + err := db.GetEngine(ctx).Table(ProjectIssue{}). + Where("issue_id = ?", issueID). + Find(&projectState) + if err != nil { + return nil, err + } + + // Nothing to do if there are no projects + if len(projectState) == 0 { + return nil, nil + } + + projectIDs := make([]int64, 0, len(projectState)) + for _, project := range projectState { + projectIDs = append(projectIDs, project.ProjectID) + } + + // Get all automations relating to issue + automationTable := make([]*Automation, 0, 10) + err = db.GetEngine(ctx).Table(Automation{}). + In("project_id", projectIDs). + Where("enabled = ?", true). + Find(&automationTable) + if err != nil { + return nil, err + } + + trigger := &AutomationTrigger{triggerType, triggerID} + + // Get the relevant actions for this trigger + actions := getActions(automationTable, projectState, trigger) + + return actions, nil +} + +func getProject(automation *Automation, projectState []*ProjectIssue) *ProjectIssue { + for _, state := range projectState { + if state.ProjectID == automation.ProjectID { + return state + } + } + return nil +} + +func matchTrigger(automation *Automation, project *ProjectIssue, trigger *AutomationTrigger) bool { + if automation.ProjectID != project.ProjectID || + automation.TriggerType != trigger.Type || + automation.TriggerData != trigger.Data { + return false + } + if automation.ProjectBoardID != 0 { + if automation.ProjectBoardID != project.ProjectBoardID { + return false + } + } + return true +} + +func getActions(automationTable []*Automation, projectState []*ProjectIssue, trigger *AutomationTrigger) []AutomationAction { + actions := make([]AutomationAction, 0, 10) + for _, automation := range automationTable { + project := getProject(automation, projectState) + if project == nil || !matchTrigger(automation, project, trigger) { + continue + } + actions = append(actions, AutomationAction{ + automation, + automation.ActionType, + automation.ActionData, + }) + } + return actions +} + +func (p *Project) AutomationFromConfig( + config map[string]string, + lookupBoardID func(title string) (int64, error), + lookupLabelID func(label string) (int64, error), +) (*Automation, error) { + parseActionTarget := func(s string) AutomationActionTargetType { + target := AutomationActionTargetTypeDefault + for _, t := range strings.Split(s, ",") { + switch t { + case "issue": + target |= AutomationActionTargetTypeIssue + case "pr": + target |= AutomationActionTargetTypePullRequest + } + } + return target + } + + parseArg := func(arg string, lookup func(string) (int64, error)) (int64, error) { + data, _ := strconv.ParseInt(arg, 10, 64) + if len(arg) > 0 && lookup != nil { + data, err := lookup(arg) + if err != nil { + return 0, err + } + return data, nil + } + return data, nil + } + + lookupStatus := func(s string) (int64, error) { + switch s { + case "closed": + return 1, nil + case "reopened": + return 0, nil + default: + return 0, fmt.Errorf("unknown status %s", s) + } + } + + parseTrigger := func(s string) (AutomationTriggerType, int64, error) { + cmd, arg, _ := strings.Cut(s, ":") + if trigger, ok := automationTriggerTypeStringToID[cmd]; ok { + data, _ := strconv.ParseInt(arg, 10, 64) + var err error + switch trigger { + case AutomationTriggerTypeStatus: + data, err = parseArg(arg, lookupStatus) + case AutomationTriggerTypeMove: + data, err = parseArg(arg, lookupBoardID) + case AutomationTriggerTypeLabel: + data, err = parseArg(arg, lookupLabelID) + case AutomationTriggerTypeUnlabel: + data, err = parseArg(arg, lookupLabelID) + case AutomationTriggerTypeXRef: + data = int64(references.XRefActionFromString(arg)) + } + return trigger, data, err + } + return 0, 0, fmt.Errorf("could not parse trigger: %s", s) + } + + parseAction := func(s string) (AutomationActionType, int64, error) { + cmd, arg, _ := strings.Cut(s, ":") + if action, ok := automationActionTypeStringToID[cmd]; ok { + data, _ := strconv.ParseInt(arg, 10, 64) + var err error + switch action { + case AutomationActionTypeStatus: + data, err = parseArg(arg, lookupStatus) + case AutomationActionTypeMove: + data, err = parseArg(arg, lookupBoardID) + case AutomationActionTypeLabel: + data, err = parseArg(arg, lookupLabelID) + case AutomationActionTypeUnlabel: + data, err = parseArg(arg, lookupLabelID) + } + return action, data, err + } + return 0, 0, fmt.Errorf("could not parse action: %s", s) + } + + var err error + automation := &Automation{} + automation.ProjectID = p.ID + if automation.ProjectBoardID, err = parseArg(config["context"], lookupBoardID); err != nil { + return nil, err + } + if automation.TriggerType, automation.TriggerData, err = parseTrigger(config["trigger"]); err != nil { + return nil, err + } + if automation.ActionType, automation.ActionData, err = parseAction(config["action"]); err != nil { + return nil, err + } + automation.ActionTarget = parseActionTarget(config["target"]) + + return automation, nil +} + +func (p *Project) AutomationToConfig(automation *Automation) (map[string]string, error) { + return nil, nil +} + +func createAutomationForProjectsType(ctx context.Context, project *Project, lookupLabelID func(label string) (int64, error)) error { + var items []map[string]string + + switch project.BoardType { + + case BoardTypeAutomatedKanban: + items = setting.ProjectAutomationKanban.GetConfig() + + case BoardTypeNone: + fallthrough + default: + return nil + } + + if len(items) == 0 { + return nil + } + + boards, err := project.GetBoards(ctx) + if err != nil { + return err + } + + automation := make([]*Automation, 0, len(items)) + lookupBoardID := func(s string) (int64, error) { + for _, board := range boards { + if board.Title == s { + return board.ID, nil + } + } + return 0, fmt.Errorf("board %s not found", s) + } + + for _, v := range items { + if a, err := project.AutomationFromConfig(v, lookupBoardID, lookupLabelID); a != nil && err == nil { + a.Enabled = true + a.CreatedUnix = timeutil.TimeStampNow() + automation = append(automation, a) + } else { + log.Error("AutomationFromConfig: %v", err) + } + } + + if len(automation) == 0 { + return nil + } + + return db.Insert(ctx, automation) +} + +func deleteAutomationByProjectID(ctx context.Context, projectID int64) error { + _, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&Automation{}) + return err +} + +func deleteAutomationByBoardID(ctx context.Context, boardID int64) error { + _, err := db.GetEngine(ctx).Where("project_board_id=?", boardID).Delete(&Automation{}) + return err +} diff --git a/models/project/automation_test.go b/models/project/automation_test.go new file mode 100644 index 0000000000000..ecb169e6de982 --- /dev/null +++ b/models/project/automation_test.go @@ -0,0 +1,121 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "fmt" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/references" + + "github.com/stretchr/testify/assert" +) + +func TestAutomationFromConfig(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + project, _ := GetProjectByID(db.DefaultContext, 1) + lookupBoardID := func(title string) (int64, error) { + switch title { + case "Done": + return 3, nil + case "To Do": + return 5, nil + case "In Progress": + return 7, nil + } + return 0, fmt.Errorf("unknown board: %s", title) + } + lookupLabelID := func(label string) (int64, error) { + switch label { + case "Status/Blocked": + return 10, nil + case "Status/Invalid": + return 12, nil + } + return 0, fmt.Errorf("unknown label: %s", label) + } + + tests := []struct { + config map[string]string + result Automation + }{ + { + config: map[string]string{"trigger": "label:Status/Invalid", "action": "move:Done"}, + result: Automation{ + ProjectID: 1, + ProjectBoardID: 0, + TriggerType: AutomationTriggerTypeLabel, + TriggerData: 12, + ActionType: AutomationActionTypeMove, + ActionData: 3, + ActionTarget: AutomationActionTargetTypeDefault, + }, + }, + { + config: map[string]string{"trigger": "move:Done", "action": "status:closed", "target": "issue,pr"}, + result: Automation{ + ProjectID: 1, + ProjectBoardID: 0, + TriggerType: AutomationTriggerTypeMove, + TriggerData: 3, + ActionType: AutomationActionTypeStatus, + ActionData: 1, + ActionTarget: AutomationActionTargetTypeIssue | AutomationActionTargetTypePullRequest, + }, + }, + { + config: map[string]string{"context": "In Progress", "trigger": "xref:closes", "action": "label:Status/Blocked", "target": "issue"}, + result: Automation{ + ProjectID: 1, + ProjectBoardID: 7, + TriggerType: AutomationTriggerTypeXRef, + TriggerData: int64(references.XRefActionCloses), + ActionType: AutomationActionTypeLabel, + ActionData: 10, + ActionTarget: AutomationActionTargetTypeIssue, + }, + }, + } + + for _, test := range tests { + result, err := project.AutomationFromConfig(test.config, lookupBoardID, lookupLabelID) + assert.NoError(t, err) + assert.NotNil(t, result, "Expected result") + assert.Equal(t, test.result.ProjectID, result.ProjectID, "ProjectID mismatch") + assert.Equal(t, test.result.ProjectBoardID, result.ProjectBoardID, "ProjectBoardID mismatch") + assert.Equal(t, test.result.TriggerType, result.TriggerType, "TriggerType mismatch") + assert.Equal(t, test.result.TriggerData, result.TriggerData, "TriggerData mismatch") + assert.Equal(t, test.result.ActionType, result.ActionType, "ActionType mismatch") + assert.Equal(t, test.result.ActionData, result.ActionData, "ActionData mismatch") + assert.Equal(t, test.result.ActionTarget, result.ActionTarget, "ActionTarget mismatch") + } + + config := map[string]string{"trigger": "move:Nonexisting board", "action": "noop"} + result, err := project.AutomationFromConfig(config, lookupBoardID, lookupLabelID) + assert.EqualError(t, err, "unknown board: Nonexisting board") + assert.Nil(t, result, "Expected nil result on error") + + config = map[string]string{"context": "Nonexisting board", "trigger": "closed", "action": "noop"} + result, err = project.AutomationFromConfig(config, lookupBoardID, lookupLabelID) + assert.EqualError(t, err, "unknown board: Nonexisting board") + assert.Nil(t, result, "Expected nil result on error") + + config = map[string]string{"trigger": "label:Nonexisting label", "action": "noop"} + result, err = project.AutomationFromConfig(config, lookupBoardID, lookupLabelID) + assert.EqualError(t, err, "unknown label: Nonexisting label") + assert.Nil(t, result, "Expected nil result on error") + + config = map[string]string{"trigger": "unknown_trigger", "action": "noop"} + result, err = project.AutomationFromConfig(config, nil, nil) + assert.EqualError(t, err, "could not parse trigger: unknown_trigger") + assert.Nil(t, result, "Expected nil result on error") + + config = map[string]string{"trigger": "status:closed", "action": "unknown_action"} + result, err = project.AutomationFromConfig(config, nil, nil) + assert.EqualError(t, err, "could not parse action: unknown_action") + assert.Nil(t, result, "Expected nil result on error") +} diff --git a/models/project/board.go b/models/project/board.go index 3e2d8e0472c51..8f56e8a27ec7d 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -35,6 +35,9 @@ const ( // BoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs BoardTypeBugTriage + + // Automated Kanban is the same as Basic Kanban but with some predefined automation rules + BoardTypeAutomatedKanban ) const ( @@ -89,7 +92,7 @@ func init() { // IsBoardTypeValid checks if the project board type is valid func IsBoardTypeValid(p BoardType) bool { switch p { - case BoardTypeNone, BoardTypeBasicKanban, BoardTypeBugTriage: + case BoardTypeNone, BoardTypeAutomatedKanban, BoardTypeBasicKanban, BoardTypeBugTriage: return true default: return false @@ -117,6 +120,9 @@ func createBoardsForProjectsType(ctx context.Context, project *Project) error { case BoardTypeBasicKanban: items = setting.Project.ProjectBoardBasicKanbanType + case BoardTypeAutomatedKanban: + items = setting.Project.ProjectBoardBasicKanbanType + case BoardTypeNone: fallthrough default: @@ -163,6 +169,10 @@ func DeleteBoardByID(ctx context.Context, boardID int64) error { return err } + if err := deleteAutomationByBoardID(ctx, boardID); err != nil { + return err + } + return committer.Commit() } diff --git a/models/project/project.go b/models/project/project.go index 3a1bfe1dbd3ff..1b3cd39a3fa07 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -165,11 +165,15 @@ func init() { // GetBoardConfig retrieves the types of configurations project boards could have func GetBoardConfig() []BoardConfig { - return []BoardConfig{ + config := []BoardConfig{ {BoardTypeNone, "repo.projects.type.none"}, {BoardTypeBasicKanban, "repo.projects.type.basic_kanban"}, {BoardTypeBugTriage, "repo.projects.type.bug_triage"}, } + if setting.ProjectAutomation.Enabled { + config = append(config, BoardConfig{BoardTypeAutomatedKanban, "repo.projects.type.automated_kanban"}) + } + return config } // GetCardConfig retrieves the types of configurations project board cards could have @@ -261,7 +265,7 @@ func FindProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, e } // NewProject creates a new Project -func NewProject(ctx context.Context, p *Project) error { +func NewProject(ctx context.Context, p *Project, lookupLabelID func(label string) (int64, error)) error { if !IsBoardTypeValid(p.BoardType) { p.BoardType = BoardTypeNone } @@ -294,6 +298,10 @@ func NewProject(ctx context.Context, p *Project) error { return err } + if err := createAutomationForProjectsType(ctx, p, lookupLabelID); err != nil { + return err + } + return committer.Commit() } @@ -420,6 +428,10 @@ func DeleteProjectByID(ctx context.Context, id int64) error { return err } + if err := deleteAutomationByProjectID(ctx, id); err != nil { + return err + } + if _, err = db.GetEngine(ctx).ID(p.ID).Delete(new(Project)); err != nil { return err } diff --git a/models/project/project_test.go b/models/project/project_test.go index 6b5bd5b371dd2..8bb2b0a466068 100644 --- a/models/project/project_test.go +++ b/models/project/project_test.go @@ -60,7 +60,7 @@ func TestProject(t *testing.T) { CreatorID: 2, } - assert.NoError(t, NewProject(db.DefaultContext, project)) + assert.NoError(t, NewProject(db.DefaultContext, project, func(label string) (int64, error) { return 0, nil })) _, err := GetProjectByID(db.DefaultContext, project.ID) assert.NoError(t, err) diff --git a/modules/references/references.go b/modules/references/references.go index 68662425cccf1..93645ba06d98f 100644 --- a/modules/references/references.go +++ b/modules/references/references.go @@ -78,6 +78,15 @@ func (a XRefAction) String() string { return actionStrings[a] } +func XRefActionFromString(s string) XRefAction { + for idx, action := range actionStrings { + if action == s { + return XRefAction(idx) + } + } + return XRefActionNone +} + // IssueReference contains an unverified cross-reference to a local issue or pull request type IssueReference struct { Index int64 diff --git a/modules/setting/project.go b/modules/setting/project.go index 803e933b887e4..b722a07fdef58 100644 --- a/modules/setting/project.go +++ b/modules/setting/project.go @@ -3,6 +3,38 @@ package setting +import ( + "strings" +) + +type ProjectAutomationConfig struct { + Rule1 []string + Rule2 []string + Rule3 []string + Rule4 []string + Rule5 []string + Rule6 []string + Rule7 []string + Rule8 []string + Rule9 []string + Rule10 []string + Rule11 []string + Rule12 []string + Rule13 []string + Rule14 []string + Rule15 []string + Rule16 []string + Rule17 []string + Rule18 []string + Rule19 []string + Rule20 []string + Rule21 []string + Rule22 []string + Rule23 []string + Rule24 []string + Rule25 []string +} + // Project settings var ( Project = struct { @@ -12,8 +44,58 @@ var ( ProjectBoardBasicKanbanType: []string{"To Do", "In Progress", "Done"}, ProjectBoardBugTriageType: []string{"Needs Triage", "High Priority", "Low Priority", "Closed"}, } + ProjectAutomation = struct { + Enabled bool + MaxRulesPerProject int + }{ + Enabled: false, + MaxRulesPerProject: 25, + } + ProjectAutomationKanban = ProjectAutomationConfig{ + Rule1: []string{"trigger=status:closed", "action=move:Done"}, + Rule2: []string{"context=Done", "trigger=status:reopened", "action=move:To Do"}, + Rule3: []string{"trigger=approve", "target=pr", "action=move:Done"}, + Rule4: []string{"trigger=xref:closes", "target=pr", "action=assign_project"}, + Rule5: []string{"trigger=assign_project", "target=pr", "action=move:To Do"}, + Rule6: []string{"trigger=move:To Do", "action=unassign"}, + Rule7: []string{"trigger=move:To Do", "action=status:reopened"}, + Rule8: []string{"trigger=move:To Do", "action=unassign_reviewers"}, + Rule9: []string{"trigger=move:In Progress", "action=assign"}, + Rule10: []string{"trigger=move:In Progress", "action=status:reopened"}, + Rule11: []string{"trigger=move:In Progress", "action=assign_reviewer"}, + Rule12: []string{"trigger=move:Done", "action=status:closed", "target=issue"}, + Rule13: []string{"trigger=move:Done", "action=approve", "target=pr"}, + Rule14: []string{"context=To Do", "trigger=assign", "target=issue", "action=move:In Progress"}, + Rule15: []string{"context=To Do", "trigger=assign_reviewer", "action=move:In Progress"}, + Rule16: []string{"context=In Progress", "trigger=xref:closes", "target=issue", "action=label:Status/Blocked"}, + Rule17: []string{"context=In Progress", "trigger=approve", "target=issue", "action=unlabel:Status/Blocked"}, + } ) func loadProjectFrom(rootCfg ConfigProvider) { mustMapSetting(rootCfg, "project", &Project) + mustMapSetting(rootCfg, "project.automation", &ProjectAutomation) + mustMapSetting(rootCfg, "project.automation.kanban", &ProjectAutomationKanban) +} + +func (conf *ProjectAutomationConfig) GetConfig() []map[string]string { + config := []map[string]string{} + for _, rules := range [][]string{ + conf.Rule1, conf.Rule2, conf.Rule3, conf.Rule4, conf.Rule5, + conf.Rule6, conf.Rule7, conf.Rule8, conf.Rule9, conf.Rule10, + conf.Rule11, conf.Rule12, conf.Rule13, conf.Rule14, conf.Rule15, + conf.Rule16, conf.Rule17, conf.Rule18, conf.Rule19, conf.Rule20, + conf.Rule21, conf.Rule22, conf.Rule23, conf.Rule24, conf.Rule25, + } { + ruleConfig := map[string]string{} + for _, rule := range rules { + if key, value, ok := strings.Cut(rule, "="); ok { + ruleConfig[key] = value + } + } + if len(ruleConfig) > 0 { + config = append(config, ruleConfig) + } + } + return config } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 8c40bbc01df59..188a7c6ef3b08 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1316,6 +1316,7 @@ projects.edit_subheader = Projects organize issues and track progress. projects.modify = Edit Project projects.edit_success = Project "%s" has been updated. projects.type.none = "None" +projects.type.automated_kanban = "Automated Kanban" projects.type.basic_kanban = "Basic Kanban" projects.type.bug_triage = "Bug Triage" projects.template.desc = "Template" diff --git a/routers/init.go b/routers/init.go index c1cfe26bc4c64..654ed4a170b90 100644 --- a/routers/init.go +++ b/routers/init.go @@ -44,6 +44,7 @@ import ( markup_service "code.gitea.io/gitea/services/markup" repo_migrations "code.gitea.io/gitea/services/migrations" mirror_service "code.gitea.io/gitea/services/mirror" + project_service "code.gitea.io/gitea/services/project" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/services/repository/archiver" @@ -151,6 +152,7 @@ func InitWebInstalled(ctx context.Context) { mustInit(automerge.Init) mustInit(task.Init) mustInit(repo_migrations.Init) + mustInit(project_service.Init) eventsource.GetManager().Init() mustInitCtx(ctx, mailer_incoming.Init) diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 439fdf644bb6a..e042ac81f9e86 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/forms" + project_service "code.gitea.io/gitea/services/project" ) const ( @@ -179,7 +180,15 @@ func NewProjectPost(ctx *context.Context) { newProject.Type = project_model.TypeIndividual } - if err := project_model.NewProject(ctx, &newProject); err != nil { + lookupLabelID := func(labelName string) (int64, error) { + label, err := issues_model.GetLabelInOrgByName(ctx, ctx.ContextUser.ID, labelName) + if err != nil { + return 0, fmt.Errorf("label %s not found in org", labelName) + } + return label.ID, nil + } + + if err := project_model.NewProject(ctx, &newProject, lookupLabelID); err != nil { ctx.ServerError("NewProject", err) return } @@ -738,10 +747,11 @@ func MoveIssues(ctx *context.Context) { } } - if err = project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs); err != nil { + reload, err := project_service.MoveIssuesOnProjectBoard(ctx, ctx.Doer, board, sortedIssueIDs) + if err != nil { ctx.ServerError("MoveIssuesOnProjectBoard", err) return } - ctx.JSONOK() + ctx.JSON(http.StatusOK, map[string]any{"ok": true, "reload": reload}) } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 6417024f8ba3c..1e55277a4593e 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/forms" + project_service "code.gitea.io/gitea/services/project" ) const ( @@ -142,6 +143,14 @@ func NewProjectPost(ctx *context.Context) { return } + lookupLabelID := func(labelName string) (int64, error) { + label, err := issues_model.GetLabelInRepoByName(ctx, ctx.Repo.Repository.ID, labelName) + if err != nil { + return 0, fmt.Errorf("label %s not found in repo", labelName) + } + return label.ID, nil + } + if err := project_model.NewProject(ctx, &project_model.Project{ RepoID: ctx.Repo.Repository.ID, Title: form.Title, @@ -150,7 +159,7 @@ func NewProjectPost(ctx *context.Context) { BoardType: form.BoardType, CardType: form.CardType, Type: project_model.TypeRepository, - }); err != nil { + }, lookupLabelID); err != nil { ctx.ServerError("NewProject", err) return } @@ -683,10 +692,11 @@ func MoveIssues(ctx *context.Context) { } } - if err = project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs); err != nil { + reload, err := project_service.MoveIssuesOnProjectBoard(ctx, ctx.Doer, board, sortedIssueIDs) + if err != nil { ctx.ServerError("MoveIssuesOnProjectBoard", err) return } - ctx.JSONOK() + ctx.JSON(http.StatusOK, map[string]any{"ok": true, "reload": reload}) } diff --git a/services/project/automation.go b/services/project/automation.go new file mode 100644 index 0000000000000..8c6fc1b30753e --- /dev/null +++ b/services/project/automation.go @@ -0,0 +1,218 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "slices" + + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + issue_service "code.gitea.io/gitea/services/issue" + notify_service "code.gitea.io/gitea/services/notify" +) + +type ActionsQueueEntry struct { + action project_model.AutomationAction + issue *issues_model.Issue +} + +var ( + actionsQueue []ActionsQueueEntry + dispatchedActionIDs []int64 +) + +func Init() error { + actionsQueue = nil + dispatchedActionIDs = nil + if setting.ProjectAutomation.Enabled { + notify_service.RegisterNotifier(NewNotifier()) + } + return nil +} + +func dispatchActions(ctx context.Context, actions []project_model.AutomationAction, issue *issues_model.Issue, doer *user_model.User) bool { + // the "root dispatch" is responsible for handling the actions queue + isRootDispatch := actionsQueue == nil + if isRootDispatch { + actionsQueue = make([]ActionsQueueEntry, 0, 10) + dispatchedActionIDs = make([]int64, 0, 10) + } + + // append actions to queue (prevent running the same action twice) + for _, action := range actions { + actionID := action.Automation.ID + if slices.Contains(dispatchedActionIDs, action.Automation.ID) { + log.Warn("Possible loop relating to project automation ID %d while processing issue %d", + action.Automation.ID, issue.ID) + continue + } + dispatchedActionIDs = append(dispatchedActionIDs, actionID) + actionsQueue = append(actionsQueue, ActionsQueueEntry{action, issue}) + } + + // only the "root dispatch" should process the queue + if !isRootDispatch { + return false + } + + reloadHint := false + for { + if len(actionsQueue) == 0 { + break + } + entry := actionsQueue[0] + actionsQueue = actionsQueue[1:] + action := entry.action + issue := entry.issue + + target := project_model.AutomationActionTargetTypeIssue + if issue.IsPull { + target = project_model.AutomationActionTargetTypePullRequest + } + + if !action.Automation.ShouldRunForTarget(target) { + continue + } + + switch action.Type { + // Label - add label to issue + case project_model.AutomationActionTypeLabel: + if addLabel, _ := issues_model.GetLabelByID(ctx, action.Data); addLabel != nil { + if err := issue.LoadLabels(ctx); err != nil { + log.Error("LoadLabels: %v", err) + } + if !slices.ContainsFunc(issue.Labels, func(label *issues_model.Label) bool { return label.ID == addLabel.ID }) { + reloadHint = true + if err := issue_service.AddLabel(ctx, issue, doer, addLabel); err != nil { + log.Error("AddLabel: %v", err) + } + } + + } + + // Unlabel - remove label from issue + case project_model.AutomationActionTypeUnlabel: + if removeLabel, _ := issues_model.GetLabelByID(ctx, action.Data); removeLabel != nil { + if err := issue.LoadLabels(ctx); err != nil { + log.Error("LoadLabels: %v", err) + } + if slices.ContainsFunc(issue.Labels, func(label *issues_model.Label) bool { return label.ID == removeLabel.ID }) { + reloadHint = true + if err := issue_service.RemoveLabel(ctx, issue, doer, removeLabel); err != nil { + log.Error("RemoveLabel: %v", err) + } + } + } + + // Move - move issue to column + case project_model.AutomationActionTypeMove: + if board, _ := project_model.GetBoard(ctx, action.Data); board != nil { + if hint, _ := MoveIssuesOnProjectBoard(ctx, doer, board, map[int64]int64{0: issue.ID}); hint { + reloadHint = true + } + } + + // Status - change status of issue / pr + case project_model.AutomationActionTypeStatus: + isClosed := action.Data > 0 + if isClosed != issue.IsClosed { + reloadHint = true + if err := issue_service.ChangeStatus(ctx, issue, doer, "", isClosed); err != nil { + log.Error("ChangeStatus: %v", err) + } + } + + // Assign - assign issue / pr to current user + case project_model.AutomationActionTypeAssign: + if len(issue.Assignees) == 0 { + if err := issue.LoadRepo(ctx); err == nil { + reloadHint = true + if _, err := issue_service.AddAssigneeIfNotAssigned(ctx, issue, doer, doer.ID, true); err != nil { + log.Error("AddAssigneeIfNotAssigned: %v", err) + } + } + } + + // Unassign - remove all assignees from issue / pr + case project_model.AutomationActionTypeUnassign: + if err := issue.LoadAssignees(ctx); err != nil { + continue + } + if len(issue.Assignees) > 0 { + if err := issue.LoadRepo(ctx); err == nil { + reloadHint = true + if err := issue_service.DeleteNotPassedAssignee(ctx, issue, doer, []*user_model.User{}); err != nil { + log.Error("DeleteNotPassedAssignee: %v", err) + } + } + } + + // AssignReviewer - add reviewer to pr + case project_model.AutomationActionTypeAssignReviewer: + if pr, _ := issue.GetPullRequest(ctx); pr != nil { + if len(pr.RequestedReviewers) == 0 { + reloadHint = true + if _, err := issue_service.ReviewRequest(ctx, issue, doer, doer, true); err != nil { + log.Error("ReviewRequest: %v", err) + } + } + } + + // UnassignReviewers - remove all reviewers from pr + case project_model.AutomationActionTypeUnassignReviewers: + if pr, _ := issue.GetPullRequest(ctx); pr != nil { + for _, reviewer := range pr.RequestedReviewers { + reloadHint = true + if _, err := issue_service.ReviewRequest(ctx, issue, doer, reviewer, false); err != nil { + log.Error("ReviewRequest: %v", err) + } + } + } + + // AssignProject - add issue / pr to current project + case project_model.AutomationActionTypeAssignProject: + if err := issue.LoadProject(ctx); err != nil { + log.Error("LoadProject: %v", err) + } + if issue.Project == nil { + reloadHint = true + if err := issues_model.ChangeProjectAssign(ctx, issue, doer, action.Automation.ProjectID); err != nil { + log.Error("ChangeProjectAssign: %v", err) + } + } + + // Approve - approve pull request + case project_model.AutomationActionTypeApprove: + // can not approve/reject your own PR + if issue.PosterID != doer.ID { + // Don't approve if you already approved it + review, err := issues_model.GetReviewByIssueIDAndUserID(ctx, issue.ID, doer.ID) + if issues_model.IsErrReviewNotExist(err) || (err == nil && review != nil && review.Type != issues_model.ReviewTypeApprove) { + if pr, _ := issue.GetPullRequest(ctx); pr != nil { + reloadHint = true + if err := issue.LoadRepo(ctx); err != nil { + log.Error("LoadRepo: %v", err) + } + if _, _, err := issues_model.SubmitReview(ctx, doer, issue, issues_model.ReviewTypeApprove, "", pr.HeadCommitID, false, []string{}); err != nil { + log.Error("SubmitReview: %v", err) + } + } + } + } + + // NoOperation + case project_model.AutomationActionTypeNoOperation: + // no operation + } + } + + actionsQueue = nil + dispatchedActionIDs = nil + + return reloadHint +} diff --git a/services/project/notifier.go b/services/project/notifier.go new file mode 100644 index 0000000000000..1bc8e62f8af33 --- /dev/null +++ b/services/project/notifier.go @@ -0,0 +1,138 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/references" + notify_service "code.gitea.io/gitea/services/notify" +) + +type projectNotifier struct { + notify_service.NullNotifier +} + +var _ notify_service.Notifier = &projectNotifier{} + +func NewNotifier() notify_service.Notifier { + return &projectNotifier{} +} + +// Closed, Reopened +func (n *projectNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, _ *issues_model.Comment, isClosed bool) { + triggerType := project_model.AutomationTriggerTypeStatus + triggerData := int64(0) + if isClosed { + triggerData = 1 + } + actions, err := project_model.FindAutomationsForTrigger(ctx, issue.ID, triggerType, triggerData) + if err != nil { + log.Error("FindAutomationsForTrigger: %v", err) + return + } + if len(actions) > 0 { + dispatchActions(ctx, actions, issue, doer) + } +} + +// LabelAdded +func (n *projectNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, addedLabels, removedLabels []*issues_model.Label) { + actions := make([]project_model.AutomationAction, 0, 10) + for _, label := range addedLabels { + triggerType := project_model.AutomationTriggerTypeLabel + newActions, err := project_model.FindAutomationsForTrigger(ctx, issue.ID, triggerType, label.ID) + if err == nil { + actions = append(actions, newActions...) + } + } + for _, label := range removedLabels { + triggerType := project_model.AutomationTriggerTypeUnlabel + newActions, err := project_model.FindAutomationsForTrigger(ctx, issue.ID, triggerType, label.ID) + if err == nil { + actions = append(actions, newActions...) + } + } + if len(actions) > 0 { + dispatchActions(ctx, actions, issue, doer) + } +} + +// ReviewerAssigned, ReviewersUnassigned +func (n *projectNotifier) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isAdd bool, comment *issues_model.Comment) { + if pr, _ := issue.GetPullRequest(ctx); pr != nil { + triggerType := project_model.AutomationTriggerTypeAssignReviewer + if isAdd { + // We want to trigger when the number of reviewers goes from 0 to 1 + if len(pr.RequestedReviewers) != 1 { + return + } + } else { + // We want to trigger when the number of reviewers goes to 0 + if len(pr.RequestedReviewers) != 0 { + return + } + triggerType = project_model.AutomationTriggerTypeUnassignReviewers + } + + actions, err := project_model.FindAutomationsForTrigger(ctx, issue.ID, triggerType, 0) + if err != nil { + log.Error("FindAutomationsForTrigger: %v", err) + return + } + if len(actions) > 0 { + dispatchActions(ctx, actions, issue, doer) + } + } +} + +// Approve +func (n *projectNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) { + if review.Type == issues_model.ReviewTypeApprove { + triggerType := project_model.AutomationTriggerTypeApprove + if actions, err := project_model.FindAutomationsForTrigger(ctx, pr.IssueID, triggerType, 0); err == nil { + if err := review.LoadReviewer(ctx); err != nil { + log.Error("LoadReviewer: %v", err) + } + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + } + // Trigger actions for both target PR and cross referenced issues which will be closed + dispatchActions(ctx, actions, pr.Issue, review.Reviewer) + if comments, err := pr.ResolveCrossReferences(ctx); err == nil { + for _, comment := range comments { + if comment.RefAction == references.XRefActionCloses { + if actions, err := project_model.FindAutomationsForTrigger(ctx, comment.IssueID, triggerType, 0); err == nil { + if err := comment.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + } + dispatchActions(ctx, actions, comment.Issue, review.Reviewer) + } + } + } + } + } + } +} + +// XRef +func (n *projectNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { + triggerType := project_model.AutomationTriggerTypeXRef + if comments, err := pr.ResolveCrossReferences(ctx); err == nil { + for _, comment := range comments { + if actions, err := project_model.FindAutomationsForTrigger(ctx, comment.IssueID, triggerType, int64(comment.RefAction)); err == nil { + if err := comment.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + } + // Trigger actions for both target issue and source PR + dispatchActions(ctx, actions, comment.Issue, pr.Issue.Poster) + dispatchActions(ctx, actions, pr.Issue, pr.Issue.Poster) + } + } + } +} diff --git a/services/project/project.go b/services/project/project.go new file mode 100644 index 0000000000000..54f425107304a --- /dev/null +++ b/services/project/project.go @@ -0,0 +1,48 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" +) + +func MoveIssuesOnProjectBoard(ctx context.Context, doer *user_model.User, board *project_model.Board, sortedIssueIDs map[int64]int64) (bool, error) { + type MovedIssue struct { + issue *issues_model.Issue + actions []project_model.AutomationAction + } + var movedIssues []MovedIssue + + if setting.ProjectAutomation.Enabled { + movedIssues = make([]MovedIssue, 0, 10) + triggerType := project_model.AutomationTriggerTypeMove + for _, issueID := range sortedIssueIDs { + issue, _ := issues_model.GetIssueByID(ctx, issueID) + if issue != nil && !issue.IsOnProjectBoard(ctx, board) { + actions, _ := project_model.FindAutomationsForTrigger(ctx, issue.ID, triggerType, board.ID) + if len(actions) > 0 { + movedIssues = append(movedIssues, MovedIssue{issue: issue, actions: actions}) + } + } + } + } + + err := project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs) + + reloadHint := false + if err == nil && len(movedIssues) > 0 { + for _, movedIssue := range movedIssues { + if dispatchActions(ctx, movedIssue.actions, movedIssue.issue, doer) { + reloadHint = true + } + } + } + + return reloadHint, err +} diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js index 5a2a7e72ef335..416d5c3df0fe6 100644 --- a/web_src/js/features/repo-projects.js +++ b/web_src/js/features/repo-projects.js @@ -49,6 +49,10 @@ function moveIssue({item, from, to, oldIndex}) { error: () => { from.insertBefore(item, from.children[oldIndex]); }, + }).done((response) => { + if (response.reload) { + window.location.reload(); + } }); }