diff --git a/src/integrations/jenkins/jenkins.client.ts b/src/integrations/jenkins/jenkins.client.ts index 5cb48c3..317497f 100644 --- a/src/integrations/jenkins/jenkins.client.ts +++ b/src/integrations/jenkins/jenkins.client.ts @@ -12,6 +12,7 @@ import { EnvConfig } from '../../config/env.schema'; import { JenkinsBuildStatus, JenkinsCancelResult, + JenkinsJobInfo, JenkinsProgressiveText, JenkinsQueueItem, } from './jenkins.types'; @@ -121,6 +122,24 @@ export class JenkinsClient { }; } + async getJobInfo(jobPath: string): Promise { + this.assertConfigured(); + const response = await this.request( + `${this.jobPath(jobPath)}/api/json?tree=name,fullName,url,color,buildable,lastBuild[number,result,building,url]`, + ); + const payload = (await response.json()) as Omit; + + return { + jobPath, + name: payload.name, + fullName: payload.fullName, + url: payload.url, + color: payload.color, + buildable: payload.buildable, + lastBuild: payload.lastBuild, + }; + } + async getProgressiveText( jobPath: string, buildNumber: number, diff --git a/src/integrations/jenkins/jenkins.types.ts b/src/integrations/jenkins/jenkins.types.ts index e9bf793..1b0acd7 100644 --- a/src/integrations/jenkins/jenkins.types.ts +++ b/src/integrations/jenkins/jenkins.types.ts @@ -12,6 +12,21 @@ export type JenkinsBuildStatus = { url?: string; }; +export type JenkinsJobInfo = { + jobPath: string; + name?: string; + fullName?: string; + url?: string; + color?: string; + buildable?: boolean; + lastBuild?: { + number?: number; + result?: JenkinsBuildStatus['result']; + building?: boolean; + url?: string; + }; +}; + export type JenkinsProgressiveText = { text: string; nextStart: number; diff --git a/src/projects/project.types.ts b/src/projects/project.types.ts index 1a778ca..7efebea 100644 --- a/src/projects/project.types.ts +++ b/src/projects/project.types.ts @@ -47,3 +47,24 @@ export type ProjectRefSummary = { branches: ProjectRefItemSummary[]; tags: ProjectRefItemSummary[]; }; + +export type ProjectJenkinsJobDiagnostic = { + projectKey: string; + environment: ProjectEnvironmentSummary['name']; + jenkinsJobPath: string; + status: 'ok' | 'missing' | 'not_configured' | 'unavailable'; + message?: string; + buildable?: boolean; + url?: string; + lastBuild?: { + number?: number; + result?: 'SUCCESS' | 'FAILURE' | 'ABORTED' | 'UNSTABLE'; + building?: boolean; + url?: string; + }; +}; + +export type ProjectJenkinsDiagnosticsSummary = { + checkedAt: string; + jobs: ProjectJenkinsJobDiagnostic[]; +}; diff --git a/src/projects/projects.controller.ts b/src/projects/projects.controller.ts index d2a9516..db829c4 100644 --- a/src/projects/projects.controller.ts +++ b/src/projects/projects.controller.ts @@ -4,7 +4,11 @@ import { AuthGuard } from '../auth/auth.guard'; import { AuthenticatedUser } from '../auth/auth.types'; import { CurrentUser } from '../auth/current-user.decorator'; import { PasswordChangeGuard } from '../auth/password-change.guard'; -import { ProjectRefSummary, ProjectSummary } from './project.types'; +import { + ProjectJenkinsDiagnosticsSummary, + ProjectRefSummary, + ProjectSummary, +} from './project.types'; import { ProjectsService } from './projects.service'; @ApiTags('项目') @@ -21,6 +25,14 @@ export class ProjectsController { return this.projectsService.listProjects(user); } + @Get('jenkins-diagnostics') + @ApiOkResponse({ description: '查询当前账号可访问项目的 Jenkins job 可达性。' }) + async getJenkinsDiagnostics( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.projectsService.getJenkinsDiagnostics(user); + } + @Get(':key') @ApiOkResponse({ description: '查询项目详情。' }) async getProject( diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 845a270..41741a9 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { GiteaModule } from '../integrations/gitea/gitea.module'; +import { JenkinsModule } from '../integrations/jenkins/jenkins.module'; import { ProjectRepository } from './project.repository'; import { ProjectsController } from './projects.controller'; import { ProjectsService } from './projects.service'; @Module({ - imports: [AuthModule, GiteaModule], + imports: [AuthModule, GiteaModule, JenkinsModule], controllers: [ProjectsController], providers: [ProjectRepository, ProjectsService], exports: [ProjectRepository, ProjectsService], diff --git a/src/projects/projects.service.ts b/src/projects/projects.service.ts index 2ab6775..00317fa 100644 --- a/src/projects/projects.service.ts +++ b/src/projects/projects.service.ts @@ -2,13 +2,20 @@ import { Injectable } from '@nestjs/common'; import { AuthService } from '../auth/auth.service'; import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; +import { IntegrationRequestError } from '../common/errors/integration-error'; import { GiteaClient } from '../integrations/gitea/gitea.client'; import { GiteaCommitSummary, GiteaPullRequestSummary, } from '../integrations/gitea/gitea.types'; +import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { ProjectRepository } from './project.repository'; -import { ProjectRefSummary, ProjectSummary } from './project.types'; +import { + ProjectJenkinsDiagnosticsSummary, + ProjectJenkinsJobDiagnostic, + ProjectRefSummary, + ProjectSummary, +} from './project.types'; type ParsedRepository = { owner: string; @@ -21,6 +28,7 @@ export class ProjectsService { constructor( private readonly projectRepository: ProjectRepository, private readonly giteaClient: GiteaClient, + private readonly jenkinsClient: JenkinsClient, private readonly authService: AuthService, ) {} @@ -88,6 +96,45 @@ export class ProjectsService { return this.fallbackRefs(project); } + async getJenkinsDiagnostics( + user?: AuthenticatedUser, + ): Promise { + const projects = await this.listProjects(user); + const health = this.jenkinsClient.healthSummary(); + + if (health.status === 'not_configured') { + return { + checkedAt: new Date().toISOString(), + jobs: projects.flatMap((project) => + project.environments.map((environment) => ({ + projectKey: project.key, + environment: environment.name, + jenkinsJobPath: environment.jenkinsJobPath, + status: 'not_configured', + message: health.message, + })), + ), + }; + } + + const jobs = await Promise.all( + projects.flatMap((project) => + project.environments.map((environment) => + this.getEnvironmentJenkinsDiagnostic( + project.key, + environment.name, + environment.jenkinsJobPath, + ), + ), + ), + ); + + return { + checkedAt: new Date().toISOString(), + jobs, + }; + } + /** * Commit 摘要只作为发布前辅助信息;单个摘要读取失败不能影响 refs 主流程。 */ @@ -157,6 +204,48 @@ export class ProjectsService { } } + private async getEnvironmentJenkinsDiagnostic( + projectKey: string, + environment: ProjectJenkinsJobDiagnostic['environment'], + jenkinsJobPath: string, + ): Promise { + try { + const info = await this.jenkinsClient.getJobInfo(jenkinsJobPath); + + return { + projectKey, + environment, + jenkinsJobPath, + status: info.buildable === false ? 'unavailable' : 'ok', + message: info.buildable === false ? 'Jenkins job 当前不可构建' : undefined, + buildable: info.buildable, + url: info.url, + lastBuild: info.lastBuild, + }; + } catch (error) { + if ( + error instanceof IntegrationRequestError && + error.details?.status === 404 + ) { + return { + projectKey, + environment, + jenkinsJobPath, + status: 'missing', + message: 'Jenkins job 不存在', + }; + } + + return { + projectKey, + environment, + jenkinsJobPath, + status: 'unavailable', + message: error instanceof Error ? error.message : 'Jenkins 诊断失败', + }; + } + } + private fallbackRefs(project: ProjectSummary): ProjectRefSummary { const branches = Array.from( new Set([project.defaultBranch, project.key === 'my-resume' ? 'master' : 'develop']),