Skip to content

Commit 00626ea

Browse files
committed
fix: exclude PR-modified tests from the flaky count
A failure in a test that the pull request itself modified is likely caused by that change, so counting it as an independent flaky occurrence overstates the number of affected pull requests. Compare the failing test against the files the pull request touched and drop the occurrence when they match. When the file list cannot be fetched the occurrence is kept, so incomplete information never removes a genuine failure. Fixes: #1164 Signed-off-by: Avocado <ujubongbong@gmail.com>
1 parent e2da124 commit 00626ea

3 files changed

Lines changed: 170 additions & 11 deletions

File tree

bin/ncu-ci.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,8 @@ class WalkCommand extends CICommand {
460460
if (this.queue.length === 0) {
461461
return;
462462
}
463-
const aggregator = new FailureAggregator(cli, this.json);
464-
this.json = aggregator.aggregate();
463+
const aggregator = new FailureAggregator(cli, this.json, this.request);
464+
this.json = await aggregator.aggregate();
465465
cli.log('');
466466
cli.separator('Stats');
467467
cli.log('');
@@ -541,8 +541,8 @@ class DailyCommand extends CICommand {
541541

542542
async aggregate() {
543543
const { argv, cli } = this;
544-
const aggregator = new FailureAggregator(cli, this.json);
545-
this.json = aggregator.aggregate();
544+
const aggregator = new FailureAggregator(cli, this.json, this.request);
545+
this.json = await aggregator.aggregate();
546546
cli.log('');
547547
cli.separator('Stats');
548548
cli.log('');

lib/ci/failure_aggregator.js

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,47 @@ function uniqBy(array, key) {
2020
}
2121

2222
export class FailureAggregator {
23-
constructor(cli, data) {
23+
constructor(cli, data, request) {
2424
this.cli = cli;
25+
this.request = request;
2526
this.health = data[0];
2627
this.failures = data.slice(1);
2728
this.aggregates = null;
2829
}
2930

30-
aggregate() {
31+
/**
32+
* Tells whether the pull request that triggered the run also modified the
33+
* test that failed. Such a failure is likely caused by the change itself,
34+
* so it should not count as an independent flaky occurrence.
35+
*/
36+
async isSelfInflicted(failure) {
37+
const { file, source } = failure;
38+
if (!file || !this.request) {
39+
return false;
40+
}
41+
42+
const pr = parsePRFromURL(source);
43+
if (!pr) {
44+
return false;
45+
}
46+
47+
const path = `test/${file}.js`;
48+
try {
49+
for await (const changed of this.request.getPullRequestFiles(pr)) {
50+
if (changed.filename === path) {
51+
return true;
52+
}
53+
}
54+
} catch {
55+
// Not being able to fetch the changed files is not fatal: keep the
56+
// occurrence rather than dropping it on incomplete information.
57+
this.cli.warn(`Could not determine the files changed by ${source}`);
58+
}
59+
60+
return false;
61+
}
62+
63+
async aggregate() {
3164
const groupedByReason = Object.groupBy(this.failures, getHighlight);
3265
const data = [];
3366
for (const reason of Object.keys(groupedByReason).sort()) {
@@ -37,7 +70,11 @@ export class FailureAggregator {
3770

3871
// If multiple sub builds of one PR are failed by the same reason,
3972
// we'll only take one of those builds, as that might be a genuine failure
40-
const prs = uniqBy(failures, 'source')
73+
const candidates = uniqBy(failures, 'source');
74+
const selfInflicted = await Promise.all(
75+
candidates.map(failure => this.isSelfInflicted(failure)));
76+
const prs = candidates
77+
.filter((_, index) => !selfInflicted[index])
4178
.map(({ source, upstream }) => ({ source, upstream, _id: parseJobFromURL(upstream).jobid }))
4279
.sort((a, b) => a._id - b._id);
4380
const machines = uniqBy(
@@ -57,9 +94,9 @@ export class FailureAggregator {
5794
}
5895

5996
formatAsMarkdown() {
60-
let { aggregates } = this;
97+
const { aggregates } = this;
6198
if (!aggregates) {
62-
aggregates = this.aggregates = this.aggregate();
99+
throw new Error('aggregate() must be awaited before formatAsMarkdown()');
63100
}
64101

65102
const last = parseJobFromURL(this.failures[0].upstream);
@@ -118,9 +155,9 @@ export class FailureAggregator {
118155
}
119156

120157
display() {
121-
let { cli, aggregates } = this;
158+
const { cli, aggregates } = this;
122159
if (!aggregates) {
123-
aggregates = this.aggregates = this.aggregate();
160+
throw new Error('aggregate() must be awaited before display()');
124161
}
125162

126163
for (const type of Object.keys(aggregates)) {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import assert from 'node:assert';
2+
import { describe, it } from 'node:test';
3+
4+
import { FailureAggregator } from '../../lib/ci/failure_aggregator.js';
5+
6+
const health = { type: 'health' };
7+
8+
/**
9+
* Builds a JS test failure as produced by the CI parsers, where `file` is the
10+
* test name reported by the runner and `source` is the pull request that
11+
* triggered the run.
12+
*/
13+
function failure(prid, file, jobid) {
14+
return {
15+
type: 'JS_TEST_FAILURE',
16+
reason: `not ok 1 ${file}\n ---\n severity: fail\n`,
17+
highlight: 0,
18+
file,
19+
source: `https://github.com/nodejs/node/pull/${prid}/`,
20+
upstream: `https://ci.nodejs.org/job/node-test-pull-request/${jobid}/`,
21+
builtOn: `test-machine-${jobid}`,
22+
url: `https://ci.nodejs.org/job/node-test-commit/${jobid}/console`
23+
};
24+
}
25+
26+
/**
27+
* Stubs the parts of the request client the aggregator relies on. `changed`
28+
* maps a pull request number to the files it modified.
29+
*/
30+
function requestStub(changed) {
31+
return {
32+
async * getPullRequestFiles({ prid }) {
33+
for (const filename of changed[prid] ?? []) {
34+
yield { filename };
35+
}
36+
}
37+
};
38+
}
39+
40+
const cli = { warn() {} };
41+
42+
describe('FailureAggregator', () => {
43+
it('should not count a failure in a test the pull request modified', async() => {
44+
const request = requestStub({
45+
65113: ['lib/fs.js'],
46+
65233: ['test/ffi/test-ffi-fast-buffer.js']
47+
});
48+
49+
const aggregator = new FailureAggregator(cli, [
50+
health,
51+
failure(65113, 'ffi/test-ffi-fast-buffer', 75793),
52+
failure(65233, 'ffi/test-ffi-fast-buffer', 75799)
53+
], request);
54+
55+
const aggregates = await aggregator.aggregate();
56+
const [entry] = aggregates.JS_TEST_FAILURE;
57+
58+
assert.strictEqual(entry.prs.length, 1);
59+
assert.strictEqual(
60+
entry.prs[0].source,
61+
'https://github.com/nodejs/node/pull/65113/'
62+
);
63+
});
64+
65+
it('should keep failures in tests the pull request left alone', async() => {
66+
const request = requestStub({
67+
65113: ['lib/fs.js'],
68+
65233: ['src/ffi/fast.cc']
69+
});
70+
71+
const aggregator = new FailureAggregator(cli, [
72+
health,
73+
failure(65113, 'ffi/test-ffi-fast-buffer', 75793),
74+
failure(65233, 'ffi/test-ffi-fast-buffer', 75799)
75+
], request);
76+
77+
const aggregates = await aggregator.aggregate();
78+
const [entry] = aggregates.JS_TEST_FAILURE;
79+
80+
assert.strictEqual(entry.prs.length, 2);
81+
});
82+
83+
it('should keep the occurrence when the changed files cannot be fetched', async() => {
84+
const request = {
85+
getPullRequestFiles() {
86+
throw new Error('network is down');
87+
}
88+
};
89+
90+
const aggregator = new FailureAggregator(cli, [
91+
health,
92+
failure(65233, 'ffi/test-ffi-fast-buffer', 75799)
93+
], request);
94+
95+
const aggregates = await aggregator.aggregate();
96+
const [entry] = aggregates.JS_TEST_FAILURE;
97+
98+
assert.strictEqual(entry.prs.length, 1);
99+
});
100+
101+
it('should leave failures without a test file untouched', async() => {
102+
const request = requestStub({ 65233: ['test/ffi/test-ffi-fast-buffer.js'] });
103+
104+
const buildFailure = {
105+
type: 'BUILD_FAILURE',
106+
reason: 'fatal: could not read Username',
107+
highlight: 0,
108+
source: 'https://github.com/nodejs/node/pull/65233/',
109+
upstream: 'https://ci.nodejs.org/job/node-test-pull-request/75799/',
110+
builtOn: 'test-machine',
111+
url: 'https://ci.nodejs.org/job/node-test-commit/75799/console'
112+
};
113+
114+
const aggregator = new FailureAggregator(
115+
cli, [health, buildFailure], request);
116+
117+
const aggregates = await aggregator.aggregate();
118+
const [entry] = aggregates.BUILD_FAILURE;
119+
120+
assert.strictEqual(entry.prs.length, 1);
121+
});
122+
});

0 commit comments

Comments
 (0)