Skip to content

Commit 76fc75b

Browse files
committed
fix: guide CI recovery without bypassing duplicates
Check resume availability before suggesting a recovery action. Keep same-commit duplicates blocked when neither the latest run nor an eligible ancestor can resume, and guide users to inspect the run and recover manually. Suggest resume-ci only when a resume action is available. Signed-off-by: Filip Skokan <panva.ip@gmail.com> Assisted-by: Codex
1 parent b4fd6b2 commit 76fc75b

6 files changed

Lines changed: 294 additions & 18 deletions

File tree

‎docs/ncu-ci.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ Options:
150150
- `--owner <owner>`: GitHub repository owner, used when `<prid>` is not a GitHub URL
151151
- `--repo <repo>`: GitHub repository name, used when `<prid>` is not a GitHub URL
152152

153+
With `--check-for-duplicates`, an existing CI run for the same approved commit
154+
prevents starting another run, including when a failed or aborted build has no
155+
eligible resume action. Errors checking resume availability also prevent starting
156+
another run.
157+
153158
Examples:
154159

155160
Run CI for a PR number using repository information from config or flags:
@@ -189,7 +194,7 @@ ancestor that offers the action, and reports which run it will resume. Each
189194
ancestor must have finished with `FAILURE` or `ABORTED` and match the PR,
190195
repository, and approved commit. It does not search unrelated older CI runs or
191196
skip past a mismatched ancestor. If no eligible ancestor is available, the command
192-
reports the build URL and a command to start a new CI run. Errors checking
197+
reports the build URL and exits unsuccessfully. Errors checking
193198
availability stop the command without attempting to resume.
194199

195200
The CI-approved commit (`COMMIT_SHA_CHECK`) must match the PR's current HEAD.

‎lib/ci/ci_utils.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import qs from 'node:querystring';
2+
import { getPrURL } from '../links.js';
23

34
import {
45
CI_DOMAIN,
@@ -13,6 +14,18 @@ export const statusType = {
1314
UNSTABLE: 'UNSTABLE'
1415
};
1516

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+
1629
export function getPath(url) {
1730
return url.replace(`https://${CI_DOMAIN}/`, '').replace('api/json', '');
1831
}

‎lib/ci/resume_ci.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getPrURL } from '../links.js';
55
import { debuglog } from '../verbosity.js';
66
import { FailureFileScanner } from './failure_file_scanner.js';
77
import { findResumableBuild, getApprovedSHA, RESUME_TREE } from './resumable_build.js';
8+
import { getCIActionAdvice } from './ci_utils.js';
89

910
export class ResumePRJob {
1011
constructor(cli, request, owner, repo, prid) {
@@ -19,7 +20,7 @@ export class ResumePRJob {
1920
const { cli } = this;
2021
cli.stopSpinner(
2122
`Cannot resume PR CI job ${jobid}: Jenkins does not offer a "Resume build" action. ` +
22-
`Start a new CI run with: ncu-ci run ${getPrURL(this)}`,
23+
getCIActionAdvice(this, 'run'),
2324
cli.SPINNER_STATUS.FAILED);
2425
cli.error(build.jobUrl);
2526
}

‎lib/ci/run_ci.js‎

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import PRData from '../pr_data.js';
1010
import { debuglog } from '../verbosity.js';
1111
import PRChecker from '../pr_checker.js';
1212
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';
1319

1420
export const CI_CRUMB_URL = `https://${CI_DOMAIN}/crumbIssuer/api/json`;
1521
const CI_PR_NAME = CI_TYPES.get(CI_TYPES_KEYS.PR).jobName;
@@ -94,12 +100,12 @@ export class RunPRJob {
94100
if (checkForDuplicates) {
95101
await this.prData.getComments();
96102
const { jobid, link } = new JobParser(this.prData.comments).parse().get('PR') ?? {};
97-
let actions;
103+
let buildData;
98104
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+
}
103109
} catch (err) {
104110
// Jenkins may no longer have the build (e.g. the record was lost) or
105111
// may answer with an HTML error page instead of JSON. Do not let that
@@ -108,11 +114,35 @@ export class RunPRJob {
108114
cli.SPINNER_STATUS.WARN);
109115
cli.warn('Skipping the duplicate CI check');
110116
}
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) {
113121
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}`);
116146
return false;
117147
}
118148
}

‎test/unit/ci_resume.test.js‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ describe('Jenkins resume', () => {
7878
const jobURL = `https://ci.nodejs.org/job/node-test-pull-request/${jobid}/`;
7979
const menuURL = `${jobURL}contextMenu`;
8080
const unavailableMessage = `Cannot resume PR CI job ${jobid}: Jenkins does not offer a ` +
81-
'"Resume build" action. Start a new CI run with: ' +
81+
'"Resume build" action. Check the existing CI run in Jenkins and rebase the PR if needed. ' +
82+
'To start a new CI run manually: ' +
8283
`ncu-ci run https://github.com/${owner}/${repo}/pull/${prid}`;
8384
const apiURL = `${jobURL}api/json?tree=${encodeURIComponent(resumeTree)}`;
8485
const fullAPIURL = new PRBuild(null, null, jobid).apiUrl;
@@ -152,10 +153,26 @@ describe('Jenkins resume', () => {
152153
assert.deepEqual(cli._calls.stopSpinner.at(-1), [
153154
unavailableMessage, cli.SPINNER_STATUS.FAILED
154155
]);
156+
assert.doesNotMatch(cli._calls.stopSpinner.at(-1)[0], /request-ci|resume-ci/);
155157
assert.deepEqual(cli._calls.error, [[jobURL]]);
156158
});
157159
}
158160

