pull/30205/merge
Lunny Xiao 2025-11-19 07:01:03 +01:00 committed by GitHub
commit 9fcff58efc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 5410 additions and 68 deletions

View File

@ -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
}

View File

@ -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"`
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{})
}

View File

@ -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:"-"`
@ -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).
@ -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

View File

@ -46,6 +46,7 @@ const (
type ErrProjectNotExist struct {
ID int64
RepoID int64
Name string
}
// IsErrProjectNotExist checks if an error is a ErrProjectNotExist
@ -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)
}
@ -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
@ -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)
}
@ -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)

241
models/project/workflows.go Normal file
View File

@ -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
}

View File

@ -0,0 +1,310 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
import (
"strconv"
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestIsValidWorkflowEvent(t *testing.T) {
tests := []struct {
event string
valid bool
}{
{string(WorkflowEventItemOpened), true},
{string(WorkflowEventItemClosed), true},
{string(WorkflowEventItemReopened), true},
{string(WorkflowEventItemAddedToProject), true},
{string(WorkflowEventItemRemovedFromProject), true},
{string(WorkflowEventItemColumnChanged), true},
{string(WorkflowEventCodeChangesRequested), true},
{string(WorkflowEventCodeReviewApproved), true},
{string(WorkflowEventPullRequestMerged), true},
{"invalid_event", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.event, func(t *testing.T) {
result := IsValidWorkflowEvent(tt.event)
assert.Equal(t, tt.valid, result, "Event: %s", tt.event)
})
}
}
func TestWorkflowEventLangKey(t *testing.T) {
tests := []struct {
event WorkflowEvent
langKey string
}{
{WorkflowEventItemOpened, "projects.workflows.event.item_opened"},
{WorkflowEventItemClosed, "projects.workflows.event.item_closed"},
{WorkflowEventItemReopened, "projects.workflows.event.item_reopened"},
{WorkflowEventItemAddedToProject, "projects.workflows.event.item_added_to_project"},
{WorkflowEventItemRemovedFromProject, "projects.workflows.event.item_removed_from_project"},
{WorkflowEventItemColumnChanged, "projects.workflows.event.item_column_changed"},
{WorkflowEventCodeChangesRequested, "projects.workflows.event.code_changes_requested"},
{WorkflowEventCodeReviewApproved, "projects.workflows.event.code_review_approved"},
{WorkflowEventPullRequestMerged, "projects.workflows.event.pull_request_merged"},
}
for _, tt := range tests {
t.Run(string(tt.event), func(t *testing.T) {
result := tt.event.LangKey()
assert.Equal(t, tt.langKey, result)
})
}
}
func TestGetWorkflowEventCapabilities(t *testing.T) {
capabilities := GetWorkflowEventCapabilities()
// Verify all events have capabilities
assert.Len(t, capabilities, 9, "Should have capabilities for all 9 workflow events")
// Test item_opened event
itemOpenedCap := capabilities[WorkflowEventItemOpened]
assert.Contains(t, itemOpenedCap.AvailableFilters, WorkflowFilterTypeIssueType)
assert.Contains(t, itemOpenedCap.AvailableFilters, WorkflowFilterTypeLabels)
assert.Contains(t, itemOpenedCap.AvailableActions, WorkflowActionTypeColumn)
assert.Contains(t, itemOpenedCap.AvailableActions, WorkflowActionTypeAddLabels)
// Test item_column_changed event (should have the most filters)
columnChangedCap := capabilities[WorkflowEventItemColumnChanged]
assert.Contains(t, columnChangedCap.AvailableFilters, WorkflowFilterTypeIssueType)
assert.Contains(t, columnChangedCap.AvailableFilters, WorkflowFilterTypeSourceColumn)
assert.Contains(t, columnChangedCap.AvailableFilters, WorkflowFilterTypeTargetColumn)
assert.Contains(t, columnChangedCap.AvailableFilters, WorkflowFilterTypeLabels)
// Test code review events (should not have issue state action)
codeReviewCap := capabilities[WorkflowEventCodeReviewApproved]
assert.NotContains(t, codeReviewCap.AvailableActions, WorkflowActionTypeIssueState)
}
func TestCreateWorkflow(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Get an existing project from fixtures
project := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
// Create a column for the project
column := &Column{
Title: "Test Column",
ProjectID: project.ID,
}
err := NewColumn(t.Context(), column)
assert.NoError(t, err)
// Create a workflow
workflow := &Workflow{
ProjectID: project.ID,
WorkflowEvent: WorkflowEventItemOpened,
WorkflowFilters: []WorkflowFilter{
{
Type: WorkflowFilterTypeIssueType,
Value: "issue",
},
},
WorkflowActions: []WorkflowAction{
{
Type: WorkflowActionTypeColumn,
Value: strconv.FormatInt(column.ID, 10),
},
},
Enabled: true,
}
err = CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
assert.NotZero(t, workflow.ID, "Workflow ID should be set after creation")
// Verify the workflow was created
createdWorkflow, err := GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.Equal(t, project.ID, createdWorkflow.ProjectID)
assert.Equal(t, WorkflowEventItemOpened, createdWorkflow.WorkflowEvent)
assert.True(t, createdWorkflow.Enabled)
assert.Len(t, createdWorkflow.WorkflowFilters, 1)
assert.Len(t, createdWorkflow.WorkflowActions, 1)
assert.Equal(t, WorkflowFilterTypeIssueType, createdWorkflow.WorkflowFilters[0].Type)
assert.Equal(t, "issue", createdWorkflow.WorkflowFilters[0].Value)
assert.Equal(t, WorkflowActionTypeColumn, createdWorkflow.WorkflowActions[0].Type)
}
func TestUpdateWorkflow(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Get an existing project from fixtures
project := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
// Create a workflow
workflow := &Workflow{
ProjectID: project.ID,
WorkflowEvent: WorkflowEventItemOpened,
WorkflowFilters: []WorkflowFilter{},
WorkflowActions: []WorkflowAction{},
Enabled: true,
}
err := CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
// Update the workflow
workflow.WorkflowFilters = []WorkflowFilter{
{
Type: WorkflowFilterTypeIssueType,
Value: "pull_request",
},
}
err = UpdateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
// Verify the update
updatedWorkflow, err := GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.True(t, updatedWorkflow.Enabled)
assert.Len(t, updatedWorkflow.WorkflowFilters, 1)
assert.Equal(t, "pull_request", updatedWorkflow.WorkflowFilters[0].Value)
}
func TestDeleteWorkflow(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Get an existing project from fixtures
project := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
// Create a workflow
workflow := &Workflow{
ProjectID: project.ID,
WorkflowEvent: WorkflowEventItemOpened,
WorkflowFilters: []WorkflowFilter{},
WorkflowActions: []WorkflowAction{},
Enabled: true,
}
err := CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
workflowID := workflow.ID
// Delete the workflow
err = DeleteWorkflow(t.Context(), workflowID)
assert.NoError(t, err)
// Verify it was deleted
_, err = GetWorkflowByID(t.Context(), workflowID)
assert.Error(t, err)
assert.True(t, db.IsErrNotExist(err), "Should return ErrNotExist")
}
func TestEnableDisableWorkflow(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Get an existing project from fixtures
project := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
// Create a workflow (enabled by default)
workflow := &Workflow{
ProjectID: project.ID,
WorkflowEvent: WorkflowEventItemOpened,
WorkflowFilters: []WorkflowFilter{},
WorkflowActions: []WorkflowAction{},
Enabled: true,
}
err := CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
// Test Disable
err = DisableWorkflow(t.Context(), workflow.ID)
assert.NoError(t, err)
disabledWorkflow, err := GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.False(t, disabledWorkflow.Enabled)
// Test Enable
err = EnableWorkflow(t.Context(), workflow.ID)
assert.NoError(t, err)
enabledWorkflow, err := GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.True(t, enabledWorkflow.Enabled)
}
func TestFindWorkflowsByProjectID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Get an existing project from fixtures
project := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
// Create multiple workflows
workflow1 := &Workflow{
ProjectID: project.ID,
WorkflowEvent: WorkflowEventItemOpened,
WorkflowFilters: []WorkflowFilter{},
WorkflowActions: []WorkflowAction{},
Enabled: true,
}
err := CreateWorkflow(t.Context(), workflow1)
assert.NoError(t, err)
workflow2 := &Workflow{
ProjectID: project.ID,
WorkflowEvent: WorkflowEventItemClosed,
WorkflowFilters: []WorkflowFilter{},
WorkflowActions: []WorkflowAction{},
Enabled: false,
}
err = CreateWorkflow(t.Context(), workflow2)
assert.NoError(t, err)
// Find all workflows for the project
workflows, err := FindWorkflowsByProjectID(t.Context(), project.ID)
assert.NoError(t, err)
assert.Len(t, workflows, 2)
// Verify the workflows
assert.Equal(t, WorkflowEventItemOpened, workflows[0].WorkflowEvent)
assert.True(t, workflows[0].Enabled)
assert.Equal(t, WorkflowEventItemClosed, workflows[1].WorkflowEvent)
assert.False(t, workflows[1].Enabled)
}
func TestWorkflowLoadProject(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Get an existing project from fixtures
project := unittest.AssertExistsAndLoadBean(t, &Project{ID: 1})
// Create a workflow
workflow := &Workflow{
ProjectID: project.ID,
WorkflowEvent: WorkflowEventItemOpened,
WorkflowFilters: []WorkflowFilter{},
WorkflowActions: []WorkflowAction{},
Enabled: true,
}
err := CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
// Get the workflow
loadedWorkflow, err := GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.Nil(t, loadedWorkflow.Project)
// Load the project
err = loadedWorkflow.LoadProject(t.Context())
assert.NoError(t, err)
assert.NotNil(t, loadedWorkflow.Project)
assert.Equal(t, project.ID, loadedWorkflow.Project.ID)
// Load again should not error
err = loadedWorkflow.LoadProject(t.Context())
assert.NoError(t, err)
}

View File

@ -561,8 +561,9 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
emailRegexp: regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"),
systemUserNewFuncs: map[int64]func() *User{
GhostUserID: NewGhostUser,
ActionsUserID: NewActionsUser,
GhostUserID: NewGhostUser,
ActionsUserID: NewActionsUser,
ProjectWorkflowsUserID: NewProjectWorkflowsUser,
},
}
})

View File

@ -36,6 +36,8 @@ func GetPossibleUserFromMap(userID int64, usererMaps map[int64]*User) *User {
return NewGhostUser()
case ActionsUserID:
return NewActionsUser()
case ProjectWorkflowsUserID:
return NewProjectWorkflowsUser()
case 0:
return nil
default:

View File

@ -65,6 +65,37 @@ func (u *User) IsGiteaActions() bool {
return u != nil && u.ID == ActionsUserID
}
const (
ProjectWorkflowsUserID int64 = -3
ProjectWorkflowsUserName = "project-workflows"
ProjectWorkflowsUserEmail = "workflows@gitea.io"
)
func IsProjectWorkflowsUserName(name string) bool {
return strings.EqualFold(name, ProjectWorkflowsUserName)
}
// NewProjectWorkflowsUser creates and returns a fake user for running the project workflows.
func NewProjectWorkflowsUser() *User {
return &User{
ID: ProjectWorkflowsUserID,
Name: ProjectWorkflowsUserName,
LowerName: ProjectWorkflowsUserName,
IsActive: true,
FullName: "Project Workflows",
Email: ProjectWorkflowsUserEmail,
KeepEmailPrivate: true,
LoginName: ProjectWorkflowsUserName,
Type: UserTypeBot,
AllowCreateOrganization: true,
Visibility: structs.VisibleTypePublic,
}
}
func (u *User) IsProjectWorkflows() bool {
return u != nil && u.ID == ProjectWorkflowsUserID
}
func GetSystemUserByName(name string) *User {
if IsGhostUserName(name) {
return NewGhostUser()
@ -72,5 +103,8 @@ func GetSystemUserByName(name string) *User {
if IsGiteaActionsUserName(name) {
return NewActionsUser()
}
if IsProjectWorkflowsUserName(name) {
return NewProjectWorkflowsUser()
}
return nil
}

View File

@ -25,6 +25,13 @@ func TestSystemUser(t *testing.T) {
assert.True(t, u.IsGiteaActions())
assert.True(t, IsGiteaActionsUserName("Gitea-actionS"))
_, err = GetPossibleUserByID(t.Context(), -3)
u, err = GetPossibleUserByID(t.Context(), -3)
require.NoError(t, err)
assert.Equal(t, "project-workflows", u.Name)
assert.Equal(t, "project-workflows", u.LowerName)
assert.True(t, u.IsProjectWorkflows())
assert.True(t, IsProjectWorkflowsUserName("Project-Workflows"))
_, err = GetPossibleUserByID(t.Context(), -4)
require.Error(t, err)
}

View File

@ -3930,7 +3930,56 @@ type-1.display_name = Individual Project
type-2.display_name = Repository Project
type-3.display_name = Organization Project
enter_fullscreen = Fullscreen
workflows = Workflows
exit_fullscreen = Exit Fullscreen
workflows.event.item_opened = Item opened
workflows.event.item_added_to_project = Item added to project
workflows.event.item_removed_from_project = Item removed from project
workflows.event.item_reopened = Item reopened
workflows.event.item_closed = Item closed
workflows.event.item_column_changed = Item column changed
workflows.event.code_changes_requested = Code changes requested
workflows.event.code_review_approved = Code review approved
workflows.event.pull_request_merged = Pull request merged
workflows.view_workflow_configuration = View workflow configuration
workflows.configure_workflow = Configure automated actions for this workflow
workflows.when = When
workflows.run_when = This workflow will run when:
workflows.filters = Filters
workflows.apply_to = Apply to
workflows.when_moved_from_column = When moved from column
workflows.when_moved_to_column = When moved to column
workflows.only_if_has_labels = Only if has labels
workflows.default_workflows = Default Workflows
workflows.actions = Actions
workflows.move_to_column = Move to column
workflows.add_labels = Add labels
workflows.remove_labels = Remove labels
workflows.any_label = Any label
workflows.any_column = Any column
workflows.issue_state = Issue state
workflows.none = None
workflows.no_change = No change
workflows.edit = Edit
workflows.delete = Delete
workflows.save = Save
workflows.clone = Clone
workflows.cancel = Cancel
workflows.disable = Disable
workflows.disabled = Disabled
workflows.enable = Enable
workflows.enabled = Enabled
workflows.issues_and_pull_requests = Issues and Pull Requests
workflows.issues_only = Issues only
workflows.pull_requests_only = Pull Requests only
workflows.select_column = Select column ...
workflows.close_issue = Close issue
workflows.reopen_issue = Reopen issue
workflows.save_workflow_failed = Failed to save workflow
workflows.update_workflow_failed = Failed to update workflow status
workflows.delete_workflow_failed = Failed to delete workflow
workflows.at_least_one_action_required = At least one action must be configured
workflows.error.at_least_one_action = At least one action must be configured
[git.filemode]
changed_filemode = %[1]s → %[2]s

View File

@ -0,0 +1,624 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package projects
import (
"io"
"net/http"
"strconv"
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
project_service "code.gitea.io/gitea/services/projects"
)
var (
tmplRepoWorkflows = templates.TplName("repo/projects/workflows")
tmplOrgWorkflows = templates.TplName("org/projects/workflows")
)
// convertFormToFilters converts form filters to WorkflowFilter objects
func convertFormToFilters(formFilters map[string]any) []project_model.WorkflowFilter {
filters := make([]project_model.WorkflowFilter, 0)
for key, value := range formFilters {
switch key {
case "labels":
// Handle labels array
if labelInterfaces, ok := value.([]any); ok && len(labelInterfaces) > 0 {
for _, labelInterface := range labelInterfaces {
if label, ok := labelInterface.(string); ok && label != "" {
filters = append(filters, project_model.WorkflowFilter{
Type: project_model.WorkflowFilterTypeLabels,
Value: label,
})
}
}
}
default:
// Handle string values (issue_type, column)
if strValue, ok := value.(string); ok && strValue != "" {
filters = append(filters, project_model.WorkflowFilter{
Type: project_model.WorkflowFilterType(key),
Value: strValue,
})
}
}
}
return filters
}
// convertFormToActions converts form actions to WorkflowAction objects
func convertFormToActions(formActions map[string]any) []project_model.WorkflowAction {
actions := make([]project_model.WorkflowAction, 0)
for key, value := range formActions {
switch key {
case "column":
if floatValue, ok := value.(string); ok {
floatValueInt, _ := strconv.ParseInt(floatValue, 10, 64)
if floatValueInt > 0 {
actions = append(actions, project_model.WorkflowAction{
Type: project_model.WorkflowActionTypeColumn,
Value: strconv.FormatInt(floatValueInt, 10),
})
}
}
case "add_labels":
// Handle both []string and []any from JSON unmarshaling
if labels, ok := value.([]string); ok && len(labels) > 0 {
for _, label := range labels {
if label != "" {
actions = append(actions, project_model.WorkflowAction{
Type: project_model.WorkflowActionTypeAddLabels,
Value: label,
})
}
}
} else if labelInterfaces, ok := value.([]any); ok && len(labelInterfaces) > 0 {
for _, labelInterface := range labelInterfaces {
if label, ok := labelInterface.(string); ok && label != "" {
actions = append(actions, project_model.WorkflowAction{
Type: project_model.WorkflowActionTypeAddLabels,
Value: label,
})
}
}
}
case "remove_labels":
// Handle both []string and []any from JSON unmarshaling
if labels, ok := value.([]string); ok && len(labels) > 0 {
for _, label := range labels {
if label != "" {
actions = append(actions, project_model.WorkflowAction{
Type: project_model.WorkflowActionTypeRemoveLabels,
Value: label,
})
}
}
} else if labelInterfaces, ok := value.([]any); ok && len(labelInterfaces) > 0 {
for _, labelInterface := range labelInterfaces {
if label, ok := labelInterface.(string); ok && label != "" {
actions = append(actions, project_model.WorkflowAction{
Type: project_model.WorkflowActionTypeRemoveLabels,
Value: label,
})
}
}
}
case "issue_state":
if strValue, ok := value.(string); ok {
v := strings.ToLower(strValue)
if v == "close" || v == "reopen" {
actions = append(actions, project_model.WorkflowAction{
Type: project_model.WorkflowActionTypeIssueState,
Value: v,
})
}
}
}
}
return actions
}
func WorkflowsEvents(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.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
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, projectID)
if err != nil {
ctx.ServerError("FindWorkflowsByProjectID", err)
return
}
type WorkflowConfig struct {
ID int64 `json:"id"`
EventID string `json:"event_id"`
DisplayName string `json:"display_name"`
WorkflowEvent string `json:"workflow_event"` // The workflow event
Capabilities project_model.WorkflowEventCapabilities `json:"capabilities"`
Filters []project_model.WorkflowFilter `json:"filters"`
Actions []project_model.WorkflowAction `json:"actions"`
Summary string `json:"summary"` // Human readable filter description
Enabled bool `json:"enabled"`
IsConfigured bool `json:"isConfigured"` // Whether this workflow is configured/saved
}
outputWorkflows := make([]*WorkflowConfig, 0)
events := project_model.GetWorkflowEvents()
capabilities := project_model.GetWorkflowEventCapabilities()
// Create a map for quick lookup of existing workflows
workflowMap := make(map[project_model.WorkflowEvent][]*project_model.Workflow)
for _, wf := range workflows {
workflowMap[wf.WorkflowEvent] = append(workflowMap[wf.WorkflowEvent], wf)
}
for _, event := range events {
existingWorkflows := workflowMap[event]
if len(existingWorkflows) > 0 {
// Add all existing workflows for this event
for _, wf := range existingWorkflows {
workflowSummary := project_service.GetWorkflowSummary(ctx, wf)
outputWorkflows = append(outputWorkflows, &WorkflowConfig{
ID: wf.ID,
EventID: strconv.FormatInt(wf.ID, 10),
DisplayName: string(ctx.Tr(wf.WorkflowEvent.LangKey())),
WorkflowEvent: string(wf.WorkflowEvent),
Capabilities: capabilities[event],
Filters: wf.WorkflowFilters,
Actions: wf.WorkflowActions,
Summary: workflowSummary,
Enabled: wf.Enabled,
IsConfigured: true,
})
}
} else {
// Add placeholder for creating new workflow
outputWorkflows = append(outputWorkflows, &WorkflowConfig{
ID: 0,
EventID: event.EventID(),
DisplayName: string(ctx.Tr(event.LangKey())),
WorkflowEvent: string(event),
Capabilities: capabilities[event],
Summary: "",
Enabled: true, // Default to enabled for new workflows
IsConfigured: false,
})
}
}
ctx.JSON(http.StatusOK, outputWorkflows)
}
func WorkflowsColumns(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.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
}
columns, err := p.GetColumns(ctx)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return
}
type Column struct {
ID int64 `json:"id"`
Title string `json:"title"`
Color string `json:"color"`
}
outputColumns := make([]*Column, 0, len(columns))
for _, col := range columns {
outputColumns = append(outputColumns, &Column{
ID: col.ID,
Title: col.Title,
Color: col.Color,
})
}
ctx.JSON(http.StatusOK, outputColumns)
}
func WorkflowsLabels(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
}
// Only repository projects have access to labels
if p.Type != project_model.TypeRepository {
ctx.JSON(http.StatusOK, []any{})
return
}
if p.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
// Get repository labels
labels, err := issues_model.GetLabelsByRepoID(ctx, p.RepoID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return
}
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
ExclusiveOrder int `json:"exclusiveOrder"`
}
outputLabels := make([]*Label, 0, len(labels))
for _, label := range labels {
outputLabels = append(outputLabels, &Label{
ID: label.ID,
Name: label.Name,
Color: label.Color,
Description: label.Description,
Exclusive: label.Exclusive,
ExclusiveOrder: label.ExclusiveOrder,
})
}
ctx.JSON(http.StatusOK, outputLabels)
}
func Workflows(ctx *context.Context) {
workflowIDStr := ctx.PathParam("workflow_id")
if workflowIDStr == "events" {
WorkflowsEvents(ctx)
return
}
if workflowIDStr == "columns" {
WorkflowsColumns(ctx)
return
}
if workflowIDStr == "labels" {
WorkflowsLabels(ctx)
return
}
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["IsProjectsPage"] = true
ctx.Data["Project"] = p
workflows, err := project_model.FindWorkflowsByProjectID(ctx, projectID)
if err != nil {
ctx.ServerError("FindWorkflowsByProjectID", err)
return
}
for _, wf := range workflows {
wf.Project = p
}
ctx.Data["Workflows"] = workflows
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
ctx.Data["ProjectLink"] = project_model.ProjectLinkForRepo(ctx.Repo.Repository, projectID)
if p.Type == project_model.TypeRepository {
ctx.HTML(200, tmplRepoWorkflows)
} else {
ctx.HTML(200, tmplOrgWorkflows)
}
}
type WorkflowsPostForm struct {
EventID string `json:"event_id"`
Filters map[string]any `json:"filters"`
Actions map[string]any `json:"actions"`
}
// WorkflowsPost handles creating or updating a workflow
func WorkflowsPost(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.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
}
// Handle both form data and JSON data
// Handle JSON data
form := &WorkflowsPostForm{}
content, err := io.ReadAll(ctx.Req.Body)
if err != nil {
ctx.ServerError("ReadRequestBody", err)
return
}
defer ctx.Req.Body.Close()
log.Trace("get " + string(content))
if err := json.Unmarshal(content, &form); err != nil {
ctx.ServerError("DecodeWorkflowsPostForm", err)
return
}
if form.EventID == "" {
ctx.JSON(http.StatusBadRequest, map[string]any{"error": "InvalidEventID", "message": "EventID is required"})
return
}
// Convert form data to filters and actions
filters := convertFormToFilters(form.Filters)
actions := convertFormToActions(form.Actions)
// Validate: at least one action must be configured
if len(actions) == 0 {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "NoActions",
"message": ctx.Tr("projects.workflows.error.at_least_one_action"),
})
return
}
eventID, _ := strconv.ParseInt(form.EventID, 10, 64)
if eventID == 0 {
// check if workflow event is valid
if !project_model.IsValidWorkflowEvent(form.EventID) {
ctx.JSON(http.StatusBadRequest, map[string]any{"error": "EventID is invalid"})
return
}
// Create a new workflow for the given event
wf := &project_model.Workflow{
ProjectID: projectID,
WorkflowEvent: project_model.WorkflowEvent(form.EventID),
WorkflowFilters: filters,
WorkflowActions: actions,
Enabled: true, // New workflows are enabled by default
}
if err := project_model.CreateWorkflow(ctx, wf); err != nil {
ctx.ServerError("CreateWorkflow", err)
return
}
// Return the newly created workflow with filter summary
workflowSummary := project_service.GetWorkflowSummary(ctx, wf)
ctx.JSON(http.StatusOK, map[string]any{
"success": true,
"workflow": map[string]any{
"id": wf.ID,
"event_id": strconv.FormatInt(wf.ID, 10),
"display_name": string(ctx.Tr(wf.WorkflowEvent.LangKey())),
"filters": wf.WorkflowFilters,
"actions": wf.WorkflowActions,
"summary": workflowSummary,
"enabled": wf.Enabled,
},
})
} else {
// Update an existing workflow
wf, err := project_model.GetWorkflowByID(ctx, eventID)
if err != nil {
ctx.ServerError("GetWorkflowByID", err)
return
}
if wf.ProjectID != projectID {
ctx.NotFound(nil)
return
}
wf.WorkflowFilters = filters
wf.WorkflowActions = actions
if err := project_model.UpdateWorkflow(ctx, wf); err != nil {
ctx.ServerError("UpdateWorkflow", err)
return
}
// Return the updated workflow with filter summary
workflowSummary := project_service.GetWorkflowSummary(ctx, wf)
ctx.JSON(http.StatusOK, map[string]any{
"success": true,
"workflow": map[string]any{
"id": wf.ID,
"event_id": strconv.FormatInt(wf.ID, 10),
"display_name": string(ctx.Tr(wf.WorkflowEvent.LangKey())),
"filters": wf.WorkflowFilters,
"actions": wf.WorkflowActions,
"summary": workflowSummary,
"enabled": wf.Enabled,
},
})
}
}
func WorkflowsStatus(ctx *context.Context) {
projectID := ctx.PathParamInt64("id")
workflowID, _ := strconv.ParseInt(ctx.PathParam("workflow_id"), 10, 64)
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
}
wf, err := project_model.GetWorkflowByID(ctx, workflowID)
if err != nil {
ctx.ServerError("GetWorkflowByID", err)
return
}
if wf.ProjectID != projectID {
ctx.NotFound(nil)
return
}
// Get enabled status from form
_ = ctx.Req.ParseForm()
enabledStr := ctx.Req.FormValue("enabled")
enabled, _ := strconv.ParseBool(enabledStr)
if enabled {
if err := project_model.EnableWorkflow(ctx, workflowID); err != nil {
ctx.ServerError("EnableWorkflow", err)
return
}
} else {
if err := project_model.DisableWorkflow(ctx, workflowID); err != nil {
ctx.ServerError("DisableWorkflow", err)
return
}
}
ctx.JSON(http.StatusOK, map[string]any{
"success": true,
"enabled": wf.Enabled,
})
}
func WorkflowsDelete(ctx *context.Context) {
projectID := ctx.PathParamInt64("id")
workflowID, _ := strconv.ParseInt(ctx.PathParam("workflow_id"), 10, 64)
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
}
wf, err := project_model.GetWorkflowByID(ctx, workflowID)
if err != nil {
if db.IsErrNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetWorkflowByID", err)
}
return
}
if wf.ProjectID != projectID {
ctx.NotFound(nil)
return
}
if err := project_model.DeleteWorkflow(ctx, workflowID); err != nil {
ctx.ServerError("DeleteWorkflow", err)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"success": true,
})
}

View File

@ -27,6 +27,7 @@ import (
shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
issues_servie "code.gitea.io/gitea/services/issue"
project_service "code.gitea.io/gitea/services/projects"
)
@ -446,7 +447,7 @@ func UpdateIssueProject(ctx *context.Context) {
if issue.Project != nil && issue.Project.ID == projectID {
continue
}
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
if err := issues_servie.AssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil {
if errors.Is(err, util.ErrPermissionDenied) {
continue
}
@ -684,6 +685,7 @@ func MoveIssues(ctx *context.Context) {
form := &movedIssuesForm{}
if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
ctx.ServerError("DecodeMovedIssuesForm", err)
return
}
issueIDs := make([]int64, 0, len(form.Issues))

View File

@ -1430,6 +1430,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
return
}
// FIXME: this should be moved in the function NewPullRequest
if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) {
if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil {
if !errors.Is(err, util.ErrPermissionDenied) {

View File

@ -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"
@ -1037,7 +1038,14 @@ func registerWebRoutes(m *web.Router) {
m.Get("", org.Projects)
m.Get("/{id}", org.ViewProject)
}, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true))
m.Group("", func() { //nolint:dupl // duplicates lines 1421-1441
m.Group("/{id}/workflows", func() {
m.Get("", projects.Workflows)
m.Get("/{workflow_id}", projects.Workflows)
m.Post("/{workflow_id}", projects.WorkflowsPost)
m.Post("/{workflow_id}/status", projects.WorkflowsStatus)
m.Post("/{workflow_id}/delete", projects.WorkflowsDelete)
}, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true))
m.Group("", func() {
m.Get("/new", org.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
m.Group("/{id}", func() {
@ -1435,7 +1443,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{username}/{reponame}/projects", func() {
m.Get("", repo.Projects)
m.Get("/{id}", repo.ViewProject)
m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054
m.Group("", func() {
m.Get("/new", repo.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
m.Group("/{id}", func() {
@ -1454,6 +1462,14 @@ func registerWebRoutes(m *web.Router) {
m.Post("/default", repo.SetDefaultProjectColumn)
m.Post("/move", repo.MoveIssues)
})
m.Group("/workflows", func() {
m.Get("", projects.Workflows)
m.Get("/{workflow_id}", projects.Workflows)
m.Post("/{workflow_id}", projects.WorkflowsPost)
m.Post("/{workflow_id}/status", projects.WorkflowsStatus)
m.Post("/{workflow_id}/delete", projects.WorkflowsDelete)
})
})
}, reqRepoProjectsWriter, context.RepoMustNotBeArchived())
}, optSignIn, context.RepoAssignment, reqRepoProjectsReader, repo.MustEnableRepoProjects)

View File

@ -8,7 +8,6 @@ import (
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
user_model "code.gitea.io/gitea/models/user"
notify_service "code.gitea.io/gitea/services/notify"
)
@ -47,21 +46,6 @@ func AddLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.
// RemoveLabel removes a label from issue by given ID.
func RemoveLabel(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, label *issues_model.Label) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if err := issue.LoadRepo(ctx); err != nil {
return err
}
perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
if err != nil {
return err
}
if !perm.CanWriteIssuesOrPulls(issue.IsPull) {
if label.OrgID > 0 {
return issues_model.ErrOrgLabelNotExist{}
}
return issues_model.ErrRepoLabelNotExist{}
}
return issues_model.DeleteIssueLabel(ctx, issue, label, doer)
}); err != nil {
return err
@ -85,3 +69,29 @@ func ReplaceLabels(ctx context.Context, issue *issues_model.Issue, doer *user_mo
notify_service.IssueChangeLabels(ctx, doer, issue, labels, old)
return nil
}
func AddRemoveLabels(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, toAddLabels, toRemoveLabels []*issues_model.Label) error {
if len(toAddLabels) == 0 && len(toRemoveLabels) == 0 {
return nil
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
if len(toAddLabels) > 0 {
if err := issues_model.NewIssueLabels(ctx, issue, toAddLabels, doer); err != nil {
return err
}
}
for _, label := range toRemoveLabels {
if err := issues_model.DeleteIssueLabel(ctx, issue, label, doer); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
notify_service.IssueChangeLabels(ctx, doer, issue, toAddLabels, toRemoveLabels)
return nil
}

View File

@ -59,3 +59,38 @@ func TestIssue_AddLabel(t *testing.T) {
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: test.labelID})
}
}
func TestIssue_AddRemoveLabels(t *testing.T) {
tests := []struct {
issueID int64
toAddIDs []int64
toRemoveIDs []int64
doerID int64
}{
{1, []int64{2}, []int64{1}, 2}, // now there are both 1 and 2
{1, []int64{}, []int64{1, 2}, 2}, // no label left
{1, []int64{1, 2}, []int64{}, 2}, // add them back
{1, []int64{}, []int64{}, 2}, // no-op
}
for _, test := range tests {
assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: test.issueID})
toAddLabels := make([]*issues_model.Label, len(test.toAddIDs))
for i, labelID := range test.toAddIDs {
toAddLabels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID})
}
toRemoveLabels := make([]*issues_model.Label, len(test.toRemoveIDs))
for i, labelID := range test.toRemoveIDs {
toRemoveLabels[i] = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID})
}
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.doerID})
assert.NoError(t, AddRemoveLabels(t.Context(), issue, doer, toAddLabels, toRemoveLabels))
for _, labelID := range test.toAddIDs {
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: labelID})
}
for _, labelID := range test.toRemoveIDs {
unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: test.issueID, LabelID: labelID})
}
}
}

31
services/issue/project.go Normal file
View File

@ -0,0 +1,31 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issue
import (
"context"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/services/notify"
)
func AssignOrRemoveProject(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, projectID int64, position int) error {
if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, doer, projectID, 0); err != nil {
return err
}
var newProject *project_model.Project
var err error
if projectID > 0 {
newProject, err = project_model.GetProjectByID(ctx, projectID)
if err != nil {
return err
}
}
notify.IssueChangeProjects(ctx, doer, issue, newProject)
return nil
}

View File

@ -10,6 +10,7 @@ import (
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
packages_model "code.gitea.io/gitea/models/packages"
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"
@ -41,6 +42,8 @@ type Notifier interface {
IssueChangeRef(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldRef string)
IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue,
addedLabels, removedLabels []*issues_model.Label)
IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newProject *project_model.Project)
IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldColumnID, newColumnID int64)
NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User)
MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest)

View File

@ -10,6 +10,7 @@ import (
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
packages_model "code.gitea.io/gitea/models/packages"
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"
@ -274,6 +275,19 @@ func IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues
}
}
// IssueChangeProjects notifies change projects to notifiers
func IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newProject *project_model.Project) {
for _, notifier := range notifiers {
notifier.IssueChangeProjects(ctx, doer, issue, newProject)
}
}
func IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldColumnID, newColumnID int64) {
for _, notifier := range notifiers {
notifier.IssueChangeProjectColumn(ctx, doer, issue, oldColumnID, newColumnID)
}
}
// CreateRepository notifies create repository to notifiers
func CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
for _, notifier := range notifiers {

View File

@ -10,6 +10,7 @@ import (
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
packages_model "code.gitea.io/gitea/models/packages"
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"
@ -143,6 +144,12 @@ func (*NullNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.Use
addedLabels, removedLabels []*issues_model.Label) {
}
func (*NullNotifier) IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newProject *project_model.Project) {
}
func (*NullNotifier) IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldColumnID, newColumnID int64) {
}
// CreateRepository places a place holder function
func (*NullNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
}

View File

@ -1,7 +1,7 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
package projects
import (
"context"
@ -12,30 +12,33 @@ import (
project_model "code.gitea.io/gitea/models/project"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/services/notify"
)
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error {
issueIDs := make([]int64, 0, len(sortedIssueIDs))
for _, issueID := range sortedIssueIDs {
issueIDs = append(issueIDs, issueID)
}
count, err := db.GetEngine(ctx).
Where("project_id=?", column.ProjectID).
In("issue_id", issueIDs).
Count(new(project_model.ProjectIssue))
if err != nil {
return err
}
if int(count) != len(sortedIssueIDs) {
return errors.New("all issues have to be added to a project first")
}
issueIDs := make([]int64, 0, len(sortedIssueIDs))
for _, issueID := range sortedIssueIDs {
issueIDs = append(issueIDs, issueID)
}
count, err := db.GetEngine(ctx).
Where("project_id=?", column.ProjectID).
In("issue_id", issueIDs).
Count(new(project_model.ProjectIssue))
if err != nil {
return err
}
if int(count) != len(sortedIssueIDs) {
return errors.New("all issues have to be added to a project first")
}
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
return err
}
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil {
return err
}
oldColumnIDsMap := make(map[int64]int64, len(issues))
if err := db.WithTx(ctx, func(ctx context.Context) error {
if _, err := issues.LoadRepositories(ctx); err != nil {
return err
}
@ -60,6 +63,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
if err != nil {
return err
}
oldColumnIDsMap[issueID] = projectColumnID
if projectColumnID != column.ID {
// add timeline to issue
@ -83,7 +87,15 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
}
}
return nil
})
}); err != nil {
return err
}
for _, issue := range issues {
notify.IssueChangeProjectColumn(ctx, doer, issue, oldColumnIDsMap[issue.ID], column.ID)
}
return nil
}
// LoadIssuesFromProject load issues assigned to each project column inside the given project
@ -205,3 +217,43 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj
return nil
}
func MoveIssueToAnotherColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newColumn *project_model.Column) error {
oldColumnID, err := issue.ProjectColumnID(ctx)
if err != nil {
return err
}
if oldColumnID == newColumn.ID {
return nil
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
if _, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id=? WHERE issue_id=?", newColumn.ID, issue.ID); err != nil {
return err
}
if err := newColumn.LoadProject(ctx); err != nil {
return err
}
// add timeline to issue
if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeProjectColumn,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
ProjectID: newColumn.ProjectID,
ProjectTitle: newColumn.Project.Title,
ProjectColumnID: newColumn.ID,
ProjectColumnTitle: newColumn.Title,
}); err != nil {
return err
}
return nil
}); err != nil {
return err
}
notify.IssueChangeProjectColumn(ctx, doer, issue, oldColumnID, newColumn.ID)
return nil
}

View File

@ -1,7 +1,7 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
package projects
import (
"testing"

View File

@ -1,7 +1,7 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package project
package projects
import (
"testing"

View File

@ -0,0 +1,94 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package projects
import (
"context"
"strconv"
"strings"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/log"
)
// GetWorkflowSummary returns a human-readable summary of the workflow
func GetWorkflowSummary(ctx context.Context, wf *project_model.Workflow) string {
filters := wf.WorkflowFilters
if len(filters) == 0 {
return ""
}
var summary strings.Builder
labelIDs := make([]int64, 0)
for _, filter := range filters {
switch filter.Type {
case project_model.WorkflowFilterTypeIssueType:
switch filter.Value {
case "issue":
if summary.Len() > 0 {
summary.WriteString(" ")
}
summary.WriteString("(Issues only)")
case "pull_request":
if summary.Len() > 0 {
summary.WriteString(" ")
}
summary.WriteString("(Pull requests only)")
}
case project_model.WorkflowFilterTypeSourceColumn:
columnID, _ := strconv.ParseInt(filter.Value, 10, 64)
if columnID <= 0 {
continue
}
col, err := project_model.GetColumn(ctx, columnID)
if err != nil {
log.Error("GetColumn: %v", err)
continue
}
if summary.Len() > 0 {
summary.WriteString(" ")
}
summary.WriteString("(Source: " + col.Title + ")")
case project_model.WorkflowFilterTypeTargetColumn:
columnID, _ := strconv.ParseInt(filter.Value, 10, 64)
if columnID <= 0 {
continue
}
col, err := project_model.GetColumn(ctx, columnID)
if err != nil {
log.Error("GetColumn: %v", err)
continue
}
if summary.Len() > 0 {
summary.WriteString(" ")
}
summary.WriteString("(Target: " + col.Title + ")")
case project_model.WorkflowFilterTypeLabels:
labelID, _ := strconv.ParseInt(filter.Value, 10, 64)
if labelID > 0 {
labelIDs = append(labelIDs, labelID)
}
}
}
if len(labelIDs) > 0 {
labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs)
if err != nil {
log.Error("GetLabelsByIDs: %v", err)
} else {
if summary.Len() > 0 {
summary.WriteString(" ")
}
summary.WriteString("(Labels: ")
for i, label := range labels {
summary.WriteString(label.Name)
if i < len(labels)-1 {
summary.WriteString(", ")
}
}
summary.WriteString(")")
}
}
return summary.String()
}

View File

@ -0,0 +1,417 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package projects
import (
"context"
"strconv"
"strings"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
issue_service "code.gitea.io/gitea/services/issue"
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 (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
}
if err := issue.LoadProject(ctx); err != nil {
log.Error("NewIssue: LoadProject: %v", err)
return
}
if issue.Project == nil {
// TODO: handle item opened
return
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("NewIssue: FindWorkflowsByProjectID: %v", err)
return
}
// Find workflows for the ItemOpened event
for _, workflow := range workflows {
if workflow.WorkflowEvent == project_model.WorkflowEventItemOpened {
fireIssueWorkflow(ctx, workflow, issue, 0, 0)
}
}
}
func (m *workflowNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("NewIssue: LoadIssue: %v", err)
return
}
issue := pr.Issue
m.NewIssue(ctx, issue, mentions)
}
func (m *workflowNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) {
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
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err)
return
}
workflowEvent := util.Iif(isClosed, project_model.WorkflowEventItemClosed, project_model.WorkflowEventItemReopened)
// Find workflows for the specific event
for _, workflow := range workflows {
if workflow.WorkflowEvent == workflowEvent {
fireIssueWorkflow(ctx, workflow, issue, 0, 0)
}
}
}
func (*workflowNotifier) IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newProject *project_model.Project) {
if newProject == nil { // removed from project
if err := issue.LoadProject(ctx); err != nil {
log.Error("LoadProject: %v", err)
return
}
if issue.Project == nil {
return
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err)
return
}
// Find workflows for the ItemOpened event
for _, workflow := range workflows {
if workflow.WorkflowEvent == project_model.WorkflowEventItemRemovedFromProject {
fireIssueWorkflow(ctx, workflow, issue, 0, 0)
}
}
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 || issue.Project.ID != newProject.ID {
return
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err)
return
}
// Find workflows for the ItemOpened event
for _, workflow := range workflows {
if workflow.WorkflowEvent == project_model.WorkflowEventItemAddedToProject {
fireIssueWorkflow(ctx, workflow, issue, 0, 0)
}
}
}
func (*workflowNotifier) IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldColumnID, newColumnID int64) {
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
}
newColumn, err := project_model.GetColumn(ctx, newColumnID)
if err != nil {
log.Error("IssueChangeProjectColumn: GetColumn: %v", err)
return
}
if issue.Project == nil || issue.Project.ID != newColumn.ProjectID {
return
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err)
return
}
// Find workflows for the ItemColumnChanged event
for _, workflow := range workflows {
if workflow.WorkflowEvent == project_model.WorkflowEventItemColumnChanged {
fireIssueWorkflow(ctx, workflow, issue, oldColumnID, newColumnID)
}
}
}
func (*workflowNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("NewIssue: LoadIssue: %v", err)
return
}
issue := pr.Issue
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
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err)
return
}
// Find workflows for the PullRequestMerged event
for _, workflow := range workflows {
if workflow.WorkflowEvent == project_model.WorkflowEventPullRequestMerged {
fireIssueWorkflow(ctx, workflow, issue, 0, 0)
}
}
}
func (m *workflowNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
m.MergePullRequest(ctx, doer, pr)
}
func (*workflowNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("NewIssue: LoadIssue: %v", err)
return
}
issue := pr.Issue
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
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err)
return
}
// Find workflows for the PullRequestMerged event
for _, workflow := range workflows {
if (workflow.WorkflowEvent == project_model.WorkflowEventCodeChangesRequested && review.Type == issues_model.ReviewTypeReject) ||
(workflow.WorkflowEvent == project_model.WorkflowEventCodeReviewApproved && review.Type == issues_model.ReviewTypeApprove) {
fireIssueWorkflow(ctx, workflow, issue, 0, 0)
}
}
}
func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, issue *issues_model.Issue, sourceColumnID, targetColumnID int64) {
if !workflow.Enabled {
return
}
// Load issue labels for labels filter
if err := issue.LoadLabels(ctx); err != nil {
log.Error("LoadLabels: %v", err)
return
}
if !matchWorkflowsFilters(workflow, issue, sourceColumnID, targetColumnID) {
return
}
executeWorkflowActions(ctx, workflow, issue)
}
// matchWorkflowsFilters checks if the issue matches all filters of the workflow
func matchWorkflowsFilters(workflow *project_model.Workflow, issue *issues_model.Issue, sourceColumnID, targetColumnID int64) bool {
for _, filter := range workflow.WorkflowFilters {
switch filter.Type {
case project_model.WorkflowFilterTypeIssueType:
// If filter value is empty, match all types
if filter.Value == "" {
continue
}
// Filter value can be "issue" or "pull_request"
if filter.Value == "issue" && issue.IsPull {
return false
}
if filter.Value == "pull_request" && !issue.IsPull {
return false
}
case project_model.WorkflowFilterTypeTargetColumn:
// If filter value is empty, match all columns
if filter.Value == "" {
continue
}
filterColumnID, _ := strconv.ParseInt(filter.Value, 10, 64)
if filterColumnID == 0 {
log.Error("Invalid column ID: %s", filter.Value)
return false
}
// For column changed event, check against the new column ID
if targetColumnID > 0 && targetColumnID != filterColumnID {
return false
}
case project_model.WorkflowFilterTypeSourceColumn:
// If filter value is empty, match all columns
if filter.Value == "" {
continue
}
filterColumnID, _ := strconv.ParseInt(filter.Value, 10, 64)
if filterColumnID == 0 {
log.Error("Invalid column ID: %s", filter.Value)
return false
}
// For column changed event, check against the new column ID
if sourceColumnID > 0 && sourceColumnID != filterColumnID {
return false
}
case project_model.WorkflowFilterTypeLabels:
// Check if issue has the specified label
labelID, _ := strconv.ParseInt(filter.Value, 10, 64)
if labelID == 0 {
log.Error("Invalid label ID: %s", filter.Value)
return false
}
// Check if issue has this label
hasLabel := false
for _, label := range issue.Labels {
if label.ID == labelID {
hasLabel = true
break
}
}
if !hasLabel {
return false
}
default:
log.Error("Unsupported filter type: %s", filter.Type)
return false
}
}
return true
}
func executeWorkflowActions(ctx context.Context, workflow *project_model.Workflow, issue *issues_model.Issue) {
var toAddedLables, toRemovedLables []*issues_model.Label
for _, action := range workflow.WorkflowActions {
switch action.Type {
case project_model.WorkflowActionTypeColumn:
columnID, _ := strconv.ParseInt(action.Value, 10, 64)
if columnID == 0 {
log.Error("Invalid column ID: %s", action.Value)
continue
}
column, err := project_model.GetColumnByProjectIDAndColumnID(ctx, issue.Project.ID, columnID)
if err != nil {
log.Error("GetColumnByProjectIDAndColumnID: %v", err)
continue
}
if err := MoveIssueToAnotherColumn(ctx, user_model.NewProjectWorkflowsUser(), issue, column); err != nil {
log.Error("MoveIssueToAnotherColumn: %v", err)
continue
}
case project_model.WorkflowActionTypeAddLabels:
labelID, _ := strconv.ParseInt(action.Value, 10, 64)
if labelID == 0 {
log.Error("Invalid label ID: %s", action.Value)
continue
}
label, err := issues_model.GetLabelByID(ctx, labelID)
if err != nil {
log.Error("GetLabelByID: %v", err)
continue
}
toAddedLables = append(toAddedLables, label)
case project_model.WorkflowActionTypeRemoveLabels:
labelID, _ := strconv.ParseInt(action.Value, 10, 64)
if labelID == 0 {
log.Error("Invalid label ID: %s", action.Value)
continue
}
label, err := issues_model.GetLabelByID(ctx, labelID)
if err != nil {
log.Error("GetLabelByID: %v", err)
continue
}
toRemovedLables = append(toRemovedLables, label)
case project_model.WorkflowActionTypeIssueState:
if strings.EqualFold(action.Value, "reopen") {
if issue.IsClosed {
if err := issue_service.ReopenIssue(ctx, issue, user_model.NewProjectWorkflowsUser(), ""); err != nil {
log.Error("ReopenIssue: %v", err)
continue
}
}
} else if strings.EqualFold(action.Value, "close") {
if !issue.IsClosed {
if err := issue_service.CloseIssue(ctx, issue, user_model.NewProjectWorkflowsUser(), ""); err != nil {
log.Error("CloseIssue: %v", err)
continue
}
}
}
default:
log.Error("Unsupported action type: %s", action.Type)
}
}
if len(toAddedLables)+len(toRemovedLables) > 0 {
if err := issue_service.AddRemoveLabels(ctx, issue, user_model.NewProjectWorkflowsUser(), toAddedLables, toRemovedLables); err != nil {
log.Error("AddRemoveLabels: %v", err)
}
}
}

View File

@ -0,0 +1,13 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content organization repository projects view-project">
{{if .ContextUser.IsOrganization}}
{{template "org/header" .}}
{{else}}
{{template "shared/user/org_profile_avatar" .}}
<div class="ui container tw-mb-4">
{{template "user/overview/header" .}}
</div>
{{end}}
{{template "projects/workflows" .}}
</div>
{{template "base/footer" .}}

View File

@ -19,6 +19,10 @@
</div>
{{if $canWriteProject}}
<div class="ui compact mini menu">
<a class="item" href="{{.Link}}/workflows">
{{svg "octicon-workflow"}}
{{ctx.Locale.Tr "projects.workflows"}}
</a>
<a class="item screen-full">
{{svg "octicon-screen-full"}}
{{ctx.Locale.Tr "projects.enter_fullscreen"}}

View File

@ -0,0 +1,45 @@
<div class="ui container padded projects-view">
<div id="project-workflows"
data-project-link="{{.ProjectLink}}"
data-event-id="{{.workflowIDStr}}"
data-locale-default-workflows="{{ctx.Locale.Tr "projects.workflows.default_workflows"}}"
data-locale-view-workflow-configuration="{{ctx.Locale.Tr "projects.workflows.view_workflow_configuration"}}"
data-locale-configure-workflow="{{ctx.Locale.Tr "projects.workflows.configure_workflow"}}"
data-locale-when="{{ctx.Locale.Tr "projects.workflows.when"}}"
data-locale-run-when="{{ctx.Locale.Tr "projects.workflows.run_when"}}"
data-locale-filters="{{ctx.Locale.Tr "projects.workflows.filters"}}"
data-locale-apply-to="{{ctx.Locale.Tr "projects.workflows.apply_to"}}"
data-locale-when-moved-from-column="{{ctx.Locale.Tr "projects.workflows.when_moved_from_column"}}"
data-locale-when-moved-to-column="{{ctx.Locale.Tr "projects.workflows.when_moved_to_column"}}"
data-locale-only-if-has-labels="{{ctx.Locale.Tr "projects.workflows.only_if_has_labels"}}"
data-locale-actions="{{ctx.Locale.Tr "projects.workflows.actions"}}"
data-locale-move-to-column="{{ctx.Locale.Tr "projects.workflows.move_to_column"}}"
data-locale-add-labels="{{ctx.Locale.Tr "projects.workflows.add_labels"}}"
data-locale-remove-labels="{{ctx.Locale.Tr "projects.workflows.remove_labels"}}"
data-locale-any-label="{{ctx.Locale.Tr "projects.workflows.any_label"}}"
data-locale-any-column="{{ctx.Locale.Tr "projects.workflows.any_column"}}"
data-locale-issue-state="{{ctx.Locale.Tr "projects.workflows.issue_state"}}"
data-locale-none="{{ctx.Locale.Tr "projects.workflows.none"}}"
data-locale-no-change="{{ctx.Locale.Tr "projects.workflows.no_change"}}"
data-locale-edit="{{ctx.Locale.Tr "projects.workflows.edit"}}"
data-locale-delete="{{ctx.Locale.Tr "projects.workflows.delete"}}"
data-locale-save="{{ctx.Locale.Tr "projects.workflows.save"}}"
data-locale-clone="{{ctx.Locale.Tr "projects.workflows.clone"}}"
data-locale-cancel="{{ctx.Locale.Tr "projects.workflows.cancel"}}"
data-locale-disable="{{ctx.Locale.Tr "projects.workflows.disable"}}"
data-locale-disabled="{{ctx.Locale.Tr "projects.workflows.disabled"}}"
data-locale-enable="{{ctx.Locale.Tr "projects.workflows.enable"}}"
data-locale-enabled="{{ctx.Locale.Tr "projects.workflows.enabled"}}"
data-locale-issues-and-pull-requests="{{ctx.Locale.Tr "projects.workflows.issues_and_pull_requests"}}"
data-locale-issues-only="{{ctx.Locale.Tr "projects.workflows.issues_only"}}"
data-locale-pull-requests-only="{{ctx.Locale.Tr "projects.workflows.pull_requests_only"}}"
data-locale-select-column="{{ctx.Locale.Tr "projects.workflows.select_column"}}"
data-locale-close-issue="{{ctx.Locale.Tr "projects.workflows.close_issue"}}"
data-locale-reopen-issue="{{ctx.Locale.Tr "projects.workflows.reopen_issue"}}"
data-locale-save-workflow-failed="{{ctx.Locale.Tr "projects.workflows.save_workflow_failed"}}"
data-locale-update-workflow-failed="{{ctx.Locale.Tr "projects.workflows.update_workflow_failed"}}"
data-locale-delete-workflow-failed="{{ctx.Locale.Tr "projects.workflows.delete_workflow_failed"}}"
data-locale-at-least-one-action-required="{{ctx.Locale.Tr "projects.workflows.at_least_one_action_required"}}"
>
</div>
</div>

View File

@ -0,0 +1,12 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository projects view-project">
{{template "repo/header" .}}
<div class="ui container padded">
<div class="tw-flex tw-justify-between tw-items-center tw-mb-4">
<a class="ui" href="{{.ProjectLink}}">{{svg "octicon-arrow-left"}} {{ctx.Locale.Tr "projects.workflows"}} {{.Project.Title}}</a>
</div>
</div>
{{template "projects/workflows" .}}
</div>
{{template "base/footer" .}}

View File

@ -122,17 +122,34 @@ func TestNoLoginViewIssue(t *testing.T) {
MakeRequest(t, req, http.StatusOK)
}
func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content string) string {
type newIssueOptions struct {
Title string
Content string
ProjectID int64
LabelIDs []int64
}
func testNewIssue(t *testing.T, session *TestSession, user, repo string, opts newIssueOptions) string {
req := NewRequest(t, "GET", path.Join(user, repo, "issues", "new"))
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
assert.True(t, exists, "The template has changed")
var labelIDs string
for i, id := range opts.LabelIDs {
labelIDs += strconv.FormatInt(id, 10)
if i < len(opts.LabelIDs)-1 {
labelIDs += ","
}
}
req = NewRequestWithValues(t, "POST", link, map[string]string{
"_csrf": htmlDoc.GetCSRF(),
"title": title,
"content": content,
"_csrf": htmlDoc.GetCSRF(),
"title": opts.Title,
"content": opts.Content,
"project_id": strconv.FormatInt(opts.ProjectID, 10),
"label_ids": labelIDs,
})
resp = session.MakeRequest(t, req, http.StatusOK)
@ -142,9 +159,9 @@ func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content
htmlDoc = NewHTMLParser(t, resp.Body)
val := htmlDoc.doc.Find("#issue-title-display").Text()
assert.Contains(t, val, title)
assert.Contains(t, val, opts.Title)
val = htmlDoc.doc.Find(".comment .render-content p").First().Text()
assert.Equal(t, content, val)
assert.Equal(t, opts.Content, val)
return issueURL
}
@ -210,13 +227,19 @@ func testIssueChangeMilestone(t *testing.T, session *TestSession, repoLink strin
func TestNewIssue(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
testNewIssue(t, session, "user2", "repo1", "Title", "Description")
testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title",
Content: "Description",
})
}
func TestEditIssue(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title",
Content: "Description",
})
req := NewRequestWithValues(t, "POST", issueURL+"/content", map[string]string{
"_csrf": GetUserCSRFToken(t, session),
@ -244,7 +267,10 @@ func TestEditIssue(t *testing.T) {
func TestIssueCommentClose(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title",
Content: "Description",
})
testIssueAddComment(t, session, issueURL, "Test comment 1", "")
testIssueAddComment(t, session, issueURL, "Test comment 2", "")
testIssueAddComment(t, session, issueURL, "Test comment 3", "close")
@ -260,7 +286,10 @@ func TestIssueCommentClose(t *testing.T) {
func TestIssueCommentDelete(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title",
Content: "Description",
})
comment1 := "Test comment 1"
commentID := testIssueAddComment(t, session, issueURL, comment1, "")
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID})
@ -281,7 +310,10 @@ func TestIssueCommentDelete(t *testing.T) {
func TestIssueCommentUpdate(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title",
Content: "Description",
})
comment1 := "Test comment 1"
commentID := testIssueAddComment(t, session, issueURL, comment1, "")
@ -310,7 +342,10 @@ func TestIssueCommentUpdate(t *testing.T) {
func TestIssueCommentUpdateSimultaneously(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title",
Content: "Description",
})
comment1 := "Test comment 1"
commentID := testIssueAddComment(t, session, issueURL, comment1, "")
@ -348,7 +383,10 @@ func TestIssueCommentUpdateSimultaneously(t *testing.T) {
func TestIssueReaction(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title",
Content: "Description",
})
req := NewRequest(t, "GET", issueURL)
resp := session.MakeRequest(t, req, http.StatusOK)
@ -448,7 +486,10 @@ func TestIssueCrossReference(t *testing.T) {
func testIssueWithBean(t *testing.T, user string, repoID int64, title, content string) (string, *issues_model.Issue) {
session := loginUser(t, user)
issueURL := testNewIssue(t, session, user, fmt.Sprintf("repo%d", repoID), title, content)
issueURL := testNewIssue(t, session, user, fmt.Sprintf("repo%d", repoID), newIssueOptions{
Title: title,
Content: content,
})
indexStr := issueURL[strings.LastIndexByte(issueURL, '/')+1:]
index, err := strconv.Atoi(indexStr)
assert.NoError(t, err, "Invalid issue href: %s", issueURL)

View File

@ -0,0 +1,774 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"testing"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func testCreateProjectWorkflow(t *testing.T, session *TestSession, userName, repoName string, projectID int64, event string, workflowData map[string]any) {
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/%s?_csrf=%s",
userName, repoName, projectID, event, GetUserCSRFToken(t, session)),
workflowData)
resp := session.MakeRequest(t, req, http.StatusOK)
var result map[string]any
err := json.Unmarshal(resp.Body.Bytes(), &result)
assert.NoError(t, err)
assert.True(t, result["success"].(bool))
}
func testNewIssueReturnIssue(t *testing.T, session *TestSession, repo *repo_model.Repository, opts newIssueOptions) int64 {
testNewIssue(t, session, repo.OwnerName, repo.Name, opts)
// Get the created issue from database to verify
issues, err := issues_model.Issues(t.Context(), &issues_model.IssuesOptions{
RepoIDs: []int64{repo.ID},
SortType: "newest",
Paginator: &db.ListOptions{
PageSize: 1,
},
})
assert.NoError(t, err)
assert.NotEmpty(t, issues)
return issues[0].ID
}
// testAddIssueToProject adds the issue to the project via web form if projectID == 0, it removes the issue from the project
func testAddIssueToProject(t *testing.T, session *TestSession, userName, repoName string, projectID, issueID int64) {
addToProjectReq := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/issues/projects?_csrf=%s",
userName, repoName, GetUserCSRFToken(t, session)),
map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"id": strconv.FormatInt(projectID, 10),
"issue_ids": strconv.FormatInt(issueID, 10),
})
session.MakeRequest(t, addToProjectReq, http.StatusOK)
}
// TestProjectWorkflowExecutionItemOpened tests workflow execution when an issue is added to project
func TestProjectWorkflowExecutionItemOpened(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create project and columns
project := &project_model.Project{
Title: "Test Workflow Execution",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
assert.NoError(t, project_model.NewProject(t.Context(), project))
columnToDo := &project_model.Column{
Title: "To Do",
ProjectID: project.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), columnToDo))
// Create label
label := &issues_model.Label{
RepoID: repo.ID,
Name: "bug",
Color: "ee0701",
}
assert.NoError(t, issues_model.NewLabel(t.Context(), label))
session := loginUser(t, user.Name)
// Create workflow via HTTP: when item is opened, move to "To Do" and add "bug" label
testCreateProjectWorkflow(t, session, user.Name, repo.Name, project.ID, "item_opened", map[string]any{
"event_id": string(project_model.WorkflowEventItemOpened),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "issue",
},
"actions": map[string]any{
string(project_model.WorkflowActionTypeColumn): strconv.FormatInt(columnToDo.ID, 10),
string(project_model.WorkflowActionTypeAddLabels): []string{strconv.FormatInt(label.ID, 10)},
},
})
issueID := testNewIssueReturnIssue(t, session, repo, newIssueOptions{
Title: "Test Issue for Workflow",
Content: "This should trigger item_opened workflow",
ProjectID: project.ID,
})
// Verify workflow executed: issue moved to "To Do" and has "bug" label
issue, err := issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
projectIssue := &project_model.ProjectIssue{}
has, err := db.GetEngine(t.Context()).Where("issue_id=?", issue.ID).Get(projectIssue)
assert.NoError(t, err)
assert.True(t, has)
assert.Equal(t, columnToDo.ID, projectIssue.ProjectColumnID)
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
assert.Len(t, issue.Labels, 1)
assert.Equal(t, label.ID, issue.Labels[0].ID)
}
func TestProjectWorkflowExecutionItemAddedToProject(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create project and columns
project := &project_model.Project{
Title: "Test Workflow Execution",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
assert.NoError(t, project_model.NewProject(t.Context(), project))
columnToDo := &project_model.Column{
Title: "To Do",
ProjectID: project.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), columnToDo))
// Create label
label := &issues_model.Label{
RepoID: repo.ID,
Name: "bug",
Color: "ee0701",
}
assert.NoError(t, issues_model.NewLabel(t.Context(), label))
session := loginUser(t, user.Name)
// Create workflow via HTTP: when item added to project, move to "To Do" and add "bug" label
testCreateProjectWorkflow(t, session, user.Name, repo.Name, project.ID, "item_added_to_project", map[string]any{
"event_id": string(project_model.WorkflowEventItemAddedToProject),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "issue",
},
"actions": map[string]any{
string(project_model.WorkflowActionTypeColumn): strconv.FormatInt(columnToDo.ID, 10),
string(project_model.WorkflowActionTypeAddLabels): []string{strconv.FormatInt(label.ID, 10)},
},
})
issueID := testNewIssueReturnIssue(t, session, repo, newIssueOptions{
Title: "Test Issue for Workflow",
Content: "This should trigger workflow when added to project",
})
// Add issue to project via Web form - this triggers the workflow
testAddIssueToProject(t, session, user.Name, repo.Name, project.ID, issueID)
// Verify workflow executed: issue moved to "To Do" and has "bug" label
issue, err := issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
projectIssue := &project_model.ProjectIssue{}
has, err := db.GetEngine(t.Context()).Where("issue_id=?", issue.ID).Get(projectIssue)
assert.NoError(t, err)
assert.True(t, has)
assert.Equal(t, columnToDo.ID, projectIssue.ProjectColumnID)
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
assert.Len(t, issue.Labels, 1)
assert.Equal(t, label.ID, issue.Labels[0].ID)
}
func TestProjectWorkflowExecutionItemRemovedFromProject(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create project and columns
project := &project_model.Project{
Title: "Test Workflow Execution",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
assert.NoError(t, project_model.NewProject(t.Context(), project))
columnToDo := &project_model.Column{
Title: "To Do",
ProjectID: project.ID,
}
assert.NoError(t, project_model.NewColumn(t.Context(), columnToDo))
// Create label
label := &issues_model.Label{
RepoID: repo.ID,
Name: "no-project",
Color: "ee0701",
}
assert.NoError(t, issues_model.NewLabel(t.Context(), label))
session := loginUser(t, user.Name)
// Create workflow via HTTP: when item added to project, move to "To Do" and add "bug" label
testCreateProjectWorkflow(t, session, user.Name, repo.Name, project.ID, "item_removed_from_project", map[string]any{
"event_id": string(project_model.WorkflowEventItemRemovedFromProject),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "issue",
},
"actions": map[string]any{
string(project_model.WorkflowActionTypeAddLabels): []string{strconv.FormatInt(label.ID, 10)},
},
})
issueID := testNewIssueReturnIssue(t, session, repo, newIssueOptions{
Title: "Test Issue for Workflow",
Content: "This should trigger workflow when removed from project",
ProjectID: project.ID,
})
// remove issue from the project to trigger the workflow
testAddIssueToProject(t, session, user.Name, repo.Name, 0, issueID)
issue, err := issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
assert.NoError(t, issue.LoadProject(t.Context()))
assert.Nil(t, issue.Project)
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
assert.Len(t, issue.Labels, 1)
assert.Equal(t, label.ID, issue.Labels[0].ID)
}
// TestProjectWorkflowExecutionItemClosed tests workflow when issue is closed
func TestProjectWorkflowExecutionItemClosed(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
project := &project_model.Project{
Title: "Test Close Workflow",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
columnDone := &project_model.Column{
Title: "Done",
ProjectID: project.ID,
}
err = project_model.NewColumn(t.Context(), columnDone)
assert.NoError(t, err)
labelCompleted := &issues_model.Label{
RepoID: repo.ID,
Name: "completed",
Color: "00ff00",
}
err = issues_model.NewLabel(t.Context(), labelCompleted)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Create workflow: when closed, move to "Done" and add "completed" label
testCreateProjectWorkflow(t, session, user.Name, repo.Name, project.ID, "item_closed", map[string]any{
"event_id": string(project_model.WorkflowEventItemClosed),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "issue",
},
"actions": map[string]any{
string(project_model.WorkflowActionTypeColumn): strconv.FormatInt(columnDone.ID, 10),
string(project_model.WorkflowActionTypeAddLabels): []string{strconv.FormatInt(labelCompleted.ID, 10)},
},
})
issueID := testNewIssueReturnIssue(t, session, repo, newIssueOptions{
Title: "Test Issue for Workflow",
Content: "This should trigger workflow when item is closed",
ProjectID: project.ID,
})
issue, err := issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
assert.False(t, issue.IsClosed)
assert.NoError(t, issue.LoadRepo(t.Context()))
// Close issue via API
testIssueAddComment(t, session, issue.Link(), "Test comment 3", "close")
// Verify workflow executed
issue, err = issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
assert.True(t, issue.IsClosed)
projectIssue := &project_model.ProjectIssue{}
has, err := db.GetEngine(t.Context()).Where("issue_id=?", issue.ID).Get(projectIssue)
assert.NoError(t, err)
assert.True(t, has)
assert.Equal(t, columnDone.ID, projectIssue.ProjectColumnID)
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
hasLabel := false
for _, l := range issue.Labels {
if l.ID == labelCompleted.ID {
hasLabel = true
break
}
}
assert.True(t, hasLabel)
}
func TestProjectWorkflowExecutionItemReopened(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
project := &project_model.Project{
Title: "Test Close Workflow",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
columnDone := &project_model.Column{
Title: "Done",
ProjectID: project.ID,
}
err = project_model.NewColumn(t.Context(), columnDone)
assert.NoError(t, err)
labelCompleted := &issues_model.Label{
RepoID: repo.ID,
Name: "completed",
Color: "00ff00",
}
err = issues_model.NewLabel(t.Context(), labelCompleted)
assert.NoError(t, err)
session := loginUser(t, user.Name)
testCreateProjectWorkflow(t, session, user.Name, repo.Name, project.ID, "item_reopened",
map[string]any{
"event_id": string(project_model.WorkflowEventItemReopened),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "issue",
string(project_model.WorkflowFilterTypeLabels): strconv.FormatInt(labelCompleted.ID, 10),
},
"actions": map[string]any{
string(project_model.WorkflowActionTypeColumn): strconv.FormatInt(columnDone.ID, 10),
},
})
issueID := testNewIssueReturnIssue(t, session, repo, newIssueOptions{
Title: "Test Issue for Workflow",
Content: "This should trigger workflow when item is reopened",
ProjectID: project.ID,
LabelIDs: []int64{labelCompleted.ID},
})
issue, err := issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
assert.False(t, issue.IsClosed)
assert.NoError(t, issue.LoadRepo(t.Context()))
// Reopen issue
testIssueAddComment(t, session, issue.Link(), "Test comment 3", "close")
testIssueAddComment(t, session, issue.Link(), "Test comment 3", "reopen")
// Reload and Verify workflow executed
issue, err = issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
assert.False(t, issue.IsClosed)
projectIssue := &project_model.ProjectIssue{}
has, err := db.GetEngine(t.Context()).Where("issue_id=?", issue.ID).Get(projectIssue)
assert.NoError(t, err)
assert.True(t, has)
assert.Equal(t, columnDone.ID, projectIssue.ProjectColumnID)
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
hasLabel := false
for _, l := range issue.Labels {
if l.ID == labelCompleted.ID {
hasLabel = true
break
}
}
assert.True(t, hasLabel)
}
// TestProjectWorkflowExecutionColumnChanged tests workflow when moving between columns
func TestProjectWorkflowExecutionColumnChanged(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
project := &project_model.Project{
Title: "Test Column Change",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
columnToDo := &project_model.Column{Title: "To Do", ProjectID: project.ID}
err = project_model.NewColumn(t.Context(), columnToDo)
assert.NoError(t, err)
columnDone := &project_model.Column{Title: "Done", ProjectID: project.ID}
err = project_model.NewColumn(t.Context(), columnDone)
assert.NoError(t, err)
labelWIP := &issues_model.Label{RepoID: repo.ID, Name: "wip", Color: "fbca04"}
err = issues_model.NewLabel(t.Context(), labelWIP)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Create workflow: when moved to "Done", remove "wip" and close
testCreateProjectWorkflow(t, session, user.Name, repo.Name, project.ID, "item_column_changed", map[string]any{
"event_id": string(project_model.WorkflowEventItemColumnChanged),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeTargetColumn): strconv.FormatInt(columnDone.ID, 10),
},
"actions": map[string]any{
string(project_model.WorkflowActionTypeRemoveLabels): []string{strconv.FormatInt(labelWIP.ID, 10)},
string(project_model.WorkflowActionTypeIssueState): "close",
},
})
// Create issue with "wip" label
issueID := testNewIssueReturnIssue(t, session, repo, newIssueOptions{
Title: "Test Column Change",
Content: "Will move columns",
ProjectID: project.ID,
LabelIDs: []int64{labelWIP.ID},
})
// Move to "To Do" first
moveReq := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/%d/move?_csrf=%s", user.Name, repo.Name, project.ID, columnToDo.ID, GetUserCSRFToken(t, session)),
map[string]any{
"issues": []map[string]any{
{
"issueID": issueID,
"sorting": 0,
},
},
})
session.MakeRequest(t, moveReq, http.StatusOK)
// Move to "Done" - triggers workflow
moveReq = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/%d/move?_csrf=%s", user.Name, repo.Name, project.ID, columnDone.ID, GetUserCSRFToken(t, session)),
map[string]any{
"issues": []map[string]any{
{
"issueID": issueID,
"sorting": 0,
},
},
})
session.MakeRequest(t, moveReq, http.StatusOK)
// Verify workflow executed
issue, err := issues_model.GetIssueByID(t.Context(), issueID)
assert.NoError(t, err)
assert.True(t, issue.IsClosed, "Issue should be closed")
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
hasWIP := false
for _, l := range issue.Labels {
if l.ID == labelWIP.ID {
hasWIP = true
break
}
}
assert.False(t, hasWIP, "WIP label should be removed")
}
func TestProjectWorkflowExecutionCodeChangesRequested(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Use existing PR #3 from fixtures (issue_id: 3, pull_request id: 2)
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
assert.NoError(t, pr.LoadIssue(t.Context()))
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
repo := pr.BaseRepo
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
project := &project_model.Project{
Title: "Test Code Changes Requested",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
columnInProgress := &project_model.Column{Title: "In Progress", ProjectID: project.ID}
err = project_model.NewColumn(t.Context(), columnInProgress)
assert.NoError(t, err)
labelNeedChange := &issues_model.Label{RepoID: repo.ID, Name: "needs-changes", Color: "fbca04"}
err = issues_model.NewLabel(t.Context(), labelNeedChange)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Create workflow: when code changes requested, add "needs-changes" label
testCreateProjectWorkflow(t, session, user.Name, repo.Name, project.ID, "code_changes_requested", map[string]any{
"event_id": string(project_model.WorkflowEventCodeChangesRequested),
"filters": map[string]any{},
"actions": map[string]any{
string(project_model.WorkflowActionTypeAddLabels): []string{strconv.FormatInt(labelNeedChange.ID, 10)},
},
})
// Add PR to project
testAddIssueToProject(t, session, user.Name, repo.Name, project.ID, pr.Issue.ID)
// User 2 submits a "REQUEST_CHANGES" review
user2Session := loginUser(t, "user2")
prURL := fmt.Sprintf("/%s/%s/pulls/%d", user.Name, repo.Name, pr.Issue.Index)
req := NewRequest(t, "GET", prURL+"/files")
resp := user2Session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
gitRepo, err := gitrepo.OpenRepository(t.Context(), pr.BaseRepo)
assert.NoError(t, err)
defer gitRepo.Close()
commitID, err := gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
assert.NoError(t, err)
testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), user.Name, repo.Name, strconv.FormatInt(pr.Issue.Index, 10), commitID, "reject", http.StatusOK)
// Verify workflow executed: PR should have "needs-changes" label
issue, err := issues_model.GetIssueByID(t.Context(), pr.Issue.ID)
assert.NoError(t, err)
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
hasNeedChangeLabel := false
for _, l := range issue.Labels {
if l.ID == labelNeedChange.ID {
hasNeedChangeLabel = true
break
}
}
assert.True(t, hasNeedChangeLabel, "needs-changes label should be added")
}
func TestProjectWorkflowExecutionCodeReviewApproved(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Use existing PR #3 from fixtures (issue_id: 3, pull_request id: 2)
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
assert.NoError(t, pr.LoadIssue(t.Context()))
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
repo := pr.BaseRepo
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
project := &project_model.Project{
Title: "Test Code Review Approved",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
columnReadyToMerge := &project_model.Column{Title: "Ready to Merge", ProjectID: project.ID}
err = project_model.NewColumn(t.Context(), columnReadyToMerge)
assert.NoError(t, err)
labelApproved := &issues_model.Label{RepoID: repo.ID, Name: "approved", Color: "00ff00"}
err = issues_model.NewLabel(t.Context(), labelApproved)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Create workflow: when code review approved, move to "Ready to Merge" and add "approved" label
testCreateProjectWorkflow(t, session, user.Name, repo.Name, project.ID, "code_review_approved", map[string]any{
"event_id": string(project_model.WorkflowEventCodeReviewApproved),
"filters": map[string]any{},
"actions": map[string]any{
string(project_model.WorkflowActionTypeColumn): strconv.FormatInt(columnReadyToMerge.ID, 10),
string(project_model.WorkflowActionTypeAddLabels): []string{strconv.FormatInt(labelApproved.ID, 10)},
},
})
// Add PR to project
testAddIssueToProject(t, session, user.Name, repo.Name, project.ID, pr.Issue.ID)
// User 2 submits an "APPROVE" review
user2Session := loginUser(t, "user2")
prURL := fmt.Sprintf("/%s/%s/pulls/%d", user.Name, repo.Name, pr.Issue.Index)
req := NewRequest(t, "GET", prURL+"/files")
resp := user2Session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
gitRepo, err := gitrepo.OpenRepository(t.Context(), pr.BaseRepo)
assert.NoError(t, err)
defer gitRepo.Close()
commitID, err := gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
assert.NoError(t, err)
testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), user.Name, repo.Name, strconv.FormatInt(pr.Issue.Index, 10), commitID, "approve", http.StatusOK)
// Verify workflow executed: PR should be in "Ready to Merge" column and have "approved" label
issue, err := issues_model.GetIssueByID(t.Context(), pr.Issue.ID)
assert.NoError(t, err)
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
hasApprovedLabel := false
for _, l := range issue.Labels {
if l.ID == labelApproved.ID {
hasApprovedLabel = true
break
}
}
assert.True(t, hasApprovedLabel, "approved label should be added")
// Check column
projectIssue := &project_model.ProjectIssue{}
has, err := db.GetEngine(t.Context()).Where("issue_id=?", issue.ID).Get(projectIssue)
assert.NoError(t, err)
assert.True(t, has)
assert.Equal(t, columnReadyToMerge.ID, projectIssue.ProjectColumnID)
}
func TestProjectWorkflowExecutionPullRequestMerged(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
// Fork repo1 and create a PR that can be merged
session := loginUser(t, "user1")
testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "")
testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited for merge test)\n")
// Get the base repo
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
// Create project in base repo
project := &project_model.Project{
Title: "Test PR Merged",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
columnDone := &project_model.Column{Title: "Done", ProjectID: project.ID}
err = project_model.NewColumn(t.Context(), columnDone)
assert.NoError(t, err)
labelMerged := &issues_model.Label{RepoID: repo.ID, Name: "merged", Color: "6f42c1"}
err = issues_model.NewLabel(t.Context(), labelMerged)
assert.NoError(t, err)
// Login as user2 (repo owner) to create workflow
user2Session := loginUser(t, "user2")
// Create workflow: when PR merged, move to "Done" and add "merged" label
testCreateProjectWorkflow(t, user2Session, "user2", "repo1", project.ID, "pull_request_merged", map[string]any{
"event_id": string(project_model.WorkflowEventPullRequestMerged),
"filters": map[string]any{},
"actions": map[string]any{
string(project_model.WorkflowActionTypeColumn): strconv.FormatInt(columnDone.ID, 10),
string(project_model.WorkflowActionTypeAddLabels): []string{strconv.FormatInt(labelMerged.ID, 10)},
},
})
// Create PR from user1's fork to user2's repo
resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "Test PR for Merge Workflow")
// Get PR details from redirect URL
elem := strings.Split(test.RedirectURL(resp), "/")
assert.Equal(t, "pulls", elem[3])
prNum := elem[4]
// Load the PR
prNumInt, err := strconv.ParseInt(prNum, 10, 64)
assert.NoError(t, err)
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, Index: prNumInt})
assert.NoError(t, pr.LoadIssue(t.Context()))
// Add PR to project (as user2, the repo owner)
testAddIssueToProject(t, user2Session, "user2", "repo1", project.ID, pr.Issue.ID)
// Merge the PR (as user2, who has permission)
prURL := "/user2/repo1/pulls/" + prNum
req := NewRequest(t, "GET", prURL)
resp = user2Session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
req = NewRequestWithValues(t, "POST", path.Join(prURL, "merge"), map[string]string{
"_csrf": htmlDoc.GetCSRF(),
"do": string(repo_model.MergeStyleMerge),
})
user2Session.MakeRequest(t, req, http.StatusOK)
// Verify workflow executed: PR should be in "Done" column and have "merged" label
issue, err := issues_model.GetIssueByID(t.Context(), pr.Issue.ID)
assert.NoError(t, err)
err = issue.LoadLabels(t.Context())
assert.NoError(t, err)
hasMergedLabel := false
for _, l := range issue.Labels {
if l.ID == labelMerged.ID {
hasMergedLabel = true
break
}
}
assert.True(t, hasMergedLabel, "merged label should be added")
// Check column
projectIssue := &project_model.ProjectIssue{}
has, err := db.GetEngine(t.Context()).Where("issue_id=?", issue.ID).Get(projectIssue)
assert.NoError(t, err)
assert.True(t, has)
assert.Equal(t, columnDone.ID, projectIssue.ProjectColumnID)
// Verify PR is merged
pr, err = issues_model.GetPullRequestByID(t.Context(), pr.ID)
assert.NoError(t, err)
assert.True(t, pr.HasMerged, "PR should be merged")
})
}

View File

@ -0,0 +1,557 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"code.gitea.io/gitea/models/db"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestProjectWorkflowsPage(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create a project
project := &project_model.Project{
Title: "Test Project for Workflows",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
// Create columns for the project
column1 := &project_model.Column{
Title: "To Do",
ProjectID: project.ID,
}
err = project_model.NewColumn(t.Context(), column1)
assert.NoError(t, err)
column2 := &project_model.Column{
Title: "Done",
ProjectID: project.ID,
}
err = project_model.NewColumn(t.Context(), column2)
assert.NoError(t, err)
// Create some workflows
workflow1 := &project_model.Workflow{
ProjectID: project.ID,
WorkflowEvent: project_model.WorkflowEventItemOpened,
WorkflowFilters: []project_model.WorkflowFilter{
{
Type: project_model.WorkflowFilterTypeIssueType,
Value: "issue",
},
},
WorkflowActions: []project_model.WorkflowAction{
{
Type: project_model.WorkflowActionTypeColumn,
Value: strconv.FormatInt(column1.ID, 10),
},
},
Enabled: true,
}
err = project_model.CreateWorkflow(t.Context(), workflow1)
assert.NoError(t, err)
workflow2 := &project_model.Workflow{
ProjectID: project.ID,
WorkflowEvent: project_model.WorkflowEventItemClosed,
WorkflowFilters: []project_model.WorkflowFilter{
{
Type: project_model.WorkflowFilterTypeIssueType,
Value: "pull_request",
},
},
WorkflowActions: []project_model.WorkflowAction{
{
Type: project_model.WorkflowActionTypeColumn,
Value: strconv.FormatInt(column2.ID, 10),
},
},
Enabled: false, // Disabled workflow
}
err = project_model.CreateWorkflow(t.Context(), workflow2)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Test accessing workflows page
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/projects/%d/workflows", user.Name, repo.Name, project.ID))
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
// Verify the main workflow container exists
assert.Positive(t, htmlDoc.Find("#project-workflows").Length(), "Main workflow container should exist")
// Verify data attributes are set correctly
workflowDiv := htmlDoc.Find("#project-workflows")
assert.Positive(t, workflowDiv.Length(), "Workflow div should exist")
// Check that locale data attributes exist
assert.NotEmpty(t, workflowDiv.AttrOr("data-locale-default-workflows", ""), "data-locale-default-workflows should be set")
assert.NotEmpty(t, workflowDiv.AttrOr("data-locale-when", ""), "data-locale-when should be set")
assert.NotEmpty(t, workflowDiv.AttrOr("data-locale-actions", ""), "data-locale-actions should be set")
assert.NotEmpty(t, workflowDiv.AttrOr("data-locale-filters", ""), "data-locale-filters should be set")
assert.NotEmpty(t, workflowDiv.AttrOr("data-locale-close-issue", ""), "data-locale-close-issue should be set")
assert.NotEmpty(t, workflowDiv.AttrOr("data-locale-reopen-issue", ""), "data-locale-reopen-issue should be set")
assert.NotEmpty(t, workflowDiv.AttrOr("data-locale-issues-and-pull-requests", ""), "data-locale-issues-and-pull-requests should be set")
// Verify project link is set
projectLink := workflowDiv.AttrOr("data-project-link", "")
assert.Equal(t, fmt.Sprintf("/%s/%s/projects/%d", user.Name, repo.Name, project.ID), projectLink, "Project link should be correct")
// Test that unauthenticated users cannot access
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/projects/%d/workflows", user.Name, repo.Name, project.ID))
MakeRequest(t, req, http.StatusNotFound)
// Test accessing non-existent project workflows page
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/projects/999999/workflows", user.Name, repo.Name))
session.MakeRequest(t, req, http.StatusNotFound)
// Verify workflows were created
workflows, err := project_model.FindWorkflowsByProjectID(t.Context(), project.ID)
assert.NoError(t, err)
assert.Len(t, workflows, 2, "Should have 2 workflows")
// Verify workflow details
assert.Equal(t, project_model.WorkflowEventItemOpened, workflows[0].WorkflowEvent)
assert.True(t, workflows[0].Enabled, "First workflow should be enabled")
assert.Equal(t, project_model.WorkflowEventItemClosed, workflows[1].WorkflowEvent)
assert.False(t, workflows[1].Enabled, "Second workflow should be disabled")
}
func TestProjectWorkflowCreate(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create a project
project := &project_model.Project{
Title: "Test Project for Workflow Create",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
// Create a column
column := &project_model.Column{
Title: "Test Column",
ProjectID: project.ID,
}
err = project_model.NewColumn(t.Context(), column)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Create a workflow via API
workflowData := map[string]any{
"event_id": string(project_model.WorkflowEventItemOpened),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "issue",
},
"actions": map[string]any{
string(project_model.WorkflowActionTypeColumn): strconv.FormatInt(column.ID, 10),
},
}
body, err := json.Marshal(workflowData)
assert.NoError(t, err)
req := NewRequestWithBody(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/item_opened?_csrf=%s", user.Name, repo.Name, project.ID, GetUserCSRFToken(t, session)),
strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp := session.MakeRequest(t, req, http.StatusOK)
// Parse response
var result map[string]any
err = json.Unmarshal(resp.Body.Bytes(), &result)
assert.NoError(t, err)
assert.True(t, result["success"].(bool))
// Verify workflow was created
workflows, err := project_model.FindWorkflowsByProjectID(t.Context(), project.ID)
assert.NoError(t, err)
assert.Len(t, workflows, 1)
assert.Equal(t, project_model.WorkflowEventItemOpened, workflows[0].WorkflowEvent)
assert.True(t, workflows[0].Enabled)
}
func TestProjectWorkflowUpdate(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create a project
project := &project_model.Project{
Title: "Test Project for Workflow Update",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
// Create a column
column := &project_model.Column{
Title: "Test Column",
ProjectID: project.ID,
}
err = project_model.NewColumn(t.Context(), column)
assert.NoError(t, err)
// Create a workflow
workflow := &project_model.Workflow{
ProjectID: project.ID,
WorkflowEvent: project_model.WorkflowEventItemOpened,
WorkflowFilters: []project_model.WorkflowFilter{
{
Type: project_model.WorkflowFilterTypeIssueType,
Value: "issue",
},
},
WorkflowActions: []project_model.WorkflowAction{
{
Type: project_model.WorkflowActionTypeColumn,
Value: strconv.FormatInt(column.ID, 10),
},
},
Enabled: true,
}
err = project_model.CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Update the workflow
updateData := map[string]any{
"event_id": strconv.FormatInt(workflow.ID, 10),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "pull_request", // Change to PR
},
"actions": map[string]any{
string(project_model.WorkflowActionTypeColumn): strconv.FormatInt(column.ID, 10),
},
}
body, err := json.Marshal(updateData)
assert.NoError(t, err)
req := NewRequestWithBody(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/%d?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session)),
strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp := session.MakeRequest(t, req, http.StatusOK)
// Parse response
var result map[string]any
err = json.Unmarshal(resp.Body.Bytes(), &result)
assert.NoError(t, err)
assert.True(t, result["success"].(bool))
// Verify workflow was updated
updatedWorkflow, err := project_model.GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.True(t, updatedWorkflow.Enabled)
assert.Len(t, updatedWorkflow.WorkflowFilters, 1)
assert.Equal(t, "pull_request", updatedWorkflow.WorkflowFilters[0].Value)
}
func TestProjectWorkflowToggleStatus(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create a project
project := &project_model.Project{
Title: "Test Project for Workflow Status",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
// Create a workflow that is initially enabled
workflow := &project_model.Workflow{
ProjectID: project.ID,
WorkflowEvent: project_model.WorkflowEventItemOpened,
WorkflowFilters: []project_model.WorkflowFilter{},
WorkflowActions: []project_model.WorkflowAction{},
Enabled: true,
}
err = project_model.CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Test 1: Toggle status from enabled to disabled
t.Run("Disable workflow", func(t *testing.T) {
req := NewRequestWithValues(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/%d/status?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session)),
map[string]string{
"enabled": "false",
})
resp := session.MakeRequest(t, req, http.StatusOK)
// Parse response
var result map[string]any
err = json.Unmarshal(resp.Body.Bytes(), &result)
assert.NoError(t, err)
assert.True(t, result["success"].(bool), "Response should indicate success")
// Verify status was changed to disabled
updatedWorkflow, err := project_model.GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.False(t, updatedWorkflow.Enabled, "Workflow should be disabled")
})
// Test 2: Toggle status from disabled to enabled
t.Run("Enable workflow", func(t *testing.T) {
req := NewRequestWithValues(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/%d/status?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session)),
map[string]string{
"enabled": "true",
})
resp := session.MakeRequest(t, req, http.StatusOK)
// Parse response
var result map[string]any
err = json.Unmarshal(resp.Body.Bytes(), &result)
assert.NoError(t, err)
assert.True(t, result["success"].(bool), "Response should indicate success")
// Verify status was changed back to enabled
updatedWorkflow, err := project_model.GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.True(t, updatedWorkflow.Enabled, "Workflow should be enabled")
})
}
func TestProjectWorkflowDelete(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create a project
project := &project_model.Project{
Title: "Test Project for Workflow Delete",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
// Create a workflow
workflow := &project_model.Workflow{
ProjectID: project.ID,
WorkflowEvent: project_model.WorkflowEventItemOpened,
WorkflowFilters: []project_model.WorkflowFilter{},
WorkflowActions: []project_model.WorkflowAction{},
Enabled: true,
}
err = project_model.CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Delete the workflow
req := NewRequest(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/%d/delete?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session)))
resp := session.MakeRequest(t, req, http.StatusOK)
// Parse response
var result map[string]any
err = json.Unmarshal(resp.Body.Bytes(), &result)
assert.NoError(t, err)
assert.True(t, result["success"].(bool), "Delete response should indicate success")
// Verify workflow was deleted - should return ErrNotExist
_, err = project_model.GetWorkflowByID(t.Context(), workflow.ID)
assert.Error(t, err, "Should return an error when workflow doesn't exist")
assert.True(t, db.IsErrNotExist(err), "Error should be ErrNotExist type")
// Verify we cannot delete it again (should fail gracefully)
req = NewRequest(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/%d/delete?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session)))
session.MakeRequest(t, req, http.StatusNotFound)
}
func TestProjectWorkflowPermissions(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create a project
project := &project_model.Project{
Title: "Test Project for Workflow Permissions",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
// Create a workflow
workflow := &project_model.Workflow{
ProjectID: project.ID,
WorkflowEvent: project_model.WorkflowEventItemOpened,
WorkflowFilters: []project_model.WorkflowFilter{},
WorkflowActions: []project_model.WorkflowAction{},
Enabled: true,
}
err = project_model.CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
// User with write permission should be able to access workflows
session1 := loginUser(t, user.Name)
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/projects/%d/workflows", user.Name, repo.Name, project.ID))
session1.MakeRequest(t, req, http.StatusOK)
// User without write permission should not be able to modify workflows
session2 := loginUser(t, user2.Name)
req = NewRequest(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/%d/delete?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session2)))
session2.MakeRequest(t, req, http.StatusNotFound) // we use 404 to avoid leaking existence
}
func TestProjectWorkflowValidation(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
// Create a project
project := &project_model.Project{
Title: "Test Project for Workflow Validation",
RepoID: repo.ID,
Type: project_model.TypeRepository,
TemplateType: project_model.TemplateTypeNone,
}
err := project_model.NewProject(t.Context(), project)
assert.NoError(t, err)
session := loginUser(t, user.Name)
// Test 1: Try to create a workflow without any actions (should fail)
t.Run("Create workflow without actions should fail", func(t *testing.T) {
workflowData := map[string]any{
"event_id": string(project_model.WorkflowEventItemOpened),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "issue",
},
"actions": map[string]any{
// No actions provided - this should trigger validation error
},
}
body, err := json.Marshal(workflowData)
assert.NoError(t, err)
req := NewRequestWithBody(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/item_opened?_csrf=%s", user.Name, repo.Name, project.ID, GetUserCSRFToken(t, session)),
strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp := session.MakeRequest(t, req, http.StatusBadRequest)
// Parse response
var result map[string]any
err = json.Unmarshal(resp.Body.Bytes(), &result)
assert.NoError(t, err)
assert.Equal(t, "NoActions", result["error"], "Error should be NoActions")
assert.NotEmpty(t, result["message"], "Error message should be provided")
})
// Test 2: Try to update a workflow to have no actions (should fail)
t.Run("Update workflow to remove all actions should fail", func(t *testing.T) {
// First create a valid workflow
column := &project_model.Column{
Title: "Test Column",
ProjectID: project.ID,
}
err := project_model.NewColumn(t.Context(), column)
assert.NoError(t, err)
workflow := &project_model.Workflow{
ProjectID: project.ID,
WorkflowEvent: project_model.WorkflowEventItemOpened,
WorkflowFilters: []project_model.WorkflowFilter{
{
Type: project_model.WorkflowFilterTypeIssueType,
Value: "issue",
},
},
WorkflowActions: []project_model.WorkflowAction{
{
Type: project_model.WorkflowActionTypeColumn,
Value: strconv.FormatInt(column.ID, 10),
},
},
Enabled: true,
}
err = project_model.CreateWorkflow(t.Context(), workflow)
assert.NoError(t, err)
// Try to update it to have no actions
updateData := map[string]any{
"event_id": strconv.FormatInt(workflow.ID, 10),
"filters": map[string]any{
string(project_model.WorkflowFilterTypeIssueType): "issue",
},
"actions": map[string]any{
// No actions - should fail
},
}
body, err := json.Marshal(updateData)
assert.NoError(t, err)
req := NewRequestWithBody(t, "POST",
fmt.Sprintf("/%s/%s/projects/%d/workflows/%d?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session)),
strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp := session.MakeRequest(t, req, http.StatusBadRequest)
// Parse response
var result map[string]any
err = json.Unmarshal(resp.Body.Bytes(), &result)
assert.NoError(t, err)
assert.Equal(t, "NoActions", result["error"], "Error should be NoActions")
assert.NotEmpty(t, result["message"], "Error message should be provided")
// Verify the workflow was not changed
unchangedWorkflow, err := project_model.GetWorkflowByID(t.Context(), workflow.ID)
assert.NoError(t, err)
assert.Len(t, unchangedWorkflow.WorkflowActions, 1, "Workflow should still have the original action")
})
}

View File

@ -39,9 +39,18 @@ func TestRepoActivity(t *testing.T) {
testPullCreate(t, session, "user1", "repo1", false, "master", "feat/much_better_readme", "This is a pull title")
// Create issues (3 new issues)
testNewIssue(t, session, "user2", "repo1", "Issue 1", "Description 1")
testNewIssue(t, session, "user2", "repo1", "Issue 2", "Description 2")
testNewIssue(t, session, "user2", "repo1", "Issue 3", "Description 3")
testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Issue 1",
Content: "Description 1",
})
testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Issue 2",
Content: "Description 2",
})
testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Issue 3",
Content: "Description 3",
})
// Create releases (1 new release)
createNewRelease(t, session, "/user2/repo1", "v1.0.0", "v1.0.0", false, false)

View File

@ -255,7 +255,10 @@ func Test_WebhookIssueComment(t *testing.T) {
t.Run("create comment", func(t *testing.T) {
// 2. trigger the webhook
issueURL := testNewIssue(t, session, "user2", "repo1", "Title2", "Description2")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title2",
Content: "Description2",
})
testIssueAddComment(t, session, issueURL, "issue title2 comment1", "")
// 3. validate the webhook is triggered
@ -274,7 +277,10 @@ func Test_WebhookIssueComment(t *testing.T) {
triggeredEvent = ""
// 2. trigger the webhook
issueURL := testNewIssue(t, session, "user2", "repo1", "Title3", "Description3")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title3",
Content: "Description3",
})
commentID := testIssueAddComment(t, session, issueURL, "issue title3 comment1", "")
modifiedContent := "issue title2 comment1 - modified"
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{
@ -300,7 +306,10 @@ func Test_WebhookIssueComment(t *testing.T) {
commentContent := "issue title3 comment1"
// 2. trigger the webhook
issueURL := testNewIssue(t, session, "user2", "repo1", "Title3", "Description3")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title3",
Content: "Description3",
})
commentID := testIssueAddComment(t, session, issueURL, commentContent, "")
payloads = make([]api.IssueCommentPayload, 0, 2)
@ -511,7 +520,10 @@ func Test_WebhookIssue(t *testing.T) {
testAPICreateWebhookForRepo(t, session, "user2", "repo1", provider.URL(), "issues")
// 2. trigger the webhook
testNewIssue(t, session, "user2", "repo1", "Title1", "Description1")
testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title1",
Content: "Description1",
})
// 3. validate the webhook is triggered
assert.Equal(t, "issues", triggeredEvent)
@ -543,7 +555,10 @@ func Test_WebhookIssueDelete(t *testing.T) {
// 1. create a new webhook with special webhook for repo1
session := loginUser(t, "user2")
testAPICreateWebhookForRepo(t, session, "user2", "repo1", provider.URL(), "issues")
issueURL := testNewIssue(t, session, "user2", "repo1", "Title1", "Description1")
issueURL := testNewIssue(t, session, "user2", "repo1", newIssueOptions{
Title: "Title1",
Content: "Description1",
})
// 2. trigger the webhook
testIssueDelete(t, session, issueURL)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,358 @@
import {reactive} from 'vue';
import {GET, POST} from '../../modules/fetch.ts';
import {showErrorToast} from '../../modules/toast.ts';
type WorkflowFilters = {
issue_type: string;
source_column: string;
target_column: string;
labels: string[];
};
type WorkflowIssueStateAction = '' | 'close' | 'reopen';
type WorkflowActions = {
column: string;
add_labels: string[];
remove_labels: string[];
issue_state: WorkflowIssueStateAction;
};
type WorkflowDraftState = {
filters: WorkflowFilters;
actions: WorkflowActions;
};
const createDefaultFilters = (): WorkflowFilters => ({issue_type: '', source_column: '', target_column: '', labels: []});
const createDefaultActions = (): WorkflowActions => ({column: '', add_labels: [], remove_labels: [], issue_state: ''});
function convertFilters(workflow: any): WorkflowFilters {
const filters = createDefaultFilters();
if (workflow?.filters && Array.isArray(workflow.filters)) {
for (const filter of workflow.filters) {
if (filter.type === 'issue_type') {
filters.issue_type = filter.value;
} else if (filter.type === 'source_column') {
filters.source_column = filter.value;
} else if (filter.type === 'target_column') {
filters.target_column = filter.value;
} else if (filter.type === 'labels') {
filters.labels.push(filter.value);
}
}
}
return filters;
}
function convertActions(workflow: any): WorkflowActions {
const actions = createDefaultActions();
if (workflow?.actions && Array.isArray(workflow.actions)) {
for (const action of workflow.actions) {
if (action.type === 'column') {
// Backend returns string, keep as string to match column.id type
actions.column = action.value;
} else if (action.type === 'add_labels') {
// Backend returns string, keep as string to match label.id type
actions.add_labels.push(action.value);
} else if (action.type === 'remove_labels') {
// Backend returns string, keep as string to match label.id type
actions.remove_labels.push(action.value);
} else if (action.type === 'issue_state') {
actions.issue_state = action.value as WorkflowIssueStateAction;
}
}
}
return actions;
}
const cloneFilters = (filters: WorkflowFilters): WorkflowFilters => ({
issue_type: filters.issue_type,
source_column: filters.source_column,
target_column: filters.target_column,
labels: Array.from(filters.labels),
});
const cloneActions = (actions: WorkflowActions): WorkflowActions => ({
column: actions.column,
add_labels: Array.from(actions.add_labels),
remove_labels: Array.from(actions.remove_labels),
issue_state: actions.issue_state,
});
export function createWorkflowStore(props: any) {
const store = reactive({
workflowEvents: [],
selectedItem: props.eventID,
selectedWorkflow: null,
projectColumns: [],
projectLabels: [], // Add labels data
saving: false,
loading: false, // Add loading state to prevent rapid clicks
showCreateDialog: false, // For create workflow dialog
selectedEventType: null, // For workflow creation
workflowFilters: createDefaultFilters(),
workflowActions: createDefaultActions(),
workflowDrafts: {} as Record<string, WorkflowDraftState>,
getDraft(eventId: string): WorkflowDraftState | undefined {
return store.workflowDrafts[eventId];
},
updateDraft(eventId: string, filters: WorkflowFilters, actions: WorkflowActions) {
store.workflowDrafts[eventId] = {
filters: cloneFilters(filters),
actions: cloneActions(actions),
};
},
clearDraft(eventId: string) {
delete store.workflowDrafts[eventId];
},
async loadEvents() {
const response = await GET(`${props.projectLink}/workflows/events`);
store.workflowEvents = await response.json();
return store.workflowEvents;
},
async loadProjectColumns() {
try {
const response = await GET(`${props.projectLink}/workflows/columns`);
store.projectColumns = await response.json();
} catch (error) {
console.error('Failed to load project columns:', error);
store.projectColumns = [];
}
},
async loadWorkflowData(eventId: string) {
store.loading = true;
try {
// Load project columns and labels for the dropdowns
await store.loadProjectColumns();
await store.loadProjectLabels();
const draft = store.getDraft(eventId);
if (draft) {
store.workflowFilters = cloneFilters(draft.filters);
store.workflowActions = cloneActions(draft.actions);
return;
}
// Find the workflow from existing workflowEvents
const workflow = store.workflowEvents.find((e) => e.event_id === eventId);
store.workflowFilters = convertFilters(workflow);
store.workflowActions = convertActions(workflow);
store.updateDraft(eventId, store.workflowFilters, store.workflowActions);
} finally {
store.loading = false;
}
},
async loadProjectLabels() {
try {
const response = await GET(`${props.projectLink}/workflows/labels`);
store.projectLabels = await response.json();
} catch (error) {
console.error('Failed to load project labels:', error);
store.projectLabels = [];
}
},
resetWorkflowData() {
store.workflowFilters = createDefaultFilters();
store.workflowActions = createDefaultActions();
const currentEventId = store.selectedWorkflow?.event_id;
if (currentEventId) {
store.updateDraft(currentEventId, store.workflowFilters, store.workflowActions);
}
},
async saveWorkflow() {
if (!store.selectedWorkflow) return;
// Validate: at least one action must be configured
const hasAtLeastOneAction = Boolean(
store.workflowActions.column ||
store.workflowActions.add_labels.length > 0 ||
store.workflowActions.remove_labels.length > 0 ||
store.workflowActions.issue_state,
);
if (!hasAtLeastOneAction) {
showErrorToast(props.locale.atLeastOneActionRequired || 'At least one action must be configured');
return;
}
store.saving = true;
try {
// For new workflows, use the base event type
const eventId = store.selectedWorkflow.event_id;
// Convert frontend data format to backend JSON format
const postData = {
event_id: eventId,
filters: store.workflowFilters,
actions: store.workflowActions,
};
const response = await POST(`${props.projectLink}/workflows/${eventId}`, {
data: postData,
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
let errorMessage = `${props.locale.failedToSaveWorkflow}: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error === 'NoActions') {
errorMessage = props.locale.atLeastOneActionRequired || 'At least one action must be configured';
}
} catch {
const errorText = await response.text();
console.error('Response error:', errorText);
errorMessage += `\n${errorText}`;
}
showErrorToast(errorMessage);
return;
}
const result = await response.json();
if (result.success && result.workflow) {
// Always reload the events list to get the updated structure
// This ensures we have both the base event and the new filtered event
const eventKey = typeof store.selectedWorkflow.event_id === 'string' ? store.selectedWorkflow.event_id : '';
const wasNewWorkflow = store.selectedWorkflow.id === 0 ||
eventKey.startsWith('new-') ||
eventKey.startsWith('clone-');
if (wasNewWorkflow) {
store.clearDraft(store.selectedWorkflow.workflow_event);
}
// Reload events from server to get the correct event structure
await store.loadEvents();
// Find the reloaded workflow which has complete data including capabilities
const reloadedWorkflow = store.workflowEvents.find((w) => w.event_id === result.workflow.event_id);
if (reloadedWorkflow) {
// Use the reloaded workflow as it has all the necessary fields
store.selectedWorkflow = reloadedWorkflow;
store.selectedItem = reloadedWorkflow.event_id;
} else {
// Fallback: use the result from backend (shouldn't normally happen)
store.selectedWorkflow = result.workflow;
store.selectedItem = result.workflow.event_id;
}
store.workflowFilters = convertFilters(store.selectedWorkflow);
store.workflowActions = convertActions(store.selectedWorkflow);
if (store.selectedWorkflow?.event_id) {
store.updateDraft(store.selectedWorkflow.event_id, store.workflowFilters, store.workflowActions);
}
// Update URL to use the new workflow ID
if (wasNewWorkflow) {
const newUrl = `${props.projectLink}/workflows/${store.selectedWorkflow.event_id}`;
window.history.replaceState({eventId: store.selectedWorkflow.event_id}, '', newUrl);
}
} else {
console.error('Unexpected response format:', result);
showErrorToast(`${props.locale.failedToSaveWorkflow}: Unexpected response format`);
}
} catch (error) {
console.error('Failed to save workflow:', error);
showErrorToast(`${props.locale.failedToSaveWorkflow}: ${error.message}`);
} finally {
store.saving = false;
}
},
async saveWorkflowStatus() {
if (!store.selectedWorkflow || store.selectedWorkflow.id === 0) return;
try {
const formData = new FormData();
formData.append('enabled', store.selectedWorkflow.enabled.toString());
// Use workflow ID for status update
const workflowId = store.selectedWorkflow.id;
const response = await POST(`${props.projectLink}/workflows/${workflowId}/status`, {
data: formData,
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to update workflow status:', errorText);
showErrorToast(`${props.locale.failedToUpdateWorkflowStatus}: ${response.status} ${response.statusText}`);
// Revert the status change on error
store.selectedWorkflow.enabled = !store.selectedWorkflow.enabled;
return;
}
const result = await response.json();
if (result.success) {
// Update workflow in the list
const existingIndex = store.workflowEvents.findIndex((e) => e.event_id === store.selectedWorkflow.event_id);
if (existingIndex >= 0) {
store.workflowEvents[existingIndex].enabled = store.selectedWorkflow.enabled;
}
} else {
// Revert the status change on failure
store.selectedWorkflow.enabled = !store.selectedWorkflow.enabled;
showErrorToast(`${props.locale.failedToUpdateWorkflowStatus}: Unexpected error`);
}
} catch (error) {
console.error('Failed to update workflow status:', error);
// Revert the status change on error
store.selectedWorkflow.enabled = !store.selectedWorkflow.enabled;
showErrorToast(`${props.locale.failedToUpdateWorkflowStatus}: ${error.message}`);
}
},
async deleteWorkflow() {
if (!store.selectedWorkflow || store.selectedWorkflow.id === 0) return;
try {
// Use workflow ID for deletion
const workflowId = store.selectedWorkflow.id;
const response = await POST(`${props.projectLink}/workflows/${workflowId}/delete`, {
data: new FormData(),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to delete workflow:', errorText);
showErrorToast(`${props.locale.failedToDeleteWorkflow}: ${response.status} ${response.statusText}`);
return;
}
const result = await response.json();
if (result.success) {
// Remove workflow from the list
const existingIndex = store.workflowEvents.findIndex((e) => e.event_id === store.selectedWorkflow.event_id);
if (existingIndex >= 0) {
store.workflowEvents.splice(existingIndex, 1);
}
} else {
showErrorToast(`${props.locale.failedToDeleteWorkflow}: Unexpected error`);
}
} catch (error) {
console.error('Error deleting workflow:', error);
showErrorToast(`${props.locale.failedToDeleteWorkflow}: ${error.message}`);
}
},
});
return store;
}

