diff --git a/README.md b/README.md
index 8bd547a..8fe364b 100644
--- a/README.md
+++ b/README.md
@@ -1,48 +1,176 @@
-
# pull-request-code-coverage
+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](https://github.com/go-vela) (Target's open-source CI/CD) as well as any CI that can run a Docker container (e.g. GitHub Actions).
+
+---
+
+## Contents
+
+- [How it works](#how-it-works)
+- [Lines vs. instructions](#lines-vs-instructions)
+- [What you'll see](#what-youll-see)
+ - [In the CI/CD console](#in-the-cicd-console)
+ - [As a pull-request comment](#as-a-pull-request-comment)
+- [Supported coverage formats](#supported-coverage-formats)
+- [Usage](#usage)
+ - [Docker image](#docker-image)
+ - [JVM projects (jacoco)](#jvm-projects-jacoco)
+ - [Go projects (cobertura)](#go-projects-cobertura)
+ - [Python projects (python)](#python-projects-python)
+ - [JavaScript / TypeScript projects (lcov)](#javascript--typescript-projects-lcov)
+ - [Running outside Vela](#running-outside-vela)
+- [Parameters](#parameters)
+- [Development](#development)
+- [License](#license)
+
+---
-A continuous integration plugin to allow detecting code coverage for only the lines changed in a PR.
+## How it works
-Sometimes when working to get a repo to an acceptable level of code coverage, it can be hard to tell if one change is
-covered enough. This plugin will look at just the lines changed in the PR and report code coverage for only those
-lines.
+1. It reads the PR's unified diff to find the lines you changed.
+2. It reads your coverage report (JaCoCo / Cobertura / coverage.py).
+3. For each changed line, it checks whether your tests executed it.
+4. It reports the result in two places: the **CI/CD console** (always) and a **pull-request comment** (when GitHub credentials are provided).
-This plugin will output the coverage details to the CI/CD step's console. A sample [Vela](https://github.com/go-vela) step console
+---
-
+## Lines vs. instructions
+The report uses two different units, and they are **not** the same thing:
-This plugin as well as has the ability to comment on the PR with a summary of the coverage details.
-
+- **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.
-Currently, this plugin supports two coverage file format.
-* jacoco for jvm based languages like java,kotlin,scala
-* cobertura can be used for golang projects using [gocov-xml](https://github.com/AlekSi/gocov-xml) utility
+Every changed line falls into one of these buckets:
-This plugin works out of box for [Vela](https://github.com/go-vela),a CI/CD open-sourced by target
+| 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 |
-## Docker image
+**Diff coverage** is the headline number: `covered รท (covered + missed)` instructions.
-The plugin is published as a public Docker image to the GitHub Container Registry (GHCR) on every release:
+---
+
+## What you'll see
+
+The same data is rendered for two audiences: plain text for the build log, and Markdown for the PR.
+
+### In the CI/CD console
+
+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](#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.
+
+### As a pull-request comment
+
+When GitHub credentials are set, the same report is posted as a PR comment. It renders like this:
+
+> ### ๐ก๏ธ Patch Coverage Report
+>
+> 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%` ๐ก โ `8` of `11` changed 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.
+
+---
+
+## Supported coverage formats
+
+| `coverage_type` | Language(s) | Report format |
+|---|---|---|
+| `jacoco` | Java, Kotlin, Scala (JVM) | JaCoCo XML |
+| `cobertura` | Go | Cobertura XML via [gocov-xml](https://github.com/AlekSi/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`) |
+
+---
+
+## Usage
+
+### Docker image
+
+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:
```
-Pull it with:
-
```
docker pull ghcr.io/target/pull-request-code-coverage:latest
```
-## VELA Usage
+The examples below use [Vela](https://github.com/go-vela) step syntax. See [Running outside Vela](#running-outside-vela) for other CIs.
+
+### JVM projects (jacoco)
-### Jvm based projects
-For java/koltin based projects you need jacoco files that goes as an input to this plugin. How to generate jacoco files is outside the scope of
-this project. Once you have that jacoco file, you can pass that path to coverage_file parameter as shown below
+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`:
```yaml
- name: check-pr-code-coverage
@@ -65,9 +193,10 @@ this project. Once you have that jacoco file, you can pass that path to coverage
target: plugin_gh_api_key
```
+### Go projects (cobertura)
+
+Use [gocov-xml](https://github.com/AlekSi/gocov-xml) to convert Go's coverage profile to Cobertura XML:
-### Golang based projects
-You can use [gocov-xml](https://github.com/AlekSi/gocov-xml) utility to generate coverage.xml
```
- go get github.com/axw/gocov/gocov
- go get github.com/AlekSi/gocov-xml
@@ -76,7 +205,7 @@ You can use [gocov-xml](https://github.com/AlekSi/gocov-xml) utility to generate
- gocov convert coverage.txt | gocov-xml > ./coverage.xml
```
-Once you have coverage.xml same can be passed as an input to plugin shown below
+Then pass `coverage.xml` to the plugin:
```yaml
- name: check-pr-code-coverage
@@ -86,36 +215,140 @@ Once you have coverage.xml same can be passed as an input to plugin shown below
event: [pull_request]
parameters:
coverage_type: cobertura
- #coverage.xml generated in above step
+ # coverage.xml generated in the step above
coverage_file: coverage.xml
source_dirs:
- /vela/src/github.com/targetOSS/pull-request-code-coverage
- # 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
secrets:
- source: pull_request_api_key
target: plugin_gh_api_key
```
-#### Parameters
+> For `cobertura`, `source_dirs` must match the `` path in the generated XML (the directory the tests ran in), and only a single source dir is supported.
+
+### Python projects (python)
+
+Generate the XML report with [coverage.py](https://coverage.readthedocs.io) 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`:
+
+```yaml
+- 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_key
+```
+
+> Unlike `cobertura`, the `python` type matches files by their **repo-relative path**, so `source_dirs` does **not** need to be an absolute build path. Run from the repo root with `source_dirs: ["."]`, or set it to your source folder (e.g. `src`) if your code lives under one.
+
+### JavaScript / TypeScript projects (lcov)
+
+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`:
+
+```yaml
+- 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_key
+```
+
+> Like `python`, the `lcov` type matches files by their **repo-relative path**, and it also handles the absolute `SF:` paths Istanbul commonly writes (e.g. `/home/runner/work/app/app/src/x.ts`) by suffix-matching. Set `source_dirs` to `.` (repo root) or to your source folder.
+
+### Running outside Vela
+
+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_` 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`](.github/workflows/pr-coverage.yml).
+
+---
+
+## Parameters
+
+**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_key` **and** all three build-context values are present. Otherwise the plugin prints to the console and exits successfully.
+
+---
+
+## Development
-|param|required| default | description|
-|---|---|---|---|
-|coverage_type| true | | **supported values**: jacoco, cobertura
sets the coverage file format |
-|coverage_file| true | | path to where the coverage file will be located, relative to the working dir |
-|source_dirs| true | | array of source dirs, relative to the working dir |
-|gh_api_base_url| false | | base url of the gh api for posting coverage comments
if not set, coverage details will not be commented on PR |
-|gh_api_key| false | | api key to auth for posting coverage comments
if not set, coverage details will not be commented on PR |
-|module | false | \ | sub-module to use if operating inside a multi-module project (e.g. gradle multi-project build) |
+This project needs go (>= 1.26.3) installed. Before submitting a PR, run:
-# Development
+* `make format`
+* `make lint`
-This project needs go (>= 1.26.3) to be installed. Make sure you run
-* make format
-* make lint
+---
- before submitting a PR
+## License
-# License
-This project is licensed under the Apache License, Version 2.0.
\ No newline at end of file
+This project is licensed under the Apache License, Version 2.0.
diff --git a/internal/plugin/coverage/lcov/report.go b/internal/plugin/coverage/lcov/report.go
new file mode 100644
index 0000000..b7e2323
--- /dev/null
+++ b/internal/plugin/coverage/lcov/report.go
@@ -0,0 +1,186 @@
+// Package lcov reads LCOV coverage reports (the lcov.info format produced by
+// JavaScript/TypeScript tooling such as Jest, nyc, Vitest and c8, all of which
+// build on Istanbul). It matches files by their repo-relative path and tolerates
+// the absolute SF: paths Istanbul commonly emits by also suffix-matching.
+package lcov
+
+import (
+ "bufio"
+ "io"
+ "log"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/pkg/errors"
+ "github.com/target/pull-request-code-coverage/internal/plugin/coverage"
+ "github.com/target/pull-request-code-coverage/internal/plugin/domain"
+)
+
+type DefaultLoader struct{}
+
+func NewReportLoader() *DefaultLoader {
+ return &DefaultLoader{}
+}
+
+func (l *DefaultLoader) Load(coverageFile string) (coverage.Report, error) {
+ // nolint: gosec // coverageFile is a user-supplied report path; opening it is the intended behavior
+ file, openErr := os.Open(coverageFile)
+ if openErr != nil {
+ return nil, errors.Wrapf(openErr, "Could not open lcov file %v", coverageFile)
+ }
+
+ defer silentlyCall(file.Close)
+
+ report, parseErr := parse(file)
+ if parseErr != nil {
+ return nil, errors.Wrapf(parseErr, "Failed parsing lcov file %v", coverageFile)
+ }
+
+ return report, nil
+}
+
+func silentlyCall(c func() error) {
+ if err := c(); err != nil {
+ log.Panic(err)
+ }
+}
+
+// Report holds, per source file, the hit count for each line that LCOV tracked.
+type Report struct {
+ order []string
+ files map[string]map[int]int
+}
+
+// parse reads an LCOV stream into a Report. Only the SF: (source file) and DA:
+// (line execution) records matter for changed-line coverage; everything else
+// (functions, branches, summaries) is ignored.
+func parse(r io.Reader) (*Report, error) {
+ report := &Report{files: map[string]map[int]int{}}
+
+ scanner := bufio.NewScanner(r)
+ currentFile := ""
+
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+
+ switch {
+ case strings.HasPrefix(line, "SF:"):
+ currentFile = normalizePath(strings.TrimPrefix(line, "SF:"))
+ if _, seen := report.files[currentFile]; !seen {
+ report.files[currentFile] = map[int]int{}
+ report.order = append(report.order, currentFile)
+ }
+
+ case strings.HasPrefix(line, "DA:"):
+ if currentFile == "" {
+ continue
+ }
+
+ lineNumber, hits, daErr := parseDA(line)
+ if daErr != nil {
+ return nil, daErr
+ }
+
+ // LCOV can list a line more than once; keep the highest hit count.
+ if existing, ok := report.files[currentFile][lineNumber]; !ok || hits > existing {
+ report.files[currentFile][lineNumber] = hits
+ }
+
+ case line == "end_of_record":
+ currentFile = ""
+ }
+ }
+
+ if scanErr := scanner.Err(); scanErr != nil {
+ return nil, errors.Wrap(scanErr, "Failed reading lcov data")
+ }
+
+ return report, nil
+}
+
+// parseDA parses a "DA:,[,]" record.
+func parseDA(line string) (int, int, error) {
+ parts := strings.Split(strings.TrimPrefix(line, "DA:"), ",")
+ if len(parts) < 2 {
+ return 0, 0, errors.Errorf("Invalid DA record %q", line)
+ }
+
+ lineNumber, lineErr := strconv.Atoi(strings.TrimSpace(parts[0]))
+ if lineErr != nil {
+ return 0, 0, errors.Wrapf(lineErr, "Invalid line number in DA record %q", line)
+ }
+
+ hits, hitsErr := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if hitsErr != nil {
+ return 0, 0, errors.Wrapf(hitsErr, "Invalid hit count in DA record %q", line)
+ }
+
+ return lineNumber, hits, nil
+}
+
+func (r *Report) GetCoverageData(module string, sourceDir string, pkg string, fileName string, lineNumber int) (*domain.CoverageData, bool) {
+ candidates := relativePathCandidates(module, sourceDir, pkg, fileName)
+
+ for _, sf := range r.order {
+ if !matchesAny(sf, candidates) {
+ continue
+ }
+
+ hits, tracked := r.files[sf][lineNumber]
+ if !tracked {
+ continue
+ }
+
+ if hits > 0 {
+ return &domain.CoverageData{CoveredInstructionCount: 1}, true
+ }
+
+ return &domain.CoverageData{MissedInstructionCount: 1}, true
+ }
+
+ return nil, false
+}
+
+// relativePathCandidates builds the repo-relative paths a changed line could
+// appear under as an SF: entry. We try both the full path and the
+// source-dir-stripped path, since reports can be written relative to the repo
+// root or to a configured source dir.
+func relativePathCandidates(module string, sourceDir string, pkg string, fileName string) []string {
+ full := joinNonEmpty(module, sourceDir, pkg, fileName)
+ withoutSourceDir := joinNonEmpty(module, pkg, fileName)
+
+ if full == withoutSourceDir {
+ return []string{full}
+ }
+
+ return []string{full, withoutSourceDir}
+}
+
+// matchesAny reports whether an SF: path is, or ends with, one of the candidate
+// repo-relative paths. The suffix check handles the absolute paths Istanbul
+// tooling typically writes (e.g. /home/runner/work/app/app/src/x.ts).
+func matchesAny(sf string, candidates []string) bool {
+ for _, candidate := range candidates {
+ if sf == candidate || strings.HasSuffix(sf, "/"+candidate) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func normalizePath(p string) string {
+ return strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(p), "\\", "/"), "./")
+}
+
+func joinNonEmpty(parts ...string) string {
+ var nonEmpty []string
+ for _, part := range parts {
+ if len(part) > 0 {
+ nonEmpty = append(nonEmpty, part)
+ }
+ }
+
+ return strings.Join(nonEmpty, "/")
+}
diff --git a/internal/plugin/coverage/lcov/report_test.go b/internal/plugin/coverage/lcov/report_test.go
new file mode 100644
index 0000000..bcfba06
--- /dev/null
+++ b/internal/plugin/coverage/lcov/report_test.go
@@ -0,0 +1,89 @@
+package lcov
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_silentlyCall(t *testing.T) {
+ assert.Panics(t, func() {
+ silentlyCall(func() error {
+ return errors.New("anything")
+ })
+ })
+}
+
+func Test_Load_NoFile(t *testing.T) {
+ _, e := NewReportLoader().Load("../../../test/anything.info")
+ assert.EqualError(t, e, "Could not open lcov file ../../../test/anything.info: open ../../../test/anything.info: no such file or directory")
+}
+
+func Test_Load_BadDARecord(t *testing.T) {
+ _, e := NewReportLoader().Load("../../../test/example_lcov_bad.info")
+ assert.EqualError(t, e, "Failed parsing lcov file ../../../test/example_lcov_bad.info: Invalid line number in DA record \"DA:notanumber,1\": strconv.Atoi: parsing \"notanumber\": invalid syntax")
+}
+
+func loadReport(t *testing.T) *Report {
+ r, e := NewReportLoader().Load("../../../test/example_lcov.info")
+ assert.NoError(t, e)
+
+ report, ok := r.(*Report)
+ assert.True(t, ok)
+
+ return report
+}
+
+func Test_GetCoverageData_CoveredLine_SuffixMatchesAbsolutePath(t *testing.T) {
+ // SF: in the fixture is absolute, so this exercises suffix matching.
+ data, found := loadReport(t).GetCoverageData("", "", "src", "calculator.ts", 1)
+
+ assert.True(t, found)
+ assert.Equal(t, 1, data.CoveredInstructionCount)
+ assert.Equal(t, 0, data.MissedInstructionCount)
+}
+
+func Test_GetCoverageData_MissedLine(t *testing.T) {
+ data, found := loadReport(t).GetCoverageData("", "", "src", "calculator.ts", 6)
+
+ assert.True(t, found)
+ assert.Equal(t, 0, data.CoveredInstructionCount)
+ assert.Equal(t, 1, data.MissedInstructionCount)
+}
+
+func Test_GetCoverageData_UntrackedLineNotFound(t *testing.T) {
+ _, found := loadReport(t).GetCoverageData("", "", "src", "calculator.ts", 3)
+
+ assert.False(t, found)
+}
+
+func Test_GetCoverageData_UnknownFileNotFound(t *testing.T) {
+ _, found := loadReport(t).GetCoverageData("", "", "other", "missing.ts", 1)
+
+ assert.False(t, found)
+}
+
+func Test_GetCoverageData_ExactRelativePathMatch(t *testing.T) {
+ report, e := parse(strings.NewReader("SF:src/app.ts\nDA:10,1\nDA:11,0\nend_of_record\n"))
+ assert.NoError(t, e)
+
+ covered, foundCovered := report.GetCoverageData("", "", "src", "app.ts", 10)
+ assert.True(t, foundCovered)
+ assert.Equal(t, 1, covered.CoveredInstructionCount)
+
+ missed, foundMissed := report.GetCoverageData("", "", "src", "app.ts", 11)
+ assert.True(t, foundMissed)
+ assert.Equal(t, 1, missed.MissedInstructionCount)
+}
+
+func Test_GetCoverageData_MatchesWhenSourceDirStripped(t *testing.T) {
+ // Report path omits the "src" source dir the diff carries.
+ report, e := parse(strings.NewReader("SF:app.ts\nDA:1,1\nend_of_record\n"))
+ assert.NoError(t, e)
+
+ data, found := report.GetCoverageData("", "src", "", "app.ts", 1)
+ assert.True(t, found)
+ assert.Equal(t, 1, data.CoveredInstructionCount)
+}
diff --git a/internal/plugin/coverage/pythoncov/report.go b/internal/plugin/coverage/pythoncov/report.go
new file mode 100644
index 0000000..4d4960a
--- /dev/null
+++ b/internal/plugin/coverage/pythoncov/report.go
@@ -0,0 +1,144 @@
+// Package pythoncov reads coverage.py XML reports (the format produced by
+// `coverage xml` and pytest-cov's --cov-report=xml). That XML follows the
+// Cobertura schema, but unlike the cobertura loader this one matches purely on
+// the repo-relative file path and ignores the absolute root, so a
+// typical pytest project run from the repo root works without pointing
+// PARAMETER_SOURCE_DIRS at an absolute build path.
+package pythoncov
+
+import (
+ "encoding/xml"
+ "io"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/pkg/errors"
+ "github.com/target/pull-request-code-coverage/internal/plugin/coverage"
+ "github.com/target/pull-request-code-coverage/internal/plugin/domain"
+)
+
+type DefaultLoader struct {
+ readAllFunc func(io.Reader) ([]byte, error)
+}
+
+func NewReportLoader() *DefaultLoader {
+ return &DefaultLoader{
+ readAllFunc: io.ReadAll,
+ }
+}
+
+func (l *DefaultLoader) Load(coverageFile string) (coverage.Report, error) {
+ // nolint: gosec // coverageFile is a user-supplied report path; opening it is the intended behavior
+ xmlFile, openFileErr := os.Open(coverageFile)
+ if openFileErr != nil {
+ return nil, errors.Wrapf(openFileErr, "Could not open xml file %v", coverageFile)
+ }
+
+ defer silentlyCall(xmlFile.Close)
+
+ byteValue, readAllErr := l.readAllFunc(xmlFile)
+ if readAllErr != nil {
+ return nil, errors.Wrapf(readAllErr, "Failed reading in all of coverage file %v", coverageFile)
+ }
+
+ var coverageReport Report
+ if err := xml.Unmarshal(byteValue, &coverageReport); err != nil {
+ return nil, errors.Wrapf(err, "Failed unmarshalling coverage file %v", coverageFile)
+ }
+
+ return &coverageReport, nil
+}
+
+func silentlyCall(c func() error) {
+ if err := c(); err != nil {
+ log.Panic(err)
+ }
+}
+
+type Report struct {
+ XMLName xml.Name `xml:"coverage"`
+
+ Packages []Package `xml:"packages>package"`
+}
+
+func (r *Report) GetCoverageData(module string, sourceDir string, pkg string, fileName string, lineNumber int) (*domain.CoverageData, bool) {
+ candidates := relativePathCandidates(module, sourceDir, pkg, fileName)
+
+ for _, p := range r.Packages {
+ for _, c := range p.Classes {
+ if !matchesAny(c.Filename, candidates) {
+ continue
+ }
+
+ for _, l := range c.Lines {
+ if l.Number == lineNumber {
+ if l.Hits > 0 {
+ return &domain.CoverageData{CoveredInstructionCount: 1}, true
+ }
+
+ return &domain.CoverageData{MissedInstructionCount: 1}, true
+ }
+ }
+ }
+ }
+
+ return nil, false
+}
+
+// relativePathCandidates builds the repo-relative paths a changed line could
+// appear under as a . coverage.py writes filenames relative
+// to its root, which is commonly either the repo root (so the source
+// dir is part of the path) or the configured source dir (so it is stripped) โ
+// we try both forms.
+func relativePathCandidates(module string, sourceDir string, pkg string, fileName string) []string {
+ full := joinNonEmpty(module, sourceDir, pkg, fileName)
+ withoutSourceDir := joinNonEmpty(module, pkg, fileName)
+
+ if full == withoutSourceDir {
+ return []string{full}
+ }
+
+ return []string{full, withoutSourceDir}
+}
+
+func matchesAny(filename string, candidates []string) bool {
+ for _, candidate := range candidates {
+ if filename == candidate {
+ return true
+ }
+ }
+
+ return false
+}
+
+func joinNonEmpty(parts ...string) string {
+ var nonEmpty []string
+ for _, part := range parts {
+ if len(part) > 0 {
+ nonEmpty = append(nonEmpty, part)
+ }
+ }
+
+ return strings.Join(nonEmpty, "/")
+}
+
+type Package struct {
+ XMLName xml.Name `xml:"package"`
+
+ Classes []Class `xml:"classes>class"`
+}
+
+type Class struct {
+ XMLName xml.Name `xml:"class"`
+
+ Filename string `xml:"filename,attr"`
+ Lines []Line `xml:"lines>line"`
+}
+
+type Line struct {
+ XMLName xml.Name `xml:"line"`
+
+ Number int `xml:"number,attr"`
+ Hits int `xml:"hits,attr"`
+}
diff --git a/internal/plugin/coverage/pythoncov/report_test.go b/internal/plugin/coverage/pythoncov/report_test.go
new file mode 100644
index 0000000..4fcc778
--- /dev/null
+++ b/internal/plugin/coverage/pythoncov/report_test.go
@@ -0,0 +1,86 @@
+package pythoncov
+
+import (
+ "io"
+ "testing"
+
+ "github.com/pkg/errors"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_silentlyCall(t *testing.T) {
+ assert.Panics(t, func() {
+ silentlyCall(func() error {
+ return errors.New("anything")
+ })
+ })
+}
+
+func Test_Load_ReadAllFails(t *testing.T) {
+ l := NewReportLoader()
+ l.readAllFunc = func(io.Reader) ([]byte, error) {
+ return nil, errors.New("anything")
+ }
+
+ _, e := l.Load("../../../test/example_python_coverage.xml")
+ assert.EqualError(t, e, "Failed reading in all of coverage file ../../../test/example_python_coverage.xml: anything")
+}
+
+func Test_Load_BadXml(t *testing.T) {
+ l := NewReportLoader()
+ _, e := l.Load("../../../test/jacocoTestReport.json")
+ assert.EqualError(t, e, "Failed unmarshalling coverage file ../../../test/jacocoTestReport.json: EOF")
+}
+
+func Test_Load_NoFile(t *testing.T) {
+ l := NewReportLoader()
+ _, e := l.Load("../../../test/anything.xml")
+ assert.EqualError(t, e, "Could not open xml file ../../../test/anything.xml: open ../../../test/anything.xml: no such file or directory")
+}
+
+func loadReport(t *testing.T) *Report {
+ r, e := NewReportLoader().Load("../../../test/example_python_coverage.xml")
+ assert.NoError(t, e)
+
+ report, ok := r.(*Report)
+ assert.True(t, ok)
+
+ return report
+}
+
+func Test_GetCoverageData_CoveredLine(t *testing.T) {
+ data, found := loadReport(t).GetCoverageData("", "", "myapp", "calculator.py", 1)
+
+ assert.True(t, found)
+ assert.Equal(t, 1, data.CoveredInstructionCount)
+ assert.Equal(t, 0, data.MissedInstructionCount)
+}
+
+func Test_GetCoverageData_MissedLine(t *testing.T) {
+ data, found := loadReport(t).GetCoverageData("", "", "myapp", "calculator.py", 6)
+
+ assert.True(t, found)
+ assert.Equal(t, 0, data.CoveredInstructionCount)
+ assert.Equal(t, 1, data.MissedInstructionCount)
+}
+
+func Test_GetCoverageData_UntrackedLineNotFound(t *testing.T) {
+ _, found := loadReport(t).GetCoverageData("", "", "myapp", "calculator.py", 3)
+
+ assert.False(t, found)
+}
+
+func Test_GetCoverageData_UnknownFileNotFound(t *testing.T) {
+ _, found := loadReport(t).GetCoverageData("", "", "other", "missing.py", 1)
+
+ assert.False(t, found)
+}
+
+func Test_GetCoverageData_MatchesWhenSourceDirStripped(t *testing.T) {
+ // coverage.py wrote filenames relative to a "src"-like root, so the
+ // report path ("myapp/calculator.py") omits the source dir the diff carries.
+ data, found := loadReport(t).GetCoverageData("", "src", "myapp", "calculator.py", 1)
+
+ assert.True(t, found)
+ assert.Equal(t, 1, data.CoveredInstructionCount)
+}
diff --git a/internal/plugin/runner.go b/internal/plugin/runner.go
index 2d3c5c9..6f7fd53 100644
--- a/internal/plugin/runner.go
+++ b/internal/plugin/runner.go
@@ -12,6 +12,8 @@ import (
"github.com/target/pull-request-code-coverage/internal/plugin/coverage"
"github.com/target/pull-request-code-coverage/internal/plugin/coverage/cobertura"
"github.com/target/pull-request-code-coverage/internal/plugin/coverage/jacoco"
+ "github.com/target/pull-request-code-coverage/internal/plugin/coverage/lcov"
+ "github.com/target/pull-request-code-coverage/internal/plugin/coverage/pythoncov"
"github.com/target/pull-request-code-coverage/internal/plugin/pluginhttp"
"github.com/target/pull-request-code-coverage/internal/plugin/pluginjson"
"github.com/target/pull-request-code-coverage/internal/plugin/reporter"
@@ -150,6 +152,10 @@ func getCoverageReportLoader(coverageType string, sourceDirs []string) (coverage
}
return cobertura.NewReportLoader(sourceDirs[0]), nil
+ case "python":
+ return pythoncov.NewReportLoader(), nil
+ case "lcov", "javascript", "typescript":
+ return lcov.NewReportLoader(), nil
default:
return jacoco.NewReportLoader(), nil
}
diff --git a/internal/plugin/runner_test.go b/internal/plugin/runner_test.go
index 4922c7a..917934c 100644
--- a/internal/plugin/runner_test.go
+++ b/internal/plugin/runner_test.go
@@ -131,6 +131,68 @@ func TestDefaultRunner_Run_GoExample(t *testing.T) {
})
}
+func TestDefaultRunner_Run_PythonExample(t *testing.T) {
+
+ mocks.WithMockGithubAPI(func(mockServerURL string, requestAsserter mocks.GithubAPIRequestAsserter) {
+ propGetter := mocks.NewMockPropertyGetter()
+
+ propGetter.On("GetProperty", "PARAMETER_DEBUG").Return("false", true)
+ propGetter.On("GetProperty", "PARAMETER_COVERAGE_FILE").Return("../test/example_python_coverage.xml", true)
+ propGetter.On("GetProperty", "PARAMETER_COVERAGE_TYPE").Return("python", true)
+ propGetter.On("GetProperty", "PARAMETER_MODULE").Return("", false)
+ propGetter.On("GetProperty", "PARAMETER_SOURCE_DIRS").Return(".", true)
+ propGetter.On("GetProperty", "PARAMETER_GH_API_KEY").Return("SOME_API_KEY", true)
+ propGetter.On("GetProperty", "PARAMETER_GH_API_BASE_URL").Return(mockServerURL, true)
+ propGetter.On("GetProperty", "BUILD_PULL_REQUEST_NUMBER").Return("123", true)
+ propGetter.On("GetProperty", "REPOSITORY_ORG").Return("some_org", true)
+ propGetter.On("GetProperty", "REPOSITORY_NAME").Return("some_repo", true)
+
+ var buf bytes.Buffer
+
+ err := NewRunner().Run(propGetter.GetProperty, MustOpen(t, "../test/example_python_unified.diff"), &buf)
+ assert.NoError(t, err)
+
+ assert.Equal(t, "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n ๐ Patch Coverage Report โ changed lines only\nโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n\n Diff coverage: 71% ๐ก โ 5 of 7 changed instructions covered\n\n Summary\n Covered instructions 71% (5)\n Missed instructions 29% (2)\n Tracked changed lines 78% (7)\n Untracked changed lines 22% (2)\n\n Note: \"lines\" are the source lines you changed; \"instructions\" are the\n executable units the coverage tool counts inside them (one line can hold\n several, e.g. JaCoCo bytecode), so the two counts differ.\n\n Coverage by file (lowest coverage first)\n 71% 5 cov / 2 miss myapp/calculator.py\n\n Uncovered lines (2)\n - myapp/calculator.py:6\n return wrong_name\n - myapp/calculator.py:9\n return a / b\n\nโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n", buf.String())
+
+ requestAsserter.AssertRequestWasMade(t, "/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", map[string]interface{}{
+ "body": "## ๐ก๏ธ Patch Coverage Report\n\n> 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?*\n\n**Diff coverage:** `71%` ๐ก โ `5` of `7` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| ๐ข Covered instructions | `5` (71%) | changed code your tests executed |\n| ๐ด Missed instructions | `2` (29%) | changed code your tests never ran |\n| ๐ Tracked changed lines | `7` (78%) | lines the coverage tool could measure |\n| โช Untracked changed lines | `2` (22%) | comments, blanks, declarations |\n\n**Lines** = the source lines you changed. **Instructions** = the executable units the coverage tool counts inside those lines โ one line can hold several (e.g. JaCoCo bytecode), so the two counts differ.\n\n### Coverage by file\n\n| File | Diff coverage | Covered / Missed |\n| :-- | :-: | :-: |\n| `myapp/calculator.py` | ๐ก 71% | 5 / 2 |\n\n\n๐ Uncovered lines (2)\n\n```\n--- myapp/calculator.py:6\n return wrong_name\n--- myapp/calculator.py:9\n return a / b\n```\n\n\n๐ค Generated by pull-request-code-coverage โ coverage for changed lines only.\n",
+ })
+
+ propGetter.AssertExpectations(t)
+ })
+}
+
+func TestDefaultRunner_Run_LcovExample(t *testing.T) {
+
+ mocks.WithMockGithubAPI(func(mockServerURL string, requestAsserter mocks.GithubAPIRequestAsserter) {
+ propGetter := mocks.NewMockPropertyGetter()
+
+ propGetter.On("GetProperty", "PARAMETER_DEBUG").Return("false", true)
+ propGetter.On("GetProperty", "PARAMETER_COVERAGE_FILE").Return("../test/example_lcov.info", true)
+ propGetter.On("GetProperty", "PARAMETER_COVERAGE_TYPE").Return("lcov", true)
+ propGetter.On("GetProperty", "PARAMETER_MODULE").Return("", false)
+ propGetter.On("GetProperty", "PARAMETER_SOURCE_DIRS").Return(".", true)
+ propGetter.On("GetProperty", "PARAMETER_GH_API_KEY").Return("SOME_API_KEY", true)
+ propGetter.On("GetProperty", "PARAMETER_GH_API_BASE_URL").Return(mockServerURL, true)
+ propGetter.On("GetProperty", "BUILD_PULL_REQUEST_NUMBER").Return("123", true)
+ propGetter.On("GetProperty", "REPOSITORY_ORG").Return("some_org", true)
+ propGetter.On("GetProperty", "REPOSITORY_NAME").Return("some_repo", true)
+
+ var buf bytes.Buffer
+
+ err := NewRunner().Run(propGetter.GetProperty, MustOpen(t, "../test/example_lcov_unified.diff"), &buf)
+ assert.NoError(t, err)
+
+ assert.Equal(t, "โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n ๐ Patch Coverage Report โ changed lines only\nโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n\n Diff coverage: 71% ๐ก โ 5 of 7 changed instructions covered\n\n Summary\n Covered instructions 71% (5)\n Missed instructions 29% (2)\n Tracked changed lines 78% (7)\n Untracked changed lines 22% (2)\n\n Note: \"lines\" are the source lines you changed; \"instructions\" are the\n executable units the coverage tool counts inside them (one line can hold\n several, e.g. JaCoCo bytecode), so the two counts differ.\n\n Coverage by file (lowest coverage first)\n 71% 5 cov / 2 miss src/calculator.ts\n\n Uncovered lines (2)\n - src/calculator.ts:6\n return wrongName;\n - src/calculator.ts:9\n return a / b;\n\nโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ\n", buf.String())
+
+ requestAsserter.AssertRequestWasMade(t, "/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", map[string]interface{}{
+ "body": "## ๐ก๏ธ Patch Coverage Report\n\n> 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?*\n\n**Diff coverage:** `71%` ๐ก โ `5` of `7` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| ๐ข Covered instructions | `5` (71%) | changed code your tests executed |\n| ๐ด Missed instructions | `2` (29%) | changed code your tests never ran |\n| ๐ Tracked changed lines | `7` (78%) | lines the coverage tool could measure |\n| โช Untracked changed lines | `2` (22%) | comments, blanks, declarations |\n\n**Lines** = the source lines you changed. **Instructions** = the executable units the coverage tool counts inside those lines โ one line can hold several (e.g. JaCoCo bytecode), so the two counts differ.\n\n### Coverage by file\n\n| File | Diff coverage | Covered / Missed |\n| :-- | :-: | :-: |\n| `src/calculator.ts` | ๐ก 71% | 5 / 2 |\n\n\n๐ Uncovered lines (2)\n\n```\n--- src/calculator.ts:6\n return wrongName;\n--- src/calculator.ts:9\n return a / b;\n```\n\n\n๐ค Generated by pull-request-code-coverage โ coverage for changed lines only.\n",
+ })
+
+ propGetter.AssertExpectations(t)
+ })
+}
+
func TestDefaultRunner_Run(t *testing.T) {
mocks.WithMockGithubAPI(func(mockServerURL string, requestAsserter mocks.GithubAPIRequestAsserter) {
diff --git a/internal/test/example_lcov.info b/internal/test/example_lcov.info
new file mode 100644
index 0000000..7aa1e2a
--- /dev/null
+++ b/internal/test/example_lcov.info
@@ -0,0 +1,14 @@
+TN:
+SF:/home/runner/work/app/app/src/calculator.ts
+FNF:3
+FNH:3
+DA:1,1
+DA:2,1
+DA:4,1
+DA:5,1
+DA:6,0
+DA:8,1
+DA:9,0
+LF:7
+LH:5
+end_of_record
diff --git a/internal/test/example_lcov_bad.info b/internal/test/example_lcov_bad.info
new file mode 100644
index 0000000..ac6ff12
--- /dev/null
+++ b/internal/test/example_lcov_bad.info
@@ -0,0 +1,3 @@
+SF:src/x.ts
+DA:notanumber,1
+end_of_record
diff --git a/internal/test/example_lcov_unified.diff b/internal/test/example_lcov_unified.diff
new file mode 100644
index 0000000..acc23e9
--- /dev/null
+++ b/internal/test/example_lcov_unified.diff
@@ -0,0 +1,15 @@
+diff --git a/src/calculator.ts b/src/calculator.ts
+new file mode 100644
+index 0000000..abcdef0
+--- /dev/null
++++ b/src/calculator.ts
+@@ -0,0 +1,9 @@
++export function add(a: number, b: number): number {
++ return a + b;
++}
++export function subtract(a: number, b: number): number {
++ const result = a - b;
++ return wrongName;
++}
++export function divide(a: number, b: number): number {
++ return a / b;
diff --git a/internal/test/example_python_coverage.xml b/internal/test/example_python_coverage.xml
new file mode 100644
index 0000000..75ad11a
--- /dev/null
+++ b/internal/test/example_python_coverage.xml
@@ -0,0 +1,24 @@
+
+
+
+ /home/runner/work/myapp/myapp
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/test/example_python_unified.diff b/internal/test/example_python_unified.diff
new file mode 100644
index 0000000..cae2fd5
--- /dev/null
+++ b/internal/test/example_python_unified.diff
@@ -0,0 +1,15 @@
+diff --git a/myapp/calculator.py b/myapp/calculator.py
+new file mode 100644
+index 0000000..abcdef0
+--- /dev/null
++++ b/myapp/calculator.py
+@@ -0,0 +1,9 @@
++def add(a, b):
++ return a + b
++
++def subtract(a, b):
++ result = a - b
++ return wrong_name
++
++def divide(a, b):
++ return a / b