Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 35 additions & 19 deletions .github/workflows/pr-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ permissions:
jobs:
coverage:
runs-on: ubuntu-latest
# Fork PRs only get a read-only GITHUB_TOKEN (can't comment) and no secrets,
# so restrict to same-repo PRs to avoid a guaranteed failure on forks.
if: github.event.pull_request.head.repo.full_name == github.repository
# Runs on fork PRs too. Fork PRs only get a read-only GITHUB_TOKEN and no
# secrets, so the plugin can't post the PR comment there — it just prints
# coverage to the job log (the reporter no-ops the comment when the token /
# PR context is missing).
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0 # need the base branch present to diff against it
# No fetch-depth needed: the plugin now fetches the PR diff from the
# GitHub API (PARAMETER_DIFF_SOURCE=github), so we don't diff against the
# base branch locally. Checkout is only here to build the image and run
# the tests that produce the coverage report.

- name: Set up Go
uses: actions/setup-go@v5
Expand All @@ -43,7 +46,16 @@ jobs:
run: docker build -t pr-code-coverage:ci .

- name: Report coverage on changed lines
# On fork PRs the GITHUB_TOKEN is read-only: the plugin can read the diff
# but the PR-comment POST gets a 403 and errors. Tolerate that on forks so
# the run still goes green with coverage printed to the log; same-repo PRs
# keep failing loudly on real errors.
continue-on-error: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
env:
# Fetch the PR diff straight from the GitHub API instead of piping in a
# local `git diff`. Note the API diff covers ALL changed files, not just
# *.go, so non-Go edits (yml/md) show up as "untracked changed lines".
PARAMETER_DIFF_SOURCE: github
PARAMETER_COVERAGE_TYPE: cobertura
PARAMETER_COVERAGE_FILE: coverage.xml
# Must equal the cobertura <source> path (the dir go test ran in).
Expand All @@ -53,18 +65,22 @@ jobs:
BUILD_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY_ORG: ${{ github.repository_owner }}
REPOSITORY_NAME: ${{ github.event.repository.name }}
# The container mounts the workspace so it can read coverage.xml, and the
# GITHUB_STEP_SUMMARY file so the plugin can render coverage on the run's
# summary page (visible even on fork PRs where the comment can't post).
# The diff no longer comes from stdin, so `-i` and the pipe are gone.
run: |
git fetch --no-tags origin "${{ github.base_ref }}"
git --no-pager diff --unified=0 "origin/${{ github.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 \
-v "${{ github.workspace }}:${{ github.workspace }}" \
-w "${{ github.workspace }}" \
--entrypoint /plugin \
pr-code-coverage:ci
docker run --rm \
-e PARAMETER_DIFF_SOURCE \
-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 \
-e GITHUB_STEP_SUMMARY \
-v "${{ github.workspace }}:${{ github.workspace }}" \
-v "$GITHUB_STEP_SUMMARY:$GITHUB_STEP_SUMMARY" \
-w "${{ github.workspace }}" \
pr-code-coverage:ci
30 changes: 21 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,32 +46,43 @@ The CI (`.github/workflows/test.yml`) runs build, test, `make format`, and `make

Entry point `main.go` → `plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)`.
The runner (`internal/plugin/runner.go`) reads **config from env vars** (`PARAMETER_*`,
`BUILD_PULL_REQUEST_NUMBER`, `REPOSITORY_ORG`, `REPOSITORY_NAME`), the **diff from stdin**, and the
`BUILD_PULL_REQUEST_NUMBER`, `REPOSITORY_ORG`, `REPOSITORY_NAME`), the **diff** (from stdin by
default, or fetched from the GitHub API — see `PARAMETER_DIFF_SOURCE` below), and the
**coverage report from the file** at `PARAMETER_COVERAGE_FILE`.

```
stdin (unified diff) ──► sourcelines/unifieddiff ──► []domain.SourceLine
diff (unified) ──► sourcelines/unifieddiff ──► []domain.SourceLine
│ {Module,SrcDir,Pkg,FileName,LineNumber,LineValue}
coverage file ──► coverage.Loader.Load() ──► coverage.Report
calculator.DetermineCoverage(lines, report) ─────┘
└ for each line: report.GetCoverageData(...) ──► []domain.SourceLineCoverage
reporter.Forking{ Simple, GithubPullRequest }.Write(...)
reporter.Forking{ Simple, GithubPullRequest, StepSummary }.Write(...)
├─ Simple → plain-text report to stdout (always)
└─ GithubPullRequest → Markdown PR comment (only if creds present)
├─ GithubPullRequest → Markdown PR comment (only if creds present)
└─ StepSummary → Markdown to $GITHUB_STEP_SUMMARY (only if set)
```

Key packages:
- `internal/plugin/sourcelines/unifieddiff/changed_source_loader.go` — parses the unified diff into
changed `SourceLine`s. `PARAMETER_SOURCE_DIRS` controls how a path prefix is split into `SrcDir`/`Pkg`.
Handles both `--unified=0` diffs (the stdin/Vela path, no context lines) and diffs that carry context
lines (e.g. from the GitHub API) — context lines advance the new-file line counter but aren't recorded.
- `internal/plugin/githubdiff/diff.go` — alternative diff source. When `PARAMETER_DIFF_SOURCE=github`,
the runner fetches the PR diff from `GET /repos/{owner}/{repo}/pulls/{n}` with the
`application/vnd.github.v3.diff` media type instead of reading stdin. Default is `stdin`
(unchanged behavior). The `github` mode requires `PARAMETER_GH_API_KEY` + the three build-context vars.
- `internal/plugin/coverage/` — `report.go` defines the two interfaces every format implements:
`Loader.Load(file) (Report, error)` and `Report.GetCoverageData(module, sourceDir, pkg, fileName, lineNumber) (*CoverageData, bool)`.
- `internal/plugin/calculator/calculator.go` — joins changed lines to coverage data.
- `internal/plugin/reporter/` — `simple.go` (console), `github_pr.go` (PR comment markdown),
`forking.go` (runs all reporters), `utils.go` (`filePath`, `lineDescription`). Per-file
aggregation (`collectFileCoverage`) and `coverageStatusEmoji` live in `github_pr.go` and are
shared by both reporters (same package).
`step_summary.go` (writes the same markdown to `$GITHUB_STEP_SUMMARY`), `forking.go` (runs all
reporters), `utils.go` (`filePath`, `lineDescription`). Per-file aggregation
(`collectFileCoverage`), `coverageStatusEmoji`, and the shared markdown builder
(`buildMarkdownReport`, used by both `github_pr.go` and `step_summary.go`) live in `github_pr.go`.
The PR comment is **sticky**: `github_pr.go` first GETs the PR's comments, and if it finds the one
carrying the hidden `commentMarker` it PATCHes that comment instead of POSTing a new one.
- `internal/plugin/domain/domain.go` — core types. Coverage is counted in **instructions**
(`CoveredInstructionCount`/`MissedInstructionCount`), not lines (see below).

Expand All @@ -81,8 +92,9 @@ Key packages:
JVM bytecode *instructions*, so a line can be partly covered. For Go/Python/LCOV the loaders emit
exactly 1 instruction per line. The reports surface both units on purpose — do not "fix" this as if
it were a bug. The user has explicitly asked for this distinction to be clear.
- **Two output formats, one dataset.** `Simple` (plain text, stdout) and `GithubPullRequest`
(Markdown) render the same data differently. Change both if you change what's reported.
- **Two output formats, one dataset.** `Simple` (plain text, stdout) renders one way;
`GithubPullRequest` and `StepSummary` both render the shared `buildMarkdownReport` output. Change
`Simple` and `buildMarkdownReport` if you change what's reported.
- **The PR comment is posted only** when `gh_api_key` AND `BUILD_PULL_REQUEST_NUMBER` AND
`REPOSITORY_ORG` AND `REPOSITORY_NAME` are all present; otherwise console-only. `GithubPullRequest`
also returns early when there are zero changed lines with coverage data.
Expand Down
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ FROM alpine:latest
COPY --from=builder /go/src/github.com/target/pull-request-code-coverage/bin/plugin /
RUN apk --no-cache add ca-certificates git bash openssh-client
WORKDIR /root/
COPY scripts/start.sh /
CMD ["/start.sh"]
# Run the plugin directly. With PARAMETER_DIFF_SOURCE=github it fetches the PR
# diff from the GitHub API; for the stdin path, pipe a `git diff` into the
# container (docker run -i ... | git diff ...).
ENTRYPOINT ["/plugin"]
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,16 @@ git --no-pager diff --unified=0 "origin/$BASE_REF" -- '*.go' | docker run --rm -

A working GitHub Actions example lives in [`.github/workflows/pr-coverage.yml`](.github/workflows/pr-coverage.yml).

**No git checkout?** Set `PARAMETER_DIFF_SOURCE=github` and the plugin fetches the PR's diff straight from the GitHub API instead of reading stdin — so you don't need the repo checked out or `git` available, just the coverage file and a token. This uses the same diff GitHub shows reviewers (computed against the merge base), so it can differ slightly from a local `git diff origin/<base>`. It requires `PARAMETER_GH_API_KEY`, `BUILD_PULL_REQUEST_NUMBER`, `REPOSITORY_ORG`, and `REPOSITORY_NAME`.

```
docker run --rm \
-e PARAMETER_DIFF_SOURCE=github \
-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
```
---
## Parameters
Expand All @@ -326,6 +336,7 @@ A working GitHub Actions example lives in [`.github/workflows/pr-coverage.yml`](
| `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` |
| `diff_source` | `PARAMETER_DIFF_SOURCE` | no | `stdin` | where the PR diff comes from: `stdin` (pipe a `git diff` in, the default) or `github` (fetch the PR diff from the GitHub API — needs no git checkout; requires `gh_api_key` and the three build-context values) |
| `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.
Expand All @@ -335,8 +346,11 @@ A working GitHub Actions example lives in [`.github/workflows/pr-coverage.yml`](
| `BUILD_PULL_REQUEST_NUMBER` | the PR number to comment on |
| `REPOSITORY_ORG` | repository owner / org |
| `REPOSITORY_NAME` | repository name |
| `GITHUB_STEP_SUMMARY` | set automatically by GitHub Actions. When present, the plugin also writes the report to the [job summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary), so coverage shows on the run page even on fork PRs that can't be commented on |
> 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.
>
> The comment is **sticky**: on later pushes the plugin updates its existing comment in place instead of posting a new one each time. In GitHub Actions, mount the summary file (`-v "$GITHUB_STEP_SUMMARY:$GITHUB_STEP_SUMMARY" -e GITHUB_STEP_SUMMARY`) to get the job-summary output — see [`.github/workflows/pr-coverage.yml`](.github/workflows/pr-coverage.yml).
---
Expand Down
74 changes: 74 additions & 0 deletions internal/plugin/githubdiff/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Package githubdiff fetches a pull request's unified diff directly from the
// GitHub REST API, so the plugin can determine what a PR changed without a git
// checkout. It is an alternative to reading the diff piped in on stdin.
package githubdiff

import (
"bytes"
"fmt"
"io"
"strings"

"github.com/pkg/errors"
"github.com/target/pull-request-code-coverage/internal/plugin/pluginhttp"
)

const httpResponseOK = 200

// Loader retrieves the diff for a single pull request from the GitHub API.
type Loader struct {
apiKey string
apiBaseURL string
pr string
owner string
repo string
httpClient pluginhttp.Client
}

func NewLoader(apiKey string, apiBaseURL string, pr string, owner string, repo string, httpClient pluginhttp.Client) *Loader {
return &Loader{
apiKey: apiKey,
apiBaseURL: apiBaseURL,
pr: pr,
owner: owner,
repo: repo,
httpClient: httpClient,
}
}

// Load requests the pull request diff using the `application/vnd.github.v3.diff`
// media type. GitHub returns the same unified diff it shows reviewers — computed
// against the merge base and carrying context lines — which the unified-diff
// parser handles. The whole response is read into memory and returned as a
// reader so the caller can treat it exactly like the stdin diff.
func (l *Loader) Load() (io.Reader, error) {
url := fmt.Sprintf("%v/repos/%v/%v/pulls/%v", strings.TrimRight(l.apiBaseURL, "/"), l.owner, l.repo, l.pr)

req, newErr := l.httpClient.NewRequest("GET", url, nil)
if newErr != nil {
return nil, errors.Wrap(newErr, "Failed creating request to github")
}

req.Header.Add("Authorization", "token "+l.apiKey)
req.Header.Add("Accept", "application/vnd.github.v3.diff")

resp, doErr := l.httpClient.Do(req)
if doErr != nil {
return nil, errors.Wrap(doErr, "Failed calling github")
}

defer func() {
_ = resp.Body.Close()
}()

if resp.StatusCode != httpResponseOK {
return nil, errors.Errorf("Failed calling github: bad status code: %v", resp.StatusCode)
}

body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, errors.Wrap(readErr, "Failed reading diff response from github")
}

return bytes.NewReader(body), nil
}
88 changes: 88 additions & 0 deletions internal/plugin/githubdiff/diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package githubdiff

