0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-24 20:06:46 +02:00

some improvements

This commit is contained in:
Lunny Xiao 2025-09-02 21:42:47 -07:00
parent bf9511e63c
commit 5481800fe8
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
10 changed files with 566 additions and 21 deletions

View File

@ -43,26 +43,26 @@ func GetWorkflowEvents() []WorkflowEvent {
return workflowEvents
}
func (we WorkflowEvent) ToString() string {
func (we WorkflowEvent) LangKey() string {
switch we {
case WorkflowEventItemAddedToProject:
return "Item added to project"
return "projects.workflows.event.item_added_to_project"
case WorkflowEventItemReopened:
return "Item reopened"
return "projects.workflows.event.item_reopened"
case WorkflowEventItemClosed:
return "Item closed"
return "projects.workflows.event.item_closed"
case WorkflowEventCodeChangesRequested:
return "Code changes requested"
return "projects.workflows.event.code_changes_requested"
case WorkflowEventCodeReviewApproved:
return "Code review approved"
return "projects.workflows.event.code_review_approved"
case WorkflowEventPullRequestMerged:
return "Pull request merged"
return "projects.workflows.event.pull_request_merged"
case WorkflowEventAutoArchiveItems:
return "Auto archive items"
return "projects.workflows.event.auto_archive_items"
case WorkflowEventAutoAddToProject:
return "Auto add to project"
return "projects.workflows.event.auto_add_to_project"
case WorkflowEventAutoCloseIssue:
return "Auto close issue"
return "projects.workflows.event.auto_close_issue"
default:
return string(we)
}
@ -179,3 +179,12 @@ func GetWorkflowByID(ctx context.Context, id int64) (*Workflow, error) {
}
return p, nil
}
func CreateWorkflow(ctx context.Context, wf *Workflow) error {
return db.Insert(ctx, wf)
}
func UpdateWorkflow(ctx context.Context, wf *Workflow) error {
_, err := db.GetEngine(ctx).ID(wf.ID).Update(wf)
return err
}

View File

@ -3902,7 +3902,17 @@ type-1.display_name = Individual Project
type-2.display_name = Repository Project
type-3.display_name = Organization Project
enter_fullscreen = Fullscreen
workflows = Workflows
exit_fullscreen = Exit Fullscreen
workflows.event.item_added_to_project = Item added to project
workflows.event.item_reopened = Item reopened
workflows.event.item_closed = Item closed
workflows.event.code_changes_requested = Code changes requested
workflows.event.code_review_approved = Code review approved
workflows.event.pull_request_merged = Pull request merged
workflows.event.auto_archive_items = Auto archive items
workflows.event.auto_add_to_project = Auto add to project
workflows.event.auto_close_issue = Auto close issue
[git.filemode]
changed_filemode = %[1]s → %[2]s

View File

@ -4,10 +4,14 @@
package projects
import (
"fmt"
"net/http"
"strconv"
"code.gitea.io/gitea/models/project"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
)
@ -16,7 +20,112 @@ var (
tmplOrgWorkflows = templates.TplName("org/projects/workflows")
)
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.FindWorkflowEvents(ctx, projectID)
if err != nil {
ctx.ServerError("GetWorkflows", err)
return
}
type WorkflowEvent struct {
EventID string `json:"event_id"`
DisplayName string `json:"display_name"`
}
outputWorkflows := make([]*WorkflowEvent, 0, len(workflows))
events := project_model.GetWorkflowEvents()
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
}
}
if workflow == nil {
workflow = &WorkflowEvent{
EventID: event.UUID(),
DisplayName: string(ctx.Tr(event.LangKey())),
}
}
outputWorkflows = append(outputWorkflows, workflow)
}
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"`
}
outputColumns := make([]*Column, 0, len(columns))
for _, col := range columns {
outputColumns = append(outputColumns, &Column{
ID: col.ID,
Title: col.Title,
})
}
ctx.JSON(http.StatusOK, outputColumns)
}
func Workflows(ctx *context.Context) {
workflowIDStr := ctx.PathParam("workflow_id")
if workflowIDStr == "events" {
WorkflowsEvents(ctx)
return
}
if workflowIDStr == "columns" {
WorkflowsColumns(ctx)
return
}
ctx.Data["WorkflowEvents"] = project_model.GetWorkflowEvents()
projectID := ctx.PathParamInt64("id")
@ -54,7 +163,6 @@ func Workflows(ctx *context.Context) {
}
ctx.Data["Workflows"] = workflows
workflowIDStr := ctx.PathParam("workflow_id")
ctx.Data["workflowIDStr"] = workflowIDStr
var curWorkflow *project_model.Workflow
if workflowIDStr == "" { // get first value workflow or the first workflow
@ -76,6 +184,7 @@ func Workflows(ctx *context.Context) {
}
}
ctx.Data["CurWorkflow"] = curWorkflow
ctx.Data["ProjectLink"] = project.ProjectLinkForRepo(ctx.Repo.Repository, projectID)
if p.Type == project_model.TypeRepository {
ctx.HTML(200, tmplRepoWorkflows)
@ -83,3 +192,59 @@ func Workflows(ctx *context.Context) {
ctx.HTML(200, tmplOrgWorkflows)
}
}
type WorkflowsPostForm struct {
EventID string `form:"event_id" binding:"Required"`
Filters map[string]string `form:"filters"`
Actions map[string]any `form:"actions"`
}
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
}
form := web.GetForm(ctx).(*WorkflowsPostForm)
eventID, _ := strconv.ParseInt(form.EventID, 10, 64)
if eventID == 0 {
// Create a new workflow
wf := &project_model.Workflow{
ProjectID: projectID,
WorkflowEvent: project_model.WorkflowEvent(form.EventID),
WorkflowFilters: []project_model.WorkflowFilter{},
WorkflowActions: []project_model.WorkflowAction{},
}
if err := project_model.CreateWorkflow(ctx, wf); err != nil {
ctx.ServerError("CreateWorkflow", err)
return
}
} else {
// Update an existing workflow
wf, err := project_model.GetWorkflowByID(ctx, eventID)
if err != nil {
ctx.ServerError("GetWorkflowByID", err)
return
}
wf.WorkflowFilters = []project_model.WorkflowFilter{}
wf.WorkflowActions = []project_model.WorkflowAction{}
if err := project_model.UpdateWorkflow(ctx, wf); err != nil {
ctx.ServerError("UpdateWorkflow", err)
return
}
}
}

