pull/30205/head
Lunny Xiao 2025-09-04 20:23:37 +07:00
parent 58a368021f
commit a18eba0026
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
4 changed files with 184 additions and 173 deletions

@ -4,14 +4,17 @@
package projects
import (
"errors"
"io"
"net/http"
"strconv"
"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/modules/web"
"code.gitea.io/gitea/services/context"
)
@ -55,6 +58,14 @@ func convertFormToFilters(formFilters map[string]string) []project_model.Workflo
return filters
}
func convertFiltersToMap(filters []project_model.WorkflowFilter) map[string]string {
filterMap := make(map[string]string)
for _, filter := range filters {
filterMap[string(filter.Type)] = filter.Value
}
return filterMap
}
// convertFormToActions converts form actions to WorkflowAction objects
func convertFormToActions(formActions map[string]any) []project_model.WorkflowAction {
actions := make([]project_model.WorkflowAction, 0)
@ -62,11 +73,14 @@ func convertFormToActions(formActions map[string]any) []project_model.WorkflowAc
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,
})
if floatValue, ok := value.(float64); ok {
floatValueInt := int64(floatValue)
if floatValueInt > 0 {
actions = append(actions, project_model.WorkflowAction{
ActionType: project_model.WorkflowActionTypeColumn,
ActionValue: strconv.FormatInt(floatValueInt, 10),
})
}
}
case "add_labels":
if labels, ok := value.([]string); ok && len(labels) > 0 {
@ -103,6 +117,14 @@ func convertFormToActions(formActions map[string]any) []project_model.WorkflowAc
return actions
}
func convertActionsToMap(actions []project_model.WorkflowAction) map[string]any {
actionMap := make(map[string]any)
for _, action := range actions {
actionMap[string(action.ActionType)] = action.ActionValue
}
return actionMap
}
func WorkflowsEvents(ctx *context.Context) {
projectID := ctx.PathParamInt64("id")
p, err := project_model.GetProjectByID(ctx, projectID)
@ -134,8 +156,8 @@ func WorkflowsEvents(ctx *context.Context) {
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"`
Filters map[string]string `json:"filters"`
Actions map[string]any `json:"actions"`
FilterSummary string `json:"filter_summary"` // Human readable filter description
Enabled bool `json:"enabled"`
}
@ -161,8 +183,8 @@ func WorkflowsEvents(ctx *context.Context) {
EventID: strconv.FormatInt(wf.ID, 10),
DisplayName: string(ctx.Tr(wf.WorkflowEvent.LangKey())) + filterSummary,
Capabilities: capabilities[event],
Filters: wf.WorkflowFilters,
Actions: wf.WorkflowActions,
Filters: convertFiltersToMap(wf.WorkflowFilters),
Actions: convertActionsToMap(wf.WorkflowActions),
FilterSummary: filterSummary,
Enabled: wf.Enabled,
})
@ -174,8 +196,6 @@ func WorkflowsEvents(ctx *context.Context) {
EventID: event.UUID(),
DisplayName: string(ctx.Tr(event.LangKey())),
Capabilities: capabilities[event],
Filters: []project_model.WorkflowFilter{},
Actions: []project_model.WorkflowAction{},
FilterSummary: "",
Enabled: true, // Default to enabled for new workflows
})
@ -354,9 +374,9 @@ func Workflows(ctx *context.Context) {
}
type WorkflowsPostForm struct {
EventID string `form:"event_id" binding:"Required"`
Filters map[string]string `form:"filters"`
Actions map[string]any `form:"actions"`
EventID string `json:"event_id"`
Filters map[string]string `json:"filters"`
Actions map[string]any `json:"actions"`
}
func WorkflowsPost(ctx *context.Context) {
@ -379,7 +399,24 @@ func WorkflowsPost(ctx *context.Context) {
return
}
form := web.GetForm(ctx).(*WorkflowsPostForm)
// 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.ServerError("InvalidEventID", errors.New("EventID is required"))
return
}
// Convert form data to filters and actions
filters := convertFormToFilters(form.Filters)