161+
it('suggests manual recovery for an unavailable nodejs/node run', async() => {
162+
jobRunner = new ResumePRJob(cli, request, 'nodejs', 'node', prid);
163+
menuRequest.resolves({ status: 200, json: async() => ({ items: [] }) });
164+
assert.equal(await jobRunner.resume(), false);
165+
sinon.assert.notCalled(resumeRequest);
166+
assert.deepEqual(cli._calls.stopSpinner.at(-1), [
167+
`Cannot resume PR CI job ${jobid}: Jenkins does not offer a "Resume build" action. ` +
168+
'Check the existing CI run in Jenkins and rebase the PR if needed. ' +
169+
'To start a new CI run manually: ' +
170+
`ncu-ci run https://github.com/nodejs/node/pull/${prid}`,
171+
cli.SPINNER_STATUS.FAILED
172+
]);
173+
assert.doesNotMatch(cli._calls.stopSpinner.at(-1)[0], /request-ci|resume-ci/);
174+
});
175+
159176
describe('resume ancestry', () => {
160177
const ancestorId = jobid - 1;
161178
const resumeCause = (id, overrides = {}) => ({
@@ -1030,16 +1047,20 @@ describe('ncu-ci resume CLI', () => {
10301047
{ status: 404, statusText: 'Not Found' });
10311048
assert.equal(status, 1, output);
10321049
assert.match(output, /Cannot resume PR CI job 654321: Jenkins does not offer a "Resume build" action/);
1033-
assert.match(output, /ncu-ci run https:\/\/github.com\/nodejs\/node\/pull\/123456/);
1050+
assert.ok(output.includes('Check the existing CI run in Jenkins and rebase the PR if needed. ' +
1051+
'To start a new CI run manually: ncu-ci run https://github.com/nodejs/node/pull/123456'));
1052+
assert.doesNotMatch(output, /request-ci|resume-ci/);
10341053
assert.doesNotMatch(output, /PR CI job successfully resumed/);
10351054
});
10361055

1037-
it('exits 1 with a new-run command when Jenkins offers no resume action', (t) => {
1056+
it('exits 1 with manual recovery guidance when Jenkins offers no resume action', (t) => {
10381057
const { status, output } = run(t, ['resume', 'https://github.com/nodejs/node/pull/123456'],
10391058
true, 'README.md', resumeBuildData, approvedSHA, { status: 200 }, { items: [] });
10401059
assert.equal(status, 1, output);
10411060
assert.match(output, /Cannot resume PR CI job 654321: Jenkins does not offer a "Resume build" action/);
1042-
assert.match(output, /ncu-ci run https:\/\/github.com\/nodejs\/node\/pull\/123456/);
1061+
assert.ok(output.includes('Check the existing CI run in Jenkins and rebase the PR if needed. ' +
1062+
'To start a new CI run manually: ncu-ci run https://github.com/nodejs/node/pull/123456'));
1063+
assert.doesNotMatch(output, /request-ci|resume-ci/);
10431064
assert.ok(output.includes(jobURL));
10441065
assert.doesNotMatch(output, /Checking failures|Resuming PR CI|PR CI job successfully resumed/);
10451066
});

0 commit comments

Comments
 (0)