Skip to content
Closed
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
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Docker Login
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Build and Push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with:
push: true
tags: |
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v5
with:
go-version: 1.17
go-version: '1.26.3'

- name: Build
run: go build -v ./...
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.17-alpine as builder
FROM golang:1.26.3-alpine as builder
WORKDIR /go/src/github.com/target/pull-request-code-coverage
COPY . .
ENV GO111MODULE=on
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,37 @@ This plugin will output the coverage details to the CI/CD step's console. A sam
This plugin as well as has the ability to comment on the PR with a summary of the coverage details.
![ ](./images/github_pr_coverage.png)

The PR comment is rendered as markdown and looks like this:

> ## 📊 Pull Request Code Coverage
>
> Coverage below is for **only the lines changed in this PR**.
>
> **Modules:** `category-search`
>
> | Metric | Coverage | Count |
> |:---|---:|---:|
> | ✅ Covered instructions | **73%** | 8 |
> | ❌ Missed instructions | 27% | 3 |
> | 📈 Lines with coverage data | 22% | 2 |
> | 📉 Lines without coverage data | 78% | 7 |
>
> <details><summary>ℹ️ What do these metrics mean?</summary>
>
> - **Covered instructions** — instructions (statements / bytecode) on the changed lines that were executed by your tests.
> - **Missed instructions** — instructions on the changed lines that were **not** executed by any test.
> - **Lines with coverage data** — changed lines the coverage tool tracks as executable code.
> - **Lines without coverage data** — changed lines with no coverage information (comments, blank lines, declarations, etc.).
> </details>
>
> <details><summary>❌ Lines missing coverage (1)</summary>
>
> **`category-search/src/main/java/com/tgt/CategorySearchApplication.java:52`**
> ```java
> System.out.print("Something");
> ```
> </details>


Currently, this plugin supports two coverage file format.
* jacoco for jvm based languages like java,kotlin,scala
Expand Down Expand Up @@ -86,7 +117,7 @@ Once you have coverage.xml same can be passed as an input to plugin shown below
|coverage_type| true | | **supported values**: jacoco, cobertura<br><br>sets the coverage file format |
|coverage_file| true | | path to where the coverage file will be located, relative to the working dir |
|source_dirs| true | | array of source dirs, relative to the working dir |
|gh_api_base_url| false | | base url of the gh api for posting coverage comments<br><br>if not set, coverage details will not be commented on PR |
|gh_api_base_url| false | https://api.github.com | base url of the gh api for posting coverage comments<br><br>defaults to public GitHub; for GitHub Enterprise set this to your host (e.g. `https://git.target.com`) |
|gh_api_key| false | | api key to auth for posting coverage comments<br><br>if not set, coverage details will not be commented on PR |
|module | false | \<empty string\> | sub-module to use if operating inside a multi-module project (e.g. gradle multi-project build) |

Expand Down
13 changes: 6 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
module github.com/target/pull-request-code-coverage

go 1.17
go 1.26.3

