Files
mesh-delivery/modules/mesh-delivery-checker/internal/check/line.go
T
jschoubben 6fe7b7dd6c Name every failed test in a check's verdict and a try's failed list (issue 1921)
The checker stopped reading a level's test results at the first that did
not pass and kept only its first failure, so a check named one fault per
round. It now reads and counts every result and writes every failed test
with its first line into the verdict's Failures and the try's failed
list (at most 50, then how many more); Failed keeps the first one's
words whole (issue 1901). Pins mesh-sdk fix/1921-every-failure.
2026-10-24 23:44:46 +02:00

309 lines
10 KiB
Go

package check
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// MaxLine is the most characters a status's line has: the forge shows a status's description in a short space, and the full
// report is linked (ADR 0447 clause 3, ADR 0484 clause 7). A line that would be longer drops its lesser parts in a fixed
// order, and is cut last.
const MaxLine = 140
// seconds is a time in milliseconds in the unit a person reads: 350ms, 4.1s, 72s.
func seconds(ms int64) string {
switch {
case ms < 1000:
return fmt.Sprintf("%dms", ms)
case ms < 10000:
return fmt.Sprintf("%.1fs", float64(ms)/1000)
}
return fmt.Sprintf("%ds", (ms+500)/1000)
}
func short12(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
// firstFailed is what failed first, in words: a conflict, a refused step or a test, named before anything else.
func firstFailed(v delivery.Verdict) string {
f := v.Failed
switch {
case f.Test != "":
if w := firstWordsLine(f.Words); w != "" {
return f.Test + ": " + w
}
return f.Test
case f.Machine != "" && firstWordsLine(f.Words) != "":
return f.Machine + ": " + firstWordsLine(f.Words)
case f.Machine != "":
return f.Machine
}
return firstWordsLine(f.Words)
}
// failedLines is everything a verdict names as failed, one line each: every failed test with its first line (issue
// 1921), or what failed first when the verdict names no list (a conflict, a refused step, an older verdict).
func failedLines(v delivery.Verdict) []string {
if len(v.Failures) == 0 {
if f := firstFailed(v); f != "" {
return []string{f}
}
return nil
}
lines := make([]string, 0, len(v.Failures))
for _, f := range v.Failures {
lines = append(lines, firstFailed(delivery.Verdict{Failed: f}))
}
return lines
}
// failedCount is how many tests a verdict names as failed, the ones after delivery.MaxFailures counted too.
func failedCount(v delivery.Verdict) int {
n := len(v.Failures)
if n > delivery.MaxFailures {
var more int
if _, err := fmt.Sscanf(v.Failures[n-1].Test, "and %d more", &more); err == nil {
return delivery.MaxFailures + more
}
}
return n
}
// firstWordsLine is the first line of a failure's words: the words are several lines (issue 1901) and the status line one.
func firstWordsLine(w string) string { return firstLine(w) }
// pieces are the parts of a line, from the most to the least needed.
type pieces struct {
head, commits, counts, shortCounts, times, shortTimes, over, builds, composition, shortComposition, group string
// judged names the program that judged a change to a build module (ADR 0535 §5); never dropped.
judged string
}
// pullKey names a pull request of a case: `<owner/repo>#<number>`.
func pullKey(p Pull) string { return fmt.Sprintf("%s#%d", p.Repository, p.Number) }
func (p pieces) join(drop int) string {
parts := []string{p.head, p.commits, p.counts, p.judged}
switch {
case drop < 3 && p.times != "":
parts = append(parts, p.times)
case drop == 3 && p.shortTimes != "":
parts = []string{p.head, p.commits, p.shortCounts, p.judged, p.shortTimes}
}
if p.over != "" {
parts = append(parts, p.over)
}
if drop < 1 && p.builds != "" {
parts = append(parts, p.builds)
}
if drop < 2 && p.group != "" {
parts = append(parts, p.group)
}
switch {
case drop >= 3 && p.shortComposition != "":
parts = append(parts, p.shortComposition)
case p.composition != "":
parts = append(parts, p.composition)
}
return joinSet(parts...)
}
func levelWords(name string, r delivery.LevelResult) string {
s := fmt.Sprintf("%s %d run, %d kept", name, r.Run, r.Kept)
if r.Skipped > 0 {
s += fmt.Sprintf(", %d skipped", r.Skipped)
}
return s
}
// compositionWords says the composition: not yet run, or once the assigner's check reports it (issue 1297) passed or
// failed, with the time it took and the machines it composed (issue 1300).
func compositionWords(c *delivery.Composition, took int64) string { return composition(c, took, false) }
// composition is compositionWords; short counts the machines and leaves the SDK commit to the report.
func composition(c *delivery.Composition, took int64, short bool) string {
if c != nil && c.State == delivery.CompositionNotRun && c.Words != "" {
return "composition not judged: " + c.Words
}
if c == nil || c.State == delivery.CompositionNotRun {
return "composition not yet run"
}
in := ""
if took > 0 {
in = " in " + seconds(took)
}
on := ""
if len(c.Machines) > 0 {
machines := slices.Sorted(slices.Values(c.Machines))
on = " on " + strings.Join(machines, ", ")
switch {
case len(machines) > 1 && short:
on = fmt.Sprintf(" on %d machines", len(machines))
case len(machines) > 1:
on = fmt.Sprintf(" on %d machines: %s", len(machines), strings.Join(machines, ", "))
}
}
switch {
case c.State == delivery.CompositionPass && short:
return "composition passed" + in + on // the SDK it composed with is in the report
case c.State == delivery.CompositionPass:
return "composition passed" + in + " with " + short12(c.SDK) + on
}
return "composition failed" + in + on + ": " + c.Words
}
func buildWords(builds []BuildNote) string {
if len(builds) == 0 {
return ""
}
parts := make([]string, len(builds))
for i, b := range builds {
parts[i] = b.Module + " " + b.State
}
return "builds: " + strings.Join(parts, ", ")
}
// parts makes the pieces of the line of a pull request of a case.
func parts(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) pieces {
var pc pieces
pc.judged = judged[pullKey(p)]
commits := short12(p.Head) + " on " + short12(p.Trunk)
switch v.State {
case delivery.VerdictRunning:
pc.head = "checking " + commits
case delivery.VerdictPass:
pc.head = "pass " + commits
default:
pc.head = "failed: " + firstFailed(v)
if n := failedCount(v); n > 1 {
pc.head = fmt.Sprintf("failed (%d tests): %s", n, firstFailed(v))
}
pc.commits = commits
}
var counts []string
for _, level := range []string{delivery.LevelUnit, delivery.LevelIntegration} {
if r, ok := v.Levels[level]; ok {
counts = append(counts, levelWords(level, r))
}
}
pc.counts = strings.Join(counts, " | ")
var short []string
for _, level := range []string{delivery.LevelUnit, delivery.LevelIntegration} {
if r, ok := v.Levels[level]; ok {
name := map[string]string{delivery.LevelUnit: "unit", delivery.LevelIntegration: "int"}[level]
w := fmt.Sprintf("%s %d run %d kept", name, r.Run, r.Kept)
if r.Skipped > 0 {
w += fmt.Sprintf(" %d skipped", r.Skipped)
}
short = append(short, w)
}
}
pc.shortCounts = strings.Join(short, " | ")
if v.State != delivery.VerdictRunning {
t := v.Took
var times []string
for _, x := range []struct {
name string
ms int64
}{{"queue", t.Queue}, {"clone", t.Clone}, {"lint", t.Lint}, {"unit", t.Unit}, {"int setup", t.IntegrationSetup}, {"int run", t.IntegrationRun}} {
if x.ms > 0 {
times = append(times, x.name+" "+seconds(x.ms))
}
}
if len(times) > 0 {
pc.times = strings.Join(times, ", ")
}
// The compact form for a line that is too long: the parts that dominate, the integration level's setup and run as one.
short = short[:0:0]
if t.Clone > 0 {
short = append(short, "clone "+seconds(t.Clone))
}
if t.Lint > 0 {
short = append(short, "lint "+seconds(t.Lint))
}
if t.Unit > 0 {
short = append(short, "unit "+seconds(t.Unit))
}
switch {
case t.IntegrationSetup > 0 && t.IntegrationRun > 0:
short = append(short, "int "+seconds(t.IntegrationSetup)+"+"+seconds(t.IntegrationRun))
case t.IntegrationRun > 0:
short = append(short, "int "+seconds(t.IntegrationRun))
}
pc.shortTimes = strings.Join(short, " ")
}
if len(v.OverBound) > 0 {
pc.over = "over bound: " + strings.Join(v.OverBound, ", ")
if len(slowest) > 0 {
pc.over += " (slowest " + strings.Join(slowest, ", ") + ")"
}
}
pc.builds = buildWords(builds)
if v.State != delivery.VerdictRunning {
pc.composition = compositionWords(v.Composition, v.Took.Composition)
pc.shortComposition = composition(v.Composition, v.Took.Composition, true)
}
if cs != nil && len(cs.Pulls) > 1 {
pc.group = fmt.Sprintf("group %s of %d pull requests", cs.Group, len(cs.Pulls))
if v.State != delivery.VerdictPass {
pc.group = fmt.Sprintf("%s#%d in group %s", p.Repository, p.Number, cs.Group)
}
}
return pc
}
// Report is the full line of a pull request's status, with no limit: what the read tools and the full report say.
func Report(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) string {
return parts(v, cs, p, builds, slowest, judged).join(0)
}
// Line is the words of a status for one pull request of a case (ADR 0447 clause 3, ADR 0484 clause 7): the verdict first (a
// failure names the conflict, the refused step or the test before anything), the head and the trunk commit as twelve
// characters each, the tests run, kept and skipped per level, the time of each part, a level over its bound with its slowest
// tests, the builds of the touched modules, and that the composition was not yet run; for a change to a build module, the
// program that judged it (judged, by pullKey; ADR 0535 §5). It is at most MaxLine characters: the
// builds, then the group drop first, then the times are given in their compact form, then dropped, and the line is cut at last.
// The program that judged is never dropped: a line naming it gives the counts in their compact form, then drops them and
// what follows, and is cut in its first part rather than lose it.
func Line(v delivery.Verdict, cs *Case, p Pull, builds []BuildNote, slowest []string, judged map[string]string) string {
pc := parts(v, cs, p, builds, slowest, judged)
for drop := 0; drop <= 4; drop++ {
if s := pc.join(drop); utf8.RuneCountInString(s) <= MaxLine {
return s
}
}
if pc.judged != "" {
for _, counts := range []string{pc.shortCounts, ""} {
if s := joinSet(pc.head, pc.commits, counts, pc.judged); utf8.RuneCountInString(s) <= MaxLine {
return s
}
}
rest := " | " + joinSet(pc.commits, pc.judged)
room := MaxLine - utf8.RuneCountInString(rest) - 1
if head := []rune(pc.head); room > 0 && len(head) > room {
return string(head[:room]) + "…" + rest
}
}
runes := []rune(pc.join(4))
return string(runes[:MaxLine-1]) + "…"
}
// joinSet joins the parts that are not empty.
func joinSet(parts ...string) string {
out := make([]string, 0, len(parts))
for _, s := range parts {
if s != "" {
out = append(out, s)
}
}
return strings.Join(out, " | ")
}