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
70 changes: 70 additions & 0 deletions .github/workflows/pr-coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: pr-code-coverage

# Dogfoods this plugin on its own PRs: builds the plugin image from the PR
# source, computes coverage for only the lines changed in the PR, and posts a
# summary comment on the PR.

on:
pull_request:

permissions:
contents: read
pull-requests: write # required so the plugin can post the coverage comment

jobs:
coverage:
runs-on: ubuntu-latest
# Fork PRs only get a read-only GITHUB_TOKEN (can't comment) and no secrets,
# so restrict to same-repo PRs to avoid a guaranteed failure on forks.
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0 # need the base branch present to diff against it

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

- name: Generate coverage profile
run: go test -coverpkg=./... -coverprofile=coverage.txt ./...

- name: Convert coverage to cobertura
# go run leaves no installed binary; boumenot emits <source> as the
# absolute build dir (== github.workspace) and class filenames relative
# to the module root, which is what PARAMETER_SOURCE_DIRS matches against.
run: go run github.com/boumenot/gocover-cobertura@v1.5.0 < coverage.txt > coverage.xml

- name: Build plugin image
# The published :latest image predates the public-github.com base-URL fix,
# so build from the PR source to test the exact code under review.
run: docker build -t pr-code-coverage:ci .

- name: Report coverage on changed lines
env:
PARAMETER_COVERAGE_TYPE: cobertura
PARAMETER_COVERAGE_FILE: coverage.xml
# Must equal the cobertura <source> path (the dir go test ran in).
PARAMETER_SOURCE_DIRS: ${{ github.workspace }}
# Omit PARAMETER_GH_API_BASE_URL -> defaults to https://api.github.com.
PARAMETER_GH_API_KEY: ${{ secrets.GITHUB_TOKEN }}
BUILD_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY_ORG: ${{ github.repository_owner }}
REPOSITORY_NAME: ${{ github.event.repository.name }}
run: |
git fetch --no-tags origin "${{ github.base_ref }}"
git --no-pager diff --unified=0 "origin/${{ github.base_ref }}" -- '*.go' \
| docker run --rm -i \
-e PARAMETER_COVERAGE_TYPE \
-e PARAMETER_COVERAGE_FILE \
-e PARAMETER_SOURCE_DIRS \
-e PARAMETER_GH_API_KEY \
-e BUILD_PULL_REQUEST_NUMBER \
-e REPOSITORY_ORG \
-e REPOSITORY_NAME \
-v "${{ github.workspace }}:${{ github.workspace }}" \
-w "${{ github.workspace }}" \
--entrypoint /plugin \
pr-code-coverage:ci
92 changes: 62 additions & 30 deletions internal/plugin/reporter/github_pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,47 +89,44 @@ func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.So

modules := collectModules(changedLinesWithCoverage)

summaryLines := []string{}

if len(modules) > 0 {
summaryLines = append(summaryLines, fmt.Sprintf("*Modules: %v*\n\n", strings.Join(modules, ", ")))
bodyLines := []string{
"## 📊 Code Coverage — Changed Lines\n",
"\n",
"> Coverage is measured **only for the lines this PR changes**, not the whole file or repo.\n",
"\n",
}
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)
}
if len(modules) > 0 {
bodyLines = append(bodyLines, fmt.Sprintf("*Modules: %v*\n\n", strings.Join(modules, ", ")))
}

summaryLines = append(summaryLines, generateSummaryLines(changedLinesWithCoverage, func(linesWithDataCount int, linesWithoutDataCount int, covered int, missed int) []string {
bodyLines = append(bodyLines, generateSummaryLines(changedLinesWithCoverage, func(linesWithDataCount int, linesWithoutDataCount int, covered int, missed int) []string {
totalLines := linesWithDataCount + linesWithoutDataCount
totalInstructions := covered + missed

result := make([]string, 5)

result[0] = "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 result
coveredPct := toPercent(safeDiv(float32(covered), float32(totalInstructions), 1))
missedPct := toPercent(safeDiv(float32(missed), float32(totalInstructions), 0))
withDataPct := toPercent(safeDiv(float32(linesWithDataCount), float32(totalLines), 1))
withoutDataPct := toPercent(safeDiv(float32(linesWithoutDataCount), float32(totalLines), 0))

return []string{
fmt.Sprintf("### %v Covered Instructions: %.f%% (%d)\n", coverageStatusEmoji(coveredPct), coveredPct, covered),
"\n",
"| Metric | Result | What it means |\n",
"| :-- | :-: | :-- |\n",
fmt.Sprintf("| 🟢 **Covered Instructions** | **%.f%%** (%d) | Changed code your tests executed. Higher is better. |\n", coveredPct, covered),
fmt.Sprintf("| 🔴 **Missed Instructions** | %.f%% (%d) | Changed code your tests never ran. Lower is better. |\n", missedPct, missed),
fmt.Sprintf("| 📈 Lines With Coverage Data | %.f%% (%d) | Changed lines the coverage tool could track. |\n", withDataPct, linesWithDataCount),
fmt.Sprintf("| ⚪ Lines Without Coverage Data | %.f%% (%d) | Changed lines with no data: comments, blanks, declarations. |\n", withoutDataPct, linesWithoutDataCount),
}
})...)

var summary string
if missedInstructions == "" {
summary = strings.Join(summaryLines, "")
} else {

summaryWithoutInstructions := strings.Join(summaryLines, "")
summary = summaryWithoutInstructions + "\n<details><summary>Missed Instructions summary</summary>\n\n" + "```\n" + missedInstructions + "```" +
"\n</details>"
}
body := strings.Join(bodyLines, "")
body += missedInstructionsSection(changedLinesWithCoverage)
body += "\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": summary,
"body": body,
}

dataBytes, marshalErr := s.jsonClient.Marshal(data)
Expand All @@ -141,6 +138,41 @@ func (s *GithubPullRequest) createCommentBody(changedLinesWithCoverage domain.So
return bytes.NewBuffer(dataBytes), nil
}

// missedInstructionsSection renders a collapsible block listing each changed
// line that was not executed by tests. Returns "" when nothing was missed.
func missedInstructionsSection(changedLinesWithCoverage domain.SourceLineCoverageReport) string {
var missedInstructions string
missedLineCount := 0

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

if missedInstructions == "" {
return ""
}

return fmt.Sprintf("\n<details><summary>🔍 Missed instructions (%d)</summary>\n\n", missedLineCount) +
"```\n" + missedInstructions + "```" + "\n</details>\n"
}

// coverageStatusEmoji maps a covered-instruction percentage to a traffic-light
// status icon, so the headline reads at a glance.
func coverageStatusEmoji(coveredPct float32) string {
switch {
case coveredPct >= 80:
return "🟢"
case coveredPct >= 50:
return "🟡"
default:
return "🔴"
}
}

func collectModules(changedLinesWithCoverage domain.SourceLineCoverageReport) []string {
collector := map[string]bool{}

Expand Down
2 changes: 2 additions & 0 deletions internal/plugin/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ func NewRunner() *DefaultRunner {
// nolint: gocyclo
func (*DefaultRunner) Run(propertyGetter func(string) (string, bool), changedSourceLinesSource io.Reader, reportDefaultOut io.Writer) error {

logrus.Info("starting pull-request-code-coverage run")

rawSourceDirs, found := propertyGetter("PARAMETER_SOURCE_DIRS")
if !found {
return errors.New("Missing property PARAMETER_SOURCE_DIRS")
Expand Down
Loading
Loading