Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
64e0ba1
Add project workflow feature so users can define how to execute steps…
lunny Mar 31, 2024
95cecbb
Add NewIssue events
lunny Apr 5, 2024
5d78be4
Merge branch 'main' into lunny/project_workflow
lunny May 28, 2024
9d0a599
Merge branch 'main' into lunny/project_workflow
lunny Dec 23, 2024
f2c7d32
Merge branch 'main' into lunny/project_workflow
lunny Jan 9, 2025
fb12ed2
add workflow
lunny Jan 9, 2025
34dd91a
Merge branch 'main' into lunny/project_workflow
lunny Mar 29, 2025
7fefa37
Some work
lunny Mar 29, 2025
b37cb32
Add web routers for project workflow
lunny Apr 6, 2025
e7365c7
Merge branch 'main' into lunny/project_workflow
lunny Apr 13, 2025
cb036a3
Merge branch 'main' into lunny/project_workflow
lunny Jun 9, 2025
7c66abc
Use database to store the project workflow data
lunny Jun 10, 2025
a4f8408
Merge branch 'main' into lunny/project_workflow
lunny Jul 6, 2025
bf9511e
Merge branch 'main' into lunny/project_workflow
lunny Sep 2, 2025
5481800
some improvements
lunny Sep 3, 2025
6af5045
new layout
lunny Sep 3, 2025
e5b16f9
remove unnecessary button
lunny Sep 3, 2025
dd63558
improvements
lunny Sep 3, 2025
20b4ce5
improvements
lunny Sep 3, 2025
ad4c17b
Fix url change
lunny Sep 4, 2025
0fe622a
Fix switch events
lunny Sep 4, 2025
5cd2901
Fix clone
lunny Sep 4, 2025
af5ba85
improvements
lunny Sep 4, 2025
f681274
enable/disable
lunny Sep 4, 2025
6411e21
disabled status
lunny Sep 4, 2025
990d800
improvements
lunny Sep 4, 2025
58a3680
adjust workflow page header
lunny Sep 4, 2025
a18eba0
fix
lunny Sep 5, 2025
f7b8f6e
Merge branch 'main' into lunny/project_workflow
lunny Oct 22, 2025
b191ded
Fix
lunny Oct 23, 2025
8115684
replace alert
lunny Oct 23, 2025
836bf98
Remove clone support at the time
lunny Oct 23, 2025
74fc30f
Support add labels and remove labels for issue's project column chang…
lunny Oct 23, 2025
822387a
support labels filter
lunny Oct 23, 2025
ab5fa40
fix clone
lunny Oct 23, 2025
cc4a85b
fix
lunny Oct 23, 2025
829fa15
fix
lunny Oct 23, 2025
7240b2b
fix
lunny Oct 23, 2025
60bf918
Fix enable/disable
lunny Oct 23, 2025
6c4160d
fix
lunny Oct 23, 2025
7eb5673
fix
lunny Oct 24, 2025
623388c
Merge branch 'main' into lunny/project_workflow
lunny Oct 24, 2025
c2419f8
improvements
lunny Oct 24, 2025
903d605
improvements
lunny Oct 24, 2025
323a24e
Fix bug
lunny Oct 25, 2025
3d081b3
Fix bug
lunny Oct 25, 2025
635649d
add translations
lunny Oct 25, 2025
f68eee5
Fix translations
lunny Oct 25, 2025
e1d2cf4
Fix bug
lunny Oct 25, 2025
d6958c5
Fix lint
lunny Oct 25, 2025
12feb0e
Add unit test and integration test
lunny Oct 25, 2025
1bffdfe
Merge branch 'main' into lunny/project_workflow
lunny Oct 25, 2025
65dbaa5
Remove log
lunny Oct 25, 2025
ae28239
Fix lint and fix wrong migrations
lunny Oct 25, 2025
3719626
improvements
lunny Oct 25, 2025
3aef130
improvements
lunny Oct 25, 2025
4efc69c
Fix dark theme
lunny Oct 26, 2025
3d636fc
fix bug and add label selector
lunny Oct 26, 2025
045945d
Fix label menu
lunny Oct 26, 2025
4a2734a
remove unused code
lunny Oct 26, 2025
fdd3bf0
remove unused code
lunny Oct 26, 2025
418a7bb
Add tests for project workflow execution
lunny Oct 27, 2025
b04d42b
Merge branch 'main' into lunny/project_workflow
lunny Oct 27, 2025
b840641
revert label component change
lunny Oct 27, 2025
3f9f725
revert label component change
lunny Oct 27, 2025
b4ebf1b
Fix lint
lunny Oct 27, 2025
e21fb90
Fix migration
lunny Oct 28, 2025
df50690
Merge branch 'main' into lunny/project_workflow
lunny Oct 28, 2025
0b41bfa
Add valid when saving project workflow event
lunny Oct 28, 2025
984d4b0
Fix lint
lunny Oct 28, 2025
4c4a16c
Merge branch 'main' into lunny/project_workflow
lunny Nov 1, 2025
507c6de
Merge branch 'main' into lunny/project_workflow
lunny Nov 1, 2025
c6bd967
Merge branch 'main' into lunny/project_workflow
lunny Nov 3, 2025
7db5b85
fix clone workflow
lunny Nov 4, 2025
be143bd
Delete unused code
lunny Nov 4, 2025
883718e
Fix unnecessary comment when moving an issue to the same column
lunny Nov 4, 2025
bf841a8
Merge branch 'main' into lunny/project_workflow
lunny Nov 4, 2025
8feb5f9
Merge add labels and remove labels as one operation when firing proje…
lunny Nov 11, 2025
08d2809
Merge branch 'main' into lunny/project_workflow
lunny Nov 11, 2025
bc8b385
Merge branch 'main' into lunny/project_workflow
lunny Nov 18, 2025
79c014e
Merge branch 'main' into lunny/project_workflow
lunny Nov 24, 2025
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
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.25.0 ends at migration ID number 322 (database version 323)

newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
newMigration(324, "Add new table project_workflow", v1_26.AddProjectWorkflow),
}
return preparedMigrations
}
Expand Down
25 changes: 25 additions & 0 deletions models/migrations/v1_26/v324.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_26

import (
"code.gitea.io/gitea/modules/timeutil"

"xorm.io/xorm"
)

func AddProjectWorkflow(x *xorm.Engine) error {
type ProjectWorkflow struct {
ID int64
ProjectID int64 `xorm:"INDEX"`
WorkflowEvent string `xorm:"INDEX"`
Comment on lines +15 to +16
Copy link
Contributor

@wxiaoguang wxiaoguang Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WorkflowEvent string `xorm:"INDEX"`

I don't think such index can work.

WorkflowFilters string `xorm:"TEXT JSON"`
WorkflowActions string `xorm:"TEXT JSON"`
Enabled bool `xorm:"DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}

return x.Sync(&ProjectWorkflow{})
}
28 changes: 28 additions & 0 deletions models/project/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ type Column struct {
Color string `xorm:"VARCHAR(7)"`

ProjectID int64 `xorm:"INDEX NOT NULL"`

Project *Project `xorm:"-"`

CreatorID int64 `xorm:"NOT NULL"`

NumIssues int64 `xorm:"-"`
Expand All @@ -59,6 +62,19 @@ func (Column) TableName() string {
return "project_board" // TODO: the legacy table name should be project_column
}

func (c *Column) LoadProject(ctx context.Context) error {
if c.Project != nil {
return nil
}

project, err := GetProjectByID(ctx, c.ProjectID)
if err != nil {
return err
}
c.Project = project
return nil
}

func (c *Column) GetIssues(ctx context.Context) ([]*ProjectIssue, error) {
issues := make([]*ProjectIssue, 0, 5)
if err := db.GetEngine(ctx).Where("project_id=?", c.ProjectID).
Expand Down Expand Up @@ -213,6 +229,18 @@ func GetColumn(ctx context.Context, columnID int64) (*Column, error) {
return column, nil
}

func GetColumnByProjectIDAndColumnID(ctx context.Context, projectID, columnID int64) (*Column, error) {
column := new(Column)
has, err := db.GetEngine(ctx).Where("project_id=? AND id=?", projectID, columnID).Get(column)
if err != nil {
return nil, err
} else if !has {
return nil, ErrProjectColumnNotExist{ProjectID: projectID, ColumnID: columnID}
}

return column, nil
}

// UpdateColumn updates a project column
func UpdateColumn(ctx context.Context, column *Column) error {
var fieldToUpdate []string
Expand Down
23 changes: 22 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,8 @@ func (err ErrProjectNotExist) Unwrap() error {

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

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

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

Expand Down Expand Up @@ -302,6 +310,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
241 changes: 241 additions & 0 deletions models/project/workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// 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"
)

type WorkflowEvent string

const (
WorkflowEventItemOpened WorkflowEvent = "item_opened"
WorkflowEventItemAddedToProject WorkflowEvent = "item_added_to_project"
WorkflowEventItemRemovedFromProject WorkflowEvent = "item_removed_from_project"
WorkflowEventItemReopened WorkflowEvent = "item_reopened"
WorkflowEventItemClosed WorkflowEvent = "item_closed"
WorkflowEventItemColumnChanged WorkflowEvent = "item_column_changed"
WorkflowEventCodeChangesRequested WorkflowEvent = "code_changes_requested"
WorkflowEventCodeReviewApproved WorkflowEvent = "code_review_approved"
WorkflowEventPullRequestMerged WorkflowEvent = "pull_request_merged"
)

var workflowEvents = []WorkflowEvent{
WorkflowEventItemOpened,
WorkflowEventItemAddedToProject,
WorkflowEventItemRemovedFromProject,
WorkflowEventItemReopened,
WorkflowEventItemClosed,
WorkflowEventItemColumnChanged,
WorkflowEventCodeChangesRequested,
WorkflowEventCodeReviewApproved,
WorkflowEventPullRequestMerged,
}

func GetWorkflowEvents() []WorkflowEvent {
return workflowEvents
}

func IsValidWorkflowEvent(event string) bool {
for _, we := range workflowEvents {
if we.EventID() == event {
return true
}
}
return false
}

func (we WorkflowEvent) LangKey() string {
switch we {
case WorkflowEventItemOpened:
return "projects.workflows.event.item_opened"
case WorkflowEventItemAddedToProject:
return "projects.workflows.event.item_added_to_project"
case WorkflowEventItemRemovedFromProject:
return "projects.workflows.event.item_removed_from_project"
case WorkflowEventItemReopened:
return "projects.workflows.event.item_reopened"
case WorkflowEventItemClosed:
return "projects.workflows.event.item_closed"
case WorkflowEventItemColumnChanged:
return "projects.workflows.event.item_column_changed"
case WorkflowEventCodeChangesRequested:
return "projects.workflows.event.code_changes_requested"
case WorkflowEventCodeReviewApproved:
return "projects.workflows.event.code_review_approved"
case WorkflowEventPullRequestMerged:
return "projects.workflows.event.pull_request_merged"
default:
return string(we)
}
}

func (we WorkflowEvent) EventID() string {
return string(we)
}

type WorkflowFilterType string

const (
WorkflowFilterTypeIssueType WorkflowFilterType = "issue_type" // issue, pull_request, etc.
WorkflowFilterTypeSourceColumn WorkflowFilterType = "source_column" // source column for item_column_changed event
WorkflowFilterTypeTargetColumn WorkflowFilterType = "target_column" // target column for item_column_changed event
WorkflowFilterTypeLabels WorkflowFilterType = "labels" // filter by issue/PR labels
)

type WorkflowFilter struct {
Type WorkflowFilterType `json:"type"`
Value string `json:"value"`
}

type WorkflowActionType string

const (
WorkflowActionTypeColumn WorkflowActionType = "column" // add the item to the project's column
WorkflowActionTypeAddLabels WorkflowActionType = "add_labels" // choose one or more labels
WorkflowActionTypeRemoveLabels WorkflowActionType = "remove_labels" // choose one or more labels
WorkflowActionTypeIssueState WorkflowActionType = "issue_state" // change the issue state (reopen/close)
)

type WorkflowAction struct {
Type WorkflowActionType `json:"type"`
Value string `json:"value"`
}

// WorkflowEventCapabilities defines what filters and actions are available for each event
type WorkflowEventCapabilities struct {
AvailableFilters []WorkflowFilterType `json:"available_filters"`
AvailableActions []WorkflowActionType `json:"available_actions"`
}

// GetWorkflowEventCapabilities returns the capabilities for each workflow event
func GetWorkflowEventCapabilities() map[WorkflowEvent]WorkflowEventCapabilities {
return map[WorkflowEvent]WorkflowEventCapabilities{
WorkflowEventItemOpened: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels},
},
WorkflowEventItemAddedToProject: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState},
},
WorkflowEventItemRemovedFromProject: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels},
AvailableActions: []WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState},
},
WorkflowEventItemReopened: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels},
},
WorkflowEventItemClosed: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels},
},
WorkflowEventItemColumnChanged: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeSourceColumn, WorkflowFilterTypeTargetColumn, WorkflowFilterTypeLabels},
AvailableActions: []WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeIssueState},
},
WorkflowEventCodeChangesRequested: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeLabels}, // only applies to pull requests
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels},
},
WorkflowEventCodeReviewApproved: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeLabels}, // only applies to pull requests
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels},
},
WorkflowEventPullRequestMerged: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeLabels}, // only applies to pull requests
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels},
},
}
}

type Workflow struct {
ID int64
ProjectID int64 `xorm:"INDEX"`
Project *Project `xorm:"-"`
WorkflowEvent WorkflowEvent `xorm:"INDEX"`
WorkflowFilters []WorkflowFilter `xorm:"TEXT JSON"`
WorkflowActions []WorkflowAction `xorm:"TEXT JSON"`
Enabled bool `xorm:"DEFAULT true"`
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 FindWorkflowsByProjectID(ctx context.Context, projectID int64) ([]*Workflow, error) {
workflows := make([]*Workflow, 0)
if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&workflows); err != nil {
return nil, err
}
return workflows, 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, db.ErrNotExist{Resource: "ProjectWorkflow", ID: id}
}
return p, nil
}

func CreateWorkflow(ctx context.Context, wf *Workflow) error {
return db.Insert(ctx, wf)
}

func UpdateWorkflow(ctx context.Context, wf *Workflow) error {
_, err := db.GetEngine(ctx).ID(wf.ID).Cols("workflow_filters", "workflow_actions").Update(wf)
return err
}

func DeleteWorkflow(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(&Workflow{})
return err
}

func EnableWorkflow(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Cols("enabled").Update(&Workflow{Enabled: true})
return err
}

func DisableWorkflow(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Cols("enabled").Update(&Workflow{Enabled: false})
return err
}
Loading