diff --git a/models/project/workflows.go b/models/project/workflows.go index 0bdb21c334..b66803a041 100644 --- a/models/project/workflows.go +++ b/models/project/workflows.go @@ -5,8 +5,10 @@ package project import ( "context" + "fmt" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" ) @@ -25,12 +27,88 @@ const ( WorkflowEventAutoCloseIssue WorkflowEvent = "auto_close_issue" ) +var workflowEvents = []WorkflowEvent{ + WorkflowEventItemAddedToProject, + WorkflowEventItemReopened, + WorkflowEventItemClosed, + WorkflowEventCodeChangesRequested, + WorkflowEventCodeReviewApproved, + WorkflowEventPullRequestMerged, + WorkflowEventAutoArchiveItems, + WorkflowEventAutoAddToProject, + WorkflowEventAutoCloseIssue, +} + +func GetWorkflowEvents() []WorkflowEvent { + return workflowEvents +} + +func (we WorkflowEvent) ToString() string { + switch we { + case WorkflowEventItemAddedToProject: + return "Item added to project" + case WorkflowEventItemReopened: + return "Item reopened" + case WorkflowEventItemClosed: + return "Item closed" + case WorkflowEventCodeChangesRequested: + return "Code changes requested" + case WorkflowEventCodeReviewApproved: + return "Code review approved" + case WorkflowEventPullRequestMerged: + return "Pull request merged" + case WorkflowEventAutoArchiveItems: + return "Auto archive items" + case WorkflowEventAutoAddToProject: + return "Auto add to project" + case WorkflowEventAutoCloseIssue: + return "Auto close issue" + default: + return string(we) + } +} + +func (we WorkflowEvent) UUID() string { + switch we { + case WorkflowEventItemAddedToProject: + return "item_added_to_project" + case WorkflowEventItemReopened: + return "item_reopened" + case WorkflowEventItemClosed: + return "item_closed" + case WorkflowEventCodeChangesRequested: + return "code_changes_requested" + case WorkflowEventCodeReviewApproved: + return "code_review_approved" + case WorkflowEventPullRequestMerged: + return "pull_request_merged" + case WorkflowEventAutoArchiveItems: + return "auto_archive_items" + case WorkflowEventAutoAddToProject: + return "auto_add_to_project" + case WorkflowEventAutoCloseIssue: + return "auto_close_issue" + default: + return string(we) + } +} + +type WorkflowFilterType string + +const ( + WorkflowFilterTypeScope WorkflowFilterType = "scope" // issue, pull_request, etc. +) + +type WorkflowFilter struct { + Type WorkflowFilterType + Value string // e.g., "issue", "pull_request", etc. +} + type WorkflowActionType string const ( - WorkflowActionTypeScope WorkflowActionType = "scope" // issue, pull_request, etc. + WorkflowActionTypeColumn WorkflowActionType = "column" // add the item to the project's column WorkflowActionTypeLabel WorkflowActionType = "label" // choose one or more labels - WorkflowActionTypeColumn WorkflowActionType = "column" // choose one column WorkflowActionTypeClose WorkflowActionType = "close" // close the issue ) @@ -42,50 +120,44 @@ type WorkflowAction struct { type ProjectWorkflow struct { ID int64 ProjectID int64 `xorm:"unique(s)"` + Project *Project `xorm:"-"` WorkflowEvent WorkflowEvent `xorm:"unique(s)"` + WorkflowFilters []WorkflowFilter `xorm:"TEXT json"` WorkflowActions []WorkflowAction `xorm:"TEXT json"` CreatedUnix timeutil.TimeStamp `xorm:"created"` UpdatedUnix timeutil.TimeStamp `xorm:"updated"` } -func (p *ProjectWorkflow) Link() string { - return "" +func (p *ProjectWorkflow) LoadProject(ctx context.Context) error { + if p.Project != nil || p.ProjectID <= 0 { + return nil + } + project, err := GetProjectByID(ctx, p.ProjectID) + if err != nil { + return err + } + p.Project = project + return nil } -func newDefaultWorkflows() []*ProjectWorkflow { - return []*ProjectWorkflow{ - { - WorkflowEvent: WorkflowEventItemAddedToProject, - WorkflowActions: []WorkflowAction{{ActionType: WorkflowActionTypeScope, ActionValue: "issue"}}, - }, - { - ProjectID: 0, - WorkflowEvent: WorkflowEventItemReopened, - WorkflowActions: []WorkflowAction{{ActionType: WorkflowActionTypeScope, ActionValue: "issue"}}, - }, +func (p *ProjectWorkflow) Link(ctx context.Context) string { + if err := p.LoadProject(ctx); err != nil { + log.Error("ProjectWorkflow Link: %v", err) + return "" } -} - -func GetWorkflowDefaultValue(workflowIDStr string) *ProjectWorkflow { - workflows := newDefaultWorkflows() - for _, workflow := range workflows { - if workflow.WorkflowEvent == WorkflowEvent(workflowIDStr) { - return workflow - } - } - return &ProjectWorkflow{} + return p.Project.Link(ctx) + fmt.Sprintf("/workflows/%d", p.ID) } func init() { db.RegisterModel(new(ProjectWorkflow)) } -func FindWorkflowEvents(ctx context.Context, projectID int64) (map[WorkflowEvent]ProjectWorkflow, error) { - events := make(map[WorkflowEvent]ProjectWorkflow) +func FindWorkflowEvents(ctx context.Context, projectID int64) (map[WorkflowEvent]*ProjectWorkflow, error) { + events := make(map[WorkflowEvent]*ProjectWorkflow) if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&events); err != nil { return nil, err } - res := make(map[WorkflowEvent]ProjectWorkflow, len(events)) + res := make(map[WorkflowEvent]*ProjectWorkflow, len(events)) for _, event := range events { res[event.WorkflowEvent] = event } @@ -102,19 +174,3 @@ func GetWorkflowByID(ctx context.Context, id int64) (*ProjectWorkflow, error) { } return p, nil } - -func GetWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) { - events := make([]*ProjectWorkflow, 0, 10) - if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&events); err != nil { - return nil, err - } - workflows := newDefaultWorkflows() - for i, defaultWorkflow := range workflows { - for _, workflow := range events { - if workflow.WorkflowEvent == defaultWorkflow.WorkflowEvent { - workflows[i] = workflow - } - } - } - return workflows, nil -} diff --git a/modules/projects/workflow.go b/modules/projects/workflow.go deleted file mode 100644 index 0127b12959..0000000000 --- a/modules/projects/workflow.go +++ /dev/null @@ -1,47 +0,0 @@ -// 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 -} diff --git a/modules/projects/workflow_test.go b/modules/projects/workflow_test.go deleted file mode 100644 index 17147335dd..0000000000 --- a/modules/projects/workflow_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// 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) -} diff --git a/routers/web/projects/workflows.go b/routers/web/projects/workflows.go index 60a9e09952..fbdeb4537a 100644 --- a/routers/web/projects/workflows.go +++ b/routers/web/projects/workflows.go @@ -11,9 +11,14 @@ import ( "code.gitea.io/gitea/services/context" ) -var tmplWorkflows = templates.TplName("projects/workflows") +var ( + tmplRepoWorkflows = templates.TplName("repo/projects/workflows") + tmplOrgWorkflows = templates.TplName("org/projects/workflows") +) func Workflows(ctx *context.Context) { + ctx.Data["WorkflowEvents"] = project_model.GetWorkflowEvents() + projectID := ctx.PathParamInt64("id") p, err := project_model.GetProjectByID(ctx, projectID) if err != nil { @@ -24,7 +29,11 @@ func Workflows(ctx *context.Context) { } return } - if p.RepoID != ctx.Repo.Repository.ID { + if p.Type == project_model.TypeRepository && p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + if (p.Type == project_model.TypeOrganization || p.Type == project_model.TypeIndividual) && p.OwnerID != ctx.ContextUser.ID { ctx.NotFound(nil) return } @@ -33,41 +42,44 @@ func Workflows(ctx *context.Context) { ctx.Data["PageIsWorkflows"] = true ctx.Data["PageIsProjects"] = true ctx.Data["PageIsProjectsWorkflows"] = true + ctx.Data["Project"] = p - workflows, err := project_model.GetWorkflows(ctx, projectID) + workflows, err := project_model.FindWorkflowEvents(ctx, projectID) if err != nil { ctx.ServerError("GetWorkflows", err) return } + for _, wf := range workflows { + wf.Project = p + } ctx.Data["Workflows"] = workflows workflowIDStr := ctx.PathParam("workflow_id") - var workflow *project_model.ProjectWorkflow + ctx.Data["workflowIDStr"] = workflowIDStr + var curWorkflow *project_model.ProjectWorkflow if workflowIDStr == "" { // get first value workflow or the first workflow for _, wf := range workflows { if wf.ID > 0 { - workflow = wf + curWorkflow = wf break } } - if workflow.ID == 0 { - workflow = workflows[0] - } } else { workflowID, _ := strconv.ParseInt(workflowIDStr, 10, 64) if workflowID > 0 { - var err error - workflow, err = project_model.GetWorkflowByID(ctx, workflowID) - if err != nil { - ctx.ServerError("GetWorkflowByID", err) - return + for _, wf := range workflows { + if wf.ID == workflowID { + curWorkflow = wf + break + } } - ctx.Data["CurWorkflow"] = workflow - } else { - workflow = project_model.GetWorkflowDefaultValue(workflowIDStr) } } - ctx.Data["CurWorkflow"] = workflow + ctx.Data["CurWorkflow"] = curWorkflow - ctx.HTML(200, tmplWorkflows) + if p.Type == project_model.TypeRepository { + ctx.HTML(200, tmplRepoWorkflows) + } else { + ctx.HTML(200, tmplOrgWorkflows) + } } diff --git a/routers/web/web.go b/routers/web/web.go index 83ce7f5713..7dd9c286b6 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1030,6 +1030,10 @@ func registerWebRoutes(m *web.Router) { m.Get("", org.Projects) m.Get("/{id}", org.ViewProject) }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("/{id}/workflows", func() { + m.Get("", projects.Workflows) + m.Get("/{workflow_id}", projects.Workflows) + }) m.Group("", func() { //nolint:dupl m.Get("/new", org.RenderNewProject) m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) diff --git a/services/projects/workflow_notifier.go b/services/projects/workflow_notifier.go index 0b5b4d283e..3897a81914 100644 --- a/services/projects/workflow_notifier.go +++ b/services/projects/workflow_notifier.go @@ -5,16 +5,14 @@ package projects import ( "context" + "slices" "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" + "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" ) @@ -33,122 +31,89 @@ 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) + if err := issue.LoadProject(ctx); err != nil { + log.Error("NewIssue: LoadProject: %v", err) + return + } + if issue.Project == nil { 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 - } + eventWorkflows, err := project_model.FindWorkflowEvents(ctx, issue.Project.ID) + if err != nil { + log.Error("NewIssue: FindWorkflowEvents: %v", err) + return } + + workflow := eventWorkflows[project_model.WorkflowEventItemAddedToProject] + if workflow == nil { + return + } + + fireIssueWorkflow(ctx, workflow, issue) } 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 - } + if err := issue.LoadRepo(ctx); err != nil { + log.Error("IssueChangeStatus: LoadRepo: %v", err) + return + } + if err := issue.LoadProject(ctx); err != nil { + log.Error("NewIssue: LoadProject: %v", err) + return + } + if issue.Project == nil { + 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) + eventWorkflows, err := project_model.FindWorkflowEvents(ctx, issue.Project.ID) + if err != nil { + log.Error("NewIssue: FindWorkflowEvents: %v", err) + return + } + + workflowEvent := util.Iif(isClosed, project_model.WorkflowEventItemClosed, project_model.WorkflowEventItemReopened) + workflow := eventWorkflows[workflowEvent] + if workflow == nil { + return + } + + fireIssueWorkflow(ctx, workflow, issue) +} + +func fireIssueWorkflow(ctx context.Context, workflow *project_model.ProjectWorkflow, issue *issues_model.Issue) { + for _, filter := range workflow.WorkflowFilters { + switch filter.Type { + case project_model.WorkflowFilterTypeScope: + values := strings.Split(filter.Value, ",") + if !(slices.Contains(values, "issue") && !issue.IsPull) || (slices.Contains(values, "pull") && issue.IsPull) { return } + default: + log.Error("NewIssue: Unsupported filter type: %s", filter.Type) + return + } + } + + for _, action := range workflow.WorkflowActions { + switch action.ActionType { + case project_model.WorkflowActionTypeColumn: + column, err := project_model.GetColumnByProjectIDAndColumnName(ctx, issue.Project.ID, action.ActionValue) + if err != nil { + log.Error("NewIssue: GetColumnByProjectIDAndColumnName: %v", err) + continue + } + if err := project_model.AddIssueToColumn(ctx, issue.ID, column); err != nil { + log.Error("NewIssue: AddIssueToColumn: %v", err) + continue + } + default: + log.Error("NewIssue: Unsupported action type: %s", action.ActionType) } } } diff --git a/templates/org/projects/workflows.tmpl b/templates/org/projects/workflows.tmpl new file mode 100644 index 0000000000..ca5124e2c8 --- /dev/null +++ b/templates/org/projects/workflows.tmpl @@ -0,0 +1,13 @@ +{{template "base/head" .}} +
+ {{if .ContextUser.IsOrganization}} + {{template "org/header" .}} + {{else}} + {{template "shared/user/org_profile_avatar" .}} +
+ {{template "user/overview/header" .}} +
+ {{end}} + {{template "projects/workflows" .}} +
+{{template "base/footer" .}} diff --git a/templates/projects/workflows.tmpl b/templates/projects/workflows.tmpl index b7799316ff..e6d1ce56b8 100644 --- a/templates/projects/workflows.tmpl +++ b/templates/projects/workflows.tmpl @@ -1,11 +1,12 @@ -
-
+
+
+ {{range .WorkflowEvents}} + {{end}}
diff --git a/templates/repo/projects/workflows.tmpl b/templates/repo/projects/workflows.tmpl new file mode 100644 index 0000000000..b3af7fe46c --- /dev/null +++ b/templates/repo/projects/workflows.tmpl @@ -0,0 +1,14 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} + + {{template "projects/workflows" .}} +
+ +{{template "base/footer" .}}