From 0cde4967a91ae72137bfdeca7df5607f4c0f2b7f Mon Sep 17 00:00:00 2001 From: Vishal Vaibhav Date: Fri, 19 Jun 2026 12:36:07 +0530 Subject: [PATCH] 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) }), )