diff --git a/bin/ncu-ci.js b/bin/ncu-ci.js index d1a8cfa4..741ec8fc 100755 --- a/bin/ncu-ci.js +++ b/bin/ncu-ci.js @@ -119,6 +119,11 @@ const args = yargs(hideBin(process.argv)) 'If not provided, the command will use the SHA of the last approved commit.', type: 'string' }) + .option('check-for-duplicates', { + describe: 'When set, NCU will query Jenkins recent builds to ensure ' + + 'there is not an existing job for the same commit.', + type: 'boolean' + }) .option('owner', { default: '', describe: 'GitHub repository owner' @@ -298,7 +303,10 @@ class RunPRJobCommand { this.cli.setExitCode(1); return; } - const jobRunner = new RunPRJob(cli, request, owner, repo, prid, this.argv.certifySafe); + const { certifySafe, checkForDuplicates } = this.argv; + const jobRunner = new RunPRJob( + cli, request, owner, repo, prid, + certifySafe, checkForDuplicates); if (!(await jobRunner.start())) { this.cli.setExitCode(1); process.exitCode = 1; diff --git a/lib/ci/build-types/pr_build.js b/lib/ci/build-types/pr_build.js index a87727fb..13f3e5fb 100644 --- a/lib/ci/build-types/pr_build.js +++ b/lib/ci/build-types/pr_build.js @@ -15,9 +15,8 @@ const { } = CIFailureParser; export class PRBuild extends TestBuild { - constructor(cli, request, id, skipMoreThan) { + constructor(cli, request, id, skipMoreThan, tree = PR_TREE) { const path = `job/node-test-pull-request/${id}/`; - const tree = PR_TREE; super(cli, request, path, tree); this.skipMoreThan = skipMoreThan; this.commitBuild = null; diff --git a/lib/ci/run_ci.js b/lib/ci/run_ci.js index d894cbb9..b8fbc848 100644 --- a/lib/ci/run_ci.js +++ b/lib/ci/run_ci.js @@ -1,6 +1,7 @@ import { FormData } from 'undici'; import { + JobParser, CI_DOMAIN, CI_TYPES, CI_TYPES_KEYS @@ -8,6 +9,7 @@ import { import PRData from '../pr_data.js'; import { debuglog } from '../verbosity.js'; import PRChecker from '../pr_checker.js'; +import { PRBuild } from './build-types/pr_build.js'; export const CI_CRUMB_URL = `https://${CI_DOMAIN}/crumbIssuer/api/json`; const CI_PR_NAME = CI_TYPES.get(CI_TYPES_KEYS.PR).jobName; @@ -17,7 +19,7 @@ const CI_V8_NAME = CI_TYPES.get(CI_TYPES_KEYS.V8).jobName; export const CI_V8_URL = `https://${CI_DOMAIN}/job/${CI_V8_NAME}/build`; export class RunPRJob { - constructor(cli, request, owner, repo, prid, certifySafe) { + constructor(cli, request, owner, repo, prid, certifySafe, checkForDuplicates) { this.cli = cli; this.request = request; this.owner = owner; @@ -29,6 +31,7 @@ export class RunPRJob { Promise.all([this.prData.getReviews(), this.prData.getCommits()]).then(() => (this.certifySafe = new PRChecker(cli, this.prData, request, {}).getApprovedTipOfHead()) ); + this.checkForDuplicates = checkForDuplicates; } async getCrumb() { @@ -70,7 +73,7 @@ export class RunPRJob { } async start() { - const { cli, certifySafe } = this; + const { cli, request, certifySafe, checkForDuplicates } = this; if (!(await certifySafe)) { cli.error('Refusing to run CI on potentially unsafe PR'); @@ -87,9 +90,25 @@ export class RunPRJob { } cli.stopSpinner('Jenkins credentials valid'); + if (checkForDuplicates) { + await this.prData.getComments(); + const { jobid, link } = new JobParser(this.prData.comments).parse().get('PR') ?? {}; + const { actions } = jobid + ? (await new PRBuild(cli, request, jobid, undefined, 'actions[parameters[name,value]]') + .getBuildData()) + : {}; + const { parameters } = actions?.find(a => 'parameters' in a) ?? {}; + if (parameters?.find(c => c.name === 'COMMIT_SHA_CHECK')?.value === certifySafe) { + cli.info('Existing CI run found: ' + link); + cli.error('Refusing to start a potentially duplicate CI job. Use the ' + + '"Resume build" button in the Jenkins UI, or start a new CI manually.'); + return false; + } + } + try { cli.startSpinner('Starting PR CI job'); - const response = await this.request.fetch(CI_PR_URL, { + const response = await request.fetch(CI_PR_URL, { method: 'POST', headers: { 'Jenkins-Crumb': crumb @@ -106,10 +125,10 @@ export class RunPRJob { // check if the job need a v8 build and trigger it await this.prData.getPR(); - const labels = this.prData.pr.labels; - if (labels.nodes.map(i => i.name).includes('v8 engine')) { + const { labels } = this.prData.pr; + if (labels?.nodes.some(i => i.name === 'v8 engine')) { cli.startSpinner('Starting V8 CI job'); - const response = await this.request.fetch(CI_V8_URL, { + const response = await request.fetch(CI_V8_URL, { method: 'POST', headers: { 'Jenkins-Crumb': crumb diff --git a/test/unit/ci_start.test.js b/test/unit/ci_start.test.js index cd8df818..895b54c7 100644 --- a/test/unit/ci_start.test.js +++ b/test/unit/ci_start.test.js @@ -1,4 +1,4 @@ -import { describe, it, before, afterEach } from 'node:test'; +import { describe, it, before, afterEach, beforeEach } from 'node:test'; import assert from 'assert'; import * as sinon from 'sinon'; @@ -13,6 +13,9 @@ import { import PRChecker from '../../lib/pr_checker.js'; import TestCLI from '../fixtures/test_cli.js'; +import { PRBuild } from '../../lib/ci/build-types/pr_build.js'; +import { JobParser } from '../../lib/ci/ci_type_parser.js'; +import PRData from '../../lib/pr_data.js'; describe('Jenkins', () => { const owner = 'nodejs'; @@ -199,4 +202,129 @@ describe('Jenkins', () => { }); } }); + + describe('--check-for-duplicates', { concurrency: false }, () => { + beforeEach(() => { + sinon.replace(PRData.prototype, 'getComments', sinon.fake.resolves()); + sinon.replace(PRData.prototype, 'getPR', sinon.fake.resolves()); + sinon.replace(JobParser.prototype, 'parse', + sinon.fake.returns(new Map().set('PR', { jobid: 123456 }))); + }); + afterEach(() => { + sinon.restore(); + }); + + const getParameters = (commitHash) => + [ + { + _class: 'hudson.model.BooleanParameterValue', + name: 'CERTIFY_SAFE', + value: true + }, + { + _class: 'hudson.model.StringParameterValue', + name: 'COMMIT_SHA_CHECK', + value: commitHash + }, + { + _class: 'hudson.model.StringParameterValue', + name: 'TARGET_GITHUB_ORG', + value: 'nodejs' + }, + { + _class: 'hudson.model.StringParameterValue', + name: 'TARGET_REPO_NAME', + value: 'node' + }, + { + _class: 'hudson.model.StringParameterValue', + name: 'PR_ID', + value: prid + }, + { + _class: 'hudson.model.StringParameterValue', + name: 'REBASE_ONTO', + value: '' + }, + { + _class: 'com.wangyin.parameter.WHideParameterValue', + name: 'DESCRIPTION_SETTER_DESCRIPTION', + value: '' + } + ]; + const mockJenkinsResponse = parameters => ({ + _class: 'com.tikal.jenkins.plugins.multijob.MultiJobBuild', + actions: [ + { _class: 'hudson.model.CauseAction' }, + { _class: 'hudson.model.ParametersAction', parameters }, + { _class: 'hudson.model.ParametersAction', parameters }, + { _class: 'hudson.model.ParametersAction', parameters }, + {}, + { _class: 'hudson.model.CauseAction' }, + {}, + {}, + {}, + {}, + { _class: 'hudson.plugins.git.util.BuildData' }, + {}, + {}, + {}, + {}, + { _class: 'hudson.model.ParametersAction', parameters }, + { + _class: 'hudson.plugins.parameterizedtrigger.BuildInfoExporterAction' + }, + { + _class: 'com.tikal.jenkins.plugins.multijob.MultiJobTestResults' + }, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + { + _class: 'org.jenkinsci.plugins.displayurlapi.actions.RunDisplayAction' + } + ] + }); + + it('should return false if already started', async() => { + const cli = new TestCLI(); + sinon.replace(PRBuild.prototype, 'getBuildData', + sinon.fake.resolves(mockJenkinsResponse(getParameters('deadbeef')))); + + const jobRunner = new RunPRJob(cli, {}, owner, repo, prid, 'deadbeef', true); + assert.strictEqual(await jobRunner.start(), false); + }); + it('should return true when last CI is on a different commit', async() => { + const cli = new TestCLI(); + sinon.replace(PRBuild.prototype, 'getBuildData', + sinon.fake.resolves(mockJenkinsResponse(getParameters('123456789abcdef')))); + + const request = { + gql: sinon.stub().returns({ + repository: { + pullRequest: { + labels: { + nodes: [] + } + } + } + }), + fetch: sinon.stub() + .callsFake((url, { method, headers, body }) => { + assert.strictEqual(url, CI_PR_URL); + assert.strictEqual(method, 'POST'); + assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb }); + return Promise.resolve({ status: 201 }); + }), + json: sinon.stub().withArgs(CI_CRUMB_URL).resolves({ crumb }) + }; + const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true); + assert.strictEqual(await jobRunner.start(), true); + }); + }); });