reviewable-enterprise-tools 1.1.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/extract_data.js CHANGED
@@ -2,13 +2,16 @@
2
2
 
3
3
  import _ from 'lodash';
4
4
  import * as fs from 'fs';
5
+ import * as path from 'path';
5
6
  import * as zlib from 'zlib';
6
7
  import commandLineArgs from 'command-line-args';
7
8
  import getUsage from 'command-line-usage';
8
9
  import {forEachLimit, forEachOfLimit, forEachOf} from 'async';
9
10
  import nodefireModule from 'nodefire';
10
11
  import {PromiseWritable} from 'promise-writable';
12
+ import {default as download} from 'download';
11
13
  import Pace from 'pace';
14
+ import {uploadedFilesUrl, PLACEHOLDER_URL} from './lib/derivedInfo.js';
12
15
 
13
16
  const NodeFire = nodefireModule.default;
14
17
 
@@ -20,6 +23,10 @@ const commandLineOptions = [
20
23
  'user id mappings. (Optional, defaults to identity mapping.)'},
21
24
  {name: 'output', alias: 'o', typeLabel: '{underline data.ndjson}',
22
25
  description: 'Output ndJSON file for extracted data.'},
26
+ {name: 'download', alias: 'd', typeLabel: '{underline file/download/dir}',
27
+ description: 'Output directory for downloaded attachments'},
28
+ {name: 'logging', alias: 'l', type: Boolean,
29
+ description: 'Turn on low-level Firebase logging for debugging purposes'},
23
30
  {name: 'help', alias: 'h', type: Boolean,
24
31
  description: 'Display these usage instructions.'}
25
32
  ];
@@ -45,6 +52,17 @@ for (const property of ['repos', 'output']) {
45
52
  }
46
53
  }
47
54
 
55
+ let uploadedFilesUrlRegex;
56
+ if (uploadedFilesUrl) {
57
+ uploadedFilesUrlRegex = new RegExp(`(${_.escapeRegExp(uploadedFilesUrl)})([^)]*)`, 'g');
58
+ } else {
59
+ console.warn(
60
+ 'WARNING: no REVIEWABLE_UPLOADS_PROVIDER or REVIEWABLE_UPLOADED_FILES_URL specified, ' +
61
+ 'so not rewriting uploaded image URLs in comments.');
62
+ }
63
+
64
+ if (args.logging) NodeFire.enableFirebaseLogging(true);
65
+
48
66
  const identityUserMap = !args.users;
49
67
  const userMap = args.users ? JSON.parse(fs.readFileSync(args.users)) : {};
50
68
  const repoNames =
@@ -55,14 +73,19 @@ const orgNames = _(repoNames).map(name => name.replace(/\/.*/, '')).uniq().value
55
73
  const out = new PromiseWritable(fs.createWriteStream(args.output));
56
74
  out.stream.setMaxListeners(Infinity);
57
75
 
58
- const pace = Pace(1 + 2 + orgNames.length + 2 * repoNames.length + _.size(userMap));
76
+ const pace = args.logging ?
77
+ {op() {}, total: 0} :
78
+ Pace(1 + 2 + orgNames.length + 2 * repoNames.length + _.size(userMap));
59
79
 
60
80
  let reviewKeys = [];
61
81
  const reversePullRequests = {};
62
82
  let ghostedUsers = [];
63
83
  const missingReviewKeys = [];
84
+ const brokenFiles = [];
85
+ const downloadedFiles = new Set();
64
86
 
