From 66b0e86b513701df62880313e148fd68c95a0dc3 Mon Sep 17 00:00:00 2001 From: Vishal Vaibhav Date: Thu, 21 May 2026 12:52:28 +0530 Subject: [PATCH 1/2] fix: for github --- .github/workflows/release.yml | 6 +- .github/workflows/test.yml | 6 +- Dockerfile | 2 +- README.md | 33 +++- go.mod | 13 +- go.sum | 22 ++- internal/plugin/reporter/github_pr.go | 169 ++++++++++++++++----- internal/plugin/reporter/github_pr_test.go | 48 ++++++ internal/plugin/runner.go | 5 +- internal/plugin/runner_test.go | 159 +++++-------------- internal/test/mocks/mock_gh_api.go | 33 ++++ 11 files changed, 316 insertions(+), 180 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5623d2..ccb761c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a781a06..898e041 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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.24' - name: Build run: go build -v ./... diff --git a/Dockerfile b/Dockerfile index aa84fe8..729b319 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 4a67ed9..d607f3c 100644 --- a/README.md +++ b/README.md @@ -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 | +> +>
â„šī¸ What do these metrics mean? +> +> - **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.). +>
+> +>
❌ Lines missing coverage (1) +> +> **`category-search/src/main/java/com/tgt/CategorySearchApplication.java:52`** +> ```java +> System.out.print("Something"); +> ``` +>
+ Currently, this plugin supports two coverage file format. * jacoco for jvm based languages like java,kotlin,scala @@ -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

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

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

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

