pullScope no longer gives a mesh-host change a Judge, and link.JudgeValidator is gone: the validator is mesh-sdk's, at the controller's pin (ADR 0446 clause 5). An older request that says validator is read as the controller the mesh runs.
687 lines
30 KiB
Go
687 lines
30 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"runtime/debug"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// A pull request's merge check (novox/hq to-be 45 §9, ADR 0237 as amended 2026-10-06): the forge
|
|
// announces every pull request's new head, the controller decides what is checked, asks the build seat to
|
|
// check it, and says the verdict as `checked`, which the forge's holder sets as the pull request's
|
|
// statuses. **Before merge, never after**: every check the mesh had ran after a merge, on a machine.
|
|
//
|
|
// **The mesh's module graph decides, not the repository.** The controller holds the graph — every module,
|
|
// the repository and directory it is built from — and maps the pull request's changed paths onto it by
|
|
// the planner's own answer (reachOfMerge, touchedBy — the one place a changed file is mapped onto modules,
|
|
// for the merge handler, the release planner, the merge gate and this check; issue 280): **a changed file
|
|
// touches exactly the modules whose build reads it** — a module's own directory (the whole repository for
|
|
// one built from its root), or a repository its recipe packages. A file no build reads — a script at the
|
|
// root, a README — touches no module. A directory the change adds a module.json in, which the graph does
|
|
// not hold yet (said by the head, issue 278), is a new module and is checked too.
|
|
//
|
|
// - touches a module: the build seat runs **the gate** — `mesh/merge-gate`, the touched manifests, every
|
|
// machine composed with the change, the replays — and the repository's own merge-check.sh beside it;
|
|
// - touches none, in a repository that is the mesh's (it sources a module on some branch, or shares the
|
|
// core's owner): the gate is a pass that says so — a fact, not a missing check — and the repository's
|
|
// own merge-check.sh runs as `mesh/repo-check`, a warning when it has none;
|
|
// - touches none, anywhere else: the gate is a pass that says so, and nothing more is said.
|
|
//
|
|
// What is checked is decided here and run there (internal/builder/check.go).
|
|
|
|
// checkTimeout is how long one check may run on the build seat. Said here so the ask's watchdog (S6)
|
|
// and the builder agree on what late means.
|
|
const checkTimeout = 45 * time.Minute
|
|
|
|
// noModuleTouched is the gate's word for a change that touches nothing of the graph.
|
|
const noModuleTouched = "the change touches no module of the mesh's graph"
|
|
|
|
// noMergeCheck is the repository layer's word for a repository of the mesh with no merge-check.sh.
|
|
const noMergeCheck = "the repository declares no merge-check.sh: none of its own tests run before it merges"
|
|
|
|
// coreModules are the modules whose repositories are the mesh's core, by the directory a check finds
|
|
// each beside it — and whose owner is the mesh's own.
|
|
var coreModules = map[string]string{"mesh-controller": "mesh-controller", "mesh-host": "mesh-host",
|
|
"node-tools": "mesh-tools", "nats": "mesh-catalog"}
|
|
|
|
// checkScope is what a pull request reaches of the mesh's graph, as the planner reckons it.
|
|
type checkScope struct {
|
|
// Modules are the modules a merge of the change would move itself — built from the repository into
|
|
// the pull request's base and reading a changed file, or packaging the repository's source — and
|
|
// Dependents those the plan would build after them; New the directories it adds a module in.
|
|
Modules []string
|
|
Dependents []string
|
|
New []string
|
|
// Manifests are the moved modules' and the new ones' manifests in the change's tree.
|
|
Manifests []string
|
|
// Width is how many modules a merge would build, in how many tiers; Unread the changed files no
|
|
// module's build reads.
|
|
Width, Tiers int
|
|
Unread []string
|
|
// Mesh says the repository is the mesh's: modules are built from it on some branch, its owner is the
|
|
// core's, or the change adds a module to it.
|
|
Mesh bool
|
|
// Judge is who judges the gate (link.JudgeSelf, or the running controller).
|
|
Judge string
|
|
// From is a module built from the repository, for how the mesh clones it; nil when none is.
|
|
From *inventory.Entry
|
|
// Reach is the planner's whole answer, which the change plan is made from.
|
|
Reach mergeReach
|
|
}
|
|
|
|
func (s checkScope) gated() bool { return len(s.Modules)+len(s.New) > 0 }
|
|
|
|
// pullScope is what a pull request reaches: **the planner's own answer** (reachOfMerge), asked as if the
|
|
// head were merged into the base — never a mapping of its own, so a change to what a merge touches
|
|
// changes what is checked with it (novox/hq ADR 0238).
|
|
func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
|
edges []inventory.Edge) checkScope {
|
|
m := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL, Commit: p.Commit,
|
|
Paths: p.Paths, PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs,
|
|
ModuleDirsSaid: p.ModuleDirsSaid}
|
|
r := reachOfMerge(m, entries, read, edges)
|
|
s := checkScope{Modules: r.Moved(), Dependents: r.Dependents(), New: r.Added, Unread: r.Unread,
|
|
Width: len(r.Plan.Modules), Tiers: len(r.Plan.Tiers), Reach: r}
|
|
for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) {
|
|
s.Manifests = append(s.Manifests, path.Join(strings.Trim(e.Source.Path, "/"), moduleManifestFile))
|
|
switch e.Manifest.Module {
|
|
case "mesh-controller":
|
|
s.Judge = link.JudgeSelf
|
|
}
|
|
}
|
|
for _, d := range r.Added {
|
|
s.Manifests = append(s.Manifests, path.Join(d, moduleManifestFile))
|
|
}
|
|
sort.Strings(s.Manifests)
|
|
s.Manifests = slices.Compact(s.Manifests)
|
|
|
|
// Whose repository it is, for how it is cloned and whether its own check is the mesh's to run.
|
|
owners := map[string]bool{}
|
|
for i, e := range entries {
|
|
if e.Provided {
|
|
continue
|
|
}
|
|
if _, core := coreModules[e.Manifest.Module]; core {
|
|
if owner := sourceOwner(e.Source.Repository); owner != "" {
|
|
owners[owner] = true
|
|
}
|
|
}
|
|
if sameRepository(e.Source.Repository, m) && s.From == nil {
|
|
s.From = &entries[i]
|
|
}
|
|
}
|
|
s.Mesh = s.From != nil || owners[strings.ToLower(p.Owner)] || s.gated()
|
|
return s
|
|
}
|
|
|
|
// sourceOwner is the owner of a recorded repository, a path on the git seat or a URL: novox/mesh-host → novox.
|
|
func sourceOwner(repository string) string {
|
|
parts := strings.Split(strings.Trim(strings.TrimSuffix(repository, ".git"), "/"), "/")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(parts[len(parts)-2])
|
|
}
|
|
|
|
// PullUpdated decides a pull request's merge check, and asks for it when there is something to run.
|
|
func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
|
_, err := f.pullUpdated(ctx, p, os.Stdout)
|
|
return err
|
|
}
|
|
|
|
// pullUpdated is PullUpdated saying what it did to w. It returns the id of the check it asked of the build seat, or
|
|
// "" when it said a verdict itself and asked nothing. A command that answers JSON on standard output (`delivery
|
|
// check`) gives it standard error, so its output stays the document alone (novox/hq issue 1311).
|
|
func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Writer) (string, error) {
|
|
inv := f.open.inventory
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
read, err := readForPlanning(ctx, inv)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
edges, err := inv.Dependencies(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
scope := pullScope(p, entries, read, edges)
|
|
// **A cycle of edges fails the check, naming every module and edge** (novox/hq ADR 0419 clause 6), a lone pull
|
|
// request's as a group's composed check is: no order exists for what it changes.
|
|
alone := orderMember{ID: p.Owner + "/" + p.Repo + "@" + twelve(p.Commit), Repository: p.Owner + "/" + p.Repo, Base: p.Base,
|
|
Head: p.Commit, Number: p.Number, Paths: p.Paths, PathsTruncated: p.PathsTruncated, Removed: p.Removed, BaseSHA: p.BaseSHA}
|
|
if failing, cyclic, err := cycleVerdict(ctx, "", []orderMember{alone},
|
|
map[string]orderReach{alone.ID: {Moved: scope.Modules}}, edges, entries); err != nil {
|
|
return "", err
|
|
} else if cyclic {
|
|
failing.Commit, failing.Trunk = p.Commit, twelve(p.BaseSHA)
|
|
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s\n", p.Owner, p.Repo, p.Number, p.Commit, failing.Summary)
|
|
sayChecked(ctx, w, failing)
|
|
return "", nil
|
|
}
|
|
// **A member of a group whose gate is the composed check's runs its repository's check alone** (novox/hq ADR 0403
|
|
// clause 1): mesh-delivery is asked once, and says so on the group's line. Told nothing, the pull request is
|
|
// checked alone, gate included (clause 6).
|
|
lines, groupsErr := groupsAsked(ctx, deliverySeatHeld(entries))
|
|
composed := groupsErr == nil && composedMember(p, lines)
|
|
// The change plan of the commit at hand (ADR 0238): what a merge of it would build and send, posted
|
|
// with the verdict whatever the verdict is.
|
|
plan := changePlanOf(p.Owner+"/"+p.Repo, p.Base, p.Commit, scope.Reach, entries, func(module string) (inventory.Upgrade, bool) {
|
|
u, err := inv.UpgradeOf(ctx, module)
|
|
return u, err == nil
|
|
})
|
|
fmt.Fprint(w, planText(plan))
|
|
direct := directVerdict(p, plan)
|
|
if composed {
|
|
scope.Modules, scope.Dependents, scope.New, scope.Manifests = nil, nil, nil, nil
|
|
composedGate(&direct)
|
|
}
|
|
switch {
|
|
case !scope.gated() && !scope.Mesh:
|
|
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n",
|
|
p.Owner, p.Repo, p.Number, p.Commit, noModuleTouched)
|
|
sayChecked(ctx, w, direct)
|
|
return "", nil
|
|
case !scope.gated() && p.MergeCheckSaid && !p.MergeCheck:
|
|
direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: noMergeCheck}
|
|
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s; %s\n", p.Owner, p.Repo, p.Number, p.Commit, noModuleTouched, noMergeCheck)
|
|
sayChecked(ctx, w, direct)
|
|
return "", nil
|
|
}
|
|
request, err := checkRequestFor(ctx, f.open, p, scope, entries)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
request.Check.Plan = &plan
|
|
request.Check.Composed = composed
|
|
// **The group's other open pull requests are read beside it** (novox/hq ADR 0382 §1).
|
|
siblingsFrom(&request, p, entries, lines, groupsErr)
|
|
seat := buildSeatHeld(ctx)
|
|
ask, err := askOverOn(seat)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer ask.Close()
|
|
if err := ask.Ask(ctx, request); err != nil {
|
|
return "", err
|
|
}
|
|
// **A newer request replaces the one still waiting, and a newer key stops the one running** (novox/hq ADR 0406 §3, §4,
|
|
// issue 974): the check queue keeps one request a pull request, and a running check watches its key. Nothing is asked
|
|
// of the forge here (issue 726).
|
|
what := "its own merge-check.sh alone: " + noModuleTouched
|
|
if composed {
|
|
what = "its own merge-check.sh alone: the gate is its group's composed check's"
|
|
}
|
|
if scope.gated() {
|
|
what = fmt.Sprintf("the gate over %s (a merge would build %d module(s) in %d tier(s))",
|
|
strings.Join(append(append([]string{}, scope.Modules...), prefixedAll("new:", scope.New)...), ", "),
|
|
scope.Width, scope.Tiers)
|
|
}
|
|
fmt.Fprintf(w, "%s/%s#%d (%.8s): asked %s to check it before it merges — %s — as %s\n", p.Owner, p.Repo, p.Number,
|
|
p.Commit, seat, what, request.ID)
|
|
return request.ID, nil
|
|
}
|
|
|
|
// composedWords is what a group member's own verdict says of its gate layer (novox/hq ADR 0403 clause 1).
|
|
const composedWords = "judged by its group's composed check"
|
|
|
|
// gateComposed is a gate layer's verdict when no gate was run for the member alone: never a pass. Mesh-delivery reads it
|
|
// as the composed check's to judge, and the forge's holder sets no mesh/merge-gate from it.
|
|
const gateComposed = "composed"
|
|
|
|
// composedGate says a group member's own verdict has no gate of its own: its gate layer, and the verdict that is
|
|
// the gate's, read "composed" in place of a pass.
|
|
func composedGate(c *link.Checked) {
|
|
c.Verdict, c.Summary = gateComposed, composedWords
|
|
c.Gate = &link.CheckLayer{Verdict: gateComposed, Summary: composedWords}
|
|
}
|
|
|
|
// directVerdict is the verdict of a change no build judges — it touches no module, and either the repository is not
|
|
// the mesh's or the head holds no merge-check.sh. **It names the trunk commit it holds for** (novox/hq ADR 0323 §3):
|
|
// the base branch's head the forge announced with the head, so the merge tool can weigh it against the trunk's head
|
|
// and does not refuse it for ever (a verdict naming no trunk is never the trunk's head's). Nothing was built to
|
|
// judge: the trunk commit is the one the forge said the change was announced against.
|
|
func directVerdict(p link.PullUpdated, plan link.ChangePlan) link.Checked {
|
|
trunk := p.BaseSHA
|
|
if len(trunk) > 12 {
|
|
trunk = trunk[:12]
|
|
}
|
|
if trunk != "" {
|
|
plan.Trunk = trunk
|
|
}
|
|
return link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit,
|
|
ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched,
|
|
Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan, Trunk: trunk}
|
|
}
|
|
|
|
// checkRequestFor is the ask for one pull request's head: the repository as the mesh clones it, the head,
|
|
// and what is read beside it.
|
|
func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scope checkScope,
|
|
entries []inventory.Entry) (link.BuildRequest, error) {
|
|
shelf := map[string]catalogue.Manifest{}
|
|
for _, e := range entries {
|
|
shelf[e.Manifest.Module] = e.Manifest
|
|
}
|
|
world, err := restOfTheMesh(ctx, open, shelf, "")
|
|
if err != nil {
|
|
return link.BuildRequest{}, err
|
|
}
|
|
clone := func(s inventory.Source) (string, error) {
|
|
if s.Seat == "" {
|
|
return s.Repository, nil
|
|
}
|
|
return clonedFromSeat(world, s.Seat, s.Repository)
|
|
}
|
|
// As the mesh clones a module built from it; a repository no module is built from, from the forge.
|
|
source := inventory.Source{Seat: gitSeat, Repository: p.Owner + "/" + p.Repo}
|
|
if scope.From != nil {
|
|
source = scope.From.Source
|
|
}
|
|
repository, err := clone(source)
|
|
if err != nil {
|
|
return link.BuildRequest{}, err
|
|
}
|
|
current, err := open.inventory.CurrentBuilds(ctx)
|
|
if err != nil {
|
|
return link.BuildRequest{}, err
|
|
}
|
|
beside, err := besideOf(entries, func(module string) string { return current[module].Commit }, clone)
|
|
if err != nil {
|
|
return link.BuildRequest{}, err
|
|
}
|
|
builds := checkBuildsOf(p, scope, entries, clone)
|
|
return link.BuildRequest{
|
|
ID: link.NewBuildID(time.Now()),
|
|
Repository: repository,
|
|
Ref: p.Commit,
|
|
Held: heldBy(ctx),
|
|
Seats: seatBases(ctx),
|
|
Source: sourceOnSeat(source),
|
|
Check: checkOf(p, scope, entries, beside, builds, namedForTheCheck(ctx, p, builds), runningBy(ctx)),
|
|
}, nil
|
|
}
|
|
|
|
// checkOf is the check a request carries: the pull request, what the change touches, and the buckets of the pull
|
|
// requests' keys of the modules claiming the delivery seat (novox/hq ADR 0437 §3, issue 1212).
|
|
func checkOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry, beside map[string]link.CheckedOut,
|
|
builds []link.CheckBuild, named []link.CheckedBuild, running map[string]string) *link.CheckRequest {
|
|
return &link.CheckRequest{Pulls: pullsBucketsOf(entries), Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
|
|
Trunk: p.BaseSHA, Ready: p.Ready, Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents,
|
|
New: scope.New, Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: named, Running: running}
|
|
}
|
|
|
|
// besideOf is what a check reads beside its own repository, by the directory it is found under: each core repository
|
|
// by the module the mesh builds from it, at besideRefs. running is the commit the mesh runs of a module.
|
|
func besideOf(entries []inventory.Entry, running func(module string) string,
|
|
clone func(inventory.Source) (string, error)) (map[string]link.CheckedOut, error) {
|
|
beside := map[string]link.CheckedOut{}
|
|
for _, e := range entries {
|
|
dir, core := coreModules[e.Manifest.Module]
|
|
if !core || e.Provided || e.Source.Repository == "" {
|
|
continue
|
|
}
|
|
url, err := clone(e.Source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refs := besideRefs(dir, running(e.Manifest.Module))
|
|
beside[dir] = link.CheckedOut{Repository: url, Ref: refs[dir]}
|
|
if dir == "mesh-controller" {
|
|
// The controller's main stays its main: it judges only a check the running controller predates.
|
|
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]}
|
|
if e.Source.Seat != "" {
|
|
for _, sibling := range []string{"mesh-lab", "mesh-sdk"} {
|
|
if url, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
|
|
sibling)}); err == nil {
|
|
beside[sibling] = link.CheckedOut{Repository: url, Ref: refs[sibling]}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return beside, nil
|
|
}
|
|
|
|
// trunkOf is the branch a module's source follows: main when it records none, or records a commit.
|
|
func trunkOf(s inventory.Source) string {
|
|
if t := strings.TrimSpace(followedBranch(s.Ref)); t != "" {
|
|
return t
|
|
}
|
|
return "main"
|
|
}
|
|
|
|
// sourceName is the name of a recorded repository, a path on the git seat or a URL: novox/mesh-host → mesh-host.
|
|
func sourceName(repository string) string {
|
|
parts := strings.Split(strings.Trim(strings.TrimSuffix(repository, ".git"), "/"), "/")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
return parts[len(parts)-1]
|
|
}
|
|
|
|
// namedForTheCheck are the builds earlier checks of the same pull request at the same head named, read once for the
|
|
// check (novox/hq issue 703): asked again because only the trunk moved, the check keeps what they built of a key
|
|
// still the same. Another pull request's build, or one of an earlier head, is never kept. None when they cannot be
|
|
// read, and the check builds, as before.
|
|
func namedForTheCheck(ctx context.Context, p link.PullUpdated, builds []link.CheckBuild) []link.CheckedBuild {
|
|
if len(builds) == 0 || p.Commit == "" {
|
|
return nil
|
|
}
|
|
pull := link.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number}
|
|
read, err := checkedBuildsOf(ctx, []link.PullRef{pull})
|
|
if err != nil {
|
|
fmt.Printf(" the earlier checks' builds of %s/%s#%d could not be read, so the check builds: %v\n",
|
|
p.Owner, p.Repo, p.Number, err)
|
|
// empty-on-error: said above; a check handed no named build builds every module, as before issue 703
|
|
return nil
|
|
}
|
|
var named []link.CheckedBuild
|
|
for _, b := range read {
|
|
if b.Pull == pull && link.SameHead(b.Head, p.Commit) {
|
|
named = append(named, b)
|
|
}
|
|
}
|
|
return named
|
|
}
|
|
|
|
// checkBuildsOf are the modules a merge of the change would build, in the walk's order, as the check builds them
|
|
// for the walk to take (novox/hq ADR 0350 §1): a module built from the repository checked, at the branch it merges
|
|
// into, from the merge the check makes; any other — a module standing on one — from its own repository at the
|
|
// branch it follows, as the walk builds it. A module whose build reads the repository checked through a context,
|
|
// or that the merge deletes, cannot be built as the walk would: said, and nothing after it is built. A change that
|
|
// moves no module builds nothing.
|
|
func checkBuildsOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry,
|
|
clone func(inventory.Source) (string, error)) []link.CheckBuild {
|
|
if len(scope.Reach.Plan.Tiers) == 0 {
|
|
return nil
|
|
}
|
|
m := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL}
|
|
byName := map[string]inventory.Entry{}
|
|
for _, e := range entries {
|
|
byName[e.Manifest.Module] = e
|
|
}
|
|
skip := map[string]string{}
|
|
for _, e := range scope.Reach.Packaging {
|
|
skip[e.Manifest.Module] = "its build reads " + p.Owner + "/" + p.Repo + " through a context, which a check " +
|
|
"cannot hand the merge"
|
|
}
|
|
for _, e := range scope.Reach.Deleted {
|
|
skip[e.Manifest.Module] = "the merge deletes it"
|
|
}
|
|
var out []link.CheckBuild
|
|
for tier, names := range scope.Reach.Plan.Tiers {
|
|
for _, name := range names {
|
|
e, known := byName[name]
|
|
b := link.CheckBuild{Module: name, Tier: tier, Skip: skip[name]}
|
|
switch {
|
|
case !known:
|
|
b.Skip = "the catalogue does not hold it"
|
|
case b.Skip != "":
|
|
case sourceIs(e.Source, m):
|
|
b.Merged, b.Path = true, e.Source.Path
|
|
default:
|
|
url, err := clone(e.Source)
|
|
if err != nil {
|
|
b.Skip = "its repository cannot be cloned: " + err.Error()
|
|
break
|
|
}
|
|
b.Repository, b.Path, b.Ref = url, e.Source.Path, followedBranch(e.Source.Ref)
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// besideRefs is the ref each repository is cloned at beside a check, by the directory it is found under:
|
|
// a core repository at the commit the mesh runs of the module built from it (`running`) — but the
|
|
// catalogue, whose checkout beside is what tests read its files from, at its main, what the next merge
|
|
// builds from; and beside the controller its main, for a judge the running controller predates, and the
|
|
// lab's main, whose replays every check runs. **One rule, read by the check the controller asks for and by
|
|
// the facts snapshot** (Facts.Beside), so a check run by hand clones what the build seat clones. Beside the
|
|
// controller also the SDK, checked out at the commit the running controller's go.mod pins (sdkPinned), not
|
|
// one a desktop holds (novox/hq issue 449). The checkout only places the clone: the conformance test reads the
|
|
// fixtures at the pin of the tree under check, from the clone's history, so a pull request moving the SDK is
|
|
// judged against the SDK it moves to (internal/link/conformance_test.go).
|
|
func besideRefs(dir, running string) map[string]string {
|
|
switch dir {
|
|
case "mesh-catalog":
|
|
return map[string]string{dir: "main"}
|
|
case "mesh-controller":
|
|
return map[string]string{dir: running, "mesh-controller-main": "main", "mesh-lab": "main",
|
|
"mesh-sdk": sdkPinned()}
|
|
}
|
|
return map[string]string{dir: running}
|
|
}
|
|
|
|
// sdkModule is the Go module of the SDK the controller is built against.
|
|
const sdkModule = "git.novox.be/novox/mesh-sdk/go"
|
|
|
|
// pseudoCommit is the commit a Go pseudo-version names: v0.1.11-0.20261009143344-f047d0a4a970 → f047d0a4a970.
|
|
var pseudoCommit = regexp.MustCompile(`-([0-9a-f]{12})$`)
|
|
|
|
// sdkPinned is the ref of the SDK repository this controller was built from, as its go.mod pins it and its
|
|
// build records it: a pseudo-version's commit, or a release's tag (the SDK tags its Go module under go/).
|
|
// "main" only for a binary that records no SDK version — a local replace — which a running controller is
|
|
// not (novox/hq issue 449).
|
|
func sdkPinned() string {
|
|
info, ok := debug.ReadBuildInfo()
|
|
if !ok {
|
|
return "main"
|
|
}
|
|
for _, dep := range info.Deps {
|
|
if dep.Path != sdkModule {
|
|
continue
|
|
}
|
|
if dep.Replace != nil {
|
|
dep = dep.Replace
|
|
}
|
|
if m := pseudoCommit.FindStringSubmatch(dep.Version); m != nil {
|
|
return m[1]
|
|
}
|
|
if strings.HasPrefix(dep.Version, "v") {
|
|
return "go/" + dep.Version
|
|
}
|
|
}
|
|
return "main"
|
|
}
|
|
|
|
// siblingOf is another repository of the same owner: novox/mesh-controller → novox/mesh-lab.
|
|
func siblingOf(repository, name string) string {
|
|
if cut := strings.LastIndex(repository, "/"); cut >= 0 {
|
|
return repository[:cut+1] + name
|
|
}
|
|
return name
|
|
}
|
|
|
|
// sourceOnSeat is a source's seat form, nil for one on no seat.
|
|
func sourceOnSeat(s inventory.Source) *link.SourceOnSeat {
|
|
if s.Seat == "" {
|
|
return nil
|
|
}
|
|
return &link.SourceOnSeat{Seat: s.Seat, Repository: s.Repository}
|
|
}
|
|
|
|
// checkEvents is where the serving controller says a check's verdict; nil in a command.
|
|
var checkEvents link.Bus
|
|
|
|
// maxCheckReport is how much of a check's report travels in its verdict: enough for the failures and
|
|
// the machines, never a log.
|
|
const maxCheckReport = 60 << 10
|
|
|
|
// checked says a merge check's verdict as the controller's `checked`. Nothing is recorded or
|
|
// registered: a check builds nothing (issue 240's rule for a dry run, kept for a check).
|
|
func checked(ctx context.Context, result link.BuildResult) {
|
|
// **A check stopped by its key says nothing** (novox/hq ADR 0406 §4, issue 974, review 2): it stopped because the head or
|
|
// the trunk commit moved on, and the newer request will judge. Said as an error it would name the live head — red
|
|
// statuses on the forge, a delivery refused — and written to the key it would stand in the newer request's way.
|
|
if result.Failed == link.SupersededByKey {
|
|
fmt.Printf("%s: stopped, its pull request's key moved on; the newer check will judge\n", result.ID)
|
|
return
|
|
}
|
|
sayChecked(ctx, os.Stdout, checkedOf(result))
|
|
}
|
|
|
|
// checkedOf is what a check's outcome says: each layer, and an error — never a pass — for a check that
|
|
// could not run.
|
|
func checkedOf(result link.BuildResult) link.Checked {
|
|
c := link.Checked{ID: result.ID, On: result.On, Commit: result.Ref}
|
|
if result.Checked != nil {
|
|
c.Owner, c.Repo, c.Number = result.Checked.Owner, result.Checked.Repo, result.Checked.Number
|
|
}
|
|
switch {
|
|
case result.Check != nil:
|
|
c.Verdict, c.Summary, c.Report = result.Check.Verdict, result.Check.Summary, result.Check.Report
|
|
c.Gate, c.RepoCheck = result.Check.Gate, result.Check.RepoCheck
|
|
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the
|
|
// repositories read beside it.
|
|
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
|
|
c.Built = result.Check.Built
|
|
c.Validator = result.Check.Validator
|
|
c.Siblings, c.SiblingsUnknown, c.JudgeFrom = result.Check.Siblings, result.Check.SiblingsUnknown, result.Check.JudgeFrom
|
|
if c.Gate == nil {
|
|
// A build seat from before the layers: its verdict is the gate's.
|
|
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
|
}
|
|
case result.Failed != "":
|
|
// The check could not run: an error, never read as a pass — on both layers it was asked for.
|
|
c.Verdict, c.Summary = "error", "the check could not run: "+firstLine(result.Failed)
|
|
default:
|
|
c.Verdict, c.Summary = "error", "the build seat answered the check with no verdict"
|
|
}
|
|
if c.Verdict == "" {
|
|
c.Verdict = "error"
|
|
}
|
|
if result.Check == nil {
|
|
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
|
c.RepoCheck = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
|
}
|
|
// **A group member's own check has no gate of its own** (novox/hq ADR 0403 clause 1): what ran was the repository's
|
|
// check alone, and its gate layer is "composed", never a pass. A failure or an error stays what it was.
|
|
if result.Checked != nil && result.Checked.Composed && result.Check != nil && c.Gate != nil &&
|
|
(c.Gate.Verdict == "pass" || c.Gate.Verdict == "warning") {
|
|
composedGate(&c)
|
|
}
|
|
if result.Checked != nil && c.Gate != nil && len(c.Gate.Modules) == 0 {
|
|
c.Gate.Modules = append(append([]string{}, result.Checked.Modules...), prefixedAll("new:", result.Checked.New)...)
|
|
}
|
|
if result.Checked != nil && c.Gate != nil && len(c.Gate.Dependents) == 0 {
|
|
c.Gate.Dependents = result.Checked.Dependents
|
|
}
|
|
if result.Checked != nil {
|
|
c.Plan = result.Checked.Plan
|
|
if c.Plan != nil && c.Trunk != "" {
|
|
// The kept delivery plan records the trunk commit with its other inputs (ADR 0323 §3).
|
|
plan := *c.Plan
|
|
plan.Trunk = c.Trunk
|
|
c.Plan = &plan
|
|
}
|
|
// A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first.
|
|
if g := result.Checked; g.Group != "" {
|
|
c.Group = g.Group
|
|
c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number,
|
|
Commit: result.Ref, Trunk: c.Trunk})
|
|
for _, m := range g.Members {
|
|
c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number,
|
|
Commit: m.Ref, Trunk: memberTrunk(result.Check, m)})
|
|
}
|
|
}
|
|
}
|
|
for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} {
|
|
if l != nil && l.Verdict == "" {
|
|
l.Verdict = "error"
|
|
}
|
|
}
|
|
// **A check run again says so** (novox/hq issue 594). The summaries are the lines the pull request
|
|
// shows, clipped by the forge to a few words, so they get only a short marker after the verdict's own
|
|
// words; the report gets the whole sentence. Layers copied, so the build's own outcome is left as it came.
|
|
if mark := link.RunMarker(result.Delivery); mark != "" {
|
|
c.Summary += " " + mark
|
|
for _, l := range []**link.CheckLayer{&c.Gate, &c.RepoCheck} {
|
|
if *l != nil {
|
|
layer := **l
|
|
layer.Summary += " " + mark
|
|
*l = &layer
|
|
}
|
|
}
|
|
c.Report = link.Restarted(result.Delivery) + "\n\n" + c.Report
|
|
}
|
|
if len(c.Report) > maxCheckReport {
|
|
c.Report = "…" + c.Report[len(c.Report)-maxCheckReport:]
|
|
}
|
|
return c
|
|
}
|
|
|
|
// memberTrunk is the trunk commit the build seat merged one of a group's heads into (novox/hq ADR 0323 §6), empty
|
|
// when it said none.
|
|
func memberTrunk(outcome *link.CheckOutcome, m link.GroupMember) string {
|
|
if outcome == nil {
|
|
return ""
|
|
}
|
|
for _, j := range outcome.Members {
|
|
if strings.EqualFold(j.Owner, m.Owner) && strings.EqualFold(j.Repo, m.Repo) && strings.HasPrefix(m.Ref, j.Commit) {
|
|
return j.Trunk
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func prefixedAll(prefix string, items []string) []string {
|
|
out := make([]string, 0, len(items))
|
|
for _, i := range items {
|
|
out = append(out, prefix+i)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sayChecked says a merge check's verdict on the bus, where the forge's holder hears it.
|
|
func sayChecked(ctx context.Context, w io.Writer, c link.Checked) {
|
|
repo := "none"
|
|
if c.RepoCheck != nil {
|
|
repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary
|
|
}
|
|
judged := ""
|
|
if c.Trunk != "" {
|
|
judged = " merged into trunk " + c.Trunk
|
|
}
|
|
fmt.Fprintf(w, "%s: %s/%s#%d at %.8s%s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
|
|
c.Commit, judged, orSomewhere(c.On), strings.ToUpper(c.Verdict), c.Summary, repo)
|
|
if checkEvents == nil {
|
|
return
|
|
}
|
|
body, err := json.Marshal(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
stating, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|
defer stop()
|
|
if err := checkEvents.PublishSeatEvent(stating, link.MeshControllerSeat, link.KeyChecked, body); err != nil {
|
|
fmt.Fprintf(w, "%s: the verdict could not be said, so the pull request is not told it: %v\n", c.ID, err)
|
|
}
|
|
}
|
|
|
|
// pullsBucketsOf are the buckets of the pull requests' keys a check names for the build agent: one per module
|
|
// claiming the delivery seat, in the order of their names (novox/hq ADR 0437 §3, issue 1212).
|
|
func pullsBucketsOf(entries []inventory.Entry) []string {
|
|
var out []string
|
|
for _, m := range claimantsOf(entries, catalogue.DeliverySeat) {
|
|
out = append(out, broker.BucketName(m, "pulls"))
|
|
}
|
|
return out
|
|
}
|