Judge a head merged onto its trunk through the build modules: run only packages without a kept verdict, the change's own tests first, stop at the first failure (issues 1679, 1288, 1289)
This commit is contained in:
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
@@ -12,7 +14,6 @@ import (
|
||||
|
||||
// judgeDeps are the parts the judge of a check is made of.
|
||||
type judgeDeps struct {
|
||||
checker *check.Checker
|
||||
runner buildrun.Runner
|
||||
tools *buildrun.ToolchainSet
|
||||
presence *buildrun.PresenceSet
|
||||
@@ -21,15 +22,90 @@ type judgeDeps struct {
|
||||
trunks *check.Trunks
|
||||
kept check.KV
|
||||
previews check.KV
|
||||
repos *repoModules
|
||||
logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
var _ = log.Printf
|
||||
|
||||
type unbuilt struct{}
|
||||
|
||||
func (unbuilt) Judge(context.Context, *check.Case) (check.Outcome, error) {
|
||||
return check.Outcome{}, errors.New("the judge of a check is not built yet")
|
||||
func newJudge(d judgeDeps) check.Judge {
|
||||
return &check.Examiner{Steps: d.runner, Toolchains: d.tools, Presence: d.presence, Health: d.have, Places: d.places,
|
||||
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Trunks: d.trunks, Logf: d.logf}
|
||||
}
|
||||
|
||||
func newJudge(judgeDeps) check.Judge { return unbuilt{} }
|
||||
// manifestKV reads a manifest by its digest.
|
||||
type manifestKV interface {
|
||||
Get(ctx context.Context, key string) (value []byte, revision uint64, found bool, err error)
|
||||
}
|
||||
|
||||
// repoModules knows which build modules judge a repository, from the indexer's modules and their manifests: the Go build
|
||||
// module always (a repository's go.mod files are its own), the Node one when a module of the repository has a TypeScript
|
||||
// artifact (ADR 0484 clause 10). Safe for use from the watch and from a check at once.
|
||||
type repoModules struct {
|
||||
manifests manifestKV
|
||||
logf func(format string, args ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
langs map[string]map[string]bool // by manifest digest: the languages of its artifacts
|
||||
repoOf map[string]string // by module: its repository
|
||||
typed map[string]string // by module: its manifest digest
|
||||
}
|
||||
|
||||
// Put takes a change of the indexer's modules bucket in: a module's repository and the languages its manifest names.
|
||||
func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted bool) {
|
||||
r.mu.Lock()
|
||||
if r.repoOf == nil {
|
||||
r.repoOf, r.typed, r.langs = map[string]string{}, map[string]string{}, map[string]map[string]bool{}
|
||||
}
|
||||
if deleted {
|
||||
delete(r.repoOf, key)
|
||||
delete(r.typed, key)
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.mu.Unlock()
|
||||
m, err := delivery.DecodeModule(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.repoOf[key] = m.Repository
|
||||
r.typed[key] = m.Manifest
|
||||
_, known := r.langs[m.Manifest]
|
||||
r.mu.Unlock()
|
||||
if m.Manifest == "" || known {
|
||||
return
|
||||
}
|
||||
set := map[string]bool{}
|
||||
if mk, err := delivery.ManifestsKey(m.Manifest); err == nil {
|
||||
if raw, _, found, err := r.manifests.Get(ctx, mk); err == nil && found {
|
||||
if kept, err := delivery.DecodeManifest(raw); err == nil {
|
||||
if man, err := catalogue.ParseManifest([]byte(kept.JSON)); err == nil && man.Build != nil {
|
||||
for _, a := range man.Build.Artifacts {
|
||||
if a.Language != "" {
|
||||
set[a.Language] = true
|
||||
}
|
||||
}
|
||||
} else if err != nil && r.logf != nil {
|
||||
r.logf("the manifest %s cannot be read, so its module counts as no TypeScript: %v", m.Manifest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.langs[m.Manifest] = set
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// BuildModules are the build modules that judge a repository, sorted.
|
||||
func (r *repoModules) BuildModules(repository string) []string {
|
||||
out := []string{buildrun.GoBuildModule}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for module, repo := range r.repoOf {
|
||||
if repo == repository && r.langs[r.typed[module]]["typescript"] {
|
||||
out = append(out, buildrun.NodeBuildModule)
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
|
||||
)
|
||||
|
||||
type memManifests map[string][]byte
|
||||
|
||||
func (m memManifests) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
v, ok := m[key]
|
||||
return v, 1, ok, nil
|
||||
}
|
||||
|
||||
func putModule(t *testing.T, r *repoModules, name, repository, manifest string) {
|
||||
t.Helper()
|
||||
data, err := delivery.EncodeModule(delivery.Module{Repository: repository, Commit: strings.Repeat("a", 40), FP: "src1:" + strings.Repeat("a", 64), Manifest: delivery.ManifestDigest([]byte(manifest))})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Put(context.Background(), name, data, false)
|
||||
}
|
||||
|
||||
func TestARepositoryWithATypeScriptModuleIsJudgedByTheNodeBuildModuleToo(t *testing.T) { // 1292/AC2
|
||||
own, err := os.ReadFile("../../module.json") // a manifest with a Go bundle: this module's own
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goManifest := string(own)
|
||||
// A TypeScript bundle names neither a system nor a directory it is built from (the catalogue's parser says so).
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(own, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifacts := doc["build"].(map[string]any)["artifacts"].([]any)
|
||||
program := artifacts[0].(map[string]any)
|
||||
program["language"] = "typescript"
|
||||
delete(program, "system")
|
||||
delete(program, "from")
|
||||
delete(program, "binary")
|
||||
tsRaw, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tsManifest := string(tsRaw)
|
||||
store := memManifests{}
|
||||
for _, m := range []string{goManifest, tsManifest} {
|
||||
data, err := delivery.EncodeManifest(delivery.Manifest{Module: "demo", JSON: m})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store[delivery.ManifestDigest([]byte(m))] = data
|
||||
}
|
||||
r := &repoModules{manifests: store}
|
||||
putModule(t, r, "mesh-delivery-checker", "novox/mesh-delivery", goManifest)
|
||||
putModule(t, r, "mesh-ui", "novox/mesh-catalog", tsManifest)
|
||||
if got := r.BuildModules("novox/mesh-delivery"); strings.Join(got, ",") != buildrun.GoBuildModule {
|
||||
t.Errorf("mesh-delivery: %v, want the Go build module alone", got)
|
||||
}
|
||||
if got := r.BuildModules("novox/mesh-catalog"); strings.Join(got, ",") != buildrun.GoBuildModule+","+buildrun.NodeBuildModule {
|
||||
t.Errorf("mesh-catalog: %v, want both", got)
|
||||
}
|
||||
if got := r.BuildModules("novox/mesh-sdk"); strings.Join(got, ",") != buildrun.GoBuildModule {
|
||||
t.Errorf("a repository with no module: %v, want the Go build module", got)
|
||||
}
|
||||
}
|
||||
@@ -192,10 +192,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
Link: link,
|
||||
Logf: logger.Printf,
|
||||
}
|
||||
repos := &repoModules{manifests: kvStore{buckets[delivery.ManifestsBucket]}, logf: logger.Printf}
|
||||
ch.Judge = newJudge(judgeDeps{
|
||||
checker: ch, runner: runner, tools: tools, presence: presence, have: have, places: places, trunks: trunks,
|
||||
runner: runner, tools: tools, presence: presence, have: have, places: places, trunks: trunks,
|
||||
kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]},
|
||||
logf: logger.Printf,
|
||||
repos: repos, logf: logger.Printf,
|
||||
})
|
||||
|
||||
// The toolchains first, so a check that starts from the first candidate finds the nodes.
|
||||
@@ -220,7 +221,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
}
|
||||
defer cc.Stop()
|
||||
}
|
||||
errs := make(chan error, 6)
|
||||
errs := make(chan error, 8)
|
||||
// The watchman's presence says which nodes are asleep. Where its bucket is not there yet no node is online, so nothing
|
||||
// is checked; the checker says so and is started again with the watchman.
|
||||
switch kv, err := js.KeyValue(ctx, delivery.PresenceBucket); {
|
||||
@@ -285,6 +286,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
})
|
||||
}()
|
||||
}
|
||||
// The modules say which repository holds a TypeScript module, so the Node build module judges it too.
|
||||
mw, err := settle(ctx, buckets[delivery.ModulesBucket], func(e jetstream.KeyValueEntry) { repos.Put(ctx, e.Key(), e.Value(), !opOf(e)) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
errs <- follow(ctx, mw, func(e jetstream.KeyValueEntry) { repos.Put(ctx, e.Key(), e.Value(), !opOf(e)) })
|
||||
}()
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
ch.Candidate(ctx, e.Key(), e.Value())
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package check
|
||||
|
||||
import "strconv"
|
||||
|
||||
// BesideRef is a sibling repository a repository's tests read, placed beside the fetched tree as a plain directory (ADR
|
||||
// 0484 clause 4). Ref names the pull request's head when the commit is only there.
|
||||
type BesideRef struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
}
|
||||
|
||||
// besideRules are the repositories each core repository's tests read, as the tests state it (mesh-sdk go/internal/beside
|
||||
// beside.go): the catalogue at its trunk, the node-engine, the SDK as the controller pins it, and the delivery's pinned
|
||||
// forge dependencies (mesh-catalog checks/deps). The checker chooses which sibling at which commit; the build module only
|
||||
// places what it is told.
|
||||
var besideRules = map[string][]string{
|
||||
"novox/mesh-controller": {"novox/mesh-catalog", "novox/mesh-host", "novox/mesh-sdk"},
|
||||
"novox/mesh-host": {"novox/mesh-catalog", "novox/mesh-sdk"},
|
||||
"novox/mesh-sdk": {"novox/mesh-catalog", "novox/mesh-host"},
|
||||
"novox/mesh-catalog": {"novox/mesh-sdk", "novox/mesh-controller"},
|
||||
"novox/mesh-delivery": {"novox/mesh-sdk", "novox/mesh-catalog"},
|
||||
}
|
||||
|
||||
// Beside are the siblings a pull request's tests read: each at its trunk head, and at the head of the group's pull request
|
||||
// when the group holds one of that repository (a group's members are checked together, ADR 0482 clause 2) — except the
|
||||
// catalogue, which the tests read at its trunk even when the group holds a catalogue pull request (beside.go).
|
||||
func (j *Examiner) Beside(c *Case, p Pull) []BesideRef {
|
||||
var heads map[string]string
|
||||
if j.Trunks != nil {
|
||||
heads = j.Trunks.Heads()
|
||||
}
|
||||
var out []BesideRef
|
||||
for _, repo := range besideRules[p.Repository] {
|
||||
if member := c.member(repo); member != nil && repo != "novox/mesh-catalog" {
|
||||
out = append(out, BesideRef{Repository: repo, Commit: member.Head, Ref: refOf(member.Number)})
|
||||
continue
|
||||
}
|
||||
if head := heads[repo]; head != "" {
|
||||
out = append(out, BesideRef{Repository: repo, Commit: head})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Case) member(repository string) *Pull {
|
||||
for i := range c.Pulls {
|
||||
if c.Pulls[i].Repository == repository {
|
||||
return &c.Pulls[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func refOf(number int) string {
|
||||
if number <= 0 {
|
||||
return ""
|
||||
}
|
||||
return "refs/pull/" + itoa(number) + "/head"
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
@@ -23,6 +23,8 @@ const NodesWords = "build node qualifies"
|
||||
type Outcome struct {
|
||||
Verdict delivery.Verdict
|
||||
Builds []BuildNote
|
||||
// Slowest names the slowest tests of a level that went over its bound ("unit pkg.Test 1.2s").
|
||||
Slowest []string
|
||||
}
|
||||
|
||||
// BuildNote is the state of a candidate's module build, by module: built, failed or waiting (ADR 0484 clause 7).
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
|
||||
)
|
||||
|
||||
// captured reads a result the build module's own code marshalled (testdata/captured/README).
|
||||
func captured[T any](t testing.TB, name string) T {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "captured", name+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v T
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type stepCall struct {
|
||||
module, node, tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
// fakeSteps stands for the build modules: a handler for each tool answers what the module's code answers.
|
||||
type fakeSteps struct {
|
||||
mu sync.Mutex
|
||||
calls []stepCall
|
||||
handlers map[string]func(c stepCall) (any, error)
|
||||
}
|
||||
|
||||
func (f *fakeSteps) Run(_ context.Context, module, node, tool string, args map[string]any) (json.RawMessage, error) {
|
||||
c := stepCall{module, node, tool, args}
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, c)
|
||||
h := f.handlers[tool]
|
||||
f.mu.Unlock()
|
||||
if h == nil {
|
||||
return nil, fmt.Errorf("the fake build module has no tool %s", tool)
|
||||
}
|
||||
v, err := h(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (f *fakeSteps) tool(name string) []stepCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []stepCall
|
||||
for _, c := range f.calls {
|
||||
if strings.HasSuffix(c.tool, "_"+name) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type entries []delivery.NodeToolchain
|
||||
|
||||
func (e entries) Entries(language string) []delivery.NodeToolchain {
|
||||
var out []delivery.NodeToolchain
|
||||
for _, x := range e {
|
||||
if x.Language == language {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type langs map[string][]string
|
||||
|
||||
func (l langs) BuildModules(repository string) []string {
|
||||
if m, ok := l[repository]; ok {
|
||||
return m
|
||||
}
|
||||
return []string{buildrun.GoBuildModule}
|
||||
}
|
||||
|
||||
func goEntries(nodes ...string) entries {
|
||||
var e entries
|
||||
for _, n := range nodes {
|
||||
e = append(e, delivery.NodeToolchain{Node: n, Toolchain: delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "7"}})
|
||||
e = append(e, delivery.NodeToolchain{Node: n, Toolchain: delivery.Toolchain{Language: "typescript", Version: "node24", Recipe: "3"}})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
const digC = "cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33cc33"
|
||||
const digD = "dd44dd44dd44dd44dd44dd44dd44dd44dd44dd44dd44dd44dd44dd44dd44dd44"
|
||||
|
||||
type bench struct {
|
||||
ex *Examiner
|
||||
steps *fakeSteps
|
||||
kept *memKV
|
||||
prev *memKV
|
||||
fetch Fetched
|
||||
digest map[string]Digested // by level
|
||||
test func(c stepCall) (any, error)
|
||||
}
|
||||
|
||||
// newBench is an examiner over fake build modules that merge cleanly, digest the captured fixture's packages and pass
|
||||
// the captured slow fixture's result at both levels, on shanks.
|
||||
func newBench(t testing.TB) *bench {
|
||||
b := &bench{steps: &fakeSteps{}, kept: newMemKV(), prev: newMemKV()}
|
||||
b.fetch = Fetched{Workspace: "ws1", Commit: head1, Modules: []string{"."}, Base: trunkA, Merge: "9999999999999999999999999999999999999999",
|
||||
Changed: []string{"README.md"}}
|
||||
one := captured[Digested](t, "go-digest")
|
||||
b.digest = map[string]Digested{
|
||||
delivery.LevelUnit: {Level: "unit", Packages: map[string]string{"fixture/slow": digA}},
|
||||
delivery.LevelIntegration: {Level: "integration", Packages: map[string]string{"fixture/slow": digB}},
|
||||
}
|
||||
_ = one
|
||||
slow := captured[Tested](t, "go-test-slow")
|
||||
b.test = func(c stepCall) (any, error) {
|
||||
r := slow
|
||||
r.Level, _ = c.args["level"].(string)
|
||||
return r, nil
|
||||
}
|
||||
b.steps.handlers = map[string]func(stepCall) (any, error){
|
||||
"build_go_fetch": func(stepCall) (any, error) { return b.fetch, nil },
|
||||
"build_node_fetch": func(stepCall) (any, error) { return b.fetch, nil },
|
||||
"build_go_digest": func(c stepCall) (any, error) { return b.digest[c.args["level"].(string)], nil },
|
||||
"build_go_test": func(c stepCall) (any, error) { return b.test(c) },
|
||||
"build_node_test": func(c stepCall) (any, error) { return b.test(c) },
|
||||
}
|
||||
now := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC)
|
||||
b.ex = &Examiner{Steps: b.steps, Toolchains: goEntries("shanks"), Places: &buildrun.Places{}, Kept: Kept{Store: b.kept}, Previews: b.prev,
|
||||
Modules: langs{}, Trunks: &Trunks{}, Now: func() time.Time { now = now.Add(50 * time.Millisecond); return now }}
|
||||
return b
|
||||
}
|
||||
|
||||
func caseOf(t testing.TB, pulls ...delivery.CandidatePull) *Case {
|
||||
t.Helper()
|
||||
raw := candidateOf(t, "feat/x", pulls...)
|
||||
c, err := delivery.DecodeCandidate(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs := CaseOf(c, map[string]string{repoA: trunkA, repoSDK: trunkS})
|
||||
if cs == nil {
|
||||
t.Fatal("no case")
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
func (b *bench) judge(t testing.TB, cs *Case) Outcome {
|
||||
t.Helper()
|
||||
out, err := b.ex.Judge(context.Background(), cs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func testedWith(t testing.TB, name string, edit func(*Tested)) Tested {
|
||||
r := captured[Tested](t, name)
|
||||
if edit != nil {
|
||||
edit(&r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func packagesOf(c stepCall) []string {
|
||||
var out []string
|
||||
if l, ok := c.args["packages"].([]string); ok {
|
||||
out = l
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestACleanMergeIsFetchedAtItsTrunkAndBothLevelsPass(t *testing.T) { // 1679/AC1
|
||||
b := newBench(t)
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1))
|
||||
out := b.judge(t, cs)
|
||||
v := out.Verdict
|
||||
if v.State != delivery.VerdictPass || v.Levels[delivery.LevelUnit].Run != 2 || v.Levels[delivery.LevelIntegration].Run != 2 {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
if v.Composition == nil || v.Composition.State != delivery.CompositionNotRun {
|
||||
t.Errorf("composition = %+v, want not run", v.Composition)
|
||||
}
|
||||
if len(v.Machines) != 1 || v.Machines[0] != "shanks" {
|
||||
t.Errorf("machines = %v", v.Machines)
|
||||
}
|
||||
fetch := b.steps.tool("fetch")
|
||||
if len(fetch) != 1 || fetch[0].args["repository"] != repoA || fetch[0].args["commit"] != head1 || fetch[0].args["base"] != trunkA || fetch[0].args["ref"] != "refs/pull/7/head" {
|
||||
t.Fatalf("fetch calls = %+v", fetch)
|
||||
}
|
||||
if v.Took.Clone <= 0 || v.Took.Unit <= 0 {
|
||||
t.Errorf("took = %+v, want the clone and the unit level timed", v.Took)
|
||||
}
|
||||
// What passed is kept under each level's digest.
|
||||
for _, k := range []string{"unit." + digA, "integration." + digB} {
|
||||
if _, _, found, _ := b.kept.Get(context.Background(), k); !found {
|
||||
t.Errorf("%s was not kept", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyThePackageWithoutAKeptVerdictRuns(t *testing.T) { // 1288/AC1
|
||||
b := newBench(t)
|
||||
b.digest[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"fixture/slow": digA, "fixture/other": digC}}
|
||||
if _, err := (Kept{Store: b.kept}).Keep(context.Background(), "unit", digC, keptValue("fixture/other", 40)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
tests := b.steps.tool("test")
|
||||
if len(tests) != 2 || strings.Join(packagesOf(tests[0]), ",") != "fixture/slow" || tests[0].args["level"] != "unit" {
|
||||
t.Fatalf("test calls = %+v, want the unit level of fixture/slow alone, then the integration level", tests)
|
||||
}
|
||||
if u := out.Verdict.Levels[delivery.LevelUnit]; u.Run != 2 || u.Kept != 40 {
|
||||
t.Errorf("unit = %+v, want 2 run and 40 kept", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReAskRunsOnlyWhatFailedAndWhatChanged(t *testing.T) { // 1288/AC2, AC3
|
||||
b := newBench(t)
|
||||
failing := captured[Tested](t, "go-test-failing")
|
||||
b.digest[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"fixture/failing/bad": digA, "fixture/failing/good": digB}}
|
||||
b.test = func(c stepCall) (any, error) {
|
||||
if c.args["level"] == "unit" {
|
||||
return failing, nil
|
||||
}
|
||||
return captured[Tested](t, "go-test-slow"), nil
|
||||
}
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1))
|
||||
first := b.judge(t, cs).Verdict
|
||||
if first.State != delivery.VerdictFail || first.Failed.Test != "unit: fixture/failing/bad.TestThreeIsFour" {
|
||||
t.Fatalf("first = %+v", first)
|
||||
}
|
||||
if _, _, found, _ := b.kept.Get(context.Background(), "unit."+digB); !found {
|
||||
t.Fatal("the package that passed in the failing run was not kept")
|
||||
}
|
||||
if _, _, found, _ := b.kept.Get(context.Background(), "unit."+digA); found {
|
||||
t.Fatal("the package that failed was kept")
|
||||
}
|
||||
// Asked again with nothing changed: only the failed package runs; the good one is taken.
|
||||
b.steps.calls = nil
|
||||
b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
tests := b.steps.tool("test")
|
||||
if len(tests) == 0 || strings.Join(packagesOf(tests[0]), ",") != "fixture/failing/bad" {
|
||||
t.Fatalf("the re-ask ran %+v, want only fixture/failing/bad", tests)
|
||||
}
|
||||
// The trunk moved and one digest changed: that package runs, the unchanged one is taken.
|
||||
b.digest[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"fixture/failing/bad": digA, "fixture/failing/good": digD}}
|
||||
b.steps.calls = nil
|
||||
b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
tests = b.steps.tool("test")
|
||||
if got := strings.Join(packagesOf(tests[0]), ","); got != "fixture/failing/bad,fixture/failing/good" && got != "fixture/failing/good,fixture/failing/bad" {
|
||||
t.Errorf("after a digest changed the run was %q, want the failed package and the changed one", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodePackageIsNeverKeptAndRunsEveryCheck(t *testing.T) { // 1288/AC4, 1292/AC1
|
||||
b := newBench(t)
|
||||
b.ex.Modules = langs{repoA: {buildrun.NodeBuildModule}}
|
||||
b.fetch.Modules = []string{"modules/ui"}
|
||||
b.test = func(c stepCall) (any, error) {
|
||||
return Tested{Level: c.args["level"].(string), Module: "modules/ui", Passed: true, Elapsed: 0.4, Bound: 5}, nil
|
||||
}
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1))
|
||||
for i := 0; i < 2; i++ {
|
||||
v := b.judge(t, cs).Verdict
|
||||
if v.State != delivery.VerdictPass || v.Levels[delivery.LevelUnit].Kept != 0 || v.Levels[delivery.LevelUnit].Run != 1 {
|
||||
t.Fatalf("round %d: verdict = %+v", i, v)
|
||||
}
|
||||
}
|
||||
if n := len(b.steps.tool("test")); n != 4 {
|
||||
t.Errorf("%d node test calls in two checks, want 4 (both levels each time)", n)
|
||||
}
|
||||
if n := len(b.steps.tool("digest")); n != 0 {
|
||||
t.Errorf("the node build module was asked for a digest %d times", n)
|
||||
}
|
||||
if keys, _ := b.kept.Keys(context.Background()); len(keys) != 0 {
|
||||
t.Errorf("kept %v for a node package", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheChangesOwnFailingTestRunsFirstAndNothingElseRuns(t *testing.T) { // 1289/AC1, AC2
|
||||
b := newBench(t)
|
||||
b.fetch.Changed = []string{"modules/m/internal/b/b_test.go"}
|
||||
b.digest[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"m/internal/a": digA, "m/internal/b": digB}}
|
||||
failing := captured[Tested](t, "go-test-failing")
|
||||
b.test = func(c stepCall) (any, error) {
|
||||
if got := packagesOf(c); len(got) == 1 && got[0] == "m/internal/b" {
|
||||
return failing, nil
|
||||
}
|
||||
return captured[Tested](t, "go-test-slow"), nil
|
||||
}
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if out.Verdict.State != delivery.VerdictFail || !strings.Contains(out.Verdict.Failed.Test, "TestThreeIsFour") {
|
||||
t.Fatalf("verdict = %+v", out.Verdict)
|
||||
}
|
||||
tests := b.steps.tool("test")
|
||||
if len(tests) != 1 || strings.Join(packagesOf(tests[0]), ",") != "m/internal/b" {
|
||||
t.Fatalf("test calls = %+v, want only m/internal/b, once", tests)
|
||||
}
|
||||
if n := len(b.steps.tool("digest")); n != 1 {
|
||||
t.Errorf("%d digest calls, want the unit level's alone (the check stopped)", n)
|
||||
}
|
||||
if out.Verdict.Composition == nil || out.Verdict.Composition.State != delivery.CompositionNotRun {
|
||||
t.Errorf("composition = %+v", out.Verdict.Composition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheChangesOwnPassingTestRunsFirstAndTheRestFollow(t *testing.T) { // 1289/AC3
|
||||
b := newBench(t)
|
||||
b.fetch.Changed = []string{"modules/m/internal/b/b_test.go", "modules/m/internal/a/a.go"}
|
||||
b.digest[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"m/internal/a": digA, "m/internal/b": digB}}
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if out.Verdict.State != delivery.VerdictPass {
|
||||
t.Fatalf("verdict = %+v", out.Verdict)
|
||||
}
|
||||
var unit []string
|
||||
for _, c := range b.steps.tool("test") {
|
||||
if c.args["level"] == "unit" {
|
||||
unit = append(unit, strings.Join(packagesOf(c), ","))
|
||||
}
|
||||
}
|
||||
if strings.Join(unit, " | ") != "m/internal/b | m/internal/a" {
|
||||
t.Errorf("the unit level ran %v, want the changed test's package first, then the rest", unit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeadThatDoesNotMergeEndsFailedNamingTheFilesAndRunsNoTest(t *testing.T) { // 1289/AC4
|
||||
b := newBench(t)
|
||||
b.fetch = Fetched{Workspace: "ws1", Commit: head1, Base: trunkA, Conflicts: []string{"go.mod", "internal/x.go"}}
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
f := out.Verdict.Failed
|
||||
if out.Verdict.State != delivery.VerdictFail || f.Machine != "shanks" || !strings.Contains(f.Words, "files in conflict: go.mod, internal/x.go") {
|
||||
t.Fatalf("verdict = %+v", out.Verdict)
|
||||
}
|
||||
if n := len(b.steps.tool("digest")) + len(b.steps.tool("test")); n != 0 {
|
||||
t.Errorf("%d digest and test steps ran after a conflict", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoQualifyingNodeIsAFailureOfTheNodes(t *testing.T) { // N6/AC4
|
||||
b := newBench(t)
|
||||
b.ex.Toolchains = entries{}
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if !IsNodesFailure(out.Verdict) {
|
||||
t.Fatalf("verdict = %+v, want a failure of the nodes", out.Verdict)
|
||||
}
|
||||
b.ex.Toolchains = goEntries("shanks")
|
||||
b.ex.Presence = offline{}
|
||||
if out = b.judge(t, caseOf(t, pullOf(repoA, 7, head1))); !IsNodesFailure(out.Verdict) {
|
||||
t.Fatalf("an offline node: verdict = %+v", out.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
type offline struct{}
|
||||
|
||||
func (offline) Online(string) bool { return false }
|
||||
|
||||
func TestAStepThatFailsIsAVerdictNamingTheNode(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.steps.handlers["build_go_fetch"] = func(stepCall) (any, error) { return nil, fmt.Errorf("the step failed: ssh refused") }
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if f := out.Verdict.Failed; out.Verdict.State != delivery.VerdictFail || f.Machine != "shanks" || !strings.Contains(f.Words, "ssh refused") {
|
||||
t.Fatalf("verdict = %+v", out.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALevelWhoseDigestIsNotNamedRunsWholeAndKeepsNothing(t *testing.T) { // 1287/AC4
|
||||
b := newBench(t)
|
||||
b.steps.handlers["build_go_digest"] = func(stepCall) (any, error) { return nil, fmt.Errorf("go list failed") }
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if out.Verdict.State != delivery.VerdictPass {
|
||||
t.Fatalf("verdict = %+v", out.Verdict)
|
||||
}
|
||||
tests := b.steps.tool("test")
|
||||
if len(tests) != 2 || tests[0].args["packages"] != nil {
|
||||
t.Errorf("test calls = %+v, want each level once, whole", tests)
|
||||
}
|
||||
if keys, _ := b.kept.Keys(context.Background()); len(keys) != 0 {
|
||||
t.Errorf("kept %v with no digest", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALevelOverItsBoundIsNamedWithItsSlowestTestsAndKeepsItsVerdict(t *testing.T) { // 1300/AC3
|
||||
b := newBench(t)
|
||||
b.test = func(c stepCall) (any, error) {
|
||||
return testedWith(t, "go-test-slow", func(r *Tested) {
|
||||
r.Level = c.args["level"].(string)
|
||||
r.OverBound = true
|
||||
r.OverWhich = "run"
|
||||
r.Elapsed = 7
|
||||
}), nil
|
||||
}
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
v := out.Verdict
|
||||
if v.State != delivery.VerdictPass || strings.Join(v.OverBound, ",") != "unit,integration" {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
if len(out.Slowest) == 0 || !strings.Contains(out.Slowest[0], "TestTakesAWhile") {
|
||||
t.Errorf("slowest = %v", out.Slowest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippedTestsAreCountedPerLevel(t *testing.T) { // 1300/AC6, 1251
|
||||
b := newBench(t)
|
||||
b.test = func(c stepCall) (any, error) {
|
||||
return testedWith(t, "go-test-slow", func(r *Tested) { r.Level = c.args["level"].(string); r.Skipped = 3 }), nil
|
||||
}
|
||||
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
|
||||
if v.Levels[delivery.LevelUnit].Skipped != 3 || v.Levels[delivery.LevelIntegration].Skipped != 3 {
|
||||
t.Errorf("levels = %+v", v.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupIsOneVerdictAndEachMembersTestsReadTheOthersAtTheirHeads(t *testing.T) { // 1624/AC1
|
||||
b := newBench(t)
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))
|
||||
v := b.judge(t, cs).Verdict
|
||||
if v.State != delivery.VerdictPass {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
fetch := b.steps.tool("fetch")
|
||||
if len(fetch) != 2 {
|
||||
t.Fatalf("%d fetches, want one for each member", len(fetch))
|
||||
}
|
||||
var forA []BesideRef
|
||||
for _, f := range fetch {
|
||||
if f.args["repository"] == repoA {
|
||||
forA, _ = f.args["beside"].([]BesideRef)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, r := range forA {
|
||||
if r.Repository == repoSDK && r.Commit == head2 && r.Ref == "refs/pull/12/head" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("mesh-delivery's tests were not given the SDK member at its head: %+v", forA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExaminerIsAJudgeOfTheChecker(t *testing.T) {
|
||||
var _ Judge = (*Examiner)(nil)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
|
||||
)
|
||||
|
||||
// Steps runs a build module's long step on a node and answers its result (buildrun.Runner).
|
||||
type Steps interface {
|
||||
Run(ctx context.Context, module, node, tool string, args map[string]any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// ToolchainEntries are the nodes' toolchain entries of a language (buildrun.ToolchainSet).
|
||||
type ToolchainEntries interface {
|
||||
Entries(language string) []delivery.NodeToolchain
|
||||
}
|
||||
|
||||
// BuildModules says which build modules judge a repository: the Go one always, the Node one when the repository holds a
|
||||
// TypeScript module.
|
||||
type BuildModules interface {
|
||||
BuildModules(repository string) []string
|
||||
}
|
||||
|
||||
// toolPrefix is the start of each build module's tool names.
|
||||
var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun.NodeBuildModule: "build_node_"}
|
||||
|
||||
// Examiner judges a case through the build modules (ADR 0484 clauses 3, 4, 10, 13): for each pull request it fetches the head
|
||||
// merged onto its trunk head on a build node, then runs the unit level and the integration level, taking the kept verdicts
|
||||
// of unchanged packages, running the change's own tests first and stopping at the first failure.
|
||||
type Examiner struct {
|
||||
Steps Steps
|
||||
Toolchains ToolchainEntries
|
||||
// Presence says which nodes are online; Health whether a node's build module is running and healthy. A node that is not
|
||||
// is not chosen; nil means every node is.
|
||||
Presence interface{ Online(node string) bool }
|
||||
Health interface {
|
||||
Healthy(node, module string) bool
|
||||
}
|
||||
// Places holds a step back while its node is at the checker's capacity (ADR 0484 clause 12).
|
||||
Places *buildrun.Places
|
||||
Kept Kept
|
||||
// Previews is the previews bucket; nil writes none.
|
||||
Previews KV
|
||||
Modules BuildModules
|
||||
// Trunks are the trunk heads of every repository, for the siblings a repository's tests read.
|
||||
Trunks interface{ Heads() map[string]string }
|
||||
Now func() time.Time
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
func (j *Examiner) now() time.Time {
|
||||
if j.Now != nil {
|
||||
return j.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (j *Examiner) logf(format string, args ...any) {
|
||||
if j.Logf != nil {
|
||||
j.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// target is a pull request on one build module: the node chosen, the workspace its fetch made and what it said.
|
||||
type target struct {
|
||||
pull Pull
|
||||
module string
|
||||
node string
|
||||
toolchain string
|
||||
fetched Fetched
|
||||
// priority are the Go packages (import paths) or the Node module directories whose test files the merge changes.
|
||||
changed []string
|
||||
plans map[string]*goPlan // by level
|
||||
}
|
||||
|
||||
// goPlan is what a Go target has to run at one level: the digests, the packages without a kept verdict and the tests taken.
|
||||
type goPlan struct {
|
||||
digests map[string]string
|
||||
todo []string
|
||||
// all says the digest step gave nothing to key a kept verdict by, so the level runs whole and keeps nothing.
|
||||
all bool
|
||||
}
|
||||
|
||||
// acc collects a level's counts across targets.
|
||||
type acc struct {
|
||||
run, kept, skipped int
|
||||
elapsed, setup float64
|
||||
over bool
|
||||
slowest []TestTime
|
||||
}
|
||||
|
||||
// state is one judging.
|
||||
type state struct {
|
||||
j *Examiner
|
||||
c *Case
|
||||
targets []*target
|
||||
levels map[string]*acc
|
||||
nodes map[string]bool
|
||||
clone time.Duration
|
||||
slow []string
|
||||
}
|
||||
|
||||
// Judge runs the case (it is the Judge of a Checker). A failure of the change, of a merge or of a step is a verdict; an error is only a case that cannot
|
||||
// be judged at all.
|
||||
func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) {
|
||||
st := &state{j: j, c: c, levels: map[string]*acc{delivery.LevelUnit: {}, delivery.LevelIntegration: {}}, nodes: map[string]bool{}}
|
||||
if v := st.fetchAll(ctx); v != nil {
|
||||
return st.outcome(*v), nil
|
||||
}
|
||||
for _, level := range []string{delivery.LevelUnit, delivery.LevelIntegration} {
|
||||
for _, priority := range []bool{true, false} {
|
||||
for _, t := range st.targets {
|
||||
if v := st.runTarget(ctx, t, level, priority); v != nil {
|
||||
return st.outcome(*v), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return st.outcome(delivery.Verdict{State: delivery.VerdictPass}), nil
|
||||
}
|
||||
|
||||
// outcome fills the verdict's levels, times, machines and composition from what ran.
|
||||
func (st *state) outcome(v delivery.Verdict) Outcome {
|
||||
if v.State == delivery.VerdictPass || len(v.Levels) == 0 {
|
||||
v.Levels = map[string]delivery.LevelResult{}
|
||||
for level, a := range st.levels {
|
||||
v.Levels[level] = delivery.LevelResult{Run: a.run, Kept: a.kept, Skipped: a.skipped}
|
||||
}
|
||||
}
|
||||
ms := func(s float64) int64 { return int64(s * 1000) }
|
||||
u, i := st.levels[delivery.LevelUnit], st.levels[delivery.LevelIntegration]
|
||||
v.Took = delivery.Took{Clone: st.clone.Milliseconds(), Unit: ms(u.elapsed), IntegrationSetup: ms(i.setup), IntegrationRun: ms(i.elapsed)}
|
||||
for node := range st.nodes {
|
||||
v.Machines = append(v.Machines, node)
|
||||
}
|
||||
sort.Strings(v.Machines)
|
||||
for _, level := range []string{delivery.LevelUnit, delivery.LevelIntegration} {
|
||||
if st.levels[level].over {
|
||||
v.OverBound = append(v.OverBound, level)
|
||||
}
|
||||
}
|
||||
// The composition is the assigner's check (issue 1297): until it delivers a verdict says it was not run (ADR 0484 clause 6).
|
||||
v.Composition = &delivery.Composition{State: delivery.CompositionNotRun}
|
||||
var slow []string
|
||||
for _, level := range []string{delivery.LevelUnit, delivery.LevelIntegration} {
|
||||
a := st.levels[level]
|
||||
if !a.over {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(a.slowest, func(x, y int) bool { return a.slowest[x].Elapsed > a.slowest[y].Elapsed })
|
||||
for n, s := range a.slowest {
|
||||
if n == 3 {
|
||||
break
|
||||
}
|
||||
slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed))
|
||||
}
|
||||
}
|
||||
return Outcome{Verdict: v, Slowest: slow}
|
||||
}
|
||||
|
||||
// fail is a verdict of failure at a machine.
|
||||
func fail(machine, words string) *delivery.Verdict {
|
||||
v := failure(machine, words)
|
||||
return &v
|
||||
}
|
||||
|
||||
// step runs one step of a build module on the node the target is on, holding a place there while it runs.
|
||||
func (st *state) step(ctx context.Context, t *target, tool, label string, args map[string]any) (json.RawMessage, error) {
|
||||
st.c.Progress.Set(label, t.node)
|
||||
release, err := st.j.Places.Acquire(ctx, t.node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return st.j.Steps.Run(ctx, t.module, t.node, toolPrefix[t.module]+tool, args)
|
||||
}
|
||||
|
||||
// choose picks the build node for a pull request's repository on a build module.
|
||||
func (st *state) choose(bm string) (node, toolchain string, err error) {
|
||||
language := buildrun.LanguageOf[bm]
|
||||
qualifies := func(node string) bool {
|
||||
return (st.j.Presence == nil || st.j.Presence.Online(node)) && (st.j.Health == nil || st.j.Health.Healthy(node, bm))
|
||||
}
|
||||
nodes, tc, err := buildrun.ChooseNodes(language, st.j.Toolchains.Entries(language), st.j.Places, qualifies, 1)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return nodes[0], tc.Version, nil
|
||||
}
|
||||
|
||||
// fetchAll fetches every pull request's head merged onto its trunk on a build node of each build module the repository
|
||||
// needs. A head that does not merge, or a step that fails, ends the check at once, running no test (ADR 0484 clause 13).
|
||||
func (st *state) fetchAll(ctx context.Context) *delivery.Verdict {
|
||||
began := st.j.now()
|
||||
defer func() { st.clone = st.j.now().Sub(began) }()
|
||||
for _, p := range st.c.Pulls {
|
||||
for _, bm := range st.j.Modules.BuildModules(p.Repository) {
|
||||
node, tc, err := st.choose(bm)
|
||||
if err != nil {
|
||||
return fail(bm, err.Error())
|
||||
}
|
||||
t := &target{pull: p, module: bm, node: node, toolchain: tc, plans: map[string]*goPlan{}}
|
||||
st.nodes[node] = true
|
||||
args := map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number), "base": p.Trunk}
|
||||
if bm == buildrun.GoBuildModule {
|
||||
if beside := st.j.Beside(st.c, p); len(beside) > 0 {
|
||||
args["beside"] = beside
|
||||
}
|
||||
}
|
||||
raw, err := st.step(ctx, t, "fetch", fmt.Sprintf("fetch %s#%d on %s", p.Repository, p.Number, node), args)
|
||||
if err != nil {
|
||||
return fail(node, fmt.Sprintf("fetch of %s#%d: %v", p.Repository, p.Number, err))
|
||||
}
|
||||
if err := json.Unmarshal(raw, &t.fetched); err != nil || t.fetched.Workspace == "" {
|
||||
return fail(node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, raw))
|
||||
}
|
||||
if len(t.fetched.Conflicts) > 0 {
|
||||
return fail(node, fmt.Sprintf("%s#%d does not merge into the trunk: files in conflict: %s", p.Repository, p.Number, strings.Join(t.fetched.Conflicts, ", ")))
|
||||
}
|
||||
t.changed = t.fetched.Changed
|
||||
st.targets = append(st.targets, t)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTestFile says whether a repository-relative path is a test file of the languages the checker judges: Go's _test.go and
|
||||
// the TypeScript module's .test. and .spec. files and test directories.
|
||||
func isTestFile(p string) bool {
|
||||
switch {
|
||||
case strings.HasSuffix(p, "_test.go"):
|
||||
return true
|
||||
case strings.Contains(p, ".test.") || strings.Contains(p, ".spec."):
|
||||
return true
|
||||
}
|
||||
for _, seg := range strings.Split(path.Dir(p), "/") {
|
||||
if seg == "__tests__" || seg == "test" || seg == "tests" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// changesTestOf says whether the merge changes a test file of the Go package with the import path pkg: the directory of the
|
||||
// file and the import path agree on their last segments, whichever holds the module's directory and path in front.
|
||||
func changesTestOf(changed []string, pkg string) bool {
|
||||
for _, f := range changed {
|
||||
if !strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
dir := path.Dir(f)
|
||||
if dir == pkg || strings.HasSuffix(pkg, "/"+dir) || strings.HasSuffix(dir, "/"+pkg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// changesTestIn says whether the merge changes a test file inside the directory of a Node module.
|
||||
func changesTestIn(changed []string, dir string) bool {
|
||||
for _, f := range changed {
|
||||
if !isTestFile(f) || strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
if dir == "." || dir == "" || strings.HasPrefix(f, strings.TrimSuffix(dir, "/")+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// failedName names a failure: its package and test.
|
||||
func failedName(f Failure) string {
|
||||
if f.Test == "" {
|
||||
return f.Package
|
||||
}
|
||||
return f.Package + "." + f.Test
|
||||
}
|
||||
|
||||
// firstLine is the first non-empty line of a failure's output, shortened.
|
||||
func firstLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
if len(l) > 120 {
|
||||
l = l[:120]
|
||||
}
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// failure turns a level that did not pass into a verdict naming what failed first.
|
||||
func levelFailure(t *target, level string, res Tested) *delivery.Verdict {
|
||||
v := delivery.Verdict{State: delivery.VerdictFail}
|
||||
switch {
|
||||
case len(res.Failed) > 0:
|
||||
f := res.Failed[0]
|
||||
name := failedName(f)
|
||||
if t.module == buildrun.NodeBuildModule && f.Package == "" {
|
||||
name = res.Module
|
||||
}
|
||||
v.Failed = delivery.FailedFirst{Test: level + ": " + name, Words: firstLine(f.Output)}
|
||||
default:
|
||||
why := res.Why
|
||||
if why == "" {
|
||||
why = "the " + level + " level did not pass"
|
||||
}
|
||||
v.Failed = delivery.FailedFirst{Test: level + ": " + t.pull.Repository, Words: why}
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// runTarget runs one level of a target: first the packages the change's own tests are in (priority), then the others.
|
||||
func (st *state) runTarget(ctx context.Context, t *target, level string, priority bool) *delivery.Verdict {
|
||||
if t.module == buildrun.NodeBuildModule {
|
||||
return st.runNode(ctx, t, level, priority)
|
||||
}
|
||||
return st.runGo(ctx, t, level, priority)
|
||||
}
|
||||
|
||||
// A level's work is planned once for a target: the digest of each package, the kept verdicts taken, the packages left to run.
|
||||
func (st *state) planGo(ctx context.Context, t *target, level string) *delivery.Verdict {
|
||||
if t.plans[level] != nil {
|
||||
return nil
|
||||
}
|
||||
plan := &goPlan{digests: map[string]string{}}
|
||||
t.plans[level] = plan
|
||||
raw, err := st.step(ctx, t, "digest", fmt.Sprintf("digest the %s level of %s#%d on %s", level, t.pull.Repository, t.pull.Number, t.node),
|
||||
map[string]any{"workspace": t.fetched.Workspace, "level": level})
|
||||
var d Digested
|
||||
if err == nil {
|
||||
err = json.Unmarshal(raw, &d)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return fail(t.node, ctx.Err().Error())
|
||||
}
|
||||
if err != nil || len(d.Packages) == 0 {
|
||||
// The build module named no digest (a level it cannot digest here): the level runs whole and keeps nothing.
|
||||
if err != nil {
|
||||
st.j.logf("%s#%d: no digest of the %s level, so it runs whole: %v", t.pull.Repository, t.pull.Number, level, err)
|
||||
}
|
||||
plan.all = true
|
||||
return nil
|
||||
}
|
||||
plan.digests = d.Packages
|
||||
a := st.levels[level]
|
||||
pkgs := make([]string, 0, len(d.Packages))
|
||||
for p := range d.Packages {
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
sort.Strings(pkgs)
|
||||
for _, p := range pkgs {
|
||||
k, found, err := st.j.Kept.Lookup(ctx, level, d.Packages[p])
|
||||
if err != nil {
|
||||
st.j.logf("the kept verdict of %s at %s cannot be read, so it runs: %v", p, level, err)
|
||||
}
|
||||
if found {
|
||||
a.kept += k.Tests
|
||||
continue
|
||||
}
|
||||
plan.todo = append(plan.todo, p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (st *state) runGo(ctx context.Context, t *target, level string, priority bool) *delivery.Verdict {
|
||||
if v := st.planGo(ctx, t, level); v != nil {
|
||||
return v
|
||||
}
|
||||
plan := t.plans[level]
|
||||
var run []string
|
||||
switch {
|
||||
case plan.all:
|
||||
if priority {
|
||||
return nil // nothing to put first: the level runs whole after
|
||||
}
|
||||
default:
|
||||
for _, p := range plan.todo {
|
||||
if changesTestOf(t.changed, p) == priority {
|
||||
run = append(run, p)
|
||||
}
|
||||
}
|
||||
if len(run) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
args := map[string]any{"workspace": t.fetched.Workspace, "level": level}
|
||||
if run != nil {
|
||||
args["packages"] = run
|
||||
}
|
||||
raw, err := st.step(ctx, t, "test", fmt.Sprintf("test the %s level of %s#%d on %s", level, t.pull.Repository, t.pull.Number, t.node), args)
|
||||
if err != nil {
|
||||
return fail(t.node, fmt.Sprintf("the %s level of %s#%d: %v", level, t.pull.Repository, t.pull.Number, err))
|
||||
}
|
||||
var res Tested
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return fail(t.node, fmt.Sprintf("the %s level of %s#%d answered what is not a result: %v", level, t.pull.Repository, t.pull.Number, err))
|
||||
}
|
||||
a := st.levels[level]
|
||||
a.run += res.Tests
|
||||
a.skipped += res.Skipped
|
||||
a.elapsed += res.Elapsed
|
||||
a.setup += res.Setup
|
||||
a.over = a.over || res.OverBound
|
||||
a.slowest = append(a.slowest, res.Slowest...)
|
||||
if len(plan.digests) > 0 {
|
||||
if _, err := st.j.Kept.KeepPasses(ctx, t.pull.Repository, level, t.toolchain, res, plan.digests, st.j.now()); err != nil {
|
||||
st.j.logf("%s#%d: a passing package could not be kept: %v", t.pull.Repository, t.pull.Number, err)
|
||||
}
|
||||
}
|
||||
if !res.Passed {
|
||||
return levelFailure(t, level, res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (st *state) runNode(ctx context.Context, t *target, level string, priority bool) *delivery.Verdict {
|
||||
for _, dir := range t.fetched.Modules {
|
||||
if changesTestIn(t.changed, dir) != priority {
|
||||
continue
|
||||
}
|
||||
raw, err := st.step(ctx, t, "test", fmt.Sprintf("test the %s level of %s#%d (%s) on %s", level, t.pull.Repository, t.pull.Number, dir, t.node),
|
||||
map[string]any{"workspace": t.fetched.Workspace, "module": dir, "level": level})
|
||||
if err != nil {
|
||||
return fail(t.node, fmt.Sprintf("the %s level of %s#%d (%s): %v", level, t.pull.Repository, t.pull.Number, dir, err))
|
||||
}
|
||||
var res Tested
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return fail(t.node, fmt.Sprintf("the %s level of %s#%d (%s) answered what is not a result: %v", level, t.pull.Repository, t.pull.Number, dir, err))
|
||||
}
|
||||
if res.Module == "" {
|
||||
res.Module = dir
|
||||
}
|
||||
a := st.levels[level]
|
||||
if !res.NoTests {
|
||||
a.run++ // a Node package is counted whole: its build module counts no tests (ADR 0484 clause 10)
|
||||
}
|
||||
a.elapsed += res.Elapsed
|
||||
a.setup += res.Setup
|
||||
a.over = a.over || res.OverBound
|
||||
if !res.Passed {
|
||||
return levelFailure(t, level, res)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
go-test-failing.json, go-test-slow.json, go-test-empty.json and go-digest.json are what mesh-catalog's mesh-build-go answers
|
||||
(TestResult and DigestResult, cmd/mesh-build-go levels.go and digest.go), marshalled by the module's own code: a throwaway
|
||||
test beside the module's test_test.go and digest_test.go (workspaceFor + runUnit on the fixtures testdata/captured/failing,
|
||||
slow and empty, and digestFixture + digests on testdata/repos/digest) at mesh-catalog origin/main cdbdb474, captured on
|
||||
2026-10-21 for issue 1679 and not kept there. The fixtures' package names are the build module's own (fixture/failing/bad...).
|
||||
|
||||
The fields a fetch with a base adds (base, merge, conflicts, changed, trees) are the contract the lead relayed to the
|
||||
mesh-catalog agent (ADR 0484 clause 3, 2026-10-21); mesh-catalog's fetch step is not on main yet, so the fetch fixtures in
|
||||
the tests are written from that contract and are to be replaced by a capture once its fetch_test can marshal one.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"level": "unit",
|
||||
"packages": {
|
||||
"fixture/digest/a": "c288afe6252c38d9e7286ecb5f28b3c961780871fcc253670dcc3151c11c1df2",
|
||||
"fixture/digest/c": "11abf5f32f0f12caafd76c565e4c2c2f3bf2e3ce6c2c7fcb6c2264a06db40087"
|
||||
},
|
||||
"without_tests": [
|
||||
"fixture/digest/b",
|
||||
"fixture/digest/d"
|
||||
]
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"level": "unit",
|
||||
"passed": true,
|
||||
"why": "the repository has no tests at the unit level",
|
||||
"no_tests": true,
|
||||
"tests": 0,
|
||||
"skipped": 0,
|
||||
"packages": [
|
||||
{
|
||||
"dir": ".",
|
||||
"package": "fixture/empty",
|
||||
"passed": true,
|
||||
"elapsed": 0,
|
||||
"tests": 0,
|
||||
"no_tests": true
|
||||
}
|
||||
],
|
||||
"elapsed": 0,
|
||||
"bound": 5,
|
||||
"over_bound": false
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"level": "unit",
|
||||
"passed": false,
|
||||
"why": "1 failed: fixture/failing/bad TestThreeIsFour",
|
||||
"tests": 2,
|
||||
"skipped": 0,
|
||||
"packages": [
|
||||
{
|
||||
"dir": ".",
|
||||
"package": "fixture/failing/bad",
|
||||
"passed": false,
|
||||
"elapsed": 0.003,
|
||||
"tests": 1
|
||||
},
|
||||
{
|
||||
"dir": ".",
|
||||
"package": "fixture/failing/good",
|
||||
"passed": true,
|
||||
"elapsed": 0.002,
|
||||
"tests": 1
|
||||
}
|
||||
],
|
||||
"elapsed": 0.003,
|
||||
"bound": 5,
|
||||
"slowest": [
|
||||
{
|
||||
"package": "fixture/failing/bad",
|
||||
"test": "TestThreeIsFour",
|
||||
"elapsed": 0
|
||||
},
|
||||
{
|
||||
"package": "fixture/failing/good",
|
||||
"test": "TestTwo",
|
||||
"elapsed": 0
|
||||
}
|
||||
],
|
||||
"failed": [
|
||||
{
|
||||
"package": "fixture/failing/bad",
|
||||
"test": "TestThreeIsFour",
|
||||
"output": " bad_test.go:7: Three() = 3, want 4\n--- FAIL: TestThreeIsFour (0.00s)"
|
||||
}
|
||||
],
|
||||
"over_bound": false
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"level": "unit",
|
||||
"passed": true,
|
||||
"tests": 2,
|
||||
"skipped": 0,
|
||||
"packages": [
|
||||
{
|
||||
"dir": ".",
|
||||
"package": "fixture/slow",
|
||||
"passed": true,
|
||||
"elapsed": 0.065,
|
||||
"tests": 2
|
||||
}
|
||||
],
|
||||
"elapsed": 0.065,
|
||||
"bound": 5,
|
||||
"slowest": [
|
||||
{
|
||||
"package": "fixture/slow",
|
||||
"test": "TestTakesAWhile",
|
||||
"elapsed": 0.06
|
||||
},
|
||||
{
|
||||
"package": "fixture/slow",
|
||||
"test": "TestQuick",
|
||||
"elapsed": 0
|
||||
}
|
||||
],
|
||||
"over_bound": false
|
||||
}
|
||||
Reference in New Issue
Block a user