ADR 0593 §7, §9: the checker asks compose-candidate of each placed node in parallel within its own setting compose-answer-bound (60 s); a refusal fails the check naming node and module, a silent, unready or unreachable node passes with "not composed on that node: no answer". A group with a node-engine member is composed by the composer at that head on the Go build seat over each node's input. The wait on the assigner's compositions goes, and the checker invokes the one engine verb it needs. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
399 lines
14 KiB
Go
399 lines
14 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
|
|
}
|
|
|
|
// failedHead is the head of a failed verdict's line: what failed first, and when more did, how many failed and "and N
|
|
// more" after it (the operator, 2026-11-02), so a line cut to MaxLine still says that more failed. The words of the first
|
|
// are cut to fit room characters when room is not negative; the count and "and N more" are never cut.
|
|
func failedHead(v delivery.Verdict, room int) string {
|
|
first := firstFailed(v)
|
|
n := failedCount(v)
|
|
if n <= 1 {
|
|
return clipRunes("failed: "+first, room)
|
|
}
|
|
prefix, more := fmt.Sprintf("failed (%d): ", n), fmt.Sprintf("; and %d more", n-1)
|
|
if room >= 0 {
|
|
first = clipRunes(first, room-utf8.RuneCountInString(prefix+more))
|
|
}
|
|
return prefix + first + more
|
|
}
|
|
|
|
// clipRunes cuts s to at most n characters, the last an ellipsis; a negative n leaves it whole.
|
|
func clipRunes(s string, n int) string {
|
|
r := []rune(s)
|
|
if n < 0 || len(r) <= n {
|
|
return s
|
|
}
|
|
if n < 1 {
|
|
return ""
|
|
}
|
|
return string(r[:n-1]) + "…"
|
|
}
|
|
|
|
// failedCount is how many failures a verdict names, 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
|
|
// warning says that a part of the check did not run, for instance that an older build module could not refuse a compiled program; never dropped.
|
|
warning 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.warning, 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.warning, 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 engines answered (ADR 0593 §7) passed, with the nodes
|
|
// that gave no answer, 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, ", "))
|
|
}
|
|
}
|
|
warned := ""
|
|
if c.State == delivery.CompositionPass && c.Words != "" {
|
|
warned = " (" + c.Words + ")" // a node that did not answer (ADR 0593 §9)
|
|
}
|
|
switch {
|
|
case c.State == delivery.CompositionPass && short:
|
|
return "composition passed" + in + on + warned // what it composed with is in the report
|
|
case c.State == delivery.CompositionPass:
|
|
return "composition passed" + in + " with " + short12(c.SDK) + on + warned
|
|
}
|
|
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)]
|
|
if cs != nil {
|
|
pc.warning = cs.Warning(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 = failedHead(v, -1)
|
|
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)
|
|
if v.State == delivery.VerdictFail {
|
|
// The head gives way first to the commits and what is never dropped: its count and "and N more" stay whole.
|
|
kept := joinSet(pc.commits, pc.carried, pc.warning, pc.judged)
|
|
pc.head = failedHead(v, MaxLine-utf8.RuneCountInString(" | "+kept))
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if v.State == delivery.VerdictFail {
|
|
// A failure's head, sized above, and the commits fit: the counts and the composition are in the report.
|
|
if s := joinSet(pc.head, pc.commits, pc.carried, pc.warning, pc.judged); utf8.RuneCountInString(s) <= MaxLine {
|
|
return s
|
|
}
|
|
}
|
|
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, " | ")
|
|
}
|