if not set, coverage details will not be commented on PR | |module | false | \ | sub-module to use if operating inside a multi-module project (e.g. gradle multi-project build) | diff --git a/go.mod b/go.mod index de194c4..78138dd 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 2fe2451..4c19f8a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/plugin/reporter/github_pr.go b/internal/plugin/reporter/github_pr.go index 6e42a4f..d11b875 100644 --- a/internal/plugin/reporter/github_pr.go +++ b/internal/plugin/reporter/github_pr.go @@ -4,6 +4,9 @@ import ( "bytes" "fmt" "io" + "net/url" + "path/filepath" + "sort" "strings" "github.com/pkg/errors" @@ -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 @@ -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", @@ -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) @@ -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 = "
â„šī¸ What do these metrics mean?\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" + + "
\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
Missed Instructions summary\n\n" + "```\n" + missedInstructions + "```" + - "\n
" + if len(missed) == 0 { + return "" } - data := map[string]string{ - "body": summary, + var b strings.Builder + b.WriteString(fmt.Sprintf("
❌ Lines missing coverage (%d)\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("
\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 { @@ -151,5 +250,7 @@ func collectModules(changedLinesWithCoverage domain.SourceLineCoverageReport) [] keys = append(keys, k) } + sort.Strings(keys) + return keys } diff --git a/internal/plugin/reporter/github_pr_test.go b/internal/plugin/reporter/github_pr_test.go index f1777c1..5ee72fa 100644 --- a/internal/plugin/reporter/github_pr_test.go +++ b/internal/plugin/reporter/github_pr_test.go @@ -13,6 +13,54 @@ import ( "github.com/target/pull-request-code-coverage/internal/plugin/pluginjson" ) +func TestGithubPullRequest_commentsURL(t *testing.T) { + + tests := []struct { + name string + apiBaseURL string + expected string + }{ + { + name: "github enterprise host gets /api/v3 appended", + apiBaseURL: "https://git.target.com", + expected: "https://git.target.com/api/v3/repos/some_org/some_repo/issues/123/comments", + }, + { + name: "trailing slash is trimmed", + apiBaseURL: "https://git.target.com/", + expected: "https://git.target.com/api/v3/repos/some_org/some_repo/issues/123/comments", + }, + { + name: "base url already pointing at /api/v3 is not doubled", + apiBaseURL: "https://git.target.com/api/v3", + expected: "https://git.target.com/api/v3/repos/some_org/some_repo/issues/123/comments", + }, + { + name: "public github uses api.github.com without /api/v3", + apiBaseURL: "https://api.github.com", + expected: "https://api.github.com/repos/some_org/some_repo/issues/123/comments", + }, + { + name: "enterprise cloud data residency api host without /api/v3", + apiBaseURL: "https://api.acme.ghe.com", + expected: "https://api.acme.ghe.com/repos/some_org/some_repo/issues/123/comments", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := &GithubPullRequest{ + apiBaseURL: tt.apiBaseURL, + owner: "some_org", + repo: "some_repo", + pr: "123", + } + + assert.Equal(t, tt.expected, writer.commentsURL()) + }) + } +} + func TestGithubPullRequest_Write_FailedNewRequest(t *testing.T) { mockClient := &pluginhttp.MockClient{} diff --git a/internal/plugin/runner.go b/internal/plugin/runner.go index 032a99b..9de5fbf 100644 --- a/internal/plugin/runner.go +++ b/internal/plugin/runner.go @@ -67,7 +67,8 @@ func (*DefaultRunner) Run(propertyGetter func(string) (string, bool), changedSou ghAPIBaseURL, ghAPIBaseURLFound := propertyGetter("PARAMETER_GH_API_BASE_URL") if !ghAPIBaseURLFound { - logrus.Info("PARAMETER_GH_API_BASE_URL was missing, will not send report to PR comments") + ghAPIBaseURL = reporter.DefaultGithubAPIBaseURL + logrus.Info(fmt.Sprintf("PARAMETER_GH_API_BASE_URL was missing, defaulting to %v", reporter.DefaultGithubAPIBaseURL)) } repoPR, repoPRFound := propertyGetter("BUILD_PULL_REQUEST_NUMBER") @@ -112,7 +113,7 @@ func (*DefaultRunner) Run(propertyGetter func(string) (string, bool), changedSou reporters := []reporter.Reporter{reporter.NewSimple(reportDefaultOut)} - if ghAPIKeyFound && ghAPIBaseURLFound && repoPRFound && repoOwnerFound && repoNameFound { + if ghAPIKeyFound && repoPRFound && repoOwnerFound && repoNameFound { reporters = append(reporters, reporter.NewGithubPullRequest(ghAPIKey, ghAPIBaseURL, repoPR, repoOwner, repoName, &pluginhttp.DefaultClient{}, &pluginjson.DefaultClient{})) } logrus.Info("enabled reporters are ") diff --git a/internal/plugin/runner_test.go b/internal/plugin/runner_test.go index 7529ed6..8ecbfdb 100644 --- a/internal/plugin/runner_test.go +++ b/internal/plugin/runner_test.go @@ -107,29 +107,14 @@ Covered Instructions -> 97% (177) Missed Instructions -> 3% (5) `, buf.String()) - requestAsserter.AssertRequestWasMade(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", map[string]interface{}{ - "body": `Code Coverage Summary: - -Lines Without Coverage Data -> 92% (2216) -Lines With Coverage Data -> 8% (182) -Covered Instructions -> **97%** (177) -Missed Instructions -> 3% (5) - -
Missed Instructions summary - -` + "```" + ` ---- internal/plugin/runner.go:72 -func GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader { ---- main.go:10 - err := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout) ---- main.go:12 - if err != nil { ---- main.go:13 - log.WithFields(log.Fields{ ---- main.go:17 - os.Exit(1) -` + - "```\n
", + requestAsserter.AssertRequestBodyContains(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", []string{ + "## 📊 Pull Request Code Coverage", + "| ✅ Covered instructions | **97%** | 177 |", + "| ❌ Missed instructions | 3% | 5 |", + "What do these metrics mean?", + "❌ Lines missing coverage (5)", + "`internal/plugin/runner.go:72`", + "```go", }) propGetter.AssertExpectations(t) @@ -178,29 +163,14 @@ Covered Instructions -> 97% (177) Missed Instructions -> 3% (5) `, buf.String()) - requestAsserter.AssertRequestWasMade(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", map[string]interface{}{ - "body": `Code Coverage Summary: - -Lines Without Coverage Data -> 92% (2216) -Lines With Coverage Data -> 8% (182) -Covered Instructions -> **97%** (177) -Missed Instructions -> 3% (5) - -
Missed Instructions summary - -` + "```" + ` ---- internal/plugin/runner.go:72 -func GetCoverageReportLoader(coverageType string, sourceDir string) coverage.Loader { ---- main.go:10 - err := plugin.NewRunner().Run(os.LookupEnv, os.Stdin, os.Stdout) ---- main.go:12 - if err != nil { ---- main.go:13 - log.WithFields(log.Fields{ ---- main.go:17 - os.Exit(1) -` + - "```\n
", + requestAsserter.AssertRequestBodyContains(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", []string{ + "## 📊 Pull Request Code Coverage", + "| ✅ Covered instructions | **97%** | 177 |", + "| ❌ Missed instructions | 3% | 5 |", + "What do these metrics mean?", + "❌ Lines missing coverage (5)", + "`internal/plugin/runner.go:72`", + "```go", }) propGetter.AssertExpectations(t) @@ -239,23 +209,13 @@ Covered Instructions -> 73% (8) Missed Instructions -> 27% (3) `, buf.String()) - requestAsserter.AssertRequestWasMade(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", map[string]interface{}{ - "body": `*Modules: category-search* - -Code Coverage Summary: - -Lines Without Coverage Data -> 78% (7) -Lines With Coverage Data -> 22% (2) -Covered Instructions -> **73%** (8) -Missed Instructions -> 27% (3) - -
Missed Instructions summary - -` + "```" + ` ---- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52 - System.out.print("Something"); -` + - "```\n
", + requestAsserter.AssertRequestBodyContains(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", []string{ + "**Modules:** `category-search`", + "| ✅ Covered instructions | **73%** | 8 |", + "| ❌ Missed instructions | 27% | 3 |", + "What do these metrics mean?", + "category-search/src/main/java/com/tgt/CategorySearchApplication.java:52", + "```java", }) propGetter.AssertExpectations(t) @@ -295,23 +255,13 @@ Covered Instructions -> 73% (8) Missed Instructions -> 27% (3) `, buf.String()) - requestAsserter.AssertRequestWasMade(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", map[string]interface{}{ - "body": `*Modules: category-search* - -Code Coverage Summary: - -Lines Without Coverage Data -> 78% (7) -Lines With Coverage Data -> 22% (2) -Covered Instructions -> **73%** (8) -Missed Instructions -> 27% (3) - -
Missed Instructions summary - -` + "```" + ` ---- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52 - System.out.print("Something"); -` + - "```\n
", + requestAsserter.AssertRequestBodyContains(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", []string{ + "**Modules:** `category-search`", + "| ✅ Covered instructions | **73%** | 8 |", + "| ❌ Missed instructions | 27% | 3 |", + "What do these metrics mean?", + "category-search/src/main/java/com/tgt/CategorySearchApplication.java:52", + "```java", }) propGetter.AssertExpectations(t) @@ -353,24 +303,12 @@ Covered Instructions -> 88% (42) Missed Instructions -> 12% (6) `, buf.String()) - requestAsserter.AssertRequestWasMade(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", map[string]interface{}{ - "body": `*Modules: category-search* - -Code Coverage Summary: - -Lines Without Coverage Data -> 47% (7) -Lines With Coverage Data -> 53% (8) -Covered Instructions -> **88%** (42) -Missed Instructions -> 12% (6) - -
Missed Instructions summary - -` + "```" + ` ---- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52 - System.out.print("Something"); ---- category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12 - System.out.print("Something2"); -` + "```\n
", + requestAsserter.AssertRequestBodyContains(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", []string{ + "**Modules:** `category-search`", + "| ✅ Covered instructions | **88%** | 42 |", + "| ❌ Missed instructions | 12% | 6 |", + "category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12", + "```kotlin", }) propGetter.AssertExpectations(t) @@ -413,25 +351,12 @@ Covered Instructions -> 88% (42) Missed Instructions -> 12% (6) `, buf.String()) - requestAsserter.AssertRequestWasMade(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", map[string]interface{}{ - "body": `*Modules: category-search* - -Code Coverage Summary: - -Lines Without Coverage Data -> 47% (7) -Lines With Coverage Data -> 53% (8) -Covered Instructions -> **88%** (42) -Missed Instructions -> 12% (6) - -
Missed Instructions summary - -` + "```" + ` ---- category-search/src/main/java/com/tgt/CategorySearchApplication.java:52 - System.out.print("Something"); ---- category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12 - System.out.print("Something2"); -` + - "```\n
", + requestAsserter.AssertRequestBodyContains(t, "/api/v3/repos/some_org/some_repo/issues/123/comments", "SOME_API_KEY", []string{ + "**Modules:** `category-search`", + "| ✅ Covered instructions | **88%** | 42 |", + "| ❌ Missed instructions | 12% | 6 |", + "category-search/src/main/kotlin/com/tgt/SomeOtherClass.kt:12", + "```kotlin", }) propGetter.AssertExpectations(t) diff --git a/internal/test/mocks/mock_gh_api.go b/internal/test/mocks/mock_gh_api.go index 0b87bc4..8aad00d 100644 --- a/internal/test/mocks/mock_gh_api.go +++ b/internal/test/mocks/mock_gh_api.go @@ -39,6 +39,7 @@ func WithMockGithubAPI(doer func(mockServerURL string, requestAsserter GithubAPI type GithubAPIRequestAsserter interface { AssertRequestWasMade(t *testing.T, path string, apikey string, body map[string]interface{}) + AssertRequestBodyContains(t *testing.T, path string, apikey string, substrings []string) AssertNoRequestsWereMade(t *testing.T) } @@ -75,6 +76,38 @@ func (a *DefaultGithubAPIRequestAsserter) AssertRequestWasMade(t *testing.T, pat assert.Fail(t, fmt.Sprintf("Request was not made for path=%v, apikey=%v, body=%v", path, apikey, body)) } +// AssertRequestBodyContains finds the request matching path/apikey and asserts +// its JSON "body" field contains every given substring. Use this (rather than an +// exact body match) for the PR comment, whose markdown formatting is expected to +// evolve — assert the meaningful content, not the exact bytes. +func (a *DefaultGithubAPIRequestAsserter) AssertRequestBodyContains(t *testing.T, path string, apikey string, substrings []string) { + for _, r := range a.requests { + if r.req.URL.Path != path { + continue + } + + if r.req.Header.Get("Authorization") != "token "+apikey { + continue + } + + if r.req.Header.Get("Content-Type") != "application/json" { + continue + } + + var bodyData map[string]interface{} + mustJSONUnmarshall(r.body, &bodyData) + body, _ := bodyData["body"].(string) + + for _, sub := range substrings { + assert.Contains(t, body, sub) + } + + return + } + + assert.Fail(t, fmt.Sprintf("Request was not made for path=%v, apikey=%v", path, apikey)) +} + func mustJSONUnmarshall(bytes []byte, result interface{}) { err := json.Unmarshal(bytes, result) From 3937f5d1f065e01eaeb83759fa76de7a77f01330 Mon Sep 17 00:00:00 2001 From: Vishal Vaibhav Date: Thu, 21 May 2026 12:55:03 +0530 Subject: [PATCH 2/2] fix: golang version in test --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 898e041..0e269b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.26.3' - name: Build run: go build -v ./...