Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add project workflow feature so users can define how to execute steps when project related events fired #30205

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions models/project/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@ func GetColumn(ctx context.Context, columnID int64) (*Column, error) {
return column, nil
}

func GetColumnByProjectIDAndColumnName(ctx context.Context, projectID int64, columnName string) (*Column, error) {
board := new(Column)
has, err := db.GetEngine(ctx).Where("project_id=? AND title=?", projectID, columnName).Get(board)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectColumnNotExist{ProjectID: projectID, Name: columnName}
}

return board, nil
}

// UpdateColumn updates a project column
func UpdateColumn(ctx context.Context, column *Column) error {
var fieldToUpdate []string
Expand Down
13 changes: 13 additions & 0 deletions models/project/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ func (p *Project) NumOpenIssues(ctx context.Context) int {
return int(c)
}

func AddIssueToColumn(ctx context.Context, issueID int64, newColumn *Column) error {
return db.Insert(ctx, &ProjectIssue{
IssueID: issueID,
ProjectID: newColumn.ProjectID,
ProjectColumnID: newColumn.ID,
})
}

func MoveIssueToAnotherColumn(ctx context.Context, issueID int64, newColumn *Column) error {
_, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id=? WHERE issue_id=?", newColumn.ID, issueID)
return err
}

// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
func MoveIssuesOnProjectColumn(ctx context.Context, column *Column, sortedIssueIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
Expand Down
24 changes: 23 additions & 1 deletion models/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const (
type ErrProjectNotExist struct {
ID int64
RepoID int64
Name string
}

// IsErrProjectNotExist checks if an error is a ErrProjectNotExist
Expand All @@ -55,6 +56,9 @@ func IsErrProjectNotExist(err error) bool {
}

func (err ErrProjectNotExist) Error() string {
if err.RepoID > 0 && len(err.Name) > 0 {
return fmt.Sprintf("projects does not exist [repo_id: %d, name: %s]", err.RepoID, err.Name)
}
return fmt.Sprintf("projects does not exist [id: %d]", err.ID)
}

Expand All @@ -64,7 +68,9 @@ func (err ErrProjectNotExist) Unwrap() error {

// ErrProjectColumnNotExist represents a "ErrProjectColumnNotExist" kind of error.
type ErrProjectColumnNotExist struct {
ColumnID int64
ColumnID int64
ProjectID int64
Name string
}

// IsErrProjectColumnNotExist checks if an error is a ErrProjectColumnNotExist
Expand All @@ -74,6 +80,9 @@ func IsErrProjectColumnNotExist(err error) bool {
}

func (err ErrProjectColumnNotExist) Error() string {
if err.ProjectID > 0 && len(err.Name) > 0 {
return fmt.Sprintf("project column does not exist [project_id: %d, name: %s]", err.ProjectID, err.Name)
}
return fmt.Sprintf("project column does not exist [id: %d]", err.ColumnID)
}

Expand Down Expand Up @@ -277,6 +286,19 @@ func GetProjectByID(ctx context.Context, id int64) (*Project, error) {
return p, nil
}

// GetProjectByName returns the projects in a repository
func GetProjectByName(ctx context.Context, repoID int64, name string) (*Project, error) {
p := new(Project)
has, err := db.GetEngine(ctx).Where("repo_id=? AND title=?", repoID, name).Get(p)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectNotExist{RepoID: repoID, Name: name}
}

return p, nil
}

// GetProjectForRepoByID returns the projects in a repository
func GetProjectForRepoByID(ctx context.Context, repoID, id int64) (*Project, error) {
p := new(Project)
Expand Down
47 changes: 47 additions & 0 deletions modules/projects/workflow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package projects

// Action represents an action that can be taken in a workflow
type Action struct {
SetValue string
}

const (
// Project workflow event names
EventItemAddedToProject = "item_added_to_project"
EventItemClosed = "item_closed"
EventItem
)

type Event struct {
Name string
Types []string
Actions []Action
}

type Workflow struct {
Name string
Events []Event
ProjectID int64
}

func ParseWorkflow(content string) (*Workflow, error) {
return &Workflow{}, nil
}

func (w *Workflow) FireAction(evtName string, f func(action Action) error) error {
for _, evt := range w.Events {
if evt.Name == evtName {
for _, action := range evt.Actions {
// Do something with action
if err := f(action); err != nil {
return err
}
}
break
}
}
return nil
}
46 changes: 46 additions & 0 deletions modules/projects/workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package projects

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestParseWorkflow(t *testing.T) {
workflowFile := `
name: Test Workflow
on:
item_added_to_project:
types: [issue, pull_request]
action:
- set_value: "status=Todo"

item_closed:
types: [issue, pull_request]
action:
- remove_label: ""

item_reopened:
action:

code_changes_requested:
action:

code_review_approved:
action:

pull_request_merged:
action:

auto_add_to_project:
action:
`

wf, err := ParseWorkflow(workflowFile)
assert.NoError(t, err)

assert.Equal(t, "Test Workflow", wf.Name)
}
154 changes: 154 additions & 0 deletions services/projects/workflow_notifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package projects

import (
"context"
"strings"

issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
project_module "code.gitea.io/gitea/modules/projects"
notify_service "code.gitea.io/gitea/services/notify"
)

func init() {
notify_service.RegisterNotifier(&workflowNotifier{})
}

type workflowNotifier struct {
notify_service.NullNotifier
}

var _ notify_service.Notifier = &workflowNotifier{}

// NewNotifier create a new workflowNotifier notifier
func NewNotifier() notify_service.Notifier {
return &workflowNotifier{}
}

func findRepoProjectsWorkflows(ctx context.Context, repo *repo_model.Repository) ([]*project_module.Workflow, error) {
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
log.Error("IssueChangeStatus: OpenRepository: %v", err)
return nil, err
}
defer gitRepo.Close()

// Get the commit object for the ref
commit, err := gitRepo.GetCommit(repo.DefaultBranch)
if err != nil {
log.Error("gitRepo.GetCommit: %w", err)
return nil, err
}

tree, err := commit.SubTree(".gitea/projects")
if _, ok := err.(git.ErrNotExist); ok {
return nil, nil
}
if err != nil {
log.Error("commit.SubTree: %w", err)
return nil, err
}

entries, err := tree.ListEntriesRecursiveFast()
if err != nil {
log.Error("tree.ListEntriesRecursiveFast: %w", err)
return nil, err
}

ret := make(git.Entries, 0, len(entries))
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml") {
ret = append(ret, entry)
}
}
if len(ret) == 0 {
return nil, nil
}

wfs := make([]*project_module.Workflow, 0, len(ret))
for _, entry := range ret {
workflowContent, err := commit.GetFileContent(".gitea/projects/"+entry.Name(), 1024*1024)
if err != nil {
log.Error("gitRepo.GetCommit: %w", err)
return nil, err
}

wf, err := project_module.ParseWorkflow(workflowContent)
if err != nil {
log.Error("IssueChangeStatus: OpenRepository: %v", err)
return nil, err
}
projectName := strings.TrimSuffix(strings.TrimSuffix(entry.Name(), ".yml"), ".yaml")
project, err := project_model.GetProjectByName(ctx, repo.ID, projectName)
if err != nil {
log.Error("IssueChangeStatus: GetProjectByName: %v", err)
return nil, err
}
wf.ProjectID = project.ID

wfs = append(wfs, wf)
}
return wfs, nil
}

func (m *workflowNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
if err := issue.LoadRepo(ctx); err != nil {
log.Error("NewIssue: LoadRepo: %v", err)
return
}
wfs, err := findRepoProjectsWorkflows(ctx, issue.Repo)
if err != nil {
log.Error("NewIssue: findRepoProjectsWorkflows: %v", err)
return
}

for _, wf := range wfs {
if err := wf.FireAction(project_module.EventItemClosed, func(action project_module.Action) error {
board, err := project_model.GetColumnByProjectIDAndColumnName(ctx, wf.ProjectID, action.SetValue)
if err != nil {
log.Error("NewIssue: GetBoardByProjectIDAndBoardName: %v", err)
return err
}
return project_model.AddIssueToColumn(ctx, issue.ID, board)
}); err != nil {
log.Error("NewIssue: FireAction: %v", err)
return
}
}
}

func (m *workflowNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) {
if isClosed {
if err := issue.LoadRepo(ctx); err != nil {
log.Error("IssueChangeStatus: LoadRepo: %v", err)
return
}
wfs, err := findRepoProjectsWorkflows(ctx, issue.Repo)
if err != nil {
log.Error("IssueChangeStatus: findRepoProjectsWorkflows: %v", err)
return
}

for _, wf := range wfs {
if err := wf.FireAction(project_module.EventItemClosed, func(action project_module.Action) error {
board, err := project_model.GetColumnByProjectIDAndColumnName(ctx, wf.ProjectID, action.SetValue)
if err != nil {
log.Error("IssueChangeStatus: GetBoardByProjectIDAndBoardName: %v", err)
return err
}
return project_model.MoveIssueToAnotherColumn(ctx, issue.ID, board)
}); err != nil {
log.Error("IssueChangeStatus: FireAction: %v", err)
return
}
}
}
}
Loading