diff --git a/.env.example b/.env.example index 5dd2654..81a02c0 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ REDIS_URL=redis://127.0.0.1:6379/0 JENKINS_BASE_URL=https://jenkins.example.internal JENKINS_USERNAME=devops-bot JENKINS_API_TOKEN=replace-with-secret-manager-value +JENKINS_AUTO_SYNC_ENABLED=false +JENKINS_AUTO_SYNC_INTERVAL_MS=30000 # Gitea integration, server-side only. GITEA_BASE_URL=https://gitea.example.internal diff --git a/README.md b/README.md index 34929d7..4bf49d7 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ pnpm prisma:migrate:dev --name init - `GET /deploy-runs`: latest deploy runs. - `POST /deploy-runs`: create a deploy run. Missing Jenkins config uses a local dry-run path; configured Jenkins calls `buildWithParameters` and returns `queued/running`. - `POST /deploy-runs/:id/sync-jenkins`: synchronize Jenkins queue/build/log status and advance success or failure. +- `POST /deploy-runs/sync-jenkins`: synchronize all queued/running runs that have Jenkins queue/build identifiers. - `POST /deploy-runs/:id/cancel`: cancel a deploy run. - `POST /deploy-runs/:id/retry`: create a retry deploy run. - `POST /webhooks/gitea`: accept Gitea push webhooks and trigger test deploy runs. @@ -71,3 +72,24 @@ The Agent module is not a generic chat entrypoint. It can only support DevOps wo - incident review drafts. Agent calls are read-only. They must not trigger deploys, mutate Jenkins/Gitea, or expose raw secrets. When `LLM_BASE_URL`, `LLM_API_KEY`, and `LLM_MODEL` are configured, the backend proxies DevOps-scoped prompts to `/chat/completions`; otherwise it returns local mock results with the same API shape. + +## Notification Providers + +`DeployNotificationService` supports the first provider boundary for: + +- `NOTIFICATION_PROVIDER=wecom` with `WECOM_WEBHOOK_URL` +- `NOTIFICATION_PROVIDER=feishu` with `FEISHU_WEBHOOK_URL` +- `NOTIFICATION_PROVIDER=generic` with `NOTIFICATION_WEBHOOK_URL` + +Missing provider or webhook configuration returns a skipped notification result and does not block deploy status transitions. + +## Jenkins Auto Sync + +Manual sync is always available through `POST /deploy-runs/sync-jenkins`. To enable a lightweight in-process poller before Redis/BullMQ is introduced: + +```env +JENKINS_AUTO_SYNC_ENABLED=true +JENKINS_AUTO_SYNC_INTERVAL_MS=30000 +``` + +The poller only scans queued/running runs that already have a Jenkins queue id or build number. diff --git a/src/common/security/redact-sensitive.ts b/src/common/security/redact-sensitive.ts index 3b3a932..22b25d8 100644 --- a/src/common/security/redact-sensitive.ts +++ b/src/common/security/redact-sensitive.ts @@ -1,5 +1,9 @@ const SENSITIVE_KEY_PATTERN = /(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)/i; +const INLINE_SECRET_PATTERN = + /\b(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)=([^\s&]+)/gi; +const INLINE_URL_WITH_SECRET_PATTERN = + /https?:\/\/[^\s]*(?:token|secret|password|passwd|api[-_]?key|webhook|key=)[^\s]*/gi; export function redactSensitive(value: T): T { return redactValue(value) as T; @@ -19,8 +23,18 @@ function redactValue(value: unknown): unknown { ); } - if (typeof value === 'string' && looksLikeSecret(value)) { - return '[REDACTED]'; + if (typeof value === 'string') { + const redacted = value + .replace(INLINE_URL_WITH_SECRET_PATTERN, '[REDACTED_URL]') + .replace(INLINE_SECRET_PATTERN, '$1=[REDACTED]'); + + if (redacted !== value) { + return redacted; + } + + if (looksLikeSecret(value)) { + return '[REDACTED]'; + } } return value; diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index 5d337e4..060b20a 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -34,6 +34,15 @@ const booleanFromEnv = z.preprocess((value) => { return false; }, z.boolean().default(false)); +const positiveIntegerFromEnv = (defaultValue: number) => + z.preprocess((value) => { + if (value === undefined || value === null || value === '') { + return undefined; + } + + return value; + }, z.coerce.number().int().positive().default(defaultValue)); + export const envSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().int().positive().default(4300), @@ -46,6 +55,8 @@ export const envSchema = z.object({ JENKINS_BASE_URL: optionalUrl, JENKINS_USERNAME: optionalString, JENKINS_API_TOKEN: optionalString, + JENKINS_AUTO_SYNC_ENABLED: booleanFromEnv, + JENKINS_AUTO_SYNC_INTERVAL_MS: positiveIntegerFromEnv(30000), GITEA_BASE_URL: optionalUrl, GITEA_TOKEN: optionalString, GITEA_WEBHOOK_SECRET: optionalString, diff --git a/src/deploy-runs/deploy-execution.service.ts b/src/deploy-runs/deploy-execution.service.ts index 0a827cd..0ba7cec 100644 --- a/src/deploy-runs/deploy-execution.service.ts +++ b/src/deploy-runs/deploy-execution.service.ts @@ -329,7 +329,7 @@ export class DeployExecutionService { ); this.auditService.record({ - action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`, + action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`, resourceType: 'deploy_run', resourceId: run.id, actorName: run.operator, @@ -501,7 +501,7 @@ export class DeployExecutionService { await this.deployNotificationService.sendDeployRunEvent(run, event); this.auditService.record({ - action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`, + action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`, resourceType: 'deploy_run', resourceId: run.id, actorName: run.operator, diff --git a/src/deploy-runs/deploy-runs.module.ts b/src/deploy-runs/deploy-runs.module.ts index 0f2e665..d443c56 100644 --- a/src/deploy-runs/deploy-runs.module.ts +++ b/src/deploy-runs/deploy-runs.module.ts @@ -7,11 +7,17 @@ import { DeployExecutionService } from './deploy-execution.service'; import { DeployRunRepository } from './deploy-run.repository'; import { DeployRunsController } from './deploy-runs.controller'; import { DeployRunsService } from './deploy-runs.service'; +import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service'; @Module({ imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule], controllers: [DeployRunsController], - providers: [DeployExecutionService, DeployRunRepository, DeployRunsService], + providers: [ + DeployExecutionService, + DeployRunRepository, + DeployRunsService, + JenkinsSyncSchedulerService, + ], exports: [DeployExecutionService, DeployRunRepository, DeployRunsService], }) export class DeployRunsModule {} diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index 968ded4..79863a4 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -171,7 +171,7 @@ export class DeployRunsService { await this.deployNotificationService.sendDeployRunEvent(run, event); this.auditService.record({ - action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`, + action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`, resourceType: 'deploy_run', resourceId: run.id, after: { @@ -193,6 +193,6 @@ export class DeployRunsService { const message = error instanceof Error ? error.message : 'Jenkins sync failed'; - return redactSensitive(message); + return redactSensitive({ message }).message; } } diff --git a/src/deploy-runs/jenkins-sync-scheduler.service.spec.ts b/src/deploy-runs/jenkins-sync-scheduler.service.spec.ts new file mode 100644 index 0000000..0e6fbd2 --- /dev/null +++ b/src/deploy-runs/jenkins-sync-scheduler.service.spec.ts @@ -0,0 +1,106 @@ +import { ConfigService } from '@nestjs/config'; +import { AuditService } from '../audit/audit.service'; +import { EnvConfig } from '../config/env.schema'; +import { DeployRunsService } from './deploy-runs.service'; +import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service'; + +describe('JenkinsSyncSchedulerService', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not start interval when auto sync is disabled', () => { + jest.useFakeTimers(); + const sync = { + syncJenkinsRuns: jest.fn(), + } satisfies Pick; + const service = new JenkinsSyncSchedulerService( + createConfig({ + JENKINS_AUTO_SYNC_ENABLED: false, + JENKINS_AUTO_SYNC_INTERVAL_MS: 1000, + }), + sync as unknown as DeployRunsService, + createAudit(), + ); + + service.onModuleInit(); + jest.advanceTimersByTime(3000); + + expect(sync.syncJenkinsRuns).not.toHaveBeenCalled(); + }); + + it('runs one sweep and records aggregate audit data', async () => { + const sync = { + syncJenkinsRuns: jest.fn(() => + Promise.resolve({ + checkedAt: '2026-06-11T00:00:00.000Z', + totalCandidates: 2, + syncedRuns: [{} as never], + failedRuns: [{} as never], + }), + ), + } satisfies Pick; + const audit = createAudit(); + const service = new JenkinsSyncSchedulerService( + createConfig({ + JENKINS_AUTO_SYNC_ENABLED: true, + JENKINS_AUTO_SYNC_INTERVAL_MS: 1000, + }), + sync as unknown as DeployRunsService, + audit, + ); + + await service.runOnce(); + + expect(sync.syncJenkinsRuns).toHaveBeenCalledTimes(1); + expect(audit.recordMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED', + after: { + totalCandidates: 2, + syncedCount: 1, + failedCount: 1, + }, + }), + ); + }); + + it('records redacted failure audit when sweep fails', async () => { + const sync = { + syncJenkinsRuns: jest.fn(() => + Promise.reject( + new Error('token=secret-value https://jenkins.internal failed'), + ), + ), + } satisfies Pick; + const audit = createAudit(); + const service = new JenkinsSyncSchedulerService( + createConfig({}), + sync as unknown as DeployRunsService, + audit, + ); + + await service.runOnce(); + + const payload = audit.recordMock.mock.calls[0][0]; + expect(payload.action).toBe('JENKINS_AUTO_SYNC_TICK_FAILED'); + expect(JSON.stringify(payload)).not.toContain('secret-value'); + }); +}); + +function createConfig( + values: Partial>, +): ConfigService { + return { + get: jest.fn((key: keyof EnvConfig) => values[key]), + } as unknown as ConfigService; +} + +function createAudit(): AuditService & { recordMock: jest.Mock } { + const recordMock = jest.fn(); + + return { + record: recordMock, + recordMock, + } as unknown as AuditService & { recordMock: jest.Mock }; +} diff --git a/src/deploy-runs/jenkins-sync-scheduler.service.ts b/src/deploy-runs/jenkins-sync-scheduler.service.ts new file mode 100644 index 0000000..f9beb77 --- /dev/null +++ b/src/deploy-runs/jenkins-sync-scheduler.service.ts @@ -0,0 +1,81 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuditService } from '../audit/audit.service'; +import { redactSensitive } from '../common/security/redact-sensitive'; +import { EnvConfig } from '../config/env.schema'; +import { DeployRunsService } from './deploy-runs.service'; + +/** + * 轻量自动同步器,先复用内存发布单和现有 Jenkins 同步逻辑。 + * 后续接 Redis/BullMQ 后,这里会迁移成队列 worker。 + */ +@Injectable() +export class JenkinsSyncSchedulerService + implements OnModuleInit, OnModuleDestroy +{ + private timer: ReturnType | undefined; + private running = false; + + constructor( + private readonly config: ConfigService, + private readonly deployRunsService: DeployRunsService, + private readonly auditService: AuditService, + ) {} + + onModuleInit(): void { + if (!this.config.get('JENKINS_AUTO_SYNC_ENABLED', { infer: true })) { + return; + } + + const intervalMs = this.config.get('JENKINS_AUTO_SYNC_INTERVAL_MS', { + infer: true, + }); + + this.timer = setInterval(() => { + void this.runOnce(); + }, intervalMs); + void this.runOnce(); + } + + onModuleDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + async runOnce(): Promise { + if (this.running) { + return; + } + + this.running = true; + + try { + const summary = await this.deployRunsService.syncJenkinsRuns(); + + this.auditService.record({ + action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED', + resourceType: 'deploy_run', + after: { + totalCandidates: summary.totalCandidates, + syncedCount: summary.syncedRuns.length, + failedCount: summary.failedRuns.length, + }, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Jenkins auto sync failed'; + + this.auditService.record({ + action: 'JENKINS_AUTO_SYNC_TICK_FAILED', + resourceType: 'deploy_run', + after: { + message: redactSensitive({ message }).message, + }, + }); + } finally { + this.running = false; + } + } +} diff --git a/src/health/health.service.spec.ts b/src/health/health.service.spec.ts index 391b8ab..a981e2e 100644 --- a/src/health/health.service.spec.ts +++ b/src/health/health.service.spec.ts @@ -3,7 +3,7 @@ import { LlmClient } from '../agent/llm.client'; import { EnvConfig } from '../config/env.schema'; import { GiteaClient } from '../integrations/gitea/gitea.client'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; -import { WeComClient } from '../notifications/wecom/wecom.client'; +import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service'; import { PrismaService } from '../prisma/prisma.service'; import { HealthService } from './health.service'; @@ -26,9 +26,9 @@ describe('HealthService', () => { const gitea = { healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), } satisfies Pick; - const weCom = { + const notification = { healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), - } satisfies Pick; + } satisfies Pick; const llm = { healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), } satisfies Pick; @@ -37,7 +37,7 @@ describe('HealthService', () => { prisma as unknown as PrismaService, jenkins as unknown as JenkinsClient, gitea as unknown as GiteaClient, - weCom as unknown as WeComClient, + notification as unknown as DeployNotificationService, llm as unknown as LlmClient, ); @@ -46,5 +46,6 @@ describe('HealthService', () => { expect(health.status).toBe('ok'); expect(health.dependencies.database.status).toBe('not_configured'); expect(health.dependencies.jenkins.status).toBe('not_configured'); + expect(health.dependencies.notification.status).toBe('not_configured'); }); }); diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 2d71af4..9980dc3 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -4,7 +4,7 @@ import { LlmClient } from '../agent/llm.client'; import { EnvConfig } from '../config/env.schema'; import { GiteaClient } from '../integrations/gitea/gitea.client'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; -import { WeComClient } from '../notifications/wecom/wecom.client'; +import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service'; import { PrismaService } from '../prisma/prisma.service'; type HealthStatus = 'ok' | 'not_configured' | 'unavailable'; @@ -16,7 +16,7 @@ export class HealthService { private readonly prisma: PrismaService, private readonly jenkins: JenkinsClient, private readonly gitea: GiteaClient, - private readonly weCom: WeComClient, + private readonly notification: DeployNotificationService, private readonly llm: LlmClient, ) {} @@ -32,7 +32,7 @@ export class HealthService { : { status: 'not_configured' as const }, jenkins: this.jenkins.healthSummary(), gitea: this.gitea.healthSummary(), - wecom: this.weCom.healthSummary(), + notification: this.notification.healthSummary(), llm: this.llm.healthSummary(), }; const hasUnavailable = Object.values(dependencies).some( diff --git a/src/notifications/wecom/deploy-notification.service.spec.ts b/src/notifications/wecom/deploy-notification.service.spec.ts index c11d4a1..819af68 100644 --- a/src/notifications/wecom/deploy-notification.service.spec.ts +++ b/src/notifications/wecom/deploy-notification.service.spec.ts @@ -1,9 +1,11 @@ +import { ConfigService } from '@nestjs/config'; +import { EnvConfig } from '../../config/env.schema'; import { WeComClient } from './wecom.client'; import { DeployNotificationService } from './deploy-notification.service'; import { DeployRunSummary } from '../../deploy-runs/deploy-run.types'; /** - * 覆盖企微通知的安全边界:缺少 webhook 时跳过,配置后才发送。 + * 覆盖通知 provider 的安全边界:缺少配置时跳过,配置后才发送。 */ describe('DeployNotificationService', () => { const run: DeployRunSummary = { @@ -18,7 +20,11 @@ describe('DeployNotificationService', () => { steps: [], }; - it('skips notification when WeCom is not configured', async () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('skips notification when provider is not configured', async () => { const weCom = { healthSummary: jest.fn(() => ({ status: 'not_configured' as const, @@ -27,27 +33,31 @@ describe('DeployNotificationService', () => { sendMarkdown: jest.fn(), } satisfies Pick; const service = new DeployNotificationService( + createConfig({}), weCom as unknown as WeComClient, ); const result = await service.sendDeployRunEvent(run, 'created'); expect(result.status).toBe('skipped'); + expect(result.channel).toBe('none'); expect(weCom.sendMarkdown).not.toHaveBeenCalled(); }); - it('sends markdown when WeCom is configured', async () => { + it('sends markdown when WeCom provider is configured', async () => { const weCom = { healthSummary: jest.fn(() => ({ status: 'ok' as const })), sendMarkdown: jest.fn(() => Promise.resolve({ accepted: true })), } satisfies Pick; const service = new DeployNotificationService( + createConfig({ NOTIFICATION_PROVIDER: 'wecom' }), weCom as unknown as WeComClient, ); const result = await service.sendDeployRunEvent(run, 'created'); expect(result.status).toBe('sent'); + expect(result.channel).toBe('wecom'); expect(weCom.sendMarkdown).toHaveBeenCalledWith( expect.objectContaining({ title: 'DevOps 发布已创建', @@ -55,4 +65,69 @@ describe('DeployNotificationService', () => { }), ); }); + + it('sends text payload when Feishu provider is configured', async () => { + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + } as Response); + const weCom = { + healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), + sendMarkdown: jest.fn(), + } satisfies Pick; + const service = new DeployNotificationService( + createConfig({ + NOTIFICATION_PROVIDER: 'feishu', + FEISHU_WEBHOOK_URL: 'https://open.feishu.cn/open-apis/bot/v2/hook/test', + }), + weCom as unknown as WeComClient, + ); + + const result = await service.sendDeployRunEvent(run, 'failed'); + + expect(result).toMatchObject({ status: 'sent', channel: 'feishu' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://open.feishu.cn/open-apis/bot/v2/hook/test', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('DevOps 发布失败'), + }), + ); + }); + + it('sends structured payload when generic provider is configured', async () => { + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + } as Response); + const weCom = { + healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), + sendMarkdown: jest.fn(), + } satisfies Pick; + const service = new DeployNotificationService( + createConfig({ + NOTIFICATION_PROVIDER: 'generic', + NOTIFICATION_WEBHOOK_URL: 'https://notification.internal/webhook/test', + DEVOPS_PUBLIC_URL: 'https://devops.mrzhan.top', + }), + weCom as unknown as WeComClient, + ); + + const result = await service.sendDeployRunEvent(run, 'succeeded'); + + expect(result).toMatchObject({ status: 'sent', channel: 'generic' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://notification.internal/webhook/test', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('devops.mrzhan.top'), + }), + ); + }); }); + +function createConfig( + values: Partial>, +): ConfigService { + return { + get: jest.fn((key: keyof EnvConfig) => values[key]), + } as unknown as ConfigService; +} diff --git a/src/notifications/wecom/deploy-notification.service.ts b/src/notifications/wecom/deploy-notification.service.ts index 8f2202e..94ca314 100644 --- a/src/notifications/wecom/deploy-notification.service.ts +++ b/src/notifications/wecom/deploy-notification.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { redactSensitive } from '../../common/security/redact-sensitive'; +import { EnvConfig } from '../../config/env.schema'; import { DeployRunSummary } from '../../deploy-runs/deploy-run.types'; import { WeComClient } from './wecom.client'; @@ -13,50 +15,191 @@ export type DeployNotificationEvent = export type DeployNotificationResult = { status: 'sent' | 'skipped' | 'failed'; - channel: 'wecom'; + channel: DeployNotificationProvider | 'none'; reason?: string; }; +export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic'; + +type DeployNotificationHealth = { + status: 'ok' | 'not_configured' | 'unavailable'; + provider?: DeployNotificationProvider; + message?: string; +}; + /** - * 统一封装发布单企微通知,保证 webhook 只存在于后端配置边界内。 + * 统一封装发布单通知,保证 webhook 只存在于后端配置边界内。 */ @Injectable() export class DeployNotificationService { - constructor(private readonly weComClient: WeComClient) {} + constructor( + private readonly config: ConfigService, + private readonly weComClient: WeComClient, + ) {} + + healthSummary(): DeployNotificationHealth { + const provider = this.resolveProvider(); + + if (!provider) { + return { + status: 'not_configured', + message: 'Missing NOTIFICATION_PROVIDER', + }; + } + + if (provider === 'wecom') { + const health = this.weComClient.healthSummary(); + + return { + ...health, + provider, + }; + } + + const webhookKey = this.webhookKeyFor(provider); + + return this.config.get(webhookKey, { infer: true }) + ? { status: 'ok', provider } + : { + status: 'not_configured', + provider, + message: `Missing ${webhookKey}`, + }; + } async sendDeployRunEvent( run: DeployRunSummary, event: DeployNotificationEvent, ): Promise { - const health = this.weComClient.healthSummary(); + const health = this.healthSummary(); if (health.status === 'not_configured') { return { status: 'skipped', - channel: 'wecom', - reason: health.message ?? 'WeCom webhook is not configured', + channel: health.provider ?? 'none', + reason: health.message ?? 'Notification provider is not configured', + }; + } + + if (health.status === 'unavailable' || !health.provider) { + return { + status: 'failed', + channel: health.provider ?? 'none', + reason: health.message ?? 'Notification provider is unavailable', }; } try { - await this.weComClient.sendMarkdown({ - title: this.titleFor(event), - markdown: this.markdownFor(run, event), - }); + await this.sendByProvider(health.provider, run, event); - return { status: 'sent', channel: 'wecom' }; + return { status: 'sent', channel: health.provider }; } catch (error) { return { status: 'failed', - channel: 'wecom', + channel: health.provider, reason: error instanceof Error ? redactSensitive({ message: error.message }).message - : 'Unknown WeCom notification error', + : 'Unknown notification error', }; } } + private resolveProvider(): DeployNotificationProvider | undefined { + const provider = this.config + .get('NOTIFICATION_PROVIDER', { infer: true }) + ?.toLowerCase(); + + if ( + provider === 'wecom' || + provider === 'feishu' || + provider === 'generic' + ) { + return provider; + } + + return undefined; + } + + private webhookKeyFor( + provider: Exclude, + ): 'FEISHU_WEBHOOK_URL' | 'NOTIFICATION_WEBHOOK_URL' { + return provider === 'feishu' + ? 'FEISHU_WEBHOOK_URL' + : 'NOTIFICATION_WEBHOOK_URL'; + } + + private async sendByProvider( + provider: DeployNotificationProvider, + run: DeployRunSummary, + event: DeployNotificationEvent, + ): Promise { + const title = this.titleFor(event); + const markdown = this.markdownFor(run, event); + + if (provider === 'wecom') { + await this.weComClient.sendMarkdown({ + title, + markdown, + }); + return; + } + + if (provider === 'feishu') { + await this.postWebhook( + this.config.get('FEISHU_WEBHOOK_URL', { infer: true }) ?? '', + { + msg_type: 'text', + content: { + text: `${title}\n${this.plainTextFor(run, event)}`, + }, + }, + provider, + ); + return; + } + + await this.postWebhook( + this.config.get('NOTIFICATION_WEBHOOK_URL', { infer: true }) ?? '', + { + event: `deploy.${event}`, + title, + markdown, + run: { + id: run.id, + projectKey: run.projectKey, + environment: run.environment, + ref: run.ref, + status: run.status, + operator: run.operator, + jenkinsQueueId: run.jenkinsQueueId, + jenkinsBuildNumber: run.jenkinsBuildNumber, + failureSummary: run.failureSummary, + }, + publicUrl: this.config.get('DEVOPS_PUBLIC_URL', { infer: true }), + }, + provider, + ); + } + + private async postWebhook( + webhookUrl: string, + body: unknown, + provider: DeployNotificationProvider, + ): Promise { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`${provider} webhook request failed: ${response.status}`); + } + } + private titleFor(event: DeployNotificationEvent): string { if (event === 'canceled') { return 'DevOps 发布已取消'; @@ -100,4 +243,22 @@ export class DeployNotificationService { .filter(Boolean) .join('\n'); } + + private plainTextFor( + run: DeployRunSummary, + event: DeployNotificationEvent, + ): string { + return [ + `事件:${this.titleFor(event)}`, + `项目:${run.projectKey}`, + `环境:${run.environment}`, + `Ref:${run.ref}`, + `状态:${run.status}`, + `操作者:${run.operator}`, + `Run ID:${run.id}`, + run.failureSummary ? `失败摘要:${run.failureSummary}` : undefined, + ] + .filter(Boolean) + .join('\n'); + } } diff --git a/src/settings/settings.service.ts b/src/settings/settings.service.ts index 8140fb0..ae8ece8 100644 --- a/src/settings/settings.service.ts +++ b/src/settings/settings.service.ts @@ -28,6 +28,7 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [ key: 'jenkins', name: 'Jenkins', required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'], + optional: ['JENKINS_AUTO_SYNC_ENABLED', 'JENKINS_AUTO_SYNC_INTERVAL_MS'], }, { key: 'gitea', @@ -40,7 +41,12 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [ key: 'notification', name: '通知平台', required: ['NOTIFICATION_PROVIDER'], - optional: ['WECOM_WEBHOOK_URL', 'FEISHU_WEBHOOK_URL', 'NOTIFICATION_WEBHOOK_URL'], + optional: [ + 'WECOM_WEBHOOK_URL', + 'FEISHU_WEBHOOK_URL', + 'NOTIFICATION_WEBHOOK_URL', + 'DEVOPS_PUBLIC_URL', + ], note: '当前不强制配置机器人;后续可按 provider 接入企微、飞书或通用 webhook。', }, { @@ -87,6 +93,10 @@ export class SettingsService { private toIntegrationStatus( definition: IntegrationDefinition, ): IntegrationConfigStatus { + if (definition.key === 'notification') { + return this.toNotificationIntegrationStatus(definition); + } + const configured = definition.required.filter((key) => this.isConfiguredValue(this.getConfigValue(key)), ); @@ -106,6 +116,54 @@ export class SettingsService { }; } + private toNotificationIntegrationStatus( + definition: IntegrationDefinition, + ): IntegrationConfigStatus { + const provider = this.getConfigValue('NOTIFICATION_PROVIDER')?.toLowerCase(); + const providerWebhook = this.notificationWebhookKey(provider); + const required: EnvStringKey[] = providerWebhook + ? ['NOTIFICATION_PROVIDER', providerWebhook] + : ['NOTIFICATION_PROVIDER']; + const configured = required.filter((key) => + this.isConfiguredValue(this.getConfigValue(key)), + ); + const missing = required.filter( + (key) => !this.isConfiguredValue(this.getConfigValue(key)), + ); + + return { + key: definition.key, + name: definition.name, + status: this.resolveStatus(configured.length, missing.length), + required, + configured, + missing, + ...(definition.optional ? { optional: [...definition.optional] } : {}), + note: + provider && !providerWebhook + ? `NOTIFICATION_PROVIDER=${provider} 暂不支持;可选 wecom、feishu、generic。` + : definition.note, + }; + } + + private notificationWebhookKey( + provider: string | undefined, + ): EnvStringKey | undefined { + if (provider === 'wecom') { + return 'WECOM_WEBHOOK_URL'; + } + + if (provider === 'feishu') { + return 'FEISHU_WEBHOOK_URL'; + } + + if (provider === 'generic') { + return 'NOTIFICATION_WEBHOOK_URL'; + } + + return undefined; + } + private getConfigValue(key: EnvStringKey): string | undefined { const value = this.config.get(key, { infer: true });