From 95106d9c2bc29c5669f221fb62f7ce52c373d390 Mon Sep 17 00:00:00 2001 From: Vishal Vaibhav Date: Sun, 24 May 2026 11:15:22 +0530 Subject: [PATCH 1/2] feat: python base test coverage --- README.md | 281 +++++++++++++++--- internal/plugin/coverage/pythoncov/report.go | 144 +++++++++ .../plugin/coverage/pythoncov/report_test.go | 86 ++++++ internal/plugin/runner.go | 3 + internal/plugin/runner_test.go | 31 ++ internal/test/example_python_coverage.xml | 24 ++ internal/test/example_python_unified.diff | 15 + 7 files changed, 541 insertions(+), 43 deletions(-) create mode 100644 internal/plugin/coverage/pythoncov/report.go create mode 100644 internal/plugin/coverage/pythoncov/report_test.go create mode 100644 internal/test/example_python_coverage.xml create mode 100644 internal/test/example_python_unified.diff diff --git a/README.md b/README.md index 8bd547a..4bd14b9 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,174 @@ - # 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) + - [Running outside Vela](#running-outside-vela) +- [Parameters](#parameters) +- [Development](#development) +- [License](#license) + +--- + +## How it works + +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). + +--- -A continuous integration plugin to allow detecting code coverage for only the lines changed in a PR. +## Lines vs. instructions -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. +The report uses two different units, and they are **not** the same thing: -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** โ€” the source lines your PR changed. +- **Instructions** โ€” the smaller executable units the coverage tool counts *inside* those lines. -![ ](./images/vela-step-console-pr-code-coverage.png) +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: -This plugin as well as has the ability to comment on the PR with a summary of the coverage details. -![ ](./images/github_pr_coverage.png) +| 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. -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 +--- -This plugin works out of box for [Vela](https://github.com/go-vela),a CI/CD open-sourced by target +## What you'll see -## Docker image +The same data is rendered for two audiences: plain text for the build log, and Markdown for the PR. -The plugin is published as a public Docker image to the GitHub Container Registry (GHCR) on every release: +### 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`) | + +--- + +## 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 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 +### JVM projects (jacoco) + +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 +191,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 +203,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 +213,104 @@ 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. + +### 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`, or `python` | +| `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/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..0d54ed5 100644 --- a/internal/plugin/runner.go +++ b/internal/plugin/runner.go @@ -12,6 +12,7 @@ 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/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 +151,8 @@ func getCoverageReportLoader(coverageType string, sourceDirs []string) (coverage } return cobertura.NewReportLoader(sourceDirs[0]), nil + case "python": + return pythoncov.NewReportLoader(), nil default: return jacoco.NewReportLoader(), nil } diff --git a/internal/plugin/runner_test.go b/internal/plugin/runner_test.go index 4922c7a..c7e0d92 100644 --- a/internal/plugin/runner_test.go +++ b/internal/plugin/runner_test.go @@ -131,6 +131,37 @@ 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(t *testing.T) { mocks.WithMockGithubAPI(func(mockServerURL string, requestAsserter mocks.GithubAPIRequestAsserter) { 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 From 1b43f1f1c55db001e161f6685f401446cba10d8a Mon Sep 17 00:00:00 2001 From: Vishal Vaibhav Date: Sun, 24 May 2026 11:26:20 +0530 Subject: [PATCH 2/2] feat: add javascript --- README.md | 40 +++- internal/plugin/coverage/lcov/report.go | 186 +++++++++++++++++++ internal/plugin/coverage/lcov/report_test.go | 89 +++++++++ internal/plugin/runner.go | 3 + internal/plugin/runner_test.go | 31 ++++ internal/test/example_lcov.info | 14 ++ internal/test/example_lcov_bad.info | 3 + internal/test/example_lcov_unified.diff | 15 ++ 8 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 internal/plugin/coverage/lcov/report.go create mode 100644 internal/plugin/coverage/lcov/report_test.go create mode 100644 internal/test/example_lcov.info create mode 100644 internal/test/example_lcov_bad.info create mode 100644 internal/test/example_lcov_unified.diff diff --git a/README.md b/README.md index 4bd14b9..8fe364b 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ It supports **JVM, Go, and Python** projects, and works out of the box for [Vela - [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) @@ -146,6 +147,7 @@ It carries the same sections as the console โ€” diff-coverage headline, summary | `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`) | --- @@ -261,6 +263,42 @@ Then pass `coverage.xml` with `coverage_type: python`: > 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: @@ -282,7 +320,7 @@ A working GitHub Actions example lives in [`.github/workflows/pr-coverage.yml`]( | Parameter | Env var | Required | Default | Description | |---|---|---|---|---| -| `coverage_type` | `PARAMETER_COVERAGE_TYPE` | yes | | coverage format: `jacoco`, `cobertura`, or `python` | +| `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) | 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/runner.go b/internal/plugin/runner.go index 0d54ed5..6f7fd53 100644 --- a/internal/plugin/runner.go +++ b/internal/plugin/runner.go @@ -12,6 +12,7 @@ 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" @@ -153,6 +154,8 @@ 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 c7e0d92..917934c 100644 --- a/internal/plugin/runner_test.go +++ b/internal/plugin/runner_test.go @@ -162,6 +162,37 @@ func TestDefaultRunner_Run_PythonExample(t *testing.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;