Skip to content

Commit c4660c7

Browse files
andimarekclaude
andcommitted
Add per-class coverage deltas to PR comment
Parse per-class counters from JaCoCo XML and compare against master baseline. The PR comment now shows a collapsible table of classes where line, branch, or method coverage changed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eb3e934 commit c4660c7

File tree

3 files changed

+123
-8
lines changed

3 files changed

+123
-8
lines changed

.github/workflows/master.yml

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,17 +123,43 @@ jobs:
123123
}
124124
125125
// Update coverage from JaCoCo XML
126-
let coverage = baseline.coverage || {};
126+
let coverage = { overall: {}, classes: {} };
127127
const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
128128
if (fs.existsSync(jacocoFile)) {
129129
const xml = fs.readFileSync(jacocoFile, 'utf8');
130+
131+
// Overall counters (outside <package> tags)
130132
const stripped = xml.replace(/<package[\s\S]*?<\/package>/g, '');
131133
const re = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
132134
let m;
133135
while ((m = re.exec(stripped)) !== null) {
134-
if (m[1] === 'LINE') coverage.line = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
135-
else if (m[1] === 'BRANCH') coverage.branch = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
136-
else if (m[1] === 'METHOD') coverage.method = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
136+
if (m[1] === 'LINE') coverage.overall.line = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
137+
else if (m[1] === 'BRANCH') coverage.overall.branch = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
138+
else if (m[1] === 'METHOD') coverage.overall.method = { covered: parseInt(m[3]), missed: parseInt(m[2]) };
139+
}
140+
141+
// Per-class counters from <package>/<class> elements
142+
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
143+
let pkgMatch;
144+
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
145+
const pkgName = pkgMatch[1].replace(/\//g, '.');
146+
const pkgBody = pkgMatch[2];
147+
const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
148+
let classMatch;
149+
while ((classMatch = classRe.exec(pkgBody)) !== null) {
150+
const className = classMatch[1].replace(/\//g, '.');
151+
const classBody = classMatch[2];
152+
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
153+
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
154+
let cntMatch;
155+
while ((cntMatch = cntRe.exec(classBody)) !== null) {
156+
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
157+
if (cntMatch[1] === 'LINE') counters.line = entry;
158+
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
159+
else if (cntMatch[1] === 'METHOD') counters.method = entry;
160+
}
161+
coverage.classes[className] = counters;
162+
}
137163
}
138164
}
139165

.github/workflows/pull_request.yml

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,12 @@ jobs:
133133
baseline = JSON.parse(fs.readFileSync(baselineFile, 'utf8'));
134134
}
135135
const baseTests = baseline.tests || {};
136-
const baseCov = baseline.coverage || {};
136+
const baseCov = (baseline.coverage || {}).overall || {};
137+
const baseClasses = (baseline.coverage || {}).classes || {};
137138
138139
// --- Parse JaCoCo XML for coverage ---
139140
let covLine = null, covBranch = null, covMethod = null;
141+
const classCounters = {};
140142
const jacocoFile = path.join('coverage', 'jacocoTestReport.xml');
141143
if (fs.existsSync(jacocoFile)) {
142144
const xml = fs.readFileSync(jacocoFile, 'utf8');
@@ -150,6 +152,30 @@ jobs:
150152
else if (m[1] === 'BRANCH') covBranch = entry;
151153
else if (m[1] === 'METHOD') covMethod = entry;
152154
}
155+
156+
// Extract per-class counters from <package>/<class> elements
157+
const pkgRe = /<package\s+name="([^"]+)">([\s\S]*?)<\/package>/g;
158+
let pkgMatch;
159+
while ((pkgMatch = pkgRe.exec(xml)) !== null) {
160+
const pkgName = pkgMatch[1].replace(/\//g, '.');
161+
const pkgBody = pkgMatch[2];
162+
const classRe = /<class\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/class>/g;
163+
let classMatch;
164+
while ((classMatch = classRe.exec(pkgBody)) !== null) {
165+
const className = classMatch[1].replace(/\//g, '.');
166+
const classBody = classMatch[2];
167+
const counters = { line: { ...zeroCov }, branch: { ...zeroCov }, method: { ...zeroCov } };
168+
const cntRe = /<counter type="(\w+)" missed="(\d+)" covered="(\d+)"\/>/g;
169+
let cntMatch;
170+
while ((cntMatch = cntRe.exec(classBody)) !== null) {
171+
const entry = { covered: parseInt(cntMatch[3]), missed: parseInt(cntMatch[2]) };
172+
if (cntMatch[1] === 'LINE') counters.line = entry;
173+
else if (cntMatch[1] === 'BRANCH') counters.branch = entry;
174+
else if (cntMatch[1] === 'METHOD') counters.method = entry;
175+
}
176+
classCounters[className] = counters;
177+
}
178+
}
153179
}
154180
155181
// --- Helpers ---
@@ -232,6 +258,66 @@ jobs:
232258
body += `| ${name} | ${curr.covered} | ${curr.missed} | ${fmtPct(currPct)} | ${fmtPctDelta(currPct, basePct)} |\n`;
233259
}
234260
261+
body += '\n';
262+
263+
// --- Per-class coverage deltas ---
264+
const changedClasses = [];
265+
for (const [cls, curr] of Object.entries(classCounters)) {
266+
const base = baseClasses[cls] || { line: zeroCov, branch: zeroCov, method: zeroCov };
267+
const currLinePct = pct(curr.line.covered, curr.line.missed);
268+
const baseLinePct = pct(base.line.covered, base.line.missed);
269+
const currBranchPct = pct(curr.branch.covered, curr.branch.missed);
270+
const baseBranchPct = pct(base.branch.covered, base.branch.missed);
271+
const currMethodPct = pct(curr.method.covered, curr.method.missed);
272+
const baseMethodPct = pct(base.method.covered, base.method.missed);
273+
if (Math.abs(currLinePct - baseLinePct) >= 0.05 ||
274+
Math.abs(currBranchPct - baseBranchPct) >= 0.05 ||
275+
Math.abs(currMethodPct - baseMethodPct) >= 0.05) {
276+
changedClasses.push({
277+
name: cls,
278+
linePct: currLinePct, lineDelta: currLinePct - baseLinePct,
279+
branchPct: currBranchPct, branchDelta: currBranchPct - baseBranchPct,
280+
methodPct: currMethodPct, methodDelta: currMethodPct - baseMethodPct,
281+
});
282+
}
283+
}
284+
// Also detect classes removed (in baseline but not in current)
285+
for (const cls of Object.keys(baseClasses)) {
286+
if (!classCounters[cls]) {
287+
changedClasses.push({
288+
name: cls,
289+
linePct: 0, lineDelta: -pct(baseClasses[cls].line.covered, baseClasses[cls].line.missed),
290+
branchPct: 0, branchDelta: -pct(baseClasses[cls].branch.covered, baseClasses[cls].branch.missed),
291+
methodPct: 0, methodDelta: -pct(baseClasses[cls].method.covered, baseClasses[cls].method.missed),
292+
removed: true,
293+
});
294+
}
295+
}
296+
297+
changedClasses.sort((a, b) => a.name.localeCompare(b.name));
298+
299+
function fmtClassPct(val, delta) {
300+
const pctStr = fmtPct(val);
301+
const deltaStr = fmtPctDelta(val, val - delta);
302+
return `${pctStr} (${deltaStr})`;
303+
}
304+
305+
if (changedClasses.length > 0) {
306+
body += `<details><summary>Changed Class Coverage (${changedClasses.length} ${changedClasses.length === 1 ? 'class' : 'classes'})</summary>\n\n`;
307+
body += '| Class | Line | Branch | Method |\n';
308+
body += '|:------|-----:|-------:|-------:|\n';
309+
for (const c of changedClasses) {
310+
if (c.removed) {
311+
body += `| \`${c.name}\` | *removed* | *removed* | *removed* |\n`;
312+
} else {
313+
body += `| \`${c.name}\` | ${fmtClassPct(c.linePct, c.lineDelta)} | ${fmtClassPct(c.branchPct, c.branchDelta)} | ${fmtClassPct(c.methodPct, c.methodDelta)} |\n`;
314+
}
315+
}
316+
body += '\n</details>\n';
317+
} else {
318+
body += '> No per-class coverage changes detected.\n';
319+
}
320+
235321
body += '\n> Full HTML report: build artifact `jacoco-html-report`\n';
236322
}
237323

test-baseline.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
"java25": { "total": 0, "passed": 0, "failed": 0, "errors": 0, "skipped": 0 }
77
},
88
"coverage": {
9-
"line": { "covered": 0, "missed": 0 },
10-
"branch": { "covered": 0, "missed": 0 },
11-
"method": { "covered": 0, "missed": 0 }
9+
"overall": {
10+
"line": { "covered": 0, "missed": 0 },
11+
"branch": { "covered": 0, "missed": 0 },
12+
"method": { "covered": 0, "missed": 0 }
13+
},
14+
"classes": {}
1215
}
1316
}

0 commit comments

Comments
 (0)