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 { ProjectEnvironmentSummary, ProjectSummary, } from '../projects/project.types'; 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 { return this.deployRunRepository.findMany(); } async getRun(id: string): Promise { 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 { 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); this.validateReleaseRequest(input, project); const run = await 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 { const run = await this.deployRunRepository.updateStatus(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 { 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 { const run = await this.getRun(id); const project = await this.projectsService.getProject(run.projectKey); return this.deployExecutionService.syncJenkinsRun(run, project); } async syncJenkinsRuns(): Promise { 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 { const notification = await this.deployNotificationService.sendDeployRunEvent(run, event); this.auditService.record({ action: `DEPLOY_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 validateReleaseRequest( input: CreateDeployRunInput, project: ProjectSummary, ): ProjectEnvironmentSummary { if (project.status !== 'active') { this.recordDeployRejected(input, 'Project is archived'); throw new AppError( 'VALIDATION_FAILED', `Project ${project.key} is archived and cannot be deployed`, 400, { projectKey: project.key }, ); } const environment = project.environments.find( (item) => item.name === input.environment, ); if (!environment) { this.recordDeployRejected(input, 'Environment is not configured'); throw new AppError( 'VALIDATION_FAILED', `Project ${project.key} does not expose ${input.environment} environment`, 400, { projectKey: project.key, environment: input.environment }, ); } if (!new RegExp(environment.refPattern).test(input.ref)) { this.recordDeployRejected(input, 'Ref does not match release policy'); throw new AppError( 'VALIDATION_FAILED', `Ref ${input.ref} does not match ${environment.releasePolicy} release policy`, 400, { projectKey: project.key, environment: input.environment, ref: input.ref, releasePolicy: environment.releasePolicy, refPattern: environment.refPattern, }, ); } return environment; } private recordDeployRejected( input: CreateDeployRunInput, reason: string, ): void { this.auditService.record({ action: 'DEPLOY_REJECTED', resourceType: 'deploy_run', actorName: input.operator, after: { projectKey: input.projectKey, environment: input.environment, ref: input.ref, reason, }, }); } private toSafeSyncError(error: unknown): string { const message = error instanceof Error ? error.message : 'Jenkins sync failed'; return redactSensitive({ message }).message; } }