@@ -217,6 +217,18 @@ func GetBoard(ctx context.Context, boardID int64) (*Board, error) { | |||
return board, nil | |||
} | |||
func GetBoardByProjectIDAndBoardName(ctx context.Context, projectID int64, boardName string) (*Board, error) { | |||
board := new(Board) | |||
has, err := db.GetEngine(ctx).Where("project_id=? AND title=?", projectID, boardName).Get(board) | |||
if err != nil { | |||
return nil, err | |||
} else if !has { | |||
return nil, ErrProjectBoardNotExist{ProjectID: projectID, Name: boardName} | |||
} | |||
return board, nil | |||
} | |||
// UpdateBoard updates a project board | |||
func UpdateBoard(ctx context.Context, board *Board) error { | |||
var fieldToUpdate []string |
@@ -75,6 +75,19 @@ func (p *Project) NumOpenIssues(ctx context.Context) int { | |||
return int(c) | |||
} | |||
func AddIssueToBoard(ctx context.Context, issueID int64, newBoard *Board) error { | |||
return db.Insert(ctx, &ProjectIssue{ | |||
IssueID: issueID, | |||
ProjectID: newBoard.ProjectID, | |||
ProjectBoardID: newBoard.ID, | |||
}) | |||
} | |||
func MoveIssueToAnotherBoard(ctx context.Context, issueID int64, newBoard *Board) error { | |||
_, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id=? WHERE issue_id=?", newBoard.ID, issueID) | |||
return err | |||
} | |||
// MoveIssuesOnProjectBoard moves or keeps issues in a column and sorts them inside that column | |||
func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs map[int64]int64) error { | |||
return db.WithTx(ctx, func(ctx context.Context) error { |
@@ -52,6 +52,7 @@ const ( | |||
type ErrProjectNotExist struct { | |||
ID int64 | |||
RepoID int64 | |||
Name string | |||
} | |||
// IsErrProjectNotExist checks if an error is a ErrProjectNotExist | |||
@@ -61,6 +62,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) | |||
} | |||
@@ -70,7 +74,9 @@ func (err ErrProjectNotExist) Unwrap() error { | |||
// ErrProjectBoardNotExist represents a "ProjectBoardNotExist" kind of error. | |||
type ErrProjectBoardNotExist struct { | |||
BoardID int64 | |||
BoardID int64 | |||
ProjectID int64 | |||
Name string | |||
} | |||
// IsErrProjectBoardNotExist checks if an error is a ErrProjectBoardNotExist | |||
@@ -80,6 +86,9 @@ func IsErrProjectBoardNotExist(err error) bool { | |||
} | |||
func (err ErrProjectBoardNotExist) Error() string { | |||
if err.ProjectID > 0 && len(err.Name) > 0 { | |||
return fmt.Sprintf("project board does not exist [project_id: %d, name: %s]", err.ProjectID, err.Name) | |||
} | |||
return fmt.Sprintf("project board does not exist [id: %d]", err.BoardID) | |||
} | |||
@@ -293,6 +302,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) |
@@ -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 | |||
} |
@@ -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) | |||
} |
@@ -0,0 +1,155 @@ | |||
// 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.GetBoardByProjectIDAndBoardName(ctx, wf.ProjectID, action.SetValue) | |||
if err != nil { | |||
log.Error("NewIssue: GetBoardByProjectIDAndBoardName: %v", err) | |||
return err | |||
} | |||
return project_model.AddIssueToBoard(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.GetBoardByProjectIDAndBoardName(ctx, wf.ProjectID, action.SetValue) | |||
if err != nil { | |||
log.Error("IssueChangeStatus: GetBoardByProjectIDAndBoardName: %v", err) | |||
return err | |||
} | |||
return project_model.MoveIssueToAnotherBoard(ctx, issue.ID, board) | |||
}); err != nil { | |||
log.Error("IssueChangeStatus: FireAction: %v", err) | |||
return | |||
} | |||
} | |||
} | |||
} |