mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[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:
committed by
Dan Abramov
parent
a52e000356
commit
c88cb03b96
@@ -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});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user