import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/target/pull-request-code-coverage/internal/plugin/pluginhttp"
)

func TestLoader_Load_BuildsRequestAndReturnsDiff(t *testing.T) {
mockClient := &pluginhttp.MockClient{}
request := httptest.NewRequest("GET", "http://anywhere", nil)

mockClient.
On("NewRequest", "GET", "https://api.github.com/repos/some_org/some_repo/pulls/123", nil).
Return(request, nil)
mockClient.
On("Do", request).
Return(&http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("THE DIFF"))}, nil)

reader, err := NewLoader("SOME_API_KEY", "https://api.github.com", "123", "some_org", "some_repo", mockClient).Load()

assert.NoError(t, err)

body, _ := io.ReadAll(reader)
assert.Equal(t, "THE DIFF", string(body))

assert.Equal(t, "token SOME_API_KEY", request.Header.Get("Authorization"))
assert.Equal(t, "application/vnd.github.v3.diff", request.Header.Get("Accept"))

mockClient.AssertExpectations(t)
}

func TestLoader_Load_TrimsTrailingSlashOnBaseURL(t *testing.T) {
mockClient := &pluginhttp.MockClient{}
request := httptest.NewRequest("GET", "http://anywhere", nil)

mockClient.
On("NewRequest", "GET", "https://git.example.com/api/v3/repos/o/r/pulls/9", nil).
Return(request, nil)
mockClient.
On("Do", request).
Return(&http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil)

_, err := NewLoader("k", "https://git.example.com/api/v3/", "9", "o", "r", mockClient).Load()

assert.NoError(t, err)
mockClient.AssertExpectations(t)
}

