@node-core/utils 7.3.1 → 7.4.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.
Files changed (41) hide show
  1. package/bin/ncu-ci.js +39 -0
  2. package/lib/ci/availability.js +24 -0
  3. package/lib/ci/ci_utils.js +13 -0
  4. package/lib/ci/failure_file_scanner.js +72 -21
  5. package/lib/ci/jenkins.js +23 -0
  6. package/lib/ci/resumable_build.js +86 -0
  7. package/lib/ci/resume_ci.js +64 -25
  8. package/lib/ci/run_ci.js +39 -9
  9. package/lib/ci/workload.js +72 -0
  10. package/lib/update-v8/deps.js +4 -1
  11. package/node_modules/branch-diff/.github/dependabot.yml +14 -2
  12. package/node_modules/branch-diff/.github/workflows/test-and-release.yml +4 -15
  13. package/node_modules/branch-diff/CHANGELOG.md +24 -0
  14. package/node_modules/branch-diff/package.json +35 -3
  15. package/node_modules/js-yaml/dist/browser/js-yaml.esm.min.mjs +2 -2
  16. package/node_modules/js-yaml/dist/browser/js-yaml.esm.min.mjs.map +1 -1
  17. package/node_modules/js-yaml/dist/browser/js-yaml.umd.min.js +1 -1
  18. package/node_modules/js-yaml/dist/browser/js-yaml.umd.min.js.map +1 -1
  19. package/node_modules/js-yaml/dist/js-yaml.cjs.js +2 -1
  20. package/node_modules/js-yaml/dist/js-yaml.cjs.js.map +1 -1
  21. package/node_modules/js-yaml/dist/js-yaml.mjs +2 -1
  22. package/node_modules/js-yaml/dist/js-yaml.mjs.map +1 -1
  23. package/node_modules/js-yaml/package.json +1 -1
  24. package/node_modules/smol-toml/README.md +460 -273
  25. package/node_modules/smol-toml/dist/date.js +50 -21
  26. package/node_modules/smol-toml/dist/error.d.ts +1 -2
  27. package/node_modules/smol-toml/dist/error.js +6 -2
  28. package/node_modules/smol-toml/dist/extract.js +240 -20
  29. package/node_modules/smol-toml/dist/index.cjs +409 -373
  30. package/node_modules/smol-toml/dist/index.d.ts +5 -4
  31. package/node_modules/smol-toml/dist/index.js +2 -1
  32. package/node_modules/smol-toml/dist/parse.d.ts +38 -1
  33. package/node_modules/smol-toml/dist/parse.js +52 -37
  34. package/node_modules/smol-toml/dist/primitive.js +23 -110
  35. package/node_modules/smol-toml/dist/stringify.d.ts +2 -1
  36. package/node_modules/smol-toml/dist/stringify.js +74 -36
  37. package/node_modules/smol-toml/dist/struct.js +76 -99
  38. package/node_modules/smol-toml/dist/util.d.ts +6 -1
  39. package/node_modules/smol-toml/dist/util.js +4 -36
  40. package/node_modules/smol-toml/package.json +8 -8
  41. package/package.json +1 -1
package/bin/ncu-ci.js CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  RunPRJob
25
25
  } from '../lib/ci/run_ci.js';
26
26
  import { ResumePRJob } from '../lib/ci/resume_ci.js';
27
+ import { checkAvailability } from '../lib/ci/availability.js';
28
+ import { getPRWorkload } from '../lib/ci/workload.js';
27
29
  import { writeJson, writeFile } from '../lib/file.js';
28
30
  import { getMergedConfig } from '../lib/config.js';
29
31
  import { runPromise } from '../lib/run.js';
