diff --git a/bin/ncu-ci.js b/bin/ncu-ci.js index 5e865927..77cadc9d 100755 --- a/bin/ncu-ci.js +++ b/bin/ncu-ci.js @@ -110,6 +110,26 @@ const args = yargs(hideBin(process.argv)) }, handler }) + .command({ + command: 'resume ', + desc: 'Resume CI for given PR', + builder: (yargs) => { + yargs + .positional('prid', { + describe: 'ID of the PR', + type: 'number' + }) + .option('owner', { + default: '', + describe: 'GitHub repository owner' + }) + .option('repo', { + default: '', + describe: 'GitHub repository name' + }); + }, + handler + }) .command({ command: 'url ', desc: 'Automatically detect CI type and show results', @@ -253,10 +273,8 @@ class RunPRJobCommand { return this.argv.prid; } - async start() { - const { - cli, request, prid, repo, owner - } = this; + validate() { + const { cli, repo, owner } = this; let validArgs = true; if (!repo) { validArgs = false; @@ -270,10 +288,44 @@ class RunPRJobCommand { } if (!validArgs) { this.cli.setExitCode(1); + } + return validArgs; + } + + async start() { + const { + cli, request, prid, repo, owner + } = this; + if (!this.validate()) { return; } const jobRunner = new RunPRJob(cli, request, owner, repo, prid); - if (!jobRunner.start()) { + if (!await jobRunner.start()) { + this.cli.setExitCode(1); + process.exitCode = 1; + } + } +} + +class ResumePRJobCommand extends RunPRJobCommand { + async start() { + const { + cli, request, prid, repo, owner + } = this; + if (!this.validate()) { + return; + } + // Parse CI links from PR. + const parser = await JobParser.fromPRId(this, cli, request); + const ciMap = parser.parse(); + + if (!ciMap.has(PR)) { + cli.info(`No CI run detected from pull request ${prid}`); + } + + const { jobid } = ciMap.get(PR); + const jobRunner = new RunPRJob(cli, request, owner, repo, prid, jobid); + if (!await jobRunner.resume()) { this.cli.setExitCode(1); process.exitCode = 1; } @@ -539,6 +591,10 @@ async function main(command, argv) { const jobRunner = new RunPRJobCommand(cli, request, argv); return jobRunner.start(); } + case 'resume': { + const jobResumer = new ResumePRJobCommand(cli, request, argv); + return jobResumer.start(); + } case 'rate': { commandHandler = new RateCommand(cli, request, argv); break; diff --git a/lib/ci/ci_type_parser.js b/lib/ci/ci_type_parser.js index 9850fa9b..742c29cb 100644 --- a/lib/ci/ci_type_parser.js +++ b/lib/ci/ci_type_parser.js @@ -189,6 +189,13 @@ JobParser.fromPR = async function(url, cli, request) { return new JobParser(thread); }; +JobParser.fromPRId = async function({ owner, repo, prid }, cli, request) { + const data = new PRData({ owner, repo, prid }, cli, request); + await data.getThreadData(); + const thread = data.getThread(); + return new JobParser(thread); +}; + export const CI_TYPES_KEYS = { CITGM, CITGM_NOBUILD, diff --git a/lib/ci/jenkins_constants.js b/lib/ci/jenkins_constants.js index ca896d15..c3002d7a 100644 --- a/lib/ci/jenkins_constants.js +++ b/lib/ci/jenkins_constants.js @@ -4,6 +4,7 @@ const ACTION_TREE = 'actions[parameters[name,value]]'; const CHANGE_FIELDS = 'commitId,author[absoluteUrl,fullName],authorEmail,' + 'msg,date'; const CHANGE_TREE = `changeSet[items[${CHANGE_FIELDS}]]`; +export const BASIC_TREE = 'result,url,number'; export const PR_TREE = `result,url,number,${ACTION_TREE},${CHANGE_TREE},builtOn,` + `subBuilds[${BUILD_FIELDS},build[subBuilds[${BUILD_FIELDS}]]]`; diff --git a/lib/ci/run_ci.js b/lib/ci/run_ci.js index beead3dc..c0cc6dbb 100644 --- a/lib/ci/run_ci.js +++ b/lib/ci/run_ci.js @@ -1,5 +1,7 @@ import FormData from 'form-data'; +import { BASIC_TREE } from './jenkins_constants.js'; +import { TestBuild } from './build-types/test_build.js'; import { CI_DOMAIN, CI_TYPES, @@ -7,16 +9,18 @@ import { } from './ci_type_parser.js'; export const CI_CRUMB_URL = `https://${CI_DOMAIN}/crumbIssuer/api/json`; -const CI_PR_NAME = CI_TYPES.get(CI_TYPES_KEYS.PR).jobName; +export const CI_PR_NAME = CI_TYPES.get(CI_TYPES_KEYS.PR).jobName; export const CI_PR_URL = `https://${CI_DOMAIN}/job/${CI_PR_NAME}/build`; +export const CI_PR_RESUME_URL = `https://${CI_DOMAIN}/job/${CI_PR_NAME}/`; export class RunPRJob { - constructor(cli, request, owner, repo, prid) { + constructor(cli, request, owner, repo, prid, jobid) { this.cli = cli; this.request = request; this.owner = owner; this.repo = repo; this.prid = prid; + this.jobid = jobid; } async getCrumb() { @@ -43,7 +47,7 @@ export class RunPRJob { return payload; } - async start() { + async #validateJenkinsCredentials() { const { cli } = this; cli.startSpinner('Validating Jenkins credentials'); const crumb = await this.getCrumb(); @@ -51,10 +55,20 @@ export class RunPRJob { if (crumb === false) { cli.stopSpinner('Jenkins credentials invalid', this.cli.SPINNER_STATUS.FAILED); - return false; + return { crumb, success: false }; } cli.stopSpinner('Jenkins credentials valid'); + return { crumb, success: true }; + } + + async start() { + const { cli } = this; + const { crumb, success } = await this.#validateJenkinsCredentials(); + if (success === false) { + return false; + } + try { cli.startSpinner('Starting PR CI job'); const response = await this.request.fetch(CI_PR_URL, { @@ -77,4 +91,46 @@ export class RunPRJob { } return true; } + + async resume() { + const { cli, request, jobid } = this; + const { crumb, success } = await this.#validateJenkinsCredentials(); + if (success === false) { + return false; + } + + try { + cli.startSpinner('Resuming PR CI job'); + const path = `job/${CI_PR_NAME}/${jobid}/`; + const testBuild = new TestBuild(cli, request, path, BASIC_TREE); + const { result } = await testBuild.getBuildData(); + + if (result !== 'FAILURE') { + cli.stopSpinner( + `CI Job is in status ${result ?? 'RUNNING'}, skipping resume`, + this.cli.SPINNER_STATUS.FAILED); + return false; + } + + const resume_url = `${CI_PR_RESUME_URL}${jobid}/resume`; + const response = await this.request.fetch(resume_url, { + method: 'POST', + headers: { + 'Jenkins-Crumb': crumb + } + }); + if (response.status !== 200) { + cli.stopSpinner( + `Failed to resume PR CI: ${response.status} ${response.statusText}`, + this.cli.SPINNER_STATUS.FAILED); + return false; + } + + cli.stopSpinner('PR CI job successfully resumed'); + } catch (err) { + cli.stopSpinner('Failed to resume CI', this.cli.SPINNER_STATUS.FAILED); + return false; + } + return true; + } } diff --git a/test/unit/ci_resume.test.js b/test/unit/ci_resume.test.js new file mode 100644 index 00000000..e9c3bcd3 --- /dev/null +++ b/test/unit/ci_resume.test.js @@ -0,0 +1,137 @@ +import assert from 'assert'; + +import sinon from 'sinon'; +import FormData from 'form-data'; + +import { + RunPRJob, + CI_CRUMB_URL, + CI_PR_NAME, + CI_PR_RESUME_URL +} from '../../lib/ci/run_ci.js'; + +import { CI_DOMAIN } from '../../lib/ci/ci_type_parser.js'; +import TestCLI from '../fixtures/test_cli.js'; +import { jobCache } from '../../lib/ci/build-types/job.js'; + +describe('Jenkins resume', () => { + const owner = 'nodejs'; + const repo = 'node-auto-test'; + const prid = 123456; + const jobid = 654321; + const crumb = 'asdf1234'; + + before(() => { + jobCache.disable(); + sinon.stub(FormData.prototype, 'append').callsFake(function(key, value) { + assert.strictEqual(key, 'json'); + const { parameter } = JSON.parse(value); + const expectedParameters = { + CERTIFY_SAFE: 'on', + TARGET_GITHUB_ORG: owner, + TARGET_REPO_NAME: repo, + PR_ID: prid, + REBASE_ONTO: '', + DESCRIPTION_SETTER_DESCRIPTION: '' + }; + for (const { name, value } of parameter) { + assert.strictEqual(value, expectedParameters[name]); + delete expectedParameters[name]; + } + assert.strictEqual(Object.keys(expectedParameters).length, 0); + + this._validated = true; + + return FormData.prototype.append.wrappedMethod.bind(this)(key, value); + }); + }); + + after(() => { + sinon.restore(); + }); + + it('should return false if crumb fails', async() => { + const cli = new TestCLI(); + const request = { + json: sinon.stub().throws() + }; + + const jobRunner = new RunPRJob(cli, request, owner, repo, prid, jobid); + assert.strictEqual(await jobRunner.resume(), false); + }); + + it('should return false if run status not FAILURE', async() => { + const cli = new TestCLI(); + + const request = { + json: sinon.stub() + }; + + request.json.withArgs(CI_CRUMB_URL) + .returns(Promise.resolve({ crumb })); + request.json.withArgs(`https://${CI_DOMAIN}/job/${CI_PR_NAME}/${jobid}/api/json?tree=result%2Curl%2Cnumber`) + .returns(Promise.resolve({ result: null })); + const jobRunner = new RunPRJob(cli, request, owner, repo, prid, jobid); + assert.strictEqual(await jobRunner.resume(), false); + }); + + it('should resume node-pull-request job', async() => { + const cli = new TestCLI(); + + const request = { + fetch: sinon.stub() + .callsFake((url, { method, headers }) => { + assert.strictEqual(url, `${CI_PR_RESUME_URL}${jobid}/resume`); + assert.strictEqual(method, 'POST'); + assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb }); + return Promise.resolve({ status: 200 }); + }), + json: sinon.stub() + }; + + request.json.withArgs(CI_CRUMB_URL) + .returns(Promise.resolve({ crumb })); + request.json.withArgs(`https://${CI_DOMAIN}/job/${CI_PR_NAME}/${jobid}/api/json?tree=result%2Curl%2Cnumber`) + .returns(Promise.resolve({ result: 'FAILURE' })); + const jobRunner = new RunPRJob(cli, request, owner, repo, prid, jobid); + assert.ok(await jobRunner.resume()); + }); + + it('should fail if resuming node-pull-request throws', async() => { + const cli = new TestCLI(); + const request = { + fetch: sinon.stub().throws(), + json: sinon.stub() + }; + + request.json.withArgs(CI_CRUMB_URL) + .returns(Promise.resolve({ crumb })); + request.json.withArgs(`https://${CI_DOMAIN}/job/${CI_PR_NAME}/${jobid}/api/json?tree=result%2Curl%2Cnumber`) + .returns(Promise.resolve({ result: 'FAILURE' })); + + const jobRunner = new RunPRJob(cli, request, owner, repo, prid, jobid); + assert.strictEqual(await jobRunner.resume(), false); + }); + + it('should return false if node-pull-request not resumed', async() => { + const cli = new TestCLI(); + + const request = { + fetch: sinon.stub() + .callsFake((url, { method, headers }) => { + assert.strictEqual(url, `${CI_PR_RESUME_URL}${jobid}/resume`); + assert.strictEqual(method, 'POST'); + assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb }); + return Promise.resolve({ status: 401 }); + }), + json: sinon.stub() + }; + + request.json.withArgs(CI_CRUMB_URL) + .returns(Promise.resolve({ crumb })); + request.json.withArgs(`https://${CI_DOMAIN}/job/${CI_PR_NAME}/${jobid}/api/json?tree=result%2Curl%2Cnumber`) + .returns(Promise.resolve({ result: 'FAILURE' })); + const jobRunner = new RunPRJob(cli, request, owner, repo, prid, jobid); + assert.strictEqual(await jobRunner.resume(), false); + }); +}); diff --git a/test/unit/ci_start.test.js b/test/unit/ci_start.test.js index cac43f62..a022a28a 100644 --- a/test/unit/ci_start.test.js +++ b/test/unit/ci_start.test.js @@ -40,6 +40,9 @@ describe('Jenkins', () => { return FormData.prototype.append.wrappedMethod.bind(this)(key, value); }); }); + after(() => { + sinon.restore(); + }); it('should fail if starting node-pull-request throws', async() => { const cli = new TestCLI();