Skip to content

Commit 0cde496

Browse files
Vishal Vaibhavclaude
authored andcommitted
feat: sticky-comment-and-job-summary
Add two reporting features that share the report markdown: - Sticky PR comment: github_pr.go now lists the PR's comments and PATCHes the one carrying a hidden marker instead of POSTing a new comment on every push. Adds Unmarshal to the json client. - Job summary: a StepSummary reporter writes the report to $GITHUB_STEP_SUMMARY when set, so coverage shows on the Actions run page even on fork PRs that can't be commented on. Wired into the workflow by mounting and passing the summary file. Extracts the shared buildMarkdownReport used by both the comment and the summary, and updates README/AGENTS docs and tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 51b16d9 commit 0cde496

13 files changed

Lines changed: 321 additions & 92 deletions

File tree

.github/workflows/pr-coverage.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ jobs:
6565
BUILD_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
6666
REPOSITORY_ORG: ${{ github.repository_owner }}
6767
REPOSITORY_NAME: ${{ github.event.repository.name }}
68-
# The container still mounts the workspace so it can read coverage.xml;
69-
# the diff no longer comes from stdin, so `-i` and the pipe are gone.
68+
# The container mounts the workspace so it can read coverage.xml, and the
69+
# GITHUB_STEP_SUMMARY file so the plugin can render coverage on the run's
70+
# summary page (visible even on fork PRs where the comment can't post).
71+
# The diff no longer comes from stdin, so `-i` and the pipe are gone.
7072
run: |
7173
docker run --rm \
7274
-e PARAMETER_DIFF_SOURCE \
@@ -77,6 +79,8 @@ jobs:
7779
-e BUILD_PULL_REQUEST_NUMBER \
7880
-e REPOSITORY_ORG \
7981
-e REPOSITORY_NAME \
82+
-e GITHUB_STEP_SUMMARY \
8083
-v "${{ github.workspace }}:${{ github.workspace }}" \
84+
-v "$GITHUB_STEP_SUMMARY:$GITHUB_STEP_SUMMARY" \
8185
-w "${{ github.workspace }}" \
8286
pr-code-coverage:ci

AGENTS.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ coverage file ──► coverage.Loader.Load() ──► coverage.Report
5858
calculator.DetermineCoverage(lines, report) ─────┘
5959
└ for each line: report.GetCoverageData(...) ──► []domain.SourceLineCoverage
6060
61-
reporter.Forking{ Simple, GithubPullRequest }.Write(...)
61+
reporter.Forking{ Simple, GithubPullRequest, StepSummary }.Write(...)
6262
├─ Simple → plain-text report to stdout (always)
63-
└─ GithubPullRequest → Markdown PR comment (only if creds present)
63+
├─ GithubPullRequest → Markdown PR comment (only if creds present)
64+
└─ StepSummary → Markdown to $GITHUB_STEP_SUMMARY (only if set)
6465
```
6566

6667
Key packages:
@@ -76,9 +77,12 @@ Key packages:
7677
`Loader.Load(file) (Report, error)` and `Report.GetCoverageData(module, sourceDir, pkg, fileName, lineNumber) (*CoverageData, bool)`.
7778
- `internal/plugin/calculator/calculator.go` — joins changed lines to coverage data.
7879
- `internal/plugin/reporter/``simple.go` (console), `github_pr.go` (PR comment markdown),
79-
`forking.go` (runs all reporters), `utils.go` (`filePath`, `lineDescription`). Per-file
80-
aggregation (`collectFileCoverage`) and `coverageStatusEmoji` live in `github_pr.go` and are
81-
shared by both reporters (same package).
80+
`step_summary.go` (writes the same markdown to `$GITHUB_STEP_SUMMARY`), `forking.go` (runs all
81+
reporters), `utils.go` (`filePath`, `lineDescription`). Per-file aggregation
82+
(`collectFileCoverage`), `coverageStatusEmoji`, and the shared markdown builder
83+
(`buildMarkdownReport`, used by both `github_pr.go` and `step_summary.go`) live in `github_pr.go`.
84+
The PR comment is **sticky**: `github_pr.go` first GETs the PR's comments, and if it finds the one
85+
carrying the hidden `commentMarker` it PATCHes that comment instead of POSTing a new one.
8286
- `internal/plugin/domain/domain.go` — core types. Coverage is counted in **instructions**
8387
(`CoveredInstructionCount`/`MissedInstructionCount`), not lines (see below).
8488

@@ -88,8 +92,9 @@ Key packages:
8892
JVM bytecode *instructions*, so a line can be partly covered. For Go/Python/LCOV the loaders emit
8993
exactly 1 instruction per line. The reports surface both units on purpose — do not "fix" this as if
9094
it were a bug. The user has explicitly asked for this distinction to be clear.
91-
- **Two output formats, one dataset.** `Simple` (plain text, stdout) and `GithubPullRequest`
92-
(Markdown) render the same data differently. Change both if you change what's reported.
95+
- **Two output formats, one dataset.** `Simple` (plain text, stdout) renders one way;
96+
`GithubPullRequest` and `StepSummary` both render the shared `buildMarkdownReport` output. Change
97+
`Simple` and `buildMarkdownReport` if you change what's reported.
9398
- **The PR comment is posted only** when `gh_api_key` AND `BUILD_PULL_REQUEST_NUMBER` AND
9499
`REPOSITORY_ORG` AND `REPOSITORY_NAME` are all present; otherwise console-only. `GithubPullRequest`
95100
also returns early when there are zero changed lines with coverage data.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,11 @@ docker run --rm \
346346
| `BUILD_PULL_REQUEST_NUMBER` | the PR number to comment on |
347347
| `REPOSITORY_ORG` | repository owner / org |
348348
| `REPOSITORY_NAME` | repository name |
349+
| `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 |
349350
350351
> 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.
352+
>
353+
> 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).
351354
352355
---
353356

