From 63928ecb3bf35057a92372db7a7b1f9422e77726 Mon Sep 17 00:00:00 2001 From: m-sureshraj Date: Sun, 6 Dec 2020 17:57:27 +0530 Subject: [PATCH] feat(build-cmd): option to interactively enable disabled job before triggering a build on it When the job is disabled, the build command prompts an option to enable the job and trigger a build on it. --- .../__tests__/assert-job-buildable.spec.js | 81 +++++++++++++++++++ src/commands/build/__tests__/build.spec.js | 18 ++++- src/commands/build/assert-job-buildable.js | 29 +++++++ src/commands/build/index.js | 9 ++- src/lib/__tests__/jenkins.spec.js | 47 +++++++++++ src/lib/jenkins.js | 36 ++++++++- src/lib/prompt.js | 12 +++ 7 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 src/commands/build/__tests__/assert-job-buildable.spec.js create mode 100644 src/commands/build/assert-job-buildable.js diff --git a/src/commands/build/__tests__/assert-job-buildable.spec.js b/src/commands/build/__tests__/assert-job-buildable.spec.js new file mode 100644 index 0000000..bc5a601 --- /dev/null +++ b/src/commands/build/__tests__/assert-job-buildable.spec.js @@ -0,0 +1,81 @@ +const { enableJob, isJobBuildable } = require('../../../lib/jenkins'); +const { getConfirmationToEnableTheJob } = require('../../../lib/prompt'); +const { logNetworkErrors } = require('../../../lib/log'); +const assertJobBuildable = require('../assert-job-buildable'); + +jest.mock('../../../lib/jenkins'); +jest.mock('../../../lib/prompt'); +jest.mock('../../../lib/log'); + +const spinnerStub = { + start: jest.fn(), + stop: jest.fn(), + fail: jest.fn(), + succeed: jest.fn(), +}; +const branchName = 'foo'; + +describe('assertJobBuildable', () => { + beforeEach(() => { + isJobBuildable.mockImplementation(() => Promise.resolve(true)); + + // Note: mocked `process.exit` wont stop the execution + jest.spyOn(process, 'exit').mockImplementation(() => {}); + }); + + afterEach(jest.clearAllMocks); + + it('should not prompt confirmation when the job is buildable', async () => { + await assertJobBuildable(branchName, spinnerStub); + + expect(spinnerStub.start).toHaveBeenCalledTimes(1); + expect(isJobBuildable).toHaveBeenCalledWith(branchName); + expect(getConfirmationToEnableTheJob).not.toHaveBeenCalled(); + }); + + it('should catch all the exceptions locally', async () => { + const error = new Error('bar'); + isJobBuildable.mockImplementation(() => Promise.reject(error)); + + await assertJobBuildable(branchName, spinnerStub); + + expect(getConfirmationToEnableTheJob).not.toHaveBeenCalled(); + expect(spinnerStub.fail).toHaveBeenCalledWith(error.message); + expect(logNetworkErrors).toHaveBeenCalledWith(error); + expect(process.exit).toHaveBeenCalled(); + }); + + it('should prompt confirmation when the job is disabled', async () => { + isJobBuildable.mockImplementation(() => Promise.resolve(false)); + + await assertJobBuildable(branchName, spinnerStub); + + expect(spinnerStub.stop).toHaveBeenCalled(); + expect(getConfirmationToEnableTheJob).toHaveBeenCalled(); + }); + + it('should end the process when the confirmation gets rejected', async () => { + getConfirmationToEnableTheJob.mockImplementation(() => + Promise.resolve({ confirmation: false }) + ); + isJobBuildable.mockImplementation(() => Promise.resolve(false)); + + await assertJobBuildable(branchName, spinnerStub); + + expect(spinnerStub.fail).toHaveBeenCalledWith('Aborted'); + expect(process.exit).toHaveBeenCalledTimes(1); + }); + + it('should enable the job when the confirmation gets accepted', async () => { + getConfirmationToEnableTheJob.mockImplementation(() => + Promise.resolve({ confirmation: false }) + ); + isJobBuildable.mockImplementation(() => Promise.resolve(false)); + + await assertJobBuildable(branchName, spinnerStub); + + expect(spinnerStub.start).toHaveBeenNthCalledWith(2, 'Enabling the job'); + expect(enableJob).toHaveBeenCalledWith(branchName); + expect(spinnerStub.succeed).toHaveBeenCalledWith('Job successfully enabled'); + }); +}); diff --git a/src/commands/build/__tests__/build.spec.js b/src/commands/build/__tests__/build.spec.js index 12bbeb0..a8c63d7 100644 --- a/src/commands/build/__tests__/build.spec.js +++ b/src/commands/build/__tests__/build.spec.js @@ -1,4 +1,4 @@ -const { red, yellow } = require('kleur'); +const { yellow } = require('kleur'); const { getCurrentBranchName } = require('../../../lib/git-cmd'); const { triggerNewBuild, getRunningBuilds } = require('../../../lib/jenkins'); @@ -7,6 +7,7 @@ const { askConfirmationBeforeTriggeringNewBuild } = require('../../../lib/prompt const { WatchError } = require('../../../lib/errors'); const reportBuildProgress = require('../watch-option'); const reportBuildStages = require('../stage-option'); +const assertJobBuildable = require('../assert-job-buildable'); jest.mock('../../../lib/git-cmd'); jest.mock('../../../lib/log'); @@ -14,6 +15,7 @@ jest.mock('../../../lib/jenkins'); jest.mock('../../../lib/prompt'); jest.mock('../watch-option'); jest.mock('../stage-option'); +jest.mock('../assert-job-buildable'); const spinner = { start: jest.fn(), @@ -52,12 +54,24 @@ describe('build', () => { }) ); + assertJobBuildable.mockImplementationOnce(() => Promise.resolve()); + jest.spyOn(global.console, 'log').mockImplementation(); jest.spyOn(process, 'exit').mockImplementation(); }); afterEach(jest.clearAllMocks); + it('should assert job is buildable', async () => { + getRunningBuilds.mockImplementation(() => Promise.resolve([])); + + const options = {}; + await build(options); + + expect(assertJobBuildable).toHaveBeenCalledWith(branchName, spinner); + expect(triggerNewBuild).toHaveBeenCalled(); + }); + it('should throw an error when the watch, stage options are enabled together', async () => { const options = { watch: true, stage: true }; await build(options); @@ -107,7 +121,7 @@ describe('build', () => { await build(); - expect(console.log.mock.calls[0][0]).toBe(red('Aborted')); + expect(spinner.fail).toHaveBeenCalledWith('Aborted'); expect(process.exit).toHaveBeenCalledTimes(1); }); diff --git a/src/commands/build/assert-job-buildable.js b/src/commands/build/assert-job-buildable.js new file mode 100644 index 0000000..87643c5 --- /dev/null +++ b/src/commands/build/assert-job-buildable.js @@ -0,0 +1,29 @@ +const { enableJob, isJobBuildable } = require('../../lib/jenkins'); +const { getConfirmationToEnableTheJob } = require('../../lib/prompt'); +const { logNetworkErrors } = require('../../lib/log'); + +async function assertJobBuildable(branchName, spinner) { + try { + spinner.start(); + + if (!(await isJobBuildable(branchName))) { + spinner.stop(); + + const { confirmation } = await getConfirmationToEnableTheJob(); + if (!confirmation) { + spinner.fail('Aborted'); + process.exit(); + } + + spinner.start('Enabling the job'); + await enableJob(branchName); + spinner.succeed('Job successfully enabled'); + } + } catch (error) { + spinner.fail(error.message); + logNetworkErrors(error); + process.exit(); + } +} + +module.exports = assertJobBuildable; diff --git a/src/commands/build/index.js b/src/commands/build/index.js index 891b8d7..2866f90 100644 --- a/src/commands/build/index.js +++ b/src/commands/build/index.js @@ -1,5 +1,5 @@ const ora = require('ora'); -const { red, yellow } = require('kleur'); +const { yellow } = require('kleur'); const { getCurrentBranchName } = require('../../lib/git-cmd'); const { logNetworkErrors, debug } = require('../../lib/log'); @@ -8,6 +8,7 @@ const { askConfirmationBeforeTriggeringNewBuild } = require('../../lib/prompt'); const { ERROR_TYPE } = require('../../lib/errors'); const reportBuildStages = require('./stage-option'); const reportBuildProgress = require('./watch-option'); +const assertJobBuildable = require('./assert-job-buildable'); const spinner = ora(); @@ -32,8 +33,10 @@ module.exports = async function build(options = {}) { const branchName = getCurrentBranchName(); debug(`Branch name: ${branchName}`); + await assertJobBuildable(branchName, spinner); + try { - spinner.start(); + if (!spinner.isSpinning) spinner.start(); const runningBuilds = await getRunningBuilds(branchName); if (runningBuilds.length) { @@ -41,7 +44,7 @@ module.exports = async function build(options = {}) { const { confirmation } = await askConfirmationBeforeTriggeringNewBuild(); if (!confirmation) { - console.log(red('Aborted')); + spinner.fail('Aborted'); process.exit(); } diff --git a/src/lib/__tests__/jenkins.spec.js b/src/lib/__tests__/jenkins.spec.js index 446d49c..ac3f26f 100644 --- a/src/lib/__tests__/jenkins.spec.js +++ b/src/lib/__tests__/jenkins.spec.js @@ -13,6 +13,8 @@ const { getQueueItem, createProgressiveTextStream, createBuildStageStream, + enableJob, + isJobBuildable, } = require('../jenkins'); const { getGitRootDirPath } = require('../git-cmd'); const { JOB_TYPE } = require('../../config'); @@ -602,3 +604,48 @@ describe('createBuildStageStream', () => { }); }); }); + +describe('isJobBuildable', () => { + const branchName = 'feature-z'; + let mockServer; + let url; + beforeAll(() => { + mockServer = nock(jenkinsCredentials.url); + url = `${jobConfigPath}/job/${branchName}/api/json`; + }); + + it("should throw an error when it couldn't find the `buildable` property in the response", async () => { + mockServer + .get(url) + .query({ tree: 'buildable' }) + .reply(200, {}); + + await expect(isJobBuildable(branchName)).rejects.toThrow( + "Failed to get the job's buildable status" + ); + }); + + it("should return the job's buildable status", async () => { + mockServer + .get(url) + .query({ tree: 'buildable' }) + .reply(200, { buildable: true }); + + expect(await isJobBuildable(branchName)).toBe(true); + }); +}); + +describe('enableJob', () => { + const branchName = 'feature-z'; + let mockServer; + let url; + beforeAll(() => { + mockServer = nock(jenkinsCredentials.url); + url = `${jobConfigPath}/job/${branchName}/enable`; + }); + + it('should enable the job', async () => { + mockServer.post(url).reply(302); + await enableJob(branchName); + }); +}); diff --git a/src/lib/jenkins.js b/src/lib/jenkins.js index 3f58c1f..44efd95 100644 --- a/src/lib/jenkins.js +++ b/src/lib/jenkins.js @@ -29,7 +29,7 @@ function getJobUrl(branchName, includeCredentials = true) { const config = store.get(getGitRootDirPath()); const baseUrl = getBaseUrl(config, includeCredentials); - // TODO: add other Jenkins job types + // TODO: support other Jenkins job types switch (config.job.type) { case JOB_TYPE.WorkflowJob: return `${baseUrl}${config.job.path}`; @@ -172,6 +172,21 @@ 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); }; @@ -314,3 +329,22 @@ exports.getQueueItem = async function( 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, + }); +}; diff --git a/src/lib/prompt.js b/src/lib/prompt.js index 88a5b72..e2e9e28 100644 --- a/src/lib/prompt.js +++ b/src/lib/prompt.js @@ -127,3 +127,15 @@ exports.getDeleteConfigConfirmation = function() { return prompts(question); }; + +exports.getConfirmationToEnableTheJob = function() { + const question = { + type: 'confirm', + name: 'confirmation', + message: + 'The job is currently disabled. Do you like to enable and trigger a build on it', + initial: true, + }; + + return prompts(question); +};