mirror of
https://github.com/edgelesssys/constellation.git
synced 2025-05-02 22:34:56 -04:00
ci: comment Go coverage report on PR
Signed-off-by: Paul Meyer <49727155+katexochen@users.noreply.github.com>
This commit is contained in:
parent
5272e7c86f
commit
11efc8d512
8 changed files with 723 additions and 24 deletions
238
hack/gocoverage/main.go
Normal file
238
hack/gocoverage/main.go
Normal file
|
@ -0,0 +1,238 @@
|
|||
/*
|
||||
Copyright (c) Edgeless Systems GmbH
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/*
|
||||
gocoverage parses 'go test -cover' output and generates a simple coverage report in JSON format.
|
||||
It can also be used to create a diff of two reports, filter for packages that were touched in
|
||||
a PR, and print a markdown table with the diff.
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type packageCoverage struct {
|
||||
Coverage float64
|
||||
Notest bool
|
||||
Nostmt bool
|
||||
}
|
||||
|
||||
func (c *packageCoverage) String() string {
|
||||
switch {
|
||||
case c == nil: // is nil if pkg was removed/added
|
||||
return fmt.Sprintf("%.2f%%", 0.0)
|
||||
case c.Notest:
|
||||
return "[no test files]"
|
||||
case c.Nostmt:
|
||||
return "[no statements]"
|
||||
default:
|
||||
return fmt.Sprintf("%.2f%%", c.Coverage)
|
||||
}
|
||||
}
|
||||
|
||||
type metadata struct {
|
||||
Created string
|
||||
}
|
||||
|
||||
type report struct {
|
||||
Metadate metadata
|
||||
Coverage map[string]*packageCoverage
|
||||
}
|
||||
|
||||
func main() {
|
||||
dodiff := flag.Bool("diff", false, "diff reports")
|
||||
touched := flag.String("touched", "", "list of touched packages, comma separated")
|
||||
flag.Parse()
|
||||
switch {
|
||||
case *dodiff:
|
||||
if err := diff(flag.Arg(0), flag.Arg(1), *touched); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case len(flag.Args()) == 0:
|
||||
if err := parseStreaming(os.Stdin, os.Stdout); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "gocoverage - generate coverage report from go test output and diff reports")
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " go test -coverage ./... | gocoverage > report.json")
|
||||
fmt.Fprintln(os.Stderr, " gocoverage -touched pkgs/foo,pkg/bar -diff old.json new.json > diff.md")
|
||||
}
|
||||
}
|
||||
|
||||
func parseStreaming(in io.Reader, out io.Writer) error {
|
||||
rep, err := parseTestOutput(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(out).Encode(rep)
|
||||
}
|
||||
|
||||
func parseTestOutput(r io.Reader) (report, error) {
|
||||
rep := report{
|
||||
Metadate: metadata{Created: time.Now().UTC().Format(time.RFC3339)},
|
||||
Coverage: make(map[string]*packageCoverage),
|
||||
}
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
pkg, cov, err := parseTestLine(scanner.Text())
|
||||
if err != nil {
|
||||
return report{}, err
|
||||
}
|
||||
rep.Coverage[pkg] = &cov
|
||||
}
|
||||
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func parseTestLine(line string) (string, packageCoverage, error) {
|
||||
fields := strings.Fields(line)
|
||||
coverage := packageCoverage{}
|
||||
var pkg string
|
||||
for i := 0; i < len(fields); i++ {
|
||||
f := fields[i]
|
||||
switch {
|
||||
case f == "FAIL":
|
||||
return "", packageCoverage{}, errors.New("test failed")
|
||||
|
||||
case strings.HasPrefix(f, "github.com/"):
|
||||
pkg = f
|
||||
|
||||
case f == "coverage:" && fields[i+1] != "[no":
|
||||
covStr := strings.TrimSuffix(fields[i+1], "%")
|
||||
cov, err := strconv.ParseFloat(covStr, 64)
|
||||
if err != nil {
|
||||
return "", packageCoverage{}, fmt.Errorf("parsing coverage: %v", err)
|
||||
}
|
||||
coverage.Coverage = cov
|
||||
|
||||
case f == "[no":
|
||||
switch fields[i+1] {
|
||||
case "test": // [no test files]
|
||||
coverage.Notest = true
|
||||
case "statements]": // [no statements]
|
||||
coverage.Nostmt = true
|
||||
// ignoring [no tests to run], as it also prints coverage 0%
|
||||
}
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return pkg, coverage, nil
|
||||
}
|
||||
|
||||
func diff(old, new, touched string) error {
|
||||
oldf, err := os.Open(old)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer oldf.Close()
|
||||
var oldRep, newReport report
|
||||
if err := json.NewDecoder(oldf).Decode(&oldRep); err != nil {
|
||||
return err
|
||||
}
|
||||
newf, err := os.Open(new)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer newf.Close()
|
||||
if err := json.NewDecoder(newf).Decode(&newReport); err != nil {
|
||||
return err
|
||||
}
|
||||
diffs, err := diffCoverage(oldRep, newReport)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
touchedPkgs := strings.Split(touched, ",")
|
||||
return diffsToMd(diffs, os.Stdout, touchedPkgs)
|
||||
}
|
||||
|
||||
type coverageDiff struct {
|
||||
old *packageCoverage
|
||||
new *packageCoverage
|
||||
}
|
||||
|
||||
func diffCoverage(old, new report) (map[string]coverageDiff, error) {
|
||||
allPkgs := make(map[string]struct{})
|
||||
for pkg := range old.Coverage {
|
||||
allPkgs[pkg] = struct{}{}
|
||||
}
|
||||
for pkg := range new.Coverage {
|
||||
allPkgs[pkg] = struct{}{}
|
||||
}
|
||||
diffs := make(map[string]coverageDiff)
|
||||
for pkg := range allPkgs {
|
||||
diffs[pkg] = coverageDiff{old: old.Coverage[pkg], new: new.Coverage[pkg]}
|
||||
if diffs[pkg].old == nil && diffs[pkg].new == nil {
|
||||
return nil, fmt.Errorf("both old and new coverage are nil for pkg %s", pkg)
|
||||
}
|
||||
}
|
||||
return diffs, nil
|
||||
}
|
||||
|
||||
const pkgPrefix = "github.com/edgelesssys/constellation/v2/"
|
||||
|
||||
func diffsToMd(diffs map[string]coverageDiff, out io.Writer, touchedPkgs []string) error {
|
||||
pkgs := make([]string, 0, len(diffs))
|
||||
for pkg := range diffs {
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
sort.Strings(pkgs)
|
||||
|
||||
fmt.Fprintln(out, "### Coverage report")
|
||||
fmt.Fprintln(out, "| Package | Old | New | Trend |")
|
||||
fmt.Fprintln(out, "| ------- | ---- | ---- | ----- |")
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
diff := diffs[pkg]
|
||||
var tendency string
|
||||
switch {
|
||||
case len(touchedPkgs) != 0 && !slices.Contains(touchedPkgs, strings.TrimPrefix(pkg, pkgPrefix)):
|
||||
continue // pkg was not touched
|
||||
case diff.old == nil && (diff.new.Notest || diff.new.Nostmt):
|
||||
tendency = ":rotating_light:" // new pkg, no tests
|
||||
case diff.old == nil:
|
||||
tendency = ":new:" // pkg is new
|
||||
case diff.new == nil:
|
||||
continue // pkg was removed
|
||||
case (diff.new.Nostmt || diff.new.Notest) && (diff.old.Notest || diff.old.Nostmt):
|
||||
tendency = ":construction:" // this is bad, but not worse than before
|
||||
case (diff.new.Nostmt || diff.new.Notest) && !(diff.old.Coverage > 0.0):
|
||||
tendency = ":rotating_light:" // tests were removed
|
||||
case diff.old.Coverage == diff.new.Coverage && diff.new.Coverage == 0.0:
|
||||
tendency = ":construction:" // no tests before, no tests now - do something about it!
|
||||
case diff.old.Coverage == diff.new.Coverage && diff.new.Coverage < 50.0:
|
||||
tendency = ":construction:" // heuristically still bad - you touch it, you own it.
|
||||
case diff.old.Coverage < diff.new.Coverage:
|
||||
tendency = ":arrow_upper_right:"
|
||||
case diff.old.Coverage > diff.new.Coverage:
|
||||
tendency = ":arrow_lower_right:"
|
||||
case diff.old.Coverage == diff.new.Coverage:
|
||||
tendency = ":left_right_arrow:"
|
||||
default:
|
||||
return fmt.Errorf("unexpected case: old: %v, new: %v", diff.old, diff.new)
|
||||
}
|
||||
oldCov := diff.old.String()
|
||||
newCov := diff.new.String()
|
||||
fmt.Fprintf(out, "| %s | %s | %s | %s |\n", strings.TrimPrefix(pkg, pkgPrefix), oldCov, newCov, tendency)
|
||||
}
|
||||
return nil
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue