@node-core/utils 5.16.2 → 6.1.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.
package/README.md CHANGED
@@ -76,18 +76,29 @@ Optionally, if you want to grant write access so `git-node` can write comments:
76
76
 
77
77
  You can also edit the permission of existing tokens later.
78
78
 
79
- After the token is generated, create an rc file with the following content:
80
- (`~/.ncurc` or `$XDG_CONFIG_HOME/ncurc`):
81
-
82
- ```json
83
- {
84
- "username": "your_github_username",
85
- "token": "token_that_you_created"
86
- }
79
+ After the token is generated, you can give it to NCU using:
80
+
81
+ <details open name="set-token"><summary>With encryption (Recommended)</summary>
82
+
83
+ ```sh
84
+ ncu-config set username your_github_username
85
+ # Do not provide the token in the CLI, `ncu-config` will prompt you for it.
86
+ ncu-config set -x token
87
+ ```
88
+
89
+ Note: Encryption is available only if you have `gpg` setup on your machine.
90
+
91
+ </details>
92
+
93
+ <details name="set-token"><summary>Without encryption</summary>
94
+
95
+ ```sh
96
+ ncu-config set username your_github_username
97
+ # Do not provide the token in the CLI, `ncu-config` will prompt you for it.
98
+ ncu-config set token
87
99
  ```
88
100
 
89
- Note: you could use `ncu-config` to configure these variables, but it's not
90
- recommended to leave your tokens in your command line history.
101
+ </details>
91
102
 
92
103
  ### Setting up Jenkins credentials
93
104
 
@@ -108,27 +119,24 @@ To obtain the Jenkins API token
108
119
  `~/.ncurc.gpg` or `$XDG_CONFIG_HOME/ncurc.gpg`) with `jenkins_token` as key,
109
120
  like this:
110
121
 
111
- ```json
112
- {
113
- "username": "your_github_username",
114
- "token": "your_github_token",
115
- "jenkins_token": "your_jenkins_token"
116
- }
122
+ <details open name="set-jenkins-token"><summary>With encryption (recommended)</summary>
123
+
124
+ ```sh
125
+ ncu-config set -x jenkins_token
117
126
  ```
118
127
 
119
- ### Protecting your credentials
128
+ Note: Encryption is available only if you have `gpg` setup on your machine.
120
129
 
121
- If you have `gpg` installed and setup on your local machine, it is strongly recommended
122
- to store an encrypted version of this file:
130
+ </details>
131
+ <details name="set-jenkins-token"><summary>Without encryption</summary>
132
+
133
+ ```sh
134
+ ncu-config set jenkins_token
135
+ ```
123
136
 
124
- ```console
125
- $ gpg --default-recipient-self --encrypt ~/.ncurc
126
- $ rm ~/.ncurc
127
- ```
137
+ </details>
128
138
 
129
- The credentials are now encrypted in `~/.ncurc.gpg` and everytime it's needed,
130
- node-core-utils will invoke `gpg` that may ask you to decrypt it using
131
- your default key via pinentry.
139
+ ### Protecting your credentials
132
140
 
133
141
  Put the following entries into your
134
142
  [global `gitignore` file](https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreexcludesFile)
package/bin/ncu-config.js CHANGED
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import * as readline from 'node:readline/promises';
4
+ import { stdin as input, stdout as output } from 'node:process';
5
+
3
6
  import yargs from 'yargs';
4
7
  import { hideBin } from 'yargs/helpers';
5
8
 
6
9
  import {
7
- getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG
10
+ getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG,
11
+ encryptValue
8
12
  } from '../lib/config.js';
9
13
  import { setVerbosityFromEnv } from '../lib/verbosity.js';
10
14
 
@@ -13,10 +17,15 @@ setVerbosityFromEnv();
13
17
  const args = yargs(hideBin(process.argv))
14
18
  .completion('completion')
