354 lines
12 KiB
Go
354 lines
12 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
|
|
// carried says, of a carried verdict, the commits it was carried from (ADR 0548 §8); never dropped.
|
|
carried 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.carried, 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.carried, 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, ", ")
|
|
}
|
|
|
|
// carriedWords say the commits a verdict was carried from, for the line of pull request p (ADR 0548 §8): its own trunk's
|
|
// commit as twelve characters, a sibling's with its name before it; only those that moved, sorted. Empty when none did.
|
|
func carriedWords(from map[string]string, v delivery.Verdict, cs *Case, p Pull) string {
|
|
var own string
|
|
var others []string
|
|
for r, old := range from {
|
|
now, ok := v.Trunk[r]
|
|
if cs != nil && !ok {
|
|
now, ok = cs.Trunk[r]
|
|
}
|
|
if !ok {
|
|
now = v.Beside[r]
|
|
}
|
|
if now == "" || now == old {
|
|
continue
|
|
}
|
|
if strings.EqualFold(r, p.Repository) {
|
|
own = short12(old)
|
|
continue
|
|
}
|
|
name := r
|
|
if i := strings.LastIndex(r, "/"); i >= 0 {
|
|
name = r[i+1:]
|
|
}
|
|
others = append(others, name+" "+short12(old))
|
|
}
|
|
slices.Sort(others)
|
|
if own != "" {
|
|
others = append([]string{own}, others...)
|
|
}
|
|
return strings.Join(others, ", ")
|
|
}
|
|
|
|
// 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
|
|
if cs != nil && cs.CarriedFrom != nil {
|
|
// Out of date and being carried to the new commit (ADR 0548 §6): pending, and saying so.
|
|
pc.head = "being carried to " + commits
|
|
if w := carriedWords(cs.CarriedFrom, v, cs, p); w != "" {
|
|
pc.carried = "from " + w
|
|
}
|
|
}
|
|
case delivery.VerdictPass:
|
|
pc.head = "pass " + commits
|
|
if w := carriedWords(v.CarriedFrom, v, cs, p); w != "" {
|
|
pc.carried = "carried from " + w // the counts that follow say what the carry ran and kept
|
|
}
|
|
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, " | ")
|
|
}
|