Skip to content

Commit 5a789fd

Browse files
authored
feat(ncu-ci): add --check-for-duplicates flag (#1035)
1 parent 5034b4f commit 5a789fd

File tree

4 files changed

+164
-10
lines changed

4 files changed

+164
-10
lines changed

bin/ncu-ci.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ const args = yargs(hideBin(process.argv))
119119
'If not provided, the command will use the SHA of the last approved commit.',
120120
type: 'string'
121121
})
122+
.option('check-for-duplicates', {
123+
describe: 'When set, NCU will query Jenkins recent builds to ensure ' +
124+
'there is not an existing job for the same commit.',
125+
type: 'boolean'
126+
})
122127
.option('owner', {
123128
default: '',
124129
describe: 'GitHub repository owner'
@@ -298,7 +303,10 @@ class RunPRJobCommand {
298303
this.cli.setExitCode(1);
299304
return;
300305
}
301-
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, this.argv.certifySafe);
306+
const { certifySafe, checkForDuplicates } = this.argv;
307+
const jobRunner = new RunPRJob(
308+
cli, request, owner, repo, prid,
309+
certifySafe, checkForDuplicates);
302310
if (!(await jobRunner.start())) {
303311
this.cli.setExitCode(1);
304312
process.exitCode = 1;

lib/ci/build-types/pr_build.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,8 @@ const {
1515
} = CIFailureParser;
1616

1717
export class PRBuild extends TestBuild {
18-
constructor(cli, request, id, skipMoreThan) {
18+
constructor(cli, request, id, skipMoreThan, tree = PR_TREE) {
1919
const path = `job/node-test-pull-request/${id}/`;
20-
const tree = PR_TREE;
2120
super(cli, request, path, tree);
2221
this.skipMoreThan = skipMoreThan;
2322
this.commitBuild = null;

lib/ci/run_ci.js

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { FormData } from 'undici';
22

33
import {
4+
JobParser,
45
CI_DOMAIN,
56
CI_TYPES,
67
CI_TYPES_KEYS
78
} from './ci_type_parser.js';
89
import PRData from '../pr_data.js';
910
import { debuglog } from '../verbosity.js';
1011
import PRChecker from '../pr_checker.js';
12+
import { PRBuild } from './build-types/pr_build.js';
1113

1214
export const CI_CRUMB_URL = `https://${CI_DOMAIN}/crumbIssuer/api/json`;
1315
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;
1719
export const CI_V8_URL = `https://${CI_DOMAIN}/job/${CI_V8_NAME}/build`;
1820

1921
export class RunPRJob {
20-
constructor(cli, request, owner, repo, prid, certifySafe) {
22+
constructor(cli, request, owner, repo, prid, certifySafe, checkForDuplicates) {
2123
this.cli = cli;
2224
this.request = request;
2325
this.owner = owner;
@@ -29,6 +31,7 @@ export class RunPRJob {
2931
Promise.all([this.prData.getReviews(), this.prData.getCommits()]).then(() =>
3032
(this.certifySafe = new PRChecker(cli, this.prData, request, {}).getApprovedTipOfHead())
3133
);
34+
this.checkForDuplicates = checkForDuplicates;
3235
}
3336

3437
async getCrumb() {
@@ -70,7 +73,7 @@ export class RunPRJob {
7073
}
7174

7275
async start() {
73-
const { cli, certifySafe } = this;
76+
const { cli, request, certifySafe, checkForDuplicates } = this;
7477

7578
if (!(await certifySafe)) {
7679
cli.error('Refusing to run CI on potentially unsafe PR');
@@ -87,9 +90,25 @@ export class RunPRJob {
8790
}
8891
cli.stopSpinner('Jenkins credentials valid');
8992

93+
if (checkForDuplicates) {
94+
await this.prData.getComments();
95+
const { jobid, link } = new JobParser(this.prData.comments).parse().get('PR') ?? {};
96+
const { actions } = jobid
97+
? (await new PRBuild(cli, request, jobid, undefined, 'actions[parameters[name,value]]')
98+
.getBuildData())
99+
: {};
100+
const { parameters } = actions?.find(a => 'parameters' in a) ?? {};
101+
if (parameters?.find(c => c.name === 'COMMIT_SHA_CHECK')?.value === certifySafe) {
102+
cli.info('Existing CI run found: ' + link);
103+
cli.error('Refusing to start a potentially duplicate CI job. Use the ' +
104+
'"Resume build" button in the Jenkins UI, or start a new CI manually.');
105+
return false;
106+
}
107+
}
108+
90109
try {
91110
cli.startSpinner('Starting PR CI job');
92-
const response = await this.request.fetch(CI_PR_URL, {
111+
const response = await request.fetch(CI_PR_URL, {
93112
method: 'POST',
94113
headers: {
95114
'Jenkins-Crumb': crumb
@@ -106,10 +125,10 @@ export class RunPRJob {
106125

107126
// check if the job need a v8 build and trigger it
108127
await this.prData.getPR();
109-
const labels = this.prData.pr.labels;
110-
if (labels.nodes.map(i => i.name).includes('v8 engine')) {
128+
const { labels } = this.prData.pr;
129+
if (labels?.nodes.some(i => i.name === 'v8 engine')) {
111130
cli.startSpinner('Starting V8 CI job');
112-
const response = await this.request.fetch(CI_V8_URL, {
131+
const response = await request.fetch(CI_V8_URL, {
113132
method: 'POST',
114133
headers: {
115134
'Jenkins-Crumb': crumb

test/unit/ci_start.test.js

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, before, afterEach } from 'node:test';
1+
import { describe, it, before, afterEach, beforeEach } from 'node:test';
22
import assert from 'assert';
33

44
import * as sinon from 'sinon';
@@ -13,6 +13,9 @@ import {
1313
import PRChecker from '../../lib/pr_checker.js';
1414

1515
import TestCLI from '../fixtures/test_cli.js';
16+
import { PRBuild } from '../../lib/ci/build-types/pr_build.js';
17+
import { JobParser } from '../../lib/ci/ci_type_parser.js';
18+
import PRData from '../../lib/pr_data.js';
1619

1720
describe('Jenkins', () => {
1821
const owner = 'nodejs';
@@ -199,4 +202,129 @@ describe('Jenkins', () => {
199202
});
200203
}
201204
});
205+
206+
describe('--check-for-duplicates', { concurrency: false }, () => {
207+
beforeEach(() => {
208+
sinon.replace(PRData.prototype, 'getComments', sinon.fake.resolves());
209+
sinon.replace(PRData.prototype, 'getPR', sinon.fake.resolves());
210+
sinon.replace(JobParser.prototype, 'parse',
211+
sinon.fake.returns(new Map().set('PR', { jobid: 123456 })));
212+
});
213+
afterEach(() => {
214+
sinon.restore();
215+
});
216+
217+
const getParameters = (commitHash) =>
218+
[
219+
{
220+
_class: 'hudson.model.BooleanParameterValue',
221+
name: 'CERTIFY_SAFE',
222+
value: true
223+
},
224+
{
225+
_class: 'hudson.model.StringParameterValue',
226+
name: 'COMMIT_SHA_CHECK',
227+
value: commitHash
228+
},
229+
{
230+
_class: 'hudson.model.StringParameterValue',
231+
name: 'TARGET_GITHUB_ORG',
232+
value: 'nodejs'
233+
},
234+
{
235+
_class: 'hudson.model.StringParameterValue',
236+
name: 'TARGET_REPO_NAME',
237+
value: 'node'
238+
},
239+
{
240+
_class: 'hudson.model.StringParameterValue',
241+
name: 'PR_ID',
242+
value: prid
243+
},
244+
{
245+
_class: 'hudson.model.StringParameterValue',
246+
name: 'REBASE_ONTO',
247+
value: '<pr base branch>'
248+
},
249+
{
250+
_class: 'com.wangyin.parameter.WHideParameterValue',
251+
name: 'DESCRIPTION_SETTER_DESCRIPTION',
252+
value: ''
253+
}
254+
];
255+
const mockJenkinsResponse = parameters => ({
256+
_class: 'com.tikal.jenkins.plugins.multijob.MultiJobBuild',
257+
actions: [
258+
{ _class: 'hudson.model.CauseAction' },
259+
{ _class: 'hudson.model.ParametersAction', parameters },
260+
{ _class: 'hudson.model.ParametersAction', parameters },
261+
{ _class: 'hudson.model.ParametersAction', parameters },
262+
{},
263+
{ _class: 'hudson.model.CauseAction' },
264+
{},
265+
{},
266+
{},
267+
{},
268+
{ _class: 'hudson.plugins.git.util.BuildData' },
269+
{},
270+
{},
271+
{},
272+
{},
273+
{ _class: 'hudson.model.ParametersAction', parameters },
274+
{
275+
_class: 'hudson.plugins.parameterizedtrigger.BuildInfoExporterAction'
276+
},
277+
{
278+
_class: 'com.tikal.jenkins.plugins.multijob.MultiJobTestResults'
279+
},
280+
{},
281+
{},
282+
{},
283+
{},
284+
{},
285+
{},
286+
{},
287+
{},
288+
{
289+
_class: 'org.jenkinsci.plugins.displayurlapi.actions.RunDisplayAction'
290+
}
291+
]
292+
});
293+
294+
it('should return false if already started', async() => {
295+
const cli = new TestCLI();
296+
sinon.replace(PRBuild.prototype, 'getBuildData',
297+
sinon.fake.resolves(mockJenkinsResponse(getParameters('deadbeef'))));
298+
299+
const jobRunner = new RunPRJob(cli, {}, owner, repo, prid, 'deadbeef', true);
300+
assert.strictEqual(await jobRunner.start(), false);
301+
});
302+
it('should return true when last CI is on a different commit', async() => {
303+
const cli = new TestCLI();
304+
sinon.replace(PRBuild.prototype, 'getBuildData',
305+
sinon.fake.resolves(mockJenkinsResponse(getParameters('123456789abcdef'))));
306+
307+
const request = {
308+
gql: sinon.stub().returns({
309+
repository: {
310+
pullRequest: {
311+
labels: {
312+
nodes: []
313+
}
314+
}
315+
}
316+
}),
317+
fetch: sinon.stub()
318+
.callsFake((url, { method, headers, body }) => {
319+
assert.strictEqual(url, CI_PR_URL);
320+
assert.strictEqual(method, 'POST');
321+
assert.deepStrictEqual(headers, { 'Jenkins-Crumb': crumb });
322+
return Promise.resolve({ status: 201 });
323+
}),
324+
json: sinon.stub().withArgs(CI_CRUMB_URL).resolves({ crumb })
325+
};
326+
const jobRunner = new RunPRJob(cli, request, owner, repo, prid, 'deadbeef', true);
327+
assert.strictEqual(await jobRunner.start(), true);
328+
});
329+
});
202330
});

0 commit comments

Comments
 (0)