react/scripts/release-manager/commands/stable-prs.js

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});
});
});
}