internal/plugin/pluginjson/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ import "encoding/json"
44

55
type Client interface {
66
Marshal(data interface{}) ([]byte, error)
7+
Unmarshal(data []byte, v interface{}) error
78
}
89

910
type DefaultClient struct{}
1011

1112
func (c *DefaultClient) Marshal(data interface{}) ([]byte, error) {
1213
return json.Marshal(data)
1314
}
15+
16+
func (c *DefaultClient) Unmarshal(data []byte, v interface{}) error {
17+
return json.Unmarshal(data, v)
18+
}

internal/plugin/pluginjson/mocks.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ func (m *MockClient) Marshal(data interface{}) ([]byte, error) {
1919
}
2020
return r.([]byte), e
2121
}
22+
23+
func (m *MockClient) Unmarshal(data []byte, v interface{}) error {
24+
args := m.Called(data, v)
25+
26+
return args.Error(0)
27+
}

internal/plugin/reporter/github_pr.go

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ type GithubPullRequest struct {
2424
}
2525

2626
const (
27+
HTTPResponseOK = 200
2728
HTTPResponseCreated = 201
2829
)
2930

31+
// commentMarker is an HTML comment embedded at the top of the report. It renders
32+
// invisibly on GitHub but lets a later run find the comment it posted earlier so
33+
// it can update that one in place instead of posting a new comment every push.
34+
const commentMarker = "<!-- pull-request-code-coverage:patch-coverage -->"
35+
3036
func NewGithubPullRequest(apiKey string, apiBaseURL string, pr string, owner string, repo string, httpClient pluginhttp.Client, jsonClient pluginjson.Client) *GithubPullRequest {
3137
return &GithubPullRequest{
3238
apiKey: apiKey,
@@ -51,12 +57,32 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove
5157
return errors.Wrap(bodyErr, "Failed creating payload for github")
5258
}
5359

54-
url := fmt.Sprintf("%v/repos/%v/%v/issues/%v/comments", strings.TrimRight(s.apiBaseURL, "/"), s.owner, s.repo, s.pr)
60+
existingID, findErr := s.findExistingCommentID()
61+
if findErr != nil {
62+
return findErr
63+
}
64+
65+
// Update the comment from a previous run when we find one; otherwise post a
66+
// fresh comment. This keeps a single, always-current coverage comment on the
67+
// PR instead of a new one per push.
68+
if existingID != 0 {
69+
url := fmt.Sprintf("%v/repos/%v/%v/issues/comments/%v", s.baseURL(), s.owner, s.repo, existingID)
70+
return s.send("PATCH", url, body, HTTPResponseOK)
71+
}
72+
73+
url := fmt.Sprintf("%v/repos/%v/%v/issues/%v/comments", s.baseURL(), s.owner, s.repo, s.pr)
74+
return s.send("POST", url, body, HTTPResponseCreated)
75+
}
76+
77+
// baseURL returns the configured GitHub API root without a trailing slash.
78+
func (s *GithubPullRequest) baseURL() string {
79+
return strings.TrimRight(s.apiBaseURL, "/")
80+
}
5581

