From 9897bd04c2eabfc5ec2b5a2b6ad42a3ca84138af Mon Sep 17 00:00:00 2001 From: hurole <1192163814@qq.com> Date: Fri, 12 Dec 2025 23:21:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=94=AF=E6=8C=81=E7=A8=80?= =?UTF-8?q?=E7=96=8F=E6=A3=80=E5=87=BA=E8=B7=AF=E5=BE=84=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E9=83=A8=E7=BD=B2=E6=89=A7=E8=A1=8C=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在部署DTO中添加sparseCheckoutPaths字段支持稀疏检出路径 - 数据模型Deployment新增稀疏检出路径字段及相关数据库映射 - 部署创建时支持设置稀疏检出路径字段 - 部署重试接口实现,支持复制原始部署记录并加入执行队列 - 新增流水线模板初始化与基于模板创建流水线接口 - 优化应用初始化流程,确保执行队列和流水线模板正确加载 - 添加启动日志,提示执行队列初始化完成 --- apps/server/app.ts | 29 +- apps/server/controllers/deployment/dto.ts | 1 + apps/server/controllers/deployment/index.ts | 58 +++ apps/server/controllers/pipeline/index.ts | 67 +++ apps/server/generated/internal/class.ts | 4 +- .../generated/internal/prismaNamespace.ts | 1 + .../internal/prismaNamespaceBrowser.ts | 1 + apps/server/generated/models/Deployment.ts | 37 +- apps/server/libs/execution-queue.ts | 233 +++++++++++ apps/server/libs/pipeline-template.ts | 247 +++++++++++ apps/server/middlewares/index.ts | 2 +- apps/server/prisma/data/dev.db | Bin 32768 -> 36864 bytes apps/server/prisma/schema.prisma | 1 + apps/server/runners/index.ts | 3 + apps/server/runners/mq-interface.ts | 28 ++ apps/server/runners/pipeline-runner.ts | 254 +++++++++++ .../project/detail/components/DeployModal.tsx | 12 + .../detail/components/DeployRecordItem.tsx | 1 + apps/web/src/pages/project/detail/index.tsx | 393 ++++++++++++------ apps/web/src/pages/project/detail/service.ts | 49 ++- apps/web/src/pages/project/types.ts | 12 + apps/web/src/shared/request.ts | 10 + 22 files changed, 1307 insertions(+), 136 deletions(-) create mode 100644 apps/server/libs/execution-queue.ts create mode 100644 apps/server/libs/pipeline-template.ts create mode 100644 apps/server/runners/index.ts create mode 100644 apps/server/runners/mq-interface.ts create mode 100644 apps/server/runners/pipeline-runner.ts diff --git a/apps/server/app.ts b/apps/server/app.ts index 7adb510..8331591 100644 --- a/apps/server/app.ts +++ b/apps/server/app.ts @@ -1,13 +1,32 @@ import Koa from 'koa'; import { initMiddlewares } from './middlewares/index.ts'; import { log } from './libs/logger.ts'; +import { ExecutionQueue } from './libs/execution-queue.ts'; +import { initializePipelineTemplates } from './libs/pipeline-template.ts'; -const app = new Koa(); +// 初始化应用 +async function initializeApp() { + // 初始化流水线模板 + await initializePipelineTemplates(); -initMiddlewares(app); + // 初始化执行队列 + const executionQueue = ExecutionQueue.getInstance(); + await executionQueue.initialize(); -const PORT = process.env.PORT || 3001; + const app = new Koa(); -app.listen(PORT, () => { - log.info('APP', 'Server started at port %d', PORT); + initMiddlewares(app); + + const PORT = process.env.PORT || 3001; + + app.listen(PORT, () => { + log.info('APP', 'Server started at port %d', PORT); + log.info('QUEUE', 'Execution queue initialized'); + }); +} + +// 启动应用 +initializeApp().catch(error => { + console.error('Failed to start application:', error); + process.exit(1); }); diff --git a/apps/server/controllers/deployment/dto.ts b/apps/server/controllers/deployment/dto.ts index b839718..22b4407 100644 --- a/apps/server/controllers/deployment/dto.ts +++ b/apps/server/controllers/deployment/dto.ts @@ -13,6 +13,7 @@ export const createDeploymentSchema = z.object({ commitHash: z.string().min(1, { message: '提交哈希不能为空' }), commitMessage: z.string().min(1, { message: '提交信息不能为空' }), env: z.string().optional(), + sparseCheckoutPaths: z.string().optional(), // 添加稀疏检出路径字段 }); export type ListDeploymentsQuery = z.infer; diff --git a/apps/server/controllers/deployment/index.ts b/apps/server/controllers/deployment/index.ts index ff7a32a..e404ce8 100644 --- a/apps/server/controllers/deployment/index.ts +++ b/apps/server/controllers/deployment/index.ts @@ -3,6 +3,7 @@ import type { Prisma } from '../../generated/client.ts'; import { prisma } from '../../libs/prisma.ts'; import type { Context } from 'koa'; import { listDeploymentsQuerySchema, createDeploymentSchema } from './dto.ts'; +import { ExecutionQueue } from '../../libs/execution-queue.ts'; @Controller('/deployments') export class DeploymentController { @@ -50,12 +51,69 @@ export class DeploymentController { }, pipelineId: body.pipelineId, env: body.env || 'dev', + sparseCheckoutPaths: body.sparseCheckoutPaths || '', // 添加稀疏检出路径 buildLog: '', createdBy: 'system', // TODO: get from user updatedBy: 'system', valid: 1, }, }); + + // 将新创建的部署任务添加到执行队列 + const executionQueue = ExecutionQueue.getInstance(); + await executionQueue.addTask(result.id, result.pipelineId); + return result; } + + // 添加重新执行部署的接口 + @Post('/:id/retry') + async retry(ctx: Context) { + const { id } = ctx.params; + + // 获取原始部署记录 + const originalDeployment = await prisma.deployment.findUnique({ + where: { id: Number(id) } + }); + + if (!originalDeployment) { + ctx.status = 404; + ctx.body = { + code: 404, + message: '部署记录不存在', + data: null, + timestamp: Date.now() + }; + return; + } + + // 创建一个新的部署记录,复制原始记录的信息 + const newDeployment = await prisma.deployment.create({ + data: { + branch: originalDeployment.branch, + commitHash: originalDeployment.commitHash, + commitMessage: originalDeployment.commitMessage, + status: 'pending', + projectId: originalDeployment.projectId, + pipelineId: originalDeployment.pipelineId, + env: originalDeployment.env, + sparseCheckoutPaths: originalDeployment.sparseCheckoutPaths, + buildLog: '', + createdBy: 'system', + updatedBy: 'system', + valid: 1, + }, + }); + + // 将新创建的部署任务添加到执行队列 + const executionQueue = ExecutionQueue.getInstance(); + await executionQueue.addTask(newDeployment.id, newDeployment.pipelineId); + + ctx.body = { + code: 0, + message: '重新执行任务已创建', + data: newDeployment, + timestamp: Date.now() + }; + } } diff --git a/apps/server/controllers/pipeline/index.ts b/apps/server/controllers/pipeline/index.ts index c13a1af..067b598 100644 --- a/apps/server/controllers/pipeline/index.ts +++ b/apps/server/controllers/pipeline/index.ts @@ -3,6 +3,7 @@ import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts'; import { prisma } from '../../libs/prisma.ts'; import { log } from '../../libs/logger.ts'; import { BusinessError } from '../../middlewares/exception.ts'; +import { getAvailableTemplates, createPipelineFromTemplate } from '../../libs/pipeline-template.ts'; import { createPipelineSchema, updatePipelineSchema, @@ -43,6 +44,18 @@ export class PipelineController { return pipelines; } + // GET /api/pipelines/templates - 获取可用的流水线模板 + @Get('/templates') + async getTemplates(ctx: Context) { + try { + const templates = await getAvailableTemplates(); + return templates; + } catch (error) { + console.error('Failed to get templates:', error); + throw new BusinessError('获取模板失败', 3002, 500); + } + } + // GET /api/pipelines/:id - 获取单个流水线 @Get('/:id') async get(ctx: Context) { @@ -92,6 +105,60 @@ export class PipelineController { return pipeline; } + // POST /api/pipelines/from-template - 基于模板创建流水线 + @Post('/from-template') + async createFromTemplate(ctx: Context) { + try { + const { templateId, projectId, name, description } = ctx.request.body as { + templateId: number; + projectId: number; + name: string; + description?: string; + }; + + // 验证必要参数 + if (!templateId || !projectId || !name) { + throw new BusinessError('缺少必要参数', 3003, 400); + } + + // 基于模板创建流水线 + const newPipelineId = await createPipelineFromTemplate( + templateId, + projectId, + name, + description || '' + ); + + // 返回新创建的流水线 + const pipeline = await prisma.pipeline.findUnique({ + where: { id: newPipelineId }, + include: { + steps: { + where: { + valid: 1, + }, + orderBy: { + order: 'asc', + }, + }, + }, + }); + + if (!pipeline) { + throw new BusinessError('创建流水线失败', 3004, 500); + } + + log.info('pipeline', 'Created pipeline from template: %s', pipeline.name); + return pipeline; + } catch (error) { + console.error('Failed to create pipeline from template:', error); + if (error instanceof BusinessError) { + throw error; + } + throw new BusinessError('基于模板创建流水线失败', 3005, 500); + } + } + // PUT /api/pipelines/:id - 更新流水线 @Put('/:id') async update(ctx: Context) { diff --git a/apps/server/generated/internal/class.ts b/apps/server/generated/internal/class.ts index e89a99a..155d138 100644 --- a/apps/server/generated/internal/class.ts +++ b/apps/server/generated/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.0.0", "engineVersion": "0c19ccc313cf9911a90d99d2ac2eb0280c76c513", "activeProvider": "sqlite", - "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel Project {\n id Int @id @default(autoincrement())\n name String\n description String?\n repository String\n // Relations\n deployments Deployment[]\n pipelines Pipeline[]\n\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n}\n\nmodel User {\n id Int @id @default(autoincrement())\n username String\n login String\n email String\n avatar_url String?\n active Boolean @default(true)\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String @default(\"system\")\n updatedBy String @default(\"system\")\n}\n\nmodel Pipeline {\n id Int @id @default(autoincrement())\n name String\n description String?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n // Relations\n projectId Int?\n Project Project? @relation(fields: [projectId], references: [id])\n steps Step[]\n}\n\nmodel Step {\n id Int @id @default(autoincrement())\n name String\n order Int\n script String // 执行的脚本命令\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n pipelineId Int\n pipeline Pipeline @relation(fields: [pipelineId], references: [id])\n}\n\nmodel Deployment {\n id Int @id @default(autoincrement())\n branch String\n env String?\n status String // pending, running, success, failed, cancelled\n commitHash String?\n commitMessage String?\n buildLog String?\n startedAt DateTime @default(now())\n finishedAt DateTime?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n projectId Int\n Project Project? @relation(fields: [projectId], references: [id])\n pipelineId Int\n}\n", + "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel Project {\n id Int @id @default(autoincrement())\n name String\n description String?\n repository String\n // Relations\n deployments Deployment[]\n pipelines Pipeline[]\n\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n}\n\nmodel User {\n id Int @id @default(autoincrement())\n username String\n login String\n email String\n avatar_url String?\n active Boolean @default(true)\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String @default(\"system\")\n updatedBy String @default(\"system\")\n}\n\nmodel Pipeline {\n id Int @id @default(autoincrement())\n name String\n description String?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n // Relations\n projectId Int?\n Project Project? @relation(fields: [projectId], references: [id])\n steps Step[]\n}\n\nmodel Step {\n id Int @id @default(autoincrement())\n name String\n order Int\n script String // 执行的脚本命令\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n pipelineId Int\n pipeline Pipeline @relation(fields: [pipelineId], references: [id])\n}\n\nmodel Deployment {\n id Int @id @default(autoincrement())\n branch String\n env String?\n status String // pending, running, success, failed, cancelled\n commitHash String?\n commitMessage String?\n buildLog String?\n sparseCheckoutPaths String? // 稀疏检出路径,用于monorepo项目\n startedAt DateTime @default(now())\n finishedAt DateTime?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n projectId Int\n Project Project? @relation(fields: [projectId], references: [id])\n pipelineId Int\n}\n", "runtimeDataModel": { "models": {}, "enums": {}, @@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = { } } -config.runtimeDataModel = JSON.parse("{\"models\":{\"Project\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"repository\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deployments\",\"kind\":\"object\",\"type\":\"Deployment\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelines\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToProject\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar_url\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Pipeline\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"PipelineToProject\"},{\"name\":\"steps\",\"kind\":\"object\",\"type\":\"Step\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Step\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"order\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"script\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"pipeline\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Deployment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"branch\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"env\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitMessage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"buildLog\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"startedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"finishedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"Project\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"repository\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deployments\",\"kind\":\"object\",\"type\":\"Deployment\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelines\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToProject\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar_url\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Pipeline\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"PipelineToProject\"},{\"name\":\"steps\",\"kind\":\"object\",\"type\":\"Step\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Step\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"order\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"script\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"pipeline\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Deployment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"branch\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"env\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitMessage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"buildLog\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sparseCheckoutPaths\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"startedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"finishedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") async function decodeBase64AsWasm(wasmBase64: string): Promise { const { Buffer } = await import('node:buffer') diff --git a/apps/server/generated/internal/prismaNamespace.ts b/apps/server/generated/internal/prismaNamespace.ts index af7e460..c0d9b1b 100644 --- a/apps/server/generated/internal/prismaNamespace.ts +++ b/apps/server/generated/internal/prismaNamespace.ts @@ -885,6 +885,7 @@ export const DeploymentScalarFieldEnum = { commitHash: 'commitHash', commitMessage: 'commitMessage', buildLog: 'buildLog', + sparseCheckoutPaths: 'sparseCheckoutPaths', startedAt: 'startedAt', finishedAt: 'finishedAt', valid: 'valid', diff --git a/apps/server/generated/internal/prismaNamespaceBrowser.ts b/apps/server/generated/internal/prismaNamespaceBrowser.ts index 80d50b7..99fbd97 100644 --- a/apps/server/generated/internal/prismaNamespaceBrowser.ts +++ b/apps/server/generated/internal/prismaNamespaceBrowser.ts @@ -142,6 +142,7 @@ export const DeploymentScalarFieldEnum = { commitHash: 'commitHash', commitMessage: 'commitMessage', buildLog: 'buildLog', + sparseCheckoutPaths: 'sparseCheckoutPaths', startedAt: 'startedAt', finishedAt: 'finishedAt', valid: 'valid', diff --git a/apps/server/generated/models/Deployment.ts b/apps/server/generated/models/Deployment.ts index f41745a..e84a7ab 100644 --- a/apps/server/generated/models/Deployment.ts +++ b/apps/server/generated/models/Deployment.ts @@ -48,6 +48,7 @@ export type DeploymentMinAggregateOutputType = { commitHash: string | null commitMessage: string | null buildLog: string | null + sparseCheckoutPaths: string | null startedAt: Date | null finishedAt: Date | null valid: number | null @@ -67,6 +68,7 @@ export type DeploymentMaxAggregateOutputType = { commitHash: string | null commitMessage: string | null buildLog: string | null + sparseCheckoutPaths: string | null startedAt: Date | null finishedAt: Date | null valid: number | null @@ -86,6 +88,7 @@ export type DeploymentCountAggregateOutputType = { commitHash: number commitMessage: number buildLog: number + sparseCheckoutPaths: number startedAt: number finishedAt: number valid: number @@ -121,6 +124,7 @@ export type DeploymentMinAggregateInputType = { commitHash?: true commitMessage?: true buildLog?: true + sparseCheckoutPaths?: true startedAt?: true finishedAt?: true valid?: true @@ -140,6 +144,7 @@ export type DeploymentMaxAggregateInputType = { commitHash?: true commitMessage?: true buildLog?: true + sparseCheckoutPaths?: true startedAt?: true finishedAt?: true valid?: true @@ -159,6 +164,7 @@ export type DeploymentCountAggregateInputType = { commitHash?: true commitMessage?: true buildLog?: true + sparseCheckoutPaths?: true startedAt?: true finishedAt?: true valid?: true @@ -265,6 +271,7 @@ export type DeploymentGroupByOutputType = { commitHash: string | null commitMessage: string | null buildLog: string | null + sparseCheckoutPaths: string | null startedAt: Date finishedAt: Date | null valid: number @@ -307,6 +314,7 @@ export type DeploymentWhereInput = { commitHash?: Prisma.StringNullableFilter<"Deployment"> | string | null commitMessage?: Prisma.StringNullableFilter<"Deployment"> | string | null buildLog?: Prisma.StringNullableFilter<"Deployment"> | string | null + sparseCheckoutPaths?: Prisma.StringNullableFilter<"Deployment"> | string | null startedAt?: Prisma.DateTimeFilter<"Deployment"> | Date | string finishedAt?: Prisma.DateTimeNullableFilter<"Deployment"> | Date | string | null valid?: Prisma.IntFilter<"Deployment"> | number @@ -327,6 +335,7 @@ export type DeploymentOrderByWithRelationInput = { commitHash?: Prisma.SortOrderInput | Prisma.SortOrder commitMessage?: Prisma.SortOrderInput | Prisma.SortOrder buildLog?: Prisma.SortOrderInput | Prisma.SortOrder + sparseCheckoutPaths?: Prisma.SortOrderInput | Prisma.SortOrder startedAt?: Prisma.SortOrder finishedAt?: Prisma.SortOrderInput | Prisma.SortOrder valid?: Prisma.SortOrder @@ -350,6 +359,7 @@ export type DeploymentWhereUniqueInput = Prisma.AtLeast<{ commitHash?: Prisma.StringNullableFilter<"Deployment"> | string | null commitMessage?: Prisma.StringNullableFilter<"Deployment"> | string | null buildLog?: Prisma.StringNullableFilter<"Deployment"> | string | null + sparseCheckoutPaths?: Prisma.StringNullableFilter<"Deployment"> | string | null startedAt?: Prisma.DateTimeFilter<"Deployment"> | Date | string finishedAt?: Prisma.DateTimeNullableFilter<"Deployment"> | Date | string | null valid?: Prisma.IntFilter<"Deployment"> | number @@ -370,6 +380,7 @@ export type DeploymentOrderByWithAggregationInput = { commitHash?: Prisma.SortOrderInput | Prisma.SortOrder commitMessage?: Prisma.SortOrderInput | Prisma.SortOrder buildLog?: Prisma.SortOrderInput | Prisma.SortOrder + sparseCheckoutPaths?: Prisma.SortOrderInput | Prisma.SortOrder startedAt?: Prisma.SortOrder finishedAt?: Prisma.SortOrderInput | Prisma.SortOrder valid?: Prisma.SortOrder @@ -397,6 +408,7 @@ export type DeploymentScalarWhereWithAggregatesInput = { commitHash?: Prisma.StringNullableWithAggregatesFilter<"Deployment"> | string | null commitMessage?: Prisma.StringNullableWithAggregatesFilter<"Deployment"> | string | null buildLog?: Prisma.StringNullableWithAggregatesFilter<"Deployment"> | string | null + sparseCheckoutPaths?: Prisma.StringNullableWithAggregatesFilter<"Deployment"> | string | null startedAt?: Prisma.DateTimeWithAggregatesFilter<"Deployment"> | Date | string finishedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Deployment"> | Date | string | null valid?: Prisma.IntWithAggregatesFilter<"Deployment"> | number @@ -415,6 +427,7 @@ export type DeploymentCreateInput = { commitHash?: string | null commitMessage?: string | null buildLog?: string | null + sparseCheckoutPaths?: string | null startedAt?: Date | string finishedAt?: Date | string | null valid?: number @@ -434,6 +447,7 @@ export type DeploymentUncheckedCreateInput = { commitHash?: string | null commitMessage?: string | null buildLog?: string | null + sparseCheckoutPaths?: string | null startedAt?: Date | string finishedAt?: Date | string | null valid?: number @@ -452,6 +466,7 @@ export type DeploymentUpdateInput = { commitHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null commitMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null buildLog?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + sparseCheckoutPaths?: Prisma.NullableStringFieldUpdateOperationsInput | string | null startedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string finishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null valid?: Prisma.IntFieldUpdateOperationsInput | number @@ -471,6 +486,7 @@ export type DeploymentUncheckedUpdateInput = { commitHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null commitMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null buildLog?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + sparseCheckoutPaths?: Prisma.NullableStringFieldUpdateOperationsInput | string | null startedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string finishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null valid?: Prisma.IntFieldUpdateOperationsInput | number @@ -490,6 +506,7 @@ export type DeploymentCreateManyInput = { commitHash?: string | null commitMessage?: string | null buildLog?: string | null + sparseCheckoutPaths?: string | null startedAt?: Date | string finishedAt?: Date | string | null valid?: number @@ -508,6 +525,7 @@ export type DeploymentUpdateManyMutationInput = { commitHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null commitMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null buildLog?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + sparseCheckoutPaths?: Prisma.NullableStringFieldUpdateOperationsInput | string | null startedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string finishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null valid?: Prisma.IntFieldUpdateOperationsInput | number @@ -526,6 +544,7 @@ export type DeploymentUncheckedUpdateManyInput = { commitHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null commitMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null buildLog?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + sparseCheckoutPaths?: Prisma.NullableStringFieldUpdateOperationsInput | string | null startedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string finishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null valid?: Prisma.IntFieldUpdateOperationsInput | number @@ -555,6 +574,7 @@ export type DeploymentCountOrderByAggregateInput = { commitHash?: Prisma.SortOrder commitMessage?: Prisma.SortOrder buildLog?: Prisma.SortOrder + sparseCheckoutPaths?: Prisma.SortOrder startedAt?: Prisma.SortOrder finishedAt?: Prisma.SortOrder valid?: Prisma.SortOrder @@ -581,6 +601,7 @@ export type DeploymentMaxOrderByAggregateInput = { commitHash?: Prisma.SortOrder commitMessage?: Prisma.SortOrder buildLog?: Prisma.SortOrder + sparseCheckoutPaths?: Prisma.SortOrder startedAt?: Prisma.SortOrder finishedAt?: Prisma.SortOrder valid?: Prisma.SortOrder @@ -600,6 +621,7 @@ export type DeploymentMinOrderByAggregateInput = { commitHash?: Prisma.SortOrder commitMessage?: Prisma.SortOrder buildLog?: Prisma.SortOrder + sparseCheckoutPaths?: Prisma.SortOrder startedAt?: Prisma.SortOrder finishedAt?: Prisma.SortOrder valid?: Prisma.SortOrder @@ -671,6 +693,7 @@ export type DeploymentCreateWithoutProjectInput = { commitHash?: string | null commitMessage?: string | null buildLog?: string | null + sparseCheckoutPaths?: string | null startedAt?: Date | string finishedAt?: Date | string | null valid?: number @@ -689,6 +712,7 @@ export type DeploymentUncheckedCreateWithoutProjectInput = { commitHash?: string | null commitMessage?: string | null buildLog?: string | null + sparseCheckoutPaths?: string | null startedAt?: Date | string finishedAt?: Date | string | null valid?: number @@ -735,6 +759,7 @@ export type DeploymentScalarWhereInput = { commitHash?: Prisma.StringNullableFilter<"Deployment"> | string | null commitMessage?: Prisma.StringNullableFilter<"Deployment"> | string | null buildLog?: Prisma.StringNullableFilter<"Deployment"> | string | null + sparseCheckoutPaths?: Prisma.StringNullableFilter<"Deployment"> | string | null startedAt?: Prisma.DateTimeFilter<"Deployment"> | Date | string finishedAt?: Prisma.DateTimeNullableFilter<"Deployment"> | Date | string | null valid?: Prisma.IntFilter<"Deployment"> | number @@ -754,6 +779,7 @@ export type DeploymentCreateManyProjectInput = { commitHash?: string | null commitMessage?: string | null buildLog?: string | null + sparseCheckoutPaths?: string | null startedAt?: Date | string finishedAt?: Date | string | null valid?: number @@ -771,6 +797,7 @@ export type DeploymentUpdateWithoutProjectInput = { commitHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null commitMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null buildLog?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + sparseCheckoutPaths?: Prisma.NullableStringFieldUpdateOperationsInput | string | null startedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string finishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null valid?: Prisma.IntFieldUpdateOperationsInput | number @@ -789,6 +816,7 @@ export type DeploymentUncheckedUpdateWithoutProjectInput = { commitHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null commitMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null buildLog?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + sparseCheckoutPaths?: Prisma.NullableStringFieldUpdateOperationsInput | string | null startedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string finishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null valid?: Prisma.IntFieldUpdateOperationsInput | number @@ -807,6 +835,7 @@ export type DeploymentUncheckedUpdateManyWithoutProjectInput = { commitHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null commitMessage?: Prisma.NullableStringFieldUpdateOperationsInput | string | null buildLog?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + sparseCheckoutPaths?: Prisma.NullableStringFieldUpdateOperationsInput | string | null startedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string finishedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null valid?: Prisma.IntFieldUpdateOperationsInput | number @@ -827,6 +856,7 @@ export type DeploymentSelect = runtime.Types.Extensions.GetOmit<"id" | "branch" | "env" | "status" | "commitHash" | "commitMessage" | "buildLog" | "startedAt" | "finishedAt" | "valid" | "createdAt" | "updatedAt" | "createdBy" | "updatedBy" | "projectId" | "pipelineId", ExtArgs["result"]["deployment"]> +export type DeploymentOmit = runtime.Types.Extensions.GetOmit<"id" | "branch" | "env" | "status" | "commitHash" | "commitMessage" | "buildLog" | "sparseCheckoutPaths" | "startedAt" | "finishedAt" | "valid" | "createdAt" | "updatedAt" | "createdBy" | "updatedBy" | "projectId" | "pipelineId", ExtArgs["result"]["deployment"]> export type DeploymentInclude = { Project?: boolean | Prisma.Deployment$ProjectArgs } @@ -922,6 +955,7 @@ export type $DeploymentPayload readonly commitMessage: Prisma.FieldRef<"Deployment", 'String'> readonly buildLog: Prisma.FieldRef<"Deployment", 'String'> + readonly sparseCheckoutPaths: Prisma.FieldRef<"Deployment", 'String'> readonly startedAt: Prisma.FieldRef<"Deployment", 'DateTime'> readonly finishedAt: Prisma.FieldRef<"Deployment", 'DateTime'> readonly valid: Prisma.FieldRef<"Deployment", 'Int'> diff --git a/apps/server/libs/execution-queue.ts b/apps/server/libs/execution-queue.ts new file mode 100644 index 0000000..11fd2d3 --- /dev/null +++ b/apps/server/libs/execution-queue.ts @@ -0,0 +1,233 @@ +import { PipelineRunner } from '../runners/index.ts'; +import { prisma } from './prisma.ts'; + +// 存储正在运行的部署任务 +const runningDeployments = new Set(); + +// 存储待执行的任务队列 +const pendingQueue: Array<{ + deploymentId: number; + pipelineId: number; +}> = []; + +// 定时器ID +let pollingTimer: NodeJS.Timeout | null = null; + +// 轮询间隔(毫秒) +const POLLING_INTERVAL = 30000; // 30秒 + +/** + * 执行队列管理器 + */ +export class ExecutionQueue { + private static instance: ExecutionQueue; + private isProcessing = false; + private isPolling = false; + + private constructor() {} + + /** + * 获取执行队列的单例实例 + */ + public static getInstance(): ExecutionQueue { + if (!ExecutionQueue.instance) { + ExecutionQueue.instance = new ExecutionQueue(); + } + return ExecutionQueue.instance; + } + + /** + * 初始化执行队列,包括恢复未完成的任务 + */ + public async initialize(): Promise { + console.log('Initializing execution queue...'); + // 恢复未完成的任务 + await this.recoverPendingDeployments(); + + // 启动定时轮询 + this.startPolling(); + + console.log('Execution queue initialized'); + } + + /** + * 从数据库中恢复未完成的部署任务 + */ + private async recoverPendingDeployments(): Promise { + try { + console.log('Recovering pending deployments from database...'); + + // 查询数据库中状态为pending的部署任务 + const pendingDeployments = await prisma.deployment.findMany({ + where: { + status: 'pending', + valid: 1 + }, + select: { + id: true, + pipelineId: true + } + }); + + console.log(`Found ${pendingDeployments.length} pending deployments`); + + // 将这些任务添加到执行队列中 + for (const deployment of pendingDeployments) { + await this.addTask(deployment.id, deployment.pipelineId); + } + + console.log('Pending deployments recovery completed'); + } catch (error) { + console.error('Failed to recover pending deployments:', error); + } + } + + /** + * 启动定时轮询机制 + */ + private startPolling(): void { + if (this.isPolling) { + console.log('Polling is already running'); + return; + } + + this.isPolling = true; + console.log(`Starting polling with interval ${POLLING_INTERVAL}ms`); + + // 立即执行一次检查 + this.checkPendingDeployments(); + + // 设置定时器定期检查 + pollingTimer = setInterval(() => { + this.checkPendingDeployments(); + }, POLLING_INTERVAL); + } + + /** + * 停止定时轮询机制 + */ + public stopPolling(): void { + if (pollingTimer) { + clearInterval(pollingTimer); + pollingTimer = null; + this.isPolling = false; + console.log('Polling stopped'); + } + } + + /** + * 检查数据库中的待处理部署任务 + */ + private async checkPendingDeployments(): Promise { + try { + console.log('Checking for pending deployments in database...'); + + // 查询数据库中状态为pending的部署任务 + const pendingDeployments = await prisma.deployment.findMany({ + where: { + status: 'pending', + valid: 1 + }, + select: { + id: true, + pipelineId: true + } + }); + + console.log(`Found ${pendingDeployments.length} pending deployments in polling`); + + // 检查这些任务是否已经在队列中,如果没有则添加 + for (const deployment of pendingDeployments) { + // 检查是否已经在运行队列中 + if (!runningDeployments.has(deployment.id)) { + console.log(`Adding deployment ${deployment.id} to queue from polling`); + await this.addTask(deployment.id, deployment.pipelineId); + } + } + } catch (error) { + console.error('Failed to check pending deployments:', error); + } + } + + /** + * 将部署任务添加到执行队列 + * @param deploymentId 部署ID + * @param pipelineId 流水线ID + */ + public async addTask(deploymentId: number, pipelineId: number): Promise { + // 检查是否已经在运行队列中 + if (runningDeployments.has(deploymentId)) { + console.log(`Deployment ${deploymentId} is already queued or running`); + return; + } + + // 添加到运行队列 + runningDeployments.add(deploymentId); + + // 添加到待执行队列 + pendingQueue.push({ deploymentId, pipelineId }); + + // 开始处理队列(如果尚未开始) + if (!this.isProcessing) { + this.processQueue(); + } + } + + /** + * 处理执行队列中的任务 + */ + private async processQueue(): Promise { + this.isProcessing = true; + + while (pendingQueue.length > 0) { + const task = pendingQueue.shift(); + + if (task) { + try { + // 执行流水线 + await this.executePipeline(task.deploymentId, task.pipelineId); + } catch (error) { + console.error('执行流水线失败:', error); + // 这里可以添加更多的错误处理逻辑 + } finally { + // 从运行队列中移除 + runningDeployments.delete(task.deploymentId); + } + } + + // 添加一个小延迟以避免过度占用资源 + await new Promise(resolve => setTimeout(resolve, 100)); + } + + this.isProcessing = false; + } + + /** + * 执行流水线 + * @param deploymentId 部署ID + * @param pipelineId 流水线ID + */ + private async executePipeline(deploymentId: number, pipelineId: number): Promise { + try { + const runner = new PipelineRunner(deploymentId); + await runner.run(pipelineId); + } catch (error) { + console.error('执行流水线失败:', error); + // 错误处理可以在这里添加,比如更新部署状态为失败 + throw error; + } + } + + /** + * 获取队列状态 + */ + public getQueueStatus(): { + pendingCount: number; + runningCount: number; + } { + return { + pendingCount: pendingQueue.length, + runningCount: runningDeployments.size + }; + } +} diff --git a/apps/server/libs/pipeline-template.ts b/apps/server/libs/pipeline-template.ts new file mode 100644 index 0000000..70dc2b5 --- /dev/null +++ b/apps/server/libs/pipeline-template.ts @@ -0,0 +1,247 @@ +import { prisma } from './prisma.ts'; + +// 默认流水线模板 +export interface PipelineTemplate { + name: string; + description: string; + steps: Array<{ + name: string; + order: number; + script: string; + }>; +} + +// 系统默认的流水线模板 +export const DEFAULT_PIPELINE_TEMPLATES: PipelineTemplate[] = [ + { + name: 'Git Clone Pipeline', + description: '默认的Git克隆流水线,用于从仓库克隆代码', + steps: [ + { + name: 'Clone Repository', + order: 0, + script: '# 克隆指定commit的代码\ngit init\ngit remote add origin $REPOSITORY_URL\ngit fetch --depth 1 origin $COMMIT_HASH\ngit checkout -q FETCH_HEAD\n\n# 显示当前提交信息\ngit log --oneline -1', + }, + { + name: 'Install Dependencies', + order: 1, + script: '# 安装项目依赖\nnpm install', + }, + { + name: 'Run Tests', + order: 2, + script: '# 运行测试\nnpm test', + }, + { + name: 'Build Project', + order: 3, + script: '# 构建项目\nnpm run build', + } + ] + }, + { + name: 'Sparse Checkout Pipeline', + description: '稀疏检出流水线,适用于monorepo项目,只获取指定目录的代码', + steps: [ + { + name: 'Sparse Checkout Repository', + order: 0, + script: '# 进行稀疏检出指定目录的代码\ngit init\ngit remote add origin $REPOSITORY_URL\ngit config core.sparseCheckout true\necho "$SPARSE_CHECKOUT_PATHS" > .git/info/sparse-checkout\ngit fetch --depth 1 origin $COMMIT_HASH\ngit checkout -q FETCH_HEAD\n\n# 显示当前提交信息\ngit log --oneline -1', + }, + { + name: 'Install Dependencies', + order: 1, + script: '# 安装项目依赖\nnpm install', + }, + { + name: 'Run Tests', + order: 2, + script: '# 运行测试\nnpm test', + }, + { + name: 'Build Project', + order: 3, + script: '# 构建项目\nnpm run build', + } + ] + }, + { + name: 'Simple Deploy Pipeline', + description: '简单的部署流水线,包含基本的构建和部署步骤', + steps: [ + { + name: 'Clone Repository', + order: 0, + script: '# 克隆指定commit的代码\ngit init\ngit remote add origin $REPOSITORY_URL\ngit fetch --depth 1 origin $COMMIT_HASH\ngit checkout -q FETCH_HEAD', + }, + { + name: 'Build and Deploy', + order: 1, + script: '# 构建并部署项目\nnpm run build\n\n# 部署到目标服务器\n# 这里可以添加具体的部署命令', + } + ] + } +]; + +/** + * 初始化系统默认流水线模板 + */ +export async function initializePipelineTemplates(): Promise { + console.log('Initializing pipeline templates...'); + + try { + // 检查是否已经存在模板流水线 + const existingTemplates = await prisma.pipeline.findMany({ + where: { + name: { + in: DEFAULT_PIPELINE_TEMPLATES.map(template => template.name) + }, + valid: 1 + } + }); + + // 如果没有现有的模板,则创建默认模板 + if (existingTemplates.length === 0) { + console.log('Creating default pipeline templates...'); + + for (const template of DEFAULT_PIPELINE_TEMPLATES) { + // 创建模板流水线(使用负数ID表示模板) + const pipeline = await prisma.pipeline.create({ + data: { + name: template.name, + description: template.description, + createdBy: 'system', + updatedBy: 'system', + valid: 1, + projectId: null // 模板不属于任何特定项目 + } + }); + + // 创建模板步骤 + for (const step of template.steps) { + await prisma.step.create({ + data: { + name: step.name, + order: step.order, + script: step.script, + pipelineId: pipeline.id, + createdBy: 'system', + updatedBy: 'system', + valid: 1 + } + }); + } + + console.log(`Created template: ${template.name}`); + } + } else { + console.log('Pipeline templates already exist, skipping initialization'); + } + + console.log('Pipeline templates initialization completed'); + } catch (error) { + console.error('Failed to initialize pipeline templates:', error); + throw error; + } +} + +/** + * 获取所有可用的流水线模板 + */ +export async function getAvailableTemplates(): Promise> { + try { + const templates = await prisma.pipeline.findMany({ + where: { + projectId: null, // 模板流水线没有关联的项目 + valid: 1 + }, + select: { + id: true, + name: true, + description: true + } + }); + + // 处理可能为null的description字段 + return templates.map(template => ({ + id: template.id, + name: template.name, + description: template.description || '' + })); + } catch (error) { + console.error('Failed to get pipeline templates:', error); + throw error; + } +} + +/** + * 基于模板创建新的流水线 + * @param templateId 模板ID + * @param projectId 项目ID + * @param pipelineName 新流水线名称 + * @param pipelineDescription 新流水线描述 + */ +export async function createPipelineFromTemplate( + templateId: number, + projectId: number, + pipelineName: string, + pipelineDescription: string +): Promise { + try { + // 获取模板流水线及其步骤 + const templatePipeline = await prisma.pipeline.findUnique({ + where: { + id: templateId, + projectId: null, // 确保是模板流水线 + valid: 1 + }, + include: { + steps: { + where: { + valid: 1 + }, + orderBy: { + order: 'asc' + } + } + } + }); + + if (!templatePipeline) { + throw new Error(`Template with id ${templateId} not found`); + } + + // 创建新的流水线 + const newPipeline = await prisma.pipeline.create({ + data: { + name: pipelineName, + description: pipelineDescription, + projectId: projectId, + createdBy: 'system', + updatedBy: 'system', + valid: 1 + } + }); + + // 复制模板步骤到新流水线 + for (const templateStep of templatePipeline.steps) { + await prisma.step.create({ + data: { + name: templateStep.name, + order: templateStep.order, + script: templateStep.script, + pipelineId: newPipeline.id, + createdBy: 'system', + updatedBy: 'system', + valid: 1 + } + }); + } + + console.log(`Created pipeline from template ${templateId}: ${newPipeline.name}`); + return newPipeline.id; + } catch (error) { + console.error('Failed to create pipeline from template:', error); + throw error; + } +} diff --git a/apps/server/middlewares/index.ts b/apps/server/middlewares/index.ts index 2be254c..9103c5f 100644 --- a/apps/server/middlewares/index.ts +++ b/apps/server/middlewares/index.ts @@ -5,7 +5,7 @@ import { Session } from './session.ts'; import { CORS } from './cors.ts'; import { HttpLogger } from './logger.ts'; import type Koa from 'koa'; -import { Authorization } from './Authorization.ts'; +import { Authorization } from './authorization.ts'; /** * 初始化中间件 diff --git a/apps/server/prisma/data/dev.db b/apps/server/prisma/data/dev.db index 0ad9f40df7f1aee38640f7f3c75770a5251cdaef..7f9afae143463bd15e918a7e7691758d05d31574 100644 GIT binary patch literal 36864 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 + + /** + * 监听MQ消息并执行流水线 + */ + listenForPipelineMessages(): void; + + /** + * 停止监听MQ消息 + */ + stopListening(): void; +} diff --git a/apps/server/runners/pipeline-runner.ts b/apps/server/runners/pipeline-runner.ts new file mode 100644 index 0000000..bbe470e --- /dev/null +++ b/apps/server/runners/pipeline-runner.ts @@ -0,0 +1,254 @@ +import { $ } from 'zx'; +import { prisma } from '../libs/prisma.ts'; +import type { Step } from '../generated/client.ts'; +import fs from 'node:fs'; +import path from 'node:path'; + +export class PipelineRunner { + private deploymentId: number; + private workspace: string; + + constructor(deploymentId: number) { + this.deploymentId = deploymentId; + // 从环境变量获取工作空间路径,默认为/tmp/foka-ci/workspace + this.workspace = process.env.PIPELINE_WORKSPACE || '/tmp/foka-ci/workspace'; + } + + /** + * 执行流水线 + * @param pipelineId 流水线ID + */ + async run(pipelineId: number): Promise { + // 获取流水线及其步骤 + const pipeline = await prisma.pipeline.findUnique({ + where: { id: pipelineId }, + include: { + steps: { where: { valid: 1 }, orderBy: { order: 'asc' } }, + Project: true // 同时获取关联的项目信息 + } + }); + + if (!pipeline) { + throw new Error(`Pipeline with id ${pipelineId} not found`); + } + + // 获取部署信息 + const deployment = await prisma.deployment.findUnique({ + where: { id: this.deploymentId } + }); + + if (!deployment) { + throw new Error(`Deployment with id ${this.deploymentId} not found`); + } + + // 确保工作空间目录存在 + await this.ensureWorkspace(); + + // 创建项目目录(在工作空间内) + const projectDir = path.join(this.workspace, `project-${pipelineId}`); + await this.ensureProjectDirectory(projectDir); + + // 更新部署状态为running + await prisma.deployment.update({ + where: { id: this.deploymentId }, + data: { status: 'running' } + }); + + let logs = ''; + let hasError = false; + + try { + // 依次执行每个步骤 + for (const [index, step] of pipeline.steps.entries()) { + // 准备环境变量 + const envVars = this.prepareEnvironmentVariables(pipeline, deployment, projectDir); + + // 记录开始执行步骤的日志,包含脚本内容(合并为一行,并用括号括起脚本内容) + const startLog = `[${new Date().toISOString()}] 开始执行步骤 ${index + 1}/${pipeline.steps.length}: ${step.name}\n`; + logs += startLog; + + // 实时更新日志 + await prisma.deployment.update({ + where: { id: this.deploymentId }, + data: { buildLog: logs } + }); + + // 执行步骤(传递环境变量和项目目录) + const stepLog = await this.executeStep(step, envVars, projectDir); + logs += stepLog + '\n'; + + // 记录步骤执行完成的日志 + const endLog = `[${new Date().toISOString()}] 步骤 "${step.name}" 执行完成\n`; + logs += endLog; + + // 实时更新日志 + await prisma.deployment.update({ + where: { id: this.deploymentId }, + data: { buildLog: logs } + }); + } + } catch (error) { + hasError = true; + logs += `[${new Date().toISOString()}] Error: ${(error as Error).message}\n`; + + // 记录错误日志 + await prisma.deployment.update({ + where: { id: this.deploymentId }, + data: { + buildLog: logs, + status: 'failed' + } + }); + + throw error; + } finally { + // 更新最终状态 + if (!hasError) { + await prisma.deployment.update({ + where: { id: this.deploymentId }, + data: { + buildLog: logs, + status: 'success', + finishedAt: new Date() + } + }); + } + } + } + + /** + * 准备环境变量 + * @param pipeline 流水线信息 + * @param deployment 部署信息 + * @param projectDir 项目目录路径 + */ + private prepareEnvironmentVariables(pipeline: any, deployment: any, projectDir: string): Record { + const envVars: Record = {}; + + // 项目相关信息 + if (pipeline.Project) { + envVars.REPOSITORY_URL = pipeline.Project.repository || ''; + envVars.PROJECT_NAME = pipeline.Project.name || ''; + } + + // 部署相关信息 + envVars.BRANCH_NAME = deployment.branch || ''; + envVars.COMMIT_HASH = deployment.commitHash || ''; + + // 稀疏检出路径(如果有配置的话) + envVars.SPARSE_CHECKOUT_PATHS = deployment.sparseCheckoutPaths || ''; + + // 工作空间路径和项目路径 + envVars.WORKSPACE = this.workspace; + envVars.PROJECT_DIR = projectDir; + + return envVars; + } + + /** + * 为日志添加时间戳前缀 + * @param message 日志消息 + * @param isError 是否为错误日志 + * @returns 带时间戳的日志消息 + */ + private addTimestamp(message: string, isError = false): string { + const timestamp = new Date().toISOString(); + if (isError) { + return `[${timestamp}] [ERROR] ${message}`; + } + return `[${timestamp}] ${message}`; + } + + /** + * 为多行日志添加时间戳前缀 + * @param content 多行日志内容 + * @param isError 是否为错误日志 + * @returns 带时间戳的多行日志消息 + */ + private addTimestampToLines(content: string, isError = false): string { + if (!content) return ''; + + return content.split('\n') + .filter(line => line.trim() !== '') + .map(line => this.addTimestamp(line, isError)) + .join('\n') + '\n'; + } + + /** + * 确保工作空间目录存在 + */ + private async ensureWorkspace(): Promise { + try { + // 检查目录是否存在,如果不存在则创建 + if (!fs.existsSync(this.workspace)) { + // 创建目录包括所有必要的父目录 + fs.mkdirSync(this.workspace, { recursive: true }); + } + + // 检查目录是否可写 + fs.accessSync(this.workspace, fs.constants.W_OK); + } catch (error) { + throw new Error(`无法访问或创建工作空间目录 "${this.workspace}": ${(error as Error).message}`); + } + } + + /** + * 确保项目目录存在 + * @param projectDir 项目目录路径 + */ + private async ensureProjectDirectory(projectDir: string): Promise { + try { + // 检查目录是否存在,如果不存在则创建 + if (!fs.existsSync(projectDir)) { + fs.mkdirSync(projectDir, { recursive: true }); + } + + // 检查目录是否可写 + fs.accessSync(projectDir, fs.constants.W_OK); + } catch (error) { + throw new Error(`无法访问或创建项目目录 "${projectDir}": ${(error as Error).message}`); + } + } + + /** + * 执行单个步骤 + * @param step 步骤对象 + * @param envVars 环境变量 + * @param projectDir 项目目录路径 + */ + private async executeStep(step: Step, envVars: Record, projectDir: string): Promise { + let logs = ''; + + try { + // 添加步骤开始执行的时间戳 + logs += this.addTimestamp(`开始执行步骤 "${step.name}"`) + '\n'; + + // 使用zx执行脚本,设置项目目录为工作目录和环境变量 + const script = step.script; + + // 通过bash -c执行脚本,确保环境变量能被正确解析 + const result = await $({ + cwd: projectDir, + env: { ...process.env, ...envVars } + })`bash -c ${script}`; + + if (result.stdout) { + // 为stdout中的每一行添加时间戳 + logs += this.addTimestampToLines(result.stdout); + } + + if (result.stderr) { + // 为stderr中的每一行添加时间戳和错误标记 + logs += this.addTimestampToLines(result.stderr, true); + } + + // 添加步骤执行完成的时间戳 + logs += this.addTimestamp(`步骤 "${step.name}" 执行完成`) + '\n'; + } catch (error) { + logs += this.addTimestamp(`Error executing step "${step.name}": ${(error as Error).message}`) + '\n'; + throw error; + } + + return logs; + } +} diff --git a/apps/web/src/pages/project/detail/components/DeployModal.tsx b/apps/web/src/pages/project/detail/components/DeployModal.tsx index ff6667c..d577c5d 100644 --- a/apps/web/src/pages/project/detail/components/DeployModal.tsx +++ b/apps/web/src/pages/project/detail/components/DeployModal.tsx @@ -108,6 +108,7 @@ function DeployModal({ commitHash: selectedCommit.sha, commitMessage: selectedCommit.commit.message, env: env, + sparseCheckoutPaths: values.sparseCheckoutPaths, }); Message.success('部署任务已创建'); @@ -196,6 +197,17 @@ function DeployModal({ + + + +
环境变量
{(fields, { add, remove }) => ( diff --git a/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx b/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx index ac3e261..ab747ff 100644 --- a/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx +++ b/apps/web/src/pages/project/detail/components/DeployRecordItem.tsx @@ -34,6 +34,7 @@ function DeployRecordItem({ const config = envMap[env] || { color: 'gray', text: env }; return {config.text}; }; + return ( (); @@ -82,7 +82,24 @@ function ProjectDetailPage() { const [deployRecords, setDeployRecords] = useState([]); const [deployModalVisible, setDeployModalVisible] = useState(false); + // 流水线模板相关状态 + const [isCreatingFromTemplate, setIsCreatingFromTemplate] = useState(false); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); + const [templates, setTemplates] = useState>([]); + const { id } = useParams(); + + // 获取可用的流水线模板 + useAsyncEffect(async () => { + try { + const templateData = await detailService.getPipelineTemplates(); + setTemplates(templateData); + } catch (error) { + console.error('获取流水线模板失败:', error); + Message.error('获取流水线模板失败'); + } + }, []); + useAsyncEffect(async () => { if (id) { const project = await detailService.getProject(id); @@ -134,6 +151,28 @@ function ProjectDetailPage() { return record.buildLog.split('\n'); }; + // 定期轮询部署记录以更新状态和日志 + useAsyncEffect(async () => { + const interval = setInterval(async () => { + if (id) { + try { + const records = await detailService.getDeployments(Number(id)); + setDeployRecords(records); + + // 如果当前选中的记录正在运行,则更新选中记录 + const selectedRecord = records.find((r: Deployment) => r.id === selectedRecordId); + if (selectedRecord && (selectedRecord.status === 'running' || selectedRecord.status === 'pending')) { + // 保持当前选中状态,但更新数据 + } + } catch (error) { + console.error('轮询部署记录失败:', error); + } + } + }, 3000); // 每3秒轮询一次 + + return () => clearInterval(interval); + }, [id, selectedRecordId]); + // 触发部署 const handleDeploy = () => { setDeployModalVisible(true); @@ -144,6 +183,8 @@ function ProjectDetailPage() { setEditingPipeline(null); pipelineForm.resetFields(); setPipelineModalVisible(true); + setIsCreatingFromTemplate(false); // 默认不是从模板创建 + setSelectedTemplateId(null); }; // 编辑流水线 @@ -295,6 +336,32 @@ function ProjectDetailPage() { ), ); Message.success('流水线更新成功'); + } else if (isCreatingFromTemplate && selectedTemplateId) { + // 基于模板创建新流水线 + const newPipeline = await detailService.createPipelineFromTemplate( + selectedTemplateId, + Number(id), + values.name, + values.description || '' + ); + + // 更新本地状态 - 需要转换步骤数据结构 + const transformedSteps = newPipeline.steps?.map(step => ({ + ...step, + enabled: step.valid === 1 + })) || []; + + const pipelineWithDefaults = { + ...newPipeline, + description: newPipeline.description || '', + enabled: newPipeline.valid === 1, + steps: transformedSteps, + }; + + setPipelines((prev) => [...prev, pipelineWithDefaults]); + // 自动选中新创建的流水线 + setSelectedPipelineId(newPipeline.id); + Message.success('基于模板创建流水线成功'); } else { // 创建新流水线 const newPipeline = await detailService.createPipeline({ @@ -316,6 +383,8 @@ function ProjectDetailPage() { Message.success('流水线创建成功'); } setPipelineModalVisible(false); + setIsCreatingFromTemplate(false); + setSelectedTemplateId(null); } catch (error) { console.error('保存流水线失败:', error); Message.error('保存流水线失败'); @@ -494,6 +563,23 @@ function ProjectDetailPage() { } }; + // 添加重新执行部署的函数 + const handleRetryDeployment = async (deploymentId: number) => { + try { + await detailService.retryDeployment(deploymentId); + Message.success('重新执行任务已创建'); + + // 刷新部署记录 + if (id) { + const records = await detailService.getDeployments(Number(id)); + setDeployRecords(records); + } + } catch (error) { + console.error('重新执行部署失败:', error); + Message.error('重新执行部署失败'); + } + }; + const selectedRecord = deployRecords.find( (record) => record.id === selectedRecordId, ); @@ -512,17 +598,20 @@ function ProjectDetailPage() { }; // 渲染部署记录项 - const renderDeployRecordItem = (item: Deployment, _index: number) => { - const isSelected = item.id === selectedRecordId; - return ( - - ); - }; + const renderDeployRecordItem = (item: Deployment) => ( + + ); + + // 获取选中的流水线 + const selectedPipeline = pipelines.find( + (pipeline) => pipeline.id === selectedPipelineId, + ); return (
@@ -541,7 +630,7 @@ function ProjectDetailPage() {
@@ -588,6 +677,16 @@ function ProjectDetailPage() {
{selectedRecord && (
+ {selectedRecord.status === 'failed' && ( + + )} {renderStatusTag(selectedRecord.status)}
)} @@ -700,43 +799,36 @@ function ProjectDetailPage() { } - position="bottom" + position="br" + trigger="click" > -
-
-
{pipeline.description}
-
- - 共 {pipeline.steps?.length || 0} 个步骤 - - - {new Date( - pipeline.updatedAt, - ).toLocaleString()} - -
+ + {pipeline.description} + +
+ + {pipeline.steps?.length || 0} 个步骤 + + {pipeline.updatedAt}
); })} - - {pipelines.length === 0 && ( -
- - - 点击上方"新建流水线"按钮开始创建 - -
- )} @@ -768,7 +860,6 @@ function ProjectDetailPage() { -
+
step.id, - ) || [] - } + items={selectedPipeline.steps?.map(step => step.id) || []} strategy={verticalListSortingStrategy} > -
+
{selectedPipeline.steps?.map((step, index) => (
- - {/* 新建/编辑流水线模态框 */} - setPipelineModalVisible(false)} - style={{ width: 500 }} - > -
- - - - - - -
-
- - {/* 编辑步骤模态框 */} - setEditModalVisible(false)} - style={{ width: 600 }} - > -
- - - - - - -
- - 可用环境变量: -
• $PROJECT_NAME - 项目名称 -
• $BUILD_NUMBER - 构建编号 -
• $REGISTRY - 镜像仓库地址 -
-
-
-
+ {/* 新建/编辑流水线模态框 */} + { + setPipelineModalVisible(false); + setIsCreatingFromTemplate(false); + setSelectedTemplateId(null); + }} + style={{ width: 500 }} + > +
+ {!editingPipeline && templates.length > 0 && ( + +
+ + +
+
+ )} + + {isCreatingFromTemplate && templates.length > 0 ? ( + <> + + + + + {selectedTemplateId && ( + <> + + + + + + + + )} + + ) : ( + <> + + + + + + + + )} +
+
+ + {/* 编辑步骤模态框 */} + setEditModalVisible(false)} + style={{ width: 600 }} + > +
+ + + + + + +
+ + 可用环境变量: +
• $PROJECT_NAME - 项目名称 +
• $BUILD_NUMBER - 构建编号 +
• $REGISTRY - 镜像仓库地址 +
+
+
+
+ setDeployModalVisible(false)} diff --git a/apps/web/src/pages/project/detail/service.ts b/apps/web/src/pages/project/detail/service.ts index 705b0fc..02ffc84 100644 --- a/apps/web/src/pages/project/detail/service.ts +++ b/apps/web/src/pages/project/detail/service.ts @@ -1,5 +1,5 @@ import { type APIResponse, net } from '@shared'; -import type { Branch, Commit, Deployment, Pipeline, Project, Step } from '../types'; +import type { Branch, Commit, Deployment, Pipeline, Project, Step, CreateDeploymentRequest } from '../types'; class DetailService { async getProject(id: string) { @@ -17,6 +17,14 @@ class DetailService { return data; } + // 获取可用的流水线模板 + async getPipelineTemplates() { + const { data } = await net.request>({ + url: '/api/pipelines/templates', + }); + return data; + } + // 获取项目的部署记录 async getDeployments(projectId: number) { const { data } = await net.request({ @@ -46,6 +54,26 @@ class DetailService { return data; } + // 基于模板创建流水线 + async createPipelineFromTemplate( + templateId: number, + projectId: number, + name: string, + description?: string + ) { + const { data } = await net.request>({ + url: '/api/pipelines/from-template', + method: 'POST', + data: { + templateId, + projectId, + name, + description + }, + }); + return data; + } + // 更新流水线 async updatePipeline( id: number, @@ -122,6 +150,7 @@ class DetailService { // 删除步骤 async deleteStep(id: number) { + // DELETE请求返回204状态码,通过拦截器处理为成功响应 const { data } = await net.request>({ url: `/api/steps/${id}`, method: 'DELETE', @@ -146,14 +175,7 @@ class DetailService { } // 创建部署 - async createDeployment(deployment: { - projectId: number; - pipelineId: number; - branch: string; - commitHash: string; - commitMessage: string; - env?: string; - }) { + async createDeployment(deployment: CreateDeploymentRequest) { const { data } = await net.request>({ url: '/api/deployments', method: 'POST', @@ -161,6 +183,15 @@ class DetailService { }); return data; } + + // 重新执行部署 + async retryDeployment(deploymentId: number) { + const { data } = await net.request>({ + url: `/api/deployments/${deploymentId}/retry`, + method: 'POST', + }); + return data; + } } export const detailService = new DetailService(); diff --git a/apps/web/src/pages/project/types.ts b/apps/web/src/pages/project/types.ts index bf5cf41..a24598d 100644 --- a/apps/web/src/pages/project/types.ts +++ b/apps/web/src/pages/project/types.ts @@ -54,6 +54,7 @@ export interface Deployment { commitHash?: string; commitMessage?: string; buildLog?: string; + sparseCheckoutPaths?: string; // 稀疏检出路径,用于monorepo项目 startedAt: string; finishedAt?: string; valid: number; @@ -90,3 +91,14 @@ export interface Branch { }; }; } + +// 创建部署请求的类型定义 +export interface CreateDeploymentRequest { + projectId: number; + pipelineId: number; + branch: string; + commitHash: string; + commitMessage: string; + env?: string; + sparseCheckoutPaths?: string; // 稀疏检出路径,用于monorepo项目 +} diff --git a/apps/web/src/shared/request.ts b/apps/web/src/shared/request.ts index d86e815..70af1c2 100644 --- a/apps/web/src/shared/request.ts +++ b/apps/web/src/shared/request.ts @@ -19,6 +19,16 @@ class Net { }, (error) => { console.log('error', error); + // 对于DELETE请求返回204状态码的情况,视为成功 + if (error.response && error.response.status === 204 && error.config.method === 'delete') { + // 创建一个模拟的成功响应 + return Promise.resolve({ + ...error.response, + data: error.response.data || null, + status: 200, // 将204转换为200,避免被当作错误处理 + }); + } + if (error.status === 401 && error.config.url !== '/api/auth/info') { window.location.href = '/login'; return;