chore: add detailed file coverage reporting and sorting functionality

This commit is contained in:
GitHub Actions
2026-02-17 13:59:11 +00:00
parent aefbc5eee8
commit b9bb14694f
3 changed files with 223 additions and 14 deletions

View File

@@ -31,16 +31,17 @@ type artifactsJSON struct {
}
type reportJSON struct {
Baseline string `json:"baseline"`
GeneratedAt string `json:"generated_at"`
Mode string `json:"mode"`
Thresholds thresholdJSON `json:"thresholds"`
ThresholdSources thresholdSourcesJSON `json:"threshold_sources"`
Overall patchreport.ScopeCoverage `json:"overall"`
Backend patchreport.ScopeCoverage `json:"backend"`
Frontend patchreport.ScopeCoverage `json:"frontend"`
Warnings []string `json:"warnings,omitempty"`
Artifacts artifactsJSON `json:"artifacts"`
Baseline string `json:"baseline"`
GeneratedAt string `json:"generated_at"`
Mode string `json:"mode"`
Thresholds thresholdJSON `json:"thresholds"`
ThresholdSources thresholdSourcesJSON `json:"threshold_sources"`
Overall patchreport.ScopeCoverage `json:"overall"`
Backend patchreport.ScopeCoverage `json:"backend"`
Frontend patchreport.ScopeCoverage `json:"frontend"`
FilesNeedingCoverage []patchreport.FileCoverageDetail `json:"files_needing_coverage,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Artifacts artifactsJSON `json:"artifacts"`
}
func main() {
@@ -102,6 +103,9 @@ func main() {
backendScope := patchreport.ComputeScopeCoverage(backendChanged, backendCoverage)
frontendScope := patchreport.ComputeScopeCoverage(frontendChanged, frontendCoverage)
overallScope := patchreport.MergeScopeCoverage(backendScope, frontendScope)
backendFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(backendChanged, backendCoverage, backendThreshold.Value)
frontendFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(frontendChanged, frontendCoverage, frontendThreshold.Value)
filesNeedingCoverage := patchreport.MergeFileCoverageDetails(backendFilesNeedingCoverage, frontendFilesNeedingCoverage)
backendScope = patchreport.ApplyStatus(backendScope, backendThreshold.Value)
frontendScope = patchreport.ApplyStatus(frontendScope, frontendThreshold.Value)
@@ -136,10 +140,11 @@ func main() {
Backend: backendThreshold.Source,
Frontend: frontendThreshold.Source,
},
Overall: overallScope,
Backend: backendScope,
Frontend: frontendScope,
Warnings: warnings,
Overall: overallScope,
Backend: backendScope,
Frontend: frontendScope,
FilesNeedingCoverage: filesNeedingCoverage,
Warnings: warnings,
Artifacts: artifactsJSON{
Markdown: relOrAbs(repoRoot, mdOutPath),
JSON: relOrAbs(repoRoot, jsonOutPath),
@@ -246,6 +251,20 @@ func writeMarkdown(path string, report reportJSON, backendCoveragePath, frontend
builder.WriteString(scopeRow("Frontend", report.Frontend))
builder.WriteString("\n")
if len(report.FilesNeedingCoverage) > 0 {
builder.WriteString("## Files Needing Coverage\n\n")
builder.WriteString("| Path | Patch Coverage (%) | Uncovered Changed Lines | Uncovered Changed Line Ranges |\n")
builder.WriteString("|---|---:|---:|---|\n")
for _, fileCoverage := range report.FilesNeedingCoverage {
ranges := "-"
if len(fileCoverage.UncoveredChangedLineRange) > 0 {
ranges = strings.Join(fileCoverage.UncoveredChangedLineRange, ", ")
}
builder.WriteString(fmt.Sprintf("| `%s` | %.1f | %d | %s |\n", fileCoverage.Path, fileCoverage.PatchCoveragePct, fileCoverage.UncoveredChangedLines, ranges))
}
builder.WriteString("\n")
}
if len(report.Warnings) > 0 {
builder.WriteString("## Warnings\n\n")
for _, warning := range report.Warnings {

View File

@@ -27,6 +27,13 @@ type ScopeCoverage struct {
Status string `json:"status"`
}
type FileCoverageDetail struct {
Path string `json:"path"`
PatchCoveragePct float64 `json:"patch_coverage_pct"`
UncoveredChangedLines int `json:"uncovered_changed_lines"`
UncoveredChangedLineRange []string `json:"uncovered_changed_line_ranges,omitempty"`
}
type ThresholdResolution struct {
Value float64
Source string
@@ -344,6 +351,70 @@ func ApplyStatus(scope ScopeCoverage, minThreshold float64) ScopeCoverage {
return scope
}
func ComputeFilesNeedingCoverage(changedLines FileLineSet, coverage CoverageData, minThreshold float64) []FileCoverageDetail {
details := make([]FileCoverageDetail, 0, len(changedLines))
for filePath, lines := range changedLines {
executable, ok := coverage.Executable[filePath]
if !ok {
continue
}
coveredLines := coverage.Covered[filePath]
executableChanged := 0
coveredChanged := 0
uncoveredLines := make([]int, 0, len(lines))
for lineNo := range lines {
if _, executableLine := executable[lineNo]; !executableLine {
continue
}
executableChanged++
if _, isCovered := coveredLines[lineNo]; isCovered {
coveredChanged++
} else {
uncoveredLines = append(uncoveredLines, lineNo)
}
}
if executableChanged == 0 {
continue
}
patchCoveragePct := roundToOneDecimal(float64(coveredChanged) * 100 / float64(executableChanged))
uncoveredCount := executableChanged - coveredChanged
if uncoveredCount == 0 && patchCoveragePct >= minThreshold {
continue
}
sort.Ints(uncoveredLines)
details = append(details, FileCoverageDetail{
Path: filePath,
PatchCoveragePct: patchCoveragePct,
UncoveredChangedLines: uncoveredCount,
UncoveredChangedLineRange: formatLineRanges(uncoveredLines),
})
}
sortFileCoverageDetails(details)
return details
}
func MergeFileCoverageDetails(groups ...[]FileCoverageDetail) []FileCoverageDetail {
count := 0
for _, group := range groups {
count += len(group)
}
merged := make([]FileCoverageDetail, 0, count)
for _, group := range groups {
merged = append(merged, group...)
}
sortFileCoverageDetails(merged)
return merged
}
func SortedWarnings(warnings []string) []string {
filtered := make([]string, 0, len(warnings))
for _, warning := range warnings {
@@ -466,6 +537,47 @@ func roundToOneDecimal(value float64) float64 {
return float64(int(value*10+0.5)) / 10
}
func formatLineRanges(lines []int) []string {
if len(lines) == 0 {
return nil
}
ranges := make([]string, 0, len(lines))
start := lines[0]
end := lines[0]
for index := 1; index < len(lines); index++ {
lineNo := lines[index]
if lineNo == end+1 {
end = lineNo
continue
}
ranges = append(ranges, formatLineRange(start, end))
start = lineNo
end = lineNo
}
ranges = append(ranges, formatLineRange(start, end))
return ranges
}
func formatLineRange(start, end int) string {
if start == end {
return strconv.Itoa(start)
}
return fmt.Sprintf("%d-%d", start, end)
}
func sortFileCoverageDetails(details []FileCoverageDetail) {
sort.Slice(details, func(left, right int) bool {
if details[left].PatchCoveragePct != details[right].PatchCoveragePct {
return details[left].PatchCoveragePct < details[right].PatchCoveragePct
}
return details[left].Path < details[right].Path
})
}
func validateReadablePath(rawPath string) (string, error) {
trimmedPath := strings.TrimSpace(rawPath)
if trimmedPath == "" {

View File

@@ -293,3 +293,81 @@ func TestValidateReadablePath(t *testing.T) {
}
})
}
func TestComputeFilesNeedingCoverage_IncludesUncoveredAndSortsDeterministically(t *testing.T) {
t.Parallel()
changed := FileLineSet{
"backend/internal/b.go": {1: {}, 2: {}},
"backend/internal/a.go": {1: {}, 2: {}},
"backend/internal/c.go": {1: {}, 2: {}},
}
coverage := CoverageData{
Executable: FileLineSet{
"backend/internal/a.go": {1: {}, 2: {}},
"backend/internal/b.go": {1: {}, 2: {}},
"backend/internal/c.go": {1: {}, 2: {}},
},
Covered: FileLineSet{
"backend/internal/a.go": {1: {}},
"backend/internal/c.go": {1: {}, 2: {}},
},
}
details := ComputeFilesNeedingCoverage(changed, coverage, 40)
if len(details) != 2 {
t.Fatalf("expected 2 files needing coverage, got %d", len(details))
}
if details[0].Path != "backend/internal/b.go" {
t.Fatalf("expected first file to be backend/internal/b.go, got %s", details[0].Path)
}
if details[0].PatchCoveragePct != 0.0 {
t.Fatalf("expected first file coverage 0.0, got %.1f", details[0].PatchCoveragePct)
}
if details[0].UncoveredChangedLines != 2 {
t.Fatalf("expected first file uncovered lines 2, got %d", details[0].UncoveredChangedLines)
}
if strings.Join(details[0].UncoveredChangedLineRange, ",") != "1-2" {
t.Fatalf("expected first file uncovered ranges 1-2, got %v", details[0].UncoveredChangedLineRange)
}
if details[1].Path != "backend/internal/a.go" {
t.Fatalf("expected second file to be backend/internal/a.go, got %s", details[1].Path)
}
if details[1].PatchCoveragePct != 50.0 {
t.Fatalf("expected second file coverage 50.0, got %.1f", details[1].PatchCoveragePct)
}
if details[1].UncoveredChangedLines != 1 {
t.Fatalf("expected second file uncovered lines 1, got %d", details[1].UncoveredChangedLines)
}
if strings.Join(details[1].UncoveredChangedLineRange, ",") != "2" {
t.Fatalf("expected second file uncovered range 2, got %v", details[1].UncoveredChangedLineRange)
}
}
func TestMergeFileCoverageDetails_SortsWorstCoverageThenPath(t *testing.T) {
t.Parallel()
merged := MergeFileCoverageDetails(
[]FileCoverageDetail{
{Path: "frontend/src/z.ts", PatchCoveragePct: 50.0},
{Path: "frontend/src/a.ts", PatchCoveragePct: 50.0},
},
[]FileCoverageDetail{
{Path: "backend/internal/w.go", PatchCoveragePct: 0.0},
},
)
if len(merged) != 3 {
t.Fatalf("expected 3 merged items, got %d", len(merged))
}
orderedPaths := []string{merged[0].Path, merged[1].Path, merged[2].Path}
got := strings.Join(orderedPaths, ",")
want := "backend/internal/w.go,frontend/src/a.ts,frontend/src/z.ts"
if got != want {
t.Fatalf("unexpected merged order: got %s want %s", got, want)
}
}