[rrm] Rewrite stable-prs command

Now with
- better UX (can skip, handle each failed cherr-pick individually)
- easier to read code
- better error handling / cancellation
This commit is contained in:
Paul O’Shannessy
2017-01-06 13:47:16 +00:00
committed by Dan Abramov
parent a52e000356
commit c88cb03b96
2 changed files with 226 additions and 134 deletions
+225 -134
View File
@@ -1,176 +1,267 @@
'use strict';
const chalk = require('chalk');
const promisify = require('es6-promisify');
const pify = require('pify');
const git = require('./utils/git');
// currently 15-next
// IDEA: maybe just always use this milestone across major releases too?
const MILESTONE_NUMBER = 25;
const SEMVER_LABELS = [
'semver-major',
'semver-minor',
'semver-patch',
'semver-exempt',
];
// 15.3.0
const TARGET_MILESTONE_NUMBER = 31;
const LABELS = {
// Until there is a UI for it, uncomment these lines to run a patch release instead
// 'semver-patch': true,
// 'semver-exempt': true,
};
// FOR CODE
// get all closed issues with milestone
// ensure all have pull_request
// FAIL: log issues that aren't prs
// fetch each pr, (issues.pull_request.url)
// sort each by merged_at
// git cherry-pick -x sha || git cherry-pick -x -m1 sha
// (or use API to look up number of parents, 2 = use -m1)
// track progress. on fail, pause and force user to handle manually, continue? prompt
// git push
// update labels on each PR
// ALT: dump link to https://github.com/facebook/react/issues?q=label%3A%22Documentation%3A+needs+merge+to+stable%22+is%3Aclosed
// and say manual step to remove label
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, actionCB) {
// TODO: stop assuming this all fits into a single
const query = {
milestone: MILESTONE_NUMBER,
per_page: 100, // MAX
state: 'closed',
};
.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));
app.ghissues.listIssues(query, (err, body) => {
if (err) {
this.log('ERROR', err);
return actionCB();
}
app.writeTo('stable-issues.json', body);
let targetMilestone = null;
return new Promise((resolveAction, rejectAction) => {
listMilestones(null).then((milestones) => {
app.writeTo('milestones.json', milestones);
// 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)
const filteringLabels = Object.keys(LABELS).length > 0;
const pulls = body.filter((issue) => {
if (!issue.pull_request) {
return false;
}
// Turn the milestones into choices for Inquirer
let milestoneChoices = milestones.map((milestone) => {
return {
value: milestone.number,
name: milestone.title,
};
});
if (!filteringLabels) {
return true;
}
// We need label choices too
let labelChoices = SEMVER_LABELS.map((label) => {
return {
value: label,
name: label.split('-')[1], // "major" instead of "semver-major"
};
});
return issue.labels.some((label) => LABELS[label.name]);
});
// Ask about source milestone
// Ask about dest milestone
// TODO: allow creation of milestone here.
// Ask about which labels to pull from
// We don't enough data about the pull request (merge sha or merge time) so we
// need to fetch more. We'll use promises so we don't have to count completions.
const pullPromises = pulls.map((pr) => {
return new Promise((resolve, reject) => {
app.ghrepo.getPullRequest(pr.number, (err, body) => {
if (err) {
reject(err);
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;
}
app.writeTo(`pr-${pr.number}.json`, body);
// We want to track the original issue as well since it has the
// milestone & label information.
const richPull = body;
richPull.__originalIssue = pr;
resolve(richPull);
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);
});
});
});
Promise.all(pullPromises).then((richPulls) => {
// Remove any pull requests that were closed but not merged. We don't
// care about those. They shouldn't have the milestone set anyway.
// Log for that so they can be manually modified.
richPulls = richPulls.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;
});
richPulls.forEach((pr) => {
// Convert merged_at to real Date for sorting
pr.merged_at_date = new Date(pr.merged_at);
})
// 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)}`);
});
richPulls = richPulls.sort((a, b) => a.merged_at_date - b.merged_at_date);
this.log(`Found ${chalk.bold(richPulls.length)} pull requests:`);
promptForPRs.call(this, app, richPulls, 0).then(() => {
// Update the milestone
if (!TARGET_MILESTONE_NUMBER) {
return actionCB();
}
const milestonePromises = richPulls.map((pr) => {
return app.ghissues.editIssue(pr.number, {
milestone: TARGET_MILESTONE_NUMBER,
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,
});
});
});
Promise.all(milestonePromises).then(actionCB);
});
})
.then(({successful, skipped, didAbort}) => {
console.log(successful.length, skipped.length, didAbort);
})
// 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();
});
});
});
};
// TODO: pull this out to some shared place. We can reuse this for docs.
function promptForPRs(app, prs, start) {
function cherryPickPRs(app, prs) {
let successful = [];
let skipped = [];
return new Promise((resolve, reject) => {
const choices = prs.map((pr, idx) => {
return {
value: idx,
name: `${pr.html_url}: ${chalk.bold(pr.title)}`,
};
});
// TODO: add a seperator so we know when we loop around. doesn't look like
// the "native" inquirer one is exposed so maybe build our own.
this.prompt({
name: 'start',
type: 'list',
message: 'Start from?',
default: start,
choices: choices,
}).then((res) => {
let failed = false;
for (let i = +res.start; i < prs.length; i++) {
let pr = prs[i];
this.log(chalk.grey.italic(`Cherry-picking ${pr.number}`));
// 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) {
// TODO: add ability to mark a PR as skipped
failed = true;
this.log(`${chalk.bold.red('FAILED!')} Please fix manually and continue when ready.`);
promptForPRs.call(this, app, prs, i + 1).then(resolve);
break;
}
}
// Make sure we resolve in case there were no issues
if (!failed) {
resolve();
}
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});
});
});
}
+1
View File
@@ -10,6 +10,7 @@
"github-api": "^2.2.0",
"glob": "^7.0.5",
"opn": "^4.0.2",
"pify": "^2.3.0",
"semver": "^5.3.0",
"untildify": "^3.0.2",
"vorpal": "^1.10.10"