From fef7cd6cade991272e33230cdeb840dc31a4e490 Mon Sep 17 00:00:00 2001 From: Andrew Shirley Date: Wed, 16 Jun 2021 17:41:40 +0100 Subject: [PATCH] Extract Jenkins logic from router and test Signed-off-by: Andrew Shirley Signed-off-by: blam --- .../src/service/jenkinsApi.test.ts | 421 ++++++++++++++++++ .../jenkins-backend/src/service/jenkinsApi.ts | 283 ++++++++++++ plugins/jenkins-backend/src/service/router.ts | 263 ++--------- plugins/jenkins-backend/src/types.ts | 4 +- 4 files changed, 733 insertions(+), 238 deletions(-) create mode 100644 plugins/jenkins-backend/src/service/jenkinsApi.test.ts create mode 100644 plugins/jenkins-backend/src/service/jenkinsApi.ts diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts new file mode 100644 index 0000000000..43902a40a4 --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -0,0 +1,421 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { JenkinsApiImpl } from './jenkinsApi'; +import jenkins from 'jenkins'; +import { JenkinsInfo } from './jenkinsInfoProvider'; +import { JenkinsBuild, JenkinsProject } from '../types'; + +jest.mock('jenkins'); +const mockedJenkinsClient = { + job: { + get: jest.fn(), + build: jest.fn(), + }, + build: { + get: jest.fn(), + }, +}; +const mockedJenkins = jenkins as jest.Mocked; +mockedJenkins.mockReturnValue(mockedJenkinsClient); + +const jobName = 'example-jobName/foo'; +const buildNumber = 19; +const jenkinsInfo: JenkinsInfo = { + baseUrl: 'https://jenkins.example.com', + headers: { headerName: 'headerValue' }, + jobName: 'example-jobName', +}; + +describe('JenkinsApi', () => { + const jenkinsApi = new JenkinsApiImpl(); + + describe('getProjects', () => { + const project: JenkinsProject = { + actions: [], + displayName: 'Example Build', + fullDisplayName: 'Example jobName » Example Build', + fullName: 'example-jobName/exampleBuild', + inQueue: false, + lastBuild: { + actions: [], + timestamp: 1, + building: false, + duration: 10, + result: 'success', + displayName: '#7', + fullDisplayName: 'Example jobName » Example Build #7', + url: 'https://jenkins.example.com/job/example-jobName/job/exampleBuild', + number: 7, + }, + }; + + describe('unfiltered', () => { + it('standard github layout', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce({ jobs: [project] }); + + const result = await jenkinsApi.getProjects(jenkinsInfo); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); + expect(mockedJenkinsClient.job.get).toBeCalledWith({ + name: jenkinsInfo.jobName, + tree: expect.anything(), + }); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + actions: [], + displayName: 'Example Build', + fullDisplayName: 'Example jobName » Example Build', + fullName: 'example-jobName/exampleBuild', + inQueue: false, + lastBuild: { + actions: [], + timestamp: 1, + building: false, + duration: 10, + result: 'success', + displayName: '#7', + fullDisplayName: 'Example jobName » Example Build #7', + url: + 'https://jenkins.example.com/job/example-jobName/job/exampleBuild', + number: 7, + status: 'success', + source: {}, + }, + status: 'success', + }); + }); + it.skip('3-layer nesting', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce({ + jobs: [{ jobs: [project] }], + }); + + const result = await jenkinsApi.getProjects(jenkinsInfo); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); + expect(mockedJenkinsClient.job.get).toBeCalledWith({ + name: jenkinsInfo.jobName, + tree: expect.anything(), + }); + expect(result).toHaveLength(1); + expect(result[0].fullName).toEqual('example-jobName/exampleBuild'); + }); + it.skip('start at project', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce(project); + + const result = await jenkinsApi.getProjects(jenkinsInfo); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); + expect(mockedJenkinsClient.job.get).toBeCalledWith({ + name: jenkinsInfo.jobName, + tree: expect.anything(), + }); + expect(result).toHaveLength(1); + expect(result[0].fullName).toEqual('example-jobName/exampleBuild'); + }); + }); + describe('filtered by branch', () => { + it('standard github layout', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce(project); + + const result = await jenkinsApi.getProjects( + jenkinsInfo, + 'testBranchName', + ); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); + expect(mockedJenkinsClient.job.get).toBeCalledWith({ + name: `${jenkinsInfo.jobName}/testBranchName`, + tree: expect.anything(), + }); + expect(result).toHaveLength(1); + }); + }); + describe('augmented', () => { + const projectWithScmActions: JenkinsProject = { + actions: [ + {}, + {}, + {}, + {}, + { + _class: 'jenkins.scm.api.metadata.ContributorMetadataAction', + contributor: 'testuser', + contributorDisplayName: 'Mr. T User', + contributorEmail: null, + }, + {}, + { + _class: 'jenkins.scm.api.metadata.ObjectMetadataAction', + objectDescription: '', + objectDisplayName: 'Add LICENSE, CoC etc', + objectUrl: 'https://github.com/backstage/backstage/pull/1', + }, + {}, + {}, + { + _class: 'com.cloudbees.plugins.credentials.ViewCredentialsAction', + stores: {}, + }, + ], + displayName: 'Example Build', + fullDisplayName: 'Example jobName » Example Build', + fullName: 'example-jobName/exampleBuild', + inQueue: false, + lastBuild: { + actions: [ + { + _class: 'hudson.model.CauseAction', + causes: [ + { + _class: 'jenkins.branch.BranchIndexingCause', + shortDescription: 'Branch indexing', + }, + ], + }, + {}, + {}, + {}, + { + _class: 'org.jenkinsci.plugins.workflow.cps.EnvActionImpl', + environment: {}, + }, + {}, + {}, + {}, + {}, + {}, + { + _class: 'hudson.plugins.git.util.BuildData', + buildsByBranchName: { + 'PR-1': { + _class: 'hudson.plugins.git.util.Build', + buildNumber: 5, + buildResult: null, + marked: { + SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb', + branch: [ + { + SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb', + name: 'PR-1', + }, + ], + }, + revision: { + SHA1: '6c6b34c0fb91cf077a01fe62d3e8e996b4ea5861', + branch: [ + { + SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb', + name: 'PR-1', + }, + ], + }, + }, + }, + lastBuiltRevision: { + SHA1: '6c6b34c0fb91cf077a01fe62d3e8e996b4ea5861', + branch: [ + { + SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb', + name: 'PR-1', + }, + ], + }, + remoteUrls: ['https://github.com/backstage/backstage.git'], + scmName: '', + }, + { + _class: 'hudson.plugins.git.util.BuildData', + buildsByBranchName: { + master: { + _class: 'hudson.plugins.git.util.Build', + buildNumber: 5, + buildResult: null, + marked: { + SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb', + branch: [ + { + SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb', + name: 'master', + }, + ], + }, + revision: { + SHA1: '6c6b34c0fb91cf077a01fe62d3e8e996b4ea5861', + branch: [ + { + SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb', + name: 'master', + }, + ], + }, + }, + }, + lastBuiltRevision: { + SHA1: '6c6b34c0fb91cf077a01fe62d3e8e996b4ea5861', + branch: [ + { + SHA1: '14d31bde346fcad64ab939f82d195db36701cfcb', + name: 'master', + }, + ], + }, + remoteUrls: ['https://github.com/backstage/backstage.git'], + scmName: '', + }, + {}, + {}, + { + _class: 'hudson.tasks.junit.TestResultAction', + failCount: 2, + skipCount: 1, + totalCount: 635, + urlName: 'testReport', + }, + {}, + {}, + { + _class: + 'org.jenkinsci.plugins.pipeline.modeldefinition.actions.RestartDeclarativePipelineAction', + restartEnabled: false, + restartableStages: [], + }, + {}, + ], + timestamp: 1, + building: false, + duration: 10, + result: 'success', + displayName: '#7', + fullDisplayName: 'Example jobName » Example Build #7', + url: + 'https://jenkins.example.com/job/example-jobName/job/exampleBuild/7/', + number: 7, + }, + }; + + it('augments project', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce({ + jobs: [projectWithScmActions], + }); + + const result = await jenkinsApi.getProjects(jenkinsInfo); + + expect(result).toHaveLength(1); + expect(result[0].status).toEqual('success'); + }); + it('augments build', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce({ + jobs: [projectWithScmActions], + }); + + const result = await jenkinsApi.getProjects(jenkinsInfo); + + expect(result).toHaveLength(1); + // TODO: I am really just asserting the previous behaviour wth no understanding here. + // In my 2 Jenkins instances, 1 returns a lot of different and confusing BuildData sections and 1 returns none ☹️ + expect(result[0].lastBuild.source).toEqual({ + branchName: 'master', + commit: { + hash: '14d31bde', + }, + url: 'https://github.com/backstage/backstage/pull/1', + displayName: 'Add LICENSE, CoC etc', + author: 'Mr. T User', + }); + }); + it('finds test report', async () => { + mockedJenkinsClient.job.get.mockResolvedValueOnce({ + jobs: [projectWithScmActions], + }); + + const result = await jenkinsApi.getProjects(jenkinsInfo); + + expect(result).toHaveLength(1); + expect(result[0].lastBuild.tests).toEqual({ + total: 635, + passed: 632, + skipped: 1, + failed: 2, + testUrl: + 'https://jenkins.example.com/job/example-jobName/job/exampleBuild/7/testReport/', + }); + }); + }); + }); + it('getBuild', async () => { + const project: JenkinsProject = { + actions: [], + displayName: 'Example Build', + fullDisplayName: 'Example jobName » Example Build', + fullName: 'example-jobName/exampleBuild', + inQueue: false, + lastBuild: { + actions: [], + timestamp: 1, + building: false, + duration: 10, + result: 'success', + displayName: '#7', + fullDisplayName: 'Example jobName » Example Build #7', + url: 'https://jenkins.example.com/job/example-jobName/job/exampleBuild', + number: 7, + }, + }; + const build: JenkinsBuild = { + actions: [], + timestamp: 1, + building: false, + duration: 10, + result: 'success', + fullDisplayName: 'example-jobName/exampleBuild', + displayName: 'exampleBuild', + url: `https://jenkins.example.com/job/example-jobName/job/exampleBuild/build/${buildNumber}`, + number: buildNumber, + }; + mockedJenkinsClient.job.get.mockResolvedValueOnce(project); + mockedJenkinsClient.build.get.mockResolvedValueOnce(build); + + await jenkinsApi.getBuild(jenkinsInfo, jobName, buildNumber); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }); + expect(mockedJenkinsClient.job.get).toBeCalledWith({ + name: jobName, + depth: 1, + }); + expect(mockedJenkinsClient.build.get).toBeCalledWith(jobName, buildNumber); + }); + it('buildProject', () => {}); +}); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts new file mode 100644 index 0000000000..f7476daab9 --- /dev/null +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -0,0 +1,283 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { JenkinsInfo } from './jenkinsInfoProvider'; +import jenkins from 'jenkins'; +import { + BackstageBuild, + BackstageProject, + JenkinsBuild, + JenkinsProject, + ScmDetails, +} from '../types'; + +export class JenkinsApiImpl { + private static readonly lastBuildTreeSpec = `lastBuild[ + number, + url, + fullDisplayName, + displayName, + building, + result, + timestamp, + duration, + actions[ + *[ + *[ + *[ + * + ] + ] + ] + ] + ],`; + + private static readonly jobTreeSpec = `actions[*], + ${JenkinsApiImpl.lastBuildTreeSpec} + jobs{0,1}, + name, + fullName, + displayName, + fullDisplayName, + inQueue`; + + private static readonly jobsTreeSpec = `jobs[ + ${JenkinsApiImpl.jobTreeSpec} + ]{0,50}`; + + /** + * Get a list of projects for the given JenkinsInfo. + * @see ../../../jenkins/src/api/JenkinsApi.ts#getProjects + */ + async getProjects(jenkinsInfo: JenkinsInfo, branch?: string) { + const client = await JenkinsApiImpl.getClient(jenkinsInfo); + const projects: BackstageProject[] = []; + + if (branch) { + // we have been asked to filter to a single branch. + // Assume jenkinsInfo.jobName is a folder which contains one job per branch. + // TODO: extract a strategy interface for this + const job = await client.job.get({ + name: `${jenkinsInfo.jobName}/${branch}`, + tree: JenkinsApiImpl.jobTreeSpec.replace(/\s/g, ''), + }); + projects.push(this.augmentProject(job)); + } else { + // We aren't filtering + // Assume jenkinsInfo.jobName is a folder which contains one job per branch. + const folder = await client.job.get({ + name: jenkinsInfo.jobName, + // Filter only be the information we need, instead of loading all fields. + // Limit to only show the latest build for each job and only load 50 jobs + // at all. + // Whitespaces are only included for readablity here and stripped out + // before sending to Jenkins + tree: JenkinsApiImpl.jobsTreeSpec.replace(/\s/g, ''), + }); + + // TODO: support this being a project itself. + for (const jobDetails of folder.jobs) { + // for each branch (we assume) + if (jobDetails?.jobs) { + // skipping folders inside folders for now + // TODO: recurse + } else { + projects.push(this.augmentProject(jobDetails)); + } + } + } + return projects; + } + + /** + * Get a single build. + * @see ../../../jenkins/src/api/JenkinsApi.ts#getBuild + */ + async getBuild( + jenkinsInfo: JenkinsInfo, + jobName: string, + buildNumber: number, + ) { + const client = await JenkinsApiImpl.getClient(jenkinsInfo); + + const project = await client.job.get({ + name: jobName, + depth: 1, + }); + + const build = await client.build.get(jobName, buildNumber); + const jobScmInfo = JenkinsApiImpl.extractScmDetailsFromJob(project); + + return this.augmentBuild(build, jobScmInfo); + } + + /** + * Trigger a build of a project + * @see ../../../jenkins/src/api/JenkinsApi.ts#retry + */ + async buildProject(jenkinsInfo: JenkinsInfo, jobName: string) { + const client = await JenkinsApiImpl.getClient(jenkinsInfo); + + // looks like the current SDK only supports triggering a new build + // can't see any support for replay (re-running the specific build with the same SCM info) + + // Note Jenkins itself has concepts of rebuild and replay on a job. + // The latter should be possible to trigger with a POST to /replay/rebuild + await client.job.build(jobName); + } + + // private helper methods + + private static async getClient(jenkinsInfo: JenkinsInfo) { + // The typings for the jenkins library are out of date so just cast to any + return jenkins({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + }) as any; + } + + private augmentProject(project: JenkinsProject): BackstageProject { + let status: string; + if (project.inQueue) { + status = 'queued'; + } else if (project.lastBuild.building) { + status = 'running'; + } else if (!project.lastBuild.result) { + status = 'unknown'; + } else { + status = project.lastBuild.result; + } + + const jobScmInfo = JenkinsApiImpl.extractScmDetailsFromJob(project); + + return { + ...project, + lastBuild: this.augmentBuild(project.lastBuild, jobScmInfo), + status, + // actions: undefined, + }; + } + + private augmentBuild( + build: JenkinsBuild, + jobScmInfo: ScmDetails | undefined, + ): BackstageBuild { + const source = + build.actions + .filter( + (action: any) => + action._class === 'hudson.plugins.git.util.BuildData', + ) + .map((action: any) => { + const [first]: any = Object.values(action.buildsByBranchName); + const branch = first.revision.branch[0]; + return { + branchName: branch.name, + commit: { + hash: branch.SHA1.substring(0, 8), + }, + }; + }) + .pop() || {}; + + if (jobScmInfo) { + source.url = jobScmInfo.url; + source.displayName = jobScmInfo.displayName; + source.author = jobScmInfo.author; + } + + let status: string; + if (build.building) { + status = 'running'; + } else if (!build.result) { + status = 'unknown'; + } else { + status = build.result; + } + return { + ...build, + status, + source: source, + tests: this.getTestReport(build), + }; + } + + private static extractScmDetailsFromJob( + project: JenkinsProject, + ): ScmDetails | undefined { + const scmInfo: ScmDetails | undefined = project.actions + .filter( + (action: any) => + action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction', + ) + .map((action: any) => { + return { + url: action?.objectUrl, + // https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html + // branch name for regular builds, pull request title on pull requests + displayName: action?.objectDisplayName, + }; + }) + .pop(); + + if (!scmInfo) { + return undefined; + } + + const author = project.actions + .filter( + (action: any) => + action._class === + 'jenkins.scm.api.metadata.ContributorMetadataAction', + ) + .map((action: any) => { + return action.contributorDisplayName; + }) + .pop(); + + if (author) { + scmInfo.author = author; + } + + return scmInfo; + } + + private getTestReport( + build: JenkinsBuild, + ): { + total: number; + passed: number; + skipped: number; + failed: number; + testUrl: string; + } { + return build.actions + .filter( + (action: any) => + action._class === 'hudson.tasks.junit.TestResultAction', + ) + .map((action: any) => { + return { + total: action.totalCount, + passed: action.totalCount - action.failCount - action.skipCount, + skipped: action.skipCount, + failed: action.failCount, + testUrl: `${build.url}${action.urlName}/`, + }; + }) + .pop(); + } +} diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 6ba7f9e574..d8563cfbeb 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -18,48 +18,8 @@ import { errorHandler } from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import jenkins from 'jenkins'; -import { - BackstageBuild, - BackstageProject, - JenkinsBuild, - JenkinsProject, - ScmDetails, -} from '../types'; -import { JenkinsInfo, JenkinsInfoProvider } from './jenkinsInfoProvider'; - -const lastBuildTreeSpec = `lastBuild[ - number, - url, - fullDisplayName, - displayName, - building, - result, - timestamp, - duration, - actions[ - *[ - *[ - *[ - * - ] - ] - ] - ] - ],`; - -const jobTreeSpec = `actions[*], - ${lastBuildTreeSpec} - jobs{0,1}, - name, - fullName, - displayName, - fullDisplayName, - inQueue`; - -const jobsTreeSpec = `jobs[ - ${jobTreeSpec} - ]{0,50}`; +import { JenkinsInfoProvider } from './jenkinsInfoProvider'; +import { JenkinsApiImpl } from './jenkinsApi'; export interface RouterOptions { logger: Logger; @@ -71,6 +31,8 @@ export async function createRouter( ): Promise { const { jenkinsInfoProvider } = options; + const jenkinsApi = new JenkinsApiImpl(); + const router = Router(); router.use(express.json()); @@ -79,6 +41,21 @@ export async function createRouter( async (request, response) => { const { namespace, kind, name } = request.params; const branch = request.query.branch; + let branchStr: string | undefined; + + if (branch === undefined) { + branchStr = undefined; + } else if (typeof branch === 'string') { + branchStr = branch; + } else { + // this was passed in as something weird -> 400 + // https://evanhahn.com/gotchas-with-express-query-parsing-and-how-to-avoid-them/ + response + .status(400) + .send('Something was unexpected about the branch queryString'); + + return; + } const jenkinsInfo = await jenkinsInfoProvider.getInstance({ entityRef: { @@ -87,43 +64,7 @@ export async function createRouter( name, }, }); - - const client = await getClient(jenkinsInfo); - const projects: BackstageProject[] = []; - - if (branch) { - // we have been asked to filter to a single branch. - // Assume jenkinsInfo.jobName is a folder which contains one job per branch. - // TODO: extract a strategy interface for this - const job = await client.job.get({ - name: `${jenkinsInfo.jobName}/${branch}`, - tree: jobTreeSpec.replace(/\s/g, ''), - }); - projects.push(augmentProject(job)); - } else { - // We aren't filtering - // Assume jenkinsInfo.jobName is a folder which contains one job per branch. - const folder = await client.job.get({ - name: jenkinsInfo.jobName, - // Filter only be the information we need, instead of loading all fields. - // Limit to only show the latest build for each job and only load 50 jobs - // at all. - // Whitespaces are only included for readablity here and stripped out - // before sending to Jenkins - tree: jobsTreeSpec.replace(/\s/g, ''), - }); - - // TODO: support this being a project itself. - for (const jobDetails of folder.jobs) { - // for each branch (we assume) - if (jobDetails?.jobs) { - // skipping folders inside folders for now - // TODO: recurse - } else { - projects.push(augmentProject(jobDetails)); - } - } - } + const projects = await jenkinsApi.getProjects(jenkinsInfo, branchStr); response.send({ projects: projects, @@ -153,19 +94,14 @@ export async function createRouter( jobName, }); - const client = await getClient(jenkinsInfo); - - const project = await client.job.get({ - name: jobName, - depth: 1, - }); - - const build = await client.build.get(jobName, parseInt(buildNumber, 10)); - - const jobScmInfo = extractScmDetailsFromJob(project); + const build = await jenkinsApi.getBuild( + jenkinsInfo, + jobName, + parseInt(buildNumber, 10), + ); response.send({ - build: augmentBuild(build, jobScmInfo), + build: build, }); }, ); @@ -184,14 +120,7 @@ export async function createRouter( jobName, }); - const client = await getClient(jenkinsInfo); - - // looks like the current SDK only supports triggering a new build - // can't see any support for replay (re-running the specific build with the same SCM info) - - // Note Jenkins itself has concepts of rebuild and replay on a job. - // The latter should be possible to trigger with a POST to /replay/rebuild - await client.job.build(jobName); + await jenkinsApi.buildProject(jenkinsInfo, jobName); // TODO: return the buildNumber which was started. response.send({}); @@ -201,141 +130,3 @@ export async function createRouter( router.use(errorHandler()); return router; } - -function augmentProject(project: JenkinsProject): BackstageProject { - const jobScmInfo = extractScmDetailsFromJob(project); - - let status: string; - if (project.inQueue) { - status = 'queued'; - } else if (project.lastBuild.building) { - status = 'running'; - } else if (!project.lastBuild.result) { - status = 'unknown'; - } else { - status = project.lastBuild.result; - } - - return { - ...project, - lastBuild: augmentBuild(project.lastBuild, jobScmInfo), - status, - // actions: undefined, - }; -} - -function augmentBuild( - build: JenkinsBuild, - jobScmInfo: ScmDetails | undefined, -): BackstageBuild { - const source = - build.actions - .filter( - (action: any) => action._class === 'hudson.plugins.git.util.BuildData', - ) - .map((action: any) => { - const [first]: any = Object.values(action.buildsByBranchName); - const branch = first.revision.branch[0]; - return { - branchName: branch.name, - commit: { - hash: branch.SHA1.substring(0, 8), - }, - }; - }) - .pop() || {}; - - if (jobScmInfo) { - source.url = jobScmInfo.url; - source.displayName = jobScmInfo.displayName; - source.author = jobScmInfo.author; - } - - let status: string; - if (build.building) { - status = 'running'; - } else if (!build.result) { - status = 'unknown'; - } else { - status = build.result; - } - return { - ...build, - status, - source: source, - tests: getTestReport(build), - }; -} - -function extractScmDetailsFromJob( - project: JenkinsProject, -): ScmDetails | undefined { - const scmInfo: ScmDetails | undefined = project.actions - .filter( - (action: any) => - action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction', - ) - .map((action: any) => { - return { - url: action?.objectUrl, - // https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html - // branch name for regular builds, pull request title on pull requests - displayName: action?.objectDisplayName, - }; - }) - .pop(); - - if (!scmInfo) { - return undefined; - } - - const author = project.actions - .filter( - (action: any) => - action._class === 'jenkins.scm.api.metadata.ContributorMetadataAction', - ) - .map((action: any) => { - return action.contributorDisplayName; - }) - .pop(); - - if (author) { - scmInfo.author = author; - } - - return scmInfo; -} - -function getTestReport( - build: JenkinsBuild, -): { - total: number; - passed: number; - skipped: number; - failed: number; - testUrl: string; -} { - return build.actions - .filter( - (action: any) => action._class === 'hudson.tasks.junit.TestResultAction', - ) - .map((action: any) => { - return { - total: action.totalCount, - passed: action.totalCount - action.failCount - action.skipCount, - skipped: action.skipCount, - failed: action.failCount, - testUrl: `${build.url}${action.urlName}/`, - }; - }) - .pop(); -} - -async function getClient(jenkinsInfo: JenkinsInfo) { - // The typings for the jenkins library are out of date so just cast to any - return jenkins({ - baseUrl: jenkinsInfo.baseUrl, - headers: jenkinsInfo.headers, - promisify: true, - }) as any; -} diff --git a/plugins/jenkins-backend/src/types.ts b/plugins/jenkins-backend/src/types.ts index 88b029bb26..3fc1bf615c 100644 --- a/plugins/jenkins-backend/src/types.ts +++ b/plugins/jenkins-backend/src/types.ts @@ -67,7 +67,7 @@ export interface CommonProject { displayName: string; fullDisplayName: string; fullName: string; - inQueue: string; + inQueue: boolean; } export interface JenkinsProject extends CommonProject { @@ -75,7 +75,7 @@ export interface JenkinsProject extends CommonProject { lastBuild: JenkinsBuild; // read by us from jenkins but not passed to frontend - actions: any; + actions: object[]; } export interface BackstageProject extends CommonProject {