feat: 增加通知Provider和Jenkins自动同步

This commit is contained in:
湛兮
2026-06-11 21:07:54 +08:00
parent caec5a618d
commit 163f411485
14 changed files with 568 additions and 31 deletions
+2
View File
@@ -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
+22
View File
@@ -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.
+16 -2
View File
@@ -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;
+11
View File
@@ -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,
+2 -2
View File
@@ -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 -1
View File
@@ -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 {}
+2 -2
View File
@@ -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;
}
}
}
+5 -4
View File
@@ -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');
});
});
+3 -3
View File
@@ -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');
}
}
+59 -1
View File
@@ -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 });