@node-core/utils 7.3.1 → 7.4.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 +39 -0
- package/lib/ci/availability.js +24 -0
- package/lib/ci/ci_utils.js +13 -0
- package/lib/ci/failure_file_scanner.js +72 -21
- package/lib/ci/jenkins.js +23 -0
- package/lib/ci/resumable_build.js +86 -0
- package/lib/ci/resume_ci.js +64 -25
- package/lib/ci/run_ci.js +39 -9
- package/lib/ci/workload.js +72 -0
- 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
|
+
}
|
package/lib/ci/ci_utils.js
CHANGED
|
@@ -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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/ci/resume_ci.js
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
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)
|
|
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 (
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
103
|
+
let buildData;
|
|
98
104
|
try {
|
|
99
|
-
({
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { CI_DOMAIN, CI_TYPES, CI_TYPES_KEYS } from './ci_type_parser.js';
|
|
2
|
+
import { readJenkinsJSON } from './jenkins.js';
|
|
3
|
+
|
|
4
|
+
const PR_JOB_URL = `https://${CI_DOMAIN}/job/${CI_TYPES.get(CI_TYPES_KEYS.PR).jobName}`;
|
|
5
|
+
|
|
6
|
+
export async function getPRWorkload(request) {
|
|
7
|
+
const signal = AbortSignal.timeout(20_000);
|
|
8
|
+
const data = await readJenkinsJSON(request, '/queue/api/json?tree=items[id,task[url]]', signal);
|
|
9
|
+
if (!Array.isArray(data?.items)) {
|
|
10
|
+
throw new Error('Jenkins returned an invalid queue');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const queued = new Set();
|
|
14
|
+
for (const item of data.items) {
|
|
15
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) ||
|
|
16
|
+
!item.task || typeof item.task !== 'object' || Array.isArray(item.task)) {
|
|
17
|
+
throw new Error('Jenkins returned an invalid queue item');
|
|
18
|
+
}
|
|
19
|
+
const { url } = item.task;
|
|
20
|
+
// Some Jenkins task types do not export a URL.
|
|
21
|
+
if (url === undefined || url === null) continue;
|
|
22
|
+
if (typeof url !== 'string') {
|
|
23
|
+
throw new Error('Jenkins returned an invalid queue task URL');
|
|
24
|
+
}
|
|
25
|
+
if (url === PR_JOB_URL || url === `${PR_JOB_URL}/`) {
|
|
26
|
+
if (!Number.isSafeInteger(item.id) || item.id < 0) {
|
|
27
|
+
throw new Error('Jenkins returned an invalid queue item ID');
|
|
28
|
+
}
|
|
29
|
+
queued.add(item.id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// PR multijobs stay on an executor while waiting for downstream tests. The
|
|
34
|
+
// waiting queue alone misses those builds, and build history can be truncated.
|
|
35
|
+
const computers = await readJenkinsJSON(request,
|
|
36
|
+
'/computer/api/json?tree=computer[executors[currentExecutable[url,queueId]],' +
|
|
37
|
+
'oneOffExecutors[currentExecutable[url,queueId]]]', signal);
|
|
38
|
+
if (!Array.isArray(computers?.computer)) {
|
|
39
|
+
throw new Error('Jenkins returned invalid executor data');
|
|
40
|
+
}
|
|
41
|
+
const running = new Set();
|
|
42
|
+
for (const computer of computers.computer) {
|
|
43
|
+
if (!Array.isArray(computer?.executors) || !Array.isArray(computer?.oneOffExecutors)) {
|
|
44
|
+
throw new Error('Jenkins returned invalid executor lists');
|
|
45
|
+
}
|
|
46
|
+
for (const executor of [...computer.executors, ...computer.oneOffExecutors]) {
|
|
47
|
+
if (!executor || typeof executor !== 'object' || Array.isArray(executor)) {
|
|
48
|
+
throw new Error('Jenkins returned an invalid executor');
|
|
49
|
+
}
|
|
50
|
+
const build = executor.currentExecutable;
|
|
51
|
+
if (build === undefined || build === null) continue;
|
|
52
|
+
if (typeof build !== 'object' || Array.isArray(build)) {
|
|
53
|
+
throw new Error('Jenkins returned an invalid executable');
|
|
54
|
+
}
|
|
55
|
+
const { url, queueId } = build;
|
|
56
|
+
if (url === undefined || url === null) continue;
|
|
57
|
+
if (typeof url !== 'string') {
|
|
58
|
+
throw new Error('Jenkins returned an invalid executable URL');
|
|
59
|
+
}
|
|
60
|
+
if (!url.startsWith(`${PR_JOB_URL}/`)) continue;
|
|
61
|
+
const match = /^([1-9]\d*)\/?$/.exec(url.slice(PR_JOB_URL.length + 1));
|
|
62
|
+
if (!match) continue;
|
|
63
|
+
if (!Number.isSafeInteger(queueId)) {
|
|
64
|
+
throw new Error('Jenkins returned an invalid executable queue ID');
|
|
65
|
+
}
|
|
66
|
+
running.add(match[1]);
|
|
67
|
+
// A queued request can start between the two reads. Count it only once.
|
|
68
|
+
queued.delete(queueId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return queued.size + running.size;
|
|
72
|
+
}
|