@@ -54,6 +56,16 @@ const commandKeys = [
54
56
 
55
57
  const args = yargs(hideBin(process.argv))
56
58
  .completion('completion')
59
+ .command({
60
+ command: 'available',
61
+ desc: 'Check whether Jenkins is available for PR CI requests',
62
+ handler
63
+ })
64
+ .command({
65
+ command: 'workload',
66
+ desc: 'Print the number of running and queued node-test-pull-request jobs',
67
+ handler
68
+ })
57
69
  .command({
58
70
  command: 'rate <type>',
59
71
  desc: 'Calculate the green rate of a CI job in the last 100 runs',
@@ -598,7 +610,34 @@ class DailyCommand extends CICommand {
598
610
  }
599
611
  }
600
612
 
613
+ async function checkJenkins(command) {
614
+ try {
615
+ let jenkins;
616
+ try {
617
+ const credentials = await auth({ github: false, jenkins: true });
618
+ jenkins = credentials.jenkins;
619
+ } catch {
620
+ throw new Error('Configure username and jenkins_token with ncu-config');
621
+ }
622
+ const request = new Request({ jenkins });
623
+ if (command === 'available') {
624
+ await checkAvailability(request);
625
+ } else {
626
+ console.log(await getPRWorkload(request));
627
+ }
628
+ } catch (err) {
629
+ const action = command === 'available'
630
+ ? 'check Jenkins availability'
631
+ : 'read the PR CI workload';
632
+ console.error(`Unable to ${action}: ${err.message}`);
633
+ process.exitCode = 1;
634
+ }
635
+ }
636
+
601
637
  async function main(command, argv) {
638
+ if (command === 'available' || command === 'workload') {
639
+ return checkJenkins(command);
640
+ }
602
641
  const cli = new CLI();
603
642
  const credentials = await auth({
604
643
  github: true,
@@ -0,0 +1,24 @@
1
+ import { readJenkinsJSON } from './jenkins.js';
2
+
3
+ export async function checkAvailability(request) {
4
+ const signal = AbortSignal.timeout(20_000);
5
+ const controller = await readJenkinsJSON(request, '/api/json?tree=quietingDown', signal);
6
+ if (controller?.quietingDown === true) {
7
+ throw new Error('Jenkins is preparing for shutdown');
8
+ }
9
+ if (controller?.quietingDown !== false) {
10
+ throw new Error('Jenkins quiet-down state is not confirmed');
11
+ }
12
+
13
+ const job = await readJenkinsJSON(request,
14
+ '/job/node-test-pull-request/api/json?tree=disabled,buildable', signal);
15
+ if (job?.disabled === true) {
16
+ throw new Error('Jenkins PR job is disabled');
17
+ }
18
+ if (job?.buildable === false) {
19
+ throw new Error('Jenkins PR job is not buildable');
20
+ }
21
+ if (job?.disabled !== false || job?.buildable !== true) {
22
+ throw new Error('Jenkins PR job availability is not confirmed');
23
+ }
24
+ }
@@ -1,4 +1,5 @@
1
1
  import qs from 'node:querystring';
2
+ import { getPrURL } from '../links.js';
2
3
 
3
4
  import {
4
5
  CI_DOMAIN,
@@ -13,6 +14,18 @@ export const statusType = {
13
14
  UNSTABLE: 'UNSTABLE'
14
15
  };
15
16
 
17
+ export function getCIActionAdvice(pr, action) {
18
+ const command = `ncu-ci ${action} ${getPrURL(pr)}`;
19
+ if (action === 'run') {
20
+ return 'Check the existing CI run in Jenkins and rebase the PR if needed. ' +
21
+ `To start a new CI run manually: ${command}`;
22
+ }
23
+ if (pr.owner === 'nodejs' && pr.repo === 'node') {
24
+ return `Resume CI by adding the "resume-ci" label to the PR, or run: ${command}`;
25
+ }
26
+ return `Resume CI with: ${command}`;
27
+ }
28
+
16
29
  export function getPath(url) {
17
30
  return url.replace(`https://${CI_DOMAIN}/`, '').replace('api/json', '');
18
31
  }
@@ -13,6 +13,30 @@ const diagnostic = createMatcher(FAILURE_PATTERNS.diagnostic);
13
13
  const infrastructure = createMatcher(FAILURE_PATTERNS.infrastructure);
14
14
  const gitStart = createMatcher(FAILURE_MARKERS.git.map(({ start }) => start));
15
15
  const gitEnd = createMatcher(FAILURE_MARKERS.git.map(({ end }) => end));
16
+ const makeRecipeFailure = createMatcher([
17
+ /^\s*(?:\[(?:out|err)\]\s*)?g?make(?:\[\d+\])?: \*\*\* \[[^\r\n]+\] .+$/,
18
+ /^\s*(?:\[(?:out|err)\]\s*)?[^\r\n]+:\d+: recipe for target '.+' failed$/
19
+ ]);
20
+ const makefileDiagnostic = /^\s*(?:\[(?:out|err)\]\s*)?[^\r\n]+:\d+: \*\*\* /;
21
+
22
+ // Keep both ends of large diagnostics without retaining whole lines or TAP blocks.
23
+ class FailureExcerpt {
24
+ head = '';
25
+ tail = '';
26
+ length = 0;
27
+
28
+ append(text) {
29
+ const remaining = Math.max(0, 4096 - this.head.length);
30
+ this.head += text.slice(0, remaining);
31
+ this.tail = (this.tail + text.slice(remaining)).slice(-4096);
32
+ this.length += text.length;
33
+ }
34
+
35
+ toString() {
36
+ const omitted = this.length > 8192 ? '\n[... failure output truncated ...]\n' : '';
37
+ return this.head + omitted + this.tail;
38
+ }
39
+ }
16
40
 
17
41
  function fileAliases(filename) {
18
42
  const paths = new Set([filename]);
@@ -35,15 +59,16 @@ async function * logWindows(source, overlap) {
35
59
 
36
60
  function * consume(raw) {
37
61
  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);
62
+ for (let offset = 0; offset < raw.length;) {
63
+ const newline = raw.indexOf('\n', offset);
64
+ const end = Math.min(offset + 8192, newline < 0 ? raw.length : newline + 1);
65
+ const content = raw.slice(offset, end);
66
+ let normalized = content.replace(/\\+/g, '/');
67
+ if (afterBackslash && content.startsWith('\\')) normalized = normalized.slice(1);
68
+ afterBackslash = content.endsWith('\\');
69
+ const window = tail + normalized;
45
70
  const lineEnd = end === newline + 1;
46
- yield { text: window, lineStart, lineEnd };
71
+ yield { text: window, content, lineStart, lineEnd };
47
72
  if (lineEnd) {
48
73
  tail = '';
49
74
  lineStart = true;
@@ -57,14 +82,15 @@ async function * logWindows(source, overlap) {
57
82
 
58
83
  for await (const chunk of source) yield * consume(decoder.write(chunk));
59
84
  yield * consume(decoder.end());
60
- if (tail) yield { text: `${tail}\n`, lineStart, lineEnd: true };
85
+ if (tail) yield { text: `${tail}\n`, content: '\n', lineStart, lineEnd: true };
61
86
  }
62
87
 
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.
88
+ // Retain failure markers and a bounded excerpt for each line.
65
89
  async function * failureLines(windows, matchFile) {
66
90
  let line = {};
67
- for await (const { text, lineStart, lineEnd } of windows) {
91
+ let excerpt = new FailureExcerpt();
92
+ for await (const { text, content, lineStart, lineEnd } of windows) {
93
+ excerpt.append(content);
68
94
  line.file ??= matchFile(text, lineStart);
69
95
  line.diagnostic ||= diagnostic(text);
70
96
  line.infrastructure ||= infrastructure(text);
@@ -75,8 +101,17 @@ async function * failureLines(windows, matchFile) {
75
101
  line.gitStart ||= gitStart(text);
76
102
  line.gitEnd ||= gitEnd(text);
77
103
  if (lineEnd) {
104
+ line.text = excerpt.toString();
105
+ if (makeRecipeFailure(line.text.trimEnd())) {
106
+ // The recipe location reports a child's exit, not the source of its
107
+ // failure. Keep the output without attributing it through TAP or history.
108
+ line = { text: line.text, todo: line.todo };
109
+ } else {
110
+ line.diagnostic ||= makefileDiagnostic.test(line.text);
111
+ }
78
112
  yield line;
79
113
  line = {};
114
+ excerpt = new FailureExcerpt();
80
115
  }
81
116
  }
82
117
  }
@@ -84,44 +119,60 @@ async function * failureLines(windows, matchFile) {
84
119
  async function findFailure(lines) {
85
120
  let history = [];
86
121
  let followingLines = 0;
122
+ let diagnosticExcerpt;
87
123
  let tap = null;
88
124
  let git = null;
89
125
 
90
126
  for await (const line of lines) {
91
127
  const precedingLines = history;
92
- history = [...history, line.file].slice(-5);
128
+ history = [...history, line].slice(-5);
93
129
  if (line.tapStart) {
94
- tap = {};
130
+ tap = { excerpt: new FailureExcerpt() };
95
131
  followingLines = 0;
96
132
  }
97
133
  if (tap) {
134
+ tap.excerpt.append(line.text);
98
135
  tap.file ??= line.file;
99
136
  tap.todo ||= line.todo;
100
137
  // A later TODO can mark this as an expected failure; wait for the ending.
101
138
  if (line.tapEnd) {
102
- if (!tap.todo && tap.file) return tap.file;
139
+ if (!tap.todo && tap.file) {
140
+ return { filename: tap.file, reason: tap.excerpt.toString().trimEnd() };
141
+ }
103
142
  tap = null;
104
143
  }
105
144
  continue;
106
145
  }
107
146
 
108
- if (line.gitStart) git = {};
147
+ if (line.gitStart) git = { excerpt: new FailureExcerpt() };
109
148
  if (git) {
149
+ git.excerpt.append(line.text);
110
150
  git.file ??= line.file;
111
151
  if (line.gitEnd) {
112
- if (git.file) return git.file;
152
+ if (git.file) return { filename: git.file, reason: git.excerpt.toString().trimEnd() };
113
153
  git = null;
114
154
  }
115
155
  }
116
156
  if (line.infrastructure || line.cpp) {
117
157
  const before = line.cpp ? 5 : 1;
118
- const file = line.file || precedingLines.slice(-before).find(Boolean);
119
- if (file) return file;
158
+ const context = [...precedingLines.slice(-before), line];
159
+ const filename = line.file || context.find(previous => previous.file)?.file;
160
+ if (filename) {
161
+ const excerpt = new FailureExcerpt();
162
+ for (const previous of context) excerpt.append(previous.text);
163
+ return { filename, reason: excerpt.toString().trimEnd() };
164
+ }
165
+ }
166
+ if (line.diagnostic) {
167
+ followingLines = 6;
168
+ diagnosticExcerpt = new FailureExcerpt();
120
169
  }
121
- if (line.diagnostic) followingLines = 6;
122
170
  if (followingLines > 0) {
123
171
  followingLines--;
124
- if (line.file) return line.file;
172
+ diagnosticExcerpt.append(line.text);
173
+ if (line.file) {
174
+ return { filename: line.file, reason: diagnosticExcerpt.toString().trimEnd() };
175
+ }
125
176
  }
126
177
  }
127
178
  }
@@ -0,0 +1,23 @@
1
+ import { CI_DOMAIN } from './ci_type_parser.js';
2
+
3
+ export async function readJenkinsJSON(request, path, signal) {
4
+ const response = await request.fetch(`https://${CI_DOMAIN}${path}`, {
5
+ method: 'GET',
6
+ headers: { Accept: 'application/json' },
7
+ redirect: 'error',
8
+ signal
9
+ });
10
+ if (response.status !== 200) {
11
+ await response.body?.cancel();
12
+ throw new Error(`Jenkins returned HTTP ${response.status} ${response.statusText ?? ''}`.trim());
13
+ }
14
+ try {
15
+ return await response.json();
16
+ } catch (cause) {
17
+ if (signal.aborted) throw signal.reason;
18
+ if (cause instanceof SyntaxError) {
19
+ throw new Error('Jenkins returned invalid JSON', { cause });
20
+ }
21
+ throw cause;
22
+ }
23
+ }
@@ -0,0 +1,86 @@
1
+ import { PRBuild } from './build-types/pr_build.js';
2
+
3
+ export const RESUME_TREE = 'result,building,' +
4
+ 'actions[parameters[name,value],causes[_class,upstreamProject,upstreamBuild,upstreamUrl]]';
5
+ const RESUME_CAUSE = 'com.tikal.jenkins.plugins.multijob.ResumeCause';
6
+ const PR_JOB = 'node-test-pull-request';
7
+
8
+ function getParameter(data, name) {
9
+ const values = new Set((data.actions ?? []).flatMap(action => action.parameters ?? [])
10
+ .filter(parameter => parameter.name === name)
11
+ .map(parameter => parameter.value));
12
+ return values.size === 1 ? [...values][0] : undefined;
13
+ }
14
+
15
+ export function getApprovedSHA(data) {
16
+ const value = getParameter(data, 'COMMIT_SHA_CHECK');
17
+ return typeof value === 'string' && value ? value : undefined;
18
+ }
19
+
20
+ export async function hasResumeAction(request, build) {
21
+ // The build API omits unexported actions. The context menu exposes the
22
+ // same actions Jenkins offers to the authenticated user on the build page.
23
+ const response = await request.fetch(`${build.jobUrl}contextMenu`, {
24
+ method: 'GET',
25
+ redirect: 'error'
26
+ });
27
+ if (response.status !== 200) {
28
+ await response.body?.cancel();
29
+ throw new Error(
30
+ `Jenkins returned HTTP ${response.status} ${response.statusText ?? ''}`.trim());
31
+ }
32
+ const menu = await response.json();
33
+ if (!Array.isArray(menu?.items)) {
34
+ throw new Error('Jenkins returned an invalid build context menu');
35
+ }
36
+ const resumeURL = `${build.jobUrl}resume`;
37
+ return menu.items.some(item => {
38
+ // Jenkins' new build page exposes action URLs through a LinkEvent.
39
+ const actionURL = item?.url ?? item?.event?.url;
40
+ if (typeof actionURL !== 'string') return false;
41
+ let url;
42
+ try {
43
+ url = new URL(actionURL, build.jobUrl).href;
44
+ } catch {
45
+ return false;
46
+ }
47
+ return url === resumeURL || url === `${resumeURL}/`;
48
+ });
49
+ }
50
+
51
+ export async function findResumableBuild(cli, request, { jobid, owner, repo, prid }, data) {
52
+ const approvedSHA = getApprovedSHA(data);
53
+ const checkedBuilds = [];
54
+ if (!approvedSHA) return { build: undefined, checkedBuilds, approvedSHA };
55
+ let build = new PRBuild(cli, request, jobid, undefined, RESUME_TREE);
56
+ while (data.building === false &&
57
+ (data.result === 'FAILURE' || data.result === 'ABORTED')) {
58
+ if (getApprovedSHA(data) !== approvedSHA) break;
59
+ const buildOwner = getParameter(data, 'TARGET_GITHUB_ORG');
60
+ const buildRepo = getParameter(data, 'TARGET_REPO_NAME');
61
+ if (typeof buildOwner !== 'string' || buildOwner.toLowerCase() !== owner.toLowerCase() ||
62
+ typeof buildRepo !== 'string' || buildRepo.toLowerCase() !== repo.toLowerCase() ||
63
+ String(getParameter(data, 'PR_ID')) !== String(prid)) {
64
+ throw new Error(`CI job ${jobid} does not match pull request ${owner}/${repo}#${prid}`);
65
+ }
66
+ checkedBuilds.push({ jobid, build });
67
+ if (await hasResumeAction(request, build)) {
68
+ return { jobid, build, checkedBuilds, approvedSHA };
69
+ }
70
+
71
+ // Jenkins copies earlier causes when resuming. Follow the nearest parent,
72
+ // not the first cause, and only within this PR job's actual resume lineage.
73
+ const parent = (data.actions ?? []).flatMap(action => action.causes ?? [])
74
+ .filter(cause => cause?._class === RESUME_CAUSE &&
75
+ cause.upstreamProject === PR_JOB && cause.upstreamUrl === `job/${PR_JOB}/` &&
76
+ Number.isSafeInteger(cause.upstreamBuild) &&
77
+ cause.upstreamBuild > 0 && cause.upstreamBuild < jobid)
78
+ .sort((a, b) => b.upstreamBuild - a.upstreamBuild)[0];
79
+ if (!parent) break;
80
+
81
+ jobid = parent.upstreamBuild;
82
+ build = new PRBuild(cli, request, jobid, undefined, RESUME_TREE);
83
+ data = await build.getBuildData();
84
+ }
85
+ return { build: undefined, checkedBuilds, approvedSHA };
86
+ }
@@ -4,6 +4,8 @@ import { PRBuild } from './build-types/pr_build.js';
4
4
  import { getPrURL } from '../links.js';
5
5
  import { debuglog } from '../verbosity.js';
6
6
  import { FailureFileScanner } from './failure_file_scanner.js';
7
+ import { findResumableBuild, getApprovedSHA, RESUME_TREE } from './resumable_build.js';
8
+ import { getCIActionAdvice } from './ci_utils.js';
7
9
 
8
10
  export class ResumePRJob {
9
11
  constructor(cli, request, owner, repo, prid) {
@@ -14,6 +16,15 @@ export class ResumePRJob {
14
16
  this.prid = prid;
15
17
  }
16
18
 
19
+ reportUnavailable(jobid, build) {
20
+ const { cli } = this;
21
+ cli.stopSpinner(
22
+ `Cannot resume PR CI job ${jobid}: Jenkins does not offer a "Resume build" action. ` +
23
+ getCIActionAdvice(this, 'run'),
24
+ cli.SPINNER_STATUS.FAILED);
25
+ cli.error(build.jobUrl);
26
+ }
27
+
17
28
  async checkFailures(jobid) {
18
29
  const { cli, request, owner, repo, prid } = this;
19
30
  const filenames = new Set();
@@ -22,7 +33,7 @@ export class ResumePRJob {
22
33
  filenames.add(filename);
23
34
  }
24
35
  }
25
- const overlaps = new Set();
36
+ let overlap;
26
37
  const buildRequest = Object.create(request);
27
38
  buildRequest.json = async(...args) => {
28
39
  const data = await request.json(...args);
@@ -35,10 +46,10 @@ export class ResumePRJob {
35
46
  let pending = Promise.resolve();
36
47
  buildRequest.text = (url) => {
37
48
  const scan = pending.then(async() => {
38
- if (!filenames.size || overlaps.size) return '';
49
+ if (!filenames.size || overlap) return '';
39
50
  const scanner = new FailureFileScanner(filenames);
40
51
  const match = await scanner.scan(request.stream(url));
41
- if (match) overlaps.add(match);
52
+ if (match) overlap = { ...match, url };
42
53
  return '';
43
54
  });
44
55
  pending = scan.catch(debuglog);
@@ -51,10 +62,10 @@ export class ResumePRJob {
51
62
  debuglog(err);
52
63
  }
53
64
  await pending;
54
- if (overlaps.size) {
55
- for (const filename of [...overlaps].sort()) {
56
- cli.error(filename);
57
- }
65
+ if (overlap) {
66
+ cli.error(overlap.filename);
67
+ cli.info(overlap.url);
68
+ cli.log(overlap.reason);
58
69
  return false;
59
70
  }
60
71
  return true;
@@ -71,11 +82,13 @@ export class ResumePRJob {
71
82
  }
72
83
  } catch (err) {
73
84
  debuglog(err);
74
- cli.stopSpinner('Jenkins credentials invalid', cli.SPINNER_STATUS.FAILED);
85
+ cli.stopSpinner(`Unable to validate Jenkins credentials: ${err.message}`,
86
+ cli.SPINNER_STATUS.FAILED);
75
87
  return false;
76
88
  }
77
89
  cli.stopSpinner('Jenkins credentials valid');
78
90
 
91
+ let failureMessage = `Failed to find CI runs for pull request ${prid}`;
79
92
  try {
80
93
  cli.startSpinner(`Looking for CI runs for pull request ${prid}`);
81
94
  const parser = await JobParser.fromPR(getPrURL(this), cli, request);
@@ -87,32 +100,48 @@ export class ResumePRJob {
87
100
  }
88
101
  cli.stopSpinner(`Found PR CI job ${job.jobid}`);
89
102
 
90
- const build = new PRBuild(cli, request, job.jobid, undefined,
91
- 'result,building,actions[parameters[name,value]]');
92
- const { result, building, actions = [] } = await build.getBuildData();
103
+ failureMessage = `Failed to load PR CI job ${job.jobid}`;
104
+ const latestBuild = new PRBuild(cli, request, job.jobid, undefined, RESUME_TREE);
105
+ const data = await latestBuild.getBuildData();
106
+ const { result, building } = data;
93
107
  if (building || (result !== 'FAILURE' && result !== 'ABORTED')) {
94
108
  const status = building ? 'RUNNING' : result ?? 'RUNNING';
95
109
  cli.error(`CI job ${job.jobid} is in status ${status}, skipping resume`);
96
110
  return false;
97
111
  }
98
- const approvedSHAs = new Set(actions.flatMap(action => action.parameters ?? [])
99
- .filter(parameter => parameter.name === 'COMMIT_SHA_CHECK')
100
- .map(parameter => parameter.value));
101
- const [approvedSHA] = approvedSHAs;
102
- if (approvedSHAs.size !== 1 || typeof approvedSHA !== 'string' || !approvedSHA) {
112
+ const approvedSHA = getApprovedSHA(data);
113
+ if (!approvedSHA) {
103
114
  cli.error(`Refusing to resume CI job ${job.jobid}: cannot determine its approved commit`);
104
115
  return false;
105
116
  }
106
117
 
107
- cli.startSpinner('Checking failures against changed PR files');
108
- if (!(await this.checkFailures(job.jobid))) {
109
- cli.stopSpinner('Refusing to resume CI: failures reference files changed by this PR',
110
- cli.SPINNER_STATUS.FAILED);
118
+ failureMessage = `Failed to check resume availability for PR CI job ${job.jobid}`;
119
+ cli.startSpinner(`Checking whether PR CI job ${job.jobid} can be resumed`);
120
+ const { build, jobid: resumeJobid, checkedBuilds } = await findResumableBuild(cli, request, {
121
+ jobid: job.jobid, owner: this.owner, repo: this.repo, prid
122
+ }, data);
123
+ if (!build) {
124
+ this.reportUnavailable(job.jobid, latestBuild);
111
125
  return false;
112
126
  }
127
+ cli.stopSpinner('Jenkins offers a Resume build action');
128
+ if (resumeJobid !== job.jobid) {
129
+ cli.info(`Using resumable ancestor PR CI job ${resumeJobid} for latest job ${job.jobid}`);
130
+ }
131
+
132
+ failureMessage = `Failed to check failures for PR CI job ${job.jobid}`;
133
+ cli.startSpinner('Checking failures against changed PR files');
134
+ for (const { jobid } of checkedBuilds) {
135
+ if (!(await this.checkFailures(jobid))) {
136
+ cli.stopSpinner('Refusing to resume CI: failures reference files changed by this PR',
137
+ cli.SPINNER_STATUS.FAILED);
138
+ return false;
139
+ }
140
+ }
113
141
  cli.stopSpinner('No changed PR files found in available failure details');
114
142
 
115
143
  // Read HEAD after inspecting failures so the comparison is fresh when resuming.
144
+ failureMessage = `Failed to read the current HEAD for pull request ${prid}`;
116
145
  const pr = await request.getPullRequest(getPrURL(this));
117
146
  if (pr.head?.sha !== approvedSHA) {
118
147
  cli.error(`Refusing to resume CI job ${job.jobid}: ` +
@@ -120,25 +149,35 @@ export class ResumePRJob {
120
149
  return false;
121
150
  }
122
151
 
123
- cli.startSpinner(`Resuming PR CI job ${job.jobid}`);
124
- // Jenkins does not export its resume action in the build API. Let the
125
- // resume endpoint determine whether the action is available.
152
+ failureMessage = `Failed to resume PR CI job ${resumeJobid}`;
153
+ cli.startSpinner(`Resuming PR CI job ${resumeJobid}`);
126
154
  const response = await request.fetch(`${build.jobUrl}resume/`, {
127
155
  method: 'POST',
128
156
  headers: {
129
157
  'Jenkins-Crumb': crumb
130
158
  }
131
159
  });
160
+ await response.body?.cancel();
161
+ // The action may disappear after the preflight check.
162
+ if (response.status === 404) {
163
+ this.reportUnavailable(resumeJobid, build);
164
+ return false;
165
+ }
132
166
  if (response.status !== 200) {
167
+ const status = `HTTP ${response.status} ${response.statusText ?? ''}`.trim();
168
+ const reason = response.status === 401 || response.status === 403
169
+ ? `Jenkins denied the request (${status}). ` +
170
+ 'Check your Jenkins credentials and build permissions.'
171
+ : `Jenkins returned ${status}. Check the build page for details: ${build.jobUrl}`;
133
172
  cli.stopSpinner(
134
- `Failed to resume PR CI: ${response.status} ${response.statusText}`,
173
+ `${failureMessage}: ${reason}`,
135
174
  cli.SPINNER_STATUS.FAILED);
136
175
  return false;
137
176
  }
138
177
  cli.stopSpinner('PR CI job successfully resumed');
139
178
  } catch (err) {
140
179
  debuglog(err);
141
- cli.stopSpinner('Failed to resume CI', cli.SPINNER_STATUS.FAILED);
180
+ cli.stopSpinner(`${failureMessage}: ${err.message}`, cli.SPINNER_STATUS.FAILED);
142
181
  return false;
143
182
  }
144
183
  return true;
package/lib/ci/run_ci.js CHANGED
@@ -10,6 +10,12 @@ import PRData from '../pr_data.js';
10
10
  import { debuglog } from '../verbosity.js';
11
11
  import PRChecker from '../pr_checker.js';
12
12
  import { PRBuild } from './build-types/pr_build.js';
13
+ import { getCIActionAdvice } from './ci_utils.js';
14
+ import {
15
+ findResumableBuild,
16
+ getApprovedSHA,
17
+ RESUME_TREE
18
+ } from './resumable_build.js';
13
19
 
14
20
  export const CI_CRUMB_URL = `https://${CI_DOMAIN}/crumbIssuer/api/json`;
15
21
  const CI_PR_NAME = CI_TYPES.get(CI_TYPES_KEYS.PR).jobName;
@@ -94,12 +100,12 @@ export class RunPRJob {
94
100
  if (checkForDuplicates) {
95
101
  await this.prData.getComments();
96
102
  const { jobid, link } = new JobParser(this.prData.comments).parse().get('PR') ?? {};
97
- let actions;
103
+ let buildData;
98
104
  try {
99
- ({ actions } = jobid
100
- ? (await new PRBuild(cli, request, jobid, undefined, 'actions[parameters[name,value]]')
101
- .getBuildData())
102
- : {});
105
+ if (jobid) {
106
+ buildData = await new PRBuild(cli, request, jobid, undefined, RESUME_TREE)
107
+ .getBuildData();
108
+ }
103
109
  } catch (err) {
104
110
  // Jenkins may no longer have the build (e.g. the record was lost) or
105
111
  // may answer with an HTML error page instead of JSON. Do not let that
@@ -108,11 +114,35 @@ export class RunPRJob {
108
114
  cli.SPINNER_STATUS.WARN);
109
115
  cli.warn('Skipping the duplicate CI check');
110
116
  }
111
- const { parameters } = actions?.find(a => 'parameters' in a) ?? {};
112
- if (parameters?.find(c => c.name === 'COMMIT_SHA_CHECK')?.value === certifySafe) {
117
+ const matchingSHA = buildData?.actions?.some(action =>
118
+ action.parameters?.some(parameter =>
119
+ parameter.name === 'COMMIT_SHA_CHECK' && parameter.value === certifySafe));
120
+ if (matchingSHA) {
113
121
  cli.info('Existing CI run found: ' + link);
114
- cli.error('Refusing to start a potentially duplicate CI job. Use the ' +
115
- '"Resume build" button in the Jenkins UI, or start a new CI manually.');
122
+ let advice = 'Check the existing CI run in Jenkins before retrying.';
123
+ if (getApprovedSHA(buildData) === certifySafe && buildData.building === false &&
124
+ ['FAILURE', 'ABORTED'].includes(buildData.result)) {
125
+ try {
126
+ const { build } = await findResumableBuild(cli, request, {
127
+ jobid,
128
+ owner: this.owner,
129
+ repo: this.repo,
130
+ prid: this.prid
131
+ }, buildData);
132
+ advice = getCIActionAdvice(this, build ? 'resume' : 'run');
133
+ } catch (err) {
134
+ cli.error(`Could not check whether existing CI run ${link} can be resumed: ` +
135
+ err.message);
136
+ cli.error('Refusing to start a potentially duplicate CI job. ' +
137
+ 'Retry after checking Jenkins access.');
138
+ return false;
139
+ }
140
+ } else if (buildData.building === true) {
141
+ advice = 'The existing CI run is still in progress.';
142
+ } else if (buildData.result === 'SUCCESS') {
143
+ advice = 'CI has already succeeded for this commit.';
144
+ }
145
+ cli.error(`Refusing to start a potentially duplicate CI job. ${advice}`);
116
146
  return false;
117
147
  }
118
148
  }