View File

@ -1041,6 +1041,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 1421-1441
m.Get("/new", org.RenderNewProject)

View File

@ -19,6 +19,10 @@
</div>
{{if $canWriteProject}}
<div class="ui compact mini menu">
<a class="item" href="{{.Link}}/workflows">
{{svg "octicon-workflow"}}
{{ctx.Locale.Tr "projects.workflows"}}
</a>
<a class="item screen-full">
{{svg "octicon-screen-full"}}
{{ctx.Locale.Tr "projects.enter_fullscreen"}}

View File

@ -1,12 +1,10 @@
<div class="ui container fluid padded projects-view">
<div class="ui container flex-text-block project-header">
<div class="four wide column">
{{range .WorkflowEvents}}
<div class="ui fluid vertical menu">
{{$workflow := (index $.Workflows .)}}
<a class="item{{if or (and $workflow $.CurWorkflow (eq $workflow.ID $.CurWorkflow.ID)) (eq $.workflowIDStr .UUID)}} active{{end}}" href="{{$.Project.Link ctx}}/workflows/{{if $workflow}}{{$workflow.ID}}{{else}}{{.UUID}}{{end}}">{{.ToString}}</a>
</div>
{{end}}
</div>
<div class="ui container padded projects-view">
<div class="project-header">
<h2>{{.Project.Title}} - {{ctx.Locale.Tr "projects.workflows"}}</h2>
</div>
<div id="project-workflows"
data-project-link="{{.ProjectLink}}"
data-event-id="{{.workflowIDStr}}"
>
</div>
</div>

View File

