feat: 初始化DevOps平台后端

This commit is contained in:
湛兮
2026-06-11 20:49:59 +08:00
commit caec5a618d
88 changed files with 11893 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { z } from 'zod';
import { DeployEnvironment } from './deploy-run.types';
const optionalTrimmedString = (maxLength: number) =>
z
.string()
.trim()
.max(maxLength)
.optional()
.or(z.literal('').transform(() => undefined));
/**
* 创建发布单的运行时校验规则;真实密钥和第三方参数不允许从前端传入。
*/
export const createDeployRunSchema = z.object({
projectKey: z.string().trim().min(1).max(80),
environment: z.enum(['test', 'production']),
ref: z.string().trim().min(1).max(200),
operator: z.string().trim().min(1).max(120),
remark: optionalTrimmedString(500),
idempotencyKey: optionalTrimmedString(260),
});
export class CreateDeployRunDto {
@ApiProperty({ example: 'access-manage' })
projectKey!: string;
@ApiProperty({ enum: ['test', 'production'], example: 'test' })
environment!: DeployEnvironment;
@ApiProperty({ example: 'develop' })
ref!: string;
@ApiProperty({ example: 'operator@example.com' })
operator!: string;
@ApiPropertyOptional({ example: 'Manual test deploy before release.' })
remark?: string;
@ApiPropertyOptional({
example: 'access-manage:test:develop:2026-06-11T10:00:00Z',
})
idempotencyKey?: string;
}
@@ -0,0 +1,251 @@
import { AuditService } from '../audit/audit.service';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { ProjectSummary } from '../projects/project.types';
import { DeployExecutionService } from './deploy-execution.service';
import { DeployRunRepository } from './deploy-run.repository';
import { CreateDeployRunInput, DeployRunSummary } from './deploy-run.types';
describe('DeployExecutionService', () => {
const project: ProjectSummary = {
id: 'project_001',
key: 'access-manage',
name: 'access-manage',
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
defaultBranch: 'develop',
status: 'active',
environments: [
{
name: 'test',
displayName: '测试环境',
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
},
],
};
const input: CreateDeployRunInput = {
projectKey: 'access-manage',
environment: 'test',
ref: 'develop',
operator: 'ops-admin',
};
function createRepository(): DeployRunRepository {
return new DeployRunRepository(
{
get: jest.fn(() => false),
} as never,
{} as never,
);
}
function createService(options: {
jenkinsHealth: ReturnType<JenkinsClient['healthSummary']>;
notificationStatus: 'sent' | 'skipped';
queueId?: string;
queueBuildNumber?: number;
buildResult?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE';
buildRunning?: boolean;
logText?: string;
}) {
const repository = createRepository();
const audit = {
record: jest.fn(),
} satisfies Pick<AuditService, 'record'>;
const notification = {
sendDeployRunEvent: jest.fn(() =>
Promise.resolve({
status: options.notificationStatus,
channel: 'wecom' as const,
reason:
options.notificationStatus === 'skipped'
? 'Missing WECOM_WEBHOOK_URL'
: undefined,
}),
),
} satisfies Pick<DeployNotificationService, 'sendDeployRunEvent'>;
const jenkins = {
healthSummary: jest.fn(() => options.jenkinsHealth),
buildWithParameters: jest.fn(() =>
Promise.resolve({
queueId: options.queueId ?? 'queue_001',
}),
),
getQueueItem: jest.fn((queueId: string) =>
Promise.resolve({
queueId,
buildNumber: options.queueBuildNumber,
executableUrl: options.queueBuildNumber
? `https://jenkins.example/job/${options.queueBuildNumber}/`
: undefined,
}),
),
getBuildStatus: jest.fn((_jobPath: string, buildNumber: number) =>
Promise.resolve({
jobPath: 'my-project-dev/test-access-manage-develop',
buildNumber,
building: options.buildRunning ?? false,
result: options.buildResult,
url: `https://jenkins.example/job/${buildNumber}/`,
}),
),
getProgressiveText: jest.fn(() =>
Promise.resolve({
text: options.logText ?? '',
nextStart: options.logText?.length ?? 0,
hasMore: false,
}),
),
} satisfies Pick<
JenkinsClient,
| 'healthSummary'
| 'buildWithParameters'
| 'getQueueItem'
| 'getBuildStatus'
| 'getProgressiveText'
>;
const service = new DeployExecutionService(
repository,
audit as unknown as AuditService,
notification as unknown as DeployNotificationService,
jenkins as unknown as JenkinsClient,
);
return {
audit,
jenkins,
notification,
repository,
service,
};
}
function createRun(
repository: DeployRunRepository,
idempotencyKey: string,
): DeployRunSummary {
return repository.create({
...input,
idempotencyKey,
});
}
function step(run: DeployRunSummary, bpmnNodeId: string) {
return run.steps.find((item) => item.bpmnNodeId === bpmnNodeId);
}
it('completes a dry-run release when Jenkins is not configured', async () => {
const { audit, jenkins, repository, service } = createService({
jenkinsHealth: {
status: 'not_configured',
message: 'Missing JENKINS_API_TOKEN',
},
notificationStatus: 'skipped',
});
const run = createRun(repository, 'run_dry_001');
const result = await service.execute(run, project);
expect(result.status).toBe('success');
expect(result.jenkinsQueueId).toBeUndefined();
expect(step(result, 'gitea-check')?.status).toBe('success');
expect(step(result, 'jenkins-build')?.status).toBe('success');
expect(step(result, 'health-check')?.status).toBe('success');
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
expect(step(result, 'finish-release')?.status).toBe('success');
expect(jenkins.buildWithParameters).not.toHaveBeenCalled();
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'JENKINS_DRY_RUN_COMPLETED' }),
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
);
});
it('triggers Jenkins and keeps the run queued until status polling completes', async () => {
const { audit, jenkins, repository, service } = createService({
jenkinsHealth: { status: 'ok' },
notificationStatus: 'sent',
queueId: 'jenkins_queue_42',
});
const run = createRun(repository, 'run_queue_001');
const result = await service.execute(run, project);
expect(result.status).toBe('queued');
expect(result.jenkinsQueueId).toBe('jenkins_queue_42');
expect(step(result, 'jenkins-build')?.status).toBe('queued');
expect(step(result, 'health-check')?.status).toBe('pending');
expect(step(result, 'notify-wecom')?.status).toBe('pending');
expect(jenkins.buildWithParameters).toHaveBeenCalledWith(
'my-project-dev/test-access-manage-develop',
expect.objectContaining({
DEPLOY_RUN_ID: 'run_queue_001',
DEPLOY_ENVIRONMENT: 'test',
GIT_REF: 'develop',
}),
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'JENKINS_BUILD_TRIGGERED',
after: expect.objectContaining({
queueId: 'jenkins_queue_42',
}),
}),
);
expect(audit.record).not.toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
);
});
it('syncs a queued Jenkins run to success', async () => {
const { audit, repository, service } = createService({
jenkinsHealth: { status: 'ok' },
notificationStatus: 'sent',
queueId: 'queue_success',
queueBuildNumber: 18,
buildResult: 'SUCCESS',
});
const run = await service.execute(createRun(repository, 'run_success_001'), project);
const result = await service.syncJenkinsRun(run, project);
expect(result.status).toBe('success');
expect(result.jenkinsBuildNumber).toBe(18);
expect(step(result, 'jenkins-build')?.status).toBe('success');
expect(step(result, 'health-check')?.status).toBe('success');
expect(step(result, 'notify-wecom')?.status).toBe('success');
expect(step(result, 'finish-release')?.status).toBe('success');
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'JENKINS_BUILD_STARTED' }),
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEPLOY_SUCCEEDED' }),
);
});
it('syncs a queued Jenkins run to failure with a log excerpt', async () => {
const { audit, repository, service } = createService({
jenkinsHealth: { status: 'ok' },
notificationStatus: 'skipped',
queueId: 'queue_failure',
queueBuildNumber: 19,
buildResult: 'FAILURE',
logText: ['install ok', 'build failed', 'token=secret-value'].join('\n'),
});
const run = await service.execute(createRun(repository, 'run_failure_001'), project);
const result = await service.syncJenkinsRun(run, project);
expect(result.status).toBe('failed');
expect(result.failureSummary).toBe('Jenkins build #19 FAILURE');
expect(step(result, 'jenkins-build')?.status).toBe('failed');
expect(step(result, 'jenkins-build')?.errorSummary).toBe(
'Jenkins build #19 FAILURE',
);
expect(step(result, 'jenkins-build')?.logExcerpt).toContain('build failed');
expect(step(result, 'notify-wecom')?.status).toBe('skipped');
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEPLOY_FAILED' }),
);
});
});
+541
View File
@@ -0,0 +1,541 @@
import { Injectable } from '@nestjs/common';
import { AuditService } from '../audit/audit.service';
import { AppError } from '../common/errors/app-error';
import { redactSensitive } from '../common/security/redact-sensitive';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { JenkinsBuildStatus } from '../integrations/jenkins/jenkins.types';
import {
DeployNotificationEvent,
DeployNotificationService,
} from '../notifications/wecom/deploy-notification.service';
import {
ProjectEnvironmentSummary,
ProjectSummary,
} from '../projects/project.types';
import { DeployRunRepository } from './deploy-run.repository';
import { DeployRunStepStatus, DeployRunSummary } from './deploy-run.types';
@Injectable()
export class DeployExecutionService {
constructor(
private readonly deployRunRepository: DeployRunRepository,
private readonly auditService: AuditService,
private readonly deployNotificationService: DeployNotificationService,
private readonly jenkinsClient: JenkinsClient,
) {}
async execute(
run: DeployRunSummary,
project: ProjectSummary,
): Promise<DeployRunSummary> {
if (run.status !== 'pending') {
return run;
}
let currentRun = this.requireRun(
run.id,
this.deployRunRepository.updateMemoryRun(run.id, { status: 'running' }),
);
let currentStep: string | undefined;
this.auditService.record({
action: 'DEPLOY_EXECUTION_STARTED',
resourceType: 'deploy_run',
resourceId: currentRun.id,
actorName: currentRun.operator,
after: {
projectKey: currentRun.projectKey,
environment: currentRun.environment,
ref: currentRun.ref,
status: currentRun.status,
},
});
try {
currentStep = 'gitea-check';
currentRun = this.completeStep(currentRun.id, currentStep, 'success');
currentStep = 'jenkins-build';
const jenkinsResult = await this.advanceJenkinsBuild(currentRun, project);
currentRun = jenkinsResult.run;
if (!jenkinsResult.shouldContinue) {
await this.recordNotificationResult(currentRun, 'queued');
return currentRun;
}
currentStep = 'health-check';
currentRun = 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 = this.requireRun(
currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, {
status: 'success',
}),
);
this.auditService.record({
action: 'DEPLOY_SUCCEEDED',
resourceType: 'deploy_run',
resourceId: currentRun.id,
actorName: currentRun.operator,
after: {
status: currentRun.status,
jenkinsQueueId: currentRun.jenkinsQueueId,
jenkinsBuildNumber: currentRun.jenkinsBuildNumber,
},
});
return currentRun;
} catch (error) {
const failureSummary = this.toFailureSummary(error);
if (currentStep) {
currentRun =
this.deployRunRepository.updateMemoryStep(
currentRun.id,
currentStep,
{
status: 'failed',
errorSummary: failureSummary,
},
) ?? currentRun;
}
currentRun =
this.deployRunRepository.updateMemoryRun(currentRun.id, {
status: 'failed',
failureSummary,
}) ?? currentRun;
currentRun = await this.advanceNotification(currentRun, 'failed');
this.auditService.record({
action: 'DEPLOY_FAILED',
resourceType: 'deploy_run',
resourceId: currentRun.id,
actorName: currentRun.operator,
after: {
status: currentRun.status,
failedStep: currentStep,
failureSummary,
},
});
return currentRun;
}
}
async syncJenkinsRun(
run: DeployRunSummary,
project: ProjectSummary,
): Promise<DeployRunSummary> {
if (['success', 'failed', 'canceled'].includes(run.status)) {
return run;
}
if (!run.jenkinsQueueId && !run.jenkinsBuildNumber) {
return run;
}
const environment = this.findEnvironment(project, run);
let currentRun = run;
let buildNumber = currentRun.jenkinsBuildNumber;
if (!buildNumber && currentRun.jenkinsQueueId) {
const queueItem = await this.jenkinsClient.getQueueItem(
currentRun.jenkinsQueueId,
);
if (!queueItem.buildNumber) {
return this.requireRun(
currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, {
status: 'queued',
jenkinsQueueId: queueItem.queueId,
jenkinsBuildUrl: queueItem.executableUrl,
}),
);
}
buildNumber = queueItem.buildNumber;
currentRun = this.requireRun(
currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, {
status: 'running',
jenkinsQueueId: queueItem.queueId,
jenkinsBuildNumber: queueItem.buildNumber,
jenkinsBuildUrl: queueItem.executableUrl,
}),
);
currentRun = this.markStep(
currentRun.id,
'jenkins-build',
'running',
`Jenkins build #${queueItem.buildNumber} is running.`,
);
this.auditService.record({
action: 'JENKINS_BUILD_STARTED',
resourceType: 'deploy_run',
resourceId: currentRun.id,
actorName: currentRun.operator,
after: {
queueId: queueItem.queueId,
buildNumber: queueItem.buildNumber,
buildUrl: queueItem.executableUrl,
},
});
}
if (!buildNumber) {
return currentRun;
}
const buildStatus = await this.jenkinsClient.getBuildStatus(
environment.jenkinsJobPath,
buildNumber,
);
currentRun = this.requireRun(
currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, {
status: buildStatus.building || !buildStatus.result ? 'running' : currentRun.status,
jenkinsBuildNumber: buildStatus.buildNumber,
jenkinsBuildUrl: buildStatus.url,
}),
);
if (buildStatus.building || !buildStatus.result) {
return this.markStep(
currentRun.id,
'jenkins-build',
'running',
`Jenkins build #${buildStatus.buildNumber} is running.`,
);
}
if (buildStatus.result === 'SUCCESS') {
return this.completeSuccessfulJenkinsRun(currentRun);
}
return this.failJenkinsRun(
currentRun,
buildStatus.result,
environment.jenkinsJobPath,
);
}
private async advanceJenkinsBuild(
run: DeployRunSummary,
project: ProjectSummary,
): Promise<{ run: DeployRunSummary; shouldContinue: boolean }> {
const environment = this.findEnvironment(project, run);
const health = this.jenkinsClient.healthSummary();
if (health.status === 'not_configured') {
const reason =
health.message ??
'Jenkins is not configured; simulated build completed.';
const currentRun = this.completeStep(run.id, 'jenkins-build', 'success');
this.auditService.record({
action: 'JENKINS_DRY_RUN_COMPLETED',
resourceType: 'deploy_run',
resourceId: run.id,
actorName: run.operator,
after: {
mode: 'dry-run',
reason,
jobPath: environment.jenkinsJobPath,
},
});
return { run: currentRun, shouldContinue: true };
}
if (!environment.jenkinsJobPath) {
throw new AppError(
'VALIDATION_FAILED',
`Project ${project.key} ${run.environment} Jenkins job path is empty`,
400,
{ projectKey: project.key, environment: run.environment },
);
}
this.markStep(run.id, 'jenkins-build', 'running');
const queueItem = await this.jenkinsClient.buildWithParameters(
environment.jenkinsJobPath,
this.buildParameters(run),
);
const nextStatus = queueItem.buildNumber ? 'running' : 'queued';
let currentRun = this.requireRun(
run.id,
this.deployRunRepository.updateMemoryRun(run.id, {
status: nextStatus,
jenkinsQueueId: queueItem.queueId,
jenkinsBuildNumber: queueItem.buildNumber,
jenkinsBuildUrl: queueItem.executableUrl,
}),
);
currentRun = this.markStep(
run.id,
'jenkins-build',
nextStatus,
queueItem.buildNumber
? `Jenkins build #${queueItem.buildNumber} is running.`
: `Jenkins queue ${queueItem.queueId} accepted.`,
);
this.auditService.record({
action: 'JENKINS_BUILD_TRIGGERED',
resourceType: 'deploy_run',
resourceId: run.id,
actorName: run.operator,
after: {
jobPath: environment.jenkinsJobPath,
queueId: queueItem.queueId,
buildNumber: queueItem.buildNumber,
},
parameterDigest: this.buildParameters(run),
});
return { run: currentRun, shouldContinue: false };
}
private async advanceNotification(
run: DeployRunSummary,
event: DeployNotificationEvent,
): Promise<DeployRunSummary> {
this.markStep(run.id, 'notify-wecom', 'running');
const notification =
await this.deployNotificationService.sendDeployRunEvent(run, event);
const stepStatus =
notification.status === 'sent'
? 'success'
: notification.status === 'failed'
? 'failed'
: 'skipped';
const currentRun = this.completeStep(
run.id,
'notify-wecom',
stepStatus,
notification.status === 'sent' ? undefined : notification.reason,
);
this.auditService.record({
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
resourceType: 'deploy_run',
resourceId: run.id,
actorName: run.operator,
after: {
event,
channel: notification.channel,
reason: notification.reason,
},
});
return currentRun;
}
private async completeSuccessfulJenkinsRun(
run: DeployRunSummary,
): Promise<DeployRunSummary> {
let currentRun = this.completeStep(run.id, 'jenkins-build', 'success');
currentRun = this.completeStep(currentRun.id, 'health-check', 'success');
currentRun = await this.advanceNotification(currentRun, 'succeeded');
currentRun = this.completeStep(currentRun.id, 'finish-release', 'success');
currentRun = this.requireRun(
currentRun.id,
this.deployRunRepository.updateMemoryRun(currentRun.id, {
status: 'success',
}),
);
this.auditService.record({
action: 'DEPLOY_SUCCEEDED',
resourceType: 'deploy_run',
resourceId: currentRun.id,
actorName: currentRun.operator,
after: {
status: currentRun.status,
jenkinsQueueId: currentRun.jenkinsQueueId,
jenkinsBuildNumber: currentRun.jenkinsBuildNumber,
},
});
return currentRun;
}
private async failJenkinsRun(
run: DeployRunSummary,
result: Exclude<NonNullable<JenkinsBuildStatus['result']>, 'SUCCESS'>,
jobPath: string,
): Promise<DeployRunSummary> {
const logExcerpt = await this.readFailureLogExcerpt(run, jobPath);
const failureSummary = `Jenkins build #${run.jenkinsBuildNumber ?? '-'} ${result}`;
let currentRun =
this.deployRunRepository.updateMemoryStep(run.id, 'jenkins-build', {
status: 'failed',
errorSummary: failureSummary,
logExcerpt,
}) ?? run;
currentRun =
this.deployRunRepository.updateMemoryRun(currentRun.id, {
status: 'failed',
failureSummary,
}) ?? currentRun;
currentRun = await this.advanceNotification(currentRun, 'failed');
this.auditService.record({
action: 'DEPLOY_FAILED',
resourceType: 'deploy_run',
resourceId: currentRun.id,
actorName: currentRun.operator,
after: {
status: currentRun.status,
failedStep: 'jenkins-build',
failureSummary,
},
});
return currentRun;
}
private async readFailureLogExcerpt(
run: DeployRunSummary,
jobPath: string,
): Promise<string | undefined> {
if (!run.jenkinsBuildNumber) {
return undefined;
}
try {
const log = await this.jenkinsClient.getProgressiveText(
jobPath,
run.jenkinsBuildNumber,
);
const logExcerpt = log.text
.split(/\r?\n/)
.filter(Boolean)
.slice(-24)
.join('\n')
.slice(-4000);
return redactSensitive({ logExcerpt }).logExcerpt;
} catch {
return undefined;
}
}
private findEnvironment(
project: ProjectSummary,
run: DeployRunSummary,
): ProjectEnvironmentSummary {
const environment = project.environments.find(
(item) => item.name === run.environment,
);
if (!environment) {
throw new AppError(
'VALIDATION_FAILED',
`Project ${project.key} does not expose ${run.environment} environment`,
400,
{ projectKey: project.key, environment: run.environment },
);
}
return environment;
}
private buildParameters(run: DeployRunSummary): Record<string, string> {
return {
DEPLOY_RUN_ID: run.id,
PROJECT_KEY: run.projectKey,
DEPLOY_ENVIRONMENT: run.environment,
GIT_REF: run.ref,
OPERATOR: run.operator,
};
}
private completeStep(
runId: string,
bpmnNodeId: string,
status: Extract<DeployRunStepStatus, 'success' | 'skipped' | 'failed'>,
errorSummary?: string,
): DeployRunSummary {
return this.requireRun(
runId,
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, {
status,
errorSummary,
}),
);
}
private markStep(
runId: string,
bpmnNodeId: string,
status: Extract<DeployRunStepStatus, 'queued' | 'running'>,
message?: string,
): DeployRunSummary {
return this.requireRun(
runId,
this.deployRunRepository.updateMemoryStep(runId, bpmnNodeId, {
status,
message,
}),
);
}
private async recordNotificationResult(
run: DeployRunSummary,
event: DeployNotificationEvent,
): Promise<void> {
const notification =
await this.deployNotificationService.sendDeployRunEvent(run, event);
this.auditService.record({
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
resourceType: 'deploy_run',
resourceId: run.id,
actorName: run.operator,
after: {
event,
channel: notification.channel,
reason: notification.reason,
},
});
}
private requireRun(
id: string,
run: DeployRunSummary | null,
): DeployRunSummary {
if (!run) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`Deploy run ${id} not found`,
404,
{
id,
},
);
}
return run;
}
private toFailureSummary(error: unknown): string {
if (error instanceof Error) {
return redactSensitive({ message: error.message }).message;
}
return 'Unknown deploy execution error';
}
}
+341
View File
@@ -0,0 +1,341 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EnvConfig } from '../config/env.schema';
import { PrismaService } from '../prisma/prisma.service';
import { seedDeployRuns } from './deploy-run.seed';
import {
CreateDeployRunInput,
DeployRunStepStatus,
DeployRunSummary,
DeployRunStatus,
} from './deploy-run.types';
type DeployRunMemoryPatch = {
status?: DeployRunStatus;
jenkinsQueueId?: string;
jenkinsBuildNumber?: number;
jenkinsBuildUrl?: string;
failureSummary?: string;
startedAt?: string;
finishedAt?: string;
};
type DeployRunStepMemoryPatch = {
status?: DeployRunStepStatus;
startedAt?: string;
finishedAt?: string;
message?: string;
errorSummary?: string;
logExcerpt?: string;
};
const terminalRunStatuses = new Set<DeployRunStatus>([
'success',
'failed',
'canceled',
]);
const terminalStepStatuses = new Set<DeployRunStepStatus>([
'success',
'failed',
'canceled',
'skipped',
]);
@Injectable()
export class DeployRunRepository {
private readonly memoryRuns = [...seedDeployRuns];
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly prisma: PrismaService,
) {}
async findMany(): Promise<DeployRunSummary[]> {
if (!this.useDatabaseReads()) {
return [...this.memoryRuns].sort((left, right) =>
right.createdAt.localeCompare(left.createdAt),
);
}
const runs = await this.prisma.deployRun.findMany({
include: {
project: true,
environment: true,
steps: {
orderBy: {
order: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
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,
})),
}));
}
async findById(id: string): Promise<DeployRunSummary | null> {
if (!this.useDatabaseReads()) {
return this.memoryRuns.find((run) => run.id === id) ?? null;
}
const run = await this.prisma.deployRun.findUnique({
where: { id },
include: {
project: true,
environment: true,
steps: {
orderBy: {
order: 'asc',
},
},
},
});
if (!run) {
return null;
}
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,
})),
};
}
create(input: CreateDeployRunInput): DeployRunSummary {
const idempotencyKey =
input.idempotencyKey ??
[
input.projectKey,
input.environment,
input.ref,
input.operator,
new Date().toISOString(),
].join(':');
const existing = this.memoryRuns.find((run) => run.id === idempotencyKey);
if (existing) {
return existing;
}
const now = new Date().toISOString();
const run: DeployRunSummary = {
id: idempotencyKey,
projectKey: input.projectKey,
environment: input.environment,
ref: input.ref,
status: 'pending',
trigger: input.trigger ?? 'manual',
operator: input.operator,
createdAt: now,
steps: [
{
id: `${idempotencyKey}:start-release`,
bpmnNodeId: 'start-release',
name: '提交发布单',
status: 'success',
order: 10,
},
{
id: `${idempotencyKey}:gitea-check`,
bpmnNodeId: 'gitea-check',
name: '校验 Gitea ref',
status: 'pending',
order: 20,
},
{
id: `${idempotencyKey}:jenkins-build`,
bpmnNodeId: 'jenkins-build',
name: '触发 Jenkins 构建',
status: 'pending',
order: 30,
},
{
id: `${idempotencyKey}:health-check`,
bpmnNodeId: 'health-check',
name: '环境健康检查',
status: 'pending',
order: 40,
},
{
id: `${idempotencyKey}:notify-wecom`,
bpmnNodeId: 'notify-wecom',
name: '发送通知',
status: 'pending',
order: 90,
},
{
id: `${idempotencyKey}:finish-release`,
bpmnNodeId: 'finish-release',
name: '归档审计',
status: 'pending',
order: 100,
},
],
};
this.memoryRuns.unshift(run);
return run;
}
updateMemoryStatus(
id: string,
status: DeployRunStatus,
): DeployRunSummary | null {
return this.updateMemoryRun(id, { status });
}
updateMemoryRun(
id: string,
patch: DeployRunMemoryPatch,
): DeployRunSummary | null {
const run = this.memoryRuns.find((item) => item.id === id);
if (!run) {
return null;
}
const now = new Date().toISOString();
if (patch.status) {
run.status = patch.status;
if (patch.status !== 'pending' && !run.startedAt) {
run.startedAt = patch.startedAt ?? now;
}
if (terminalRunStatuses.has(patch.status)) {
run.finishedAt = patch.finishedAt ?? now;
}
}
if ('jenkinsQueueId' in patch) {
run.jenkinsQueueId = patch.jenkinsQueueId;
}
if ('jenkinsBuildNumber' in patch) {
run.jenkinsBuildNumber = patch.jenkinsBuildNumber;
}
if ('jenkinsBuildUrl' in patch) {
run.jenkinsBuildUrl = patch.jenkinsBuildUrl;
}
if ('failureSummary' in patch) {
run.failureSummary = patch.failureSummary;
}
if ('startedAt' in patch) {
run.startedAt = patch.startedAt;
}
if ('finishedAt' in patch) {
run.finishedAt = patch.finishedAt;
}
return run;
}
updateMemoryStep(
runId: string,
bpmnNodeId: string,
patch: DeployRunStepMemoryPatch,
): DeployRunSummary | null {
const run = this.memoryRuns.find((item) => item.id === runId);
const step = run?.steps.find((item) => item.bpmnNodeId === bpmnNodeId);
if (!run || !step) {
return null;
}
const now = new Date().toISOString();
if (patch.status) {
step.status = patch.status;
if (patch.status !== 'pending' && !step.startedAt) {
step.startedAt = patch.startedAt ?? now;
}
if (terminalStepStatuses.has(patch.status)) {
step.finishedAt = patch.finishedAt ?? now;
}
}
if ('startedAt' in patch) {
step.startedAt = patch.startedAt;
}
if ('finishedAt' in patch) {
step.finishedAt = patch.finishedAt;
}
if ('message' in patch) {
step.message = patch.message;
}
if ('errorSummary' in patch) {
step.errorSummary = patch.errorSummary;
}
if ('logExcerpt' in patch) {
step.logExcerpt = patch.logExcerpt;
}
return run;
}
private useDatabaseReads(): boolean {
return (
this.config.get('USE_DATABASE_READS', { infer: true }) &&
Boolean(this.config.get('DATABASE_URL', { infer: true }))
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { DeployRunSummary } from './deploy-run.types';
const now = new Date().toISOString();
export const seedDeployRuns: DeployRunSummary[] = [
{
id: 'run_seed_access_manage_test_001',
projectKey: 'access-manage',
environment: 'test',
ref: 'develop',
status: 'pending',
trigger: 'manual',
operator: 'system-seed',
createdAt: now,
steps: [
{
id: 'step_seed_access_manage_start',
bpmnNodeId: 'start-release',
name: '提交发布单',
status: 'success',
order: 10,
},
{
id: 'step_seed_access_manage_gitea',
bpmnNodeId: 'gitea-check',
name: '校验 Gitea ref',
status: 'pending',
order: 20,
},
{
id: 'step_seed_access_manage_jenkins',
bpmnNodeId: 'jenkins-build',
name: '触发 Jenkins 构建',
status: 'pending',
order: 30,
},
{
id: 'step_seed_access_manage_health',
bpmnNodeId: 'health-check',
name: '环境健康检查',
status: 'pending',
order: 40,
},
{
id: 'step_seed_access_manage_notify',
bpmnNodeId: 'notify-wecom',
name: '发送通知',
status: 'pending',
order: 90,
},
{
id: 'step_seed_access_manage_archive',
bpmnNodeId: 'finish-release',
name: '归档审计',
status: 'pending',
order: 100,
},
],
},
];
+63
View File
@@ -0,0 +1,63 @@
export type DeployEnvironment = 'test' | 'production';
export type DeployRunStatus =
| 'pending'
| 'queued'
| 'running'
| 'success'
| 'failed'
| 'canceled';
export type DeployRunStepStatus = DeployRunStatus | 'skipped';
export type DeployRunStepSummary = {
id: string;
bpmnNodeId: string;
name: string;
status: DeployRunStepStatus;
order: number;
startedAt?: string;
finishedAt?: string;
message?: string;
errorSummary?: string;
logExcerpt?: string;
};
export type DeployRunSummary = {
id: string;
projectKey: string;
environment: DeployEnvironment;
ref: string;
status: DeployRunStatus;
trigger: 'manual' | 'gitea_webhook' | 'retry';
operator: string;
jenkinsQueueId?: string;
jenkinsBuildNumber?: number;
jenkinsBuildUrl?: string;
failureSummary?: string;
startedAt?: string;
finishedAt?: string;
createdAt: string;
steps: DeployRunStepSummary[];
};
export type CreateDeployRunInput = {
projectKey: string;
environment: DeployEnvironment;
ref: string;
operator: string;
remark?: string;
idempotencyKey?: string;
trigger?: DeployRunSummary['trigger'];
};
export type DeployRunJenkinsSyncError = {
id: string;
projectKey: string;
message: string;
};
export type DeployRunJenkinsSyncSummary = {
checkedAt: string;
totalCandidates: number;
syncedRuns: DeployRunSummary[];
failedRuns: DeployRunJenkinsSyncError[];
};
+69
View File
@@ -0,0 +1,69 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import {
ApiBody,
ApiCreatedResponse,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import {
CreateDeployRunDto,
createDeployRunSchema,
} from './create-deploy-run.dto';
import {
CreateDeployRunInput,
DeployRunJenkinsSyncSummary,
DeployRunSummary,
} from './deploy-run.types';
import { DeployRunsService } from './deploy-runs.service';
@ApiTags('deploy-runs')
@Controller('deploy-runs')
export class DeployRunsController {
constructor(private readonly deployRunsService: DeployRunsService) {}
@Get()
@ApiOkResponse({ description: 'Latest deploy runs.' })
async listRuns(): Promise<DeployRunSummary[]> {
return this.deployRunsService.listRuns();
}
@Get(':id')
@ApiOkResponse({ description: 'Deploy run detail.' })
async getRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.getRun(id);
}
@Post()
@ApiBody({ type: CreateDeployRunDto })
@ApiCreatedResponse({ description: 'Create a deploy run draft.' })
async createRun(
@Body(new ZodValidationPipe(createDeployRunSchema)) body: CreateDeployRunInput,
): Promise<DeployRunSummary> {
return this.deployRunsService.createRun(body);
}
@Post(':id/cancel')
@ApiOkResponse({ description: 'Cancel a deploy run.' })
async cancelRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.cancelRun(id);
}
@Post(':id/retry')
@ApiCreatedResponse({ description: 'Retry a deploy run.' })
async retryRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.retryRun(id);
}
@Post('sync-jenkins')
@ApiOkResponse({ description: 'Synchronize all queued/running Jenkins runs.' })
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
return this.deployRunsService.syncJenkinsRuns();
}
@Post(':id/sync-jenkins')
@ApiOkResponse({ description: 'Synchronize Jenkins queue/build state.' })
async syncJenkinsRun(@Param('id') id: string): Promise<DeployRunSummary> {
return this.deployRunsService.syncJenkinsRun(id);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { JenkinsModule } from '../integrations/jenkins/jenkins.module';
import { WeComModule } from '../notifications/wecom/wecom.module';
import { ProjectsModule } from '../projects/projects.module';
import { DeployExecutionService } from './deploy-execution.service';
import { DeployRunRepository } from './deploy-run.repository';
import { DeployRunsController } from './deploy-runs.controller';
import { DeployRunsService } from './deploy-runs.service';
@Module({
imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule],
controllers: [DeployRunsController],
providers: [DeployExecutionService, DeployRunRepository, DeployRunsService],
exports: [DeployExecutionService, DeployRunRepository, DeployRunsService],
})
export class DeployRunsModule {}
+250
View File
@@ -0,0 +1,250 @@
import { AuditService } from '../audit/audit.service';
import { AppError } from '../common/errors/app-error';
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { ProjectsService } from '../projects/projects.service';
import { DeployExecutionService } from './deploy-execution.service';
import { DeployRunRepository } from './deploy-run.repository';
import {
CreateDeployRunInput,
DeployRunStatus,
DeployRunSummary,
} from './deploy-run.types';
import { DeployRunsService } from './deploy-runs.service';
/**
* 覆盖发布单第一阶段内存实现,后续接真实数据库时保持服务契约不变。
*/
describe('DeployRunsService', () => {
const baseRun: DeployRunSummary = {
id: 'run_001',
projectKey: 'access-manage',
environment: 'test',
ref: 'develop',
status: 'pending',
trigger: 'manual',
operator: 'ops-admin',
createdAt: '2026-06-11T00:00:00.000Z',
steps: [],
};
function createService(options?: { runs?: DeployRunSummary[] }) {
const runs = options?.runs ?? [baseRun];
const repository = {
findMany: jest.fn(() => Promise.resolve(runs)),
findById: jest.fn((id: string) =>
Promise.resolve(runs.find((run) => run.id === id) ?? null),
),
create: jest.fn((input: CreateDeployRunInput) => ({
...baseRun,
id: input.idempotencyKey ?? 'generated_run',
projectKey: input.projectKey,
environment: input.environment,
ref: input.ref,
operator: input.operator,
trigger: input.trigger ?? 'manual',
})),
updateMemoryStatus: jest.fn((id: string, status: DeployRunStatus) =>
id === baseRun.id ? { ...baseRun, status } : null,
),
} satisfies Pick<
DeployRunRepository,
'findMany' | 'findById' | 'create' | 'updateMemoryStatus'
>;
const projects = {
getProject: jest.fn(() =>
Promise.resolve({
id: 'project_001',
key: 'access-manage',
name: 'access-manage',
repositoryUrl:
'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
defaultBranch: 'develop',
status: 'active' as const,
environments: [
{
name: 'test' as const,
displayName: '测试环境',
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
},
],
}),
),
} satisfies Pick<ProjectsService, 'getProject'>;
const audit = {
record: jest.fn(),
} satisfies Pick<AuditService, 'record'>;
const notification = {
sendDeployRunEvent: jest.fn(() =>
Promise.resolve({
status: 'skipped' as const,
channel: 'wecom' as const,
reason: 'Missing WECOM_WEBHOOK_URL',
}),
),
} satisfies Pick<DeployNotificationService, 'sendDeployRunEvent'>;
const execution = {
execute: jest.fn((run: DeployRunSummary) =>
Promise.resolve({ ...run, status: 'success' as const }),
),
syncJenkinsRun: jest.fn((run: DeployRunSummary) =>
Promise.resolve({ ...run, status: 'success' as const }),
),
} satisfies Pick<DeployExecutionService, 'execute' | 'syncJenkinsRun'>;
return {
audit,
execution,
notification,
projects,
repository,
service: new DeployRunsService(
repository as unknown as DeployRunRepository,
projects as unknown as ProjectsService,
audit as unknown as AuditService,
notification as unknown as DeployNotificationService,
execution as unknown as DeployExecutionService,
),
};
}
it('creates a manual deploy run and records audit data', async () => {
const { audit, execution, repository, service } = createService();
const run = await service.createRun({
projectKey: 'access-manage',
environment: 'test',
ref: 'develop',
operator: 'ops-admin',
idempotencyKey: 'idem_001',
});
expect(run.id).toBe('idem_001');
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({ projectKey: 'access-manage' }),
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'DEPLOY_REQUESTED',
resourceId: 'idem_001',
}),
);
expect(execution.execute).toHaveBeenCalledWith(
expect.objectContaining({ id: 'idem_001' }),
expect.objectContaining({ key: 'access-manage' }),
);
});
it('rejects unsupported environments before creating a run', async () => {
const { execution, repository, service } = createService();
await expect(
service.createRun({
projectKey: 'access-manage',
environment: 'staging',
ref: 'develop',
operator: 'ops-admin',
} as unknown as CreateDeployRunInput),
).rejects.toBeInstanceOf(AppError);
expect(repository.create).not.toHaveBeenCalled();
expect(execution.execute).not.toHaveBeenCalled();
});
it('cancels an existing deploy run', async () => {
const { audit, service } = createService();
const run = await service.cancelRun(baseRun.id);
expect(run.status).toBe('canceled');
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'DEPLOY_CANCELED' }),
);
});
it('retries a deploy run with retry trigger', async () => {
const { repository, service } = createService();
const run = await service.retryRun(baseRun.id);
expect(run.trigger).toBe('retry');
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
projectKey: baseRun.projectKey,
environment: baseRun.environment,
trigger: 'retry',
}),
);
});
it('syncs all queued or running Jenkins runs with queue/build identifiers', async () => {
const queuedRun: DeployRunSummary = {
...baseRun,
id: 'run_queued',
status: 'queued',
jenkinsQueueId: 'queue_001',
};
const runningRun: DeployRunSummary = {
...baseRun,
id: 'run_running',
status: 'running',
jenkinsBuildNumber: 7,
};
const ignoredRun: DeployRunSummary = {
...baseRun,
id: 'run_running_without_jenkins',
status: 'running',
};
const { audit, execution, service } = createService({
runs: [queuedRun, runningRun, ignoredRun],
});
const result = await service.syncJenkinsRuns();
expect(result.totalCandidates).toBe(2);
expect(result.syncedRuns).toHaveLength(2);
expect(result.failedRuns).toHaveLength(0);
expect(execution.syncJenkinsRun).toHaveBeenCalledTimes(2);
expect(execution.syncJenkinsRun).toHaveBeenCalledWith(
queuedRun,
expect.objectContaining({ key: queuedRun.projectKey }),
);
expect(execution.syncJenkinsRun).toHaveBeenCalledWith(
runningRun,
expect.objectContaining({ key: runningRun.projectKey }),
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'JENKINS_SYNC_SWEEP_COMPLETED',
after: expect.objectContaining({
totalCandidates: 2,
syncedCount: 2,
failedCount: 0,
}),
}),
);
});
it('returns a redacted error when one Jenkins run fails during bulk sync', async () => {
const queuedRun: DeployRunSummary = {
...baseRun,
id: 'run_queued',
status: 'queued',
jenkinsQueueId: 'queue_001',
};
const { execution, service } = createService({ runs: [queuedRun] });
execution.syncJenkinsRun.mockRejectedValueOnce(
new Error('abcdefghijklmnopqrstuvwxyz123456'),
);
const result = await service.syncJenkinsRuns();
expect(result.totalCandidates).toBe(1);
expect(result.syncedRuns).toHaveLength(0);
expect(result.failedRuns).toEqual([
{
id: 'run_queued',
projectKey: 'access-manage',
message: '[REDACTED]',
},
]);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { redactSensitive } from '../common/security/redact-sensitive';
import { AuditService } from '../audit/audit.service';
import {
DeployNotificationEvent,
DeployNotificationService,
} from '../notifications/wecom/deploy-notification.service';
import { ProjectsService } from '../projects/projects.service';
import { DeployExecutionService } from './deploy-execution.service';
import { DeployRunRepository } from './deploy-run.repository';
import {
CreateDeployRunInput,
DeployRunJenkinsSyncSummary,
DeployRunSummary,
} from './deploy-run.types';
@Injectable()
export class DeployRunsService {
constructor(
private readonly deployRunRepository: DeployRunRepository,
private readonly projectsService: ProjectsService,
private readonly auditService: AuditService,
private readonly deployNotificationService: DeployNotificationService,
private readonly deployExecutionService: DeployExecutionService,
) {}
async listRuns(): Promise<DeployRunSummary[]> {
return this.deployRunRepository.findMany();
}
async getRun(id: string): Promise<DeployRunSummary> {
const run = await this.deployRunRepository.findById(id);
if (!run) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`Deploy run ${id} not found`,
404,
{
id,
},
);
}
return run;
}
async createRun(input: CreateDeployRunInput): Promise<DeployRunSummary> {
if (!['test', 'production'].includes(input.environment)) {
throw new AppError(
'VALIDATION_FAILED',
'environment must be test or production',
400,
{ environment: input.environment },
);
}
const project = await this.projectsService.getProject(input.projectKey);
const run = this.deployRunRepository.create(input);
this.auditService.record({
action: 'DEPLOY_REQUESTED',
resourceType: 'deploy_run',
resourceId: run.id,
actorName: input.operator,
after: {
projectKey: input.projectKey,
environment: input.environment,
ref: input.ref,
remark: input.remark,
},
});
return this.deployExecutionService.execute(run, project);
}
async cancelRun(id: string): Promise<DeployRunSummary> {
const run = this.deployRunRepository.updateMemoryStatus(id, 'canceled');
if (!run) {
throw new AppError(
'RESOURCE_NOT_FOUND',
`Deploy run ${id} not found`,
404,
{
id,
},
);
}
this.auditService.record({
action: 'DEPLOY_CANCELED',
resourceType: 'deploy_run',
resourceId: run.id,
after: {
status: run.status,
},
});
await this.recordNotificationResult(run, 'canceled');
return run;
}
async retryRun(id: string): Promise<DeployRunSummary> {
const source = await this.getRun(id);
return this.createRun({
projectKey: source.projectKey,
environment: source.environment,
ref: source.ref,
operator: source.operator,
remark: `Retry of ${source.id}`,
idempotencyKey: `${source.id}:retry:${new Date().toISOString()}`,
trigger: 'retry',
});
}
async syncJenkinsRun(id: string): Promise<DeployRunSummary> {
const run = await this.getRun(id);
const project = await this.projectsService.getProject(run.projectKey);
return this.deployExecutionService.syncJenkinsRun(run, project);
}
async syncJenkinsRuns(): Promise<DeployRunJenkinsSyncSummary> {
const runs = await this.listRuns();
const candidates = runs.filter((run) => this.isJenkinsSyncCandidate(run));
const syncedRuns: DeployRunSummary[] = [];
const failedRuns: DeployRunJenkinsSyncSummary['failedRuns'] = [];
for (const run of candidates) {
try {
const project = await this.projectsService.getProject(run.projectKey);
const syncedRun = await this.deployExecutionService.syncJenkinsRun(
run,
project,
);
syncedRuns.push(syncedRun);
} catch (error) {
failedRuns.push({
id: run.id,
projectKey: run.projectKey,
message: this.toSafeSyncError(error),
});
}
}
this.auditService.record({
action: 'JENKINS_SYNC_SWEEP_COMPLETED',
resourceType: 'deploy_run',
after: {
totalCandidates: candidates.length,
syncedCount: syncedRuns.length,
failedCount: failedRuns.length,
},
});
return {
checkedAt: new Date().toISOString(),
totalCandidates: candidates.length,
syncedRuns,
failedRuns,
};
}
private async recordNotificationResult(
run: DeployRunSummary,
event: DeployNotificationEvent,
): Promise<void> {
const notification =
await this.deployNotificationService.sendDeployRunEvent(run, event);
this.auditService.record({
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`,
resourceType: 'deploy_run',
resourceId: run.id,
after: {
event,
channel: notification.channel,
reason: notification.reason,
},
});
}
private isJenkinsSyncCandidate(run: DeployRunSummary): boolean {
return (
['queued', 'running'].includes(run.status) &&
Boolean(run.jenkinsQueueId || run.jenkinsBuildNumber)
);
}
private toSafeSyncError(error: unknown): string {
const message =
error instanceof Error ? error.message : 'Jenkins sync failed';
return redactSensitive(message);
}
}