From 64a54390b24e5a00092ebad3c5c183edccb556db Mon Sep 17 00:00:00 2001 From: Vishal Vaibhav Date: Fri, 19 Jun 2026 11:46:32 +0530 Subject: [PATCH 1/3] feat: move-diff-logic-within-the-plugin Committer: codeyogico --- .github/workflows/pr-coverage.yml | 39 +++--- AGENTS.md | 11 +- README.md | 11 ++ internal/plugin/githubdiff/diff.go | 74 ++++++++++++ internal/plugin/githubdiff/diff_test.go | 88 ++++++++++++++ internal/plugin/runner.go | 28 +++++ internal/plugin/runner_diffsource_test.go | 111 ++++++++++++++++++ .../unifieddiff/changed_source_loader.go | 12 ++ .../unifieddiff/changed_source_loader_test.go | 83 +++++++++++++ internal/test/mocks/property_getter.go | 24 ++++ 10 files changed, 463 insertions(+), 18 deletions(-) create mode 100644 internal/plugin/githubdiff/diff.go create mode 100644 internal/plugin/githubdiff/diff_test.go create mode 100644 internal/plugin/runner_diffsource_test.go create mode 100644 internal/plugin/sourcelines/unifieddiff/changed_source_loader_test.go diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index b3cbf7e..7cfc12b 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -20,8 +20,10 @@ jobs: 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 @@ -44,6 +46,10 @@ jobs: - name: Report coverage on changed lines 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 path (the dir go test ran in). @@ -53,18 +59,19 @@ 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 }}" \ + --entrypoint /plugin \ + pr-code-coverage:ci diff --git a/AGENTS.md b/AGENTS.md index bb47610..8d49758 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 │ @@ -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. diff --git a/README.md b/README.md index 8fe364b..4c14382 100644 --- a/README.md +++ b/README.md @@ -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/`. 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 @@ -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. diff --git a/internal/plugin/githubdiff/diff.go b/internal/plugin/githubdiff/diff.go new file mode 100644 index 0000000..43ad137 --- /dev/null +++ b/internal/plugin/githubdiff/diff.go @@ -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 +} diff --git a/internal/plugin/githubdiff/diff_test.go b/internal/plugin/githubdiff/diff_test.go new file mode 100644 index 0000000..ae30bd5 --- /dev/null +++ b/internal/plugin/githubdiff/diff_test.go @@ -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") +} diff --git a/internal/plugin/runner.go b/internal/plugin/runner.go index 6f7fd53..70743a6 100644 --- a/internal/plugin/runner.go +++ b/internal/plugin/runner.go @@ -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" @@ -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") diff --git a/internal/plugin/runner_diffsource_test.go b/internal/plugin/runner_diffsource_test.go new file mode 100644 index 0000000..064502c --- /dev/null +++ b/internal/plugin/runner_diffsource_test.go @@ -0,0 +1,111 @@ +package plugin + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/target/pull-request-code-coverage/internal/test/mocks" +) + +// When PARAMETER_DIFF_SOURCE is unset the runner must behave exactly as before: +// read the diff from the reader it was given (stdin). Covered implicitly by the +// existing golden tests; this asserts the explicit "stdin" value is equivalent. +func TestDefaultRunner_Run_DiffSourceStdin_Explicit(t *testing.T) { + propGetter := mocks.NewMockPropertyGetter() + + propGetter.On("GetProperty", "PARAMETER_COVERAGE_FILE").Return("../test/jacocoTestReport.xml", true) + propGetter.On("GetProperty", "PARAMETER_COVERAGE_TYPE").Return("jacoco", true) + propGetter.On("GetProperty", "PARAMETER_SOURCE_DIRS").Return("src/main/java", true) + propGetter.On("GetProperty", "PARAMETER_MODULE").Return("category-search", true) + propGetter.On("GetProperty", "PARAMETER_DIFF_SOURCE").Return("stdin", true) + + var buf bytes.Buffer + + err := NewRunner().Run(propGetter.GetProperty, MustOpen(t, "../test/sample_unified.diff"), &buf) + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Patch Coverage Report") + + propGetter.AssertExpectations(t) +} + +func TestDefaultRunner_Run_DiffSourceGithub_MissingCreds(t *testing.T) { + propGetter := mocks.NewMockPropertyGetter() + + propGetter.On("GetProperty", "PARAMETER_COVERAGE_FILE").Return("../test/jacocoTestReport.xml", true) + propGetter.On("GetProperty", "PARAMETER_COVERAGE_TYPE").Return("jacoco", true) + propGetter.On("GetProperty", "PARAMETER_SOURCE_DIRS").Return("src/main/java", true) + propGetter.On("GetProperty", "PARAMETER_DIFF_SOURCE").Return("github", true) + + err := NewRunner().Run(propGetter.GetProperty, strings.NewReader(""), os.Stdout) + assert.EqualError(t, err, "PARAMETER_DIFF_SOURCE=github requires a GitHub API key (PARAMETER_GH_API_KEY), BUILD_PULL_REQUEST_NUMBER, REPOSITORY_ORG and REPOSITORY_NAME") + + propGetter.AssertExpectations(t) +} + +func TestDefaultRunner_Run_DiffSourceUnknown(t *testing.T) { + propGetter := mocks.NewMockPropertyGetter() + + propGetter.On("GetProperty", "PARAMETER_COVERAGE_FILE").Return("../test/jacocoTestReport.xml", true) + propGetter.On("GetProperty", "PARAMETER_COVERAGE_TYPE").Return("jacoco", true) + propGetter.On("GetProperty", "PARAMETER_SOURCE_DIRS").Return("src/main/java", true) + propGetter.On("GetProperty", "PARAMETER_DIFF_SOURCE").Return("banana", true) + + err := NewRunner().Run(propGetter.GetProperty, strings.NewReader(""), os.Stdout) + assert.EqualError(t, err, "Unknown PARAMETER_DIFF_SOURCE \"banana\" (expected \"stdin\" or \"github\")") + + propGetter.AssertExpectations(t) +} + +// End-to-end: with PARAMETER_DIFF_SOURCE=github the runner fetches the diff from +// the GitHub API instead of stdin (an empty reader here) and produces the same +// report. The mock server serves the same diff fixture for the PR-diff GET and +// accepts the PR-comment POST, so the output matches the stdin golden exactly. +func TestDefaultRunner_Run_DiffSourceGithub_FetchesDiff(t *testing.T) { + diff, readErr := os.ReadFile("../test/example_go_unified.diff") + assert.NoError(t, readErr) + + var diffRequests int + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/repos/some_org/some_repo/pulls/123" { + diffRequests++ + assert.Equal(t, "application/vnd.github.v3.diff", r.Header.Get("Accept")) + assert.Equal(t, "token SOME_API_KEY", r.Header.Get("Authorization")) + w.WriteHeader(200) + _, _ = w.Write(diff) + return + } + + // The PR-comment POST from the github reporter. + w.WriteHeader(201) + })) + defer ts.Close() + + propGetter := mocks.NewMockPropertyGetter() + + propGetter.On("GetProperty", "PARAMETER_COVERAGE_FILE").Return("../test/example_go_coverage_with_source_dir.xml", true) + propGetter.On("GetProperty", "PARAMETER_COVERAGE_TYPE").Return("cobertura", true) + propGetter.On("GetProperty", "PARAMETER_SOURCE_DIRS").Return("/go/github.com/target/pull-request-code-coverage", true) + propGetter.On("GetProperty", "PARAMETER_GH_API_KEY").Return("SOME_API_KEY", true) + propGetter.On("GetProperty", "PARAMETER_GH_API_BASE_URL").Return(ts.URL, 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) + propGetter.On("GetProperty", "PARAMETER_DIFF_SOURCE").Return("github", true) + + var buf bytes.Buffer + + // stdin is intentionally empty — the diff must come from the GitHub API. + err := NewRunner().Run(propGetter.GetProperty, strings.NewReader(""), &buf) + assert.NoError(t, err) + + assert.Equal(t, 1, diffRequests, "expected exactly one PR-diff fetch") + assert.Equal(t, "──────────────────────────────────────────────────────────────\n 📊 Patch Coverage Report — changed lines only\n──────────────────────────────────────────────────────────────\n\n Diff coverage: 97% 🟢 — 177 of 182 changed instructions covered\n\n Summary\n Covered instructions 97% (177)\n Missed instructions 3% (5)\n Tracked changed lines 8% (182)\n Untracked changed lines 92% (2216)\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 0% 0 cov / 4 miss main.go\n 96% 27 cov / 1 miss internal/plugin/runner.go\n 100% 10 cov / 0 miss internal/plugin/calculator/calculator.go\n 100% 29 cov / 0 miss internal/plugin/coverage/jacoco/report.go\n 100% 19 cov / 0 miss internal/plugin/domain/domain.go\n 100% 25 cov / 0 miss internal/plugin/reporter/reporter.go\n 100% 64 cov / 0 miss internal/plugin/sourcelines/unifieddiff/changed_source_loader.go\n 100% 3 cov / 0 miss internal/test/mocks/property_getter.go\n (25 file(s) with no measurable lines omitted)\n\n Uncovered lines (5)\n - internal/plugin/runner.go:72\n func GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader {\n - main.go:10\n \terr := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)\n - main.go:12\n \tif err != nil {\n - main.go:13\n \t\tlog.WithFields(log.Fields{\n - main.go:17\n \t\tos.Exit(1)\n\n──────────────────────────────────────────────────────────────\n", buf.String()) + + propGetter.AssertExpectations(t) +} diff --git a/internal/plugin/sourcelines/unifieddiff/changed_source_loader.go b/internal/plugin/sourcelines/unifieddiff/changed_source_loader.go index a485761..0302871 100644 --- a/internal/plugin/sourcelines/unifieddiff/changed_source_loader.go +++ b/internal/plugin/sourcelines/unifieddiff/changed_source_loader.go @@ -39,6 +39,7 @@ func (l *Loader) Load(inReader io.Reader) ([]domain.SourceLine, error) { var changedFileLine = regexp.MustCompile("^[+][+][+][ ]b?[/](.*)") var changedLineCounts = regexp.MustCompile("^[@][@][ ][-].*?[ ][+](.*?)[ ][@][@].*") var addedLine = regexp.MustCompile("^[+].*") +var contextLine = regexp.MustCompile("^[ ].*") var emptyStr = "" // nolint: gocyclo @@ -139,6 +140,17 @@ func getChangedLinesFromUnifiedDiff(unifiedDiffLines []string, module string, so Module: *currentModule, }) + currentRelativeLine++ + linesLeftInBlock-- + } else if linesLeftInBlock > 0 && contextLine.MatchString(line) { + + // A context line is unchanged code the diff shows for orientation. We + // don't record it (the PR didn't change it), but it still occupies a + // line in the new file and counts against the hunk's line budget, so + // advance both counters to keep subsequent changed-line numbers + // correct. Diffs produced with --unified=0 (the Vela/stdin path) have + // no context lines, so this branch is inert there; it only matters for + // diffs that carry context, such as those fetched from the GitHub API. currentRelativeLine++ linesLeftInBlock-- } diff --git a/internal/plugin/sourcelines/unifieddiff/changed_source_loader_test.go b/internal/plugin/sourcelines/unifieddiff/changed_source_loader_test.go new file mode 100644 index 0000000..54f6c91 --- /dev/null +++ b/internal/plugin/sourcelines/unifieddiff/changed_source_loader_test.go @@ -0,0 +1,83 @@ +package unifieddiff + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// unified=0 is what the git-diff/stdin path produces: no context lines, every +// hunk line is an addition. This pins the original behavior. +func TestLoad_Unified0_NoContextLines(t *testing.T) { + diff := strings.Join([]string{ + "diff --git a/foo.go b/foo.go", + "--- a/foo.go", + "+++ b/foo.go", + "@@ -10,0 +11,2 @@ func foo() {", + "+\ta := 1", + "+\tb := 2", + }, "\n") + + lines, err := NewChangedSourceLinesLoader("", []string{""}).Load(strings.NewReader(diff)) + + assert.NoError(t, err) + assert.Len(t, lines, 2) + assert.Equal(t, 11, lines[0].LineNumber) + assert.Equal(t, "\ta := 1", lines[0].LineValue) + assert.Equal(t, 12, lines[1].LineNumber) + assert.Equal(t, "\tb := 2", lines[1].LineValue) +} + +// unified=3 is what the GitHub API returns: changed lines surrounded by context +// lines. Only the added lines should be recorded, and their line numbers must +// account for the context lines that precede them. +func TestLoad_Unified3_WithContextLines(t *testing.T) { + diff := strings.Join([]string{ + "diff --git a/foo.go b/foo.go", + "index 1234567..89abcde 100644", + "--- a/foo.go", + "+++ b/foo.go", + "@@ -8,7 +8,8 @@ func foo() {", + " \tline8", + " \tline9", + " \tline10", + "-\told", + "+\tnewA", + "+\tnewB", + " \tline12", + " \tline13", + " \tline14", + }, "\n") + + lines, err := NewChangedSourceLinesLoader("", []string{""}).Load(strings.NewReader(diff)) + + assert.NoError(t, err) + assert.Len(t, lines, 2) + // Hunk starts at new-file line 8; three context lines (8,9,10) precede the + // additions, so the first added line is 11. + assert.Equal(t, 11, lines[0].LineNumber) + assert.Equal(t, "\tnewA", lines[0].LineValue) + assert.Equal(t, 12, lines[1].LineNumber) + assert.Equal(t, "\tnewB", lines[1].LineValue) +} + +// A blank context line is emitted as a single space; it must still be counted so +// later line numbers stay correct. +func TestLoad_Unified3_BlankContextLine(t *testing.T) { + diff := strings.Join([]string{ + "+++ b/foo.go", + "@@ -1,3 +1,4 @@", + " first", + " ", + "+added", + " last", + }, "\n") + + lines, err := NewChangedSourceLinesLoader("", []string{""}).Load(strings.NewReader(diff)) + + assert.NoError(t, err) + assert.Len(t, lines, 1) + assert.Equal(t, 3, lines[0].LineNumber) + assert.Equal(t, "added", lines[0].LineValue) +} diff --git a/internal/test/mocks/property_getter.go b/internal/test/mocks/property_getter.go index 708966b..da646b2 100644 --- a/internal/test/mocks/property_getter.go +++ b/internal/test/mocks/property_getter.go @@ -11,6 +11,30 @@ func NewMockPropertyGetter() *MockPropertyGetter { } func (m *MockPropertyGetter) GetProperty(s string) (string, bool) { + // Properties a test did not explicitly stub resolve to ("", false), the same + // as os.LookupEnv for an unset variable. This keeps each test focused on the + // properties it cares about and means looking up a newer optional property + // (e.g. PARAMETER_DIFF_SOURCE) does not panic in tests written before it + // existed. Stubbed properties still go through testify so AssertExpectations + // continues to verify them. + if !m.hasExpectedCall(s) { + return "", false + } + args := m.Called(s) return args.Get(0).(string), args.Bool(1) } + +func (m *MockPropertyGetter) hasExpectedCall(s string) bool { + for _, c := range m.ExpectedCalls { + if c.Method != "GetProperty" || len(c.Arguments) != 1 { + continue + } + + if name, ok := c.Arguments[0].(string); ok && name == s { + return true + } + } + + return false +} From 94152fdc8e4ecf63dcf7551f080ada21cf3f452b Mon Sep 17 00:00:00 2001 From: Vishal Vaibhav Date: Fri, 19 Jun 2026 12:06:25 +0530 Subject: [PATCH 2/3] feat: move-diff-logic-within-the-plugin Drop the Vela start.sh entrypoint and run the plugin binary directly (ENTRYPOINT ["/plugin"]); the diff is now produced in-plugin (PARAMETER_DIFF_SOURCE=github) or piped on stdin. Also run the coverage job on fork PRs, tolerating the read-only-token comment 403 via continue-on-error. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pr-coverage.yml | 13 +++++++++---- Dockerfile | 6 ++++-- scripts/start.sh | 30 ------------------------------ 3 files changed, 13 insertions(+), 36 deletions(-) delete mode 100755 scripts/start.sh diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index 7cfc12b..f3ecb15 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -14,9 +14,10 @@ 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 @@ -45,6 +46,11 @@ 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 @@ -73,5 +79,4 @@ jobs: -e REPOSITORY_NAME \ -v "${{ github.workspace }}:${{ github.workspace }}" \ -w "${{ github.workspace }}" \ - --entrypoint /plugin \ pr-code-coverage:ci diff --git a/Dockerfile b/Dockerfile index 5214a90..40c8679 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +# 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"] \ No newline at end of file diff --git a/scripts/start.sh b/scripts/start.sh deleted file mode 100755 index d87678a..0000000 --- a/scripts/start.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - - - -if [[ ! -f ~/.netrc ]] -then - echo "~/.netrc does not exist, creating..." - - cat >~/.netrc < Date: Fri, 19 Jun 2026 12:36:07 +0530 Subject: [PATCH 3/3] feat: sticky-comment-and-job-summary Add two reporting features that share the report markdown: - Sticky PR comment: github_pr.go now lists the PR's comments and PATCHes the one carrying a hidden marker instead of POSTing a new comment on every push. Adds Unmarshal to the json client. - Job summary: a StepSummary reporter writes the report to $GITHUB_STEP_SUMMARY when set, so coverage shows on the Actions run page even on fork PRs that can't be commented on. Wired into the workflow by mounting and passing the summary file. Extracts the shared buildMarkdownReport used by both the comment and the summary, and updates README/AGENTS docs and tests. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pr-coverage.yml | 8 +- AGENTS.md | 19 ++- README.md | 3 + internal/plugin/pluginjson/client.go | 5 + internal/plugin/pluginjson/mocks.go | 6 + internal/plugin/reporter/github_pr.go | 121 +++++++++++++++--- internal/plugin/reporter/github_pr_test.go | 114 ++++++++--------- internal/plugin/reporter/step_summary.go | 38 ++++++ internal/plugin/reporter/step_summary_test.go | 43 +++++++ internal/plugin/runner.go | 18 +++ internal/plugin/runner_diffsource_test.go | 8 ++ internal/plugin/runner_test.go | 16 +-- internal/test/mocks/mock_gh_api.go | 14 +- 13 files changed, 321 insertions(+), 92 deletions(-) create mode 100644 internal/plugin/reporter/step_summary.go create mode 100644 internal/plugin/reporter/step_summary_test.go diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index f3ecb15..dc2978a 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -65,8 +65,10 @@ 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. + # 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: | docker run --rm \ -e PARAMETER_DIFF_SOURCE \ @@ -77,6 +79,8 @@ jobs: -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 diff --git a/AGENTS.md b/AGENTS.md index 8d49758..41e660a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,9 +58,10 @@ 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: @@ -76,9 +77,12 @@ Key packages: `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). @@ -88,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. diff --git a/README.md b/README.md index 4c14382..edb1899 100644 --- a/README.md +++ b/README.md @@ -346,8 +346,11 @@ docker run --rm \ | `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). --- diff --git a/internal/plugin/pluginjson/client.go b/internal/plugin/pluginjson/client.go index 27efa5b..1990b1b 100644 --- a/internal/plugin/pluginjson/client.go +++ b/internal/plugin/pluginjson/client.go @@ -4,6 +4,7 @@ import "encoding/json" type Client interface { Marshal(data interface{}) ([]byte, error) + Unmarshal(data []byte, v interface{}) error } type DefaultClient struct{} @@ -11,3 +12,7 @@ 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) +} diff --git a/internal/plugin/pluginjson/mocks.go b/internal/plugin/pluginjson/mocks.go index 6ef6c1e..ca5132b 100644 --- a/internal/plugin/pluginjson/mocks.go +++ b/internal/plugin/pluginjson/mocks.go @@ -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) +} diff --git a/internal/plugin/reporter/github_pr.go b/internal/plugin/reporter/github_pr.go index 6633aab..22b82b0 100644 --- a/internal/plugin/reporter/github_pr.go +++ b/internal/plugin/reporter/github_pr.go @@ -24,9 +24,15 @@ type GithubPullRequest struct { } const ( + HTTPResponseOK = 200 HTTPResponseCreated = 201 ) +// commentMarker is an HTML comment embedded at the top of the report. It renders +// invisibly on GitHub but lets a later run find the comment it posted earlier so +// it can update that one in place instead of posting a new comment every push. +const commentMarker = "" + func NewGithubPullRequest(apiKey string, apiBaseURL string, pr string, owner string, repo string, httpClient pluginhttp.Client, jsonClient pluginjson.Client) *GithubPullRequest { return &GithubPullRequest{ apiKey: apiKey, @@ -51,12 +57,32 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove return errors.Wrap(bodyErr, "Failed creating payload for github") } - url := fmt.Sprintf("%v/repos/%v/%v/issues/%v/comments", strings.TrimRight(s.apiBaseURL, "/"), s.owner, s.repo, s.pr) + existingID, findErr := s.findExistingCommentID() + if findErr != nil { + return findErr + } + + // Update the comment from a previous run when we find one; otherwise post a + // fresh comment. This keeps a single, always-current coverage comment on the + // PR instead of a new one per push. + if existingID != 0 { + url := fmt.Sprintf("%v/repos/%v/%v/issues/comments/%v", s.baseURL(), s.owner, s.repo, existingID) + return s.send("PATCH", url, body, HTTPResponseOK) + } + + url := fmt.Sprintf("%v/repos/%v/%v/issues/%v/comments", s.baseURL(), s.owner, s.repo, s.pr) + return s.send("POST", url, body, HTTPResponseCreated) +} + +// baseURL returns the configured GitHub API root without a trailing slash. +func (s *GithubPullRequest) baseURL() string { + return strings.TrimRight(s.apiBaseURL, "/") +} - req, newErr := s.httpClient.NewRequest( - "POST", - url, - body) +// send issues a write request (POST/PATCH) carrying the comment payload and +// verifies the response status. +func (s *GithubPullRequest) send(method string, url string, body io.Reader, wantStatus int) error { + req, newErr := s.httpClient.NewRequest(method, url, body) if newErr != nil { return errors.Wrap(newErr, "Failed creating request to github") @@ -75,19 +101,89 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove _ = resp.Body.Close() }() - if resp.StatusCode != HTTPResponseCreated { + if resp.StatusCode != wantStatus { return errors.Errorf("Failed calling github: bad status code: %v", resp.StatusCode) } return nil } +// findExistingCommentID looks for a coverage comment this plugin posted on an +// earlier run, identified by the hidden commentMarker. It returns 0 when none is +// found. Only the first page of comments is checked (per_page=100), which covers +// any realistic PR. The GET only needs read access, so it also works on fork PRs +// even though the follow-up write may not. +func (s *GithubPullRequest) findExistingCommentID() (int64, error) { + url := fmt.Sprintf("%v/repos/%v/%v/issues/%v/comments?per_page=100", s.baseURL(), s.owner, s.repo, s.pr) + + req, newErr := s.httpClient.NewRequest("GET", url, nil) + if newErr != nil { + return 0, errors.Wrap(newErr, "Failed creating request to github") + } + + req.Header.Add("Authorization", "token "+s.apiKey) + + resp, doErr := s.httpClient.Do(req) + if doErr != nil { + return 0, errors.Wrap(doErr, "Failed calling github") + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != HTTPResponseOK { + return 0, errors.Errorf("Failed listing github comments: bad status code: %v", resp.StatusCode) + } + + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return 0, errors.Wrap(readErr, "Failed reading github comments response") + } + + var comments []struct { + ID int64 `json:"id"` + Body string `json:"body"` + } + + if unmarshalErr := s.jsonClient.Unmarshal(respBody, &comments); unmarshalErr != nil { + return 0, errors.Wrap(unmarshalErr, "Failed parsing github comments response") + } + + for _, c := range comments { + if strings.Contains(c.Body, commentMarker) { + return c.ID, nil + } + } + + return 0, nil +} + func (s *GithubPullRequest) GetName() string { return "github pull request reporter" } func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.SourceLineCoverageReport) (io.Reader, error) { + data := map[string]string{ + "body": buildMarkdownReport(changedLinesWithCoverage), + } + + dataBytes, marshalErr := s.jsonClient.Marshal(data) + + if marshalErr != nil { + return nil, errors.Wrap(marshalErr, "Failed marshalling payload to json") + } + + return bytes.NewBuffer(dataBytes), nil +} + +// buildMarkdownReport renders the changed-line coverage report as GitHub-flavored +// Markdown. It is shared by the PR-comment reporter and the job-summary reporter +// so both show identical output. The leading commentMarker is invisible when +// rendered and lets the PR reporter find and update its own comment. +func buildMarkdownReport(changedLinesWithCoverage domain.SourceLineCoverageReport) string { + modules := collectModules(changedLinesWithCoverage) covered := changedLinesWithCoverage.TotalCoveredInstructions() @@ -105,6 +201,7 @@ func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.So var b strings.Builder + b.WriteString(commentMarker + "\n") b.WriteString("## 🛡️ Patch Coverage Report\n\n") b.WriteString("> Scope: **changed lines only** — the code this PR adds or edits, not whole files or the repo. ") b.WriteString("It answers one thing — *did your tests run the code you just touched?*\n\n") @@ -129,17 +226,7 @@ func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.So b.WriteString(missedInstructionsSection(changedLinesWithCoverage)) b.WriteString("\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n") - data := map[string]string{ - "body": b.String(), - } - - dataBytes, marshalErr := s.jsonClient.Marshal(data) - - if marshalErr != nil { - return nil, errors.Wrap(marshalErr, "Failed marshalling payload to json") - } - - return bytes.NewBuffer(dataBytes), nil + return b.String() } // fileCoverage holds the aggregated changed-line coverage for a single file. diff --git a/internal/plugin/reporter/github_pr_test.go b/internal/plugin/reporter/github_pr_test.go index be239c1..8d25e1a 100644 --- a/internal/plugin/reporter/github_pr_test.go +++ b/internal/plugin/reporter/github_pr_test.go @@ -15,6 +15,16 @@ import ( "github.com/target/pull-request-code-coverage/internal/plugin/pluginjson" ) +func sampleReport() domain.SourceLineCoverageReport { + return domain.SourceLineCoverageReport{ + domain.SourceLineCoverage{ + CoverageData: domain.CoverageData{ + CoveredInstructionCount: 1, + }, + }, + } +} + func TestGithubPullRequest_Write_FailedNewRequest(t *testing.T) { mockClient := &pluginhttp.MockClient{} @@ -26,13 +36,7 @@ func TestGithubPullRequest_Write_FailedNewRequest(t *testing.T) { jsonClient: &pluginjson.DefaultClient{}, } - e := writer.Write(domain.SourceLineCoverageReport{ - domain.SourceLineCoverage{ - CoverageData: domain.CoverageData{ - CoveredInstructionCount: 1, - }, - }, - }) + e := writer.Write(sampleReport()) assert.EqualError(t, e, "Failed creating request to github: something bad happened") } @@ -51,59 +55,63 @@ func TestGithubPullRequest_Write_FailedDo(t *testing.T) { jsonClient: &pluginjson.DefaultClient{}, } - e := writer.Write(domain.SourceLineCoverageReport{ - domain.SourceLineCoverage{ - CoverageData: domain.CoverageData{ - CoveredInstructionCount: 1, - }, - }, - }) + e := writer.Write(sampleReport()) assert.EqualError(t, e, "Failed calling github: something bad happened") } -func TestGithubPullRequest_Write_FailedDo_BadStatus(t *testing.T) { +func TestGithubPullRequest_Write_FailedListingComments_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("NewRequest", "GET", mock.Anything, mock.Anything).Return(request, nil) mockClient.On("Do", request).Return(&http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader(""))}, nil) - writer := &GithubPullRequest{ - apiBaseURL: "anything", - httpClient: mockClient, - jsonClient: &pluginjson.DefaultClient{}, - } + writer := NewGithubPullRequest("KEY", "https://api.github.com", "42", "some_owner", "some_repo", mockClient, &pluginjson.DefaultClient{}) - e := writer.Write(domain.SourceLineCoverageReport{ - domain.SourceLineCoverage{ - CoverageData: domain.CoverageData{ - CoveredInstructionCount: 1, - }, - }, - }) + e := writer.Write(sampleReport()) - assert.EqualError(t, e, "Failed calling github: bad status code: 400") + assert.EqualError(t, e, "Failed listing github comments: bad status code: 400") } -func TestGithubPullRequest_Write_BuildsPublicGithubURL(t *testing.T) { +func TestGithubPullRequest_Write_CreatesCommentWhenNoneExists(t *testing.T) { mockClient := &pluginhttp.MockClient{} - request := httptest.NewRequest("POST", "http://anywhere", nil) + listReq := httptest.NewRequest("GET", "http://list", nil) + postReq := httptest.NewRequest("POST", "http://create", nil) - mockClient.On("NewRequest", "POST", "https://api.github.com/repos/some_owner/some_repo/issues/42/comments", mock.Anything).Return(request, nil) - mockClient.On("Do", request).Return(&http.Response{StatusCode: 201, Body: io.NopCloser(strings.NewReader(""))}, nil) + mockClient.On("NewRequest", "GET", "https://api.github.com/repos/some_owner/some_repo/issues/42/comments?per_page=100", mock.Anything).Return(listReq, nil) + mockClient.On("Do", listReq).Return(&http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("[]"))}, nil) + + mockClient.On("NewRequest", "POST", "https://api.github.com/repos/some_owner/some_repo/issues/42/comments", mock.Anything).Return(postReq, nil) + mockClient.On("Do", postReq).Return(&http.Response{StatusCode: 201, Body: io.NopCloser(strings.NewReader(""))}, nil) writer := NewGithubPullRequest("KEY", "https://api.github.com", "42", "some_owner", "some_repo", mockClient, &pluginjson.DefaultClient{}) - e := writer.Write(domain.SourceLineCoverageReport{ - domain.SourceLineCoverage{ - CoverageData: domain.CoverageData{ - CoveredInstructionCount: 1, - }, - }, - }) + e := writer.Write(sampleReport()) + + assert.NoError(t, e) + mockClient.AssertExpectations(t) +} + +func TestGithubPullRequest_Write_UpdatesExistingComment(t *testing.T) { + + mockClient := &pluginhttp.MockClient{} + listReq := httptest.NewRequest("GET", "http://list", nil) + patchReq := httptest.NewRequest("PATCH", "http://update", nil) + + existing := `[{"id": 7, "body": "stale"}, {"id": 99, "body": "old report ` + commentMarker + ` here"}]` + + mockClient.On("NewRequest", "GET", "https://api.github.com/repos/some_owner/some_repo/issues/42/comments?per_page=100", mock.Anything).Return(listReq, nil) + mockClient.On("Do", listReq).Return(&http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(existing))}, nil) + + mockClient.On("NewRequest", "PATCH", "https://api.github.com/repos/some_owner/some_repo/issues/comments/99", mock.Anything).Return(patchReq, nil) + mockClient.On("Do", patchReq).Return(&http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil) + + writer := NewGithubPullRequest("KEY", "https://api.github.com", "42", "some_owner", "some_repo", mockClient, &pluginjson.DefaultClient{}) + + e := writer.Write(sampleReport()) assert.NoError(t, e) mockClient.AssertExpectations(t) @@ -112,20 +120,18 @@ func TestGithubPullRequest_Write_BuildsPublicGithubURL(t *testing.T) { func TestGithubPullRequest_Write_TrimsTrailingSlashFromEnterpriseURL(t *testing.T) { mockClient := &pluginhttp.MockClient{} - request := httptest.NewRequest("POST", "http://anywhere", nil) + listReq := httptest.NewRequest("GET", "http://list", nil) + postReq := httptest.NewRequest("POST", "http://create", nil) - mockClient.On("NewRequest", "POST", "https://git.target.com/api/v3/repos/some_owner/some_repo/issues/42/comments", mock.Anything).Return(request, nil) - mockClient.On("Do", request).Return(&http.Response{StatusCode: 201, Body: io.NopCloser(strings.NewReader(""))}, nil) + mockClient.On("NewRequest", "GET", "https://git.target.com/api/v3/repos/some_owner/some_repo/issues/42/comments?per_page=100", mock.Anything).Return(listReq, nil) + mockClient.On("Do", listReq).Return(&http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("[]"))}, nil) + + mockClient.On("NewRequest", "POST", "https://git.target.com/api/v3/repos/some_owner/some_repo/issues/42/comments", mock.Anything).Return(postReq, nil) + mockClient.On("Do", postReq).Return(&http.Response{StatusCode: 201, Body: io.NopCloser(strings.NewReader(""))}, nil) writer := NewGithubPullRequest("KEY", "https://git.target.com/api/v3/", "42", "some_owner", "some_repo", mockClient, &pluginjson.DefaultClient{}) - e := writer.Write(domain.SourceLineCoverageReport{ - domain.SourceLineCoverage{ - CoverageData: domain.CoverageData{ - CoveredInstructionCount: 1, - }, - }, - }) + e := writer.Write(sampleReport()) assert.NoError(t, e) mockClient.AssertExpectations(t) @@ -143,13 +149,7 @@ func TestGithubPullRequest_Write_FailedJsonMarshal(t *testing.T) { jsonClient: mockClient, } - e := writer.Write(domain.SourceLineCoverageReport{ - domain.SourceLineCoverage{ - CoverageData: domain.CoverageData{ - CoveredInstructionCount: 1, - }, - }, - }) + e := writer.Write(sampleReport()) assert.EqualError(t, e, "Failed creating payload for github: Failed marshalling payload to json: something bad happened") } diff --git a/internal/plugin/reporter/step_summary.go b/internal/plugin/reporter/step_summary.go new file mode 100644 index 0000000..a138a4b --- /dev/null +++ b/internal/plugin/reporter/step_summary.go @@ -0,0 +1,38 @@ +package reporter + +import ( + "io" + + "github.com/pkg/errors" + "github.com/target/pull-request-code-coverage/internal/plugin/domain" +) + +// StepSummary writes the Markdown coverage report to a writer backed by the +// GitHub Actions job summary file ($GITHUB_STEP_SUMMARY). The summary shows on +// the workflow run page, so coverage is visible even on fork PRs where the +// GITHUB_TOKEN is read-only and the PR comment cannot be posted. +type StepSummary struct { + out io.Writer +} + +func NewStepSummary(out io.Writer) *StepSummary { + return &StepSummary{ + out: out, + } +} + +func (s *StepSummary) Write(changedLinesWithCoverage domain.SourceLineCoverageReport) error { + if changedLinesWithCoverage.TotalLinesWithData() == 0 { + return nil + } + + if _, err := io.WriteString(s.out, buildMarkdownReport(changedLinesWithCoverage)); err != nil { + return errors.Wrap(err, "Failed writing job summary") + } + + return nil +} + +func (s *StepSummary) GetName() string { + return "github step summary reporter" +} diff --git a/internal/plugin/reporter/step_summary_test.go b/internal/plugin/reporter/step_summary_test.go new file mode 100644 index 0000000..f0a70e2 --- /dev/null +++ b/internal/plugin/reporter/step_summary_test.go @@ -0,0 +1,43 @@ +package reporter + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/target/pull-request-code-coverage/internal/plugin/domain" +) + +func TestStepSummary_Write_RendersMarkdownReport(t *testing.T) { + + var buf bytes.Buffer + + e := NewStepSummary(&buf).Write(domain.SourceLineCoverageReport{ + domain.SourceLineCoverage{ + CoverageData: domain.CoverageData{ + CoveredInstructionCount: 1, + }, + }, + }) + + assert.NoError(t, e) + + out := buf.String() + assert.Contains(t, out, commentMarker) + assert.Contains(t, out, "Patch Coverage Report") +} + +func TestStepSummary_Write_NoDataWritesNothing(t *testing.T) { + + var buf bytes.Buffer + + e := NewStepSummary(&buf).Write(domain.SourceLineCoverageReport{}) + + assert.NoError(t, e) + assert.Empty(t, buf.String()) +} + +func TestStepSummary_GetName(t *testing.T) { + assert.Equal(t, "github step summary reporter", NewStepSummary(&strings.Builder{}).GetName()) +} diff --git a/internal/plugin/runner.go b/internal/plugin/runner.go index 70743a6..023561b 100644 --- a/internal/plugin/runner.go +++ b/internal/plugin/runner.go @@ -3,6 +3,7 @@ package plugin import ( "fmt" "io" + "os" "strconv" "strings" @@ -153,6 +154,23 @@ func (*DefaultRunner) Run(propertyGetter func(string) (string, bool), changedSou if ghAPIKeyFound && repoPRFound && repoOwnerFound && repoNameFound { reporters = append(reporters, reporter.NewGithubPullRequest(ghAPIKey, ghAPIBaseURL, repoPR, repoOwner, repoName, &pluginhttp.DefaultClient{}, &pluginjson.DefaultClient{})) } + + // GitHub Actions sets GITHUB_STEP_SUMMARY to a file whose Markdown is rendered + // on the run's summary page. Writing there surfaces coverage even when no PR + // comment can be posted (e.g. fork PRs with a read-only token). + if summaryPath, found := propertyGetter("GITHUB_STEP_SUMMARY"); found && summaryPath != "" { + // nolint: gosec // path comes from the trusted GitHub Actions runner env + summaryFile, openErr := os.OpenFile(summaryPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if openErr != nil { + return errors.Wrap(openErr, "Failed opening GITHUB_STEP_SUMMARY file") + } + defer func() { + _ = summaryFile.Close() + }() + + reporters = append(reporters, reporter.NewStepSummary(summaryFile)) + } + logrus.Info("enabled reporters are ") for _, eachOne := range reporters { logrus.Info(eachOne.GetName()) diff --git a/internal/plugin/runner_diffsource_test.go b/internal/plugin/runner_diffsource_test.go index 064502c..aa79324 100644 --- a/internal/plugin/runner_diffsource_test.go +++ b/internal/plugin/runner_diffsource_test.go @@ -81,6 +81,14 @@ func TestDefaultRunner_Run_DiffSourceGithub_FetchesDiff(t *testing.T) { return } + // The sticky-comment reporter lists existing comments first; no prior + // comment exists, so it then POSTs a new one. + if r.Method == http.MethodGet && r.URL.Path == "/repos/some_org/some_repo/issues/123/comments" { + w.WriteHeader(200) + _, _ = w.Write([]byte("[]")) + return + } + // The PR-comment POST from the github reporter. w.WriteHeader(201) })) diff --git a/internal/plugin/runner_test.go b/internal/plugin/runner_test.go index 917934c..98e15dd 100644 --- a/internal/plugin/runner_test.go +++ b/internal/plugin/runner_test.go @@ -91,7 +91,7 @@ func TestDefaultRunner_Run_GoExample_WithSourceDir(t *testing.T) { assert.Equal(t, "──────────────────────────────────────────────────────────────\n 📊 Patch Coverage Report — changed lines only\n──────────────────────────────────────────────────────────────\n\n Diff coverage: 97% 🟢 — 177 of 182 changed instructions covered\n\n Summary\n Covered instructions 97% (177)\n Missed instructions 3% (5)\n Tracked changed lines 8% (182)\n Untracked changed lines 92% (2216)\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 0% 0 cov / 4 miss main.go\n 96% 27 cov / 1 miss internal/plugin/runner.go\n 100% 10 cov / 0 miss internal/plugin/calculator/calculator.go\n 100% 29 cov / 0 miss internal/plugin/coverage/jacoco/report.go\n 100% 19 cov / 0 miss internal/plugin/domain/domain.go\n 100% 25 cov / 0 miss internal/plugin/reporter/reporter.go\n 100% 64 cov / 0 miss internal/plugin/sourcelines/unifieddiff/changed_source_loader.go\n 100% 3 cov / 0 miss internal/test/mocks/property_getter.go\n (25 file(s) with no measurable lines omitted)\n\n Uncovered lines (5)\n - internal/plugin/runner.go:72\n func GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader {\n - main.go:10\n \terr := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)\n - main.go:12\n \tif err != nil {\n - main.go:13\n \t\tlog.WithFields(log.Fields{\n - main.go:17\n \t\tos.Exit(1)\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:** `97%` 🟢 — `177` of `182` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `177` (97%) | changed code your tests executed |\n| 🔴 Missed instructions | `5` (3%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `182` (8%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `2216` (92%) | 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| `main.go` | 🔴 0% | 0 / 4 |\n| `internal/plugin/runner.go` | 🟢 96% | 27 / 1 |\n| `internal/plugin/calculator/calculator.go` | 🟢 100% | 10 / 0 |\n| `internal/plugin/coverage/jacoco/report.go` | 🟢 100% | 29 / 0 |\n| `internal/plugin/domain/domain.go` | 🟢 100% | 19 / 0 |\n| `internal/plugin/reporter/reporter.go` | 🟢 100% | 25 / 0 |\n| `internal/plugin/sourcelines/unifieddiff/changed_source_loader.go` | 🟢 100% | 64 / 0 |\n| `internal/test/mocks/property_getter.go` | 🟢 100% | 3 / 0 |\n\n25 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (5)\n\n```\n--- internal/plugin/runner.go:72\nfunc GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader {\n--- main.go:10\n\terr := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)\n--- main.go:12\n\tif err != nil {\n--- main.go:13\n\t\tlog.WithFields(log.Fields{\n--- main.go:17\n\t\tos.Exit(1)\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", + "body": "\n## 🛡️ 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:** `97%` 🟢 — `177` of `182` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `177` (97%) | changed code your tests executed |\n| 🔴 Missed instructions | `5` (3%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `182` (8%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `2216` (92%) | 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| `main.go` | 🔴 0% | 0 / 4 |\n| `internal/plugin/runner.go` | 🟢 96% | 27 / 1 |\n| `internal/plugin/calculator/calculator.go` | 🟢 100% | 10 / 0 |\n| `internal/plugin/coverage/jacoco/report.go` | 🟢 100% | 29 / 0 |\n| `internal/plugin/domain/domain.go` | 🟢 100% | 19 / 0 |\n| `internal/plugin/reporter/reporter.go` | 🟢 100% | 25 / 0 |\n| `internal/plugin/sourcelines/unifieddiff/changed_source_loader.go` | 🟢 100% | 64 / 0 |\n| `internal/test/mocks/property_getter.go` | 🟢 100% | 3 / 0 |\n\n25 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (5)\n\n```\n--- internal/plugin/runner.go:72\nfunc GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader {\n--- main.go:10\n\terr := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)\n--- main.go:12\n\tif err != nil {\n--- main.go:13\n\t\tlog.WithFields(log.Fields{\n--- main.go:17\n\t\tos.Exit(1)\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", }) propGetter.AssertExpectations(t) @@ -124,7 +124,7 @@ func TestDefaultRunner_Run_GoExample(t *testing.T) { assert.Equal(t, "──────────────────────────────────────────────────────────────\n 📊 Patch Coverage Report — changed lines only\n──────────────────────────────────────────────────────────────\n\n Diff coverage: 97% 🟢 — 177 of 182 changed instructions covered\n\n Summary\n Covered instructions 97% (177)\n Missed instructions 3% (5)\n Tracked changed lines 8% (182)\n Untracked changed lines 92% (2216)\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 0% 0 cov / 4 miss main.go\n 96% 27 cov / 1 miss internal/plugin/runner.go\n 100% 10 cov / 0 miss internal/plugin/calculator/calculator.go\n 100% 29 cov / 0 miss internal/plugin/coverage/jacoco/report.go\n 100% 19 cov / 0 miss internal/plugin/domain/domain.go\n 100% 25 cov / 0 miss internal/plugin/reporter/reporter.go\n 100% 64 cov / 0 miss internal/plugin/sourcelines/unifieddiff/changed_source_loader.go\n 100% 3 cov / 0 miss internal/test/mocks/property_getter.go\n (25 file(s) with no measurable lines omitted)\n\n Uncovered lines (5)\n - internal/plugin/runner.go:72\n func GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader {\n - main.go:10\n \terr := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)\n - main.go:12\n \tif err != nil {\n - main.go:13\n \t\tlog.WithFields(log.Fields{\n - main.go:17\n \t\tos.Exit(1)\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:** `97%` 🟢 — `177` of `182` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `177` (97%) | changed code your tests executed |\n| 🔴 Missed instructions | `5` (3%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `182` (8%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `2216` (92%) | 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| `main.go` | 🔴 0% | 0 / 4 |\n| `internal/plugin/runner.go` | 🟢 96% | 27 / 1 |\n| `internal/plugin/calculator/calculator.go` | 🟢 100% | 10 / 0 |\n| `internal/plugin/coverage/jacoco/report.go` | 🟢 100% | 29 / 0 |\n| `internal/plugin/domain/domain.go` | 🟢 100% | 19 / 0 |\n| `internal/plugin/reporter/reporter.go` | 🟢 100% | 25 / 0 |\n| `internal/plugin/sourcelines/unifieddiff/changed_source_loader.go` | 🟢 100% | 64 / 0 |\n| `internal/test/mocks/property_getter.go` | 🟢 100% | 3 / 0 |\n\n25 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (5)\n\n```\n--- internal/plugin/runner.go:72\nfunc GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader {\n--- main.go:10\n\terr := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)\n--- main.go:12\n\tif err != nil {\n--- main.go:13\n\t\tlog.WithFields(log.Fields{\n--- main.go:17\n\t\tos.Exit(1)\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", + "body": "\n## 🛡️ 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:** `97%` 🟢 — `177` of `182` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `177` (97%) | changed code your tests executed |\n| 🔴 Missed instructions | `5` (3%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `182` (8%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `2216` (92%) | 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| `main.go` | 🔴 0% | 0 / 4 |\n| `internal/plugin/runner.go` | 🟢 96% | 27 / 1 |\n| `internal/plugin/calculator/calculator.go` | 🟢 100% | 10 / 0 |\n| `internal/plugin/coverage/jacoco/report.go` | 🟢 100% | 29 / 0 |\n| `internal/plugin/domain/domain.go` | 🟢 100% | 19 / 0 |\n| `internal/plugin/reporter/reporter.go` | 🟢 100% | 25 / 0 |\n| `internal/plugin/sourcelines/unifieddiff/changed_source_loader.go` | 🟢 100% | 64 / 0 |\n| `internal/test/mocks/property_getter.go` | 🟢 100% | 3 / 0 |\n\n25 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (5)\n\n```\n--- internal/plugin/runner.go:72\nfunc GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader {\n--- main.go:10\n\terr := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)\n--- main.go:12\n\tif err != nil {\n--- main.go:13\n\t\tlog.WithFields(log.Fields{\n--- main.go:17\n\t\tos.Exit(1)\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", }) propGetter.AssertExpectations(t) @@ -155,7 +155,7 @@ func TestDefaultRunner_Run_PythonExample(t *testing.T) { 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", + "body": "\n## 🛡️ 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) @@ -186,7 +186,7 @@ func TestDefaultRunner_Run_LcovExample(t *testing.T) { 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", + "body": "\n## 🛡️ 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) @@ -217,7 +217,7 @@ func TestDefaultRunner_Run(t *testing.T) { assert.Equal(t, "──────────────────────────────────────────────────────────────\n 📊 Patch Coverage Report — changed lines only\n──────────────────────────────────────────────────────────────\n Modules: category-search\n\n Diff coverage: 73% 🟡 — 8 of 11 changed instructions covered\n\n Summary\n Covered instructions 73% (8)\n Missed instructions 27% (3)\n Tracked changed lines 22% (2)\n Untracked changed lines 78% (7)\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 73% 8 cov / 3 miss category-search/src/main/java/com/tgt/CategorySearchApplication.java\n (3 file(s) with no measurable lines omitted)\n\n Uncovered lines (1)\n - category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\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*Modules:* category-search\n\n**Diff coverage:** `73%` 🟡 — `8` of `11` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `8` (73%) | changed code your tests executed |\n| 🔴 Missed instructions | `3` (27%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `2` (22%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `7` (78%) | 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| `category-search/src/main/java/com/tgt/CategorySearchApplication.java` | 🟡 73% | 8 / 3 |\n\n3 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (1)\n\n```\n--- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", + "body": "\n## 🛡️ 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*Modules:* category-search\n\n**Diff coverage:** `73%` 🟡 — `8` of `11` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `8` (73%) | changed code your tests executed |\n| 🔴 Missed instructions | `3` (27%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `2` (22%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `7` (78%) | 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| `category-search/src/main/java/com/tgt/CategorySearchApplication.java` | 🟡 73% | 8 / 3 |\n\n3 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (1)\n\n```\n--- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", }) propGetter.AssertExpectations(t) @@ -249,7 +249,7 @@ func TestDefaultRunner_Run_Vela(t *testing.T) { assert.Equal(t, "──────────────────────────────────────────────────────────────\n 📊 Patch Coverage Report — changed lines only\n──────────────────────────────────────────────────────────────\n Modules: category-search\n\n Diff coverage: 73% 🟡 — 8 of 11 changed instructions covered\n\n Summary\n Covered instructions 73% (8)\n Missed instructions 27% (3)\n Tracked changed lines 22% (2)\n Untracked changed lines 78% (7)\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 73% 8 cov / 3 miss category-search/src/main/java/com/tgt/CategorySearchApplication.java\n (3 file(s) with no measurable lines omitted)\n\n Uncovered lines (1)\n - category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\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*Modules:* category-search\n\n**Diff coverage:** `73%` 🟡 — `8` of `11` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `8` (73%) | changed code your tests executed |\n| 🔴 Missed instructions | `3` (27%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `2` (22%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `7` (78%) | 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| `category-search/src/main/java/com/tgt/CategorySearchApplication.java` | 🟡 73% | 8 / 3 |\n\n3 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (1)\n\n```\n--- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", + "body": "\n## 🛡️ 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*Modules:* category-search\n\n**Diff coverage:** `73%` 🟡 — `8` of `11` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `8` (73%) | changed code your tests executed |\n| 🔴 Missed instructions | `3` (27%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `2` (22%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `7` (78%) | 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| `category-search/src/main/java/com/tgt/CategorySearchApplication.java` | 🟡 73% | 8 / 3 |\n\n3 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (1)\n\n```\n--- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", }) propGetter.AssertExpectations(t) @@ -281,7 +281,7 @@ func TestDefaultRunner_Run_2_Source_Dirs(t *testing.T) { assert.Equal(t, "──────────────────────────────────────────────────────────────\n 📊 Patch Coverage Report — changed lines only\n──────────────────────────────────────────────────────────────\n Modules: category-search\n\n Diff coverage: 88% 🟢 — 42 of 48 changed instructions covered\n\n Summary\n Covered instructions 88% (42)\n Missed instructions 12% (6)\n Tracked changed lines 53% (8)\n Untracked changed lines 47% (7)\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 73% 8 cov / 3 miss category-search/src/main/java/com/tgt/CategorySearchApplication.java\n 92% 34 cov / 3 miss category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt\n (3 file(s) with no measurable lines omitted)\n\n Uncovered lines (2)\n - category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n - category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12\n System.out.print(\"Something2\");\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*Modules:* category-search\n\n**Diff coverage:** `88%` 🟢 — `42` of `48` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `42` (88%) | changed code your tests executed |\n| 🔴 Missed instructions | `6` (12%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `8` (53%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `7` (47%) | 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| `category-search/src/main/java/com/tgt/CategorySearchApplication.java` | 🟡 73% | 8 / 3 |\n| `category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt` | 🟢 92% | 34 / 3 |\n\n3 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (2)\n\n```\n--- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n--- category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12\n System.out.print(\"Something2\");\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", + "body": "\n## 🛡️ 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*Modules:* category-search\n\n**Diff coverage:** `88%` 🟢 — `42` of `48` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `42` (88%) | changed code your tests executed |\n| 🔴 Missed instructions | `6` (12%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `8` (53%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `7` (47%) | 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| `category-search/src/main/java/com/tgt/CategorySearchApplication.java` | 🟡 73% | 8 / 3 |\n| `category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt` | 🟢 92% | 34 / 3 |\n\n3 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (2)\n\n```\n--- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n--- category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12\n System.out.print(\"Something2\");\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", }) propGetter.AssertExpectations(t) @@ -314,7 +314,7 @@ func TestDefaultRunner_Run_2_Source_Dirs_Vela(t *testing.T) { assert.Equal(t, "──────────────────────────────────────────────────────────────\n 📊 Patch Coverage Report — changed lines only\n──────────────────────────────────────────────────────────────\n Modules: category-search\n\n Diff coverage: 88% 🟢 — 42 of 48 changed instructions covered\n\n Summary\n Covered instructions 88% (42)\n Missed instructions 12% (6)\n Tracked changed lines 53% (8)\n Untracked changed lines 47% (7)\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 73% 8 cov / 3 miss category-search/src/main/java/com/tgt/CategorySearchApplication.java\n 92% 34 cov / 3 miss category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt\n (3 file(s) with no measurable lines omitted)\n\n Uncovered lines (2)\n - category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n - category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12\n System.out.print(\"Something2\");\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*Modules:* category-search\n\n**Diff coverage:** `88%` 🟢 — `42` of `48` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `42` (88%) | changed code your tests executed |\n| 🔴 Missed instructions | `6` (12%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `8` (53%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `7` (47%) | 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| `category-search/src/main/java/com/tgt/CategorySearchApplication.java` | 🟡 73% | 8 / 3 |\n| `category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt` | 🟢 92% | 34 / 3 |\n\n3 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (2)\n\n```\n--- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n--- category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12\n System.out.print(\"Something2\");\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", + "body": "\n## 🛡️ 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*Modules:* category-search\n\n**Diff coverage:** `88%` 🟢 — `42` of `48` changed instructions covered\n\n| Metric | Value | |\n| :-- | --: | :-- |\n| 🟢 Covered instructions | `42` (88%) | changed code your tests executed |\n| 🔴 Missed instructions | `6` (12%) | changed code your tests never ran |\n| 📈 Tracked changed lines | `8` (53%) | lines the coverage tool could measure |\n| ⚪ Untracked changed lines | `7` (47%) | 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| `category-search/src/main/java/com/tgt/CategorySearchApplication.java` | 🟡 73% | 8 / 3 |\n| `category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt` | 🟢 92% | 34 / 3 |\n\n3 changed file(s) with no measurable lines (config, docs, generated, or test-only) omitted.\n\n\n
🔍 Uncovered lines (2)\n\n```\n--- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52\n System.out.print(\"Something\");\n--- category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12\n System.out.print(\"Something2\");\n```\n
\n\n🤖 Generated by pull-request-code-coverage — coverage for changed lines only.\n", }) propGetter.AssertExpectations(t) diff --git a/internal/test/mocks/mock_gh_api.go b/internal/test/mocks/mock_gh_api.go index ae99bf2..5b66d9b 100644 --- a/internal/test/mocks/mock_gh_api.go +++ b/internal/test/mocks/mock_gh_api.go @@ -11,7 +11,10 @@ import ( "github.com/stretchr/testify/assert" ) -const HTTPResponseCreated = 201 +const ( + HTTPResponseOK = 200 + HTTPResponseCreated = 201 +) type CapturedRequest struct { req *http.Request @@ -28,6 +31,15 @@ func WithMockGithubAPI(doer func(mockServerURL string, requestAsserter GithubAPI body: mustReadAll(r.Body), }) + // The sticky-comment reporter first GETs existing comments to decide + // whether to update or create. Return an empty list so it falls + // through to creating (POST) a new comment. + if r.Method == http.MethodGet { + w.WriteHeader(HTTPResponseOK) + _, _ = w.Write([]byte("[]")) + return + } + w.WriteHeader(HTTPResponseCreated) }), )