Skip to content

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

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

Draft
wants to merge 13 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 @@ -213,6 +213,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 @@ -33,6 +33,19 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
return err
}

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
}

func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
if c.ProjectID != newColumn.ProjectID {
return errors.New("columns have to be in the same project")
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 @@ -302,6 +311,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
181 changes: 181 additions & 0 deletions models/project/workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

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"
)

type WorkflowEvent string

const (
WorkflowEventItemAddedToProject WorkflowEvent = "item_added_to_project"
WorkflowEventItemReopened WorkflowEvent = "item_reopened"
WorkflowEventItemClosed WorkflowEvent = "item_closed"
WorkflowEventCodeChangesRequested WorkflowEvent = "code_changes_requested"
WorkflowEventCodeReviewApproved WorkflowEvent = "code_review_approved"
WorkflowEventPullRequestMerged WorkflowEvent = "pull_request_merged"
WorkflowEventAutoArchiveItems WorkflowEvent = "auto_archive_items"
WorkflowEventAutoAddToProject WorkflowEvent = "auto_add_to_project"
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 (
WorkflowActionTypeColumn WorkflowActionType = "column" // add the item to the project's column
WorkflowActionTypeLabel WorkflowActionType = "label" // choose one or more labels
WorkflowActionTypeClose WorkflowActionType = "close" // close the issue
)

type WorkflowAction struct {
ActionType WorkflowActionType
ActionValue string
}

type Workflow 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"`
}

// TableName overrides the table name used by ProjectWorkflow to `project_workflow`
func (Workflow) TableName() string {
return "project_workflow"
}

func (p *Workflow) 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 (p *Workflow) Link(ctx context.Context) string {
if err := p.LoadProject(ctx); err != nil {
log.Error("ProjectWorkflow Link: %v", err)
return ""
}
return p.Project.Link(ctx) + fmt.Sprintf("/workflows/%d", p.ID)
}

func init() {
db.RegisterModel(new(Workflow))
}

func FindWorkflowEvents(ctx context.Context, projectID int64) (map[WorkflowEvent]*Workflow, error) {
events := make(map[WorkflowEvent]*Workflow)
if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&events); err != nil {
return nil, err
}
res := make(map[WorkflowEvent]*Workflow, len(events))
for _, event := range events {
res[event.WorkflowEvent] = event
}
return res, nil
}

func GetWorkflowByID(ctx context.Context, id int64) (*Workflow, error) {
p, exist, err := db.GetByID[Workflow](ctx, id)
if err != nil {
return nil, err
}
if !exist {
return nil, util.ErrNotExist
}
return p, nil
}
85 changes: 85 additions & 0 deletions routers/web/projects/workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package projects

import (
"strconv"

project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
)

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 {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
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
}

ctx.Data["Title"] = ctx.Tr("projects.workflows")
ctx.Data["PageIsWorkflows"] = true
ctx.Data["PageIsProjects"] = true
ctx.Data["PageIsProjectsWorkflows"] = true
ctx.Data["Project"] = p

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")
ctx.Data["workflowIDStr"] = workflowIDStr
var curWorkflow *project_model.Workflow
if workflowIDStr == "" { // get first value workflow or the first workflow
for _, wf := range workflows {
if wf.ID > 0 {
curWorkflow = wf
break
}
}
} else {
workflowID, _ := strconv.ParseInt(workflowIDStr, 10, 64)
if workflowID > 0 {
for _, wf := range workflows {
if wf.ID == workflowID {
curWorkflow = wf
break
}
}
}
}
ctx.Data["CurWorkflow"] = curWorkflow

if p.Type == project_model.TypeRepository {
ctx.HTML(200, tmplRepoWorkflows)
} else {
ctx.HTML(200, tmplOrgWorkflows)
}
}
9 changes: 9 additions & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"code.gitea.io/gitea/routers/web/misc"
"code.gitea.io/gitea/routers/web/org"
org_setting "code.gitea.io/gitea/routers/web/org/setting"
"code.gitea.io/gitea/routers/web/projects"
"code.gitea.io/gitea/routers/web/repo"
"code.gitea.io/gitea/routers/web/repo/actions"
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
Expand Down Expand Up @@ -1031,6 +1032,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 // duplicates lines 1421-1441
m.Get("/new", org.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
Expand Down Expand Up @@ -1418,6 +1423,10 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{username}/{reponame}/projects", func() {
m.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject)
m.Group("/{id}/workflows", func() {
m.Get("", projects.Workflows)
m.Get("/{workflow_id}", projects.Workflows)
})
m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054
m.Get("/new", repo.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
Expand Down
Loading