func TestLoader_Load_FailedNewRequest(t *testing.T) {
mockClient := &pluginhttp.MockClient{}
mockClient.On("NewRequest", mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("boom"))

_, err := NewLoader("k", "https://api.github.com", "1", "o", "r", mockClient).Load()

assert.EqualError(t, err, "Failed creating request to github: boom")
}

func TestLoader_Load_FailedDo(t *testing.T) {
mockClient := &pluginhttp.MockClient{}
request := httptest.NewRequest("GET", "http://anywhere", nil)

mockClient.On("NewRequest", mock.Anything, mock.Anything, mock.Anything).Return(request, nil)
mockClient.On("Do", request).Return(nil, errors.New("boom"))

_, err := NewLoader("k", "https://api.github.com", "1", "o", "r", mockClient).Load()

assert.EqualError(t, err, "Failed calling github: boom")
}

func TestLoader_Load_BadStatus(t *testing.T) {
mockClient := &pluginhttp.MockClient{}
request := httptest.NewRequest("GET", "http://anywhere", nil)

mockClient.On("NewRequest", mock.Anything, mock.Anything, mock.Anything).Return(request, nil)
mockClient.On("Do", request).Return(&http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(""))}, nil)

_, err := NewLoader("k", "https://api.github.com", "1", "o", "r", mockClient).Load()

assert.EqualError(t, err, "Failed calling github: bad status code: 404")
}
5 changes: 5 additions & 0 deletions internal/plugin/pluginjson/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import "encoding/json"

type Client interface {
Marshal(data interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
}

type DefaultClient struct{}

func (c *DefaultClient) Marshal(data interface{}) ([]byte, error) {
return json.Marshal(data)
}

func (c *DefaultClient) Unmarshal(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
6 changes: 6 additions & 0 deletions internal/plugin/pluginjson/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,9 @@ func (m *MockClient) Marshal(data interface{}) ([]byte, error) {
}
return r.([]byte), e
}

func (m *MockClient) Unmarshal(data []byte, v interface{}) error {
args := m.Called(data, v)

return args.Error(0)
}
Loading
Loading