diff --git a/models/project/workflows.go b/models/project/workflows.go index 459bf3b0fe..2259dcac41 100644 --- a/models/project/workflows.go +++ b/models/project/workflows.go @@ -92,6 +92,7 @@ type WorkflowFilterType string const ( WorkflowFilterTypeIssueType WorkflowFilterType = "issue_type" // issue, pull_request, etc. WorkflowFilterTypeColumn WorkflowFilterType = "column" // target column for item_column_changed event + WorkflowFilterTypeLabels WorkflowFilterType = "labels" // filter by issue/PR labels ) type WorkflowFilter struct { @@ -123,35 +124,35 @@ type WorkflowEventCapabilities struct { func GetWorkflowEventCapabilities() map[WorkflowEvent]WorkflowEventCapabilities { return map[WorkflowEvent]WorkflowEventCapabilities{ WorkflowEventItemOpened: { - AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType}, // issue, pull_request + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels}, AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels}, }, WorkflowEventItemAddedToProject: { - AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType}, // issue, pull_request + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels}, AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}, }, WorkflowEventItemReopened: { - AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType}, + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels}, AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}, }, WorkflowEventItemClosed: { - AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType}, + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeLabels}, AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}, }, WorkflowEventItemColumnChanged: { - AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeColumn}, + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeColumn, WorkflowFilterTypeLabels}, AvailableActions: []WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeClose}, }, WorkflowEventCodeChangesRequested: { - AvailableFilters: []WorkflowFilterType{}, // only applies to pull requests + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeLabels}, // only applies to pull requests AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}, }, WorkflowEventCodeReviewApproved: { - AvailableFilters: []WorkflowFilterType{}, // only applies to pull requests + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeLabels}, // only applies to pull requests AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}, }, WorkflowEventPullRequestMerged: { - AvailableFilters: []WorkflowFilterType{}, // only applies to pull requests + AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeLabels}, // only applies to pull requests AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}, }, } diff --git a/routers/web/projects/workflows.go b/routers/web/projects/workflows.go index 0df4e5297c..894d882287 100644 --- a/routers/web/projects/workflows.go +++ b/routers/web/projects/workflows.go @@ -43,15 +43,31 @@ func getFilterSummary(filters []project_model.WorkflowFilter) string { } // convertFormToFilters converts form filters to WorkflowFilter objects -func convertFormToFilters(formFilters map[string]string) []project_model.WorkflowFilter { +func convertFormToFilters(formFilters map[string]any) []project_model.WorkflowFilter { filters := make([]project_model.WorkflowFilter, 0) for key, value := range formFilters { - if value != "" { - filters = append(filters, project_model.WorkflowFilter{ - Type: project_model.WorkflowFilterType(key), - Value: value, - }) + switch key { + case "labels": + // Handle labels array + if labelInterfaces, ok := value.([]interface{}); 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, + }) + } } } @@ -378,9 +394,9 @@ func Workflows(ctx *context.Context) { } type WorkflowsPostForm struct { - EventID string `json:"event_id"` - Filters map[string]string `json:"filters"` - Actions map[string]any `json:"actions"` + EventID string `json:"event_id"` + Filters map[string]any `json:"filters"` + Actions map[string]any `json:"actions"` } func WorkflowsPost(ctx *context.Context) { diff --git a/services/projects/workflow_notifier.go b/services/projects/workflow_notifier.go index 3dc9cde2a8..77959d8287 100644 --- a/services/projects/workflow_notifier.go +++ b/services/projects/workflow_notifier.go @@ -5,10 +5,7 @@ package projects import ( "context" - "errors" - "slices" "strconv" - "strings" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" @@ -157,10 +154,10 @@ func (*workflowNotifier) IssueChangeProjectColumn(ctx context.Context, doer *use return } - // Find workflows for the ItemOpened event + // Find workflows for the ItemColumnChanged event for _, workflow := range workflows { if workflow.WorkflowEvent == project_model.WorkflowEventItemColumnChanged { - fireIssueWorkflow(ctx, workflow, issue) + fireIssueWorkflowWithColumn(ctx, workflow, issue, newColumnID) } } } @@ -238,15 +235,96 @@ func (*workflowNotifier) PullRequestReview(ctx context.Context, pr *issues_model } } -func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, issue *issues_model.Issue) { +// fireIssueWorkflowWithColumn fires a workflow for an issue with a specific column ID +// This is used for ItemColumnChanged events where we need to check the target column +func fireIssueWorkflowWithColumn(ctx context.Context, workflow *project_model.Workflow, issue *issues_model.Issue, columnID int64) { + // Load issue labels for labels filter + if err := issue.LoadLabels(ctx); err != nil { + log.Error("LoadLabels: %v", err) + return + } + for _, filter := range workflow.WorkflowFilters { switch filter.Type { case project_model.WorkflowFilterTypeIssueType: - values := strings.Split(filter.Value, ",") - if !(slices.Contains(values, "issue") && !issue.IsPull) || (slices.Contains(values, "pull") && issue.IsPull) { + // 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 + } + if filter.Value == "pull_request" && !issue.IsPull { return } case project_model.WorkflowFilterTypeColumn: + // 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 + } + // For column changed event, check against the new column ID + if columnID != filterColumnID { + return + } + 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 + } + // Check if issue has this label + hasLabel := false + for _, label := range issue.Labels { + if label.ID == labelID { + hasLabel = true + break + } + } + if !hasLabel { + return + } + default: + log.Error("Unsupported filter type: %s", filter.Type) + return + } + } + + executeWorkflowActions(ctx, workflow, issue) +} + +func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, issue *issues_model.Issue) { + // Load issue labels for labels filter + if err := issue.LoadLabels(ctx); err != nil { + log.Error("LoadLabels: %v", err) + return + } + + 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 + } + if filter.Value == "pull_request" && !issue.IsPull { + return + } + case project_model.WorkflowFilterTypeColumn: + // If filter value is empty, match all columns + if filter.Value == "" { + continue + } columnID, _ := strconv.ParseInt(filter.Value, 10, 64) if columnID == 0 { log.Error("Invalid column ID: %s", filter.Value) @@ -260,12 +338,34 @@ func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, is if issueProjectColumnID != columnID { return } + 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 + } + // Check if issue has this label + hasLabel := false + for _, label := range issue.Labels { + if label.ID == labelID { + hasLabel = true + break + } + } + if !hasLabel { + return + } default: log.Error("Unsupported filter type: %s", filter.Type) return } } + executeWorkflowActions(ctx, workflow, issue) +} + +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: diff --git a/web_src/js/components/projects/ProjectWorkflow.vue b/web_src/js/components/projects/ProjectWorkflow.vue index e595be0510..de00ba2d84 100644 --- a/web_src/js/components/projects/ProjectWorkflow.vue +++ b/web_src/js/components/projects/ProjectWorkflow.vue @@ -332,9 +332,14 @@ const isItemSelected = (item) => { return store.selectedItem === item.base_event_type; }; -// Toggle label selection for add_labels or remove_labels -const toggleLabel = (actionType, labelId) => { - const labels = store.workflowActions[actionType]; +// Toggle label selection for add_labels, remove_labels, or filter_labels +const toggleLabel = (type, labelId) => { + let labels; + if (type === 'filter_labels') { + labels = store.workflowFilters.labels; + } else { + labels = store.workflowActions[type]; + } const index = labels.indexOf(labelId); if (index > -1) { labels.splice(index, 1); @@ -672,6 +677,42 @@ onUnmounted(() => { {{ store.projectColumns.find(c => String(c.id) === store.workflowFilters.column)?.title || 'Any column' }} + +
+ + +
+ Any labels + + {{ store.projectLabels.find(l => String(l.id) === labelId)?.name }} + +
+
diff --git a/web_src/js/components/projects/WorkflowStore.ts b/web_src/js/components/projects/WorkflowStore.ts index 4a45968eb7..a3b0e71a35 100644 --- a/web_src/js/components/projects/WorkflowStore.ts +++ b/web_src/js/components/projects/WorkflowStore.ts @@ -17,6 +17,7 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin workflowFilters: { issue_type: '', // 'issue', 'pull_request', or '' column: '', // target column ID for item_column_changed event + labels: [], // label IDs to filter by }, workflowActions: { @@ -60,7 +61,7 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin // Load existing configuration from the workflow data // Convert backend filter format to frontend format - const frontendFilters = {issue_type: '', column: ''}; + const frontendFilters = {issue_type: '', column: '', labels: []}; // Convert backend action format to frontend format const frontendActions = {column: '', add_labels: [], remove_labels: [], closeIssue: false}; @@ -70,6 +71,8 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin frontendFilters.issue_type = filter.value; } else if (filter.type === 'column') { frontendFilters.column = filter.value; + } else if (filter.type === 'labels') { + frontendFilters.labels.push(filter.value); } } @@ -107,7 +110,7 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin }, resetWorkflowData() { - store.workflowFilters = {issue_type: '', column: ''}; + store.workflowFilters = {issue_type: '', column: '', labels: []}; store.workflowActions = {column: '', add_labels: [], remove_labels: [], closeIssue: false}; }, @@ -170,7 +173,7 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin // Convert backend data to frontend format and update form // Use the selectedWorkflow which now points to the reloaded workflow with complete data - const frontendFilters = {issue_type: '', column: ''}; + const frontendFilters = {issue_type: '', column: '', labels: []}; const frontendActions = {column: '', add_labels: [], remove_labels: [], closeIssue: false}; if (store.selectedWorkflow.filters && Array.isArray(store.selectedWorkflow.filters)) { @@ -179,6 +182,8 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin frontendFilters.issue_type = filter.value; } else if (filter.type === 'column') { frontendFilters.column = filter.value; + } else if (filter.type === 'labels') { + frontendFilters.labels.push(filter.value); } } }