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