@node-core/utils 6.4.0 → 7.0.0

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.
Files changed (56) hide show
  1. package/README.md +2 -5
  2. package/bin/ncu-ci.js +3 -19
  3. package/components/git/benchmark.js +112 -0
  4. package/components/git/land.js +3 -3
  5. package/components/git/metadata.js +34 -1
  6. package/components/git/release.js +3 -5
  7. package/components/git/security.js +258 -27
  8. package/components/git/v8.js +6 -3
  9. package/components/metadata.js +75 -7
  10. package/lib/auth.js +3 -4
  11. package/lib/backport_session.js +3 -3
  12. package/lib/benchmark.js +243 -0
  13. package/lib/cherry_pick.js +32 -11
  14. package/lib/ci/failure_aggregator.js +16 -20
  15. package/lib/ci/run_ci.js +1 -1
  16. package/lib/cli.js +25 -1
  17. package/lib/config.js +7 -7
  18. package/lib/file.js +5 -5
  19. package/lib/landing_session.js +65 -24
  20. package/lib/links.js +4 -4
  21. package/lib/pr_checker.js +163 -31
  22. package/lib/pr_data.js +1 -1
  23. package/lib/pr_summary.js +1 -1
  24. package/lib/prepare_release.js +270 -85
  25. package/lib/prepare_security.js +312 -46
  26. package/lib/promote_release.js +19 -16
  27. package/lib/queries/ListSecurityReleasePRs.gql +19 -0
  28. package/lib/queries/PR.gql +4 -1
  29. package/lib/request.js +41 -0
  30. package/lib/run.js +5 -5
  31. package/lib/security-announcement.js +10 -5
  32. package/lib/security-release/security-release.js +160 -20
  33. package/lib/security_blog.js +130 -65
  34. package/lib/session.js +4 -3
  35. package/lib/team_info.js +1 -1
  36. package/lib/update-v8/applyNodeChanges.js +1 -3
  37. package/lib/update-v8/backport.js +6 -6
  38. package/lib/update-v8/commitUpdate.js +3 -6
  39. package/lib/update-v8/common.js +30 -6
  40. package/lib/update-v8/constants.js +5 -0
  41. package/lib/update-v8/deps.js +3 -5
  42. package/lib/update-v8/index.js +14 -5
  43. package/lib/update-v8/majorUpdate.js +5 -4
  44. package/lib/update-v8/minorUpdate.js +4 -4
  45. package/lib/update-v8/updateCrates.js +192 -0
  46. package/lib/update-v8/updateV8Clone.js +1 -1
  47. package/lib/update-v8/updateVersionNumbers.js +3 -6
  48. package/lib/update-v8/util.js +3 -3
  49. package/lib/update_security_release.js +347 -105
  50. package/lib/utils.js +5 -5
  51. package/lib/verbosity.js +3 -3
  52. package/lib/wpt/index.js +1 -23
  53. package/package.json +18 -22
  54. package/bin/get-metadata.js +0 -11
  55. package/lib/wpt/templates/README.md +0 -16
  56. package/npm-shrinkwrap.json +0 -20380
@@ -4,9 +4,71 @@ import Request from '../lib/request.js';
4
4
  import auth from '../lib/auth.js';
5
5
  import PRData from '../lib/pr_data.js';
6
6
  import PRSummary from '../lib/pr_summary.js';
7
- import PRChecker from '../lib/pr_checker.js';
7
+ import PRChecker, {
8
+ PR_CHECK_REASON_CODES
9
+ } from '../lib/pr_checker.js';
8
10
  import MetadataGenerator from '../lib/metadata_gen.js';
9
11
 
