scripts/github-pr-title-lint.js (178 lines of code) (raw):
/* eslint max-len: 0 */
import https from 'https';
import { execSync } from 'child_process';
import { decode } from 'html-entities';
import shelljs from 'shelljs';
const {
// Set by circleci on pull request jobs, and it contains the entire url, e.g.:
// CIRCLE_PULL_REQUEST='https://github.com/mozilla/web-ext/pull/89'
CIRCLE_PULL_REQUEST,
// To be set to test changes to the PR title linting by forcing it temporarily
// (the PR #89 linked above is one that is supposed to fail the linting,
// PR #79 title should instead pass the linting checks), e.g. the following
// command can be used to test an expected linting failure:
//
// CIRCLE_PULL_REQUEST='https://github.com/mozilla/web-ext/pull/89' \
// TEST_FORCE_PR_LINT=1 npm run github-pr-title-lint
TEST_FORCE_PR_LINT,
VERBOSE,
} = process.env;
const DONT_PANIC_MESSAGE = `
Don't panic! If the CI job is failing here, please take a look at
- https://github.com/mozilla/web-ext/blob/master/CONTRIBUTING.md#writing-commit-messages
and feel free to ask for help from one of the maintainers in a comment; we are here to help ;-)
`;
function findMergeBase() {
const res = shelljs.exec('git merge-base HEAD origin/master', {
silent: true,
});
if (res.code !== 0) {
throw new Error(`findMergeBase Error: ${res.stderr}`);
}
const baseCommit = res.stdout.trim();
if (VERBOSE === 'true') {
console.log('DEBUG findMergeBase:', baseCommit);
}
return baseCommit;
}
function getGitBranchCommits() {
const baseCommit = findMergeBase();
const gitCommand = `git rev-list --no-merges HEAD ^${baseCommit}`;
const res = shelljs.exec(gitCommand, { silent: true });
if (res.code !== 0) {
throw new Error(`getGitBranchCommits Error: ${res.stderr}`);
}
const commits = res.stdout.trim().split('\n');
if (VERBOSE === 'true') {
console.log('DEBUG getGitBranchCommits:', commits);
}
return commits;
}
function getGitCommitMessage(commitSha1) {
const res = shelljs.exec(`git show -s --format=%B ${commitSha1}`, {
silent: true,
});
if (res.code !== 0) {
throw new Error(`getGitCommitMessage Error: ${res.stderr}`);
}
const commitMessage = res.stdout.trim();
if (VERBOSE === 'true') {
console.log(`DEBUG getGitCommitMessage: "${commitMessage}"`);
}
return commitMessage;
}
function getPullRequestTitle() {
return new Promise(function (resolve, reject) {
const pullRequestURL = CIRCLE_PULL_REQUEST;
const pullRequestNumber = pullRequestURL.split('/').pop();
if (!/^\d+$/.test(pullRequestNumber)) {
reject(
new Error(`Unable to parse pull request number from ${pullRequestURL}`),
);
return;
}
console.log(`Retrieving the pull request title from ${pullRequestURL}\n`);
var req = https.get(
pullRequestURL,
{
headers: {
'User-Agent': 'GitHub... your API can be very annoying ;-)',
},
},
function (response) {
if (response.statusCode < 200 || response.statusCode > 299) {
reject(
new Error(
`getPullRequestTitle got an unexpected statusCode: ${response.statusCode}`,
),
);
return;
}
response.setEncoding('utf8');
var body = '';
response.on('data', function (data) {
try {
body += data;
if (VERBOSE === 'true') {
console.log('DEBUG getPullRequestTitle got data:', String(data));
}
// Once we get the closing title tag, we can read the pull request title and
// close the http request.
if (body.includes('</title>')) {
response.removeAllListeners('data');
response.emit('end');
var titleStart = body.indexOf('<title>');
var titleEnd = body.indexOf('</title>');
// NOTE: page slice is going to be something like:
// "<title> PR title by author · Pull Request #NUM · mozilla/web-ext · GitHub"
var pageTitleParts = body
.slice(titleStart, titleEnd)
.replace('<title>', '')
.split(' · ');
// Check that we have really got the title of a real pull request.
var expectedPart1 = `Pull Request #${pullRequestNumber}`;
if (pageTitleParts[1] === expectedPart1) {
// Remove the "by author" part.
var prTitleEnd = pageTitleParts[0].lastIndexOf(' by ');
resolve(pageTitleParts[0].slice(0, prTitleEnd));
} else {
if (VERBOSE === 'true') {
console.log('DEBUG getPullRequestTitle response:', body);
}
reject(new Error('Unable to retrieve the pull request title'));
}
req.abort();
}
} catch (err) {
reject(err);
req.abort();
}
});
response.on('error', function (err) {
console.error('Failed during pull request title download: ', err);
reject(err);
});
},
);
}).then((message) => {
return decode(message, { level: 'all' });
});
}
function lintMessage(message) {
if (!message) {
throw new Error('Unable to lint an empty message.');
}
try {
return execSync('commitlint', {
input: message,
windowsHide: true,
encoding: 'utf-8',
}).trim();
} catch (e) {
// execSync failure or timeouts.
if (e.error) {
throw e.error;
}
// commitlint non-zero exit
if (e.status) {
// stderr by default will be output to the parent process' stderr and so we just throw stdout (See
// https://nodejs.org/api/child_process.html#child_process_child_process_execsync_command_options)
throw e.stdout.trim();
}
}
}
async function runChangelogLinting() {
try {
const commits = getGitBranchCommits();
let message;
if (commits.length === 1 && !TEST_FORCE_PR_LINT) {
console.log(
'There is only one commit in this pull request,',
'we are going to check the single commit message...',
);
message = getGitCommitMessage(commits[0]);
} else {
console.log(
'There is more than one commit in this pull request,',
'we are going to check the pull request title...',
);
message = await getPullRequestTitle();
}
lintMessage(message);
} catch (err) {
var errMessage = `${err.stack || err}`.trim();
console.error(
`Failures during changelog linting the pull request:\n\n${errMessage}`,
);
console.log(DONT_PANIC_MESSAGE);
process.exit(1);
}
console.log('Changelog linting completed successfully.');
}
if (CIRCLE_PULL_REQUEST) {
runChangelogLinting();
} else {
console.log(
'This isn\'t a "GitHub Pull Request" CI job. Nothing to do here.',
);
}