65
87
  async function extract() {
88
+ log('Connecting to Firebase');
66
89
  await import('./lib/loadFirebase.js');
67
90
  await extractSystem();
68
91
  await extractOrganizations();
@@ -76,8 +99,15 @@ async function extract() {
76
99
  await extractUsers();
77
100
  await out.end();
78
101
  pace.op();
102
+ console.log(
103
+ `Extracted ${orgNames.length} organizations, ${repoNames.length} repositories, ` +
104
+ `${reviewKeys.length} reviews, ${args.download ? '' : 'and '}${_.size(userMap)} users` + (
105
+ args.download ? `, and ${downloadedFiles.size - brokenFiles.length} files` : ''
106
+ )
107
+ );
79
108
  logMissingReviews();
80
109
  await logUnmappedUsers();
110
+ logBrokenFiles();
81
111
  }
82
112
 
83
113
  extract().then(() => {
@@ -115,8 +145,15 @@ function logMissingReviews() {
115
145
  console.log(_(missingReviewKeys).map(key => reversePullRequests[key]).sort().join('\n'));
116
146
  }
117
147
 
148
+ function logBrokenFiles() {
149
+ if (!args.download || !brokenFiles.length) return;
150
+ console.log(`\n${brokenFiles.length} files could not be downloaded:`);
151
+ console.log(brokenFiles.join('\n'));
152
+ }
153
+
118
154
  async function extractSystem() {
119
- const system = await db.get('system');
155
+ log('Extracting /system');
156
+ const system = await db.child('system').get();
120
157
  if (system.star && system.star !== '*' || system.bang && system.bang !== '!') {
121
158
  throw new Error('Bad or missing REVIEWABLE_ENCRYPTION_AES_KEY');
122
159
  }
@@ -128,6 +165,7 @@ async function extractSystem() {
128
165
 
129
166
  async function extractOrganizations() {
130
167
  if (!orgNames.length) return;
168
+ log('Extracting organizations');
131
169
  await forEachLimit(orgNames, 5, async org => {
132
170
  const organization = await db.child('organizations/:org', {org}).get();
133
171
  await writeItem(`organizations/${toKey(org)}`, organization);
@@ -137,6 +175,7 @@ async function extractOrganizations() {
137
175
 
138
176
  async function extractRepositories() {
139
177
  if (!repoNames.length) return;
178
+ log('Extracting repositories');
140
179
  await forEachLimit(repoNames, 10, async repoName => {
141
180
  const [owner, repo] = repoName.split('/');
142
181
  let repository = await db.child('repositories/:owner/:repo', {owner, repo}).get();
@@ -165,6 +204,7 @@ async function extractRepositories() {
165
204
 
166
205
  async function extractRules() {
167
206
  if (!repoNames.length) return;
207
+ log('Extracting rules');
168
208
  await forEachLimit(repoNames, 10, async repoName => {
169
209
  const [owner, repo] = repoName.split('/');
170
210
  const rule = await db.child('rules/:owner/:repo', {owner, repo}).get();
@@ -175,6 +215,7 @@ async function extractRules() {
175
215
 
176
216
  async function extractReviews() {
177
217
  if (!reviewKeys.length) return;
218
+ log('Extracting reviews');
178
219
  await forEachLimit(reviewKeys, 25, async reviewKey => {
179
220
  let review = await db.child('reviews/:reviewKey', {reviewKey}).get();
180
221
  if (review) {
@@ -184,12 +225,12 @@ async function extractReviews() {
184
225
  const archive = await db.child('archivedReviews/:reviewKey', {reviewKey}).get();
185
226
  if (archive) {
186
227
  review = JSON.parse(zlib.gunzipSync(Buffer.from(archive.payload, 'base64')).toString());
187
- stripReview(review);
228
+ const placeholdersPresent = stripReview(review);
188
229
  if (identityUserMap) mapAllUserKeys(review);
189
230
  archive.payload =
190
231
  zlib.gzipSync(JSON.stringify(review), {level: zlib.constants.Z_BEST_COMPRESSION})
191
232
  .toString('base64');
192
- await writeItem(`archivedReviews/${reviewKey}`, archive);
233
+ await writeItem(`archivedReviews/${reviewKey}`, archive, {placeholdersPresent});
193
234
  } else {
194
235
  missingReviewKeys.push(reviewKey);
195
236
  }
@@ -198,12 +239,33 @@ async function extractReviews() {
198
239
  });
199
240
  }
200
241
 
201
- function stripReview(review) {
242
+ async function stripReview(review) {
243
+ let placeholderAdded = false;
202
244
  review.core = _.omit(review.core, 'lastSweepTimestamp');
203
245
  delete review.lastWebhook;
246
+ const downloadPromises = [];
204
247
  review.discussions = _.pickBy(review.discussions, discussion => {
205
248
  discussion.comments =
206
249
  _.pickBy(discussion.comments, (comment, commentKey) => !/^gh-/.test(commentKey));
250
+ if (uploadedFilesUrl) {
251
+ _.forEach(discussion.comments, comment => {
252
+ if (!comment.markdownBody) return;
253
+ const body = comment.markdownBody.replace(uploadedFilesUrlRegex, (match, host, rest) => {
254
+ const url = host + rest;
255
+ if (args.download && !downloadedFiles.has(url)) {
256
+ downloadedFiles.add(url);
257
+ const dest = path.join(args.download, path.dirname(rest.slice(1)));
258
+ downloadPromises.push(download(url, dest).catch(e => {
259
+ if (args.logging) log(`File download failed:\n${url}\n${e}`);
260
+ brokenFiles.push(url);
261
+ }));
262
+ }
263
+ return PLACEHOLDER_URL + rest;
264
+ });
265
+ if (body !== comment.markdownBody) placeholderAdded = true;
266
+ comment.markdownBody = body;
267
+ });
268
+ }
207
269
  return !_.isEmpty(discussion.comments);
208
270
  });
209
271
  if (_.isEmpty(review.discussions)) delete review.discussions;
@@ -219,10 +281,13 @@ function stripReview(review) {
219
281
  return !_.isEmpty(sentiment.comments);
220
282
  });
221
283
  if (_.isEmpty(review.sentiments)) delete review.sentiments;
284
+ if (!_.isEmpty(downloadPromises)) await Promise.all(downloadPromises);
285
+ return placeholderAdded;
222
286
  }
223
287
 
224
288
  async function extractLinemaps() {
225
289
  if (!reviewKeys.length) return;
290
+ log('Extracting linemaps');
226
291
  await forEachLimit(reviewKeys, 25, async reviewKey => {
227
292
  const linemap = await db.child('linemaps/:reviewKey', {reviewKey}).get();
228
293
  await writeItem(`linemaps/${reviewKey}`, linemap);
@@ -232,6 +297,7 @@ async function extractLinemaps() {
232
297
 
233
298
  async function extractFilemaps() {
234
299
  if (!reviewKeys.length) return;
300
+ log('Extracting filemaps');
235
301
  await forEachLimit(reviewKeys, 25, async reviewKey => {
236
302
  const filemap = await db.child('filemaps/:reviewKey', {reviewKey}).get();
237
303
  await writeItem(`filemaps/${reviewKey}`, filemap);
@@ -241,6 +307,7 @@ async function extractFilemaps() {
241
307
 
242
308
  async function extractUsers() {
243
309
  if (_.isEmpty(userMap)) return;
310
+ log('Extracting users');
244
311
  await forEachOfLimit(userMap, 25, async (newUserKey, oldUserKey) => {
245
312
  let user = await db.child('users/:oldUserKey', {oldUserKey}).get();
246
313
  user = _.omit(
@@ -266,10 +333,15 @@ async function extractUsers() {
266
333
  });
267
334
  }
268
335
 
269
- async function writeItem(key, value) {
336
+ async function writeItem(key, value, flags) {
270
337
  if (value === undefined || value === null) return;
271
338
  value = mapAllUserKeys(value, key);
272
- await out.write(`[${JSON.stringify(key)}, ${JSON.stringify(value)}]\n`);
339
+ if (flags) {
340
+ await out.write(
341
+ `[${JSON.stringify(key)}, ${JSON.stringify(value)}, ${JSON.stringify(flags)}]\n`);
342
+ } else {
343
+ await out.write(`[${JSON.stringify(key)}, ${JSON.stringify(value)}]\n`);
344
+ }
273
345
  }
274
346
 
275
347
  function mapAllUserKeys(object, context) {
@@ -306,3 +378,7 @@ function mapUserKey(userKey, context) {
306
378
  function toKey(value) {
307
379
  return NodeFire.escape(value);
308
380
  }
381
+
382
+ function log(...params) {
383
+ if (args.logging) console.log('---', ...params);
384
+ }
@@ -0,0 +1,28 @@
1
+ export const uploadedFilesUrl = deriveUploadedFilesUrl();
2
+ export const PLACEHOLDER_URL = 'https://REVIEWABLE_UPLOADED_FILES.URL';
3
+
4
+ function deriveUploadedFilesUrl() {
5
+ if (process.env.REVIEWABLE_UPLOADED_FILES_URL) {
6
+ return process.env.REVIEWABLE_UPLOADED_FILES_URL.replace(/\/$/, '');
7
+ }
8
+ if (!process.env.REVIEWABLE_UPLOADS_PROVIDER) return;
9
+ switch (process.env.REVIEWABLE_UPLOADS_PROVIDER) {
10
+ case 'local':
11
+ return process.env.REVIEWABLE_HOST_URL + '/usercontent';
12
+
13
+ case 's3': {
14
+ let bucketUrl = 'https://s3.amazonaws.com/' + process.env.REVIEWABLE_S3_BUCKET;
15
+ if (process.env.AWS_REGION && process.env.AWS_REGION !== 'us-east-1') {
16
+ bucketUrl = bucketUrl.replace(/\/\/s3\./, '//s3-' + process.env.AWS_REGION + '.');
17
+ }
18
+ return bucketUrl;
19
+ }
20
+
21
+ case 'gcs':
22
+ return 'https://storage.googleapis.com/' + process.env.REVIEWABLE_GCS_BUCKET;
23
+
24
+ default:
25
+ throw new Error(
26
+ `Unknown REVIEWABLE_UPLOADS_PROVIDER: ${process.env.REVIEWABLE_UPLOADS_PROVIDER}`);
27
+ }
28
+ }
@@ -1,6 +1,7 @@
1
1
  import _ from 'lodash';
2
2
  import * as fs from 'fs';
3
3
  import * as bytes from 'bytes';
4
+ import * as path from 'path';
4
5
  import admin from 'firebase-admin';
5
6
  import nodefireModule from 'nodefire';
6
7
  import {patchFirebase} from 'firecrypt';
@@ -73,7 +74,8 @@ if (process.env.REVIEWABLE_ENCRYPTION_AES_KEY) {
73
74
  algorithm: 'aes-siv', key: process.env.REVIEWABLE_ENCRYPTION_AES_KEY,
74
75
  cacheSize: FIRECRYPT_CACHE_SIZE
75
76
  };
76
- const specification = JSON.parse(fs.readFileSync('rules_firecrypt.json', 'utf8'));
77
+ const specification = JSON.parse(fs.readFileSync(
78
+ path.join(__dirname, '..', 'rules_firecrypt.json'), 'utf8'));
77
79
  admin.database().configureEncryption(options, specification);
78
80
  }
79
81
 
package/lib/tokens.js ADDED
@@ -0,0 +1,41 @@
1
+ import crypto from 'crypto';
2
+ import * as constants from 'constants';
3
+ import _ from 'lodash';
4
+
5
+ const keys = [];
6
+
7
+ export async function fetchToken(userKey) {
8
+ const encryptedToken = await db.child('users/:userKey/core/gitHubToken', {userKey}).get();
9
+ if (!encryptedToken) throw new Error(`User ${userKey} not signed in to Reviewable`);
10
+ if (!/^rsa\d*:/.test(encryptedToken)) return encryptedToken;
11
+ const cipherText = Buffer.from(encryptedToken.replace(/^rsa\d:/, ''), 'base64');
12
+ if (!process.env.REVIEWABLE_ENCRYPTION_PRIVATE_KEYS) {
13
+ throw new Error(
14
+ `Unable to decrypt token for user ${userKey} without REVIEWABLE_ENCRYPTION_PRIVATE_KEYS`);
15
+ }
16
+ for (const key of keys) {
17
+ const token = crypto.privateDecrypt(key, cipherText).toString('utf8');
18
+ if (/^[ -~]+$/.test(token)) return token;
19
+ }
20
+ throw new Error(`Unable to decrypt token for user ${userKey} with any private key`);
21
+ }
22
+
23
+
24
+ if (process.env.REVIEWABLE_ENCRYPTION_PRIVATE_KEYS) {
25
+ _.forEach(process.env.REVIEWABLE_ENCRYPTION_PRIVATE_KEYS.split(','), pemKey => {
26
+ const key = crypto.createPrivateKey(normalizePrivateKey(pemKey));
27
+ keys.push({key, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256'});
28
+ });
29
+ }
30
+
31
+
32
+ function normalizePrivateKey(pkcsKey) {
33
+ return pkcsKey.replace(
34
+ /-----BEGIN (.*?) KEY-----([\s\S]*?)-----END (\1) KEY-----/,
35
+ (match, keyType, contents) => {
36
+ return '-----BEGIN ' + keyType + ' KEY-----\n' +
37
+ contents.replace(/\\n|\s+/g, '').replace(/.{64}/g, '$&\n').replace(/\n*$/, '\n') +
38
+ '-----END ' + keyType + ' KEY-----\n';
39
+ }
40
+ );
41
+ }
package/load_data.js CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  import _ from 'lodash';
4
4
  import * as fs from 'fs';
5
+ import * as zlib from 'zlib';
5
6
  import es from 'event-stream';
6
7
  import commandLineArgs from 'command-line-args';
7
8
  import getUsage from 'command-line-usage';
8
9
  import nodefireModule from 'nodefire';
10
+ import Hubkit from 'hubkit';
9
11
  import {PromiseReadable} from 'promise-readable';
10
12
  import Pace from 'pace';
11
13
  import {Throttle} from 'stream-throttle';
14
+ import {uploadedFilesUrl, PLACEHOLDER_URL} from './lib/derivedInfo.js';
15
+ import {fetchToken} from './lib/tokens.js';
12
16
 
13
17
  const NodeFire = nodefireModule.default;
14
18
 
@@ -43,11 +47,21 @@ for (const property of ['input', 'admin']) {
43
47
  }
44
48
 
45
49
  if (!process.env.REVIEWABLE_ENCRYPTION_AES_KEY) {
46
- console.log('WARNING: not encrypting uploaded data as REVIEWABLE_ENCRYPTION_AES_KEY not given');
50
+ console.warn('WARNING: not encrypting uploaded data as REVIEWABLE_ENCRYPTION_AES_KEY not given');
51
+ }
52
+
53
+ let placeholderUrlRegex, gh;
54
+ if (uploadedFilesUrl) {
55
+ placeholderUrlRegex = new RegExp(_.escapeRegExp(PLACEHOLDER_URL), 'g');
56
+ } else {
57
+ console.warn(
58
+ 'WARNING: no REVIEWABLE_UPLOADS_PROVIDER or REVIEWABLE_UPLOADED_FILES_URL specified, ' +
59
+ 'so not rewriting uploaded image URLs in comments.');
47
60
  }
48
61
 
49
62
  async function load() {
50
63
  await import('./lib/loadFirebase.js');
64
+ if (uploadedFilesUrl) gh = new Hubkit({token: await fetchToken(args.admin)});
51
65
 
52
66
  let sizeRead = 0;
53
67
  let fatalError;
@@ -84,8 +98,21 @@ load().then(() => {
84
98
  });
85
99
 
86
100
 
87
- async function processLine([key, value]) {
101
+ async function processLine([key, value, flags]) {
102
+ if (uploadedFilesUrl && !_.isEmpty(value)) {
103
+ if (_.startsWith(key, 'reviews/')) {
104
+ await tweakReview(value);
105
+ } else if (_.startsWith(key, 'archivedReviews/') && flags?.placeholdersPresent) {
106
+ const review = JSON.parse(zlib.gunzipSync(Buffer.from(value.payload, 'base64')).toString());
107
+ await tweakReview(review);
108
+ value.payload =
109
+ zlib.gzipSync(JSON.stringify(review), {level: zlib.constants.Z_BEST_COMPRESSION})
110
+ .toString('base64');
111
+ }
112
+ }
113
+
88
114
  if (!_.isEmpty(value)) await db.child(key).update(value);
115
+
89
116
  if (_.startsWith(key, 'reviews/')) {
90
117
  const syncOptions = {
91
118
  userKey: args.admin, prNumber: value.core.pullRequestId,
@@ -103,3 +130,23 @@ async function processLine([key, value]) {
103
130
  }
104
131
  }
105
132
 
133
+ async function tweakReview(review) {
134
+ const fullRepoName = `${review.core.ownerName}/${review.core.repoName}`;
135
+ const promises = [];
136
+ _.forEach(review.discussions, discussion => {
137
+ _.forEach(discussion.comments, comment => {
138
+ if (!comment.markdownBody) return;
139
+ const body = comment.markdownBody.replace(placeholderUrlRegex, uploadedFilesUrl);
140
+ if (body === comment.markdownBody) return;
141
+ comment.markdownBody = body;
142
+ promises.push(
143
+ gh.request('POST /markdown', {body: {
144
+ text: body, mode: 'gfm', context: fullRepoName
145
+ }}).then(htmlBody => {
146
+ comment.htmlBody = htmlBody;
147
+ })
148
+ );
149
+ });
150
+ });
151
+ if (promises.length) await Promise.all(promises);
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviewable-enterprise-tools",
3
- "version": "1.1.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "description": "Admin tools for Reviewable Enterprise",
6
6
  "bin": {
@@ -9,6 +9,8 @@
9
9
  "read": "./read.js"
10
10
  },
11
11
  "scripts": {
12
+ "extract_data": "node ./extract_data.js",
13
+ "load_data": "node ./load_data.js",
12
14
  "test": "echo \"Error: no test specified\" && exit 1"
13
15
  },
14
16
  "engines": {
@@ -36,9 +38,11 @@
36
38
  "bytes": "^3.1.0",
37
39
  "command-line-args": "^5.1.1",
38
40
  "command-line-usage": "^6.1.0",
41
+ "download": "^8.0.0",
39
42
  "event-stream": "^4.0.1",
40
43
  "firebase-admin": "9.3.0",
41
44
  "firecrypt": "^2.0.9",
45
+ "hubkit": "^3.0.0",
42
46
  "lodash": "^4.17.20",
43
47
  "ms": "^2.1.2",
44
48
  "nodefire": "^3.0.0",