@ -0,0 +1,258 @@
<script lang="ts" setup>
import {onMounted, useTemplateRef} from 'vue';
import {createWorkflowStore} from './WorkflowStore.ts';
import {svg} from '../../svg.ts';
const elRoot = useTemplateRef('elRoot');
const props = defineProps({
projectLink: {type: String, required: true},
eventID: {type: String, required: true},
});
const store = createWorkflowStore(props);
const selectWorkflowEvent = (event) => {
store.selectedItem = event.event_id;
store.selectedWorkflow = event;
store.loadWorkflowData(event.event_id);
// Update URL without page reload
const newUrl = `${props.projectLink}/workflows/${event.event_id}`;
window.history.pushState({eventId: event.event_id}, '', newUrl);
};
const saveWorkflow = async () => {
await store.saveWorkflow();
};
const resetWorkflow = () => {
store.resetWorkflowData();
};
const isWorkflowConfigured = (event) => {
// Check if the event_id is a number (saved workflow ID) vs UUID (unconfigured)
// If it's a number, it means the workflow has been saved to database
return !isNaN(parseInt(event.event_id));
};
onMounted(async () => {
store.workflowEvents = await store.loadEvents();
// Set initial selected workflow if eventID is provided
if (props.eventID) {
const selectedEvent = store.workflowEvents.find((e) => e.event_id === props.eventID);
if (selectedEvent) {
store.selectedItem = props.eventID;
store.selectedWorkflow = selectedEvent;
await store.loadWorkflowData(props.eventID);
}
}
elRoot.value.closest('.is-loading')?.classList?.remove('is-loading');
window.addEventListener('popstate', (e) => {
if (e.state?.eventId) {
const event = store.workflowEvents.find((ev) => ev.event_id === e.state.eventId);
if (event) {
selectWorkflowEvent(event);
}
}
});
});
</script>
<template>
<div ref="elRoot" class="workflow-container">
<div class="workflow-sidebar">
<div class="ui fluid vertical menu">
<a
v-for="event in store.workflowEvents"
:key="event.event_id"
class="item"
:class="{ active: store.selectedItem === event.event_id }"
:href="`${props.projectLink}/workflows/${event.event_id}`"
@click.prevent="selectWorkflowEvent(event)"
>
<span class="workflow-status" :class="{ configured: isWorkflowConfigured(event) }">
<span v-if="isWorkflowConfigured(event)" v-html="svg('octicon-dot-fill')" class="status-icon configured"></span>
<span v-else class="status-icon unconfigured"></span>
</span>
{{ event.display_name }}
</a>
</div>
</div>
<div class="workflow-main">
<div class="workflow-content">
<div v-if="!store.selectedWorkflow" class="ui placeholder segment">
<div class="ui icon header">
<i class="settings icon"/>
Select a workflow event to configure
</div>
</div>
<div v-else class="workflow-editor">
<div class="ui header">
<i class="settings icon"/>
{{ store.selectedWorkflow.display_name }}
</div>
<div class="workflow-form">
<div class="ui form">
<div class="field">
<label>When</label>
<div class="ui segment">
<div class="description">
This workflow will run when: <strong>{{ store.selectedWorkflow.display_name }}</strong>
</div>
</div>
</div>
<div class="field">
<label>Filters</label>
<div class="ui segment">
<div class="field">
<label>Apply to</label>
<select class="ui dropdown" v-model="store.workflowFilters.scope">
<option value="">Issues And Pull Requests</option>
<option value="issue">Issues</option>
<option value="pull_request">Pull requests</option>
</select>
</div>
</div>
</div>
<div class="field">
<label>Actions</label>
<div class="ui segment">
<div class="field">
<label>Move to column</label>
<select class="ui dropdown" 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>
<div class="field">
<div class="ui checkbox">
<input type="checkbox" v-model="store.workflowActions.closeIssue" id="close-issue">
<label for="close-issue">Close issue</label>
</div>
</div>
</div>
</div>
<div class="actions">
<button class="ui primary button" @click="saveWorkflow" :loading="store.saving">
Save workflow
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.workflow-container {
display: flex;
gap: 1rem;
width: 100%;
min-height: 500px;
}
.workflow-sidebar {
width: 300px;
flex-shrink: 0;
}
.workflow-main {
flex: 1;
min-width: 0;
}
.workflow-content {
padding: 1rem;
}
.workflow-editor {
margin-top: 1rem;
}
.workflow-form .field {
margin-bottom: 1.5rem;
}
.workflow-form .field label {
font-weight: bold;
margin-bottom: 0.5rem;
display: block;
}
.workflow-form .ui.segment {
padding: 1rem;
margin-bottom: 0.5rem;
}
.workflow-form .description {
color: #666;
font-style: italic;
}
.workflow-form .actions {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
}
.ui.placeholder.segment {
text-align: center;
padding: 3rem;
}
.ui.vertical.menu .item.active {
background-color: #f0f0f0;
font-weight: bold;
}
.workflow-status {
display: inline-flex;
align-items: center;
margin-right: 0.5rem;
}
.status-icon {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 0.25rem;
}
.status-icon.configured {
color: #28a745;
}
.status-icon.configured svg {
width: 8px;
height: 8px;
fill: currentColor;
}
.status-icon.unconfigured {
border: 1px solid #6c757d;
border-radius: 50%;
background-color: transparent;
}
@media (max-width: 768px) {
.workflow-container {
flex-direction: column;
}
.workflow-sidebar {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,85 @@
import {reactive} from 'vue';
import {GET, POST} from '../../modules/fetch.ts';
export function createWorkflowStore(props: { projectLink: string, eventID: string}) {
const store = reactive({
workflowEvents: [],
selectedItem: props.eventID,
selectedWorkflow: null,
projectColumns: [],
saving: false,
workflowFilters: {
scope: '', // 'issue', 'pull_request', or ''
},
workflowActions: {
column: '', // column ID to move to
closeIssue: false,
},
async loadEvents() {
const response = await GET(`${props.projectLink}/workflows/events`);
store.workflowEvents = await response.json();
return store.workflowEvents;
},
async loadProjectColumns() {
try {
const response = await GET(`${props.projectLink}/workflows/columns`);
store.projectColumns = await response.json();
} catch (error) {
console.error('Failed to load project columns:', error);
store.projectColumns = [];
}
},
async loadWorkflowData(eventId: string) {
// Load project columns for the dropdown
await store.loadProjectColumns();
// Find the workflow from existing workflowEvents
const workflow = store.workflowEvents.find((e) => e.event_id === eventId);
if (workflow && workflow.filters && workflow.actions) {
// Load existing configuration from the workflow data
store.workflowFilters = workflow.filters || {scope: ''};
store.workflowActions = workflow.actions || {column: '', closeIssue: false};
} else {
// Reset to defaults for new workflow
store.resetWorkflowData();
}
},
resetWorkflowData() {
store.workflowFilters = {scope: ''};
store.workflowActions = {column: '', closeIssue: false};
},
async saveWorkflow() {
if (!store.selectedWorkflow) return;
store.saving = true;
try {
const workflowData = {
event_id: store.selectedWorkflow.event_id,
filters: store.workflowFilters,
actions: store.workflowActions,
};
const response = await POST(`${props.projectLink}/workflows/${store.selectedWorkflow.event_id}`, {
data: workflowData,
});
if (!response.ok) {
throw new Error('Failed to save workflow');
}
} catch (error) {
console.error('Error saving workflow:', error);
} finally {
store.saving = false;
}
},
});
return store;
}

View File

@ -0,0 +1,12 @@
import {createApp} from 'vue';
import ProjectWorkflow from '../../components/projects/ProjectWorkflow.vue';
export async function initProjectWorkflow() {
const workflowDiv = document.querySelector('#project-workflows');
if (!workflowDiv) return;
createApp(ProjectWorkflow, {
projectLink: workflowDiv.getAttribute('data-project-link'),
eventID: workflowDiv.getAttribute('data-event-id'),
}).mount(workflowDiv);
}

View File

@ -66,6 +66,7 @@ import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton}
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initProjectWorkflow} from './features/projects/workflow.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
@ -164,6 +165,8 @@ const initPerformanceTracer = callInitFunctions([
initOAuth2SettingsDisableCheckbox,
initRepoFileView,
initProjectWorkflow,
]);
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.