feat: 增加通知Provider和Jenkins自动同步
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<T>(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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DeployRunsService, 'syncJenkinsRuns'>;
|
||||
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<DeployRunsService, 'syncJenkinsRuns'>;
|
||||
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<DeployRunsService, 'syncJenkinsRuns'>;
|
||||
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<Record<keyof EnvConfig, string | number | boolean | undefined>>,
|
||||
): ConfigService<EnvConfig, true> {
|
||||
return {
|
||||
get: jest.fn((key: keyof EnvConfig) => values[key]),
|
||||
} as unknown as ConfigService<EnvConfig, true>;
|
||||
}
|
||||
|
||||
function createAudit(): AuditService & { recordMock: jest.Mock } {
|
||||
const recordMock = jest.fn();
|
||||
|
||||
return {
|
||||
record: recordMock,
|
||||
recordMock,
|
||||
} as unknown as AuditService & { recordMock: jest.Mock };
|
||||
}
|
||||
@@ -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<typeof setInterval> | undefined;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService<EnvConfig, true>,
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GiteaClient, 'healthSummary'>;
|
||||
const weCom = {
|
||||
const notification = {
|
||||
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||
} satisfies Pick<WeComClient, 'healthSummary'>;
|
||||
} satisfies Pick<DeployNotificationService, 'healthSummary'>;
|
||||
const llm = {
|
||||
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
|
||||
} satisfies Pick<LlmClient, 'healthSummary'>;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||
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<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||
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<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||
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<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||
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<Record<keyof EnvConfig, string | number | boolean | undefined>>,
|
||||
): ConfigService<EnvConfig, true> {
|
||||
return {
|
||||
get: jest.fn((key: keyof EnvConfig) => values[key]),
|
||||
} as unknown as ConfigService<EnvConfig, true>;
|
||||
}
|
||||
|
||||
@@ -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<EnvConfig, true>,
|
||||
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<DeployNotificationResult> {
|
||||
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<DeployNotificationProvider, 'wecom'>,
|
||||
): '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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user