12
+ export const METADATA_READINESS = Object.freeze({
13
+ READY: 'ready',
14
+ DEFERRABLE: 'deferrable',
15
+ FAILED: 'failed'
16
+ });
17
+
18
+ export const METADATA_EXIT_CODES = Object.freeze({
19
+ READY: 0,
20
+ DEFERRABLE: 20,
21
+ FAILED: 40
22
+ });
23
+
24
+ const DEFERRABLE_REASON_CODES = new Set([
25
+ PR_CHECK_REASON_CODES.WAIT_TIME
26
+ ]);
27
+
28
+ export function classifyMetadataReadiness(ready, reasonCodes) {
29
+ if (ready) {
30
+ return METADATA_READINESS.READY;
31
+ }
32
+
33
+ if (reasonCodes.length > 0 &&
34
+ reasonCodes.every((code) => DEFERRABLE_REASON_CODES.has(code))) {
35
+ return METADATA_READINESS.DEFERRABLE;
36
+ }
37
+
38
+ return METADATA_READINESS.FAILED;
39
+ }
40
+
41
+ export function getMetadataExitCode(readiness) {
42
+ switch (readiness) {
43
+ case METADATA_READINESS.READY:
44
+ return METADATA_EXIT_CODES.READY;
45
+ case METADATA_READINESS.DEFERRABLE:
46
+ return METADATA_EXIT_CODES.DEFERRABLE;
47
+ default:
48
+ return METADATA_EXIT_CODES.FAILED;
49
+ }
50
+ }
51
+
52
+ export function formatMetadataResult({ status, data, metadata, checker }) {
53
+ const reasonCodes = [...new Set(checker.reasons.map(({ code }) => code))];
54
+ const readiness = classifyMetadataReadiness(status, reasonCodes);
55
+ const exitCode = getMetadataExitCode(readiness);
56
+ return {
57
+ ready: status,
58
+ readiness,
59
+ exitCode,
60
+ pullRequest: {
61
+ owner: data.owner,
62
+ repo: data.repo,
63
+ number: data.prid,
64
+ url: data.pr.url
65
+ },
66
+ metadata,
67
+ reasonCodes,
68
+ reasons: checker.reasons
69
+ };
70
+ }
71
+
10
72
  export async function getMetadata(argv, skipRefs, cli) {
11
73
  const credentials = await auth({
12
74
  github: true,
@@ -22,7 +84,7 @@ export async function getMetadata(argv, skipRefs, cli) {
22
84
  summary.display();
23
85
 
24
86
  const metadata = new MetadataGenerator({ skipRefs, ...data }).getMetadata();
25
- if (!process.stdout.isTTY) {
87
+ if (!argv.json && !process.stdout.isTTY) {
26
88
  process.stdout.write(metadata);
27
89
  }
28
90
 
@@ -33,17 +95,23 @@ export async function getMetadata(argv, skipRefs, cli) {
33
95
  cli.stopSpinner(`Done writing metadata to ${argv.file}`);
34
96
  }
35
97
 
36
- cli.separator('Generated metadata');
37
- cli.write(metadata);
38
- cli.separator();
98
+ if (!argv.json) {
99
+ cli.separator('Generated metadata');
100
+ cli.write(metadata);
101
+ cli.separator();
102
+ }
39
103
 
40
104
  const checker = new PRChecker(cli, data, request, argv);
41
105
  const status = await checker.checkAll(argv.checkComments, argv.checkCI);
42
- return {
106
+ const result = {
43
107
  status,
44
108
  request,
45
109
  data,
46
110
  metadata,
47
111
  checker
48
112
  };
49
- };
113
+ return {
114
+ ...result,
115
+ json: formatMetadataResult(result)
116
+ };
117
+ }
package/lib/auth.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { ClientRequest } from 'node:http';
3
3
 
4
- // eslint-disable-next-line import/no-unresolved
5
4
  import ghauth from 'ghauth';
6
5
 
7
6
  import { clearCachedConfig, encryptValue, getMergedConfig, getNcurcPath } from './config.js';
@@ -76,7 +75,7 @@ async function auth(
76
75
  let token;
77
76
  try {
78
77
  ({ username, token } = getMergedConfig());
79
- } catch (e) {
78
+ } catch {
80
79
  // Ignore error and prompt
81
80
  }
82
81
 
@@ -94,7 +93,7 @@ async function auth(
94
93
  'and run the following command to add it to your ncu config: ' +
95
94
  'ncu-config --global set -x jenkins_token'
96
95
  );
97
- };
96
+ }
98
97
  check(username, jenkins_token);
99
98
  const jenkins = encode(username, jenkins_token);
100
99
  setOwnProperty(result, 'jenkins', jenkins);
@@ -127,7 +126,7 @@ async function auth(
127
126
  let token;
128
127
  try {
129
128
  token = await encryptValue(credentials.token);
130
- } catch (err) {
129
+ } catch {
131
130
  console.warn('Failed encrypt token, storing unencrypted instead');
132
131
  token = credentials.token;
133
132
  }
@@ -27,7 +27,7 @@ export default class BackportSession extends Session {
27
27
  logs = runSync('git',
28
28
  ['log', `-${num}`, '--format=%h', rev, '--', file]
29
29
  ).trim();
30
- } catch (e) {
30
+ } catch {
31
31
  return null;
32
32
  }
33
33
  if (!logs) {
@@ -273,7 +273,7 @@ export default class BackportSession extends Session {
273
273
  const upstreamCommit = this.getBranchCommit(`${this.upstream}/${branch}`);
274
274
 
275
275
  return localCommit === upstreamCommit;
276
- };
276
+ }
277
277
 
278
278
  isLocalBranchExists(branch) {
279
279
  try {
@@ -282,7 +282,7 @@ export default class BackportSession extends Session {
282
282
  ['rev-parse', '--verify', '--quiet', branch]
283
283
  );
284
284
  return true;
285
- } catch (e) {
285
+ } catch {
286
286
  return false;
287
287
  }
288
288
  }
@@ -0,0 +1,243 @@
1
+ import { basename } from 'node:path';
2
+
3
+ import { getPrURL } from './links.js';
4
+ import { IGNORE } from './run.js';
5
+
6
+ const BENCHMARK_DIR = 'benchmark';
7
+ // Entries in the benchmark folder that are not benchmark categories.
8
+ const NON_CATEGORY_DIRS = new Set(['fixtures']);
9
+
10
+ export default class BenchmarkSession {
11
+ constructor(cli, request, argv) {
12
+ this.cli = cli;
13
+ this.request = request;
14
+ this.argv = argv;
15
+ this.workflow = argv.workflow || 'benchmark.yml';
16
+ }
17
+
18
+ get prUrl() {
19
+ const { owner, repo, prid } = this.argv;
20
+ return getPrURL({ owner, repo, prid });
21
+ }
22
+
23
+ async getPullRequest() {
24
+ const { cli } = this;
25
+ cli.startSpinner(`Fetching data for ${this.prUrl}`);
26
+ const pr = await this.request.getPullRequest(this.prUrl);
27
+ if (!pr || !pr.head) {
28
+ cli.stopSpinner(
29
+ `Could not find pull request ${this.prUrl}`,
30
+ cli.SPINNER_STATUS.FAILED);
31
+ throw new Error(IGNORE);
32
+ }
33
+ cli.stopSpinner(`Fetched data for ${this.prUrl}`);
34
+ this.pr = pr;
35
+ return pr;
36
+ }
37
+
38
+ async getCategories() {
39
+ const { cli, pr } = this;
40
+ const head = pr.head;
41
+ // Prefer listing the benchmark folder on the PR head, falling back to the
42
+ // base branch if the head repository is unavailable (e.g. deleted fork).
43
+ const source = head.repo
44
+ ? { fullName: head.repo.full_name, ref: head.sha }
45
+ : { fullName: pr.base.repo.full_name, ref: pr.base.ref };
46
+ const [owner, repo] = source.fullName.split('/');
47
+
48
+ cli.startSpinner('Fetching available benchmark categories');
49
+ let entries;
50
+ try {
51
+ entries = await this.request.listDirectory({
52
+ owner,
53
+ repo,
54
+ path: BENCHMARK_DIR,
55
+ ref: source.ref
56
+ });
57
+ } catch (e) {
58
+ cli.stopSpinner(
59
+ 'Could not fetch benchmark categories',
60
+ cli.SPINNER_STATUS.FAILED);
61
+ throw e;
62
+ }
63
+
64
+ const categories = entries
65
+ .filter((entry) => entry.type === 'dir')
66
+ .map((entry) => entry.name)
67
+ .filter((name) => !NON_CATEGORY_DIRS.has(name))
68
+ .sort();
69
+
70
+ if (categories.length === 0) {
71
+ cli.stopSpinner(
72
+ `No benchmark categories found in ${source.fullName}/${BENCHMARK_DIR}`,
73
+ cli.SPINNER_STATUS.FAILED);
74
+ throw new Error(IGNORE);
75
+ }
76
+
77
+ cli.stopSpinner(`Found ${categories.length} benchmark categories`);
78
+ this.categories = categories;
79
+ return categories;
80
+ }
81
+
82
+ async getTouchedBenchmarks() {
83
+ const { cli, argv } = this;
84
+ const touched = [];
85
+ try {
86
+ const files = this.request.getPullRequestFiles({
87
+ owner: argv.owner,
88
+ repo: argv.repo,
89
+ prid: argv.prid
90
+ });
91
+ for await (const file of files) {
92
+ const match = /^benchmark\/([^/]+)\/(.+\.js)$/.exec(file.filename);
93
+ if (match && this.categories.includes(match[1])) {
94
+ touched.push({
95
+ category: match[1],
96
+ name: basename(match[2], '.js')
97
+ });
98
+ }
99
+ }
100
+ } catch {
101
+ // Not being able to fetch the changed files is not fatal: fall back to
102
+ // an empty pre-selection.
103
+ cli.warn('Could not determine which benchmark files the PR touches.');
104
+ }
105
+ this.touched = touched;
106
+ return touched;
107
+ }
108
+
109
+ // Guess relevant categories from the PR subsystem labels, matching a label
110
+ // to a category name up to a trailing plural "s" (e.g. `buffer` -> `buffers`).
111
+ categoriesFromLabels() {
112
+ const normalize = (name) => name.toLowerCase().replace(/s$| /, '');
113
+ const labels = (this.pr.labels ?? [])
114
+ .map((label) => (typeof label === 'string' ? label : label.name))
115
+ .filter(Boolean)
116
+ .map(normalize);
117
+ return new Set(
118
+ this.categories.filter((category) =>
119
+ labels.includes(normalize(category))));
120
+ }
121
+
122
+ async promptCategories() {
123
+ const { cli, categories } = this;
124
+ // Fall back to the PR labels when the PR does not touch any benchmark file.
125
+ const preselected = this.touched.length
126
+ ? new Set(this.touched.map((f) => f.category))
127
+ : this.categoriesFromLabels();
128
+
129
+ if (preselected.size) {
130
+ cli.info(`Based on the PR ${
131
+ this.touched.length ? 'changed files' : 'labels'
132
+ }, this/these benchmark(s) seem(s) relevant:`);
133
+ cli.info([...preselected].join(', '));
134
+ cli.info('If not relevant, do not forget to unselect it/them.');
135
+ }
136
+
137
+ const selected = await cli.promptCheckbox(
138
+ 'Select the benchmark categories to run:',
139
+ categories.map((name) => ({
140
+ name,
141
+ value: name,
142
+ checked: preselected.has(name)
143
+ })));
144
+
145
+ if (!selected || selected.length === 0) {
146
+ cli.warn('No benchmark category selected, aborting.');
147
+ throw new Error(IGNORE);
148
+ }
149
+ return selected;
150
+ }
151
+
152
+ async promptFilter(selected) {
153
+ const { cli, touched } = this;
154
+
155
+ // When a single benchmark file was touched and only its category is
156
+ // selected, pre-fill the filter with that file so it runs on its own.
157
+ const defaultAnswer =
158
+ touched.length === 1 && selected.length === 1 &&
159
+ selected[0] === touched[0].category
160
+ ? touched[0].name
161
+ : '';
162
+
163
+ const filter = await cli.prompt(
164
+ 'Substring to filter which benchmarks run (leave empty to run all):',
165
+ { questionType: cli.QUESTION_TYPE.INPUT, defaultAnswer });
166
+ return filter?.trim() || undefined;
167
+ }
168
+
169
+ async start() {
170
+ const { cli, argv } = this;
171
+
172
+ await this.getPullRequest();
173
+ await this.getCategories();
174
+ cli.startSpinner('Checking for benchmark files listed in the PR changes...');
175
+ await this.getTouchedBenchmarks();
176
+ cli.stopSpinner(`Found ${this.touched.length} benchmark file(s) in the PR changes`);
177
+ const selected = await this.promptCategories();
178
+ const filter = await this.promptFilter(selected);
179
+
180
+ const workflowOwner = argv.workflowOwner || argv.owner;
181
+ const workflowRepo = argv.workflowRepo || argv.repo;
182
+ const prRepo = `${argv.owner}/${argv.repo}`;
183
+ // The workflow token can only comment when it runs in the PR repository;
184
+ // default to not commenting when the workflow lives elsewhere.
185
+ const sameRepo = `${workflowOwner}/${workflowRepo}` === prRepo;
186
+ const postComment = await cli.prompt(
187
+ 'Should the workflow post the results as a comment on the PR?',
188
+ { defaultAnswer: sameRepo });
189
+
190
+ const category = selected.join(' ');
191
+ // Benchmark the commit provided on the command line, defaulting to the
192
+ // current PR head.
193
+ const commit = argv.commit || this.pr.head.sha;
194
+
195
+ const inputs = {
196
+ pr_id: `${argv.prid}`,
197
+ commit,
198
+ category
199
+ };
200
+ // The benchmark workflow checks out `inputs.repo || github.repository`; only
201
+ // set it when the workflow lives in a different repository than the PR.
202
+ if (!sameRepo) {
203
+ inputs.repo = prRepo;
204
+ }
205
+ if (filter) {
206
+ inputs.filter = filter;
207
+ }
208
+ if (argv.runs != null) {
209
+ inputs.runs = `${argv.runs}`;
210
+ }
211
+ inputs['post-comment'] = `${postComment}`;
212
+
213
+ cli.separator('Benchmark');
214
+ cli.table('PR:', this.prUrl);
215
+ cli.table('Commit:', commit);
216
+ cli.table('Categories:', category);
217
+ if (inputs.filter) cli.table('Filter:', inputs.filter);
218
+ if (inputs.runs) cli.table('Runs:', inputs.runs);
219
+ cli.table('Comment on PR:', postComment ? 'yes' : 'no');
220
+ cli.table('Workflow:',
221
+ `${workflowOwner}/${workflowRepo} (${this.workflow}@${argv.ref})`);
222
+
223
+ const confirmed = await cli.prompt(
224
+ 'Trigger the benchmark workflow with these settings?',
225
+ { defaultAnswer: true });
226
+ if (!confirmed) {
227
+ cli.warn('Aborted, no workflow was triggered.');
228
+ throw new Error(IGNORE);
229
+ }
230
+
231
+ cli.startSpinner('Dispatching benchmark workflow');
232
+ await this.request.dispatchWorkflow(this.workflow, {
233
+ owner: workflowOwner,
234
+ repo: workflowRepo,
235
+ ref: argv.ref,
236
+ inputs
237
+ });
238
+ cli.stopSpinner('Benchmark workflow dispatched');
239
+ cli.info('Follow the run at ' +
240
+ `https://github.com/${workflowOwner}/${workflowRepo}/actions/` +
241
+ `workflows/${this.workflow}`);
242
+ }
243
+ }
@@ -16,20 +16,31 @@ export default class CherryPick {
16
16
  lint,
17
17
  includeCVE,
18
18
  cveIds,
19
- vulnCveMap
19
+ vulnCveMap,
20
+ promptAmend,
21
+ skipMessagePrompt
20
22
  } = {}) {
21
23
  this.prid = prid;
22
24
  this.cli = cli;
23
25
  this.dir = dir;
24
26
  this.upstream = upstream;
25
27
  this.gpgSign = gpgSign;
26
- this.options = { owner, repo, lint, includeCVE, cveIds, vulnCveMap };
28
+ this.skipMessagePrompt = skipMessagePrompt ?? false;
29
+ this.options = {
30
+ owner, repo, lint, includeCVE, cveIds, vulnCveMap, promptAmend
31
+ };
27
32
  }
28
33
 
29
34
  get includeCVE() {
30
35
  return this.options.includeCVE ?? false;
31
36
  }
32
37
 
38
+ // Whether to ask before amending the PR to the proposal. Callers that already
39
+ // asked opt out of it.
40
+ get promptAmend() {
41
+ return this.options.promptAmend ?? true;
42
+ }
43
+
33
44
  get cveIds() {
34
45
  return this.options.cveIds ?? null;
35
46
  }
@@ -82,14 +93,22 @@ export default class CherryPick {
82
93
  }, false, cli);
83
94
  this.expectedCommitShas =
84
95
  metadata.data.commits.map(({ commit }) => commit.oid);
85
-
86
- const amend = await cli.prompt(
87
- 'Would you like to amend this PR to the proposal?',
88
- { default: true }
89
- );
90
-
91
- if (!amend) {
92
- return true;
96
+ // In security releases the fix may have already landed publicly, so the
97
+ // PR is merged and refs/pull/<prid>/merge no longer exists - pick the
98
+ // commits as they landed on the base branch instead.
99
+ this.landedCommitSha = metadata.data.pr.merged
100
+ ? metadata.data.pr.mergeCommit?.oid
101
+ : undefined;
102
+
103
+ if (this.promptAmend) {
104
+ const amend = await cli.prompt(
105
+ 'Would you like to amend this PR to the proposal?',
106
+ { default: true }
107
+ );
108
+
109
+ if (!amend) {
110
+ return true;
111
+ }
93
112
  }
94
113
 
95
114
  try {
@@ -117,7 +136,7 @@ export default class CherryPick {
117
136
  { captureStdout: 'lines' });
118
137
 
119
138
  if (commitInfo.shas.length !== 1) {
120
- const fixupAll = await cli.prompt(
139
+ const fixupAll = this.skipMessagePrompt || await cli.prompt(
121
140
  `${subjects.length} commits from the original PR are going to be` +
122
141
  'squashed into a single commit. OK to proceed?', {
123
142
  defaultAnswer: true
@@ -151,3 +170,5 @@ CherryPick.prototype.downloadAndPatch = LandingSession.prototype.downloadAndPatc
151
170
  CherryPick.prototype.validateLint = LandingSession.prototype.validateLint;
152
171
  CherryPick.prototype.getMessagePath = LandingSession.prototype.getMessagePath;
153
172
  CherryPick.prototype.saveMessage = LandingSession.prototype.saveMessage;
173
+ CherryPick.prototype.generateAmendedMessage =
174
+ LandingSession.prototype.generateAmendedMessage;
@@ -1,4 +1,3 @@
1
- import _ from 'lodash';
2
1
  import chalk from 'chalk';
3
2
 
4
3
  import { getMachineUrl, parsePRFromURL } from '../links.js';
@@ -15,6 +14,11 @@ import {
15
14
 
16
15
  const { FAILURE_TYPES_NAME } = CIFailureParser;
17
16
 
17
+ function uniqBy(array, key) {
18
+ const seen = new Set();
19
+ return array.filter((item) => !seen.has(item[key]) && seen.add(item[key]));
20
+ }
21
+
18
22
  export class FailureAggregator {
19
23
  constructor(cli, data) {
20
24
  this.cli = cli;
@@ -24,37 +28,29 @@ export class FailureAggregator {
24
28
  }
25
29
 
26
30
  aggregate() {
27
- const failures = this.failures;
28
- const groupedByReason = _.chain(failures)
29
- .groupBy(getHighlight)
30
- .toPairs()
31
- .sortBy(0)
32
- .value();
31
+ const groupedByReason = Object.groupBy(this.failures, getHighlight);
33
32
  const data = [];
34
- for (const item of groupedByReason) {
35
- const [reason, failures] = item;
33
+ for (const reason of Object.keys(groupedByReason).sort()) {
34
+ const failures = groupedByReason[reason];
36
35
  // Uncomment this and redirect stderr away to see matched highlights
37
36
  // console.log('HIGHLIGHT', reason);
38
37
 
39
38
  // If multiple sub builds of one PR are failed by the same reason,
40
39
  // we'll only take one of those builds, as that might be a genuine failure
41
- const prs = _.chain(failures)
42
- .uniqBy('source')
43
- .sortBy((f) => parseJobFromURL(f.upstream).jobid)
44
- .map((item) => ({ source: item.source, upstream: item.upstream }))
45
- .value();
46
- const machines = _.uniqBy(
40
+ const prs = uniqBy(failures, 'source')
41
+ .map(({ source, upstream }) => ({ source, upstream, _id: parseJobFromURL(upstream).jobid }))
42
+ .sort((a, b) => a._id - b._id);
43
+ const machines = uniqBy(
47
44
  failures.map(f => ({ hostname: f.builtOn, url: f.url })),
48
45
  'hostname');
49
46
  data.push({
50
47
  reason, type: failures[0].type, failures, prs, machines
51
48
  });
52
- };
49
+ }
53
50
 
54
- const groupedByType = _.groupBy(data, 'type');
55
- for (const type of Object.keys(groupedByType)) {
56
- groupedByType[type] =
57
- _.sortBy(groupedByType[type], r => 0 - (r.prs.length));
51
+ const groupedByType = Object.groupBy(data, ({ type }) => type);
52
+ for (const group of Object.values(groupedByType)) {
53
+ group.sort((a, b) => b.prs.length - a.prs.length);
58
54
  }
59
55
  this.aggregates = groupedByType;
60
56
  return groupedByType;
package/lib/ci/run_ci.js CHANGED
@@ -38,7 +38,7 @@ export class RunPRJob {
38
38
  try {
39
39
  const { crumb } = await this.request.json(CI_CRUMB_URL);
40
40
  return crumb;
41
- } catch (e) {
41
+ } catch {
42
42
  return false;
43
43
  }
44
44
  }
package/lib/cli.js CHANGED
@@ -94,6 +94,16 @@ export default class CLI {
94
94
  return answer;
95
95
  }
96
96
 
97
+ async promptRadio(message, choices, opts = undefined) {
98
+ if (Object.hasOwn(opts, 'defaultAnswer')) {
99
+ opts.default = opts.defaultAnswer;
100
+ }
101
+ return inquirer.select({
102
+ ...opts,
103
+ message, choices,
104
+ });
105
+ }
106
+
97
107
  async promptCheckbox(message, choices) {
98
108
  if (this.assumeYes) {
99
109
  return choices.filter((c) => c.checked).map((c) => c.value);
@@ -102,6 +112,20 @@ export default class CLI {
102
112
  return inquirer.checkbox({ message, choices });
103
113
  }
104
114
 
115
+ async promptSelect(message, choices, opts = {}) {
116
+ const defaultAnswer = opts.defaultAnswer ?? choices[0]?.value;
117
+
118
+ if (this.assumeYes) {
119
+ return defaultAnswer;
120
+ }
121
+
122
+ return inquirer.select({
123
+ message,
124
+ choices,
125
+ default: defaultAnswer
126
+ });
127
+ }
128
+
105
129
  setAssumeYes() {
106
130
  this.assumeYes = true;
107
131
  }
@@ -193,7 +217,7 @@ export default class CLI {
193
217
  setExitCode(statusCode) {
194
218
  process.exitCode = statusCode;
195
219
  }
196
- };
220
+ }
197
221
 
198
222
  CLI.SPINNER_STATUS = SPINNER_STATUS;
199
223
  CLI.QUESTION_TYPE = QUESTION_TYPE;
package/lib/config.js CHANGED
@@ -40,7 +40,7 @@ export function getMergedConfig(dir, home, additional) {
40
40
  ...Object.getOwnPropertyDescriptors(additional),
41
41
  })
42
42
  : mergedConfig;
43
- };
43
+ }
44
44
  export function clearCachedConfig() {
45
45
  mergedConfig = null;
46
46
  }
@@ -117,7 +117,7 @@ export function getConfig(configType, dir, raw = false) {
117
117
  } catch (cause) {
118
118
  throw new Error('Unable to parse config file ' + configPath, { cause });
119
119
  }
120
- };
120
+ }
121
121
 
122
122
  function getConfigPath(configType, dir) {
123
123
  switch (configType) {
@@ -135,7 +135,7 @@ function getConfigPath(configType, dir) {
135
135
  default:
136
136
  throw Error('Invalid configType');
137
137
  }
138
- };
138
+ }
139
139
 
140
140
  function writeConfig(configType, obj, dir) {
141
141
  const configPath = getConfigPath(configType, dir);
@@ -158,20 +158,20 @@ function writeConfig(configType, obj, dir) {
158
158
  }
159
159
  writeJson(configPath, obj);
160
160
  return configPath;
161
- };
161
+ }
162
162
 
163
163
  export function updateConfig(configType, obj, dir) {
164
164
  const config = getConfig(configType, dir, true);
165
165
  writeConfig(configType, Object.assign(config, obj), dir);
166
- };
166
+ }
167
167
 
168
168
  export function getHomeDir(home) {
169
169
  if (process.env.XDG_CONFIG_HOME) {
170
170
  return process.env.XDG_CONFIG_HOME;
171
171
  }
172
172
  return home || os.homedir();
173
- };
173
+ }
174
174
 
175
175
  export function getNcuDir(dir) {
176
176
  return path.join(dir || process.cwd(), '.ncu');
177
- };
177
+ }