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) }), )