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
+}