gitea/routers/web/projects/workflows.go

625 lines
18 KiB
Go

// 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,
})
}