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 11 commits into
base: main
Choose a base branch
from
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
120 changes: 120 additions & 0 deletions models/project/workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package project

import (
"context"

"code.gitea.io/gitea/models/db"
"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"
)

type WorkflowActionType string

const (
WorkflowActionTypeScope WorkflowActionType = "scope" // issue, pull_request, etc.
WorkflowActionTypeLabel WorkflowActionType = "label" // choose one or more labels
WorkflowActionTypeColumn WorkflowActionType = "column" // choose one column
WorkflowActionTypeClose WorkflowActionType = "close" // close the issue
)

type WorkflowAction struct {
ActionType WorkflowActionType
ActionValue string
}

type ProjectWorkflow struct {

Check failure on line 42 in models/project/workflows.go

View workflow job for this annotation

GitHub Actions / lint-backend

exported: type name will be used as project.ProjectWorkflow by other packages, and that stutters; consider calling this Workflow (revive)

Check failure on line 42 in models/project/workflows.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

exported: type name will be used as project.ProjectWorkflow by other packages, and that stutters; consider calling this Workflow (revive)

Check failure on line 42 in models/project/workflows.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

exported: type name will be used as project.ProjectWorkflow by other packages, and that stutters; consider calling this Workflow (revive)
ID int64
ProjectID int64 `xorm:"unique(s)"`
WorkflowEvent WorkflowEvent `xorm:"unique(s)"`
WorkflowActions []WorkflowAction `xorm:"TEXT json"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}

func (p *ProjectWorkflow) Link() string {
return ""
}

func newDefaultWorkflows() []*ProjectWorkflow {
return []*ProjectWorkflow{
{
WorkflowEvent: WorkflowEventItemAddedToProject,
WorkflowActions: []WorkflowAction{{ActionType: WorkflowActionTypeScope, ActionValue: "issue"}},
},
{
ProjectID: 0,
WorkflowEvent: WorkflowEventItemReopened,
WorkflowActions: []WorkflowAction{{ActionType: WorkflowActionTypeScope, ActionValue: "issue"}},
},
}
}

func GetWorkflowDefaultValue(workflowIDStr string) *ProjectWorkflow {
workflows := newDefaultWorkflows()
for _, workflow := range workflows {
if workflow.WorkflowEvent == WorkflowEvent(workflowIDStr) {
return workflow
}
}
return &ProjectWorkflow{}
}

func init() {
db.RegisterModel(new(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))
for _, event := range events {
res[event.WorkflowEvent] = event
}
return res, nil
}

func GetWorkflowByID(ctx context.Context, id int64) (*ProjectWorkflow, error) {
p, exist, err := db.GetByID[ProjectWorkflow](ctx, id)
if err != nil {
return nil, err
}
if !exist {
return nil, util.ErrNotExist
}
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
}
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)
}
73 changes: 73 additions & 0 deletions routers/web/projects/workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// 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 tmplWorkflows = templates.TplName("projects/workflows")

func Workflows(ctx *context.Context) {
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.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}

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

workflows, err := project_model.GetWorkflows(ctx, projectID)
if err != nil {
ctx.ServerError("GetWorkflows", err)
return
}
ctx.Data["Workflows"] = workflows

workflowIDStr := ctx.PathParam("workflow_id")
var workflow *project_model.ProjectWorkflow
if workflowIDStr == "" { // get first value workflow or the first workflow
for _, wf := range workflows {
if wf.ID > 0 {
workflow = 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
}
ctx.Data["CurWorkflow"] = workflow
} else {
workflow = project_model.GetWorkflowDefaultValue(workflowIDStr)
}
}
ctx.Data["CurWorkflow"] = workflow

ctx.HTML(200, tmplWorkflows)
}
Loading
Loading