A CI plugin that reports code coverage for only the lines changed in a pull request — not the whole file, not the whole repo.
When you're working to raise a repo's coverage, a whole-repo percentage doesn't tell you whether your change is tested. This plugin looks at just the lines your PR adds or edits and reports coverage for those lines, so a reviewer can immediately see whether the new code is covered.
It supports JVM, Go, and Python projects, and works out of the box for Vela (Target's open-source CI/CD) as well as any CI that can run a Docker container (e.g. GitHub Actions).
- How it works
- Lines vs. instructions
- What you'll see
- Supported coverage formats
- Usage
- Parameters
- Development
- License
- It reads the PR's unified diff to find the lines you changed.
- It reads your coverage report (JaCoCo / Cobertura / coverage.py).
- For each changed line, it checks whether your tests executed it.
- It reports the result in two places: the CI/CD console (always) and a pull-request comment (when GitHub credentials are provided).
The report uses two different units, and they are not the same thing:
- Lines — the source lines your PR changed.
- Instructions — the smaller executable units the coverage tool counts inside those lines.
For JaCoCo (JVM), a single source line compiles to several JVM bytecode instructions, so one line can be partly covered — e.g. 8 covered / 3 missed instructions spread across only 2 measurable lines. For Go (cobertura) and Python (coverage.py), the plugin counts one instruction per line, so there the two numbers line up.
Every changed line falls into one of these buckets:
| Bucket | Meaning |
|---|---|
| 🟢 Covered instructions | changed code your tests executed |
| 🔴 Missed instructions | changed code your tests never ran |
| 📈 Tracked changed lines | changed lines the coverage tool could measure |
| ⚪ Untracked changed lines | changed lines with no coverage data: comments, blanks, declarations |
Diff coverage is the headline number: covered ÷ (covered + missed) instructions.
The same data is rendered for two audiences: plain text for the build log, and Markdown for the PR.
Every run prints a report to the step's console (stdout):
──────────────────────────────────────────────────────────────
📊 Patch Coverage Report — changed lines only
──────────────────────────────────────────────────────────────
Modules: category-search
Diff coverage: 73% 🟡 — 8 of 11 changed instructions covered
Summary
Covered instructions 73% (8)
Missed instructions 27% (3)
Tracked changed lines 22% (2)
Untracked changed lines 78% (7)
Note: "lines" are the source lines you changed; "instructions" are the
executable units the coverage tool counts inside them (one line can hold
several, e.g. JaCoCo bytecode), so the two counts differ.
Coverage by file (lowest coverage first)
73% 8 cov / 3 miss category-search/src/main/java/com/tgt/CategorySearchApplication.java
(3 file(s) with no measurable lines omitted)
Uncovered lines (1)
- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52
System.out.print("Something");
──────────────────────────────────────────────────────────────
What each part shows:
- Diff coverage — the headline: how much of your changed, measurable code ran.
- Summary — the four buckets from Lines vs. instructions.
- Coverage by file — per-file diff coverage, lowest first so the riskiest files surface at the top. Files whose only changed lines aren't measurable (config, docs, tests) are collapsed into a count.
- Uncovered lines — each changed line your tests never ran, with the source line.
When GitHub credentials are set, the same report is posted as a PR comment. It renders like this:
Scope: changed lines only — the code this PR adds or edits, not whole files or the repo. It answers one thing — did your tests run the code you just touched?
Modules: category-search
Diff coverage:
73%🟡 —8of11changed instructions covered
Metric Value 🟢 Covered instructions 8(73%)changed code your tests executed 🔴 Missed instructions 3(27%)changed code your tests never ran 📈 Tracked changed lines 2(22%)lines the coverage tool could measure ⚪ Untracked changed lines 7(78%)comments, blanks, declarations Coverage by file
File Diff coverage Covered / Missed category-search/src/main/java/com/tgt/CategorySearchApplication.java🟡 73% 8 / 3 🔍 Uncovered lines (1)
category-search/.../CategorySearchApplication.java:52→System.out.print("Something");
It carries the same sections as the console — diff-coverage headline, summary table, per-file breakdown, and a collapsible list of uncovered lines.
Note: the PR comment is posted only when
gh_api_key, the PR number, the org, and the repo name are all available. Without them the plugin still prints the console report.
coverage_type |
Language(s) | Report format |
|---|---|---|
jacoco |
Java, Kotlin, Scala (JVM) | JaCoCo XML |
cobertura |
Go | Cobertura XML via gocov-xml |
python |
Python | coverage.py XML (coverage xml / pytest-cov --cov-report=xml) |
lcov |
JavaScript, TypeScript | LCOV lcov.info from Jest / nyc / Vitest / c8 (aliases: javascript, typescript) |
The plugin runs as a Docker container, published to the GitHub Container Registry (GHCR) on every release:
ghcr.io/target/pull-request-code-coverage:latest
ghcr.io/target/pull-request-code-coverage:<release-tag>
docker pull ghcr.io/target/pull-request-code-coverage:latest
The examples below use Vela step syntax. See Running outside Vela for other CIs.
You need a JaCoCo XML report as input. Generating it (via Gradle/Maven) is outside the scope of this project. Once you have it, pass its path to coverage_file:
- name: check-pr-code-coverage
image: ghcr.io/target/pull-request-code-coverage:latest
pull: true
ruleset:
event: [pull_request]
parameters:
coverage_type: jacoco
coverage_file: some-sub-module/build/reports/jacoco/test/jacocoTestReport.xml
source_dirs:
- src/main/java
- src/main/kotlin
# omit for public github.com (defaults to https://api.github.com)
# for GitHub Enterprise, use the full API root including /api/v3
gh_api_base_url: https://git.target.com/api/v3
module: some-sub-module
secrets:
- source: pull_request_api_key
target: plugin_gh_api_keyUse gocov-xml to convert Go's coverage profile to Cobertura XML:
- go get github.com/axw/gocov/gocov
- go get github.com/AlekSi/gocov-xml
- go test -v -coverpkg=./... -coverprofile=coverage.txt ./...
- go tool cover -func=coverage.txt
- gocov convert coverage.txt | gocov-xml > ./coverage.xml
Then pass coverage.xml to the plugin:
- name: check-pr-code-coverage
image: ghcr.io/target/pull-request-code-coverage:latest
pull: true
ruleset:
event: [pull_request]
parameters:
coverage_type: cobertura
# coverage.xml generated in the step above
coverage_file: coverage.xml
source_dirs:
- /vela/src/github.com/targetOSS/pull-request-code-coverage
gh_api_base_url: https://git.target.com/api/v3
secrets:
- source: pull_request_api_key
target: plugin_gh_api_keyFor
cobertura,source_dirsmust match the<source>path in the generated XML (the directory the tests ran in), and only a single source dir is supported.
Generate the XML report with coverage.py or pytest-cov:
# with coverage.py
- coverage run -m pytest
- coverage xml # writes coverage.xml
# or directly with pytest-cov
- pytest --cov=myapp --cov-report=xml
Then pass coverage.xml with coverage_type: python:
- name: check-pr-code-coverage
image: ghcr.io/target/pull-request-code-coverage:latest
pull: true
ruleset:
event: [pull_request]
parameters:
coverage_type: python
# coverage.xml generated in the step above
coverage_file: coverage.xml
source_dirs:
# repo root; use e.g. "src" if your package lives under src/
- .
gh_api_base_url: https://git.target.com/api/v3
secrets:
- source: pull_request_api_key
target: plugin_gh_api_keyUnlike
cobertura, thepythontype matches files by their repo-relative path, sosource_dirsdoes not need to be an absolute build path. Run from the repo root withsource_dirs: ["."], or set it to your source folder (e.g.src) if your code lives under one.
Most JS/TS coverage tools (Jest, nyc, Vitest, c8 — all built on Istanbul) emit an lcov.info file. Generate it with the lcov reporter:
# Jest
- jest --coverage --coverageReporters=lcov
# nyc
- nyc --reporter=lcov npm test
# Vitest
- vitest run --coverage --coverage.reporter=lcov
Then pass the report (commonly coverage/lcov.info) with coverage_type: lcov:
- name: check-pr-code-coverage
image: ghcr.io/target/pull-request-code-coverage:latest
pull: true
ruleset:
event: [pull_request]
parameters:
coverage_type: lcov # aliases: javascript, typescript
# lcov.info generated in the step above
coverage_file: coverage/lcov.info
source_dirs:
# repo root; use e.g. "src" if your code lives under src/
- .
gh_api_base_url: https://git.target.com/api/v3
secrets:
- source: pull_request_api_key
target: plugin_gh_api_keyLike
python, thelcovtype matches files by their repo-relative path, and it also handles the absoluteSF:paths Istanbul commonly writes (e.g./home/runner/work/app/app/src/x.ts) by suffix-matching. Setsource_dirsto.(repo root) or to your source folder.
On other CIs (e.g. GitHub Actions), run the same image and pass the inputs as environment variables instead of Vela parameters:. Each parameter maps to a PARAMETER_<NAME> env var, and the build context maps to BUILD_PULL_REQUEST_NUMBER, REPOSITORY_ORG, and REPOSITORY_NAME (see the table below). Pipe the PR's unified diff to the container on stdin:
git --no-pager diff --unified=0 "origin/$BASE_REF" -- '*.go' | docker run --rm -i \
-e PARAMETER_COVERAGE_TYPE -e PARAMETER_COVERAGE_FILE -e PARAMETER_SOURCE_DIRS \
-e PARAMETER_GH_API_KEY -e BUILD_PULL_REQUEST_NUMBER -e REPOSITORY_ORG -e REPOSITORY_NAME \
ghcr.io/target/pull-request-code-coverage:latest
A working GitHub Actions example lives in .github/workflows/pr-coverage.yml.
Plugin inputs — set via Vela parameters: / secrets:, or as PARAMETER_* env vars on other CIs.
| Parameter | Env var | Required | Default | Description |
|---|---|---|---|---|
coverage_type |
PARAMETER_COVERAGE_TYPE |
yes | coverage format: jacoco, cobertura, python, or lcov (aliases javascript/typescript) |
|
coverage_file |
PARAMETER_COVERAGE_FILE |
yes | path to the coverage report, relative to the working dir | |
source_dirs |
PARAMETER_SOURCE_DIRS |
yes | array of source dirs, relative to the working dir (see per-language notes above) | |
module |
PARAMETER_MODULE |
no | (empty) | sub-module path prefix to strip, for multi-module projects (e.g. a Gradle multi-project build) |
gh_api_key |
PARAMETER_GH_API_KEY (or PLUGIN_GH_API_KEY) |
no | token used to post the PR comment. If unset, no comment is posted (console only) | |
gh_api_base_url |
PARAMETER_GH_API_BASE_URL |
no | https://api.github.com |
GitHub API root. For GitHub Enterprise, use the full root including /api/v3 |
debug |
PARAMETER_DEBUG |
no | false |
enable debug logging |
Build context — provided automatically by Vela; set these yourself on other CIs to enable the PR comment.
| Env var | Description |
|---|---|
BUILD_PULL_REQUEST_NUMBER |
the PR number to comment on |
REPOSITORY_ORG |
repository owner / org |
REPOSITORY_NAME |
repository name |
The PR comment is posted only when
gh_api_keyand all three build-context values are present. Otherwise the plugin prints to the console and exits successfully.
This project needs go (>= 1.26.3) installed. Before submitting a PR, run:
make formatmake lint
This project is licensed under the Apache License, Version 2.0.