Skip to content

Commit 46a1348

Browse files
MishaKavclaude
andauthored
Add branch coverage support for text and XML formats (#250)
* Add branch coverage support for text and XML formats (#110) Auto-detect and display Branch/BrPart columns in the coverage table when pytest runs with --cov-branch or branch=True. Supported for both text coverage output and XML coverage reports. No new action inputs required — fully backward compatible. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Refactor parseTotalLine and getTotalCoverage for improved validation and type consistency --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 09d4ea6 commit 46a1348

7 files changed

Lines changed: 237 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog of the Pytest Coverage Comment
22

3+
## [Pytest Coverage Comment 1.4.0](https://github.com/MishaKav/pytest-coverage-comment/tree/v1.4.0)
4+
5+
**Release Date:** 2026-02-22
6+
7+
#### Changes
8+
9+
- feat: auto-detect and display branch coverage columns (`Branch`, `BrPart`) in coverage table (#110)
10+
- supported for both text format (`pytest --cov --cov-branch`) and XML format (`coverage.xml`)
11+
- no new action inputs required — columns appear automatically when branch data is present
12+
- fully backward compatible — existing users see no change
13+
314
## [Pytest Coverage Comment 1.3.0](https://github.com/MishaKav/pytest-coverage-comment/tree/v1.3.0)
415

516
**Release Date:** 2026-02-21

dist/index.js

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38290,6 +38290,15 @@ const isValidCoverageContent = (data) => {
3829038290
return wordsToInclude.every((w) => data.includes(w));
3829138291
};
3829238292

38293+
// return true if coverage data includes branch coverage columns
38294+
const hasBranchCoverage = (data) => {
38295+
if (!data || !data.length) {
38296+
return false;
38297+
}
38298+
38299+
return data.includes('Branch') && data.includes('BrPart');
38300+
};
38301+
3829338302
// return full html coverage report and coverage percentage
3829438303
const getCoverageReport = (options) => {
3829538304
const { covFile, covXmlFile } = options;
@@ -38351,8 +38360,9 @@ const getTotal = (data) => {
3835138360

3835238361
const lines = data.split('\n');
3835338362
const line = lines.find((l) => l.includes('TOTAL '));
38363+
const hasBranch = hasBranchCoverage(data);
3835438364

38355-
return parseTotalLine(line);
38365+
return parseTotalLine(line, hasBranch);
3835638366
};
3835738367

3835838368
// get number of warnings from coverage-file
@@ -38374,14 +38384,15 @@ const getWarnings = (data) => {
3837438384
};
3837538385

3837638386
// parse one line from coverage-file
38377-
const parseOneLine = (line) => {
38387+
const parseOneLine = (line, hasBranch = false) => {
3837838388
if (!line) {
3837938389
return null;
3838038390
}
3838138391

3838238392
const parsedLine = line.split(' ').filter((l) => l);
38393+
const minCols = hasBranch ? 6 : 4;
3838338394

38384-
if (parsedLine.length < 4) {
38395+
if (parsedLine.length < minCols) {
3838538396
return null;
3838638397
}
3838738398

@@ -38395,33 +38406,48 @@ const parseOneLine = (line) => {
3839538406
: parsedLine[parsedLine.length - 1] &&
3839638407
parsedLine[parsedLine.length - 1].split(', ');
3839738408

38398-
return {
38409+
const result = {
3839938410
name: parsedLine[0],
3840038411
stmts: parsedLine[1].trim(),
3840138412
miss: parsedLine[2].trim(),
3840238413
cover,
3840338414
missing,
3840438415
};
38416+
38417+
if (hasBranch) {
38418+
result.branch = parsedLine[3].trim();
38419+
result.brpart = parsedLine[4].trim();
38420+
}
38421+
38422+
return result;
3840538423
};
3840638424

3840738425
// parse total line from coverage-file
38408-
const parseTotalLine = (line) => {
38426+
const parseTotalLine = (line, hasBranch = false) => {
3840938427
if (!line) {
3841038428
return null;
3841138429
}
3841238430

3841338431
const parsedLine = line.split(' ').filter((l) => l);
38432+
const minCols = hasBranch ? 6 : 4;
3841438433

38415-
if (parsedLine.length < 4) {
38434+
if (parsedLine.length < minCols) {
3841638435
return null;
3841738436
}
3841838437

38419-
return {
38438+
const result = {
3842038439
name: parsedLine[0],
3842138440
stmts: parsedLine[1].trim(),
3842238441
miss: parsedLine[2].trim(),
3842338442
cover: parsedLine[parsedLine.length - 1].trim(),
3842438443
};
38444+
38445+
if (hasBranch) {
38446+
result.branch = parsedLine[3].trim();
38447+
result.brpart = parsedLine[4].trim();
38448+
}
38449+
38450+
return result;
3842538451
};
3842638452

3842738453
// parse coverage-file
@@ -38432,7 +38458,9 @@ const parse = (data) => {
3843238458
return null;
3843338459
}
3843438460

38435-
return actualLines.map(parseOneLine);
38461+
const hasBranch = hasBranchCoverage(data);
38462+
38463+
return actualLines.map((line) => parseOneLine(line, hasBranch));
3843638464
};
3843738465

3843838466
// collapse all lines to folders structure
@@ -38499,6 +38527,7 @@ const toTable = (data, options, dataFromXml = null) => {
3849938527
}
3850038528
const totalLine = dataFromXml ? dataFromXml.total : getTotal(data);
3850138529
options.hasMissing = coverage.some((c) => c.missing);
38530+
options.hasBranch = coverage.some((c) => c.branch !== undefined);
3850238531

3850338532
core.info(`Generating coverage report`);
3850438533
const headTr = toHeadRow(options);
@@ -38545,9 +38574,11 @@ const toTable = (data, options, dataFromXml = null) => {
3854538574

3854638575
// make html head row - th
3854738576
const toHeadRow = (options) => {
38548-
const lastTd = options.hasMissing ? '<th>Missing</th>' : '';
38577+
const branchTh = options.hasBranch ? '<th>Branch</th><th>BrPart</th>' : '';
38578+
const missingTh = options.hasMissing ? '<th>Missing</th>' : '';
3854938579

38550-
return `<tr><th>File</th><th>Stmts</th><th>Miss</th><th>Cover</th>${lastTd}</tr>`;
38580+
// prettier-ignore
38581+
return `<tr><th>File</th><th>Stmts</th><th>Miss</th>${branchTh}<th>Cover</th>${missingTh}</tr>`;
3855138582
};
3855238583

3855338584
// make html row - tr
@@ -38556,17 +38587,26 @@ const toRow = (item, indent = false, options) => {
3855638587

3855738588
const name = toFileNameTd(item, indent, options);
3855838589
const missing = toMissingTd(item, options);
38559-
const lastTd = options.hasMissing ? `<td>${missing}</td>` : '';
38590+
const branchTd = options.hasBranch
38591+
? `<td>${item.branch || 0}</td><td>${item.brpart || 0}</td>`
38592+
: '';
38593+
const missingTd = options.hasMissing ? `<td>${missing}</td>` : '';
3856038594

38561-
return `<tr><td>${name}</td><td>${stmts}</td><td>${miss}</td><td>${cover}</td>${lastTd}</tr>`;
38595+
// prettier-ignore
38596+
return `<tr><td>${name}</td><td>${stmts}</td><td>${miss}</td>${branchTd}<td>${cover}</td>${missingTd}</tr>`;
3856238597
};
3856338598

3856438599
// make summary row - tr
3856538600
const toTotalRow = (item, options) => {
3856638601
const { name, stmts, miss, cover } = item;
38567-
const emptyCell = options.hasMissing ? '<td>&nbsp;</td>' : '';
38602+
const branchTd = options.hasBranch
38603+
? `<td><b>${item.branch || 0}</b></td>` +
38604+
`<td><b>${item.brpart || 0}</b></td>`
38605+
: '';
38606+
const missingTd = options.hasMissing ? '<td>&nbsp;</td>' : '';
3856838607

38569-
return `<tr><td><b>${name}</b></td><td><b>${stmts}</b></td><td><b>${miss}</b></td><td><b>${cover}</b></td>${emptyCell}</tr>`;
38608+
// prettier-ignore
38609+
return `<tr><td><b>${name}</b></td><td><b>${stmts}</b></td><td><b>${miss}</b></td>${branchTd}<td><b>${cover}</b></td>${missingTd}</tr>`;
3857038610
};
3857138611

3857238612
// make fileName cell - td
@@ -38589,7 +38629,8 @@ const toFolderTd = (path, options) => {
3858938629
return '';
3859038630
}
3859138631

38592-
const colspan = options.hasMissing ? 5 : 4;
38632+
const colspan =
38633+
4 + (options.hasBranch ? 2 : 0) + (options.hasMissing ? 1 : 0);
3859338634
return `<tr><td colspan="${colspan}"><b>${path}</b></td></tr>`;
3859438635
};
3859538636

@@ -38647,13 +38688,22 @@ const getTotalCoverage = (parsedXml) => {
3864738688
const cover = parseInt(parseFloat(coverage['line-rate']) * 100);
3864838689
const linesValid = parseInt(coverage['lines-valid']);
3864938690
const linesCovered = parseInt(coverage['lines-covered']);
38691+
const branchesValid = parseInt(coverage['branches-valid']) || 0;
38692+
const branchesCovered = parseInt(coverage['branches-covered']) || 0;
3865038693

38651-
return {
38694+
const result = {
3865238695
name: 'TOTAL',
3865338696
stmts: linesValid,
3865438697
miss: linesValid - linesCovered,
3865538698
cover: cover !== '0' ? `${cover}%` : '0',
3865638699
};
38700+
38701+
if (branchesValid > 0) {
38702+
result.branch = branchesValid.toString();
38703+
result.brpart = (branchesValid - branchesCovered).toString();
38704+
}
38705+
38706+
return result;
3865738707
};
3865838708

3865938709
// return true if "coverage file" include right structure
@@ -38750,7 +38800,13 @@ const parseClass = (classObj, xmlSkipCovered) => {
3875038800
return null;
3875138801
}
3875238802

38753-
const { stmts, missing, totalMissing: miss } = parseLines(classObj.lines);
38803+
const {
38804+
stmts,
38805+
missing,
38806+
totalMissing: miss,
38807+
branchTotal,
38808+
branchMissing,
38809+
} = parseLines(classObj.lines);
3875438810
const { filename: name, 'line-rate': lineRate } = classObj['$'];
3875538811
const isFullCoverage = lineRate === '1';
3875638812

@@ -38762,24 +38818,57 @@ const parseClass = (classObj, xmlSkipCovered) => {
3876238818
? '100%'
3876338819
: `${parseInt(parseFloat(lineRate) * 100)}%`;
3876438820

38765-
return { name, stmts, miss, cover, missing };
38821+
const result = { name, stmts, miss, cover, missing };
38822+
38823+
if (branchTotal > 0) {
38824+
result.branch = branchTotal.toString();
38825+
result.brpart = branchMissing.toString();
38826+
}
38827+
38828+
return result;
3876638829
};
3876738830

3876838831
const parseLines = (lines) => {
38832+
const emptyResult = {
38833+
stmts: '0',
38834+
missing: '',
38835+
totalMissing: '0',
38836+
branchTotal: 0,
38837+
branchMissing: 0,
38838+
};
38839+
3876938840
if (!lines || !lines.length || !lines[0].line) {
38770-
return { stmts: '0', missing: '', totalMissing: '0' };
38841+
return emptyResult;
3877138842
}
3877238843

3877338844
let stmts = 0;
3877438845
const missingLines = [];
38846+
let branchTotal = 0;
38847+
let branchMissing = 0;
3877538848

3877638849
lines[0].line.forEach((line) => {
3877738850
stmts++;
38778-
const { hits, number } = line['$'];
38851+
const {
38852+
hits,
38853+
number,
38854+
branch,
38855+
'condition-coverage': condCoverage,
38856+
} = line['$'];
3877938857

3878038858
if (hits === '0') {
3878138859
missingLines.push(parseInt(number));
3878238860
}
38861+
38862+
if (branch === 'true' && condCoverage) {
38863+
const match = condCoverage.match(/\((\d+)\/(\d+)\)/);
38864+
38865+
if (match) {
38866+
const covered = parseInt(match[1]);
38867+
const total = parseInt(match[2]);
38868+
branchTotal += total;
38869+
branchMissing += total - covered;
38870+
}
38871+
}
3878338872
});
3878438873

3878538874
const missing = missingLines.reduce((arr, val, i, a) => {
@@ -38801,6 +38890,8 @@ const parseLines = (lines) => {
3880138890
stmts: stmts.toString(),
3880238891
missing: missingText,
3880338892
totalMissing: missingLines.length.toString(),
38893+
branchTotal,
38894+
branchMissing,
3880438895
};
3880538896
};
3880638897

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pytest-coverage-comment",
3-
"version": "1.3.0",
3+
"version": "1.4.0",
44
"description": "Comments a pull request with the pytest code coverage badge, full report and tests summary",
55
"author": "Misha Kav",
66
"license": "MIT",

src/cli.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const getPathToFile = (pathToFile) => {
3636
const main = async () => {
3737
const covFile = './../data/pytest-coverage_4.txt';
3838
const xmlFile = './../data/pytest_1.xml';
39-
const covXmlFile = './../data/coverage_1.xml';
39+
const covXmlFile = './../data/coverage_1.xml'; // use coverage_2.xml for branch coverage
4040
const prefix = path.dirname(path.dirname(path.resolve(covFile))) + '/';
4141
// eslint-disable-next-line
4242
const multipleFiles = [

0 commit comments

Comments
 (0)