require (
github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0

github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.1.0 // indirect
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
github.com/stretchr/objx v0.5.3 // indirect
golang.org/x/sys v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
22 changes: 10 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
169 changes: 135 additions & 34 deletions internal/plugin/reporter/github_pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"bytes"
"fmt"
"io"
"net/url"
"path/filepath"
"sort"
"strings"

"github.com/pkg/errors"
Expand All @@ -12,6 +15,11 @@ import (
"github.com/target/pull-request-code-coverage/internal/plugin/pluginjson"
)

// DefaultGithubAPIBaseURL is the REST API root for public GitHub. It is used
// when no base URL is configured; GitHub Enterprise users override it via
// gh_api_base_url (e.g. https://git.target.com).
const DefaultGithubAPIBaseURL = "https://api.github.com"

type GithubPullRequest struct {
apiKey string
apiBaseURL string
Expand Down Expand Up @@ -50,7 +58,7 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove
return errors.Wrap(bodyErr, "Failed creating payload for github")
}

url := fmt.Sprintf("%v/api/v3/repos/%v/%v/issues/%v/comments", s.apiBaseURL, s.owner, s.repo, s.pr)
url := s.commentsURL()

req, newErr := s.httpClient.NewRequest(
"POST",
Expand All @@ -63,6 +71,7 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove

req.Header.Add("Authorization", "token "+s.apiKey)
req.Header.Add("Content-Type", "application/json")
req.Header.Add("User-Agent", "pull-request-code-coverage")

resp, doErr := s.httpClient.Do(req)

Expand All @@ -77,64 +86,154 @@ func (s *GithubPullRequest) Write(changedLinesWithCoverage domain.SourceLineCove
return nil
}

// commentsURL builds the REST endpoint for posting an issue comment.
//
// Public GitHub (api.github.com) and Enterprise Cloud (api.*.ghe.com) serve the
// REST API at the host root, while Enterprise Server (e.g. git.target.com)
// serves it under /api/v3. We append /api/v3 only for the latter so existing
// host-only Enterprise configs keep working without change.
func (s *GithubPullRequest) commentsURL() string {
base := strings.TrimRight(s.apiBaseURL, "/")

if host := hostOf(base); !strings.HasPrefix(host, "api.") && !strings.HasSuffix(base, "/api/v3") {
base += "/api/v3"
}

return fmt.Sprintf("%v/repos/%v/%v/issues/%v/comments", base, s.owner, s.repo, s.pr)
}

// hostOf returns the host component of a URL, or "" if it cannot be parsed.
func hostOf(rawURL string) string {
if u, err := url.Parse(rawURL); err == nil {
return u.Host
}

return ""
}

func (s *GithubPullRequest) GetName() string {
return "github pull request reporter"
}

// metricsLegend explains, in plain language, what each row of the metrics
// table means so readers of the PR comment don't have to guess.
const metricsLegend = "<details><summary>ℹ️ What do these metrics mean?</summary>\n\n" +
"- **Covered instructions** — instructions (statements / bytecode) on the changed lines that were executed by your tests.\n" +
"- **Missed instructions** — instructions on the changed lines that were **not** executed by any test.\n" +
"- **Lines with coverage data** — changed lines the coverage tool tracks as executable code.\n" +
"- **Lines without coverage data** — changed lines with no coverage information (comments, blank lines, declarations, etc.).\n\n" +
"</details>\n"

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

modules := collectModules(changedLinesWithCoverage)
var body strings.Builder

summaryLines := []string{}
body.WriteString("## 📊 Pull Request Code Coverage\n\n")
body.WriteString("Coverage below is for **only the lines changed in this PR**.\n\n")

if len(modules) > 0 {
summaryLines = append(summaryLines, fmt.Sprintf("*Modules: %v*\n\n", strings.Join(modules, ", ")))
if modules := collectModules(changedLinesWithCoverage); len(modules) > 0 {
body.WriteString(fmt.Sprintf("**Modules:** %v\n\n", strings.Join(backtickEach(modules), ", ")))
}
var missedInstructions string

for _, r := range changedLinesWithCoverage {
if r.MissedInstructionCount > 0 {
missedInstructions += fmt.Sprintf("--- %v\n", lineDescription(r.SourceLine))
missedInstructions += fmt.Sprintf("%v\n", r.LineValue)
}
body.WriteString(metricsTable(changedLinesWithCoverage))
body.WriteString("\n")
body.WriteString(metricsLegend)

if details := missedInstructionsDetails(changedLinesWithCoverage); details != "" {
body.WriteString("\n")
body.WriteString(details)
}

data := map[string]string{
"body": body.String(),
}

dataBytes, marshalErr := s.jsonClient.Marshal(data)

if marshalErr != nil {
return nil, errors.Wrap(marshalErr, "Failed marshalling payload to json")
}

summaryLines = append(summaryLines, generateSummaryLines(changedLinesWithCoverage, func(linesWithDataCount int, linesWithoutDataCount int, covered int, missed int) []string {
return bytes.NewBuffer(dataBytes), nil
}

// metricsTable renders the coverage numbers as a GitHub-flavoured markdown table.
func metricsTable(changedLinesWithCoverage domain.SourceLineCoverageReport) string {
rows := generateSummaryLines(changedLinesWithCoverage, func(linesWithDataCount int, linesWithoutDataCount int, covered int, missed int) []string {
totalLines := linesWithDataCount + linesWithoutDataCount
totalInstructions := covered + missed

result := make([]string, 5)

result[0] = fmt.Sprintf("Code Coverage Summary:\n\n")
result[1] = fmt.Sprintf("Lines Without Coverage Data -> %.f%% (%d)\n", toPercent(safeDiv(float32(linesWithoutDataCount), float32(totalLines), 0)), linesWithoutDataCount)
result[2] = fmt.Sprintf("Lines With Coverage Data -> %.f%% (%d)\n", toPercent(safeDiv(float32(linesWithDataCount), float32(totalLines), 1)), linesWithDataCount)
result[3] = fmt.Sprintf("Covered Instructions -> **%.f%%** (%d)\n", toPercent(safeDiv(float32(covered), float32(totalInstructions), 1)), covered)
result[4] = fmt.Sprintf("Missed Instructions -> %.f%% (%d)\n", toPercent(safeDiv(float32(missed), float32(totalInstructions), 0)), missed)
return []string{
"| Metric | Coverage | Count |\n",
"|:---|---:|---:|\n",
fmt.Sprintf("| ✅ Covered instructions | **%.f%%** | %d |\n", toPercent(safeDiv(float32(covered), float32(totalInstructions), 1)), covered),
fmt.Sprintf("| ❌ Missed instructions | %.f%% | %d |\n", toPercent(safeDiv(float32(missed), float32(totalInstructions), 0)), missed),
fmt.Sprintf("| 📈 Lines with coverage data | %.f%% | %d |\n", toPercent(safeDiv(float32(linesWithDataCount), float32(totalLines), 1)), linesWithDataCount),
fmt.Sprintf("| 📉 Lines without coverage data | %.f%% | %d |\n", toPercent(safeDiv(float32(linesWithoutDataCount), float32(totalLines), 0)), linesWithoutDataCount),
}
})

return result
})...)
return strings.Join(rows, "")
}

var summary string
if missedInstructions == "" {
summary = strings.Join(summaryLines, "")
} else {
// missedInstructionsDetails renders a collapsible section listing each changed
// line that has missed instructions, with its source in a syntax-highlighted
// block. Returns "" when nothing was missed.
func missedInstructionsDetails(changedLinesWithCoverage domain.SourceLineCoverageReport) string {
var missed []domain.SourceLineCoverage
for _, r := range changedLinesWithCoverage {
if r.MissedInstructionCount > 0 {
missed = append(missed, r)
}
}

summaryWithoutInstructions := strings.Join(summaryLines, "")
summary = summaryWithoutInstructions + "\n<details><summary>Missed Instructions summary</summary>\n\n" + "```\n" + missedInstructions + "```" +
"\n</details>"
if len(missed) == 0 {
return ""
}

data := map[string]string{
"body": summary,
var b strings.Builder
b.WriteString(fmt.Sprintf("<details><summary>❌ Lines missing coverage (%d)</summary>\n\n", len(missed)))

for _, r := range missed {
b.WriteString(fmt.Sprintf("**`%v`**\n", lineDescription(r.SourceLine)))
b.WriteString(fmt.Sprintf("```%v\n%v\n```\n\n", langForFile(r.FileName), r.LineValue))
}

dataBytes, marshalErr := s.jsonClient.Marshal(data)
b.WriteString("</details>\n")

if marshalErr != nil {
return nil, errors.Wrap(marshalErr, "Failed marshalling payload to json")
return b.String()
}

// langForFile maps a source file's extension to a markdown code-fence language
// so missed-line snippets get syntax highlighting. Unknown types render plain.
func langForFile(fileName string) string {
switch strings.ToLower(filepath.Ext(fileName)) {
case ".go":
return "go"
case ".java":
return "java"
case ".kt", ".kts":
return "kotlin"
case ".scala":
return "scala"
case ".py":
return "python"
case ".js":
return "javascript"
case ".ts":
return "typescript"
default:
return ""
}
}

return bytes.NewBuffer(dataBytes), nil
func backtickEach(items []string) []string {
out := make([]string, len(items))
for i, item := range items {
out[i] = "`" + item + "`"
}

return out
}

func collectModules(changedLinesWithCoverage domain.SourceLineCoverageReport) []string {
Expand All @@ -151,5 +250,7 @@ func collectModules(changedLinesWithCoverage domain.SourceLineCoverageReport) []
keys = append(keys, k)
}

sort.Strings(keys)

return keys
}
Loading
Loading