@samitouri / QOS-React / commits / a47041c0f7

Initial import of react-release-manager

Paul O’Shannessy committed Jul 20, 2016 at 17:47 UTC a47041c0f7bb26cc0f15b0ea5dd2c15af0a92c02
9 files changed +672
scripts/release-manager/.gitignore new
+3
@@ -0,0 +1,3 @@
1 +.config.json
2 +node_modules
3 +data
scripts/release-manager/Readme.md new
+36
@@ -0,0 +1,36 @@
1 +# react-release-manager
2 +
3 +This is a WIP tool that is being used to manage React releases.
4 +
5 +General prereqs:
6 +- A separate clone of React, checked out to this branch (currently `release-manager`). This clone should be a sibling to your primary working copy of React. I have these 2 as siblings: `react/` & `react@release-manager`.
7 +- Your `react` clone should have the latest commits fetched before running commands. You may need to rebase or reset & re-run commands if not.
8 +
9 +
10 +## Commands
11 +
12 +### `init`
13 +
14 +Run this first, it will make sure you have a GitHub token generated, letting you use the API.
15 +
16 +### `docs-prs`
17 +
18 +Cherry-picks PRs that have the `Documentation: needs merge to stable` label from `master` to the current branch. Then it removes the label from the PRs and pushes the branch to the remote it's tracking (resulting in Travis CI rebuilding the website)
19 +
20 +**Prereqs:**
21 +- Have your `react` clone on the branch from which the production website is generate (currently `15-stable`)
22 +
23 +### `stable-prs`
24 +
25 +Cherry-picks PRs that are set to the `15-next` milestone. Handles merge conflicts by pausing & allowing you to switch to your working copy of React to manually merge. A menu allows you to continue from where you left off.
26 +
27 +**Prereqs:**
28 +- Have your `react` clone on the dev branch (currently `15-dev`)
29 +
30 +### `version`
31 +
32 +Bumps the version in all the places that need it.
33 +
34 +### `q`
35 +
36 +A secret alias to `exit` because `q` is faster.
scripts/release-manager/cli.js new
+115
@@ -0,0 +1,115 @@
1 +#!/usr/bin/env node
2 +
3 +'use strict';
4 +
5 +const chalk = require('chalk');
6 +const Vorpal = require('vorpal');
7 +const GitHubAPI = require('github-api');
8 +
9 +const fs = require('fs');
10 +const path = require('path');
11 +
12 +const child_process = require('child_process');
13 +const execSync = child_process.execSync;
14 +
15 +const vorpal = new Vorpal();
16 +
17 +// Expects to be in a checkout of react that is a sibling of the react checkout you want to operate on
18 +// eg ~/code/react@release-manager/scripts/release-manager & ~/code/react
19 +// TODO: Make this an argument to the script
20 +const PATH_TO_REPO = path.resolve('../../../react');
21 +
22 +
23 +
24 +// HELPERS
25 +
26 +// Simple helper to write out some JSON for debugging
27 +function writeTo(file, data) {
28 + fs.writeFile(
29 + path.join(__dirname, 'data', file),
30 + JSON.stringify(data, null, 2)
31 + );
32 +}
33 +
34 +// Wrapper around exec so we don't have to worry about paths
35 +function execInRepo(command) {
36 + return execSync(command, {
37 + cwd: PATH_TO_REPO,
38 + encoding: 'utf8',
39 + }).trim();
40 +}
41 +
42 +/**
43 + * Cherry picks a single sha to the given branch. Very crude, but establishes
44 + * some API. We don't know if the sha is a merge or a squashed commit so just
45 + * try both.
46 + *
47 + * Assume we're already on the right branch.
48 + */
49 +function gitCherryPickMerge(sha) {
50 + // console.log(`cherry picking ${sha}`)
51 + // git cherry-pick -x sha || git cherry-pick -x -m1 sha
52 + try {
53 + execInRepo(`git cherry-pick -x ${sha}`);
54 + } catch (e) {
55 + // Assume for now this just means it was actually a merge.
56 + // TODO: gracefully handle other cases, like possibility the commit was
57 + // already cherry-picked and should be skipped.
58 +
59 + execInRepo(`git cherry-pick -x -m1 ${sha}`)
60 + }
61 +}
62 +
63 +const app = {
64 + vorpal,
65 +
66 + updateConfig() {
67 + // TODO: write this. This should make it possible to start without a config
68 + // and go through the init process to create one and then re-init the github
69 + // setup.
70 + },
71 +
72 + init() {
73 + // Config
74 + try {
75 + this.config = JSON.parse(fs.readFileSync('./.config.json', 'utf8'));
76 + } catch (e) {
77 + this.config = {
78 + token: null,
79 + }
80 + console.error('Could not read .config.json. Rate limits are much stricter as a result. Run init to setup.');
81 + }
82 +
83 + this.PATH_TO_REPO = PATH_TO_REPO;
84 +
85 + // GITHUB
86 + this.github = new GitHubAPI({
87 + token: this.config.token,
88 + });
89 + this.ghrepo = this.github.getRepo('facebook', 'react');
90 + this.ghissues = this.github.getIssues('facebook', 'react');
91 +
92 + // HELPERS
93 + this.writeTo = writeTo;
94 + this.execInRepo = execInRepo;
95 + this.gitCherryPickMerge = gitCherryPickMerge;
96 +
97 + // Register commands
98 + [
99 + 'init',
100 + 'docs-prs',
101 + 'q',
102 + 'stable-prs',
103 + 'version',
104 + ].forEach((command) => {
105 + vorpal.use(require(`./commands/${command}`)(vorpal, app));
106 + });
107 +
108 + vorpal
109 + .history('react-release-manager')
110 + .delimiter('rrm \u2234')
111 + .show();
112 + }
113 +}
114 +
115 +app.init();
scripts/release-manager/commands/docs-prs.js new
+128
@@ -0,0 +1,128 @@
1 +'use strict';
2 +
3 +const chalk = require('chalk');
4 +
5 +const DOCS_LABEL = 'Documentation: needs merge to stable';
6 +const DOCS_URL = 'https://github.com/facebook/react/issues?q=label%3A%22Documentation%3A+needs+merge+to+stable%22+is%3Aclosed'
7 +
8 +// FOR DOCS
9 +// get all issues with label
10 +// ensure all have pull_request
11 +// FAIL: log issues that aren't prs
12 +// fetch each pr, (issues.pull_request.url)
13 +// sort each by merged_at
14 +// git cherry-pick -x sha || git cherry-pick -x -m1 sha
15 +// (or use API to look up number of parents, 2 = use -m1)
16 +// track progress. on fail, pause and force user to handle manually, continue? prompt
17 +// git push
18 +// update labels on each PR
19 +// ALT: dump link to https://github.com/facebook/react/issues?q=label%3A%22Documentation%3A+needs+merge+to+stable%22+is%3Aclosed
20 +// and say manual step to remove label
21 +
22 +
23 +module.exports = function(vorpal, app) {
24 + vorpal
25 + .command('docs-prs')
26 + .description('Get list of documentation pull requests that need to be merged to the stable branch')
27 + .action(function (args, actionCB) {
28 + const query = {
29 + labels: [DOCS_LABEL].join(), // github-api doesn't join automatically
30 + state: 'closed',
31 + };
32 +
33 + app.ghissues.listIssues(query, (err, body) => {
34 + app.writeTo('issues.json', body);
35 + // console.log(body);
36 + // fs.writeFileSync('body.json', JSON.stringify(body, null, 2));
37 + // fs.writeFileSync('headers.json', JSON.stringify(headers, null, 2));
38 + // const prs = require('./body');
39 +
40 + // This API *could* return issues that aren't pull requests, so filter out
41 + // issues that don't have pull_request set.
42 + const pulls = body.filter((issue) => issue.pull_request);
43 +
44 + // We don't enough data about the pull request (merge sha or merge time) so we
45 + // need to fetch more. We'll use promises so we don't have to count completions.
46 + const pullPromises = pulls.map((pr) => {
47 + return new Promise((resolve, reject) => {
48 + app.ghrepo.getPullRequest(pr.number, (err, body) => {
49 + if (err) {
50 + reject(err);
51 + }
52 +
53 + app.writeTo(`pr-${pr.number}.json`, body);
54 + // We want to track the original issue as well since it has the
55 + // label information.
56 + const richPull = body;
57 + richPull.__originalIssue = pr;
58 + resolve(richPull);
59 + });
60 + });
61 + });
62 + Promise.all(pullPromises).then((richPulls) => {
63 + richPulls.forEach((pr) => {
64 + // Convert merged_at to real Date for sorting
65 + pr.merged_at_date = new Date(pr.merged_at);
66 + })
67 +
68 + richPulls = richPulls.sort((a, b) => a.merged_at_date - b.merged_at_date);
69 +
70 + this.log(`Found ${chalk.bold(richPulls.length)} pull requests:`)
71 + richPulls.forEach((pr) => {
72 + this.log(`${pr.html_url}: ${chalk.bold(pr.title)}`);
73 + });
74 +
75 + this.prompt({
76 + name: 'merge',
77 + type: 'confirm',
78 + message: `Merge these ${richPulls.length} pull requests?`
79 + }, (res) => {
80 + if (res.merge) {
81 + richPulls.forEach((pr) => {
82 + app.gitCherryPickMerge(pr.merge_commit_sha);
83 + });
84 +
85 + this.prompt({
86 + name: 'push',
87 + type: 'confirm',
88 + message: 'Push these commits upstream?',
89 + }, (res) => {
90 + if (res.push) {
91 + app.execInRepo('git push');
92 + this.log(`Pushed upstream! Removing "${DOCS_LABEL}" label from pull requests.`)
93 + }
94 +
95 + // TODO: actually test this
96 + var removeLabelsPromises = richPulls.map((pr) => {
97 + return new Promise((resolve, reject) => {
98 + const updatedLabels = pr.__originalIssue.labels
99 + .filter((label) => label.name !== DOCS_LABEL)
100 + .map(label => label.name);
101 + app.ghissues.editIssue(pr.number, {labels: updatedLabels}, (err, body) => {
102 + if (err) {
103 + reject(err);
104 + } else {
105 + resolve(pr);
106 + }
107 + });
108 + });
109 + });
110 +
111 + Promise.all(removeLabelsPromises).then(() => {
112 + this.log('Done!')
113 + actionCB();
114 + })
115 + });
116 +
117 + } else {
118 + actionCB();
119 + }
120 + });
121 +
122 +
123 + });
124 +
125 + });
126 +
127 + });
128 +}
scripts/release-manager/commands/init.js new
+41
@@ -0,0 +1,41 @@
1 +/**
2 + * Command to init a project. This will create the .config.json file if it
3 + * doesn't already exist.
4 + */
5 +
6 +'use strict';
7 +
8 +const chalk = require('chalk');
9 +const fs = require('fs');
10 +
11 +const FILENAME = '.config.json';
12 +
13 +module.exports = function(vorpal, options) {
14 + vorpal
15 + .command('init')
16 + .description('Initializes a .config.json file for use')
17 + .action(function(args, cb) {
18 + fs.stat(FILENAME, (err, stats) => {
19 + if (stats) {
20 + this.log('Config file exists, nothing to do.');
21 + cb();
22 + }
23 +
24 + this.prompt([
25 + {
26 + name: 'token',
27 + type: 'input',
28 + message: `${chalk.bold('GitHub token?')} ${chalk.grey('(needs "repo" privs)')} `
29 + }
30 + ]).then((answers) => {
31 + fs.writeFile(FILENAME, JSON.stringify(answers, null, 2), (err) => {
32 + if (err) {
33 + this.log('ERROR WRITING .config.json', err);
34 + }
35 + cb();
36 + });
37 + });
38 +
39 + });
40 + });
41 +}
scripts/release-manager/commands/q.js new
+14
@@ -0,0 +1,14 @@
1 +/**
2 + * Stupid command to run exit. 'q' is way shorter, like less.
3 + */
4 +
5 +'use strict';
6 +
7 +module.exports = function(vorpal, config) {
8 + vorpal
9 + .command('q')
10 + .hidden()
11 + .action((args, cb) => {
12 + vorpal.exec('exit').then(cb);
13 + });
14 +}
scripts/release-manager/commands/stable-prs.js new
+157
@@ -0,0 +1,157 @@
1 +'use strict';
2 +
3 +const chalk = require('chalk');
4 +
5 +// currently 15-next
6 +// IDEA: maybe just always use this milestone across major releases too?
7 +const MILESTONE_NUMBER = 25;
8 +
9 +const LABELS = {
10 + // Until there is a UI for it, uncomment these lines to run a patch release instead
11 + // 'semver-patch': true,
12 + // 'semver-exempt': true,
13 +};
14 +
15 +// FOR CODE
16 +// get all closed issues with milestone
17 +// ensure all have pull_request
18 +// FAIL: log issues that aren't prs
19 +// fetch each pr, (issues.pull_request.url)
20 +// sort each by merged_at
21 +// git cherry-pick -x sha || git cherry-pick -x -m1 sha
22 +// (or use API to look up number of parents, 2 = use -m1)
23 +// track progress. on fail, pause and force user to handle manually, continue? prompt
24 +// git push
25 +// update labels on each PR
26 +// ALT: dump link to https://github.com/facebook/react/issues?q=label%3A%22Documentation%3A+needs+merge+to+stable%22+is%3Aclosed
27 +// and say manual step to remove label
28 +
29 +
30 +module.exports = function(vorpal, app) {
31 + vorpal
32 + .command('stable-prs')
33 + .description('Get list of stable pull requests that need to be merged to the stable branch')
34 + .action(function (args, actionCB) {
35 + // TODO: stop assuming this all fits into a single
36 + const query = {
37 + milestone: MILESTONE_NUMBER,
38 + per_page: 100, // MAX
39 + state: 'closed',
40 + };
41 +
42 + app.ghissues.listIssues(query, (err, body) => {
43 + if (err) {
44 + this.log('ERROR', err);
45 + return actionCB();
46 + }
47 + app.writeTo('stable-issues.json', body);
48 +
49 +
50 + // This API *could* return issues that aren't pull requests, so filter out
51 + // issues that don't have pull_request set. Also filter out issues that
52 + // aren't the right level of semver (eg if running a patch release)
53 + const filteringLabels = Object.keys(LABELS).length > 0;
54 + const pulls = body.filter((issue) => {
55 + if (!issue.pull_request) {
56 + return false;
57 + }
58 +
59 + if (!filteringLabels) {
60 + return true;
61 + }
62 +
63 + return issue.labels.some((label) => LABELS[label.name])
64 + });
65 +
66 + // We don't enough data about the pull request (merge sha or merge time) so we
67 + // need to fetch more. We'll use promises so we don't have to count completions.
68 + const pullPromises = pulls.map((pr) => {
69 + return new Promise((resolve, reject) => {
70 + app.ghrepo.getPullRequest(pr.number, (err, body) => {
71 + if (err) {
72 + reject(err);
73 + }
74 +
75 + app.writeTo(`pr-${pr.number}.json`, body);
76 + // We want to track the original issue as well since it has the
77 + // milestone & label information.
78 + const richPull = body;
79 + richPull.__originalIssue = pr;
80 + resolve(richPull);
81 + });
82 + });
83 + });
84 + Promise.all(pullPromises).then((richPulls) => {
85 + // Remove any pull requests that were closed but not merged. We don't
86 + // care about those. They shouldn't have the milestone set anyway.
87 + // Log for that so they can be manually modified.
88 + richPulls = richPulls.filter((pr) => {
89 + if (!pr.merged_at) {
90 + this.log(`${chalk.yellow.bold('WARNING')} ${pr.html_url} was not merged, should have the milestone unset.`);
91 + return false;
92 + }
93 + return true;
94 + });
95 + richPulls.forEach((pr) => {
96 + // Convert merged_at to real Date for sorting
97 + pr.merged_at_date = new Date(pr.merged_at);
98 + })
99 +
100 + richPulls = richPulls.sort((a, b) => a.merged_at_date - b.merged_at_date);
101 +
102 + this.log(`Found ${chalk.bold(richPulls.length)} pull requests:`)
103 +
104 + promptForPRs.call(this, app, richPulls, 0).then(actionCB);
105 +
106 + });
107 +
108 + });
109 +
110 + });
111 +}
112 +
113 +
114 +// TODO: pull this out to some shared place. We can reuse this for docs.
115 +function promptForPRs(app, prs, start) {
116 + return new Promise((resolve, reject) => {
117 + const choices = prs.map((pr, idx) => {
118 + return {
119 + value: idx,
120 + name: `${pr.html_url}: ${chalk.bold(pr.title)}`,
121 + };
122 + });
123 + // TODO: add a seperator so we know when we loop around. doesn't look like
124 + // the "native" inquirer one is exposed so maybe build our own.
125 +
126 + this.prompt({
127 + name: 'start',
128 + type: 'list',
129 + message: 'Start from?',
130 + default: start,
131 + choices: choices,
132 + }).then((res) => {
133 +
134 + let failed = false;
135 +
136 + for (let i = +res.start; i < prs.length; i++) {
137 + let pr = prs[i];
138 + this.log(chalk.grey.italic(`Cherry-picking ${pr.number}`));
139 + try {
140 + app.gitCherryPickMerge(pr.merge_commit_sha);
141 + } catch (e) {
142 + failed = true;
143 + this.log(`${chalk.bold.red('FAILED!')} Please fix manually and continue when ready.`);
144 + promptForPRs.call(this, app, prs, i + 1).then(resolve);
145 + break;
146 + }
147 + }
148 +
149 + // Make sure we resolve in case there were no issues
150 + if (!failed) {
151 + resolve()
152 + }
153 + });
154 + });
155 +
156 +
157 +}
scripts/release-manager/commands/version.js new
+158
@@ -0,0 +1,158 @@
1 +'use strict';
2 +
3 +const fs = require('fs');
4 +const path = require('path');
5 +const semver = require('semver');
6 +
7 +const chalk = require('chalk');
8 +
9 +
10 +// Overview
11 +// 1. Display current version
12 +// 2. Prompt for new version
13 +// 3. Update appropriate files
14 +// - package.json (version)
15 +// - npm-shrinkwrap.json (version)
16 +// - packages/react/package.json (version)
17 +// - packages/react-addons/package.json (version, peerDependencies.react)
18 +// - packages/react-dom/package.json (version, peerDependencies.react)
19 +// - packages/react-native-renderer/package.json (version, peerDependencies.react)
20 +// - src/ReactVersion.js (module.exports)
21 +// 4. Commit?
22 +
23 +
24 +function updateJSON(path, fields, value) {
25 + let data;
26 + try {
27 + data = JSON.parse(fs.readFileSync(path, 'utf8'));
28 + } catch(e) {
29 + this.log(chalk.color.red('ERROR') + ` ${path} doesn't exist… skipping.`)
30 + }
31 + fields.forEach((field) => {
32 + let fieldPath = field.split('.');
33 + if (fieldPath.length === 1) {
34 + data[field] = value;
35 + } else {
36 + // assume length of 2 is some dep.react and we can just use ^ because we
37 + // know it's true. do something more versatile later
38 + data[fieldPath[0]][fieldPath[1]] = '^' + value;
39 + }
40 + });
41 + fs.writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
42 +}
43 +
44 +
45 +module.exports = function(vorpal, app) {
46 + vorpal
47 + .command('version')
48 + .description('Update the version of React, useful while publishing')
49 + .action(function (args, actionCB) {
50 + const PATH_TO_PACKAGE = path.join(app.PATH_TO_REPO, 'package.json');
51 +
52 + let packageJSON = JSON.parse(fs.readFileSync(PATH_TO_PACKAGE, 'utf8'));
53 +
54 + let currentVersion = packageJSON.version;
55 +
56 + // TODO: See if we can do a better job for handling pre* bumps. The ones
57 + // semver adds are of the form -0, but we've used -alpha.0 or -rc.0.
58 + // 'prerelease' will increment those properly (but otherwise has the same problem).
59 + // Live with it for now since it won't be super common. Write docs.
60 + let choices = ['prerelease' , 'patch', 'minor', 'major'].map((release) => {
61 + let version = semver.inc(currentVersion, release);
62 + return {
63 + value: version,
64 + name:`${chalk.bold(version)} (${release})`
65 + };
66 + });
67 + choices.push('Other');
68 +
69 + this.prompt([
70 + {
71 + type: 'list',
72 + name: 'version',
73 + choices: choices,
74 + message: `New version (currently ${chalk.bold(currentVersion)}):`,
75 + },
76 + {
77 + type: 'input',
78 + name: 'version',
79 + message: `New version (currently ${chalk.bold(currentVersion)}): `,
80 + when: (res) => res.version === 'Other'
81 + }
82 + ]).then((res) => {
83 + let newVersion = semver.valid(res.version);
84 +
85 + if (!newVersion) {
86 + return actionCB(`${chalk.red('ERROR')} ${res.version} is not a semver-valid version`);
87 + }
88 +
89 + this.log(`Updating to ${newVersion}`);
90 +
91 + // The JSON files. They're all updated the same way so batch.
92 + [
93 + {
94 + file: 'package.json',
95 + fields: ['version'],
96 + },
97 + {
98 + file: 'npm-shrinkwrap.json',
99 + fields: ['version'],
100 + },
101 + {
102 + file: 'packages/react/package.json',
103 + fields: ['version'],
104 + },
105 + {
106 + file: 'packages/react-addons/package.json',
107 + fields: ['version', 'peerDependencies.react'],
108 + },
109 + {
110 + file: 'packages/react-dom/package.json',
111 + fields: ['version', 'peerDependencies.react'],
112 + },
113 + {
114 + file: 'packages/react-native-renderer/package.json',
115 + fields: ['version', 'dependencies.react'],
116 + },
117 + ].forEach((opts) => {
118 + updateJSON.apply(this, [path.join(app.PATH_TO_REPO, opts.file), opts.fields, newVersion]);
119 + });
120 +
121 + // We also need to update src/ReactVersion.js which has the version in
122 + // string form in JS code. We'll just do a string replace.
123 +
124 + const PATH_TO_REACTVERSION = path.join(app.PATH_TO_REPO, 'src/ReactVersion.js');
125 +
126 + let reactVersionContents = fs.readFileSync(PATH_TO_REACTVERSION, 'utf8');
127 +
128 + reactVersionContents =
129 + reactVersionContents.replace(currentVersion, newVersion);
130 + fs.writeFileSync(PATH_TO_REACTVERSION, reactVersionContents);
131 +
132 + this.prompt([
133 + {
134 + name: 'commit',
135 + type: 'confirm',
136 + message: 'Commit these changes (`git commit -a`)?',
137 + default: true,
138 + },
139 + {
140 + name: 'tag',
141 + type: 'confirm',
142 + message: 'Tag the version commit (not necessary for non-stable releases)?',
143 + default: true,
144 + when: (res) => res.commit,
145 + },
146 + ]).then((res) => {
147 + if (res.commit) {
148 + app.execInRepo(`git commit -a -m "${newVersion}"`);
149 + }
150 + if (res.tag) {
151 + app.execInRepo(`git tag v${newVersion}`);
152 + }
153 + actionCB();
154 + })
155 + });
156 +
157 + });
158 +}
scripts/release-manager/package.json new
+20
@@ -0,0 +1,20 @@
1 +{
2 + "private": true,
3 + "name": "react-release-manager",
4 + "version": "1.0.0",
5 + "description": "Tool to manage React releases",
6 + "main": "cli.js",
7 + "dependencies": {
8 + "chalk": "^1.1.3",
9 + "colors": "^1.1.2",
10 + "github-api": "^2.2.0",
11 + "semver": "^5.3.0",
12 + "vorpal": "^1.10.10"
13 + },
14 + "devDependencies": {},
15 + "scripts": {
16 + "test": "echo \"Error: no test specified\" && exit 1"
17 + },
18 + "author": "Paul O'Shannessy <paul@oshannessy.com>",
19 + "license": "BSD-3-Clause"
20 +}