Skip to content

Commit 89c7685

Browse files
committed
feat: add ncu-ci available
Check Jenkins shutdown state and PR job readiness before processing CI requests. Report unavailable or unknown states with a nonzero exit code. Signed-off-by: Filip Skokan <panva.ip@gmail.com> Assisted-by: Codex
1 parent 76fc75b commit 89c7685

6 files changed

Lines changed: 425 additions & 0 deletions

File tree

‎bin/ncu-ci.js‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
RunPRJob
2525
} from '../lib/ci/run_ci.js';
2626
import { ResumePRJob } from '../lib/ci/resume_ci.js';
27+
import { checkAvailability } from '../lib/ci/availability.js';
2728
import { writeJson, writeFile } from '../lib/file.js';
2829
import { getMergedConfig } from '../lib/config.js';
2930
import { runPromise } from '../lib/run.js';
@@ -54,6 +55,11 @@ const commandKeys = [
5455

5556
const args = yargs(hideBin(process.argv))
5657
.completion('completion')
58+
.command({
59+
command: 'available',
60+
desc: 'Check whether Jenkins is available for PR CI requests',
61+
handler
62+
})
5763
.command({
5864
command: 'rate <type>',
5965
desc: 'Calculate the green rate of a CI job in the last 100 runs',
@@ -598,7 +604,27 @@ class DailyCommand extends CICommand {
598604
}
599605
}
600606

607+
async function checkJenkins() {
608+
try {
609+
let jenkins;
610+
try {
611+
const credentials = await auth({ github: false, jenkins: true });
612+
jenkins = credentials.jenkins;
613+
} catch {
614+
throw new Error('Configure username and jenkins_token with ncu-config');
615+
}
616+
const request = new Request({ jenkins });
617+
await checkAvailability(request);
618+
} catch (err) {
619+
console.error(`Unable to check Jenkins availability: ${err.message}`);
620+
process.exitCode = 1;
621+
}
622+
}
623+
601624
async function main(command, argv) {
625+
if (command === 'available') {
626+
return checkJenkins();
627+
}
602628
const cli = new CLI();
603629
const credentials = await auth({
604630
github: true,

‎docs/ncu-ci.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Supported jobs:
1313
ncu-ci <command>
1414
1515
Commands:
16+
ncu-ci available Check whether Jenkins is available for PR CI requests
1617
ncu-ci rate <type> Calculate the green rate of a CI job in the last 100
1718
runs
1819
ncu-ci walk <type> Walk the CI and display the failures
@@ -35,6 +36,30 @@ Options:
3536
--help Show help [boolean]
3637
```
3738

39+
### `ncu-ci available`
40+
41+
`ncu-ci available` checks whether Jenkins is available for PR CI requests. It
42+
exits with status 0 and no output when the controller is not preparing for
43+
shutdown and `node-test-pull-request` is enabled and buildable. Otherwise it exits
44+
with status 1 and explains the reason on stderr. HTTP failures, timeouts, and
45+
unrecognized responses are treated as unavailable.
46+
47+
The command only reads Jenkins state. It does not start or resume a build, check
48+
individual PRs, or require idle executors. Availability can change after the
49+
check.
50+
51+
The command uses the configured `username` and `jenkins_token`; no GitHub token,
52+
repository configuration, or PR argument is required. It has a 20-second deadline
53+
for its Jenkins requests, including response bodies.
54+
55+
For example, skip processing requests unless Jenkins is available:
56+
57+
```sh
58+
ncu-ci available || exit 0
59+
60+
# Process requests here.
61+
```
62+
3863
### `ncu-ci rate <type>`
3964

4065
`ncu-ci rate <type>` calculate the success rate for CI jobs in the last 100 runs per [CI Health History](https://github.com/nodejs/reliability#ci-health-history), where `<type>` can be either `pr` for `node-test-pull-request` or `commit` for `node-test-commit`. See `ncu-ci rate --help` for more.

‎lib/ci/availability.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

‎lib/ci/jenkins.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

‎test/unit/ci_availability.test.js‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { checkAvailability } from '../../lib/ci/availability.js';
5+
6+
const controllerURL = 'https://ci.nodejs.org/api/json?tree=quietingDown';
7+
const jobURL = 'https://ci.nodejs.org/job/node-test-pull-request/api/json?tree=disabled,buildable';
8+
const availableController = { quietingDown: false };
9+
const availableJob = { disabled: false, buildable: true };
10+
11+
function requestFor(t, controller = availableController, job = availableJob) {
12+
return {
13+
fetch: t.mock.fn(async(url) => {
14+
assert.ok(url === controllerURL || url === jobURL);
15+
return { status: 200, json: async() => url === controllerURL ? controller : job };
16+
})
17+
};
18+
}
19+
20+
describe('Jenkins availability', () => {
21+
it('confirms controller and PR job availability with one shared deadline', async(t) => {
22+
const controller = new AbortController();
23+
const timeout = t.mock.method(AbortSignal, 'timeout', () => controller.signal);
24+
const request = requestFor(t);
25+
26+
assert.equal(await checkAvailability(request), undefined);
27+
assert.deepEqual(timeout.mock.calls.map(call => call.arguments), [[20_000]]);
28+
assert.deepEqual(request.fetch.mock.calls.map(call => call.arguments), [
29+
[controllerURL, {
30+
method: 'GET', redirect: 'error',
31+
headers: { Accept: 'application/json' }, signal: controller.signal
32+
}],
33+
[jobURL, {
34+
method: 'GET', redirect: 'error',
35+
headers: { Accept: 'application/json' }, signal: controller.signal
36+
}]
37+
]);
38+
});
39+
40+
it('stops before reading the job when Jenkins is quieting down', async(t) => {
41+
const request = requestFor(t, { quietingDown: true });
42+
await assert.rejects(checkAvailability(request), {
43+
message: 'Jenkins is preparing for shutdown'
44+
});
45+
assert.equal(request.fetch.mock.callCount(), 1);
46+
});
47+
48+
for (const controller of [null, {}, { quietingDown: 'false' }, { quietingDown: 0 }]) {
49+
it(`rejects unknown controller state: ${JSON.stringify(controller)}`, async(t) => {
50+
const request = requestFor(t, controller);
51+
await assert.rejects(checkAvailability(request), {
52+
message: 'Jenkins quiet-down state is not confirmed'
53+
});
54+
assert.equal(request.fetch.mock.callCount(), 1);
55+
});
56+
}
57+
58+
for (const job of [
59+
{ disabled: true, buildable: false },
60+
{ disabled: true, buildable: true }
61+
]) {
62+
it(`rejects disabled PR job: ${JSON.stringify(job)}`, async(t) => {
63+
await assert.rejects(checkAvailability(requestFor(t, availableController, job)), {
64+
message: 'Jenkins PR job is disabled'
65+
});
66+
});
67+
}
68+
69+
it('rejects a PR job that cannot be built', async(t) => {
70+
const request = requestFor(t, availableController, { disabled: false, buildable: false });
71+
await assert.rejects(checkAvailability(request), {
72+
message: 'Jenkins PR job is not buildable'
73+
});
74+
});
75+
76+
for (const job of [
77+
null, {}, { disabled: false }, { buildable: true },
78+
{ disabled: 'false', buildable: true }, { disabled: false, buildable: 'true' }
79+
]) {
80+
it(`rejects unknown PR job state: ${JSON.stringify(job)}`, async(t) => {
81+
await assert.rejects(checkAvailability(requestFor(t, availableController, job)), {
82+
message: 'Jenkins PR job availability is not confirmed'
83+
});
84+
});
85+
}
86+
87+
for (const failedURL of [controllerURL, jobURL]) {
88+
it(`propagates network failures from ${failedURL}`, async(t) => {
89+
const failure = new Error('Connection reset');
90+
const request = requestFor(t);
91+
request.fetch.mock.mockImplementation(async(url) => {
92+
if (url === failedURL) throw failure;
93+
return { status: 200, json: async() => availableController };
94+
});
95+
await assert.rejects(checkAvailability(request), error => error === failure);
96+
assert.equal(request.fetch.mock.callCount(), failedURL === controllerURL ? 1 : 2);
97+
});
98+
99+
it(`rejects malformed JSON from ${failedURL}`, async(t) => {
100+
const request = requestFor(t);
101+
request.fetch.mock.mockImplementation(async(url) => ({
102+
status: 200,
103+
json: async() => {
104+
if (url === failedURL) throw new SyntaxError('Invalid JSON');
105+
return availableController;
106+
}
107+
}));
108+
await assert.rejects(checkAvailability(request), /JSON/);
109+
assert.equal(request.fetch.mock.callCount(), failedURL === controllerURL ? 1 : 2);
110+
});
111+
112+
for (const status of [302, 401, 403, 404, 503]) {
113+
it(`rejects HTTP ${status} from ${failedURL}`, async(t) => {
114+
const request = requestFor(t);
115+
const cancel = t.mock.fn(async() => {});
116+
const json = t.mock.fn(async() => availableJob);
117+
request.fetch.mock.mockImplementation(async(url) => url === failedURL
118+
? { status, statusText: 'Unavailable', body: { cancel }, json }
119+
: { status: 200, json: async() => availableController });
120+
await assert.rejects(checkAvailability(request), new RegExp(String(status)));
121+
assert.equal(request.fetch.mock.callCount(), failedURL === controllerURL ? 1 : 2);
122+
assert.equal(json.mock.callCount(), 0);
123+
assert.equal(cancel.mock.callCount(), 1);
124+
});
125+
}
126+
}
127+
128+
it('preserves connection failures while reading a response body', async() => {
129+
const failure = new Error('Connection reset while reading response');
130+
const request = {
131+
fetch: async() => ({ status: 200, json: async() => { throw failure; } })
132+
};
133+
await assert.rejects(checkAvailability(request), error => error === failure);
134+
});
135+
136+
it('keeps the deadline active while reading the job response body', async(t) => {
137+
const controller = new AbortController();
138+
t.mock.method(AbortSignal, 'timeout', () => controller.signal);
139+
const timeout = new DOMException('Availability check timed out', 'TimeoutError');
140+
const request = requestFor(t);
141+
request.fetch.mock.mockImplementation(async(url, { signal }) => ({
142+
status: 200,
143+
json: async() => {
144+
if (url === controllerURL) return availableController;
145+
const body = new Promise((resolve, reject) => {
146+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
147+
});
148+
controller.abort(timeout);
149+
return body;
150+
}
151+
}));
152+
153+
await assert.rejects(checkAvailability(request), error => error === timeout);
154+
assert.equal(request.fetch.mock.callCount(), 2);
155+
});
156+
});

0 commit comments

Comments
 (0)