From 6af504562b2b39bac032da3d157185397d6d5501 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Wed, 3 Sep 2025 13:09:28 -0700 Subject: [PATCH] new layout --- models/project/workflows.go | 64 +- routers/web/projects/workflows.go | 248 ++++++- routers/web/web.go | 1 + services/projects/workflow_notifier.go | 28 +- .../components/projects/ProjectWorkflow.vue | 635 ++++++++++++++---- .../js/components/projects/WorkflowStore.ts | 134 +++- 6 files changed, 903 insertions(+), 207 deletions(-) diff --git a/models/project/workflows.go b/models/project/workflows.go index d024c0d965..675b164b24 100644 --- a/models/project/workflows.go +++ b/models/project/workflows.go @@ -117,11 +117,59 @@ type WorkflowAction struct { ActionValue string } +// WorkflowEventCapabilities defines what filters and actions are available for each event +type WorkflowEventCapabilities struct { + AvailableFilters []string `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{ + WorkflowEventItemAddedToProject: { + AvailableFilters: []string{"scope"}, // issue, pull_request + AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel}, + }, + WorkflowEventItemReopened: { + AvailableFilters: []string{"scope"}, + AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel}, + }, + WorkflowEventItemClosed: { + AvailableFilters: []string{"scope"}, + AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel}, + }, + WorkflowEventCodeChangesRequested: { + AvailableFilters: []string{}, // only applies to pull requests + AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel}, + }, + WorkflowEventCodeReviewApproved: { + AvailableFilters: []string{}, // only applies to pull requests + AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel}, + }, + WorkflowEventPullRequestMerged: { + AvailableFilters: []string{}, // only applies to pull requests + AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel, WorkflowActionTypeClose}, + }, + WorkflowEventAutoArchiveItems: { + AvailableFilters: []string{"scope"}, + AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn}, + }, + WorkflowEventAutoAddToProject: { + AvailableFilters: []string{"scope"}, + AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel}, + }, + WorkflowEventAutoCloseIssue: { + AvailableFilters: []string{}, // only applies to issues + AvailableActions: []WorkflowActionType{WorkflowActionTypeClose, WorkflowActionTypeLabel}, + }, + } +} + type Workflow struct { ID int64 - ProjectID int64 `xorm:"unique(s)"` + ProjectID int64 `xorm:"INDEX"` Project *Project `xorm:"-"` - WorkflowEvent WorkflowEvent `xorm:"unique(s)"` + WorkflowEvent WorkflowEvent `xorm:"INDEX"` WorkflowFilters []WorkflowFilter `xorm:"TEXT json"` WorkflowActions []WorkflowAction `xorm:"TEXT json"` CreatedUnix timeutil.TimeStamp `xorm:"created"` @@ -157,16 +205,12 @@ func init() { db.RegisterModel(new(Workflow)) } -func FindWorkflowEvents(ctx context.Context, projectID int64) (map[WorkflowEvent]*Workflow, error) { - events := make(map[WorkflowEvent]*Workflow) - if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&events); err != nil { +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 } - res := make(map[WorkflowEvent]*Workflow, len(events)) - for _, event := range events { - res[event.WorkflowEvent] = event - } - return res, nil + return workflows, nil } func GetWorkflowByID(ctx context.Context, id int64) (*Workflow, error) { diff --git a/routers/web/projects/workflows.go b/routers/web/projects/workflows.go index c17e73dfde..72ec5a9ec1 100644 --- a/routers/web/projects/workflows.go +++ b/routers/web/projects/workflows.go @@ -4,11 +4,11 @@ package projects import ( - "fmt" "net/http" "strconv" - "code.gitea.io/gitea/models/project" + "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/templates" "code.gitea.io/gitea/modules/web" @@ -20,6 +20,78 @@ var ( tmplOrgWorkflows = templates.TplName("org/projects/workflows") ) +// getFilterSummary returns a human-readable summary of the filters +func getFilterSummary(filters []project_model.WorkflowFilter) string { + if len(filters) == 0 { + return "" + } + + for _, filter := range filters { + if filter.Type == "scope" { + switch filter.Value { + case "issue": + return " (Issues only)" + case "pull_request": + return " (Pull requests only)" + } + } + } + return "" +} + +// convertFormToFilters converts form filters to WorkflowFilter objects +func convertFormToFilters(formFilters map[string]string) []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, + }) + } + } + + 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 strValue, ok := value.(string); ok && strValue != "" { + actions = append(actions, project_model.WorkflowAction{ + ActionType: project_model.WorkflowActionTypeColumn, + ActionValue: strValue, + }) + } + case "labels": + if labels, ok := value.([]string); ok && len(labels) > 0 { + for _, label := range labels { + if label != "" { + actions = append(actions, project_model.WorkflowAction{ + ActionType: project_model.WorkflowActionTypeLabel, + ActionValue: label, + }) + } + } + } + case "closeIssue": + if boolValue, ok := value.(bool); ok && boolValue { + actions = append(actions, project_model.WorkflowAction{ + ActionType: project_model.WorkflowActionTypeClose, + ActionValue: "true", + }) + } + } + } + + return actions +} + func WorkflowsEvents(ctx *context.Context) { projectID := ctx.PathParamInt64("id") p, err := project_model.GetProjectByID(ctx, projectID) @@ -40,35 +112,60 @@ func WorkflowsEvents(ctx *context.Context) { return } - workflows, err := project_model.FindWorkflowEvents(ctx, projectID) + workflows, err := project_model.FindWorkflowsByProjectID(ctx, projectID) if err != nil { - ctx.ServerError("GetWorkflows", err) + ctx.ServerError("FindWorkflowsByProjectID", err) return } - type WorkflowEvent struct { - EventID string `json:"event_id"` - DisplayName string `json:"display_name"` + + type WorkflowConfig struct { + ID int64 `json:"id"` + EventID string `json:"event_id"` + DisplayName string `json:"display_name"` + Capabilities project_model.WorkflowEventCapabilities `json:"capabilities"` + Filters []project_model.WorkflowFilter `json:"filters"` + Actions []project_model.WorkflowAction `json:"actions"` + FilterSummary string `json:"filter_summary"` // Human readable filter description } - outputWorkflows := make([]*WorkflowEvent, 0, len(workflows)) + + 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 { - var workflow *WorkflowEvent - for _, wf := range workflows { - if wf.WorkflowEvent == event { - workflow = &WorkflowEvent{ - EventID: fmt.Sprintf("%d", wf.ID), - DisplayName: string(ctx.Tr(wf.WorkflowEvent.LangKey())), - } - break + existingWorkflows := workflowMap[event] + if len(existingWorkflows) > 0 { + // Add all existing workflows for this event + for _, wf := range existingWorkflows { + filterSummary := getFilterSummary(wf.WorkflowFilters) + outputWorkflows = append(outputWorkflows, &WorkflowConfig{ + ID: wf.ID, + EventID: strconv.FormatInt(wf.ID, 10), + DisplayName: string(ctx.Tr(wf.WorkflowEvent.LangKey())) + filterSummary, + Capabilities: capabilities[event], + Filters: wf.WorkflowFilters, + Actions: wf.WorkflowActions, + FilterSummary: filterSummary, + }) } + } else { + // Add placeholder for creating new workflow + outputWorkflows = append(outputWorkflows, &WorkflowConfig{ + ID: 0, + EventID: event.UUID(), + DisplayName: string(ctx.Tr(event.LangKey())), + Capabilities: capabilities[event], + Filters: []project_model.WorkflowFilter{}, + Actions: []project_model.WorkflowAction{}, + FilterSummary: "", + }) } - if workflow == nil { - workflow = &WorkflowEvent{ - EventID: event.UUID(), - DisplayName: string(ctx.Tr(event.LangKey())), - } - } - outputWorkflows = append(outputWorkflows, workflow) } ctx.JSON(http.StatusOK, outputWorkflows) @@ -115,6 +212,53 @@ func WorkflowsColumns(ctx *context.Context) { 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"` + } + outputLabels := make([]*Label, 0, len(labels)) + for _, label := range labels { + outputLabels = append(outputLabels, &Label{ + ID: label.ID, + Name: label.Name, + Color: label.Color, + }) + } + + ctx.JSON(http.StatusOK, outputLabels) +} + func Workflows(ctx *context.Context) { workflowIDStr := ctx.PathParam("workflow_id") if workflowIDStr == "events" { @@ -125,6 +269,10 @@ func Workflows(ctx *context.Context) { WorkflowsColumns(ctx) return } + if workflowIDStr == "labels" { + WorkflowsLabels(ctx) + return + } ctx.Data["WorkflowEvents"] = project_model.GetWorkflowEvents() @@ -153,9 +301,9 @@ func Workflows(ctx *context.Context) { ctx.Data["PageIsProjectsWorkflows"] = true ctx.Data["Project"] = p - workflows, err := project_model.FindWorkflowEvents(ctx, projectID) + workflows, err := project_model.FindWorkflowsByProjectID(ctx, projectID) if err != nil { - ctx.ServerError("GetWorkflows", err) + ctx.ServerError("FindWorkflowsByProjectID", err) return } for _, wf := range workflows { @@ -184,7 +332,7 @@ func Workflows(ctx *context.Context) { } } ctx.Data["CurWorkflow"] = curWorkflow - ctx.Data["ProjectLink"] = project.ProjectLinkForRepo(ctx.Repo.Repository, projectID) + ctx.Data["ProjectLink"] = project_model.ProjectLinkForRepo(ctx.Repo.Repository, projectID) if p.Type == project_model.TypeRepository { ctx.HTML(200, tmplRepoWorkflows) @@ -220,19 +368,38 @@ func WorkflowsPost(ctx *context.Context) { } form := web.GetForm(ctx).(*WorkflowsPostForm) + + // Convert form data to filters and actions + filters := convertFormToFilters(form.Filters) + actions := convertFormToActions(form.Actions) + eventID, _ := strconv.ParseInt(form.EventID, 10, 64) if eventID == 0 { - // Create a new workflow + // Create a new workflow for the given event wf := &project_model.Workflow{ ProjectID: projectID, WorkflowEvent: project_model.WorkflowEvent(form.EventID), - WorkflowFilters: []project_model.WorkflowFilter{}, - WorkflowActions: []project_model.WorkflowAction{}, + WorkflowFilters: filters, + WorkflowActions: actions, } if err := project_model.CreateWorkflow(ctx, wf); err != nil { ctx.ServerError("CreateWorkflow", err) return } + + // Return the newly created workflow with filter summary + filterSummary := getFilterSummary(wf.WorkflowFilters) + 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())) + filterSummary, + "filters": wf.WorkflowFilters, + "actions": wf.WorkflowActions, + "filter_summary": filterSummary, + }, + }) } else { // Update an existing workflow wf, err := project_model.GetWorkflowByID(ctx, eventID) @@ -240,11 +407,30 @@ func WorkflowsPost(ctx *context.Context) { ctx.ServerError("GetWorkflowByID", err) return } - wf.WorkflowFilters = []project_model.WorkflowFilter{} - wf.WorkflowActions = []project_model.WorkflowAction{} + 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 + filterSummary := getFilterSummary(wf.WorkflowFilters) + 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())) + filterSummary, + "filters": wf.WorkflowFilters, + "actions": wf.WorkflowActions, + "filter_summary": filterSummary, + }, + }) } } diff --git a/routers/web/web.go b/routers/web/web.go index aac3ff34e3..64364aec55 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1433,6 +1433,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/{id}/workflows", func() { m.Get("", projects.Workflows) m.Get("/{workflow_id}", projects.Workflows) + m.Post("/{workflow_id}", web.Bind(projects.WorkflowsPostForm{}), projects.WorkflowsPost) }) m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054 m.Get("/new", repo.RenderNewProject) diff --git a/services/projects/workflow_notifier.go b/services/projects/workflow_notifier.go index ccde3e6686..9de28d005f 100644 --- a/services/projects/workflow_notifier.go +++ b/services/projects/workflow_notifier.go @@ -44,18 +44,18 @@ func (m *workflowNotifier) NewIssue(ctx context.Context, issue *issues_model.Iss return } - eventWorkflows, err := project_model.FindWorkflowEvents(ctx, issue.Project.ID) + workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID) if err != nil { - log.Error("NewIssue: FindWorkflowEvents: %v", err) + log.Error("NewIssue: FindWorkflowsByProjectID: %v", err) return } - workflow := eventWorkflows[project_model.WorkflowEventItemAddedToProject] - if workflow == nil { - return + // Find workflows for the ItemAddedToProject event + for _, workflow := range workflows { + if workflow.WorkflowEvent == project_model.WorkflowEventItemAddedToProject { + fireIssueWorkflow(ctx, workflow, issue) + } } - - fireIssueWorkflow(ctx, workflow, issue) } func (m *workflowNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { @@ -71,19 +71,19 @@ func (m *workflowNotifier) IssueChangeStatus(ctx context.Context, doer *user_mod return } - eventWorkflows, err := project_model.FindWorkflowEvents(ctx, issue.Project.ID) + workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID) if err != nil { - log.Error("NewIssue: FindWorkflowEvents: %v", err) + log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err) return } workflowEvent := util.Iif(isClosed, project_model.WorkflowEventItemClosed, project_model.WorkflowEventItemReopened) - workflow := eventWorkflows[workflowEvent] - if workflow == nil { - return + // Find workflows for the specific event + for _, workflow := range workflows { + if workflow.WorkflowEvent == workflowEvent { + fireIssueWorkflow(ctx, workflow, issue) + } } - - fireIssueWorkflow(ctx, workflow, issue) } func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, issue *issues_model.Issue) { diff --git a/web_src/js/components/projects/ProjectWorkflow.vue b/web_src/js/components/projects/ProjectWorkflow.vue index 9315c803f8..3d7cdc75a7 100644 --- a/web_src/js/components/projects/ProjectWorkflow.vue +++ b/web_src/js/components/projects/ProjectWorkflow.vue @@ -1,5 +1,5 @@