@ -1041,10 +1041,10 @@ 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.Post("/{workflow_id}", projects.WorkflowsPost)
m.Post("/{workflow_id}/status", projects.WorkflowsStatus)
m.Post("/{workflow_id}/delete", projects.WorkflowsDelete)
})
}, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true))
m.Group("", func() { //nolint:dupl // duplicates lines 1421-1441
m.Get("/new", org.RenderNewProject)
m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
@ -1435,7 +1435,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.Post("/{workflow_id}", projects.WorkflowsPost)
m.Post("/{workflow_id}/status", projects.WorkflowsStatus)
m.Post("/{workflow_id}/delete", projects.WorkflowsDelete)
})

@ -48,8 +48,8 @@ const toggleEditMode = () => {
// If there was a previous selection, return to it
if (store.selectedWorkflow && store.selectedWorkflow.id === 0) {
// Remove temporary cloned workflow from list
const tempIndex = store.workflowEvents.findIndex(w =>
w.event_id === store.selectedWorkflow.event_id
const tempIndex = store.workflowEvents.findIndex((w) =>
w.event_id === store.selectedWorkflow.event_id,
);
if (tempIndex >= 0) {
store.workflowEvents.splice(tempIndex, 1);
@ -69,7 +69,7 @@ const toggleEditMode = () => {
// Entering edit mode - store current selection
previousSelection.value = {
selectedItem: store.selectedItem,
selectedWorkflow: store.selectedWorkflow ? {...store.selectedWorkflow} : null
selectedWorkflow: store.selectedWorkflow ? {...store.selectedWorkflow} : null,
};
setEditMode(true);
}
@ -84,7 +84,7 @@ const toggleWorkflowStatus = async () => {
};
const deleteWorkflow = async () => {
if (!store.selectedWorkflow || !confirm('Are you sure you want to delete this workflow?')) {
if (!store.selectedWorkflow || !window.confirm('Are you sure you want to delete this workflow?')) {
return;
}
@ -96,8 +96,8 @@ const deleteWorkflow = async () => {
// If deleting a temporary workflow (clone/new), just remove from list
if (store.selectedWorkflow.id === 0) {
const tempIndex = store.workflowEvents.findIndex(w =>
w.event_id === store.selectedWorkflow.event_id
const tempIndex = store.workflowEvents.findIndex((w) =>
w.event_id === store.selectedWorkflow.event_id,
);
if (tempIndex >= 0) {
store.workflowEvents.splice(tempIndex, 1);
@ -110,9 +110,9 @@ const deleteWorkflow = async () => {
}
// Find workflows for the same base event type
const sameEventWorkflows = store.workflowEvents.filter(w =>
const sameEventWorkflows = store.workflowEvents.filter((w) =>
w.base_event_type === currentBaseEventType ||
w.workflow_event === currentBaseEventType
w.workflow_event === currentBaseEventType,
);
if (sameEventWorkflows.length === 0) {
@ -226,7 +226,7 @@ const createNewWorkflow = (baseEventType, capabilities, displayName) => {
if (!isInEditMode.value) {
previousSelection.value = {
selectedItem: store.selectedItem,
selectedWorkflow: store.selectedWorkflow ? {...store.selectedWorkflow} : null
selectedWorkflow: store.selectedWorkflow ? {...store.selectedWorkflow} : null,
};
}
@ -255,7 +255,7 @@ const cloneWorkflow = (sourceWorkflow) => {
// Store current selection before cloning
previousSelection.value = {
selectedItem: store.selectedItem,
selectedWorkflow: store.selectedWorkflow ? {...store.selectedWorkflow} : null
selectedWorkflow: store.selectedWorkflow ? {...store.selectedWorkflow} : null,
};
const tempId = `clone-${sourceWorkflow.base_event_type || sourceWorkflow.workflow_event}-${Date.now()}`;
@ -276,7 +276,7 @@ const cloneWorkflow = (sourceWorkflow) => {
};
// Find the position of source workflow and insert cloned workflow after it
const sourceIndex = store.workflowEvents.findIndex(w => w.event_id === sourceWorkflow.event_id);
const sourceIndex = store.workflowEvents.findIndex((w) => w.event_id === sourceWorkflow.event_id);
if (sourceIndex >= 0) {
store.workflowEvents.splice(sourceIndex + 1, 0, clonedWorkflow);
} else {
@ -318,9 +318,9 @@ const selectWorkflowItem = async (item) => {
await selectWorkflowEvent(item);
} else {
// This is an unconfigured event - check if we already have a workflow object for it
const existingWorkflow = store.workflowEvents.find(w =>
const existingWorkflow = store.workflowEvents.find((w) =>
w.id === 0 &&
(w.base_event_type === item.base_event_type || w.workflow_event === item.base_event_type)
(w.base_event_type === item.base_event_type || w.workflow_event === item.base_event_type),
);
if (existingWorkflow) {
@ -368,10 +368,9 @@ const isItemSelected = (item) => {
if (item.isConfigured || item.id === 0) {
// For configured workflows or temporary workflows (clones/new), match by event_id
return store.selectedItem === item.event_id;
} else {
// For unconfigured events, match by base_event_type
return store.selectedItem === item.base_event_type;
}
// For unconfigured events, match by base_event_type
return store.selectedItem === item.base_event_type;
};
const _getActionsSummary = (workflow) => {
@ -446,7 +445,7 @@ onMounted(async () => {
// Check if eventID matches a base event type (unconfigured workflow)
const items = workflowList.value;
const matchingUnconfigured = items.find((item) =>
!item.isConfigured && (item.base_event_type === props.eventID || item.event_id === props.eventID)
!item.isConfigured && (item.base_event_type === props.eventID || item.event_id === props.eventID),
);
if (matchingUnconfigured) {
// Create new workflow for this base event type
@ -496,7 +495,7 @@ const popstateHandler = (e) => {
// Check if it's a base event type
const items = workflowList.value;
const matchingUnconfigured = items.find((item) =>
!item.isConfigured && (item.base_event_type === e.state.eventId || item.event_id === e.state.eventId)
!item.isConfigured && (item.base_event_type === e.state.eventId || item.event_id === e.state.eventId),
);
if (matchingUnconfigured) {
createNewWorkflow(matchingUnconfigured.base_event_type, matchingUnconfigured.capabilities, matchingUnconfigured.display_name);
@ -578,9 +577,11 @@ onUnmounted(() => {
<h2>
<i class="settings icon"/>
{{ store.selectedWorkflow.display_name }}
<span v-if="store.selectedWorkflow.id > 0 && !isInEditMode"
class="workflow-status"
:class="store.selectedWorkflow.enabled ? 'status-enabled' : 'status-disabled'">
<span
v-if="store.selectedWorkflow.id > 0 && !isInEditMode"
class="workflow-status"
:class="store.selectedWorkflow.enabled ? 'status-enabled' : 'status-disabled'"
>
{{ store.selectedWorkflow.enabled ? 'Enabled' : 'Disabled' }}
</span>
</h2>
@ -625,114 +626,115 @@ onUnmounted(() => {
</div>
</div>
<div class="editor-content">
<div class="form" :class="{ 'readonly': !isInEditMode }">
<div class="field">
<label>When</label>
<div class="segment">
<div class="description">
This workflow will run when: <strong>{{ store.selectedWorkflow.display_name }}</strong>
<!--<form class="ui form form-fetch-action" :action="props.projectLink+'/workflows/'+store.selectedWorkflow.id" method="post">-->
<div class="editor-content">
<div class="form" :class="{ 'readonly': !isInEditMode }">
<div class="field">
<label>When</label>
<div class="segment">
<div class="description">
This workflow will run when: <strong>{{ store.selectedWorkflow.display_name }}</strong>
</div>
</div>
</div>
</div>
<!-- Filters Section -->
<div class="field" v-if="hasAvailableFilters">
<label>Filters</label>
<div class="segment">
<div class="field" v-if="hasFilter('issue_type')">
<label>Apply to</label>
<select
v-if="isInEditMode"
class="form-select"
v-model="store.workflowFilters.issue_type"
>
<option value="">Issues And Pull Requests</option>
<option value="issue">Issues</option>
<option value="pull_request">Pull requests</option>
</select>
<div v-else class="readonly-value">
{{ store.workflowFilters.issue_type === 'issue' ? 'Issues' :
store.workflowFilters.issue_type === 'pull_request' ? 'Pull requests' :
'Issues And Pull Requests' }}
<!-- Filters Section -->
<div class="field" v-if="hasAvailableFilters">
<label>Filters</label>
<div class="segment">
<div class="field" v-if="hasFilter('issue_type')">
<label>Apply to</label>
<select
v-if="isInEditMode"
class="form-select"
v-model="store.workflowFilters.issue_type"
>
<option value="">Issues And Pull Requests</option>
<option value="issue">Issues</option>
<option value="pull_request">Pull requests</option>
</select>
<div v-else class="readonly-value">
{{ store.workflowFilters.issue_type === 'issue' ? 'Issues' :
store.workflowFilters.issue_type === 'pull_request' ? 'Pull requests' :
'Issues And Pull Requests' }}
</div>
</div>
</div>
</div>
</div>
<!-- Actions Section -->
<div class="field">
<label>Actions</label>
<div class="segment">
<div class="field" v-if="hasAction('column')">
<label>Move to column</label>
<select
v-if="isInEditMode"
class="form-select"
v-model="store.workflowActions.column"
>
<option value="">Select column...</option>
<option v-for="column in store.projectColumns" :key="column.id" :value="column.id">
{{ column.title }}
</option>
</select>
<div v-else class="readonly-value">
{{ store.projectColumns.find(c => c.id === store.workflowActions.column)?.title || 'None' }}
<!-- Actions Section -->
<div class="field">
<label>Actions</label>
<div class="segment">
<div class="field" v-if="hasAction('column')">
<label>Move to column</label>
<select
v-if="isInEditMode"
class="form-select"
v-model="store.workflowActions.column"
>
<option value="">Select column...</option>
<option v-for="column in store.projectColumns" :key="column.id" :value="column.id">
{{ column.title }}
</option>
</select>
<div v-else class="readonly-value">
{{ store.projectColumns.find(c => c.id === store.workflowActions.column)?.title || 'None' }}
</div>
</div>
</div>
<div class="field" v-if="hasAction('label')">
<label>Add labels</label>
<select
v-if="isInEditMode"
class="form-select"
v-model="store.workflowActions.labels"
multiple
>
<option value="">Select labels...</option>
<option v-for="label in store.projectLabels" :key="label.id" :value="label.id">
{{ label.name }}
</option>
</select>
<div v-else class="readonly-value">
{{ store.workflowActions.labels?.map(id =>
store.projectLabels.find(l => l.id === id)?.name).join(', ') || 'None' }}
<div class="field" v-if="hasAction('label')">
<label>Add labels</label>
<select
v-if="isInEditMode"
class="form-select"
v-model="store.workflowActions.add_labels"
multiple
>
<option value="">Select labels...</option>
<option v-for="label in store.projectLabels" :key="label.id" :value="label.id">
{{ label.name }}
</option>
</select>
<div v-else class="readonly-value">
{{ store.workflowActions.add_labels?.map(id =>
store.projectLabels.find(l => l.id === id)?.name).join(', ') || 'None' }}
</div>
</div>
</div>
<div class="field" v-if="hasAction('close')">
<div v-if="isInEditMode" class="form-check">
<input type="checkbox" v-model="store.workflowActions.closeIssue" id="close-issue">
<label for="close-issue">Close issue</label>
</div>
<div v-else class="readonly-value">
<label>Close issue</label>
<div>{{ store.workflowActions.closeIssue ? 'Yes' : 'No' }}</div>
<div class="field" v-if="hasAction('close')">
<div v-if="isInEditMode" class="form-check">
<input type="checkbox" v-model="store.workflowActions.closeIssue" id="close-issue">
<label for="close-issue">Close issue</label>
</div>
<div v-else class="readonly-value">
<label>Close issue</label>
<div>{{ store.workflowActions.closeIssue ? 'Yes' : 'No' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Fixed bottom actions (only show in edit mode) -->
<div v-if="isInEditMode" class="editor-actions">
<button class="btn btn-primary" @click="saveWorkflow" :disabled="store.saving">
<i class="save icon"/>
Save Workflow
</button>
<button
v-if="store.selectedWorkflow && store.selectedWorkflow.id > 0"
class="btn btn-danger"
@click="deleteWorkflow"
>
<i class="trash icon"/>
Delete
</button>
</div>
<!-- Fixed bottom actions (only show in edit mode) -->
<div v-if="isInEditMode" class="editor-actions">
<button class="btn btn-primary" @click="saveWorkflow" :disabled="store.saving">
<i class="save icon"/>
Save Workflow
</button>
<button
v-if="store.selectedWorkflow && store.selectedWorkflow.id > 0"
class="btn btn-danger"
@click="deleteWorkflow"
>
<i class="trash icon"/>
Delete
</button>
</div>
<!--</form>-->
</div>
</div>
</div>
</template>

@ -19,7 +19,7 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
workflowActions: {
column: '', // column ID to move to
labels: [], // selected label IDs
add_labels: [], // selected label IDs
closeIssue: false,
},
@ -67,7 +67,7 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
if (action.action_type === 'column') {
frontendActions.column = action.action_value;
} else if (action.action_type === 'add_labels') {
frontendActions.labels.push(action.action_value);
frontendActions.add_labels.push(action.action_value);
} else if (action.action_type === 'close') {
frontendActions.closeIssue = action.action_value === 'true';
}
@ -108,49 +108,23 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
// For new workflows, use the base event type
const eventId = store.selectedWorkflow.base_event_type || store.selectedWorkflow.event_id;
// Convert frontend data format to backend form format
const formData = new FormData();
formData.append('event_id', eventId);
// Add filters as form fields
for (const [key, value] of Object.entries(store.workflowFilters)) {
if (value !== '') {
formData.append(`filters[${key}]`, value);
}
}
// Add actions as form fields
for (const [key, value] of Object.entries(store.workflowActions)) {
if (key === 'labels' && Array.isArray(value)) {
// Handle label array
for (const labelId of value) {
if (labelId !== '') {
formData.append(`actions[labels][]`, labelId);
}
}
} else if (key === 'closeIssue') {
// Handle boolean
formData.append(`actions[${key}]`, value.toString());
} else if (value !== '') {
// Handle string fields
formData.append(`actions[${key}]`, value);
}
}
// Convert frontend data format to backend JSON format
const postData = {
event_id: eventId,
filters: store.workflowFilters,
actions: store.workflowActions,
};
console.log('Saving workflow with FormData');
console.log('URL:', `${props.projectLink}/workflows/${eventId}`);
// Log form data entries
for (const [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
// Send workflow data
console.info('Sending workflow data:', postData);
const response = await POST(`${props.projectLink}/workflows/${eventId}`, {
data: formData,
data: postData,
headers: {
'Content-Type': 'application/json',
},
});
console.log('Response status:', response.status);
console.log('Response headers:', response.headers);
if (!response.ok) {
const errorText = await response.text();
console.error('Response error:', errorText);
@ -163,8 +137,8 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
if (result.success && result.workflow) {
// Always reload the events list to get the updated structure
// This ensures we have both the base event and the new filtered event
const wasNewWorkflow = store.selectedWorkflow.id === 0 ||
store.selectedWorkflow.event_id.startsWith('new-') ||
const wasNewWorkflow = store.selectedWorkflow.id === 0 ||
store.selectedWorkflow.event_id.startsWith('new-') ||
store.selectedWorkflow.event_id.startsWith('clone-');
// Reload events from server to get the correct event structure
@ -222,7 +196,6 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
if (existingIndex >= 0) {
store.workflowEvents[existingIndex].enabled = store.selectedWorkflow.enabled;
}
console.log(`Workflow status updated to: ${store.selectedWorkflow.enabled ? 'enabled' : 'disabled'}`);
} else {
// Revert the status change on failure
store.selectedWorkflow.enabled = !store.selectedWorkflow.enabled;
@ -260,7 +233,6 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
if (existingIndex >= 0) {
store.workflowEvents.splice(existingIndex, 1);
}
console.log('Workflow deleted successfully');
} else {
alert('Failed to delete workflow');
}