jenni
Version:
CLI tool to interact with Jenkins server
351 lines (275 loc) • 9.63 kB
JavaScript
const url = require('url');
const Conf = require('conf');
const got = require('got');
const cheerio = require('cheerio');
const { getGitRootDirPath } = require('./git-cmd');
const { JOB_TYPE } = require('../config');
const { debug } = require('./log');
const StreamProgressiveText = require('./stream-progressive-text');
const StreamBuildStages = require('./stream-build-stages');
const { STATUS_TYPES } = require('./build-status');
const store = new Conf();
const client = got.extend({ timeout: 3000 });
function getBaseUrl(config = null, includeCredentials = true) {
config = config || store.get(getGitRootDirPath());
const { protocol, host } = url.parse(config.url);
if (includeCredentials) {
return `${protocol}//${config.username}:${config.token}@${host}`;
}
return `${protocol}//${host}`;
}
function getJobUrl(branchName, includeCredentials = true) {
const config = store.get(getGitRootDirPath());
const baseUrl = getBaseUrl(config, includeCredentials);
// TODO: support other Jenkins job types
switch (config.job.type) {
case JOB_TYPE.WorkflowJob:
return `${baseUrl}${config.job.path}`;
case JOB_TYPE.WorkflowMultiBranchProject:
return `${baseUrl}${config.job.path}/job/${encodeURIComponent(branchName)}`;
default:
throw new Error(`Unsupported job type: ${config.job.type}`);
}
}
function mapBuild(build) {
return {
status: build.status,
name: build.name,
id: build.id,
durationMillis: build.durationMillis,
startTimeMillis: build.startTimeMillis,
endTimeMillis: build.endTimeMillis,
__meta__: build.__meta__,
};
}
// job/some-job-name/19/ => 19 | null
function extractBuildIdFromTheLink(href = '') {
const buildId = parseInt(
href
.split('/')
.filter(Boolean)
.pop(),
10
);
return Number.isInteger(buildId) ? buildId : null;
}
// Started 8.7 sec ago<br> Estimated remaining time: 7 min 1 sec => { remainingTime, startedTime } | null
function extractBuildStartedAndRemainingTime(text = '') {
const remainingTimeRegExp = /(?<=remaining time: ).*/;
const startedTimeRegExp = /(?<=Started ).*(?= ago)/;
const data = {};
const remainingTime = text.match(remainingTimeRegExp);
const startedTime = text.match(startedTimeRegExp);
if (remainingTime && remainingTime.length) data.remainingTime = remainingTime[0];
if (startedTime && startedTime.length) data.startedTime = startedTime[0];
return Object.keys(data).length === 2 ? data : null;
}
// html => { id: { remainingTime, startedTime } } | null
function extractRemainingTimeFromHtml(html) {
const $ = cheerio.load(html);
if (!$('table').hasClass('hasPageData')) return null;
let row,
buildId,
remainingTime,
data = {};
$('.build-row').each(function() {
row = $('.progress-bar', this);
if (row.length) {
buildId = extractBuildIdFromTheLink($('.build-link', this).attr('href'));
remainingTime = extractBuildStartedAndRemainingTime(row.attr('tooltip'));
if (buildId && remainingTime) data[buildId] = remainingTime;
}
});
return Object.keys(data).length ? data : null;
}
function getRunningBuildsRemainingTime(branchName, buildId) {
// this api will return build history as a html response
// with the `n` header we can control the results limit [n, ..., n + 1]
// e.g. if the `buildId` is 520 then it will return all the build history from 520 to upwards
const url = `${getJobUrl(branchName)}/buildHistory/ajax`;
return client
.post(url, { headers: { n: buildId } })
.then(res => extractRemainingTimeFromHtml(res.body));
}
function extractJobType(str = '') {
return str.split('.').pop();
}
function flattenNestedJobs(nestedJobs) {
function mapJobs(job) {
return {
...job,
displayName: `${this.parentName} → ${job.displayName || job.name}`,
};
}
const jobs = [...nestedJobs]; // to avoid mutating the argument
const transformedJobs = [];
let job;
let jobType = '';
while (jobs.length) {
job = jobs.shift();
job.displayName = job.displayName || job.name;
jobType = extractJobType(job._class);
if (jobType === 'Folder' || jobType === 'OrganizationFolder') {
jobs.push(
...job.jobs.map(mapJobs, {
parentName: job.displayName,
})
);
} else {
transformedJobs.push({
type: jobType,
name: job.displayName,
url: job.url,
});
}
}
return transformedJobs;
}
async function getBuilds(branchName) {
const currentTimestamp = Date.now();
const jobUrl = getJobUrl(branchName);
const { body } = await client.get(`${jobUrl}/wfapi/runs?_=${currentTimestamp}`, {
json: true,
});
return body;
}
function filterRunningBuilds(builds) {
return builds.filter(build => build.status === STATUS_TYPES.inProgress);
}
async function getJob(branchName, fieldsToPick = []) {
const jobUrl = getJobUrl(branchName);
let qs = '';
if (fieldsToPick.length) {
qs = `tree=${fieldsToPick.join(',')}`;
}
const { body } = await client.get(`${jobUrl}/api/json?${qs}`, {
json: true,
});
return body;
}
exports.getJobLink = function(branchName) {
return getJobUrl(branchName, false);
};
exports.getJobs = async function(credentials) {
const baseUrl = getBaseUrl(credentials);
const pick = 'url,name,displayName';
// Temporary workaround to fetch jobs inside the folder as well
// ATM we only fetch jobs upto 3 level deep
const qs = `tree=jobs[${pick},jobs[${pick},jobs[${pick}]]]`;
const { body } = await client.get(`${baseUrl}/api/json?${qs}`, {
json: true,
});
return flattenNestedJobs(body.jobs);
};
exports.getBranchBuildHistory = async function(branchName) {
const builds = await getBuilds(branchName);
const runningBuilds = filterRunningBuilds(builds);
if (runningBuilds.length) {
// `runningBuildsRemainingTime` can be a object | null
const runningBuildsRemainingTime = await getRunningBuildsRemainingTime(
branchName,
runningBuilds.pop().id
);
if (runningBuildsRemainingTime) {
Object.entries(runningBuildsRemainingTime).forEach(([key, value]) => {
builds.find(build => build.id === key).__meta__ = value;
});
}
}
return builds.map(mapBuild);
};
exports.constructJobTitle = function(branchName = '') {
const {
job: { name, type },
} = store.get(getGitRootDirPath());
// TODO: add other Jenkins job types
switch (type) {
case JOB_TYPE.WorkflowJob:
return name;
case JOB_TYPE.WorkflowMultiBranchProject:
return `${name}/${branchName}`;
default:
throw new Error(`Unsupported job type: ${type}`);
}
};
exports.triggerNewBuild = function(branchName) {
// `?delay=0sec` to instantly trigger the build
const jobUrl = `${getJobUrl(branchName)}/build?delay=0sec`;
debug(`Triggering a build for: ${jobUrl}`);
return client.post(jobUrl);
};
exports.getRunningBuilds = async function(branchName) {
const builds = await getBuilds(branchName);
return filterRunningBuilds(builds);
};
exports.createProgressiveTextStream = function(branchName, buildId) {
if (!buildId) throw Error('Invalid build id');
const url = `${getJobUrl(branchName)}/${buildId}/logText/progressiveText`;
return new StreamProgressiveText(url);
};
exports.createBuildStageStream = function(branchName, buildId) {
if (!buildId) throw Error('Invalid build id');
const url = `${getJobUrl(branchName)}/${buildId}/wfapi/describe`;
return new StreamBuildStages({ url });
};
/* Notes related to queue (https://stackoverflow.com/a/45514691/2967670)
* If a build has not yet started, the build information will be blank.
* Once a build has started, Jenkins will remove the queue item after 5 minutes. */
exports.getQueueItem = async function(
itemNumber,
retryUntilBuildFound = false,
retryDelayInMs = 1000
) {
if (!itemNumber) throw Error('Invalid queue item number');
const url = `${getBaseUrl()}/queue/item/${itemNumber}/api/json`;
const { body } = await client.get(url, { json: true });
if (body.executable || !retryUntilBuildFound) {
return body;
}
debug(
'Unable to find the build information from the queue item on the initial attempt'
);
const maximumRetryAttempts = 3;
let attempts = 0;
return new Promise((resolve, reject) => {
function _getQueueItem() {
attempts++;
debug(`getQueueItem attempt: ${attempts}`);
client
.get(url, { json: true })
.then(({ body }) => {
if (body.executable) return resolve(body);
if (attempts > maximumRetryAttempts) {
return reject(
'Maximum retry attempts reached. Unable to find the build information from the queue item'
);
}
setTimeout(() => {
debug(
`Scheduling an event to retrieve queue item after: ${retryDelayInMs *
attempts} ms`
);
_getQueueItem();
}, retryDelayInMs * attempts);
})
.catch(error => {
reject(error);
});
}
return _getQueueItem();
});
};
exports.isJobBuildable = async function(branchName) {
const { buildable } = await getJob(branchName, ['buildable']);
if (typeof buildable !== 'boolean') {
throw Error("Failed to get the job's buildable status");
}
return buildable;
};
exports.enableJob = async function(branchName) {
const jobUrl = getJobUrl(branchName);
// Jenkins returns 302 (redirect) response if the request succeeds.
return client.post(`${jobUrl}/enable`, {
followRedirect: false,
});
};