feat: 打通 Jenkins 直构导入闭环

This commit is contained in:
湛兮
2026-06-12 16:36:11 +08:00
parent 1b645dd9c8
commit 7054908e96
18 changed files with 920 additions and 55 deletions
+7 -4
View File
@@ -35,6 +35,7 @@ pnpm prisma:seed
- 迁移历史从 `prisma/migrations/20260611000000_baseline_current_schema` 开始,空库可以直接执行 `pnpm prisma migrate deploy` 初始化当前 schema。 - 迁移历史从 `prisma/migrations/20260611000000_baseline_current_schema` 开始,空库可以直接执行 `pnpm prisma migrate deploy` 初始化当前 schema。
- 生产库在启用 Prisma 迁移历史前已经存在表结构;`deploy/deploy-devops-platform-api-from-jenkins.sh` 会检测完整表结构并自动将基线迁移标记为已应用,再执行 `pnpm prisma migrate deploy` - 生产库在启用 Prisma 迁移历史前已经存在表结构;`deploy/deploy-devops-platform-api-from-jenkins.sh` 会检测完整表结构并自动将基线迁移标记为已应用,再执行 `pnpm prisma migrate deploy`
- `20260612093000_add_jenkins_external_trigger` 会扩展发布记录触发来源,用于保存 Jenkins 直接构建导入的 run。
- Jenkins 服务器使用 `/usr/local/bin/deploy-devops-platform-api-from-jenkins`,该脚本应与仓库 `deploy/deploy-devops-platform-api-from-jenkins.sh` 保持一致。 - Jenkins 服务器使用 `/usr/local/bin/deploy-devops-platform-api-from-jenkins`,该脚本应与仓库 `deploy/deploy-devops-platform-api-from-jenkins.sh` 保持一致。
- 如需紧急跳过迁移,可在服务器环境中设置 `PRISMA_MIGRATE_DEPLOY_ENABLED=false`,但不能在存在 schema 变更的版本长期使用该开关。 - 如需紧急跳过迁移,可在服务器环境中设置 `PRISMA_MIGRATE_DEPLOY_ENABLED=false`,但不能在存在 schema 变更的版本长期使用该开关。
- 新增表或字段时必须提交新的 `prisma/migrations/<timestamp>_<name>/migration.sql`,不要再用手工 SQL 直接改生产库。 - 新增表或字段时必须提交新的 `prisma/migrations/<timestamp>_<name>/migration.sql`,不要再用手工 SQL 直接改生产库。
@@ -42,7 +43,8 @@ pnpm prisma:seed
## 内置账号 ## 内置账号
- 超级管理员账号:`zhanxi` - 超级管理员账号:`zhanxi`
- 超级管理员初始密码:`zx123456@`,可通过服务器环境变量 `SUPER_ADMIN_INITIAL_PASSWORD` 覆盖 - 本地开发默认超级管理员初始密码:`zx123456@`
- 生产环境必须通过服务器环境变量 `SUPER_ADMIN_INITIAL_PASSWORD` 提供超级管理员初始密码;未配置时服务会拒绝使用公开默认密码初始化。
- 普通成员初始密码:`111111` - 普通成员初始密码:`111111`
超级管理员初始化配置集中在 `src/auth/auth.constants.ts``prisma/seed.js` 和服务器环境变量。服务启动和种子数据只负责确保超级管理员账号存在并保持最高权限;如果超级管理员已修改密码,后续初始化不会覆盖密码。 超级管理员初始化配置集中在 `src/auth/auth.constants.ts``prisma/seed.js` 和服务器环境变量。服务启动和种子数据只负责确保超级管理员账号存在并保持最高权限;如果超级管理员已修改密码,后续初始化不会覆盖密码。
@@ -53,7 +55,7 @@ pnpm prisma:seed
pnpm reset:super-admin-password pnpm reset:super-admin-password
``` ```
该命令面向已构建的服务器部署目录,会自动读取当前目录的 `.env.production`默认把 `zhanxi` 的密码重置为 `zx123456@`。如需临时指定一次性重置密码,可在服务器环境中设置 `SUPER_ADMIN_RESET_PASSWORD` 后执行命令脚本不会输出明文密码。源码开发调试可先执行 `pnpm build`,或使用 `pnpm reset:super-admin-password:dev` 该命令面向已构建的服务器部署目录,会自动读取当前目录的 `.env.production`生产环境必须临时设置 `SUPER_ADMIN_RESET_PASSWORD` 后执行命令脚本不会输出明文密码。本地开发未设置该变量时仍可回退到开发默认密码,源码调试可先执行 `pnpm build`,或使用 `pnpm reset:super-admin-password:dev`
## 关键接口 ## 关键接口
@@ -80,6 +82,7 @@ pnpm reset:super-admin-password
- `POST /deploy-runs/:id/retry`:重试发布单,需要构建权限。 - `POST /deploy-runs/:id/retry`:重试发布单,需要构建权限。
- `POST /deploy-runs/:id/sync-jenkins`:同步单个发布单 Jenkins 状态,需要构建权限。 - `POST /deploy-runs/:id/sync-jenkins`:同步单个发布单 Jenkins 状态,需要构建权限。
- `POST /deploy-runs/sync-jenkins`:同步全部 Jenkins 状态,仅超级管理员可用。 - `POST /deploy-runs/sync-jenkins`:同步全部 Jenkins 状态,仅超级管理员可用。
- `POST /deploy-runs/import-jenkins`:扫描已纳管 Jenkins job 最近构建并导入平台外直接构建记录,仅超级管理员可用。
- `GET /audit-logs`:超级管理员查询脱敏审计日志。 - `GET /audit-logs`:超级管理员查询脱敏审计日志。
- `GET /settings/integration-config`:超级管理员查询集成配置状态。 - `GET /settings/integration-config`:超级管理员查询集成配置状态。
- `GET /health`:运行健康检查。 - `GET /health`:运行健康检查。
@@ -106,8 +109,8 @@ pnpm reset:super-admin-password
- `AUTH_TOKEN_SECRET`:登录 token 签名密钥。 - `AUTH_TOKEN_SECRET`:登录 token 签名密钥。
- `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。 - `AUTH_TOKEN_TTL_SECONDS`:登录 token 有效期,默认 43200 秒。
- `SUPER_ADMIN_INITIAL_PASSWORD`覆盖超级管理员初始化密码;不设置时使用集中初始化配置中的 `zx123456@` - `SUPER_ADMIN_INITIAL_PASSWORD`生产环境必填的超级管理员初始化密码;本地开发不设置时使用默认值
- `SUPER_ADMIN_RESET_PASSWORD`服务器重置脚本使用的一次性超级管理员重置密码。 - `SUPER_ADMIN_RESET_PASSWORD`:服务器重置脚本使用的一次性超级管理员重置密码;生产环境执行重置时必填
- `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。 - `SECRET_ENCRYPTION_KEY`:服务端密钥加密密钥,生产环境保存 Agent 密钥时必须配置。
- `DATABASE_URL`MySQL 连接地址。 - `DATABASE_URL`MySQL 连接地址。
- `USE_DATABASE_READS`:是否启用 Prisma 数据读写。 - `USE_DATABASE_READS`:是否启用 Prisma 数据读写。
+2 -1
View File
@@ -16,7 +16,7 @@
- `members`:成员管理和项目权限分配。 - `members`:成员管理和项目权限分配。
- `messages`:平台消息通知,当前用于普通成员忘记密码请求。 - `messages`:平台消息通知,当前用于普通成员忘记密码请求。
- `projects`:项目配置 API 和 Repository。 - `projects`:项目配置 API 和 Repository。
- `deploy-runs`:发布记录 API、状态模型和 BPMN 步骤摘要。 - `deploy-runs`:发布记录 API、Jenkins 直构导入、状态模型和 BPMN 步骤摘要。
- `integrations/jenkins`Jenkins API 客户端边界。 - `integrations/jenkins`Jenkins API 客户端边界。
- `integrations/gitea`Gitea API 客户端边界。 - `integrations/gitea`Gitea API 客户端边界。
- `integrations/redis`Redis PING 健康检查和 BullMQ 连接参数边界,Jenkins 自动同步与通知 outbox 调度复用该模块。 - `integrations/redis`Redis PING 健康检查和 BullMQ 连接参数边界,Jenkins 自动同步与通知 outbox 调度复用该模块。
@@ -57,3 +57,4 @@ pnpm build
- Jenkins API 部署脚本的仓库版本在 `deploy/deploy-devops-platform-api-from-jenkins.sh` - Jenkins API 部署脚本的仓库版本在 `deploy/deploy-devops-platform-api-from-jenkins.sh`
- 脚本会在切换 `/srv/www/devops-platform-api` 前运行 `pnpm prisma migrate deploy`,迁移失败时不会替换旧服务目录。 - 脚本会在切换 `/srv/www/devops-platform-api` 前运行 `pnpm prisma migrate deploy`,迁移失败时不会替换旧服务目录。
- 生产库早期通过手工 SQL 初始化,首次启用迁移时脚本会在确认 12 张核心表都存在后把基线迁移标记为 applied。 - 生产库早期通过手工 SQL 初始化,首次启用迁移时脚本会在确认 12 张核心表都存在后把基线迁移标记为 applied。
- Jenkins 直接构建导入依赖 `DeployRunTrigger.JENKINS_EXTERNAL`,生产部署必须执行最新 Prisma migration。
@@ -0,0 +1,2 @@
ALTER TABLE `deploy_runs`
MODIFY `trigger` ENUM('MANUAL', 'GITEA_WEBHOOK', 'RETRY', 'JENKINS_EXTERNAL') NOT NULL DEFAULT 'MANUAL';
+1
View File
@@ -30,6 +30,7 @@ enum DeployRunTrigger {
MANUAL MANUAL
GITEA_WEBHOOK GITEA_WEBHOOK
RETRY RETRY
JENKINS_EXTERNAL
} }
enum RunStepStatus { enum RunStepStatus {
+6
View File
@@ -92,6 +92,12 @@ function resolveSuperAdminInitialPassword() {
return process.env.SUPER_ADMIN_INITIAL_PASSWORD; return process.env.SUPER_ADMIN_INITIAL_PASSWORD;
} }
if (process.env.NODE_ENV === 'production') {
throw new Error(
'生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能使用公开默认密码初始化超级管理员',
);
}
return SUPER_ADMIN_INITIAL_PASSWORD; return SUPER_ADMIN_INITIAL_PASSWORD;
} }
+16 -2
View File
@@ -16,7 +16,10 @@ import { ConfigService } from '@nestjs/config';
describe('AuthService', () => { describe('AuthService', () => {
const passwordService = new PasswordService(); const passwordService = new PasswordService();
async function createService(options?: { superAdminPassword?: string }) { async function createService(options?: {
superAdminPassword?: string;
nodeEnv?: EnvConfig['NODE_ENV'];
}) {
const adminPassword = const adminPassword =
options?.superAdminPassword ?? SUPER_ADMIN_INITIAL_PASSWORD; options?.superAdminPassword ?? SUPER_ADMIN_INITIAL_PASSWORD;
const adminHash = await passwordService.hashPassword(adminPassword); const adminHash = await passwordService.hashPassword(adminPassword);
@@ -113,7 +116,7 @@ describe('AuthService', () => {
} }
if (key === 'NODE_ENV') { if (key === 'NODE_ENV') {
return 'test'; return options?.nodeEnv ?? 'test';
} }
return undefined; return undefined;
@@ -163,6 +166,17 @@ describe('AuthService', () => {
expect(result.user.role).toBe('super_admin'); expect(result.user.role).toBe('super_admin');
}); });
it('生产环境拒绝使用公开默认密码初始化超级管理员', async () => {
const { repository, service } = await createService({
nodeEnv: 'production',
});
await expect(service.onModuleInit()).rejects.toMatchObject({
code: 'CONFIG_MISSING',
});
expect(repository.ensureSuperAdmin).not.toHaveBeenCalled();
});
it('拒绝超级管理员使用忘记密码流程', async () => { it('拒绝超级管理员使用忘记密码流程', async () => {
const { service } = await createService(); const { service } = await createService();
+8
View File
@@ -285,6 +285,14 @@ export class AuthService implements OnModuleInit {
return password; return password;
} }
if (this.config.get('NODE_ENV', { infer: true }) === 'production') {
throw new AppError(
'CONFIG_MISSING',
'生产环境缺少 SUPER_ADMIN_INITIAL_PASSWORD,不能使用公开默认密码初始化超级管理员',
500,
);
}
return SUPER_ADMIN_INITIAL_PASSWORD; return SUPER_ADMIN_INITIAL_PASSWORD;
} }
} }
+13 -3
View File
@@ -13,9 +13,19 @@ const prisma = new PrismaClient();
const passwordService = new PasswordService(); const passwordService = new PasswordService();
async function main(): Promise<void> { async function main(): Promise<void> {
const password = process.env.SUPER_ADMIN_RESET_PASSWORD?.trim() const configuredPassword = process.env.SUPER_ADMIN_RESET_PASSWORD?.trim();
? process.env.SUPER_ADMIN_RESET_PASSWORD.trim() const password =
: SUPER_ADMIN_INITIAL_PASSWORD; configuredPassword ||
(process.env.NODE_ENV === 'production'
? undefined
: SUPER_ADMIN_INITIAL_PASSWORD);
if (!password) {
throw new Error(
'生产环境缺少 SUPER_ADMIN_RESET_PASSWORD,不能使用公开默认密码重置超级管理员',
);
}
const passwordHash = await passwordService.hashPassword(password); const passwordHash = await passwordService.hashPassword(password);
await prisma.user.upsert({ await prisma.user.upsert({
@@ -52,6 +52,8 @@ describe('DeployExecutionService', () => {
matchedBuildNumber?: number; matchedBuildNumber?: number;
buildResult?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE'; buildResult?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE';
buildRunning?: boolean; buildRunning?: boolean;
buildTimestamp?: string;
buildDurationMs?: number;
logText?: string; logText?: string;
}) { }) {
const repository = createRepository(); const repository = createRepository();
@@ -135,6 +137,8 @@ describe('DeployExecutionService', () => {
building: options.buildRunning ?? false, building: options.buildRunning ?? false,
result: options.buildResult, result: options.buildResult,
url: `https://jenkins.example/job/${buildNumber}/`, url: `https://jenkins.example/job/${buildNumber}/`,
timestamp: options.buildTimestamp,
durationMs: options.buildDurationMs,
}), }),
), ),
getProgressiveText: jest.fn(() => getProgressiveText: jest.fn(() =>
@@ -279,6 +283,8 @@ describe('DeployExecutionService', () => {
queueId: 'queue_success', queueId: 'queue_success',
queueBuildNumber: 18, queueBuildNumber: 18,
buildResult: 'SUCCESS', buildResult: 'SUCCESS',
buildTimestamp: '2026-06-12T08:00:00.000Z',
buildDurationMs: 90_000,
}); });
const run = await service.execute( const run = await service.execute(
await createRun(repository, 'run_success_001'), await createRun(repository, 'run_success_001'),
@@ -289,6 +295,7 @@ describe('DeployExecutionService', () => {
expect(result.status).toBe('success'); expect(result.status).toBe('success');
expect(result.jenkinsBuildNumber).toBe(18); expect(result.jenkinsBuildNumber).toBe(18);
expect(result.finishedAt).toBe('2026-06-12T08:01:30.000Z');
expect(step(result, 'jenkins-build')?.status).toBe('success'); expect(step(result, 'jenkins-build')?.status).toBe('success');
expect(step(result, 'health-check')?.status).toBe('success'); expect(step(result, 'health-check')?.status).toBe('success');
expect(step(result, 'notify-wecom')?.status).toBe('success'); expect(step(result, 'notify-wecom')?.status).toBe('success');
+21 -2
View File
@@ -235,6 +235,8 @@ export class DeployExecutionService {
currentRun, currentRun,
buildStatus.result, buildStatus.result,
environment.jenkinsJobPath, environment.jenkinsJobPath,
undefined,
this.jenkinsBuildFinishedAt(buildStatus),
); );
} }
@@ -680,6 +682,7 @@ export class DeployExecutionService {
private async completeSuccessfulJenkinsRun( private async completeSuccessfulJenkinsRun(
run: DeployRunSummary, run: DeployRunSummary,
finishedAt?: string,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
let currentRun = await this.completeStep(run, 'jenkins-build', 'success'); let currentRun = await this.completeStep(run, 'jenkins-build', 'success');
currentRun = await this.completeStep(currentRun, 'health-check', 'success'); currentRun = await this.completeStep(currentRun, 'health-check', 'success');
@@ -695,6 +698,7 @@ export class DeployExecutionService {
currentRun.id, currentRun.id,
{ {
status: 'success', status: 'success',
finishedAt,
}, },
currentRun.version, currentRun.version,
), ),
@@ -720,6 +724,7 @@ export class DeployExecutionService {
result: Exclude<JenkinsTerminalResult, 'SUCCESS'>, result: Exclude<JenkinsTerminalResult, 'SUCCESS'>,
jobPath: string, jobPath: string,
logText?: string, logText?: string,
finishedAt?: string,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
const logExcerpt = logText const logExcerpt = logText
? this.toLogExcerpt(logText) ? this.toLogExcerpt(logText)
@@ -743,6 +748,7 @@ export class DeployExecutionService {
{ {
status: 'failed', status: 'failed',
failureSummary, failureSummary,
finishedAt,
}, },
currentRun.version, currentRun.version,
)) ?? currentRun; )) ?? currentRun;
@@ -837,12 +843,25 @@ export class DeployExecutionService {
result: JenkinsTerminalResult, result: JenkinsTerminalResult,
jobPath: string, jobPath: string,
logText?: string, logText?: string,
finishedAt?: string,
): Promise<DeployRunSummary> { ): Promise<DeployRunSummary> {
if (result === 'SUCCESS') { if (result === 'SUCCESS') {
return this.completeSuccessfulJenkinsRun(run); return this.completeSuccessfulJenkinsRun(run, finishedAt);
} }
return this.failJenkinsRun(run, result, jobPath, logText); return this.failJenkinsRun(run, result, jobPath, logText, finishedAt);
}
private jenkinsBuildFinishedAt(
build: Pick<JenkinsBuildStatus, 'timestamp' | 'durationMs'>,
): string | undefined {
if (!build.timestamp || typeof build.durationMs !== 'number') {
return undefined;
}
return new Date(
new Date(build.timestamp).getTime() + build.durationMs,
).toISOString();
} }
private extractJenkinsFinishedResult( private extractJenkinsFinishedResult(
+47
View File
@@ -104,6 +104,7 @@ const triggerToPrisma: Record<
manual: PrismaDeployRunTrigger.MANUAL, manual: PrismaDeployRunTrigger.MANUAL,
gitea_webhook: PrismaDeployRunTrigger.GITEA_WEBHOOK, gitea_webhook: PrismaDeployRunTrigger.GITEA_WEBHOOK,
retry: PrismaDeployRunTrigger.RETRY, retry: PrismaDeployRunTrigger.RETRY,
jenkins_external: PrismaDeployRunTrigger.JENKINS_EXTERNAL,
}; };
@Injectable() @Injectable()
@@ -151,6 +152,52 @@ export class DeployRunRepository {
return run ? this.toSummary(run) : null; return run ? this.toSummary(run) : null;
} }
async findByJenkinsBuild(
projectKey: string,
environment: DeployRunSummary['environment'],
buildNumber: number,
): Promise<DeployRunSummary | null> {
if (!this.useDatabase()) {
return (
this.memoryRuns.find(
(run) =>
run.projectKey === projectKey &&
run.environment === environment &&
run.jenkinsBuildNumber === buildNumber,
) ?? null
);
}
const run = await this.prisma.deployRun.findFirst({
where: {
project: {
key: projectKey,
},
environment: {
name:
environment === 'test'
? EnvironmentKind.TEST
: EnvironmentKind.PRODUCTION,
},
jenkinsBuildNumber: buildNumber,
},
include: {
project: true,
environment: true,
steps: {
orderBy: {
order: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return run ? this.toSummary(run) : null;
}
async create(input: CreateDeployRunInput): Promise<CreateDeployRunResult> { async create(input: CreateDeployRunInput): Promise<CreateDeployRunResult> {
const idempotencyKey = this.resolveIdempotencyKey(input); const idempotencyKey = this.resolveIdempotencyKey(input);
+25 -1
View File
@@ -28,7 +28,7 @@ export type DeployRunSummary = {
environment: DeployEnvironment; environment: DeployEnvironment;
ref: string; ref: string;
status: DeployRunStatus; status: DeployRunStatus;
trigger: 'manual' | 'gitea_webhook' | 'retry'; trigger: 'manual' | 'gitea_webhook' | 'retry' | 'jenkins_external';
operator: string; operator: string;
jenkinsQueueId?: string; jenkinsQueueId?: string;
jenkinsBuildNumber?: number; jenkinsBuildNumber?: number;
@@ -70,6 +70,30 @@ export type DeployRunJenkinsSyncSummary = {
failedRuns: DeployRunJenkinsSyncError[]; failedRuns: DeployRunJenkinsSyncError[];
}; };
export type DeployRunJenkinsImportSkippedBuild = {
projectKey: string;
environment: DeployEnvironment;
buildNumber: number;
reason: 'already_imported' | 'linked_platform_run' | 'parameter_mismatch';
};
export type DeployRunJenkinsImportFailedBuild = {
projectKey: string;
environment: DeployEnvironment;
buildNumber?: number;
message: string;
};
export type DeployRunJenkinsImportSummary = {
checkedAt: string;
totalJobs: number;
totalBuilds: number;
importedRuns: DeployRunSummary[];
syncedRuns: DeployRunSummary[];
skippedBuilds: DeployRunJenkinsImportSkippedBuild[];
failedBuilds: DeployRunJenkinsImportFailedBuild[];
};
export type DeployRunJenkinsLog = { export type DeployRunJenkinsLog = {
runId: string; runId: string;
jobPath: string; jobPath: string;
+11
View File
@@ -26,6 +26,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { PasswordChangeGuard } from '../auth/password-change.guard'; import { PasswordChangeGuard } from '../auth/password-change.guard';
import { SuperAdminGuard } from '../auth/super-admin.guard'; import { SuperAdminGuard } from '../auth/super-admin.guard';
import { import {
DeployRunJenkinsImportSummary,
DeployRunJenkinsLog, DeployRunJenkinsLog,
DeployRunJenkinsSyncSummary, DeployRunJenkinsSyncSummary,
DeployRunSummary, DeployRunSummary,
@@ -110,6 +111,16 @@ export class DeployRunsController {
return this.deployRunsService.syncJenkinsRuns(user); return this.deployRunsService.syncJenkinsRuns(user);
} }
@Post('import-jenkins')
@UseGuards(SuperAdminGuard)
@AdminOnlyMessage('无权限导入 Jenkins 直构记录')
@ApiOkResponse({ description: '扫描已纳管 Jenkins job 并导入平台外直构记录。' })
async importJenkinsBuilds(
@CurrentUser() user: AuthenticatedUser,
): Promise<DeployRunJenkinsImportSummary> {
return this.deployRunsService.importJenkinsBuilds(user);
}
@Post(':id/sync-jenkins') @Post(':id/sync-jenkins')
@ApiOkResponse({ description: '同步单个发布单的 Jenkins 队列或构建状态。' }) @ApiOkResponse({ description: '同步单个发布单的 Jenkins 队列或构建状态。' })
async syncJenkinsRun( async syncJenkinsRun(
+205 -26
View File
@@ -9,7 +9,6 @@ import { DeployExecutionService } from './deploy-execution.service';
import { DeployRunRepository } from './deploy-run.repository'; import { DeployRunRepository } from './deploy-run.repository';
import { import {
CreateDeployRunInput, CreateDeployRunInput,
DeployRunStatus,
DeployRunSummary, DeployRunSummary,
} from './deploy-run.types'; } from './deploy-run.types';
import { DeployRunsService } from './deploy-runs.service'; import { DeployRunsService } from './deploy-runs.service';
@@ -60,9 +59,23 @@ describe('DeployRunsService', () => {
findById: jest.fn((id: string) => findById: jest.fn((id: string) =>
Promise.resolve(runs.find((run) => run.id === id) ?? null), Promise.resolve(runs.find((run) => run.id === id) ?? null),
), ),
create: jest.fn((input: CreateDeployRunInput) => findByJenkinsBuild: jest.fn(
Promise.resolve({ (
run: { projectKey: string,
environment: DeployRunSummary['environment'],
buildNumber: number,
) =>
Promise.resolve(
runs.find(
(run) =>
run.projectKey === projectKey &&
run.environment === environment &&
run.jenkinsBuildNumber === buildNumber,
) ?? null,
),
),
create: jest.fn((input: CreateDeployRunInput) => {
const run: DeployRunSummary = {
...baseRun, ...baseRun,
id: input.idempotencyKey ?? 'generated_run', id: input.idempotencyKey ?? 'generated_run',
projectKey: input.projectKey, projectKey: input.projectKey,
@@ -70,37 +83,58 @@ describe('DeployRunsService', () => {
ref: input.ref, ref: input.ref,
operator: input.operator, operator: input.operator,
trigger: input.trigger ?? 'manual', trigger: input.trigger ?? 'manual',
}, };
runs.unshift(run);
return Promise.resolve({
run,
created: true, created: true,
});
}), }),
), updateRun: jest.fn((id: string, patch: Partial<DeployRunSummary>) => {
updateRun: jest.fn((id: string, patch: { status?: DeployRunStatus }) => const index = runs.findIndex((run) => run.id === id);
Promise.resolve(
runs.some((run) => run.id === id) if (index === -1) {
? { return Promise.resolve(null);
...(runs.find((run) => run.id === id) ?? baseRun), }
const updated = {
...runs[index],
...patch, ...patch,
version: runs[index].version + 1,
} satisfies DeployRunSummary;
runs[index] = updated;
return Promise.resolve(updated);
}),
updateStep: jest.fn((id: string) => {
const index = runs.findIndex((run) => run.id === id);
if (index === -1) {
return Promise.resolve(null);
} }
: null,
), const updated = {
), ...runs[index],
updateStep: jest.fn((id: string) => version: runs[index].version + 1,
Promise.resolve( } satisfies DeployRunSummary;
runs.some((run) => run.id === id) runs[index] = updated;
? {
...(runs.find((run) => run.id === id) ?? baseRun), return Promise.resolve(updated);
status: 'canceled' as const, }),
}
: null,
),
),
} satisfies Pick< } satisfies Pick<
DeployRunRepository, DeployRunRepository,
'findMany' | 'findById' | 'create' | 'updateRun' | 'updateStep' | 'findMany'
| 'findById'
| 'findByJenkinsBuild'
| 'create'
| 'updateRun'
| 'updateStep'
>; >;
const projects = { const projects = {
getProject: jest.fn(() => Promise.resolve(resolvedProject)), getProject: jest.fn(() => Promise.resolve(resolvedProject)),
} satisfies Pick<ProjectsService, 'getProject'>; listProjects: jest.fn(() => Promise.resolve([resolvedProject])),
} satisfies Pick<ProjectsService, 'getProject' | 'listProjects'>;
const audit = { const audit = {
record: jest.fn(), record: jest.fn(),
} satisfies Pick<AuditService, 'record'>; } satisfies Pick<AuditService, 'record'>;
@@ -165,6 +199,11 @@ describe('DeployRunsService', () => {
); );
}, },
), ),
listRecentBuilds: jest.fn(() =>
Promise.resolve(
[] as Awaited<ReturnType<JenkinsClient['listRecentBuilds']>>,
),
),
getProgressiveText: jest.fn(() => getProgressiveText: jest.fn(() =>
Promise.resolve({ Promise.resolve({
text: 'build log', text: 'build log',
@@ -179,6 +218,7 @@ describe('DeployRunsService', () => {
| 'stopBuild' | 'stopBuild'
| 'findBuildByQueueId' | 'findBuildByQueueId'
| 'findLatestBuildByParameter' | 'findLatestBuildByParameter'
| 'listRecentBuilds'
| 'getProgressiveText' | 'getProgressiveText'
>; >;
const auth = { const auth = {
@@ -829,4 +869,143 @@ describe('DeployRunsService', () => {
}, },
]); ]);
}); });
it('imports a direct Jenkins build and syncs it through the deploy state machine', async () => {
const { audit, execution, jenkins, repository, service } = createService({
runs: [],
});
jenkins.listRecentBuilds.mockResolvedValueOnce([
{
jobPath: 'my-project-dev/test-access-manage-develop',
buildNumber: 33,
queueId: 'queue_033',
building: false,
result: 'SUCCESS',
url: 'https://jenkins.example/job/my-project-dev/job/test-access-manage-develop/33/',
timestamp: '2026-06-12T06:20:00.000Z',
parameters: {
GIT_REF: 'feature/jenkins-direct',
DEPLOY_ENVIRONMENT: 'test',
BUILD_USER_ID: 'jenkins-user',
},
},
]);
const result = await service.importJenkinsBuilds({
id: 'user_admin',
account: 'ops-admin',
displayName: 'ops-admin',
role: 'super_admin',
status: 'active',
mustChangePassword: false,
passwordVersion: 1,
});
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
projectKey: 'access-manage',
environment: 'test',
ref: 'feature/jenkins-direct',
operator: 'jenkins-user',
trigger: 'jenkins_external',
idempotencyKey: 'jenkins-external:access-manage:test:33',
}),
);
expect(repository.updateStep).toHaveBeenCalledWith(
'jenkins-external:access-manage:test:33',
'gitea-check',
expect.objectContaining({
status: 'skipped',
}),
expect.any(Number),
);
expect(repository.updateRun).toHaveBeenCalledWith(
'jenkins-external:access-manage:test:33',
expect.objectContaining({
status: 'running',
jenkinsQueueId: 'queue_033',
jenkinsBuildNumber: 33,
}),
expect.any(Number),
);
expect(execution.syncJenkinsRun).toHaveBeenCalledTimes(1);
expect(result.importedRuns).toHaveLength(1);
expect(result.syncedRuns).toHaveLength(1);
expect(result.failedBuilds).toHaveLength(0);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'JENKINS_EXTERNAL_BUILD_IMPORTED',
parameterDigest: {
parameterNames: ['BUILD_USER_ID', 'DEPLOY_ENVIRONMENT', 'GIT_REF'],
},
}),
);
});
it('skips an already represented Jenkins build and syncs the existing run', async () => {
const runningRun: DeployRunSummary = {
...baseRun,
id: 'run_existing',
status: 'running',
jenkinsBuildNumber: 41,
};
const { execution, jenkins, repository, service } = createService({
runs: [runningRun],
});
jenkins.listRecentBuilds.mockResolvedValueOnce([
{
jobPath: 'my-project-dev/test-access-manage-develop',
buildNumber: 41,
building: true,
parameters: {
GIT_REF: 'develop',
},
},
]);
const result = await service.importJenkinsBuilds();
expect(repository.create).not.toHaveBeenCalled();
expect(execution.syncJenkinsRun).toHaveBeenCalledWith(
runningRun,
expect.objectContaining({ key: 'access-manage' }),
);
expect(result.importedRuns).toHaveLength(0);
expect(result.skippedBuilds).toEqual([
{
projectKey: 'access-manage',
environment: 'test',
buildNumber: 41,
reason: 'already_imported',
},
]);
});
it('skips Jenkins builds whose platform parameters point to another environment', async () => {
const { jenkins, repository, service } = createService();
jenkins.listRecentBuilds.mockResolvedValueOnce([
{
jobPath: 'my-project-dev/test-access-manage-develop',
buildNumber: 52,
building: false,
result: 'SUCCESS',
parameters: {
PROJECT_KEY: 'access-manage',
DEPLOY_ENVIRONMENT: 'production',
},
},
]);
const result = await service.importJenkinsBuilds();
expect(repository.create).not.toHaveBeenCalled();
expect(result.skippedBuilds).toEqual([
{
projectKey: 'access-manage',
environment: 'test',
buildNumber: 52,
reason: 'parameter_mismatch',
},
]);
});
}); });
+391 -1
View File
@@ -11,7 +11,10 @@ import {
DeployNotificationEvent, DeployNotificationEvent,
DeployNotificationService, DeployNotificationService,
} from '../notifications/wecom/deploy-notification.service'; } from '../notifications/wecom/deploy-notification.service';
import { JenkinsCancelResult } from '../integrations/jenkins/jenkins.types'; import {
JenkinsCancelResult,
JenkinsRecentBuild,
} from '../integrations/jenkins/jenkins.types';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { import {
ProjectEnvironmentSummary, ProjectEnvironmentSummary,
@@ -22,6 +25,8 @@ import { DeployExecutionService } from './deploy-execution.service';
import { DeployRunRepository } from './deploy-run.repository'; import { DeployRunRepository } from './deploy-run.repository';
import { import {
CreateDeployRunInput, CreateDeployRunInput,
DeployEnvironment,
DeployRunJenkinsImportSummary,
DeployRunJenkinsLog, DeployRunJenkinsLog,
DeployRunJenkinsSyncSummary, DeployRunJenkinsSyncSummary,
DeployRunSummary, DeployRunSummary,
@@ -34,6 +39,22 @@ type ReadRunsOptions = {
const jenkinsSyncConflictRetryCount = 2; const jenkinsSyncConflictRetryCount = 2;
const jenkinsLogBuildLinkMaxAttempts = 5; const jenkinsLogBuildLinkMaxAttempts = 5;
const jenkinsLogBuildLinkRetryDelayMs = 1000; const jenkinsLogBuildLinkRetryDelayMs = 1000;
const jenkinsExternalBuildScanLimit = 20;
const jenkinsExternalOperator = 'jenkins-direct';
const jenkinsExternalRefParameterNames = [
'GIT_REF',
'DEPLOY_REF',
'BRANCH_NAME',
'TAG_NAME',
'REF',
];
const jenkinsExternalOperatorParameterNames = [
'OPERATOR',
'BUILD_USER_ID',
'BUILD_USER',
'BUILD_USER_EMAIL',
'CAUSE_USER',
];
const escapeControl = String.fromCharCode(27); const escapeControl = String.fromCharCode(27);
const jenkinsConsoleNotePattern = new RegExp( const jenkinsConsoleNotePattern = new RegExp(
`${escapeControl}\\[8mha:/{4}[A-Za-z0-9+/=]+${escapeControl}\\[0m`, `${escapeControl}\\[8mha:/{4}[A-Za-z0-9+/=]+${escapeControl}\\[0m`,
@@ -400,6 +421,131 @@ export class DeployRunsService {
}; };
} }
async importJenkinsBuilds(
user?: AuthenticatedUser,
): Promise<DeployRunJenkinsImportSummary> {
if (user) {
this.authService.assertSuperAdmin(user, '无权限导入 Jenkins 直构记录');
}
const projects = await this.projectsService.listProjects();
const importedRuns: DeployRunSummary[] = [];
const syncedRuns: DeployRunSummary[] = [];
const skippedBuilds: DeployRunJenkinsImportSummary['skippedBuilds'] = [];
const failedBuilds: DeployRunJenkinsImportSummary['failedBuilds'] = [];
let totalJobs = 0;
let totalBuilds = 0;
for (const project of projects) {
for (const environment of project.environments) {
if (!environment.jenkinsJobPath) {
continue;
}
totalJobs += 1;
let builds: JenkinsRecentBuild[];
try {
builds = await this.jenkinsClient.listRecentBuilds(
environment.jenkinsJobPath,
jenkinsExternalBuildScanLimit,
);
totalBuilds += builds.length;
} catch (error) {
failedBuilds.push({
projectKey: project.key,
environment: environment.name,
message: this.toSafeSyncError(error),
});
continue;
}
for (const build of builds) {
try {
const mismatchReason = this.externalBuildParameterMismatch(
project,
environment,
build,
);
if (mismatchReason) {
skippedBuilds.push({
projectKey: project.key,
environment: environment.name,
buildNumber: build.buildNumber,
reason: 'parameter_mismatch',
});
continue;
}
const existing = await this.findExistingJenkinsBuildRun(
project,
environment,
build,
);
if (existing) {
skippedBuilds.push({
projectKey: project.key,
environment: environment.name,
buildNumber: build.buildNumber,
reason: existing.reason,
});
const syncedRun = await this.syncLinkedJenkinsRun(
existing.run,
project,
);
syncedRuns.push(syncedRun);
continue;
}
let run = await this.createExternalJenkinsRun(
project,
environment,
build,
);
importedRuns.push(run);
run = await this.syncJenkinsRunWithRetry(run, project);
syncedRuns.push(run);
} catch (error) {
failedBuilds.push({
projectKey: project.key,
environment: environment.name,
buildNumber: build.buildNumber,
message: this.toSafeSyncError(error),
});
}
}
}
}
await this.auditService.record({
action: 'JENKINS_EXTERNAL_IMPORT_COMPLETED',
resourceType: 'deploy_run',
actorName: user?.account,
after: {
totalJobs,
totalBuilds,
importedCount: importedRuns.length,
syncedCount: syncedRuns.length,
skippedCount: skippedBuilds.length,
failedCount: failedBuilds.length,
},
});
return {
checkedAt: new Date().toISOString(),
totalJobs,
totalBuilds,
importedRuns,
syncedRuns,
skippedBuilds,
failedBuilds,
};
}
private async recordNotificationResult( private async recordNotificationResult(
run: DeployRunSummary, run: DeployRunSummary,
event: DeployNotificationEvent, event: DeployNotificationEvent,
@@ -428,6 +574,250 @@ export class DeployRunsService {
); );
} }
private externalBuildParameterMismatch(
project: ProjectSummary,
environment: ProjectEnvironmentSummary,
build: JenkinsRecentBuild,
): string | undefined {
const parameterProject = this.trimOptional(
build.parameters.PROJECT_KEY,
);
const parameterEnvironment = this.normalizeDeployEnvironment(
build.parameters.DEPLOY_ENVIRONMENT,
);
if (parameterProject && parameterProject !== project.key) {
return `PROJECT_KEY=${parameterProject}`;
}
if (parameterEnvironment && parameterEnvironment !== environment.name) {
return `DEPLOY_ENVIRONMENT=${parameterEnvironment}`;
}
return undefined;
}
private async findExistingJenkinsBuildRun(
project: ProjectSummary,
environment: ProjectEnvironmentSummary,
build: JenkinsRecentBuild,
): Promise<
| {
run: DeployRunSummary;
reason: 'already_imported' | 'linked_platform_run';
}
| undefined
> {
const deployRunId = this.trimOptional(build.parameters.DEPLOY_RUN_ID);
if (deployRunId) {
const linkedRun = await this.deployRunRepository.findById(deployRunId);
if (linkedRun) {
return {
run: linkedRun,
reason: 'linked_platform_run',
};
}
}
const existingRun = await this.deployRunRepository.findByJenkinsBuild(
project.key,
environment.name,
build.buildNumber,
);
if (!existingRun) {
return undefined;
}
return {
run: existingRun,
reason: 'already_imported',
};
}
private async createExternalJenkinsRun(
project: ProjectSummary,
environment: ProjectEnvironmentSummary,
build: JenkinsRecentBuild,
): Promise<DeployRunSummary> {
const inferredRef = this.inferExternalBuildRef(project, environment, build);
const creation = await this.deployRunRepository.create({
projectKey: project.key,
environment: environment.name,
ref: inferredRef.ref,
operator: this.inferExternalBuildOperator(build),
remark: `Jenkins 直构 #${build.buildNumber} 导入,ref 来源:${inferredRef.source}`,
idempotencyKey: this.externalJenkinsIdempotencyKey(
project.key,
environment.name,
build.buildNumber,
),
trigger: 'jenkins_external',
});
let run = creation.run;
if (!creation.created) {
return run;
}
run = this.requireRun(
run.id,
await this.deployRunRepository.updateStep(
run.id,
'gitea-check',
{
status: 'skipped',
message: 'Jenkins 直接构建导入,平台未执行 Gitea ref 校验。',
},
run.version,
),
);
run = this.requireRun(
run.id,
await this.deployRunRepository.updateRun(
run.id,
{
status: 'running',
jenkinsQueueId: build.queueId,
jenkinsBuildNumber: build.buildNumber,
jenkinsBuildUrl: build.url,
startedAt: build.timestamp,
},
run.version,
),
);
run = this.requireRun(
run.id,
await this.deployRunRepository.updateStep(
run.id,
'jenkins-build',
{
status: 'running',
message: build.result
? `Jenkins 直构 #${build.buildNumber} 已完成,正在回收结果。`
: `Jenkins 直构 #${build.buildNumber} 正在运行。`,
},
run.version,
),
);
await this.auditService.record({
action: 'JENKINS_EXTERNAL_BUILD_IMPORTED',
resourceType: 'deploy_run',
resourceId: run.id,
actorName: run.operator,
after: {
projectKey: project.key,
environment: environment.name,
jobPath: environment.jenkinsJobPath,
buildNumber: build.buildNumber,
buildUrl: build.url,
queueId: build.queueId,
ref: inferredRef.ref,
refSource: inferredRef.source,
},
parameterDigest: {
parameterNames: Object.keys(build.parameters).sort(),
},
});
return run;
}
private async syncLinkedJenkinsRun(
run: DeployRunSummary,
project: ProjectSummary,
): Promise<DeployRunSummary> {
if (['success', 'failed', 'canceled'].includes(run.status)) {
return run;
}
return this.syncJenkinsRunWithRetry(run, project);
}
private inferExternalBuildRef(
project: ProjectSummary,
environment: ProjectEnvironmentSummary,
build: JenkinsRecentBuild,
): { ref: string; source: string } {
for (const parameterName of jenkinsExternalRefParameterNames) {
const parameterValue = this.trimOptional(build.parameters[parameterName]);
if (parameterValue) {
return {
ref: parameterValue,
source: parameterName,
};
}
}
if (environment.releasePolicy === 'master') {
return {
ref: 'master',
source: 'environment.releasePolicy',
};
}
const jobLeaf = environment.jenkinsJobPath.split('/').filter(Boolean).pop();
if (jobLeaf && jobLeaf.endsWith(`-${project.defaultBranch}`)) {
return {
ref: project.defaultBranch,
source: 'jenkinsJobPath',
};
}
return {
ref: project.defaultBranch,
source: 'project.defaultBranch',
};
}
private inferExternalBuildOperator(build: JenkinsRecentBuild): string {
for (const parameterName of jenkinsExternalOperatorParameterNames) {
const parameterValue = this.trimOptional(build.parameters[parameterName]);
if (parameterValue) {
return parameterValue;
}
}
return jenkinsExternalOperator;
}
private externalJenkinsIdempotencyKey(
projectKey: string,
environment: DeployEnvironment,
buildNumber: number,
): string {
return `jenkins-external:${projectKey}:${environment}:${buildNumber}`;
}
private normalizeDeployEnvironment(
value: string | undefined,
): DeployEnvironment | undefined {
const normalized = this.trimOptional(value)?.toLowerCase();
if (normalized === 'test' || normalized === 'testing') {
return 'test';
}
if (normalized === 'production' || normalized === 'prod') {
return 'production';
}
return undefined;
}
private trimOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
private async syncJenkinsCandidatesForRead( private async syncJenkinsCandidatesForRead(
runs: DeployRunSummary[], runs: DeployRunSummary[],
): Promise<DeployRunSummary[]> { ): Promise<DeployRunSummary[]> {
@@ -168,6 +168,63 @@ describe('JenkinsClient', () => {
}), }),
); );
}); });
it('lists recent builds with queue ids, timestamps and parameters', async () => {
const client = createClient();
jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
builds: [
{
number: 33,
queueId: 701,
url: 'https://jenkins.example/job/my-project-dev/job/test-access-manage-develop/33/',
building: true,
result: null,
timestamp: 1781245200000,
duration: 120000,
actions: [
{
parameters: [
{
name: 'GIT_REF',
value: 'develop',
},
{
name: 'DEPLOY_ENVIRONMENT',
value: 'test',
},
],
},
],
},
],
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
);
const builds = await client.listRecentBuilds(
'my-project-dev/test-access-manage-develop',
);
expect(builds).toEqual([
expect.objectContaining({
buildNumber: 33,
queueId: '701',
building: true,
timestamp: '2026-06-12T06:20:00.000Z',
durationMs: 120000,
parameters: {
GIT_REF: 'develop',
DEPLOY_ENVIRONMENT: 'test',
},
}),
]);
});
}); });
function createClient(): JenkinsClient { function createClient(): JenkinsClient {
@@ -17,6 +17,7 @@ import {
JenkinsJobInfo, JenkinsJobInfo,
JenkinsProgressiveText, JenkinsProgressiveText,
JenkinsQueueItem, JenkinsQueueItem,
JenkinsRecentBuild,
} from './jenkins.types'; } from './jenkins.types';
type JenkinsConfig = { type JenkinsConfig = {
@@ -135,6 +136,8 @@ export class JenkinsClient {
building?: boolean; building?: boolean;
result?: JenkinsBuildStatus['result']; result?: JenkinsBuildStatus['result'];
url?: string; url?: string;
timestamp?: number;
duration?: number;
}; };
return { return {
@@ -143,6 +146,9 @@ export class JenkinsClient {
building: Boolean(payload.building), building: Boolean(payload.building),
result: payload.result, result: payload.result,
url: payload.url, url: payload.url,
timestamp: this.toJenkinsTimestamp(payload.timestamp),
durationMs:
typeof payload.duration === 'number' ? payload.duration : undefined,
}; };
} }
@@ -238,6 +244,58 @@ export class JenkinsClient {
return undefined; return undefined;
} }
async listRecentBuilds(
jobPath: string,
limit = 20,
): Promise<JenkinsRecentBuild[]> {
this.assertConfigured();
const normalizedLimit = Math.min(Math.max(Math.trunc(limit), 1), 50);
const tree = `builds[number,url,building,result,queueId,timestamp,duration,actions[parameters[name,value]]]{0,${normalizedLimit}}`;
const response = await this.request(
`${this.jobPath(jobPath)}/api/json?tree=${encodeURIComponent(tree)}`,
);
const payload = (await response.json()) as {
builds?: Array<{
number?: number;
queueId?: number | string;
url?: string;
building?: boolean;
result?: JenkinsBuildStatus['result'];
timestamp?: number;
duration?: number;
actions?: Array<{
parameters?: Array<{ name?: string; value?: unknown }>;
}>;
}>;
};
return (payload.builds ?? [])
.filter((build) => typeof build.number === 'number')
.slice(0, normalizedLimit)
.map((build) => ({
jobPath,
buildNumber: build.number as number,
building: Boolean(build.building),
result: build.result,
url: build.url,
queueId:
build.queueId === undefined || build.queueId === null
? undefined
: String(build.queueId),
timestamp:
this.toJenkinsTimestamp(build.timestamp),
durationMs:
typeof build.duration === 'number' ? build.duration : undefined,
parameters: this.toJenkinsParameterMap(build.actions),
}));
}
private toJenkinsTimestamp(timestamp: number | undefined): string | undefined {
return typeof timestamp === 'number'
? new Date(timestamp).toISOString()
: undefined;
}
private jenkinsParameterValueToString(value: unknown): string { private jenkinsParameterValueToString(value: unknown): string {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return ''; return '';
@@ -255,6 +313,26 @@ export class JenkinsClient {
return JSON.stringify(value) ?? ''; return JSON.stringify(value) ?? '';
} }
private toJenkinsParameterMap(
actions: Array<{
parameters?: Array<{ name?: string; value?: unknown }>;
}> = [],
): Record<string, string> {
return actions
.flatMap((action) => action.parameters ?? [])
.reduce<Record<string, string>>((result, parameter) => {
if (!parameter.name) {
return result;
}
result[parameter.name] = this.jenkinsParameterValueToString(
parameter.value,
);
return result;
}, {});
}
async getJobInfo(jobPath: string): Promise<JenkinsJobInfo> { async getJobInfo(jobPath: string): Promise<JenkinsJobInfo> {
this.assertConfigured(); this.assertConfigured();
const response = await this.request( const response = await this.request(
@@ -10,6 +10,8 @@ export type JenkinsBuildStatus = {
building: boolean; building: boolean;
result?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE'; result?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE';
url?: string; url?: string;
timestamp?: string;
durationMs?: number;
}; };
export type JenkinsBuildParameterMatch = JenkinsBuildStatus & { export type JenkinsBuildParameterMatch = JenkinsBuildStatus & {
@@ -21,6 +23,12 @@ export type JenkinsBuildQueueMatch = JenkinsBuildStatus & {
queueId: string; queueId: string;
}; };
export type JenkinsRecentBuild = JenkinsBuildStatus & {
queueId?: string;
timestamp?: string;
parameters: Record<string, string>;
};
export type JenkinsJobInfo = { export type JenkinsJobInfo = {
jobPath: string; jobPath: string;
name?: string; name?: string;