@node-core/utils 7.2.2 → 7.3.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/bin/ncu-ci.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  import {
24
24
  RunPRJob
25
25
  } from '../lib/ci/run_ci.js';
26
+ import { ResumePRJob } from '../lib/ci/resume_ci.js';
26
27
  import { writeJson, writeFile } from '../lib/file.js';
27
28
  import { getMergedConfig } from '../lib/config.js';
28
29
  import { runPromise } from '../lib/run.js';
@@ -134,6 +135,26 @@ const args = yargs(hideBin(process.argv))
134
135
  },
135
136
  handler
136
137
  })
138
+ .command({
139
+ command: 'resume <prid>',
140
+ desc: 'Resume the latest CI run for given PR',
141
+ builder: (yargs) => {
142
+ yargs
143
+ .positional('prid', {
144
+ describe: 'ID of the PR or URL to the PR',
145
+ type: 'string'
146
+ })
147
+ .option('owner', {
148
+ default: '',
149
+ describe: 'GitHub repository owner'
150
+ })
151
+ .option('repo', {
152
+ default: '',
153
+ describe: 'GitHub repository name'
154
+ });
155
+ },
156
+ handler
157
+ })
137
158
  .command({
138
159
  command: 'url <url>',
139
160
  desc: 'Automatically detect CI type and show results',
@@ -278,11 +299,13 @@ class RunPRJobCommand {
278
299
  return this.argv.prid;
279
300
  }
280
301
 
281
- async start() {
282
- const {
283
- cli, request, prid, repo, owner
284
- } = this;
302
+ validate() {
303
+ const { cli, prid, repo, owner } = this;
285
304
  let validArgs = true;
305
+ if (!Number.isSafeInteger(prid) || prid <= 0) {
306
+ validArgs = false;
307
+ cli.error('Pull request ID must be a positive integer');
308
+ }
286
309
  if (!repo) {
287
310
  validArgs = false;
288
311
  cli.error('GitHub repository is missing, please set it via ncu-config ' +
@@ -295,6 +318,13 @@ class RunPRJobCommand {
295
318
  }
296
319
  if (!validArgs) {
297
320
  this.cli.setExitCode(1);
321
+ }
322
+ return validArgs;
323
+ }
324
+
325
+ async start() {
326
+ const { cli, request, prid, repo, owner } = this;
327
+ if (!this.validate()) {
298
328
  return;
299
329
  }
300
330
  const { certifySafe, checkForDuplicates } = this.argv;
@@ -308,6 +338,20 @@ class RunPRJobCommand {
308
338
  }
309
339
  }
310
340
 
341
+ class ResumePRJobCommand extends RunPRJobCommand {
342
+ async start() {
343
+ const { cli, request, prid, repo, owner } = this;
344
+ if (!this.validate()) {
345
+ return;
346
+ }
347
+ const jobRunner = new ResumePRJob(cli, request, owner, repo, prid);
348
+ if (!(await jobRunner.resume())) {
349
+ cli.setExitCode(1);
350
+ process.exitCode = 1;
351
+ }
352
+ }
353
+ }
354
+
311
355
  class CICommand {
312
356
  constructor(cli, request, argv) {
313
357
  this.cli = cli;
@@ -565,7 +609,8 @@ async function main(command, argv) {
565
609
  let commandHandler;
566
610
  // Prepare queue.
567
611
  switch (command) {
568
- case 'run': {
612
+ case 'run':
613
+ case 'resume': {
569
614
  const maybeURL = URL.parse(argv.prid);
570
615
  if (maybeURL?.host === 'github.com') {
571
616
  const [, owner, repo, , prid, , commit_sha] = maybeURL.pathname.split('/');
@@ -575,7 +620,8 @@ async function main(command, argv) {
575
620
  argv.prid = prid;
576
621
  }
577
622
  argv.prid = Number(argv.prid);
578
- const jobRunner = new RunPRJobCommand(cli, request, argv);
623
+ const Command = command === 'run' ? RunPRJobCommand : ResumePRJobCommand;
624
+ const jobRunner = new Command(cli, request, argv);
579
625
  return jobRunner.start();
580
626
  }
581
627
  case 'rate': {
@@ -132,35 +132,54 @@ function failureMatcher(Failure, patterns, ctx, text) {
132
132
  return null;
133
133
  }
134
134
 
135
+ const fatalPattern = /fatal: .+/g;
136
+
137
+ // Shared by the whole-log parser and the streaming file scanner.
138
+ export const FAILURE_MARKERS = {
139
+ tap: {
140
+ start: /^not ok \d+/,
141
+ end: / {2}\.\.\.\r?\n/,
142
+ todo: '# TODO :'
143
+ },
144
+ cpp: /\[ {2}FAILED {2}\]/,
145
+ git: [{
146
+ start: /Changes not staged for commit:/,
147
+ end: /no changes added to commit/
148
+ }, {
149
+ start: /error: Your local changes to the following files/,
150
+ end: /Failed to merge in the changes./
151
+ }]
152
+ };
153
+
154
+ const tapPattern = new RegExp(
155
+ `${FAILURE_MARKERS.tap.start.source}[\\s\\S]+?${FAILURE_MARKERS.tap.end.source}`, 'mg');
156
+
135
157
  // The elements are ranked by priority
136
158
  const FAILURE_FILTERS = [{
137
159
  // NOTE(mmarchini): infra-related issues should have the highest priority, as
138
160
  // they can cause other issues to happen.
139
- filter(ctx, text) {
140
- const patterns = [{
141
- pattern: /Read-only file system/g,
142
- context: { index: 0, contextBefore: 1, contextAfter: 0 }
143
- },
144
- {
145
- pattern: /Device or resource busy/g,
146
- context: { index: 0, contextBefore: 1, contextAfter: 0 }
147
- },
148
- {
149
- pattern: /There is not enough space in the file system./g,
150
- context: { index: 0, contextBefore: 1, contextAfter: 0 }
151
- }
152
- ];
153
- return failureMatcher(InfraFailure, patterns, ctx, text);
161
+ Failure: InfraFailure,
162
+ patterns: [{
163
+ pattern: /Read-only file system/g,
164
+ context: { index: 0, contextBefore: 1, contextAfter: 0 }
165
+ },
166
+ {
167
+ pattern: /Device or resource busy/g,
168
+ context: { index: 0, contextBefore: 1, contextAfter: 0 }
169
+ },
170
+ {
171
+ pattern: /There is not enough space in the file system./g,
172
+ context: { index: 0, contextBefore: 1, contextAfter: 0 }
154
173
  }
174
+ ],
155
175
  }, {
156
176
  // TODO: match indentation to avoid skipping context with '...'
157
177
  filter(ctx, text) {
158
- const pattern = /^not ok \d+[\s\S]+? {2}\.\.\.\r?\n/mg;
159
- const matches = text.match(pattern);
178
+ const matches = text.match(tapPattern);
160
179
  if (!matches) {
161
180
  return null;
162
181
  }
163
- const nonFlaky = matches.filter((m) => !m.includes('# TODO :'));
182
+ const nonFlaky = matches.filter((m) => !m.includes(FAILURE_MARKERS.tap.todo));
164
183
  if (!nonFlaky.length) {
165
184
  return null;
166
185
  }
@@ -169,89 +188,71 @@ const FAILURE_FILTERS = [{
169
188
  );
170
189
  }
171
190
  }, {
172
- filter(ctx, text) {
173
- const patterns = [{
174
- pattern: /\[ {2}FAILED {2}\].+/g,
175
- context: { index: 0, contextBefore: 5, contextAfter: 0 }
176
- }];
177
- return failureMatcher(CCTestFailure, patterns, ctx, text);
178
- }
191
+ Failure: CCTestFailure,
192
+ patterns: [{
193
+ pattern: new RegExp(`${FAILURE_MARKERS.cpp.source}.+`, 'g'),
194
+ context: { index: 0, contextBefore: 5, contextAfter: 0 }
195
+ }],
179
196
  }, {
180
197
  // VS compilation error
181
- filter(ctx, text) {
182
- const patterns = [{
183
- pattern: /error C\d+:/mg,
184
- context: { index: 0, contextBefore: 0, contextAfter: 5 }
185
- }];
186
- return failureMatcher(BuildFailure, patterns, ctx, text);
187
- }
198
+ Failure: BuildFailure,
199
+ patterns: [{
200
+ pattern: /error C\d+:/mg,
201
+ context: { index: 0, contextBefore: 0, contextAfter: 5 }
202
+ }],
188
203
  }, {
189
- filter(ctx, text) {
190
- const patterns = [{
191
- pattern: /java\.io\.IOException.+/g,
192
- context: { index: -1, contextBefore: 0, contextAfter: 5 }
193
- }, {
194
- pattern: /Build timed out/g,
195
- context: { index: 0, contextBefore: 0, contextAfter: 1 }
196
- }];
197
- return failureMatcher(JenkinsFailure, patterns, ctx, text);
198
- }
204
+ Failure: JenkinsFailure,
205
+ patterns: [{
206
+ pattern: /java\.io\.IOException.+/g,
207
+ context: { index: -1, contextBefore: 0, contextAfter: 5 }
208
+ }, {
209
+ pattern: /Build timed out/g,
210
+ context: { index: 0, contextBefore: 0, contextAfter: 1 }
211
+ }],
199
212
  }, {
200
- filter(ctx, text) {
201
- const patterns = [{
202
- pattern:
203
- /Changes not staged for commit:[\s\S]+no changes added to commit/mg,
204
- context: { index: 0, contextBefore: 0, contextAfter: 0 }
205
- }, {
206
- pattern:
207
- /error: Your local changes to the following files[\s\S]+Failed to merge in the changes./g,
208
- context: { index: 0, contextBefore: 0, contextAfter: 0 }
209
- }, {
210
- pattern: /warning: failed to remove .+/g,
211
- context: { index: 0, contextBefore: 0, contextAfter: 0 }
212
- }];
213
- return failureMatcher(GitFailure, patterns, ctx, text);
214
- }
213
+ Failure: GitFailure,
214
+ patterns: [...FAILURE_MARKERS.git.map(({ start, end }) => ({
215
+ pattern: new RegExp(`${start.source}[\\s\\S]+${end.source}`, 'mg'),
216
+ context: { index: 0, contextBefore: 0, contextAfter: 0 }
217
+ })), {
218
+ pattern: /warning: failed to remove .+/g,
219
+ context: { index: 0, contextBefore: 0, contextAfter: 0 }
220
+ }],
215
221
  }, {
216
- filter(ctx, text) {
217
- const patterns = [{
218
- pattern: /ERROR: Error fetching .+/g,
219
- context: { index: 0, contextBefore: 0, contextAfter: 5 }
220
- }, {
221
- pattern: /hudson\.plugins\.git\.GitException+/g,
222
- context: { index: 0, contextBefore: 0, contextAfter: 5 }
223
- }, {
224
- pattern: /Cannot rebase: .+/g,
225
- context: { index: 0, contextBefore: 0, contextAfter: 1 }
226
- }];
227
- return failureMatcher(GitFailure, patterns, ctx, text);
228
- }
222
+ Failure: GitFailure,
223
+ patterns: [{
224
+ pattern: /ERROR: Error fetching .+/g,
225
+ context: { index: 0, contextBefore: 0, contextAfter: 5 }
226
+ }, {
227
+ pattern: /hudson\.plugins\.git\.GitException+/g,
228
+ context: { index: 0, contextBefore: 0, contextAfter: 5 }
229
+ }, {
230
+ pattern: /Cannot rebase: .+/g,
231
+ context: { index: 0, contextBefore: 0, contextAfter: 1 }
232
+ }],
229
233
  }, {
230
- filter(ctx, text) {
231
- const patterns = [{
232
- pattern: /sh: line /g,
233
- context: { index: 0, contextBefore: 0, contextAfter: 1 }
234
- }, {
235
- pattern: /fatal error:/g,
236
- context: { index: 0, contextBefore: 0, contextAfter: 1 }
237
- }, {
238
- pattern: /dtrace: failed to compile script/g,
239
- context: { index: 0, contextBefore: 0, contextAfter: 1 }
240
- }, {
241
- pattern: /ERROR: .+/g,
242
- // Pick the last one
243
- context: { index: -1, contextBefore: 0, contextAfter: 5 }
244
- }, {
245
- // Pick the first one
246
- pattern: /Error: .+/g,
247
- context: { index: 0, contextBefore: 0, contextAfter: 5 }
248
- }];
249
- return failureMatcher(BuildFailure, patterns, ctx, text);
250
- }
234
+ Failure: BuildFailure,
235
+ patterns: [{
236
+ pattern: /sh: line /g,
237
+ context: { index: 0, contextBefore: 0, contextAfter: 1 }
238
+ }, {
239
+ pattern: /fatal error:/g,
240
+ context: { index: 0, contextBefore: 0, contextAfter: 1 }
241
+ }, {
242
+ pattern: /dtrace: failed to compile script/g,
243
+ context: { index: 0, contextBefore: 0, contextAfter: 1 }
244
+ }, {
245
+ pattern: /ERROR: .+/g,
246
+ // Pick the last one
247
+ context: { index: -1, contextBefore: 0, contextAfter: 5 }
248
+ }, {
249
+ // Pick the first one
250
+ pattern: /Error: .+/g,
251
+ context: { index: 0, contextBefore: 0, contextAfter: 5 }
252
+ }],
251
253
  }, {
252
254
  filter(ctx, text) {
253
- const pattern = /fatal: .+/g;
254
- const matches = text.match(pattern);
255
+ const matches = text.match(fatalPattern);
255
256
  if (!matches) {
256
257
  return null;
257
258
  }
@@ -259,30 +260,42 @@ const FAILURE_FILTERS = [{
259
260
  return [new BuildFailure(ctx, reason)];
260
261
  }
261
262
  }, {
262
- filter(ctx, text) {
263
- const patterns = [{
264
- pattern: /FATAL: .+/g,
265
- context: { index: -1, contextBefore: 0, contextAfter: 5 }
266
- }, {
267
- pattern: /make.*: write error/mg,
268
- context: { index: 0, contextBefore: 0, contextAfter: 3 }
269
- }, {
270
- pattern: /error: .+/g,
271
- context: { index: 0, contextBefore: 0, contextAfter: 5 }
272
- }, {
273
- pattern: /Makefile:.+failed/g,
274
- context: { index: 0, contextBefore: 0, contextAfter: 5 }
275
- }, {
276
- pattern: /make.*: .+ Error \d.*/g,
277
- context: { index: 0, contextBefore: 0, contextAfter: 3 }
278
- }, {
279
- pattern: /warning: failed .+/g,
280
- context: { index: 0, contextBefore: 0, contextAfter: 3 }
281
- }];
282
- return failureMatcher(BuildFailure, patterns, ctx, text);
283
- }
263
+ Failure: BuildFailure,
264
+ patterns: [{
265
+ pattern: /FATAL: .+/g,
266
+ context: { index: -1, contextBefore: 0, contextAfter: 5 }
267
+ }, {
268
+ pattern: /make.*: write error/mg,
269
+ context: { index: 0, contextBefore: 0, contextAfter: 3 }
270
+ }, {
271
+ pattern: /error: .+/g,
272
+ context: { index: 0, contextBefore: 0, contextAfter: 5 }
273
+ }, {
274
+ pattern: /Makefile:.+failed/g,
275
+ context: { index: 0, contextBefore: 0, contextAfter: 5 }
276
+ }, {
277
+ pattern: /make.*: .+ Error \d.*/g,
278
+ context: { index: 0, contextBefore: 0, contextAfter: 3 }
279
+ }, {
280
+ pattern: /warning: failed .+/g,
281
+ context: { index: 0, contextBefore: 0, contextAfter: 3 }
282
+ }],
284
283
  }];
285
284
 
285
+ // Share recognition patterns with streaming consumers without exposing the
286
+ // parser's constructors, filter priority, or context-selection machinery.
287
+ export const FAILURE_PATTERNS = {
288
+ infrastructure: FAILURE_FILTERS
289
+ .filter(({ Failure }) => Failure === InfraFailure)
290
+ .flatMap(({ patterns }) => patterns.map(({ pattern }) => pattern)),
291
+ diagnostic: [
292
+ ...FAILURE_FILTERS
293
+ .filter(({ Failure }) => Failure && Failure !== InfraFailure && Failure !== CCTestFailure)
294
+ .flatMap(({ patterns }) => patterns.map(({ pattern }) => pattern)),
295
+ fatalPattern
296
+ ]
297
+ };
298
+
286
299
  export default class CIFailureParser {
287
300
  constructor(ctx, text) {
288
301
  this.ctx = ctx;
@@ -291,8 +304,10 @@ export default class CIFailureParser {
291
304
 
292
305
  parse() {
293
306
  const text = this.text;
294
- for (const { filter } of FAILURE_FILTERS) {
295
- const result = filter(this.ctx, text);
307
+ for (const { filter, Failure, patterns } of FAILURE_FILTERS) {
308
+ const result = filter
309
+ ? filter(this.ctx, text)
310
+ : failureMatcher(Failure, patterns, this.ctx, text);
296
311
  // TODO: we may want to concat certain types of failures
297
312
  if (result) {
298
313
  return result;
@@ -0,0 +1,153 @@
1
+ import { StringDecoder } from 'node:string_decoder';
2
+
3
+ import { FAILURE_MARKERS, FAILURE_PATTERNS } from './ci_failure_parser.js';
4
+
5
+ function createMatcher(patterns) {
6
+ // Preserve matching flags without sharing RegExp.lastIndex between scans.
7
+ const matchers = patterns.map(pattern =>
8
+ new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, '')));
9
+ return text => matchers.some(pattern => pattern.test(text));
10
+ }
11
+
12
+ const diagnostic = createMatcher(FAILURE_PATTERNS.diagnostic);
13
+ const infrastructure = createMatcher(FAILURE_PATTERNS.infrastructure);
14
+ const gitStart = createMatcher(FAILURE_MARKERS.git.map(({ start }) => start));
15
+ const gitEnd = createMatcher(FAILURE_MARKERS.git.map(({ end }) => end));
16
+
17
+ function fileAliases(filename) {
18
+ const paths = new Set([filename]);
19
+ if (filename.startsWith('test/')) {
20
+ paths.add(filename.slice(5));
21
+ paths.add(filename.slice(5).replace(/\.(?:js|mjs|cjs|out)$/, ''));
22
+ } else if (filename.startsWith('lib/')) {
23
+ paths.add(filename.slice(4));
24
+ }
25
+ return [...paths];
26
+ }
27
+
28
+ // Emit overlapping, normalized windows rather than buffering whole log lines.
29
+ // Explicit line boundaries keep network chunk boundaries out of the matching rules.
30
+ async function * logWindows(source, overlap) {
31
+ const decoder = new StringDecoder('utf8');
32
+ let tail = '';
33
+ let lineStart = true;
34
+ let afterBackslash = false;
35
+
36
+ function * consume(raw) {
37
+ raw = raw.replace(/\r/g, '');
38
+ let text = raw.replace(/\\+/g, '/');
39
+ if (afterBackslash && raw.startsWith('\\')) text = text.slice(1);
40
+ if (raw) afterBackslash = raw.endsWith('\\');
41
+ for (let offset = 0; offset < text.length;) {
42
+ const newline = text.indexOf('\n', offset);
43
+ const end = Math.min(offset + 8192, newline < 0 ? text.length : newline + 1);
44
+ const window = tail + text.slice(offset, end);
45
+ const lineEnd = end === newline + 1;
46
+ yield { text: window, lineStart, lineEnd };
47
+ if (lineEnd) {
48
+ tail = '';
49
+ lineStart = true;
50
+ } else {
51
+ lineStart &&= window.length <= overlap;
52
+ tail = window.slice(-overlap);
53
+ }
54
+ offset = end;
55
+ }
56
+ }
57
+
58
+ for await (const chunk of source) yield * consume(decoder.write(chunk));
59
+ yield * consume(decoder.end());
60
+ if (tail) yield { text: `${tail}\n`, lineStart, lineEnd: true };
61
+ }
62
+
63
+ // Each line is reduced to a filename and failure markers. Neither a giant line
64
+ // nor a giant TAP block needs to survive in memory.
65
+ async function * failureLines(windows, matchFile) {
66
+ let line = {};
67
+ for await (const { text, lineStart, lineEnd } of windows) {
68
+ line.file ??= matchFile(text, lineStart);
69
+ line.diagnostic ||= diagnostic(text);
70
+ line.infrastructure ||= infrastructure(text);
71
+ line.cpp ||= FAILURE_MARKERS.cpp.test(text);
72
+ line.tapStart ||= lineStart && FAILURE_MARKERS.tap.start.test(text);
73
+ line.tapEnd ||= lineEnd && FAILURE_MARKERS.tap.end.test(text);
74
+ line.todo ||= text.includes(FAILURE_MARKERS.tap.todo);
75
+ line.gitStart ||= gitStart(text);
76
+ line.gitEnd ||= gitEnd(text);
77
+ if (lineEnd) {
78
+ yield line;
79
+ line = {};
80
+ }
81
+ }
82
+ }
83
+
84
+ async function findFailure(lines) {
85
+ let history = [];
86
+ let followingLines = 0;
87
+ let tap = null;
88
+ let git = null;
89
+
90
+ for await (const line of lines) {
91
+ const precedingLines = history;
92
+ history = [...history, line.file].slice(-5);
93
+ if (line.tapStart) {
94
+ tap = {};
95
+ followingLines = 0;
96
+ }
97
+ if (tap) {
98
+ tap.file ??= line.file;
99
+ tap.todo ||= line.todo;
100
+ // A later TODO can mark this as an expected failure; wait for the ending.
101
+ if (line.tapEnd) {
102
+ if (!tap.todo && tap.file) return tap.file;
103
+ tap = null;
104
+ }
105
+ continue;
106
+ }
107
+
108
+ if (line.gitStart) git = {};
109
+ if (git) {
110
+ git.file ??= line.file;
111
+ if (line.gitEnd) {
112
+ if (git.file) return git.file;
113
+ git = null;
114
+ }
115
+ }
116
+ if (line.infrastructure || line.cpp) {
117
+ const before = line.cpp ? 5 : 1;
118
+ const file = line.file || precedingLines.slice(-before).find(Boolean);
119
+ if (file) return file;
120
+ }
121
+ if (line.diagnostic) followingLines = 6;
122
+ if (followingLines > 0) {
123
+ followingLines--;
124
+ if (line.file) return line.file;
125
+ }
126
+ }
127
+ }
128
+
129
+ export class FailureFileScanner {
130
+ constructor(filenames) {
131
+ const aliases = [...filenames].map(filename => ({ filename, paths: fileAliases(filename) }));
132
+ this.overlap = Math.max(256,
133
+ ...aliases.flatMap(({ paths }) => paths.map(path => path.length + 2)));
134
+ const matchers = aliases.map(({ filename, paths }) => ({
135
+ filename,
136
+ patterns: paths.map(path => {
137
+ const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
138
+ // Both delimiters must be real: a window boundary is not a path boundary.
139
+ return new RegExp(`[^\\w.-]${escaped}(?=[^\\w./-])`);
140
+ })
141
+ }));
142
+ this.matchFile = (text, lineStart) => {
143
+ // The start of a real log line is also a valid left delimiter.
144
+ if (lineStart) text = `\n${text}`;
145
+ return matchers.find(({ patterns }) =>
146
+ patterns.some(pattern => pattern.test(text)))?.filename;
147
+ };
148
+ }
149
+
150
+ scan(source) {
151
+ return findFailure(failureLines(logWindows(source, this.overlap), this.matchFile));
152
+ }
153
+ }