While the mesh-delivery seat has a holder on record, a merge that moves no core module opens its walk and asks nothing until mesh-delivery or a person says go; nothing of it is registered before its turn, so no other send carries it. The controller keeps the planner, the gate, sending and the walk, and gains the verbs the owner asks with: delivery-plan, -order, -check (a group composed as one future state), deliver, delivery-stop, delivery-walks; every walk kept is said as plan-moved.
392 lines
16 KiB
Go
392 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"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, link.JudgeValidator, 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
|
|
case "mesh-host":
|
|
if s.Judge == "" {
|
|
s.Judge = link.JudgeValidator
|
|
}
|
|
}
|
|
}
|
|
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 {
|
|
inv := f.open.inventory
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
read, err := inv.ReadRepositories(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
edges, err := inv.Dependencies(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scope := pullScope(p, entries, read, edges)
|
|
// 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.Print(planText(plan))
|
|
direct := 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}
|
|
switch {
|
|
case !scope.gated() && !scope.Mesh:
|
|
fmt.Printf("%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, direct)
|
|
return nil
|
|
case !scope.gated() && p.MergeCheckSaid && !p.MergeCheck:
|
|
direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: noMergeCheck}
|
|
fmt.Printf("%s/%s#%d (%.8s): %s; %s\n", p.Owner, p.Repo, p.Number, p.Commit, noModuleTouched, noMergeCheck)
|
|
sayChecked(ctx, direct)
|
|
return nil
|
|
}
|
|
request, err := checkRequestFor(ctx, f.open, p, scope, entries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request.Check.Plan = &plan
|
|
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
|
|
}
|
|
what := "its own merge-check.sh alone: " + noModuleTouched
|
|
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.Printf("%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 nil
|
|
}
|
|
|
|
// 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 := theRestOfTheMesh(ctx, open.inventory, 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 it, at what the mesh runs: each core repository by the module the mesh builds from it.
|
|
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 link.BuildRequest{}, err
|
|
}
|
|
ref := current[e.Manifest.Module].Commit
|
|
if dir == "mesh-catalog" {
|
|
// The catalogue the mesh runs is in the snapshot, every manifest as it holds it; its checkout
|
|
// beside is what tests read its files from, so its main — what the next merge builds from.
|
|
ref = "main"
|
|
}
|
|
beside[dir] = link.CheckedOut{Repository: url, Ref: ref}
|
|
if dir == "mesh-controller" {
|
|
// And its main, for a judge the running controller predates (Phase 5 rolling out).
|
|
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: "main"}
|
|
// And the lab, whose replays of what the mesh runs every check runs; on the same forge.
|
|
if e.Source.Seat != "" {
|
|
if lab, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
|
|
"mesh-lab")}); err == nil {
|
|
beside["mesh-lab"] = link.CheckedOut{Repository: lab, Ref: "main"}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return link.BuildRequest{
|
|
ID: link.NewBuildID(time.Now()),
|
|
Repository: repository,
|
|
Ref: p.Commit,
|
|
Held: heldBy(ctx),
|
|
Seats: seatBases(ctx),
|
|
Source: sourceOnSeat(source),
|
|
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
|
|
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
|
|
Manifests: scope.Manifests, Judge: scope.Judge},
|
|
}, nil
|
|
}
|
|
|
|
// 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) {
|
|
sayChecked(ctx, 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
|
|
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}
|
|
}
|
|
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
|
|
// 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})
|
|
for _, m := range g.Members {
|
|
c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number,
|
|
Commit: m.Ref})
|
|
}
|
|
}
|
|
}
|
|
for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} {
|
|
if l != nil && l.Verdict == "" {
|
|
l.Verdict = "error"
|
|
}
|
|
}
|
|
if len(c.Report) > maxCheckReport {
|
|
c.Report = "…" + c.Report[len(c.Report)-maxCheckReport:]
|
|
}
|
|
return c
|
|
}
|
|
|
|
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, c link.Checked) {
|
|
repo := "none"
|
|
if c.RepoCheck != nil {
|
|
repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary
|
|
}
|
|
fmt.Printf("%s: %s/%s#%d at %.8s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
|
|
c.Commit, 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.Printf("%s: the verdict could not be said, so the pull request is not told it: %v\n", c.ID, err)
|
|
}
|
|
}
|