Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/pr-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
19 changes: 12 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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).

Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
5 changes: 5 additions & 0 deletions internal/plugin/pluginjson/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import "encoding/json"

type Client interface {
Marshal(data interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
}

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)
}
6 changes: 6 additions & 0 deletions internal/plugin/pluginjson/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
121 changes: 104 additions & 17 deletions internal/plugin/reporter/github_pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<!-- pull-request-code-coverage:patch-coverage -->"

func NewGithubPullRequest(apiKey string, apiBaseURL string, pr string, owner string, repo string, httpClient pluginhttp.Client, jsonClient pluginjson.Client) *GithubPullRequest {
return &GithubPullRequest{
apiKey: apiKey,
Expand All @@ -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")
Expand All @@ -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()
Expand All @@ -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")
Expand All @@ -129,17 +226,7 @@ func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.So
b.WriteString(missedInstructionsSection(changedLinesWithCoverage))
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")

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.
Expand Down
Loading
Loading