Extract Jenkins logic from router and test
Signed-off-by: Andrew Shirley <andrew.shirley@sainsburys.co.uk> Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -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<any>;
|
||||
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', () => {});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<express.Router> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user