Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog of the Pytest Coverage Comment

## [Pytest Coverage Comment 1.9.0](https://github.com/MishaKav/pytest-coverage-comment/tree/v1.9.0)

**Release Date:** 2026-07-04

#### Changes
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- fix the Coverage percentage when branch coverage is enabled — it now combines statement and branch coverage and rounds the same way `coverage report` does, instead of showing `100%` when branches are only partially covered (#284), thanks to [@mschoettle](https://github.com/mschoettle) for contribution

## [Pytest Coverage Comment 1.8.0](https://github.com/MishaKav/pytest-coverage-comment/tree/v1.8.0)

**Release Date:** 2026-06-27
Expand Down
11 changes: 11 additions & 0 deletions __tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,17 @@ describe('toHtml', () => {
expect(html).toContain('(');
});

test('text badge fraction stays statement-only for text reports with branches', () => {
// In coverage.py text reports BrPart is partial branches, not missing
// arcs, so the fraction must not use branch counts (TOTAL: 1345 1002 386 1 22%).
const content = getContentFile(
path.join(dataPath, 'pytest-coverage_6.txt'),
);
const options = { ...baseOptions, textInsteadBadge: true };
const html = toHtml(content, options);
expect(html).toContain('22% (343/1345)');
});

test('should remove link from badge when removeLinkFromBadge is true', () => {
const content = getContentFile(
path.join(dataPath, 'pytest-coverage_4.txt'),
Expand Down
23 changes: 23 additions & 0 deletions __tests__/parseXml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ describe('getCoverageXmlReport', () => {
expect(result!.html).toContain('>63->66</a>');
});

test('should include partial branches in the cover percentage, not just statement coverage', () => {
const covXmlFile = path.join(dataPath, 'coverage_3.xml');
const options = { ...baseOptions, covXmlFile };
const result = getCoverageXmlReport(options);

expect(result).not.toBeNull();
// foo.py has 0 missing statements (line-rate="1") but 2 of its 4 branches
// are uncovered, so `coverage report` shows 83%, not 100%. The file row
// and the TOTAL row should match that, the same as `coverage report`.
expect(result!.coverage!.cover).toBe('83%');
expect(result!.html).toContain('<td>83%</td>');
});

test('text badge fraction includes branches, matching the branch-aware percentage', () => {
const covXmlFile = path.join(dataPath, 'coverage_3.xml');
const options = { ...baseOptions, covXmlFile, textInsteadBadge: true };
const result = getCoverageXmlReport(options);

expect(result).not.toBeNull();
// 8/8 statements + 2/4 branches covered => 10/12, not the statement-only 8/8.
expect(result!.html).toContain('83% (10/12)');
});

test('should combine partial branches and show exit in arrows', () => {
const covXmlFile = path.join(dataPath, 'coverage_3.xml');
const options = { ...baseOptions, covXmlFile };
Expand Down
59 changes: 51 additions & 8 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39725,9 +39725,15 @@ const toHtml = (data, options, dataFromXml = null) => {
const badgeWithLink = removeLinkFromBadge
? badge
: `<a href="${readmeHref}">${badge}</a>`;
const covered = (typeof total.stmts === 'number' ? total.stmts : parseInt(total.stmts)) -
(typeof total.miss === 'number' ? total.miss : parseInt(total.miss));
const textBadge = `${total.cover} (${covered}/${total.stmts})`;
const stmts = typeof total.stmts === 'number' ? total.stmts : parseInt(total.stmts);
const miss = typeof total.miss === 'number' ? total.miss : parseInt(total.miss);
// brpart only means "missing branches" for XML totals; text reports use
// BrPart (partial branches), so keep the statement-only fraction there
const branch = dataFromXml && total.branch ? parseInt(total.branch) : 0;
const brpart = dataFromXml && total.brpart ? parseInt(total.brpart) : 0;
const covered = stmts - miss + (branch - brpart);
const totalCount = stmts + branch;
const textBadge = `${total.cover} (${covered}/${totalCount})`;
const badgeContent = textInsteadBadge ? textBadge : badgeWithLink;
const badgeHtml = hideBadge ? '' : badgeContent;
const reportHtml = hideReport
Expand Down Expand Up @@ -39918,16 +39924,50 @@ const getParsedXml = (options) => {
}
return null;
};
// Combine statement and branch coverage into a single percentage, the same
// way coverage.py's own `coverage report` does:
// (executed statements + executed branches) / (total statements + total branches).
// Cobertura tracks `line-rate` and `branch-rate` independently, so a file
// with every statement executed but a partially-covered branch reports
// line-rate="1" even though `coverage report` shows less than 100%.
const computeCoverPercent = (coveredStmts, totalStmts, coveredBranches, totalBranches) => {
const numerator = coveredStmts + coveredBranches;
const denominator = totalStmts + totalBranches;
// reject malformed (NaN) counts instead of reporting 100%
if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) {
return NaN;
}
// empty file (no stmts/branches) counts as fully covered, like coverage.py
return denominator > 0 ? (numerator / denominator) * 100 : 100;
};
// round like `coverage report`, but never round up to 100 or down to 0
const formatCoverPercent = (percent) => {
if (!Number.isFinite(percent)) {
return NaN;
}
if (percent > 99 && percent < 100) {
return 99;
}
if (percent > 0 && percent < 1) {
return 1;
}
return Math.round(percent);
};
const getTotalCoverage = (parsedXml) => {
if (!parsedXml) {
return null;
}
const coverage = parsedXml['$'];
const cover = parseInt(String(parseFloat(coverage['line-rate']) * 100));
const linesValid = parseInt(coverage['lines-valid']);
const linesCovered = parseInt(coverage['lines-covered']);
const branchesValid = parseInt(coverage['branches-valid']) || 0;
const branchesCovered = parseInt(coverage['branches-covered']) || 0;
const cover = formatCoverPercent(computeCoverPercent(linesCovered, linesValid, branchesCovered, branchesValid));
if (!Number.isFinite(cover)) {
// prettier-ignore
core.warning(`Coverage xml file is missing valid total coverage attributes`);
return null;
}
const result = {
name: 'TOTAL',
stmts: linesValid,
Expand Down Expand Up @@ -39961,7 +40001,7 @@ const getCoverageXmlReport = (options) => {
// prettier-ignore
core.error(`Error: coverage file "${options.covXmlFile}" has bad format or wrong data`);
}
if (parsedXml && isValid) {
if (parsedXml && isValid && coverage) {
const coverageObj = coverageXmlToFiles(parsedXml, options.xmlSkipCovered);
const dataFromXml = {
coverage: coverageObj,
Expand Down Expand Up @@ -40029,14 +40069,17 @@ const parseClass = (classObj, xmlSkipCovered) => {
return null;
}
const { stmts, missing, totalMissing: miss, branchTotal, branchMissing, } = parseLines(classObj.lines);
const { filename: name, 'line-rate': lineRate } = classObj['$'];
const isFullCoverage = lineRate === '1';
const { filename: name } = classObj['$'];
const stmtsTotal = parseInt(stmts, 10);
const stmtsMissing = parseInt(miss, 10);
const isFullCoverage = stmtsMissing === 0 && branchMissing === 0;
if (xmlSkipCovered && isFullCoverage) {
return null;
}
const coverPercent = computeCoverPercent(stmtsTotal - stmtsMissing, stmtsTotal, branchTotal - branchMissing, branchTotal);
const cover = isFullCoverage
? '100%'
: `${parseInt(String(parseFloat(lineRate) * 100))}%`;
: `${formatCoverPercent(coverPercent)}%`;
const result = { name, stmts, miss, cover, missing };
if (branchTotal > 0) {
result.branch = branchTotal.toString();
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pytest-coverage-comment",
"version": "1.8.0",
"version": "1.9.0",
"description": "Comments a pull request with the pytest code coverage badge, full report and tests summary",
"author": "Misha Kav",
"license": "MIT",
Expand Down
15 changes: 11 additions & 4 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,17 @@ export const toHtml = (
const badgeWithLink = removeLinkFromBadge
? badge
: `<a href="${readmeHref}">${badge}</a>`;
const covered =
(typeof total.stmts === 'number' ? total.stmts : parseInt(total.stmts)) -
(typeof total.miss === 'number' ? total.miss : parseInt(total.miss));
const textBadge = `${total.cover} (${covered}/${total.stmts})`;
const stmts =
typeof total.stmts === 'number' ? total.stmts : parseInt(total.stmts);
const miss =
typeof total.miss === 'number' ? total.miss : parseInt(total.miss);
// brpart only means "missing branches" for XML totals; text reports use
// BrPart (partial branches), so keep the statement-only fraction there
const branch = dataFromXml && total.branch ? parseInt(total.branch) : 0;
const brpart = dataFromXml && total.brpart ? parseInt(total.brpart) : 0;
const covered = stmts - miss + (branch - brpart);
Comment thread
MishaKav marked this conversation as resolved.
const totalCount = stmts + branch;
const textBadge = `${total.cover} (${covered}/${totalCount})`;
Comment thread
MishaKav marked this conversation as resolved.
const badgeContent = textInsteadBadge ? textBadge : badgeWithLink;
const badgeHtml = hideBadge ? '' : badgeContent;
const reportHtml = hideReport
Expand Down
73 changes: 68 additions & 5 deletions src/parseXml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,71 @@ const getParsedXml = (options: Options): ParsedXml => {
return null;
};

// Combine statement and branch coverage into a single percentage, the same
// way coverage.py's own `coverage report` does:
// (executed statements + executed branches) / (total statements + total branches).
// Cobertura tracks `line-rate` and `branch-rate` independently, so a file
// with every statement executed but a partially-covered branch reports
// line-rate="1" even though `coverage report` shows less than 100%.
const computeCoverPercent = (
coveredStmts: number,
totalStmts: number,
coveredBranches: number,
totalBranches: number,
): number => {
const numerator = coveredStmts + coveredBranches;
const denominator = totalStmts + totalBranches;

// reject malformed (NaN) counts instead of reporting 100%
if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) {
return NaN;
}

// empty file (no stmts/branches) counts as fully covered, like coverage.py
return denominator > 0 ? (numerator / denominator) * 100 : 100;
Comment thread
MishaKav marked this conversation as resolved.
Comment thread
MishaKav marked this conversation as resolved.
};

// round like `coverage report`, but never round up to 100 or down to 0
const formatCoverPercent = (percent: number): number => {
if (!Number.isFinite(percent)) {
return NaN;
}

if (percent > 99 && percent < 100) {
return 99;
}

if (percent > 0 && percent < 1) {
return 1;
}

return Math.round(percent);
};

const getTotalCoverage = (parsedXml: ParsedXml): TotalLine | null => {
if (!parsedXml) {
return null;
}

const coverage = parsedXml['$'];
const cover = parseInt(String(parseFloat(coverage['line-rate']) * 100));
const linesValid = parseInt(coverage['lines-valid']);
const linesCovered = parseInt(coverage['lines-covered']);
const branchesValid = parseInt(coverage['branches-valid']) || 0;
const branchesCovered = parseInt(coverage['branches-covered']) || 0;
const cover = formatCoverPercent(
computeCoverPercent(
linesCovered,
linesValid,
branchesCovered,
branchesValid,
),
);

if (!Number.isFinite(cover)) {
Comment thread
MishaKav marked this conversation as resolved.
// prettier-ignore
core.warning(`Coverage xml file is missing valid total coverage attributes`);
return null;
}
Comment thread
MishaKav marked this conversation as resolved.

const result: TotalLine = {
name: 'TOTAL',
Expand Down Expand Up @@ -78,7 +132,7 @@ export const getCoverageXmlReport = (
core.error(`Error: coverage file "${options.covXmlFile}" has bad format or wrong data`);
}

if (parsedXml && isValid) {
if (parsedXml && isValid && coverage) {
const coverageObj = coverageXmlToFiles(parsedXml, options.xmlSkipCovered);
const dataFromXml: DataFromXml = {
coverage: coverageObj,
Expand Down Expand Up @@ -169,16 +223,25 @@ const parseClass = (
branchTotal,
branchMissing,
} = parseLines(classObj.lines);
const { filename: name, 'line-rate': lineRate } = classObj['$'];
const isFullCoverage = lineRate === '1';
const { filename: name } = classObj['$'];

const stmtsTotal = parseInt(stmts, 10);
const stmtsMissing = parseInt(miss, 10);
const isFullCoverage = stmtsMissing === 0 && branchMissing === 0;

if (xmlSkipCovered && isFullCoverage) {
return null;
}

const coverPercent = computeCoverPercent(
stmtsTotal - stmtsMissing,
stmtsTotal,
branchTotal - branchMissing,
branchTotal,
);
const cover = isFullCoverage
? '100%'
: `${parseInt(String(parseFloat(lineRate) * 100))}%`;
: `${formatCoverPercent(coverPercent)}%`;

const result: CoverageLine = { name, stmts, miss, cover, missing };

Expand Down
6 changes: 1 addition & 5 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,7 @@ export interface ChangedFiles {
}

export type CoverageColor =
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'brightgreen';
'red' | 'orange' | 'yellow' | 'green' | 'brightgreen';

export interface CoverageLine {
name: string;
Expand Down