From d0375257ce4ec1e34cbc3b66bdb3e80b1859fecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Thu, 11 Jun 2026 22:33:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=8D=95=20MySQL=20=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + prisma/seed.ts | 114 +++++ src/deploy-runs/deploy-execution.service.ts | 66 +-- src/deploy-runs/deploy-run.repository.ts | 492 +++++++++++++++++--- src/deploy-runs/deploy-runs.service.ts | 4 +- 5 files changed, 565 insertions(+), 112 deletions(-) create mode 100644 prisma/seed.ts diff --git a/package.json b/package.json index 62f54c8..7ba2511 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "lint": "eslint \"{src,prisma}/**/*.{ts,js}\"", "prisma:generate": "prisma generate", "prisma:migrate:dev": "prisma migrate dev", + "prisma:seed": "node dist/prisma/seed.js", "prisma:studio": "prisma studio", "start": "nest start", "start:dev": "nest start --watch", diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..d268be8 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,114 @@ +/** + * 将代码内的纳管项目清单同步到数据库,保持项目、环境和 Jenkins Job 可重复初始化。 + */ +import { EnvironmentKind, PrismaClient, ProjectStatus } from '@prisma/client'; +import { seedProjects } from '../src/projects/project.seed'; +import type { ProjectEnvironmentSummary } from '../src/projects/project.types'; + +const prisma = new PrismaClient(); + +async function main(): Promise { + for (const project of seedProjects) { + const projectRecord = await prisma.project.upsert({ + where: { + key: project.key, + }, + create: { + id: project.id, + key: project.key, + name: project.name, + repositoryUrl: project.repositoryUrl, + defaultBranch: project.defaultBranch, + status: + project.status === 'active' + ? ProjectStatus.ACTIVE + : ProjectStatus.ARCHIVED, + metadata: { + source: 'seedProjects', + }, + }, + update: { + name: project.name, + repositoryUrl: project.repositoryUrl, + defaultBranch: project.defaultBranch, + status: + project.status === 'active' + ? ProjectStatus.ACTIVE + : ProjectStatus.ARCHIVED, + metadata: { + source: 'seedProjects', + }, + }, + }); + + for (const environment of project.environments) { + await upsertEnvironment(projectRecord.id, environment); + } + } +} + +async function upsertEnvironment( + projectId: string, + environment: ProjectEnvironmentSummary, +): Promise { + const environmentName = toEnvironmentKind(environment.name); + const environmentRecord = await prisma.environment.upsert({ + where: { + projectId_name: { + projectId, + name: environmentName, + }, + }, + create: { + projectId, + name: environmentName, + displayName: environment.displayName, + }, + update: { + displayName: environment.displayName, + }, + }); + + await prisma.deployJob.upsert({ + where: { + projectId_environmentId: { + projectId, + environmentId: environmentRecord.id, + }, + }, + create: { + projectId, + environmentId: environmentRecord.id, + jenkinsJobPath: environment.jenkinsJobPath, + jenkinsParameters: { + releasePolicy: environment.releasePolicy, + refPattern: environment.refPattern, + }, + }, + update: { + jenkinsJobPath: environment.jenkinsJobPath, + jenkinsParameters: { + releasePolicy: environment.releasePolicy, + refPattern: environment.refPattern, + }, + }, + }); +} + +function toEnvironmentKind( + environment: ProjectEnvironmentSummary['name'], +): EnvironmentKind { + return environment === 'test' + ? EnvironmentKind.TEST + : EnvironmentKind.PRODUCTION; +} + +void main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (error: unknown) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/src/deploy-runs/deploy-execution.service.ts b/src/deploy-runs/deploy-execution.service.ts index 0ba7cec..67b5eee 100644 --- a/src/deploy-runs/deploy-execution.service.ts +++ b/src/deploy-runs/deploy-execution.service.ts @@ -34,7 +34,7 @@ export class DeployExecutionService { let currentRun = this.requireRun( run.id, - this.deployRunRepository.updateMemoryRun(run.id, { status: 'running' }), + await this.deployRunRepository.updateRun(run.id, { status: 'running' }), ); let currentStep: string | undefined; @@ -53,7 +53,7 @@ export class DeployExecutionService { try { currentStep = 'gitea-check'; - currentRun = this.completeStep(currentRun.id, currentStep, 'success'); + currentRun = await this.completeStep(currentRun.id, currentStep, 'success'); currentStep = 'jenkins-build'; const jenkinsResult = await this.advanceJenkinsBuild(currentRun, project); @@ -65,16 +65,16 @@ export class DeployExecutionService { } currentStep = 'health-check'; - currentRun = this.completeStep(currentRun.id, currentStep, 'success'); + currentRun = await this.completeStep(currentRun.id, currentStep, 'success'); currentStep = 'notify-wecom'; currentRun = await this.advanceNotification(currentRun, 'succeeded'); currentStep = 'finish-release'; - currentRun = this.completeStep(currentRun.id, currentStep, 'success'); + currentRun = await this.completeStep(currentRun.id, currentStep, 'success'); currentRun = this.requireRun( currentRun.id, - this.deployRunRepository.updateMemoryRun(currentRun.id, { + await this.deployRunRepository.updateRun(currentRun.id, { status: 'success', }), ); @@ -97,21 +97,21 @@ export class DeployExecutionService { if (currentStep) { currentRun = - this.deployRunRepository.updateMemoryStep( + (await this.deployRunRepository.updateStep( currentRun.id, currentStep, { status: 'failed', errorSummary: failureSummary, }, - ) ?? currentRun; + )) ?? currentRun; } currentRun = - this.deployRunRepository.updateMemoryRun(currentRun.id, { + (await this.deployRunRepository.updateRun(currentRun.id, { status: 'failed', failureSummary, - }) ?? currentRun; + })) ?? currentRun; currentRun = await this.advanceNotification(currentRun, 'failed'); @@ -155,7 +155,7 @@ export class DeployExecutionService { if (!queueItem.buildNumber) { return this.requireRun( currentRun.id, - this.deployRunRepository.updateMemoryRun(currentRun.id, { + await this.deployRunRepository.updateRun(currentRun.id, { status: 'queued', jenkinsQueueId: queueItem.queueId, jenkinsBuildUrl: queueItem.executableUrl, @@ -166,14 +166,14 @@ export class DeployExecutionService { buildNumber = queueItem.buildNumber; currentRun = this.requireRun( currentRun.id, - this.deployRunRepository.updateMemoryRun(currentRun.id, { + await this.deployRunRepository.updateRun(currentRun.id, { status: 'running', jenkinsQueueId: queueItem.queueId, jenkinsBuildNumber: queueItem.buildNumber, jenkinsBuildUrl: queueItem.executableUrl, }), ); - currentRun = this.markStep( + currentRun = await this.markStep( currentRun.id, 'jenkins-build', 'running', @@ -204,7 +204,7 @@ export class DeployExecutionService { currentRun = this.requireRun( currentRun.id, - this.deployRunRepository.updateMemoryRun(currentRun.id, { + await this.deployRunRepository.updateRun(currentRun.id, { status: buildStatus.building || !buildStatus.result ? 'running' : currentRun.status, jenkinsBuildNumber: buildStatus.buildNumber, jenkinsBuildUrl: buildStatus.url, @@ -242,7 +242,7 @@ export class DeployExecutionService { const reason = health.message ?? 'Jenkins is not configured; simulated build completed.'; - const currentRun = this.completeStep(run.id, 'jenkins-build', 'success'); + const currentRun = await this.completeStep(run.id, 'jenkins-build', 'success'); this.auditService.record({ action: 'JENKINS_DRY_RUN_COMPLETED', @@ -268,7 +268,7 @@ export class DeployExecutionService { ); } - this.markStep(run.id, 'jenkins-build', 'running'); + await this.markStep(run.id, 'jenkins-build', 'running'); const queueItem = await this.jenkinsClient.buildWithParameters( environment.jenkinsJobPath, this.buildParameters(run), @@ -276,14 +276,14 @@ export class DeployExecutionService { const nextStatus = queueItem.buildNumber ? 'running' : 'queued'; let currentRun = this.requireRun( run.id, - this.deployRunRepository.updateMemoryRun(run.id, { + await this.deployRunRepository.updateRun(run.id, { status: nextStatus, jenkinsQueueId: queueItem.queueId, jenkinsBuildNumber: queueItem.buildNumber, jenkinsBuildUrl: queueItem.executableUrl, }), ); - currentRun = this.markStep( + currentRun = await this.markStep( run.id, 'jenkins-build', nextStatus, @@ -312,7 +312,7 @@ export class DeployExecutionService { run: DeployRunSummary, event: DeployNotificationEvent, ): Promise { - this.markStep(run.id, 'notify-wecom', 'running'); + await this.markStep(run.id, 'notify-wecom', 'running'); const notification = await this.deployNotificationService.sendDeployRunEvent(run, event); const stepStatus = @@ -321,7 +321,7 @@ export class DeployExecutionService { : notification.status === 'failed' ? 'failed' : 'skipped'; - const currentRun = this.completeStep( + const currentRun = await this.completeStep( run.id, 'notify-wecom', stepStatus, @@ -346,13 +346,13 @@ export class DeployExecutionService { private async completeSuccessfulJenkinsRun( run: DeployRunSummary, ): Promise { - let currentRun = this.completeStep(run.id, 'jenkins-build', 'success'); - currentRun = this.completeStep(currentRun.id, 'health-check', 'success'); + let currentRun = await this.completeStep(run.id, 'jenkins-build', 'success'); + currentRun = await this.completeStep(currentRun.id, 'health-check', 'success'); currentRun = await this.advanceNotification(currentRun, 'succeeded'); - currentRun = this.completeStep(currentRun.id, 'finish-release', 'success'); + currentRun = await this.completeStep(currentRun.id, 'finish-release', 'success'); currentRun = this.requireRun( currentRun.id, - this.deployRunRepository.updateMemoryRun(currentRun.id, { + await this.deployRunRepository.updateRun(currentRun.id, { status: 'success', }), ); @@ -380,16 +380,16 @@ export class DeployExecutionService { const logExcerpt = await this.readFailureLogExcerpt(run, jobPath); const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`; let currentRun = - this.deployRunRepository.updateMemoryStep(run.id, 'jenkins-build', { + (await this.deployRunRepository.updateStep(run.id, 'jenkins-build', { status: 'failed', errorSummary: failureSummary, logExcerpt, - }) ?? run; + })) ?? run; currentRun = - this.deployRunRepository.updateMemoryRun(currentRun.id, { + (await this.deployRunRepository.updateRun(currentRun.id, { status: 'failed', failureSummary, - }) ?? currentRun; + })) ?? currentRun; currentRun = await this.advanceNotification(currentRun, 'failed'); this.auditService.record({ @@ -463,30 +463,30 @@ export class DeployExecutionService { }; } - private completeStep( + private async completeStep( runId: string, bpmnNodeId: string, status: Extract, errorSummary?: string, - ): DeployRunSummary { + ): Promise { return this.requireRun( runId, - this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, { + await this.deployRunRepository.updateStep(runId, bpmnNodeId, { status, errorSummary, }), ); } - private markStep( + private async markStep( runId: string, bpmnNodeId: string, status: Extract, message?: string, - ): DeployRunSummary { + ): Promise { return this.requireRun( runId, - this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, { + await this.deployRunRepository.updateStep(runId, bpmnNodeId, { status, message, }), diff --git a/src/deploy-runs/deploy-run.repository.ts b/src/deploy-runs/deploy-run.repository.ts index 2b0e39c..862c828 100644 --- a/src/deploy-runs/deploy-run.repository.ts +++ b/src/deploy-runs/deploy-run.repository.ts @@ -1,5 +1,16 @@ +/** + * 发布单仓储同时支持本地内存模式和 MySQL/Prisma 持久化模式。 + * 业务层只关心 run/step 状态流转,是否落库由环境配置统一控制。 + */ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { + DeployRunStatus as PrismaDeployRunStatus, + DeployRunTrigger as PrismaDeployRunTrigger, + EnvironmentKind, + Prisma, + RunStepStatus as PrismaRunStepStatus, +} from '@prisma/client'; import { EnvConfig } from '../config/env.schema'; import { PrismaService } from '../prisma/prisma.service'; import { seedDeployRuns } from './deploy-run.seed'; @@ -29,6 +40,24 @@ type DeployRunStepMemoryPatch = { logExcerpt?: string; }; +type DeployRunRecord = Prisma.DeployRunGetPayload<{ + include: { + project: true; + environment: true; + steps: true; + }; +}>; + +type DeployRunMetadata = Prisma.InputJsonObject & { + jenkinsBuildUrl?: string; + remark?: string; +}; + +type RunStepMetadata = Prisma.InputJsonObject & { + message?: string; + logExcerpt?: string; +}; + const terminalRunStatuses = new Set([ 'success', 'failed', @@ -40,6 +69,31 @@ const terminalStepStatuses = new Set([ 'canceled', 'skipped', ]); +const runStatusToPrisma: Record = { + pending: PrismaDeployRunStatus.PENDING, + queued: PrismaDeployRunStatus.QUEUED, + running: PrismaDeployRunStatus.RUNNING, + success: PrismaDeployRunStatus.SUCCESS, + failed: PrismaDeployRunStatus.FAILED, + canceled: PrismaDeployRunStatus.CANCELED, +}; +const stepStatusToPrisma: Record = { + pending: PrismaRunStepStatus.PENDING, + queued: PrismaRunStepStatus.RUNNING, + running: PrismaRunStepStatus.RUNNING, + success: PrismaRunStepStatus.SUCCESS, + failed: PrismaRunStepStatus.FAILED, + canceled: PrismaRunStepStatus.CANCELED, + skipped: PrismaRunStepStatus.SKIPPED, +}; +const triggerToPrisma: Record< + DeployRunSummary['trigger'], + PrismaDeployRunTrigger +> = { + manual: PrismaDeployRunTrigger.MANUAL, + gitea_webhook: PrismaDeployRunTrigger.GITEA_WEBHOOK, + retry: PrismaDeployRunTrigger.RETRY, +}; @Injectable() export class DeployRunRepository { @@ -51,7 +105,7 @@ export class DeployRunRepository { ) {} async findMany(): Promise { - if (!this.useDatabaseReads()) { + if (!this.useDatabase()) { return [...this.memoryRuns].sort((left, right) => right.createdAt.localeCompare(left.createdAt), ); @@ -73,40 +127,28 @@ export class DeployRunRepository { take: 50, }); - return runs.map((run) => ({ - id: run.id, - projectKey: run.project.key, - environment: run.environment.name === 'TEST' ? 'test' : 'production', - ref: run.ref, - status: run.status.toLowerCase() as DeployRunStatus, - trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'], - operator: run.operator, - jenkinsQueueId: run.jenkinsQueueId ?? undefined, - jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined, - failureSummary: run.failureSummary ?? undefined, - startedAt: run.startedAt?.toISOString(), - finishedAt: run.finishedAt?.toISOString(), - createdAt: run.createdAt.toISOString(), - steps: run.steps.map((step) => ({ - id: step.id, - bpmnNodeId: step.bpmnNodeId, - name: step.name, - status: step.status.toLowerCase() as DeployRunStepStatus, - order: step.order, - startedAt: step.startedAt?.toISOString(), - finishedAt: step.finishedAt?.toISOString(), - errorSummary: step.errorSummary ?? undefined, - })), - })); + return runs.map((run) => this.toSummary(run)); } async findById(id: string): Promise { - if (!this.useDatabaseReads()) { + if (!this.useDatabase()) { return this.memoryRuns.find((run) => run.id === id) ?? null; } - const run = await this.prisma.deployRun.findUnique({ - where: { id }, + const run = await this.findRecordById(id); + + return run ? this.toSummary(run) : null; + } + + async create(input: CreateDeployRunInput): Promise { + const idempotencyKey = this.resolveIdempotencyKey(input); + + if (!this.useDatabase()) { + return this.createMemoryRun(input, idempotencyKey); + } + + const existing = await this.prisma.deployRun.findUnique({ + where: { idempotencyKey }, include: { project: true, environment: true, @@ -118,55 +160,284 @@ export class DeployRunRepository { }, }); - if (!run) { + if (existing) { + return this.toSummary(existing); + } + + const target = await this.findTargetProjectEnvironment(input); + const initialRun = this.buildInitialRun(input, idempotencyKey); + const metadata = this.inputMetadata(input); + + const created = await this.prisma.$transaction(async (tx) => { + const run = await tx.deployRun.create({ + data: { + projectId: target.project.id, + environmentId: target.environment.id, + jobId: target.deployJob?.id, + status: PrismaDeployRunStatus.PENDING, + trigger: triggerToPrisma[input.trigger ?? 'manual'], + ref: input.ref, + operator: input.operator, + idempotencyKey, + metadata, + steps: { + create: initialRun.steps.map((step) => ({ + bpmnNodeId: step.bpmnNodeId, + name: step.name, + status: stepStatusToPrisma[step.status], + order: step.order, + })), + }, + }, + include: { + project: true, + environment: true, + steps: { + orderBy: { + order: 'asc', + }, + }, + }, + }); + + return run; + }); + + return this.toSummary(created); + } + + async updateStatus( + id: string, + status: DeployRunStatus, + ): Promise { + return this.updateRun(id, { status }); + } + + async updateRun( + id: string, + patch: DeployRunMemoryPatch, + ): Promise { + if (!this.useDatabase()) { + return this.updateMemoryRun(id, patch); + } + + const current = await this.prisma.deployRun.findUnique({ + where: { id }, + }); + + if (!current) { return null; } + const now = new Date(); + const metadata = this.objectMetadata(current.metadata); + const data: Prisma.DeployRunUpdateInput = { + version: { + increment: 1, + }, + }; + + if (patch.status) { + data.status = runStatusToPrisma[patch.status]; + + if (patch.status !== 'pending' && !current.startedAt) { + data.startedAt = this.toDate(patch.startedAt) ?? now; + } + + if (terminalRunStatuses.has(patch.status)) { + data.finishedAt = this.toDate(patch.finishedAt) ?? now; + } + } + + if ('jenkinsQueueId' in patch) { + data.jenkinsQueueId = patch.jenkinsQueueId ?? null; + } + + if ('jenkinsBuildNumber' in patch) { + data.jenkinsBuildNumber = patch.jenkinsBuildNumber ?? null; + } + + if ('jenkinsBuildUrl' in patch) { + if (patch.jenkinsBuildUrl) { + metadata.jenkinsBuildUrl = patch.jenkinsBuildUrl; + } else { + delete metadata.jenkinsBuildUrl; + } + data.metadata = metadata; + } + + if ('failureSummary' in patch) { + data.failureSummary = patch.failureSummary ?? null; + } + + if ('startedAt' in patch) { + data.startedAt = this.toDate(patch.startedAt); + } + + if ('finishedAt' in patch) { + data.finishedAt = this.toDate(patch.finishedAt); + } + + await this.prisma.deployRun.update({ + where: { id }, + data, + }); + + return this.findById(id); + } + + async updateStep( + runId: string, + bpmnNodeId: string, + patch: DeployRunStepMemoryPatch, + ): Promise { + if (!this.useDatabase()) { + return this.updateMemoryStep(runId, bpmnNodeId, patch); + } + + const current = await this.prisma.runStep.findUnique({ + where: { + deployRunId_bpmnNodeId: { + deployRunId: runId, + bpmnNodeId, + }, + }, + }); + + if (!current) { + return null; + } + + const now = new Date(); + const metadata = this.objectMetadata(current.metadata); + const data: Prisma.RunStepUpdateInput = {}; + + if (patch.status) { + data.status = stepStatusToPrisma[patch.status]; + + if (patch.status !== 'pending' && !current.startedAt) { + data.startedAt = this.toDate(patch.startedAt) ?? now; + } + + if (terminalStepStatuses.has(patch.status)) { + data.finishedAt = this.toDate(patch.finishedAt) ?? now; + } + } + + if ('startedAt' in patch) { + data.startedAt = this.toDate(patch.startedAt); + } + + if ('finishedAt' in patch) { + data.finishedAt = this.toDate(patch.finishedAt); + } + + if ('message' in patch) { + if (patch.message) { + metadata.message = patch.message; + } else { + delete metadata.message; + } + data.metadata = metadata; + } + + if ('errorSummary' in patch) { + data.errorSummary = patch.errorSummary ?? null; + } + + if ('logExcerpt' in patch) { + if (patch.logExcerpt) { + metadata.logExcerpt = patch.logExcerpt; + } else { + delete metadata.logExcerpt; + } + data.metadata = metadata; + } + + await this.prisma.runStep.update({ + where: { + deployRunId_bpmnNodeId: { + deployRunId: runId, + bpmnNodeId, + }, + }, + data, + }); + + return this.findById(runId); + } + + private async findRecordById(id: string): Promise { + return this.prisma.deployRun.findUnique({ + where: { id }, + include: { + project: true, + environment: true, + steps: { + orderBy: { + order: 'asc', + }, + }, + }, + }); + } + + private async findTargetProjectEnvironment(input: CreateDeployRunInput) { + const project = await this.prisma.project.findUnique({ + where: { key: input.projectKey }, + include: { + environments: { + include: { + deployJobs: true, + }, + }, + }, + }); + + const targetEnvironmentName = + input.environment === 'test' + ? EnvironmentKind.TEST + : EnvironmentKind.PRODUCTION; + const environment = project?.environments.find( + (item) => item.name === targetEnvironmentName, + ); + const deployJob = environment?.deployJobs[0]; + + if (!project || !environment) { + throw new Error( + `Project ${input.projectKey} ${input.environment} is not seeded in database`, + ); + } + return { - id: run.id, - projectKey: run.project.key, - environment: run.environment.name === 'TEST' ? 'test' : 'production', - ref: run.ref, - status: run.status.toLowerCase() as DeployRunStatus, - trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'], - operator: run.operator, - jenkinsQueueId: run.jenkinsQueueId ?? undefined, - jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined, - failureSummary: run.failureSummary ?? undefined, - startedAt: run.startedAt?.toISOString(), - finishedAt: run.finishedAt?.toISOString(), - createdAt: run.createdAt.toISOString(), - steps: run.steps.map((step) => ({ - id: step.id, - bpmnNodeId: step.bpmnNodeId, - name: step.name, - status: step.status.toLowerCase() as DeployRunStepStatus, - order: step.order, - startedAt: step.startedAt?.toISOString(), - finishedAt: step.finishedAt?.toISOString(), - errorSummary: step.errorSummary ?? undefined, - })), + project, + environment, + deployJob, }; } - create(input: CreateDeployRunInput): DeployRunSummary { - const idempotencyKey = - input.idempotencyKey ?? - [ - input.projectKey, - input.environment, - input.ref, - input.operator, - new Date().toISOString(), - ].join(':'); + private createMemoryRun( + input: CreateDeployRunInput, + idempotencyKey: string, + ): DeployRunSummary { const existing = this.memoryRuns.find((run) => run.id === idempotencyKey); if (existing) { return existing; } + const run = this.buildInitialRun(input, idempotencyKey); + this.memoryRuns.unshift(run); + return run; + } + + private buildInitialRun( + input: CreateDeployRunInput, + idempotencyKey: string, + ): DeployRunSummary { const now = new Date().toISOString(); - const run: DeployRunSummary = { + + return { id: idempotencyKey, projectKey: input.projectKey, environment: input.environment, @@ -220,19 +491,9 @@ export class DeployRunRepository { }, ], }; - - this.memoryRuns.unshift(run); - return run; } - updateMemoryStatus( - id: string, - status: DeployRunStatus, - ): DeployRunSummary | null { - return this.updateMemoryRun(id, { status }); - } - - updateMemoryRun( + private updateMemoryRun( id: string, patch: DeployRunMemoryPatch, ): DeployRunSummary | null { @@ -283,7 +544,7 @@ export class DeployRunRepository { return run; } - updateMemoryStep( + private updateMemoryStep( runId: string, bpmnNodeId: string, patch: DeployRunStepMemoryPatch, @@ -332,7 +593,84 @@ export class DeployRunRepository { return run; } - private useDatabaseReads(): boolean { + private toSummary(run: DeployRunRecord): DeployRunSummary { + const metadata = this.objectMetadata(run.metadata); + const sortedSteps = [...run.steps].sort((left, right) => left.order - right.order); + + return { + id: run.id, + projectKey: run.project.key, + environment: run.environment.name === 'TEST' ? 'test' : 'production', + ref: run.ref, + status: run.status.toLowerCase() as DeployRunStatus, + trigger: run.trigger.toLowerCase() as DeployRunSummary['trigger'], + operator: run.operator, + jenkinsQueueId: run.jenkinsQueueId ?? undefined, + jenkinsBuildNumber: run.jenkinsBuildNumber ?? undefined, + jenkinsBuildUrl: metadata.jenkinsBuildUrl, + failureSummary: run.failureSummary ?? undefined, + startedAt: run.startedAt?.toISOString(), + finishedAt: run.finishedAt?.toISOString(), + createdAt: run.createdAt.toISOString(), + steps: sortedSteps.map((step) => { + const stepMetadata = this.objectMetadata( + step.metadata, + ); + + return { + id: step.id, + bpmnNodeId: step.bpmnNodeId, + name: step.name, + status: step.status.toLowerCase() as DeployRunStepStatus, + order: step.order, + startedAt: step.startedAt?.toISOString(), + finishedAt: step.finishedAt?.toISOString(), + message: stepMetadata.message, + errorSummary: step.errorSummary ?? undefined, + logExcerpt: stepMetadata.logExcerpt, + }; + }), + }; + } + + private objectMetadata>( + value: Prisma.JsonValue | null | undefined, + ): T { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { ...(value as Record) } as T; + } + + return {} as T; + } + + private inputMetadata(input: CreateDeployRunInput): Prisma.InputJsonObject { + const metadata: DeployRunMetadata = {}; + + if (input.remark) { + metadata.remark = input.remark; + } + + return metadata; + } + + private resolveIdempotencyKey(input: CreateDeployRunInput): string { + return ( + input.idempotencyKey ?? + [ + input.projectKey, + input.environment, + input.ref, + input.operator, + new Date().toISOString(), + ].join(':') + ); + } + + private toDate(value: string | undefined): Date | null | undefined { + return value ? new Date(value) : undefined; + } + + private useDatabase(): boolean { return ( this.config.get('USE_DATABASE_READS', { infer: true }) && Boolean(this.config.get('DATABASE_URL', { infer: true })) diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index 3276451..fe708ac 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -62,7 +62,7 @@ export class DeployRunsService { const project = await this.projectsService.getProject(input.projectKey); this.validateReleaseRequest(input, project); - const run = this.deployRunRepository.create(input); + const run = await this.deployRunRepository.create(input); this.auditService.record({ action: 'DEPLOY_REQUESTED', @@ -81,7 +81,7 @@ export class DeployRunsService { } async cancelRun(id: string): Promise { - const run = this.deployRunRepository.updateMemoryStatus(id, 'canceled'); + const run = await this.deployRunRepository.updateStatus(id, 'canceled'); if (!run) { throw new AppError(