Skip to content

Commit ef4c7f9

Browse files
authored
Merge pull request #6 from CodeYogiCo/sticky-comment-and-job-summary
feat: sticky-comment-and-job-summary
2 parents 51b16d9 + 0cde496 commit ef4c7f9

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)