56-
req, newErr := s.httpClient.NewRequest(
57-
"POST",
58-
url,
59-
body)
82+
// send issues a write request (POST/PATCH) carrying the comment payload and
83+
// verifies the response status.
84+
func (s *GithubPullRequest) send(method string, url string, body io.Reader, wantStatus int) error {
85+
req, newErr := s.httpClient.NewRequest(method, url, body)
6086

6187
if newErr != nil {
6288
return errors.Wrap(newErr, "Failed creating request to github")
@@ -75,19 +101,89 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove
75101
_ = resp.Body.Close()
76102
}()
77103

78-
if resp.StatusCode != HTTPResponseCreated {
104+
if resp.StatusCode != wantStatus {
79105
return errors.Errorf("Failed calling github: bad status code: %v", resp.StatusCode)
80106
}
81107

82108
return nil
83109
}
84110

111+
// findExistingCommentID looks for a coverage comment this plugin posted on an
112+
// earlier run, identified by the hidden commentMarker. It returns 0 when none is
113+
// found. Only the first page of comments is checked (per_page=100), which covers
114+
// any realistic PR. The GET only needs read access, so it also works on fork PRs
115+
// even though the follow-up write may not.
116+
func (s *GithubPullRequest) findExistingCommentID() (int64, error) {
117+
url := fmt.Sprintf("%v/repos/%v/%v/issues/%v/comments?per_page=100", s.baseURL(), s.owner, s.repo, s.pr)
118+
119+
req, newErr := s.httpClient.NewRequest("GET", url, nil)
120+
if newErr != nil {
121+
return 0, errors.Wrap(newErr, "Failed creating request to github")
122+
}
123+
124+
req.Header.Add("Authorization", "token "+s.apiKey)
125+
126+
resp, doErr := s.httpClient.Do(req)
127+
if doErr != nil {
128+
return 0, errors.Wrap(doErr, "Failed calling github")
129+
}
130+
131+
defer func() {
132+
_ = resp.Body.Close()
133+
}()
134+
135+
if resp.StatusCode != HTTPResponseOK {
136+
return 0, errors.Errorf("Failed listing github comments: bad status code: %v", resp.StatusCode)
137+
}
138+
139+
respBody, readErr := io.ReadAll(resp.Body)
140+
if readErr != nil {
141+
return 0, errors.Wrap(readErr, "Failed reading github comments response")
142+
}
143+
144+
var comments []struct {
145+
ID int64 `json:"id"`
146+
Body string `json:"body"`
147+
}
148+
149+
if unmarshalErr := s.jsonClient.Unmarshal(respBody, &comments); unmarshalErr != nil {
150+
return 0, errors.Wrap(unmarshalErr, "Failed parsing github comments response")
151+
}
152+
153+
for _, c := range comments {
154+
if strings.Contains(c.Body, commentMarker) {
155+
return c.ID, nil
156+
}
157+
}
158+
159+
return 0, nil
160+
}
161+
85162
func (s *GithubPullRequest) GetName() string {
86163
return "github pull request reporter"
87164
}
88165

89166
func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.SourceLineCoverageReport) (io.Reader, error) {
90167

168+
data := map[string]string{
169+
"body": buildMarkdownReport(changedLinesWithCoverage),
170+
}
171+
172+
dataBytes, marshalErr := s.jsonClient.Marshal(data)
173+
174+
if marshalErr != nil {
175+
return nil, errors.Wrap(marshalErr, "Failed marshalling payload to json")
176+
}
177+
178+
return bytes.NewBuffer(dataBytes), nil
179+
}
180+
181+
// buildMarkdownReport renders the changed-line coverage report as GitHub-flavored
182+
// Markdown. It is shared by the PR-comment reporter and the job-summary reporter
183+
// so both show identical output. The leading commentMarker is invisible when
184+
// rendered and lets the PR reporter find and update its own comment.
185+
func buildMarkdownReport(changedLinesWithCoverage domain.SourceLineCoverageReport) string {
186+
91187
modules := collectModules(changedLinesWithCoverage)
92188

93189
covered := changedLinesWithCoverage.TotalCoveredInstructions()
@@ -105,6 +201,7 @@ func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.So
105201

106202
var b strings.Builder
107203

204+
b.WriteString(commentMarker + "\n")
108205
b.WriteString("## 🛡️ Patch Coverage Report\n\n")
109206
b.WriteString("> Scope: **changed lines only** — the code this PR adds or edits, not whole files or the repo. ")
110207
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
129226
b.WriteString(missedInstructionsSection(changedLinesWithCoverage))
130227
b.WriteString("\n<sub>🤖 Generated by <a href=\"https://github.com/target/pull-request-code-coverage\">pull-request-code-coverage</a> — coverage for changed lines only.</sub>\n")
131228

132-
data := map[string]string{
133-
"body": b.String(),
134-
}
135-
136-
dataBytes, marshalErr := s.jsonClient.Marshal(data)
137-
138-
if marshalErr != nil {
139-
return nil, errors.Wrap(marshalErr, "Failed marshalling payload to json")
140-
}
141-
142-
return bytes.NewBuffer(dataBytes), nil
229+
return b.String()
143230
}
144231

145232
// fileCoverage holds the aggregated changed-line coverage for a single file.

0 commit comments

Comments
 (0)