From ce13f826943ca7dc2843b54182ead497a6defdb4 Mon Sep 17 00:00:00 2001 From: Johnny Oskarsson Date: Sun, 29 Oct 2023 09:11:36 +0100 Subject: [PATCH] Add automated project board See #14359 If enabled (`project.automation.ENABLED`), it adds a new project template "Automated Kanban" when creating a new project. A project added with the "Automated Kanban" template will have automations associated with it upon creation. These automations are currently only configurable via `app.ini`. Currently it is not possible to modify the rules of a project after it has been created. If the settings in `app.ini` are changed, only new projects will have the updated automations. In the future, it may be possible to change the automations via the UI, but this is out of scope for now. --- custom/conf/app.example.ini | 77 ++++ .../config-cheat-sheet.en-us.md | 5 + models/issues/issue_project.go | 11 + models/migrations/migrations.go | 2 + models/migrations/v1_22/v282.go | 36 ++ models/project/automation.go | 389 ++++++++++++++++++ models/project/automation_test.go | 121 ++++++ models/project/board.go | 12 +- models/project/project.go | 16 +- models/project/project_test.go | 2 +- modules/references/references.go | 9 + modules/setting/project.go | 82 ++++ options/locale/locale_en-US.ini | 1 + routers/init.go | 2 + routers/web/org/projects.go | 16 +- routers/web/repo/projects.go | 16 +- services/project/automation.go | 218 ++++++++++ services/project/notifier.go | 138 +++++++ services/project/project.go | 48 +++ web_src/js/features/repo-projects.js | 4 + 20 files changed, 1195 insertions(+), 10 deletions(-) create mode 100644 models/migrations/v1_22/v282.go create mode 100644 models/project/automation.go create mode 100644 models/project/automation_test.go create mode 100644 services/project/automation.go create mode 100644 services/project/notifier.go create mode 100644 services/project/project.go 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(); + } }); }