diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index e8ebb5df43ce1..b87922a7de9e3 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_26/v324.go b/models/migrations/v1_26/v324.go new file mode 100644 index 0000000000000..9bfd203cd7f42 --- /dev/null +++ b/models/migrations/v1_26/v324.go @@ -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{}) +} diff --git a/models/project/column.go b/models/project/column.go index 9b9d874997edd..814794fac7801 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -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 diff --git a/models/project/project.go b/models/project/project.go index c003664fa3f0a..e423a8025bdb6 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -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) diff --git a/models/project/workflows.go b/models/project/workflows.go new file mode 100644 index 0000000000000..392cc717d1746 --- /dev/null +++ b/models/project/workflows.go @@ -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 +} diff --git a/models/project/workflows_test.go b/models/project/workflows_test.go new file mode 100644 index 0000000000000..d75595f4cbe1d --- /dev/null +++ b/models/project/workflows_test.go @@ -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) +} diff --git a/models/user/user.go b/models/user/user.go index d6e1eec27674c..1075f2bd822ea 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -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, }, } }) diff --git a/models/user/user_list.go b/models/user/user_list.go index 1b6a27dd8623c..0114db8dbdb8a 100644 --- a/models/user/user_list.go +++ b/models/user/user_list.go @@ -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: diff --git a/models/user/user_system.go b/models/user/user_system.go index 11008c77d4544..86fbab5745d40 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -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 } diff --git a/models/user/user_system_test.go b/models/user/user_system_test.go index 5aa3fa463c933..9bc9d4552f2c1 100644 --- a/models/user/user_system_test.go +++ b/models/user/user_system_test.go @@ -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) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ddc12aefaaaf9..ae877827c45a1 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3929,7 +3929,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 diff --git a/routers/web/projects/workflows.go b/routers/web/projects/workflows.go new file mode 100644 index 0000000000000..0e62bcc3b5931 --- /dev/null +++ b/routers/web/projects/workflows.go @@ -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, + }) +} diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index a57976b4ca8f4..c96140332c5fa 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -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)) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 0f9f551e12e45..13a965b8210b3 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1421,6 +1421,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) { diff --git a/routers/web/web.go b/routers/web/web.go index 8b55e4469eeb7..76f796e6bcbed 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/issue/label.go b/services/issue/label.go index e30983df379e2..3a054d0b07ede 100644 --- a/services/issue/label.go +++ b/services/issue/label.go @@ -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 diff --git a/services/issue/project.go b/services/issue/project.go new file mode 100644 index 0000000000000..eb6c4b9f256c9 --- /dev/null +++ b/services/issue/project.go @@ -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 +} diff --git a/services/notify/notifier.go b/services/notify/notifier.go index 875a70e5644a7..fcb27450c44a7 100644 --- a/services/notify/notifier.go +++ b/services/notify/notifier.go @@ -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) diff --git a/services/notify/notify.go b/services/notify/notify.go index 2416cbd2e0830..7d90ccfcf4519 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -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 { diff --git a/services/notify/null.go b/services/notify/null.go index c3085d7c9eb0a..9c92af7b3bcaf 100644 --- a/services/notify/null.go +++ b/services/notify/null.go @@ -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) { } diff --git a/services/projects/issue.go b/services/projects/issue.go index 590fe960d5329..41736cc8de8f1 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -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,39 @@ 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 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 +} diff --git a/services/projects/issue_test.go b/services/projects/issue_test.go index 7255cdfe52760..d23ee0d8f93eb 100644 --- a/services/projects/issue_test.go +++ b/services/projects/issue_test.go @@ -1,7 +1,7 @@ // Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package project +package projects import ( "testing" diff --git a/services/projects/main_test.go b/services/projects/main_test.go index d39c82a140e5e..901f2eba0cc93 100644 --- a/services/projects/main_test.go +++ b/services/projects/main_test.go @@ -1,7 +1,7 @@ // Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package project +package projects import ( "testing" diff --git a/services/projects/workflow.go b/services/projects/workflow.go new file mode 100644 index 0000000000000..27040f8eeda4f --- /dev/null +++ b/services/projects/workflow.go @@ -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() +} diff --git a/services/projects/workflow_notifier.go b/services/projects/workflow_notifier.go new file mode 100644 index 0000000000000..1ab446b9655e0 --- /dev/null +++ b/services/projects/workflow_notifier.go @@ -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) { + 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 + } + if err := issue_service.AddLabel(ctx, issue, user_model.NewProjectWorkflowsUser(), label); err != nil { + log.Error("AddLabels: %v", err) + continue + } + 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 + } + if err := issue_service.RemoveLabel(ctx, issue, user_model.NewProjectWorkflowsUser(), label); err != nil { + if !issues_model.IsErrRepoLabelNotExist(err) { + log.Error("RemoveLabels: %v", err) + } + continue + } + 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) + } + } +} diff --git a/templates/org/projects/workflows.tmpl b/templates/org/projects/workflows.tmpl new file mode 100644 index 0000000000000..ca5124e2c85ce --- /dev/null +++ b/templates/org/projects/workflows.tmpl @@ -0,0 +1,13 @@ +{{template "base/head" .}} +
+ {{if .ContextUser.IsOrganization}} + {{template "org/header" .}} + {{else}} + {{template "shared/user/org_profile_avatar" .}} +
+ {{template "user/overview/header" .}} +
+ {{end}} + {{template "projects/workflows" .}} +
+{{template "base/footer" .}} diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index 21bc287643014..389321cb1c436 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -19,6 +19,10 @@ {{if $canWriteProject}}