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
110 changes: 110 additions & 0 deletions .github/scripts/coverage_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Render a coverage.json report as a Markdown summary for a pull request comment.

Usage: python coverage_summary.py coverage.json [--floor 90] [--label "..."]

Writes Markdown to stdout. Keeps the table compact: files are listed worst-first so
the rows that need attention are the ones you see, and fully covered files are folded
into a single line rather than padding the table.
"""

import argparse
import json
import sys

# The comment is located by this marker on later runs so it can be updated in place
# rather than posting a new comment per push.
MARKER = "<!-- trustee-coverage-report -->"


def bar(percent, width=20):
filled = round(percent / 100 * width)
return "█" * filled + "░" * (width - filled)


def format_missing(missing, limit=6):
"""Condense a list of line numbers into ranges, truncated for readability."""
if not missing:
return ""
ranges = []
start = previous = missing[0]
for line in missing[1:]:
if line == previous + 1:
previous = line
continue
ranges.append((start, previous))
start = previous = line
ranges.append((start, previous))

rendered = [str(a) if a == b else f"{a}–{b}" for a, b in ranges]
if len(rendered) > limit:
return ", ".join(rendered[:limit]) + f", +{len(rendered) - limit} more"
return ", ".join(rendered)


def main():
parser = argparse.ArgumentParser()
parser.add_argument("report")
parser.add_argument("--floor", type=float, default=None)
parser.add_argument("--label", default="")
args = parser.parse_args()

with open(args.report) as handle:
data = json.load(handle)

totals = data["totals"]
percent = totals["percent_covered"]

out = [MARKER, "## Coverage report", ""]

if args.floor is not None:
met = percent >= args.floor
verdict = "meets" if met else "is below"
icon = "✅" if met else "❌"
out.append(f"{icon} **{percent:.2f}%** {verdict} the {args.floor:g}% floor.")
else:
out.append(f"**{percent:.2f}%** overall.")

out += [
"",
f"`{bar(percent)}` {totals['covered_lines']:,} / {totals['num_statements']:,} statements"
f" · {totals['missing_lines']:,} uncovered",
"",
]

files = []
for path, entry in data["files"].items():
summary = entry["summary"]
if summary["num_statements"] == 0:
continue # empty __init__.py and friends carry no signal
files.append((path, summary, entry.get("missing_lines", [])))

partial = sorted(
(f for f in files if f[1]["percent_covered"] < 100),
key=lambda f: (f[1]["percent_covered"], -f[1]["missing_lines"]),
)
complete = [f for f in files if f[1]["percent_covered"] >= 100]

if partial:
out += [
"| File | Coverage | Missed | Uncovered lines |",
"| :--- | -------: | -----: | :-------------- |",
]
for path, summary, missing in partial:
out.append(
f"| `{path}` | {summary['percent_covered']:.0f}% "
f"| {summary['missing_lines']} | {format_missing(missing)} |"
)
out.append("")

if complete:
names = ", ".join(f"`{path}`" for path, _, _ in sorted(complete))
out += [f"<details><summary>{len(complete)} file(s) at 100%</summary>", "", names, "", "</details>", ""]

if args.label:
out += ["", f"<sub>Measured on {args.label}.</sub>"]

sys.stdout.write("\n".join(out) + "\n")


if __name__ == "__main__":
main()
71 changes: 70 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,24 @@ jobs:
run: poetry install --with dev

- name: Run tests
run: poetry run pytest -v
# The floor sits just under the current figure so coverage cannot quietly
# regress; raise it as coverage improves rather than lowering it.
run: >
poetry run pytest -v
--cov=trustee
--cov-report=term-missing
--cov-report=json:coverage.json
--cov-fail-under=90

# One leg is enough to report from, and it keeps the matrix from racing to
# upload six artifacts under the same name.
- name: Upload coverage data
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
uses: actions/upload-artifact@v7
with:
name: coverage-json
path: coverage.json
if-no-files-found: error

examples:
name: Examples
Expand Down Expand Up @@ -140,3 +157,55 @@ jobs:
with:
name: dist
path: dist/

coverage-comment:
name: Comment coverage on PR
# `needs` makes this wait for every matrix leg, so a comment only ever appears
# for a run where the tests actually passed.
needs: test
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v7

- uses: actions/setup-python@v7
with:
python-version: "3.12"

- uses: actions/download-artifact@v8
with:
name: coverage-json

- name: Render the report
run: |
python .github/scripts/coverage_summary.py coverage.json \
--floor 90 \
--label "Python 3.12, ubuntu-latest" > coverage.md

- name: Post or update the comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
# Reuse this run's own earlier comment rather than stacking a new one
# on every push. Match on the marker *and* the bot author, so a comment
# someone else quoted the marker into is never edited.
existing="$(gh api "repos/$REPO/issues/$PR/comments" --paginate \
--jq '[.[] | select(.user.login == "github-actions[bot]")
| select(.body | contains("<!-- trustee-coverage-report -->"))
| .id] | first // empty')"

if [ -n "$existing" ]; then
gh api -X PATCH "repos/$REPO/issues/comments/$existing" \
-F body=@coverage.md --silent
echo "Updated comment $existing"
else
gh api -X POST "repos/$REPO/issues/$PR/comments" \
-F body=@coverage.md --silent
echo "Created a new coverage comment"
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ htmlcov/
.cache
nosetests.xml
coverage.xml
coverage.json
*.cover
*.py,cover
.hypothesis/
Expand Down
Loading
Loading