diff --git a/scripts/release-manager/commands/stable-prs.js b/scripts/release-manager/commands/stable-prs.js index 03fe2da103..24309ac314 100644 --- a/scripts/release-manager/commands/stable-prs.js +++ b/scripts/release-manager/commands/stable-prs.js @@ -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}); + }); + }); - - } diff --git a/scripts/release-manager/package.json b/scripts/release-manager/package.json index 208ebffd8f..7d99872b51 100644 --- a/scripts/release-manager/package.json +++ b/scripts/release-manager/package.json @@ -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"