Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ncu-ci resume <prid> command #642

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 61 additions & 5 deletions bin/ncu-ci.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@ const args = yargs(hideBin(process.argv))
},
handler
})
.command({
command: 'resume <prid>',
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 <url>',
desc: 'Automatically detect CI type and show results',
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions lib/ci/ci_type_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/ci/jenkins_constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}]]]`;
Expand Down
64 changes: 60 additions & 4 deletions lib/ci/run_ci.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
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,
CI_TYPES_KEYS
} 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() {
Expand All @@ -43,18 +47,28 @@ export class RunPRJob {
return payload;
}

async start() {
async #validateJenkinsCredentials() {
const { cli } = this;
cli.startSpinner('Validating Jenkins credentials');
const crumb = await this.getCrumb();

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, {
Expand All @@ -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;
}
}
137 changes: 137 additions & 0 deletions test/unit/ci_resume.test.js
Original file line number Diff line number Diff line change
@@ -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: '<pr base branch>',
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);
});
});
3 changes: 3 additions & 0 deletions test/unit/ci_start.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down