diff --git a/.github/workflows/pr-coverage.yml b/.github/workflows/pr-coverage.yml index b3cbf7e..dc2978a 100644 --- a/.github/workflows/pr-coverage.yml +++ b/.github/workflows/pr-coverage.yml @@ -14,14 +14,17 @@ permissions: jobs: coverage: runs-on: ubuntu-latest - # Fork PRs only get a read-only GITHUB_TOKEN (can't comment) and no secrets, - # so restrict to same-repo PRs to avoid a guaranteed failure on forks. - if: github.event.pull_request.head.repo.full_name == github.repository + # Runs on fork PRs too. Fork PRs only get a read-only GITHUB_TOKEN and no + # secrets, so the plugin can't post the PR comment there — it just prints + # coverage to the job log (the reporter no-ops the comment when the token / + # PR context is missing). steps: - name: Check out the repo uses: actions/checkout@v4 - with: - fetch-depth: 0 # need the base branch present to diff against it + # No fetch-depth needed: the plugin now fetches the PR diff from the + # GitHub API (PARAMETER_DIFF_SOURCE=github), so we don't diff against the + # base branch locally. Checkout is only here to build the image and run + # the tests that produce the coverage report. - name: Set up Go uses: actions/setup-go@v5 @@ -43,7 +46,16 @@ jobs: run: docker build -t pr-code-coverage:ci . - name: Report coverage on changed lines + # On fork PRs the GITHUB_TOKEN is read-only: the plugin can read the diff + # but the PR-comment POST gets a 403 and errors. Tolerate that on forks so + # the run still goes green with coverage printed to the log; same-repo PRs + # keep failing loudly on real errors. + continue-on-error: ${{ github.event.pull_request.head.repo.full_name != github.repository }} env: + # Fetch the PR diff straight from the GitHub API instead of piping in a + # local `git diff`. Note the API diff covers ALL changed files, not just + # *.go, so non-Go edits (yml/md) show up as "untracked changed lines". + PARAMETER_DIFF_SOURCE: github PARAMETER_COVERAGE_TYPE: cobertura PARAMETER_COVERAGE_FILE: coverage.xml # Must equal the cobertura path (the dir go test ran in). @@ -53,18 +65,22 @@ jobs: BUILD_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY_ORG: ${{ github.repository_owner }} REPOSITORY_NAME: ${{ github.event.repository.name }} + # The container mounts the workspace so it can read coverage.xml, and the + # GITHUB_STEP_SUMMARY file so the plugin can render coverage on the run's + # summary page (visible even on fork PRs where the comment can't post). + # The diff no longer comes from stdin, so `-i` and the pipe are gone. run: | - git fetch --no-tags origin "${{ github.base_ref }}" - git --no-pager diff --unified=0 "origin/${{ github.base_ref }}" -- '*.go' \ - | docker run --rm -i \ - -e PARAMETER_COVERAGE_TYPE \ - -e PARAMETER_COVERAGE_FILE \ - -e PARAMETER_SOURCE_DIRS \ - -e PARAMETER_GH_API_KEY \ - -e BUILD_PULL_REQUEST_NUMBER \ - -e REPOSITORY_ORG \ - -e REPOSITORY_NAME \ - -v "${{ github.workspace }}:${{ github.workspace }}" \ - -w "${{ github.workspace }}" \ - --entrypoint /plugin \ - pr-code-coverage:ci + docker run --rm \ + -e PARAMETER_DIFF_SOURCE \ + -e PARAMETER_COVERAGE_TYPE \ + -e PARAMETER_COVERAGE_FILE \ + -e PARAMETER_SOURCE_DIRS \ + -e PARAMETER_GH_API_KEY \ + -e BUILD_PULL_REQUEST_NUMBER \ + -e REPOSITORY_ORG \ + -e REPOSITORY_NAME \ + -e GITHUB_STEP_SUMMARY \ + -v "${{ github.workspace }}:${{ github.workspace }}" \ + -v "$GITHUB_STEP_SUMMARY:$GITHUB_STEP_SUMMARY" \ + -w "${{ github.workspace }}" \ + pr-code-coverage:ci diff --git a/AGENTS.md b/AGENTS.md index bb47610..41e660a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,32 +46,43 @@ The CI (`.github/workflows/test.yml`) runs build, test, `make format`, and `make Entry point `main.go` → `plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout)`. The runner (`internal/plugin/runner.go`) reads **config from env vars** (`PARAMETER_*`, -`BUILD_PULL_REQUEST_NUMBER`, `REPOSITORY_ORG`, `REPOSITORY_NAME`), the **diff from stdin**, and the +`BUILD_PULL_REQUEST_NUMBER`, `REPOSITORY_ORG`, `REPOSITORY_NAME`), the **diff** (from stdin by +default, or fetched from the GitHub API — see `PARAMETER_DIFF_SOURCE` below), and the **coverage report from the file** at `PARAMETER_COVERAGE_FILE`. ``` -stdin (unified diff) ──► sourcelines/unifieddiff ──► []domain.SourceLine +diff (unified) ──► sourcelines/unifieddiff ──► []domain.SourceLine │ {Module,SrcDir,Pkg,FileName,LineNumber,LineValue} coverage file ──► coverage.Loader.Load() ──► coverage.Report │ calculator.DetermineCoverage(lines, report) ─────┘ └ for each line: report.GetCoverageData(...) ──► []domain.SourceLineCoverage │ - reporter.Forking{ Simple, GithubPullRequest }.Write(...) + reporter.Forking{ Simple, GithubPullRequest, StepSummary }.Write(...) ├─ Simple → plain-text report to stdout (always) - └─ GithubPullRequest → Markdown PR comment (only if creds present) + ├─ GithubPullRequest → Markdown PR comment (only if creds present) + └─ StepSummary → Markdown to $GITHUB_STEP_SUMMARY (only if set) ``` Key packages: - `internal/plugin/sourcelines/unifieddiff/changed_source_loader.go` — parses the unified diff into changed `SourceLine`s. `PARAMETER_SOURCE_DIRS` controls how a path prefix is split into `SrcDir`/`Pkg`. + Handles both `--unified=0` diffs (the stdin/Vela path, no context lines) and diffs that carry context + lines (e.g. from the GitHub API) — context lines advance the new-file line counter but aren't recorded. +- `internal/plugin/githubdiff/diff.go` — alternative diff source. When `PARAMETER_DIFF_SOURCE=github`, + the runner fetches the PR diff from `GET /repos/{owner}/{repo}/pulls/{n}` with the + `application/vnd.github.v3.diff` media type instead of reading stdin. Default is `stdin` + (unchanged behavior). The `github` mode requires `PARAMETER_GH_API_KEY` + the three build-context vars. - `internal/plugin/coverage/` — `report.go` defines the two interfaces every format implements: `Loader.Load(file) (Report, error)` and `Report.GetCoverageData(module, sourceDir, pkg, fileName, lineNumber) (*CoverageData, bool)`. - `internal/plugin/calculator/calculator.go` — joins changed lines to coverage data. - `internal/plugin/reporter/` — `simple.go` (console), `github_pr.go` (PR comment markdown), - `forking.go` (runs all reporters), `utils.go` (`filePath`, `lineDescription`). Per-file - aggregation (`collectFileCoverage`) and `coverageStatusEmoji` live in `github_pr.go` and are - shared by both reporters (same package). + `step_summary.go` (writes the same markdown to `$GITHUB_STEP_SUMMARY`), `forking.go` (runs all + reporters), `utils.go` (`filePath`, `lineDescription`). Per-file aggregation + (`collectFileCoverage`), `coverageStatusEmoji`, and the shared markdown builder + (`buildMarkdownReport`, used by both `github_pr.go` and `step_summary.go`) live in `github_pr.go`. + The PR comment is **sticky**: `github_pr.go` first GETs the PR's comments, and if it finds the one + carrying the hidden `commentMarker` it PATCHes that comment instead of POSTing a new one. - `internal/plugin/domain/domain.go` — core types. Coverage is counted in **instructions** (`CoveredInstructionCount`/`MissedInstructionCount`), not lines (see below). @@ -81,8 +92,9 @@ Key packages: JVM bytecode *instructions*, so a line can be partly covered. For Go/Python/LCOV the loaders emit exactly 1 instruction per line. The reports surface both units on purpose — do not "fix" this as if it were a bug. The user has explicitly asked for this distinction to be clear. -- **Two output formats, one dataset.** `Simple` (plain text, stdout) and `GithubPullRequest` - (Markdown) render the same data differently. Change both if you change what's reported. +- **Two output formats, one dataset.** `Simple` (plain text, stdout) renders one way; + `GithubPullRequest` and `StepSummary` both render the shared `buildMarkdownReport` output. Change + `Simple` and `buildMarkdownReport` if you change what's reported. - **The PR comment is posted only** when `gh_api_key` AND `BUILD_PULL_REQUEST_NUMBER` AND `REPOSITORY_ORG` AND `REPOSITORY_NAME` are all present; otherwise console-only. `GithubPullRequest` also returns early when there are zero changed lines with coverage data. 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/README.md b/README.md index 8fe364b..edb1899 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. @@ -335,8 +346,11 @@ A working GitHub Actions example lives in [`.github/workflows/pr-coverage.yml`]( | `BUILD_PULL_REQUEST_NUMBER` | the PR number to comment on | | `REPOSITORY_ORG` | repository owner / org | | `REPOSITORY_NAME` | repository name | +| `GITHUB_STEP_SUMMARY` | set automatically by GitHub Actions. When present, the plugin also writes the report to the [job summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary), so coverage shows on the run page even on fork PRs that can't be commented on | > The PR comment is posted only when `gh_api_key` **and** all three build-context values are present. Otherwise the plugin prints to the console and exits successfully. +> +> The comment is **sticky**: on later pushes the plugin updates its existing comment in place instead of posting a new one each time. In GitHub Actions, mount the summary file (`-v "$GITHUB_STEP_SUMMARY:$GITHUB_STEP_SUMMARY" -e GITHUB_STEP_SUMMARY`) to get the job-summary output — see [`.github/workflows/pr-coverage.yml`](.github/workflows/pr-coverage.yml). --- 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/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 6f7fd53..023561b 100644 --- a/internal/plugin/runner.go +++ b/internal/plugin/runner.go @@ -3,6 +3,7 @@ package plugin import ( "fmt" "io" + "os" "strconv" "strings" @@ -14,6 +15,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 +99,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") @@ -125,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 new file mode 100644 index 0000000..aa79324 --- /dev/null +++ b/internal/plugin/runner_diffsource_test.go @@ -0,0 +1,119 @@ +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 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) + })) + 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/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/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/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) }), ) 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 +} 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 <