From 0003f96480145d5e580c1efd51529104a67e217d Mon Sep 17 00:00:00 2001 From: Sichao25 Date: Fri, 28 Aug 2026 23:02:03 -0400 Subject: [PATCH] print test coverage in comment --- .github/scripts/coverage_report.py | 168 +++++++++++++++++++++++++ .github/workflows/cmake-test.yml | 44 +++++-- .github/workflows/coverage-comment.yml | 81 ++++++++++++ 3 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 .github/scripts/coverage_report.py create mode 100644 .github/workflows/coverage-comment.yml diff --git a/.github/scripts/coverage_report.py b/.github/scripts/coverage_report.py new file mode 100644 index 00000000..45a7012d --- /dev/null +++ b/.github/scripts/coverage_report.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Parse Cobertura XML coverage reports and render a Markdown summary. + +Usage: + coverage_report.py [LABEL=]PATH [[LABEL=]PATH ...] + +Each positional argument is the path to a Cobertura XML file, optionally +prefixed with a label (e.g. ``Python=coverage.xml``). Missing files are +skipped. The report is written to stdout and starts with a marker comment so +that a bot can find and update a single PR comment. +""" + +from __future__ import annotations + +import argparse +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +MARKER = "" +MAX_FILES_PER_SECTION = 200 + + +def _to_float(value): + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _to_int(value): + number = _to_float(value) + return int(number) if number is not None else None + + +def _fmt_counts(covered, valid): + """Format a percentage with absolute counts, or an em dash if unknown.""" + if valid is None or valid <= 0: + return "\u2014" + covered = covered or 0 + return f"{100.0 * covered / valid:.1f}% ({covered:,}/{valid:,})" + + +def _fmt_rate(rate, covered, valid): + """Format a 0..1 rate, preferring absolute counts when available.""" + if rate is None: + return "\u2014" + if valid is not None and valid > 0 and covered is not None: + return _fmt_counts(covered, valid) + return f"{100.0 * rate:.1f}%" + + +def _parse(path): + root = ET.parse(path).getroot() + + def counts(prefix): + return ( + _to_int(root.get(f"{prefix}-covered")), + _to_int(root.get(f"{prefix}-valid")), + ) + + lines_covered, lines_valid = counts("lines") + branches_covered, branches_valid = counts("branches") + + classes = [] + for cls in root.iter("class"): + filename = cls.get("filename") or cls.get("name") or "?" + classes.append( + { + "filename": filename, + "line_rate": _to_float(cls.get("line-rate")), + "branch_rate": _to_float(cls.get("branch-rate")), + "line_covered": _to_int(cls.get("lines-covered")), + "line_valid": _to_int(cls.get("lines-valid")), + "branch_covered": _to_int(cls.get("branches-covered")), + "branch_valid": _to_int(cls.get("branches-valid")), + } + ) + + return { + "lines_covered": lines_covered, + "lines_valid": lines_valid, + "branches_covered": branches_covered, + "branches_valid": branches_valid, + "has_branch": branches_valid is not None and branches_valid > 0, + "classes": classes, + } + + +def _label_for(path): + name = path.name.lower() + if "cpp" in name or "c++" in name: + return "C++" + if name.startswith("coverage"): + return "Python" + return path.stem + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Render Cobertura XML as Markdown.") + parser.add_argument("inputs", nargs="*", help="[LABEL=]PATH to a Cobertura XML file") + args = parser.parse_args(argv) + + reports = [] + for spec in args.inputs: + if "=" in spec: + label, raw_path = spec.split("=", 1) + else: + label, raw_path = None, spec + path = Path(raw_path) + if not path.is_file(): + print(f"warning: skipping missing coverage file: {path}", file=sys.stderr) + continue + label = label or _label_for(path) + reports.append((label, _parse(path))) + + lines = [MARKER, "", "## Code Coverage Report", ""] + + if not reports: + lines.append("_No coverage data was found for this run._") + print("\n".join(lines)) + return 0 + + lines.append("| Source | Line Coverage | Branch Coverage |") + lines.append("|---|---|---|") + for label, report in reports: + lines.append( + f"| {label} | {_fmt_counts(report['lines_covered'], report['lines_valid'])} " + f"| {_fmt_counts(report['branches_covered'], report['branches_valid'])} |" + ) + lines.append("") + + for label, report in reports: + classes = sorted( + report["classes"], + key=lambda c: (c["line_rate"] if c["line_rate"] is not None else 1.0, c["filename"]), + ) + if not classes: + continue + lines.append("
") + lines.append(f"{label}: {len(classes)} files") + lines.append("") + lines.append("| File | Line Coverage | Branch Coverage |") + lines.append("|---|---|---|") + for cls in classes[:MAX_FILES_PER_SECTION]: + branch_cell = ( + _fmt_rate(cls["branch_rate"], cls["branch_covered"], cls["branch_valid"]) + if report["has_branch"] + else "\u2014" + ) + lines.append( + f"| `{cls['filename']}` | {_fmt_rate(cls['line_rate'], cls['line_covered'], cls['line_valid'])} " + f"| {branch_cell} |" + ) + if len(classes) > MAX_FILES_PER_SECTION: + lines.append(f"| _... {len(classes) - MAX_FILES_PER_SECTION} more files omitted_ | | |") + lines.append("") + lines.append("
") + lines.append("") + + print("\n".join(lines).rstrip()) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/cmake-test.yml b/.github/workflows/cmake-test.yml index e2bd9964..12aeb265 100644 --- a/.github/workflows/cmake-test.yml +++ b/.github/workflows/cmake-test.yml @@ -17,7 +17,6 @@ jobs: timeout-minutes: 30 permissions: contents: read - code-quality: write defaults: run: @@ -313,21 +312,42 @@ jobs: --xml coverage-cpp.xml \ --print-summary - - name: Upload coverage report - if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && matrix.python_api == 'ON' - uses: actions/upload-code-coverage@v1 + - name: Print coverage report + if: matrix.coverage == 'ON' + run: | + python3 .github/scripts/coverage_report.py coverage.xml coverage-cpp.xml > /tmp/coverage-report.md + cat /tmp/coverage-report.md + cat /tmp/coverage-report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Save PR number + if: github.event_name == 'pull_request' && matrix.coverage == 'ON' + run: | + mkdir -p pr-context + printf '%s' "${{ github.event.number }}" > pr-context/pr_number + + - name: Upload PR number + if: github.event_name == 'pull_request' && matrix.coverage == 'ON' + uses: actions/upload-artifact@v4 with: - file: coverage.xml - language: Python - label: code-coverage/pytest + name: pr-number + path: pr-context + retention-days: 1 + + - name: Upload Python coverage report + if: matrix.coverage == 'ON' + uses: actions/upload-artifact@v4 + with: + name: coverage-python + path: coverage.xml + retention-days: 1 - name: Upload C++ coverage report - if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && matrix.coverage == 'ON' - uses: actions/upload-code-coverage@v1 + if: matrix.coverage == 'ON' + uses: actions/upload-artifact@v4 with: - file: coverage-cpp.xml - language: cpp - label: code-coverage/cpp + name: coverage-cpp + path: coverage-cpp.xml + retention-days: 1 - name: Test PCMS Installation if: matrix.python_api == 'OFF' diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml new file mode 100644 index 00000000..00548d6f --- /dev/null +++ b/.github/workflows/coverage-comment.yml @@ -0,0 +1,81 @@ +# Updates a single "code coverage" comment on PRs whenever the Test-Build +# workflow completes successfully. +# +# This is triggered via workflow_run (rather than pull_request) so that it runs +# in the base repository context and can write to pull requests from forks. +# Keep the `workflows:` name below in sync with the `name:` in cmake-test.yml. +name: Coverage Comment + +on: + workflow_run: + workflows: ["Test-Build"] + types: [completed] + +permissions: + actions: read + contents: read + issues: write + +jobs: + coverage-comment: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download PR number + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: pr-number + path: artifacts/pr-number + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download Python coverage + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: coverage-python + path: artifacts/coverage-python + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download C++ coverage + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: coverage-cpp + path: artifacts/coverage-cpp + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate coverage report + run: | + python3 .github/scripts/coverage_report.py \ + artifacts/coverage-python/coverage.xml \ + artifacts/coverage-cpp/coverage-cpp.xml \ + > coverage-summary.md + cat coverage-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Post or update PR comment + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + PR_NUMBER="$(cat artifacts/pr-number/pr_number 2>/dev/null || true)" + if [ -z "$PR_NUMBER" ]; then + echo "No PR number artifact found; skipping PR comment." + exit 0 + fi + + COMMENT_ID="$(gh api "repos/$GH_REPO/issues/$PR_NUMBER/comments" \ + --jq '[.[] | select(.body | contains(""))][0].id // empty')" + + if [ -n "$COMMENT_ID" ]; then + gh api -X PATCH "repos/$GH_REPO/issues/comments/$COMMENT_ID" \ + -f body="$(cat coverage-summary.md)" + else + gh pr comment "$PR_NUMBER" --body-file coverage-summary.md + fi