Skip to content
Closed
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
50 changes: 31 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,18 @@ jobs:
BUILD_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY_ORG: ${{ github.repository_owner }}
REPOSITORY_NAME: ${{ github.event.repository.name }}
# The container still mounts the workspace so it can read coverage.xml;
# 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 \
-v "${{ github.workspace }}:${{ github.workspace }}" \
-w "${{ github.workspace }}" \
pr-code-coverage:ci
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ 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
Expand All @@ -65,6 +66,12 @@ coverage file ──► coverage.Loader.Load() ──► coverage.Report
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.
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"]
11 changes: 11 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 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")
}
28 changes: 28 additions & 0 deletions internal/plugin/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"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/githubdiff"
"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"
Expand Down Expand Up @@ -97,6 +98,33 @@ func (*DefaultRunner) Run(propertyGetter func(string) (string, bool), changedSou
return errors.Wrap(loadCoverageErr, "Failed loading coverage report")
}

diffSource, found := propertyGetter("PARAMETER_DIFF_SOURCE")
if !found || diffSource == "" {
logrus.Info("PARAMETER_DIFF_SOURCE was missing, defaulting to stdin")
diffSource = "stdin"
}

switch diffSource {
case "stdin":
// changedSourceLinesSource already points at the piped-in diff (stdin);
// nothing to do. This is the original, default behavior.
case "github":
if !ghAPIKeyFound || !repoPRFound || !repoOwnerFound || !repoNameFound {
return errors.New("PARAMETER_DIFF_SOURCE=github requires a GitHub API key (PARAMETER_GH_API_KEY), BUILD_PULL_REQUEST_NUMBER, REPOSITORY_ORG and REPOSITORY_NAME")
}

logrus.Info("PARAMETER_DIFF_SOURCE is github, fetching diff from the GitHub API")

diffReader, fetchErr := githubdiff.NewLoader(ghAPIKey, ghAPIBaseURL, repoPR, repoOwner, repoName, &pluginhttp.DefaultClient{}).Load()
if fetchErr != nil {
return errors.Wrap(fetchErr, "Failed fetching diff from github")
}

changedSourceLinesSource = diffReader
default:
return errors.Errorf("Unknown PARAMETER_DIFF_SOURCE %q (expected \"stdin\" or \"github\")", diffSource)
}

changedLines, changedLinesErr := unifieddiff.NewChangedSourceLinesLoader(module, sourceDirs).Load(changedSourceLinesSource)
if changedLinesErr != nil {
return errors.Wrap(changedLinesErr, "Failed loading changed lines")
Expand Down
Loading
Loading