264 lines
8.9 KiB
JavaScript
264 lines
8.9 KiB
JavaScript
'use strict';
|
|
|
|
const chalk = require('chalk');
|
|
const pify = require('pify');
|
|
|
|
const git = require('./utils/git');
|
|
|
|
const SEMVER_LABELS = [
|
|
'semver-major',
|
|
'semver-minor',
|
|
'semver-patch',
|
|
'semver-exempt',
|
|
];
|
|
|
|
|
|
|
|
module.exports = function(vorpal, app) {
|
|
vorpal
|
|
.command('stable-prs')
|
|
.description('Get list of stable pull requests that need to be merged to the stable branch')
|
|
.action(function(args) {
|
|
// This makes the chaining easier but obfuscates the actual API, which is
|
|
// unfortunate. The standalone API will return the right data but
|
|
// promisified will get the response object and then we need to pull data
|
|
// off of that.
|
|
let listMilestones = pify(app.ghissues.listMilestones.bind(app.ghissues));
|
|
let listIssues = pify(app.ghissues.listIssues.bind(app.ghissues));
|
|
let editIssue = pify(app.ghissues.editIssue.bind(app.ghissues));
|
|
let getPullRequest = pify(app.ghrepo.getPullRequest.bind(app.ghrepo));
|
|
|
|
let targetMilestone = null;
|
|
|
|
return new Promise((resolveAction, rejectAction) => {
|
|
listMilestones(null).then((milestones) => {
|
|
app.writeTo('milestones.json', milestones);
|
|
|
|
// Turn the milestones into choices for Inquirer
|
|
let milestoneChoices = milestones.map((milestone) => {
|
|
return {
|
|
value: milestone.number,
|
|
name: milestone.title,
|
|
};
|
|
});
|
|
|
|
// We need label choices too
|
|
let labelChoices = SEMVER_LABELS.map((label) => {
|
|
return {
|
|
value: label,
|
|
name: label.split('-')[1], // "major" instead of "semver-major"
|
|
};
|
|
});
|
|
|
|
// Ask about source milestone
|
|
// Ask about dest milestone
|
|
// TODO: allow creation of milestone here.
|
|
// Ask about which labels to pull from
|
|
|
|
return this.prompt([
|
|
{
|
|
name: 'srcMilestone',
|
|
type: 'list',
|
|
message: 'Which milestone should we pull PRs from?',
|
|
choices: milestoneChoices,
|
|
},
|
|
{
|
|
name: 'destMilestone',
|
|
type: 'list',
|
|
message: 'Which milestone should we assign PRs to upon completion?',
|
|
choices: milestoneChoices,
|
|
},
|
|
{
|
|
name: 'labels',
|
|
type: 'checkbox',
|
|
message: 'Which PRs should we select (use spacebar to check all that apply)',
|
|
choices: labelChoices,
|
|
},
|
|
]).then((answers) => {
|
|
// this.log(JSON.stringify(answers, null, 2));
|
|
targetMilestone = answers.destMilestone;
|
|
let labels = {};
|
|
answers.labels.forEach((label) => {
|
|
labels[label] = true;
|
|
});
|
|
return {
|
|
labels: labels,
|
|
query: {
|
|
milestone: answers.srcMilestone,
|
|
per_page: 100,
|
|
state: 'closed',
|
|
},
|
|
};
|
|
});
|
|
})
|
|
// Request issues, filter to applicable PRs
|
|
.then(({labels, query}) => {
|
|
return listIssues(query).then((issues) => {
|
|
app.writeTo('stable-issues.json', issues);
|
|
|
|
// This API *could* return issues that aren't pull requests, so filter out
|
|
// issues that don't have pull_request set. Also filter out issues that
|
|
// aren't the right level of semver (eg if running a patch release)
|
|
let filteringLabels = Object.keys(labels).length > 0;
|
|
const pulls = issues.filter((issue) => {
|
|
if (!issue.pull_request) {
|
|
return false;
|
|
}
|
|
|
|
if (!filteringLabels) {
|
|
return true;
|
|
}
|
|
|
|
return issue.labels.some((label) => labels[label.name]);
|
|
});
|
|
app.writeTo('stable-prs.json', pulls);
|
|
return pulls;
|
|
})
|
|
// We need to convert the issues to PRs. We don't actually have enough
|
|
// info for the pull request data, so we need to get more. Then we'll
|
|
// do some filtering and sorting to make sure we apply merged PRs in
|
|
// the order they were originally committed to avoid conflicts as much
|
|
// as possible.
|
|
.then((pulls) => {
|
|
return Promise.all(pulls.map((pr) => {
|
|
return getPullRequest(pr.number)
|
|
.then((richPR) => {
|
|
app.writeTo(`pr-${pr.number}.json`, richPR);
|
|
richPR.__originalIssue = pr;
|
|
return richPR;
|
|
});
|
|
}))
|
|
.then((richPRs) => {
|
|
return richPRs.filter((pr) => {
|
|
if (!pr.merged_at) {
|
|
this.log(`${chalk.yellow.bold('WARNING')} ${pr.html_url} was not merged, should have the milestone unset.`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}).map((pr) => {
|
|
pr.merged_at_date = new Date(pr.merged_at);
|
|
return pr;
|
|
}).sort((a, b) => a.merged_at_date - b.merged_at_date);
|
|
});
|
|
});
|
|
})
|
|
// Quick prompt to double check that we should proceed.
|
|
.then((pulls) => {
|
|
this.log(`Found ${chalk.bold(pulls.length)} pull requests:`);
|
|
pulls.forEach((pr) => {
|
|
this.log(`${pr.html_url}: ${chalk.bold(pr.title)}`);
|
|
});
|
|
|
|
return this.prompt({
|
|
name: 'merge',
|
|
type: 'confirm',
|
|
message: `Merge these ${pulls.length} pull requests?`,
|
|
}).then((answers) => {
|
|
return answers.merge ? pulls : rejectAction('cancelled');
|
|
});
|
|
})
|
|
// Ok, now we finally have rich pull request data. We can start cherry picking…
|
|
.then((pulls) => {
|
|
// We're going to do some error handling here so we don't get into a
|
|
// terrible state.
|
|
this.log(`Found ${chalk.bold(pulls.length)} pull requests:`);
|
|
return new Promise((resolve, reject) => {
|
|
cherryPickPRs.call(this, app, pulls)
|
|
.then((results) => {
|
|
resolve(results);
|
|
})
|
|
.catch((err) => {
|
|
this.log(`${chalk.red.bold('ERROR')} Something went wrong and your repo is probably in a bad state. Sorry.`);
|
|
resolve({
|
|
successful: [],
|
|
skipped: [],
|
|
didAbort: true,
|
|
});
|
|
});
|
|
});
|
|
})
|
|
// Update the milestone on successful PRs
|
|
// TODO: maybe handle didAbort and git reset --hard to a rev we read when we start the process?
|
|
.then(({successful, aborted, didAbort}) => {
|
|
if (didAbort) {
|
|
return undefined;
|
|
}
|
|
|
|
return Promise.all(successful.map((pr) => {
|
|
return editIssue(pr.number, {milestone: targetMilestone});
|
|
}));
|
|
})
|
|
// yay, we're done
|
|
.then(() => {
|
|
resolveAction();
|
|
})
|
|
.catch((err) => {
|
|
this.log('ERROR', err);
|
|
rejectAction();
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
function cherryPickPRs(app, prs) {
|
|
let successful = [];
|
|
let skipped = [];
|
|
return new Promise((resolve, reject) => {
|
|
// Build array of thenables
|
|
let promises = prs.map((pr) => {
|
|
return () => new Promise((res, rej) => {
|
|
this.log(chalk.yellow(`Cherry-picking #${pr.number} (${pr.title})...`));
|
|
let failed = false;
|
|
try {
|
|
git.cherryPickMerge(app, pr.merge_commit_sha);
|
|
} catch (e) {
|
|
failed = true;
|
|
}
|
|
|
|
if (!failed) {
|
|
this.log(chalk.green`Success`);
|
|
successful.push(pr);
|
|
return res();
|
|
}
|
|
|
|
return this.prompt({
|
|
name: 'handle',
|
|
type: 'list',
|
|
message: `${chalk.red`Failed!`} ${chalk.yellow('This must be resolved manually!')}`,
|
|
choices: [
|
|
{value: 'ok', name: 'Continue, mark successful'},
|
|
{value: 'skip', name: 'Continue, mark skipped'},
|
|
{value: 'abort', name: 'Abort process. Will require manual resetting of git state.'},
|
|
],
|
|
}).then((answers) => {
|
|
switch (answers.handle) {
|
|
case 'ok':
|
|
successful.push(pr);
|
|
break;
|
|
case 'skip':
|
|
skipped.push(pr);
|
|
break;
|
|
case 'abort':
|
|
return rej(pr.number);
|
|
}
|
|
res(pr.number);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Since promises run on creation and we don't actually want that, we create
|
|
// an array of functions that return promises. We'll chain them here, not
|
|
// actually creating the next promise until we're ready.
|
|
var p = promises[0]();
|
|
for (let i = 1; i < promises.length; i++) {
|
|
p = p.then(() => promises[i]());
|
|
}
|
|
p.then(() => {
|
|
resolve({successful, skipped, didAbort: false});
|
|
}).catch((e) => {
|
|
resolve({successful, skipped, didAbort: true});
|
|
});
|
|
|
|
});
|
|
}
|