15
19
  .command({
16
- command: 'set <key> <value>',
20
+ command: 'set <key> [<value>]',
17
21
  desc: 'Set a config variable',
18
22
  builder: (yargs) => {
19
23
  yargs
24
+ .option('encrypt', {
25
+ describe: 'Store the value encrypted using gpg',
26
+ alias: 'x',
27
+ type: 'boolean'
28
+ })
20
29
  .positional('key', {
21
30
  describe: 'key of the configuration',
22
31
  type: 'string'
@@ -61,8 +70,6 @@ const args = yargs(hideBin(process.argv))
61
70
  .conflicts('global', 'project')
62
71
  .help();
63
72
 
64
- const argv = args.parse();
65
-
66
73
  function getConfigType(argv) {
67
74
  if (argv.global) {
68
75
  return { configName: 'global', configType: GLOBAL_CONFIG };
@@ -73,9 +80,19 @@ function getConfigType(argv) {
73
80
  return { configName: 'local', configType: LOCAL_CONFIG };
74
81
  }
75
82
 
76
- function setHandler(argv) {
83
+ async function setHandler(argv) {
77
84
  const { configName, configType } = getConfigType(argv);
78
85
  const config = getConfig(configType);
86
+ if (!argv.value) {
87
+ const rl = readline.createInterface({ input, output });
88
+ argv.value = await rl.question('What value do you want to set? ');
89
+ rl.close();
90
+ } else if (argv.encrypt) {
91
+ console.warn('Passing sensitive config value via the shell is discouraged');
92
+ }
93
+ if (argv.encrypt) {
94
+ argv.value = await encryptValue(argv.value);
95
+ }
79
96
  console.log(
80
97
  `Updating ${configName} configuration ` +
81
98
  `[${argv.key}]: ${config[argv.key]} -> ${argv.value}`);
@@ -96,6 +113,8 @@ function listHandler(argv) {
96
113
  }
97
114
  }
98
115
 
116
+ const argv = await args.parse();
117
+
99
118
  if (!['get', 'set', 'list'].includes(argv._[0])) {
100
119
  args.showHelp();
101
120
  }
@@ -50,8 +50,14 @@ const releaseOptions = {
50
50
  type: 'boolean'
51
51
  },
52
52
  security: {
53
- describe: 'Demarcate the new security release as a security release',
54
- type: 'boolean'
53
+ describe: 'Demarcate the new security release as a security release. ' +
54
+ 'Optionally provide path to security-release repository for CVE auto-population',
55
+ type: 'string',
56
+ coerce: (arg) => {
57
+ // If --security=path is used, return the path
58
+ if (arg === '' || arg === true) return true;
59
+ return arg;
60
+ }
55
61
  },
56
62
  skipBranchDiff: {
57
63
  describe: 'Skips the initial branch-diff check when preparing releases',
@@ -2,12 +2,12 @@ import path from 'node:path';
2
2
 
3
3
  import logSymbols from 'log-symbols';
4
4
 
5
- import { minor, major, backport } from '../../lib/update-v8/index.js';
5
+ import { minor, major, backport, deps } from '../../lib/update-v8/index.js';
6
6
  import { defaultBaseDir } from '../../lib/update-v8/constants.js';
7
7
  import { checkCwd } from '../../lib/update-v8/common.js';
8
8
  import { forceRunAsync } from '../../lib/run.js';
9
9
 
10
- export const command = 'v8 [major|minor|backport]';
10
+ export const command = 'v8 [major|minor|backport|deps]';
11
11
  export const describe = 'Update or patch the V8 engine';
12
12
 
13
13
  export function builder(yargs) {
@@ -26,6 +26,11 @@ export function builder(yargs) {
26
26
  describe: 'Bump the NODE_MODULE_VERSION constant',
27
27
  default: true
28
28
  });
29
+ yargs.option('concurrent', {
30
+ type: 'boolean',
31
+ describe: 'Update dependencies concurrently',
32
+ default: true,
33
+ });
29
34
  }
30
35
  })
31
36
  .command({
@@ -64,6 +69,19 @@ export function builder(yargs) {
64
69
  });
65
70
  }
66
71
  })
72
+ .command({
73
+ command: 'deps',
74
+ desc: 'Update V8 dependencies from the DEPS file',
75
+ handler,
76
+ builder: (yargs) => {
77
+ yargs
78
+ .option('concurrent', {
79
+ type: 'boolean',
80
+ describe: 'Update dependencies concurrently',
81
+ default: true,
82
+ });
83
+ }
84
+ })
67
85
  .demandCommand(1, 'Please provide a valid command')
68
86
  .option('node-dir', {
69
87
  describe: 'Directory of a Node.js clone',
@@ -126,6 +144,8 @@ export function handler(argv) {
126
144
  return major(options);
127
145
  case 'backport':
128
146
  return backport(options);
147
+ case 'deps':
148
+ return deps(options);
129
149
  }
130
150
  })
131
151
  .catch((err) => {
package/lib/auth.js CHANGED
@@ -3,7 +3,7 @@ import { ClientRequest } from 'node:http';
3
3
 
4
4
  import ghauth from 'ghauth';
5
5
 
6
- import { getMergedConfig, getNcurcPath } from './config.js';
6
+ import { clearCachedConfig, encryptValue, getMergedConfig, getNcurcPath } from './config.js';
7
7
 
8
8
  export default lazy(auth);
9
9
 
@@ -60,67 +60,90 @@ function encode(name, token) {
60
60
  return Buffer.from(`${name}:${token}`).toString('base64');
61
61
  }
62
62
 
63
+ function setOwnProperty(target, key, value) {
64
+ return Object.defineProperty(target, key, {
65
+ __proto__: null,
66
+ configurable: true,
67
+ enumerable: true,
68
+ value
69
+ });
70
+ }
71
+
63
72
  // TODO: support jenkins only...or not necessary?
64
73
  // TODO: make this a class with dependency (CLI) injectable for testing
65
74
  async function auth(
66
75
  options = { github: true },
67
76
  githubAuth = ghauth) {
68
- const result = {};
77
+ const result = {
78
+ get github() {
79
+ let username;
80
+ let token;
81
+ try {
82
+ ({ username, token } = getMergedConfig());
83
+ } catch (e) {
84
+ // Ignore error and prompt
85
+ }
86
+
87
+ check(username, token);
88
+ const github = encode(username, token);
89
+ setOwnProperty(result, 'github', github);
90
+ return github;
91
+ },
92
+
93
+ get jenkins() {
94
+ const { username, jenkins_token } = getMergedConfig();
95
+ if (!username || !jenkins_token) {
96
+ errorExit(
97
+ 'Get your Jenkins API token in https://ci.nodejs.org/me/security ' +
98
+ 'and run the following command to add it to your ncu config: ' +
99
+ 'ncu-config --global set -x jenkins_token'
100
+ );
101
+ };
102
+ check(username, jenkins_token);
103
+ const jenkins = encode(username, jenkins_token);
104
+ setOwnProperty(result, 'jenkins', jenkins);
105
+ return jenkins;
106
+ },
107
+
108
+ get h1() {
109
+ const { h1_username, h1_token } = getMergedConfig();
110
+ check(h1_username, h1_token);
111
+ const h1 = encode(h1_username, h1_token);
112
+ setOwnProperty(result, 'h1', h1);
113
+ return h1;
114
+ }
115
+ };
69
116
  if (options.github) {
70
- let username;
71
- let token;
117
+ let config;
72
118
  try {
73
- ({ username, token } = getMergedConfig());
74
- } catch (e) {
75
- // Ignore error and prompt
119
+ config = getMergedConfig();
120
+ } catch {
121
+ config = {};
76
122
  }
77
-
78
- if (!username || !token) {
123
+ if (!Object.hasOwn(config, 'token') || !Object.hasOwn(config, 'username')) {
79
124
  process.stdout.write(
80
125
  'If this is your first time running this command, ' +
81
126
  'follow the instructions to create an access token' +
82
127
  '. If you prefer to create it yourself on Github, ' +
83
128
  'see https://github.com/nodejs/node-core-utils/blob/main/README.md.\n');
84
129
  const credentials = await tryCreateGitHubToken(githubAuth);
85
- username = credentials.user;
86
- token = credentials.token;
130
+ const username = credentials.user;
131
+ let token;
132
+ try {
133
+ token = await encryptValue(credentials.token);
134
+ } catch (err) {
135
+ console.warn('Failed encrypt token, storing unencrypted instead');
136
+ token = credentials.token;
137
+ }
87
138
  const json = JSON.stringify({ username, token }, null, 2);
88
139
  fs.writeFileSync(getNcurcPath(), json, {
89
140
  mode: 0o600 /* owner read/write */
90
141
  });
91
142
  // Try again reading the file
92
- ({ username, token } = getMergedConfig());
143
+ clearCachedConfig();
93
144
  }
94
- check(username, token);
95
- result.github = encode(username, token);
96
145
  }
97
146
 
98
- if (options.jenkins) {
99
- const { username, jenkins_token } = getMergedConfig();
100
- if (!username || !jenkins_token) {
101
- errorExit(
102
- 'Get your Jenkins API token in https://ci.nodejs.org/me/configure ' +
103
- 'and run the following command to add it to your ncu config: ' +
104
- 'ncu-config --global set jenkins_token TOKEN'
105
- );
106
- };
107
- check(username, jenkins_token);
108
- result.jenkins = encode(username, jenkins_token);
109
- }
110
-
111
- if (options.h1) {
112
- const { h1_username, h1_token } = getMergedConfig();
113
- if (!h1_username || !h1_token) {
114
- errorExit(
115
- 'Get your HackerOne API token in ' +
116
- 'https://docs.hackerone.com/organizations/api-tokens.html ' +
117
- 'and run the following command to add it to your ncu config: ' +
118
- 'ncu-config --global set h1_token TOKEN or ' +
119
- 'ncu-config --global set h1_username USERNAME'
120
- );
121
- };
122
- result.h1 = encode(h1_username, h1_token);
123
- }
124
147
  return result;
125
148
  }
126
149
 
@@ -14,20 +14,30 @@ export default class CherryPick {
14
14
  upstream,
15
15
  gpgSign,
16
16
  lint,
17
- includeCVE
17
+ includeCVE,
18
+ cveIds,
19
+ vulnCveMap
18
20
  } = {}) {
19
21
  this.prid = prid;
20
22
  this.cli = cli;
21
23
  this.dir = dir;
22
24
  this.upstream = upstream;
23
25
  this.gpgSign = gpgSign;
24
- this.options = { owner, repo, lint, includeCVE };
26
+ this.options = { owner, repo, lint, includeCVE, cveIds, vulnCveMap };
25
27
  }
26
28
 
27
29
  get includeCVE() {
28
30
  return this.options.includeCVE ?? false;
29
31
  }
30
32
 
33
+ get cveIds() {
34
+ return this.options.cveIds ?? null;
35
+ }
36
+
37
+ get vulnCveMap() {
38
+ return this.options.vulnCveMap ?? null;
39
+ }
40
+
31
41
  get owner() {
32
42
  return this.options.owner || 'nodejs';
33
43
  }
package/lib/config.js CHANGED
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import { readJson, writeJson } from './file.js';
5
5
  import { existsSync, mkdtempSync, rmSync } from 'node:fs';
6
6
  import { spawnSync } from 'node:child_process';
7
+ import { forceRunAsync, runSync } from './run.js';
7
8
 
8
9
  export const GLOBAL_CONFIG = Symbol('globalConfig');
9
10
  export const PROJECT_CONFIG = Symbol('projectConfig');
@@ -18,32 +19,95 @@ export function getNcurcPath() {
18
19
  }
19
20
  }
20
21
 
21
- export function getMergedConfig(dir, home) {
22
- const globalConfig = getConfig(GLOBAL_CONFIG, home);
23
- const projectConfig = getConfig(PROJECT_CONFIG, dir);
24
- const localConfig = getConfig(LOCAL_CONFIG, dir);
25
- return Object.assign(globalConfig, projectConfig, localConfig);
22
+ let mergedConfig;
23
+ export function getMergedConfig(dir, home, additional) {
24
+ if (mergedConfig == null) {
25
+ const globalConfig = getConfig(GLOBAL_CONFIG, home);
26
+ const projectConfig = getConfig(PROJECT_CONFIG, dir);
27
+ const localConfig = getConfig(LOCAL_CONFIG, dir);
28
+ mergedConfig = Object.assign(globalConfig, projectConfig, localConfig, additional);
29
+ }
30
+ return mergedConfig;
26
31
  };
32
+ export function clearCachedConfig() {
33
+ mergedConfig = null;
34
+ }
35
+
36
+ export async function encryptValue(input) {
37
+ console.warn('Spawning gpg to encrypt the config value');
38
+ return forceRunAsync(
39
+ process.env.GPG_BIN || 'gpg',
40
+ ['--default-recipient-self', '--encrypt', '--armor'],
41
+ {
42
+ captureStdout: true,
43
+ ignoreFailure: false,
44
+ input
45
+ }
46
+ );
47
+ }
48
+
49
+ function setOwnProperty(target, key, value) {
50
+ return Object.defineProperty(target, key, {
51
+ __proto__: null,
52
+ configurable: true,
53
+ enumerable: true,
54
+ value
55
+ });
56
+ }
57
+ function addEncryptedPropertyGetter(target, key, input) {
58
+ if (input?.startsWith?.('-----BEGIN PGP MESSAGE-----\n')) {
59
+ return Object.defineProperty(target, key, {
60
+ __proto__: null,
61
+ configurable: true,
62
+ get() {
63
+ // Using an error object to get a stack trace in debug mode.
64
+ const warn = new Error(
65
+ `The config value for ${key} is encrypted, spawning gpg to decrypt it...`
66
+ );
67
+ console.warn(setOwnProperty(warn, 'name', 'Warning'));
68
+ const value = runSync(process.env.GPG_BIN || 'gpg', ['--decrypt'], { input });
69
+ setOwnProperty(target, key, value);
70
+ return value;
71
+ },
72
+ set(newValue) {
73
+ if (!addEncryptedPropertyGetter(target, key, newValue)) {
74
+ throw new Error(
75
+ 'Refusing to override an encrypted value with a non-encrypted one. ' +
76
+ 'Please use an encrypted one, or delete the config key first.'
77
+ );
78
+ }
79
+ }
80
+ });
81
+ }
82
+ }
27
83
 
28
- export function getConfig(configType, dir) {
84
+ export function getConfig(configType, dir, raw = false) {
29
85
  const configPath = getConfigPath(configType, dir);
30
86
  const encryptedConfigPath = configPath + '.gpg';
31
87
  if (existsSync(encryptedConfigPath)) {
32
88
  console.warn('Encrypted config detected, spawning gpg to decrypt it...');
33
89
  const { status, stdout } =
34
- spawnSync('gpg', ['--decrypt', encryptedConfigPath]);
90
+ spawnSync(process.env.GPG_BIN || 'gpg', ['--decrypt', encryptedConfigPath]);
35
91
  if (status === 0) {
36
92
  return JSON.parse(stdout.toString('utf-8'));
37
93
  }
38
94
  }
39
95
  try {
40
- return readJson(configPath);
96
+ const json = readJson(configPath);
97
+ if (!raw) {
98
+ // Raw config means encrypted values are returned as is.
99
+ // Otherwise we install getters to decrypt them when accessed.
100
+ for (const [key, val] of Object.entries(json)) {
101
+ addEncryptedPropertyGetter(json, key, val);
102
+ }
103
+ }
104
+ return json;
41
105
  } catch (cause) {
42
106
  throw new Error('Unable to parse config file ' + configPath, { cause });
43
107
  }
44
108
  };
45
109
 
46
- export function getConfigPath(configType, dir) {
110
+ function getConfigPath(configType, dir) {
47
111
  switch (configType) {
48
112
  case GLOBAL_CONFIG:
49
113
  return getNcurcPath();
@@ -61,7 +125,7 @@ export function getConfigPath(configType, dir) {
61
125
  }
62
126
  };
63
127
 
64
- export function writeConfig(configType, obj, dir) {
128
+ function writeConfig(configType, obj, dir) {
65
129
  const configPath = getConfigPath(configType, dir);
66
130
  const encryptedConfigPath = configPath + '.gpg';
67
131
  if (existsSync(encryptedConfigPath)) {
@@ -69,7 +133,7 @@ export function writeConfig(configType, obj, dir) {
69
133
  const tmpFile = path.join(tmpDir, 'config.json');
70
134
  try {
71
135
  writeJson(tmpFile, obj);
72
- const { status } = spawnSync('gpg',
136
+ const { status } = spawnSync(process.env.GPG_BIN || 'gpg',
73
137
  ['--default-recipient-self', '--yes', '--encrypt', '--output', encryptedConfigPath, tmpFile]
74
138
  );
75
139
  if (status !== 0) {
@@ -85,7 +149,7 @@ export function writeConfig(configType, obj, dir) {
85
149
  };
86
150
 
87
151
  export function updateConfig(configType, obj, dir) {
88
- const config = getConfig(configType, dir);
152
+ const config = getConfig(configType, dir, true);
89
153
  writeConfig(configType, Object.assign(config, obj), dir);
90
154
  };
91
155
 
@@ -345,12 +345,39 @@ export default class LandingSession extends Session {
345
345
  }
346
346
 
347
347
  if (!containCVETrailer && this.includeCVE) {
348
- const cveID = await cli.prompt(
349
- 'Git found no CVE-ID trailer in the original commit message. ' +
350
- 'Please, provide the CVE-ID',
351
- { questionType: 'input', defaultAnswer: 'CVE-2023-XXXXX' }
352
- );
353
- amended.push('CVE-ID: ' + cveID);
348
+ let cveID;
349
+ if (this.cveIds && this.cveIds.length > 0) {
350
+ cveID = this.cveIds.join(', ');
351
+ cli.ok(`Using CVE-ID from vulnerabilities.json: ${cveID}`);
352
+ } else {
353
+ // Fallback: check if the original commit has a PR-URL trailer
354
+ // and use it to look up CVE-IDs from the vulnerabilities map
355
+ if (this.vulnCveMap) {
356
+ const prUrlMatch = original.match(PR_RE);
357
+ if (prUrlMatch) {
358
+ const prUrl = prUrlMatch[1];
359
+ const cveIds = this.vulnCveMap.get(prUrl);
360
+ if (cveIds && cveIds.length > 0) {
361
+ cveID = cveIds.join(', ');
362
+ cli.ok(`Using CVE-ID from backport PR-URL (${prUrl}): ${cveID}`);
363
+ }
364
+ }
365
+ }
366
+
367
+ // Fall back to prompt if still not found
368
+ if (!cveID) {
369
+ cveID = await cli.prompt(
370
+ 'Git found no CVE-ID trailer in the original commit message. ' +
371
+ 'Please, provide the CVE-ID or leave it empty',
372
+ { questionType: 'input', defaultAnswer: 'CVE-2026-XXXXX' }
373
+ );
374
+ }
375
+ }
376
+ // Some commits might not address a vulnerability, but it is necessary
377
+ // for the security release to happen.
378
+ if (cveID !== '') {
379
+ amended.push('CVE-ID: ' + cveID);
380
+ }
354
381
  }
355
382
 
356
383
  const message = amended.join('\n');
@@ -464,8 +491,8 @@ export default class LandingSession extends Session {
464
491
  const url = `https://github.com/${owner}/${repo}/pull/${prid}`;
465
492
  cli.log(`2. Post "Landed in ${willBeLanded}" in ${url}`);
466
493
  if (isGhAvailable()) {
467
- cli.log(` gh pr comment ${prid} --body "Landed in ${willBeLanded}"`);
468
- cli.log(` gh pr close ${prid}`);
494
+ cli.log(` gh pr comment ${url} --body "Landed in ${willBeLanded}"`);
495
+ cli.log(` gh pr close ${url}`);
469
496
  }
470
497
  }
471
498
 
package/lib/pr_checker.js CHANGED
@@ -19,7 +19,6 @@ const { FROM_COMMENT, FROM_REVIEW_COMMENT } = REVIEW_SOURCES;
19
19
 
20
20
  const SECOND = 1000;
21
21
  const MINUTE = SECOND * 60;
22
- const HOUR = MINUTE * 60;
23
22
 
24
23
  const WAIT_TIME_MULTI_APPROVAL = 24 * 2;
25
24
  const WAIT_TIME_SINGLE_APPROVAL = 24 * 7;
@@ -196,10 +195,18 @@ export default class PRChecker {
196
195
 
197
196
  const createTime = new Date(this.pr.createdAt);
198
197
  const msFromCreateTime = now.getTime() - createTime.getTime();
199
- const minutesFromCreateTime = Math.ceil(msFromCreateTime / MINUTE);
200
- const hoursFromCreateTime = Math.ceil(msFromCreateTime / HOUR);
201
- let timeLeftMulti = this.waitTimeMultiApproval - hoursFromCreateTime;
202
- const timeLeftSingle = this.waitTimeSingleApproval - hoursFromCreateTime;
198
+ const minutesFromCreateTime = msFromCreateTime / MINUTE;
199
+ const timeLeftMulti = this.waitTimeMultiApproval * 60 - minutesFromCreateTime;
200
+ const timeLeftSingle = this.waitTimeSingleApproval * 60 - minutesFromCreateTime;
201
+ const timeToText = (time, liaison_word = undefined) => {
202
+ let unity = 'minute';
203
+ if (time > 59) {
204
+ unity = 'hour';
205
+ time /= 60;
206
+ }
207
+ time = Math.ceil(time);
208
+ return `${time} ${liaison_word ? liaison_word + ' ' : ''}${unity}${time === 1 ? '' : 's'}`;
209
+ };
203
210
 
204
211
  if (approved.length >= 2) {
205
212
  if (isFastTracked || isCodeAndLearn) {
@@ -208,15 +215,9 @@ export default class PRChecker {
208
215
  if (timeLeftMulti < 0) {
209
216
  return true;
210
217
  }
211
- if (timeLeftMulti === 0) {
212
- const timeLeftMins =
213
- this.waitTimeMultiApproval * 60 - minutesFromCreateTime;
214
- cli.error(`This PR needs to wait ${timeLeftMins} ` +
215
- `more minutes to land${fastTrackAppendix}`);
216
- return false;
217
- }
218
- cli.error(`This PR needs to wait ${timeLeftMulti} more ` +
219
- `hours to land${fastTrackAppendix}`);
218
+ cli.error(
219
+ `This PR needs to wait ${timeToText(timeLeftMulti, 'more')} to land${fastTrackAppendix}`
220
+ );
220
221
  return false;
221
222
  }
222
223
 
@@ -224,10 +225,9 @@ export default class PRChecker {
224
225
  if (timeLeftSingle < 0) {
225
226
  return true;
226
227
  }
227
- timeLeftMulti = timeLeftMulti < 0 || isFastTracked ? 0 : timeLeftMulti;
228
- cli.error(`This PR needs to wait ${timeLeftSingle} more hours to land ` +
229
- `(or ${timeLeftMulti} hours if there is one more approval)` +
230
- fastTrackAppendix);
228
+ cli.error(`This PR needs to wait ${timeToText(timeLeftSingle, 'more')} to land (or ${
229
+ timeToText(timeLeftMulti < 0 || isFastTracked ? 0 : timeLeftMulti)
230
+ } if there is one more approval)${fastTrackAppendix}`);
231
231
  return false;
232
232
  }
233
233
  }