View File

@ -0,0 +1,60 @@
import {createApp} from 'vue';
import ProjectWorkflow from '../../components/projects/ProjectWorkflow.vue';
export async function initProjectWorkflow() {
const workflowDiv = document.querySelector('#project-workflows');
if (!workflowDiv) return;
try {
const locale = {
defaultWorkflows: workflowDiv.getAttribute('data-locale-default-workflows'),
moveToColumn: workflowDiv.getAttribute('data-locale-move-to-column'),
viewWorkflowConfiguration: workflowDiv.getAttribute('data-locale-view-workflow-configuration'),
configureWorkflow: workflowDiv.getAttribute('data-locale-configure-workflow'),
when: workflowDiv.getAttribute('data-locale-when'),
runWhen: workflowDiv.getAttribute('data-locale-run-when'),
filters: workflowDiv.getAttribute('data-locale-filters'),
applyTo: workflowDiv.getAttribute('data-locale-apply-to'),
whenMovedFromColumn: workflowDiv.getAttribute('data-locale-when-moved-from-column'),
whenMovedToColumn: workflowDiv.getAttribute('data-locale-when-moved-to-column'),
onlyIfHasLabels: workflowDiv.getAttribute('data-locale-only-if-has-labels'),
actions: workflowDiv.getAttribute('data-locale-actions'),
addLabels: workflowDiv.getAttribute('data-locale-add-labels'),
removeLabels: workflowDiv.getAttribute('data-locale-remove-labels'),
anyLabel: workflowDiv.getAttribute('data-locale-any-label'),
anyColumn: workflowDiv.getAttribute('data-locale-any-column'),
issueState: workflowDiv.getAttribute('data-locale-issue-state'),
none: workflowDiv.getAttribute('data-locale-none'),
noChange: workflowDiv.getAttribute('data-locale-no-change'),
edit: workflowDiv.getAttribute('data-locale-edit'),
delete: workflowDiv.getAttribute('data-locale-delete'),
save: workflowDiv.getAttribute('data-locale-save'),
clone: workflowDiv.getAttribute('data-locale-clone'),
cancel: workflowDiv.getAttribute('data-locale-cancel'),
disable: workflowDiv.getAttribute('data-locale-disable'),
disabled: workflowDiv.getAttribute('data-locale-disabled'),
enabled: workflowDiv.getAttribute('data-locale-enabled'),
enable: workflowDiv.getAttribute('data-locale-enable'),
issuesAndPullRequests: workflowDiv.getAttribute('data-locale-issues-and-pull-requests'),
issuesOnly: workflowDiv.getAttribute('data-locale-issues-only'),
pullRequestsOnly: workflowDiv.getAttribute('data-locale-pull-requests-only'),
selectColumn: workflowDiv.getAttribute('data-locale-select-column'),
closeIssue: workflowDiv.getAttribute('data-locale-close-issue'),
reopenIssue: workflowDiv.getAttribute('data-locale-reopen-issue'),
saveWorkflowFailed: workflowDiv.getAttribute('data-locale-save-workflow-failed'),
updateWorkflowFailed: workflowDiv.getAttribute('data-locale-update-workflow-failed'),
deleteWorkflowFailed: workflowDiv.getAttribute('data-locale-delete-workflow-failed'),
atLeastOneActionRequired: workflowDiv.getAttribute('data-locale-at-least-one-action-required'),
};
const View = createApp(ProjectWorkflow, {
projectLink: workflowDiv.getAttribute('data-project-link'),
eventID: workflowDiv.getAttribute('data-event-id'),
locale,
});
View.mount(workflowDiv);
} catch (err) {
console.error('Project Workflow failed to load', err);
workflowDiv.textContent = 'Project Workflow failed to load';
}
}

View File

@ -64,6 +64,7 @@ import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton}
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initProjectWorkflow} from './features/projects/workflow.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
@ -159,6 +160,8 @@ const initPerformanceTracer = callInitFunctions([
initOAuth2SettingsDisableCheckbox,
initRepoFileView,
initProjectWorkflow,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.