diff --git a/models/project/workflows.go b/models/project/workflows.go index 1e92f434b3..459bf3b0fe 100644 --- a/models/project/workflows.go +++ b/models/project/workflows.go @@ -91,6 +91,7 @@ type WorkflowFilterType string const ( WorkflowFilterTypeIssueType WorkflowFilterType = "issue_type" // issue, pull_request, etc. + WorkflowFilterTypeColumn WorkflowFilterType = "column" // target column for item_column_changed event ) type WorkflowFilter struct { @@ -138,8 +139,8 @@ func GetWorkflowEventCapabilities() map[WorkflowEvent]WorkflowEventCapabilities AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}, }, WorkflowEventItemColumnChanged: { - AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType}, - AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeClose}, + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeColumn}, + AvailableActions: []WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeClose}, }, WorkflowEventCodeChangesRequested: { AvailableFilters: []WorkflowFilterType{}, // only applies to pull requests diff --git a/routers/web/projects/workflows.go b/routers/web/projects/workflows.go index f54fc83fdd..0df4e5297c 100644 --- a/routers/web/projects/workflows.go +++ b/routers/web/projects/workflows.go @@ -75,6 +75,7 @@ func convertFormToActions(formActions map[string]any) []project_model.WorkflowAc } } case "add_labels": + // Handle both []string and []interface{} from JSON unmarshaling if labels, ok := value.([]string); ok && len(labels) > 0 { for _, label := range labels { if label != "" { @@ -84,8 +85,18 @@ func convertFormToActions(formActions map[string]any) []project_model.WorkflowAc }) } } + } else if labelInterfaces, ok := value.([]interface{}); 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 []interface{} from JSON unmarshaling if labels, ok := value.([]string); ok && len(labels) > 0 { for _, label := range labels { if label != "" { @@ -95,6 +106,15 @@ func convertFormToActions(formActions map[string]any) []project_model.WorkflowAc }) } } + } else if labelInterfaces, ok := value.([]interface{}); 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 "closeIssue": if boolValue, ok := value.(bool); ok && boolValue { diff --git a/services/notify/notifier.go b/services/notify/notifier.go index a599f10f6f..c0c6482c26 100644 --- a/services/notify/notifier.go +++ b/services/notify/notifier.go @@ -43,6 +43,7 @@ type Notifier interface { 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, 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 5bff7ea193..159288108f 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -282,6 +282,12 @@ func IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issu } } +func IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newColumnID int64) { + for _, notifier := range notifiers { + notifier.IssueChangeProjectColumn(ctx, doer, issue, 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 1f612033fe..52db060d0d 100644 --- a/services/notify/null.go +++ b/services/notify/null.go @@ -147,6 +147,9 @@ func (*NullNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.Use 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, 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 25f6400c52..1832f47447 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -12,30 +12,32 @@ 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 + } + + if err := db.WithTx(ctx, func(ctx context.Context) error { if _, err := issues.LoadRepositories(ctx); err != nil { return err } @@ -83,7 +85,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, column.ID) + } + + return nil } // LoadIssuesFromProject load issues assigned to each project column inside the given project @@ -207,7 +217,7 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj } func MoveIssueToAnotherColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column) error { - return db.WithTx(ctx, func(ctx context.Context) error { + if err := db.WithTx(ctx, func(ctx context.Context) error { if err := project_model.MoveIssueToAnotherColumn(ctx, issue.ID, column); err != nil { return err } @@ -230,5 +240,10 @@ func MoveIssueToAnotherColumn(ctx context.Context, doer *user_model.User, issue return err } return nil - }) + }); err != nil { + return err + } + + notify.IssueChangeProjectColumn(ctx, doer, issue, column.ID) + return nil } diff --git a/services/projects/workflow_notifier.go b/services/projects/workflow_notifier.go index 2cbf857634..3dc9cde2a8 100644 --- a/services/projects/workflow_notifier.go +++ b/services/projects/workflow_notifier.go @@ -5,6 +5,7 @@ package projects import ( "context" + "errors" "slices" "strconv" "strings" @@ -130,6 +131,40 @@ func (*workflowNotifier) IssueChangeProjects(ctx context.Context, doer *user_mod } } +func (*workflowNotifier) IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, 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 ItemOpened event + for _, workflow := range workflows { + if workflow.WorkflowEvent == project_model.WorkflowEventItemColumnChanged { + fireIssueWorkflow(ctx, workflow, issue) + } + } +} + 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) @@ -211,6 +246,20 @@ func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, is if !(slices.Contains(values, "issue") && !issue.IsPull) || (slices.Contains(values, "pull") && issue.IsPull) { return } + case project_model.WorkflowFilterTypeColumn: + columnID, _ := strconv.ParseInt(filter.Value, 10, 64) + if columnID == 0 { + log.Error("Invalid column ID: %s", filter.Value) + return + } + issueProjectColumnID, err := issue.ProjectColumnID(ctx) + if err != nil { + log.Error("Issue.ProjectColumnID: %v", err) + return + } + if issueProjectColumnID != columnID { + return + } default: log.Error("Unsupported filter type: %s", filter.Type) return @@ -235,9 +284,37 @@ func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, is continue } case project_model.WorkflowActionTypeAddLabels: - // TODO: implement adding labels + 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: - // TODO: implement removing labels + 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.WorkflowActionTypeClose: if err := issue_service.CloseIssue(ctx, issue, user_model.NewProjectWorkflowsUser(), ""); err != nil { log.Error("CloseIssue: %v", err) diff --git a/web_src/js/components/projects/ProjectWorkflow.vue b/web_src/js/components/projects/ProjectWorkflow.vue index 1e47b62775..e595be0510 100644 --- a/web_src/js/components/projects/ProjectWorkflow.vue +++ b/web_src/js/components/projects/ProjectWorkflow.vue @@ -1,8 +1,9 @@