A failed build, or a plan closed before reaching a module, left the closure its last good build said, and a fix-forward to a newly imported package would have moved nothing. A missed merge moving only a module that packages the repository was never caught up, and an older merge read as history for it through a look that was not its own. The gate, a pull request's check, the what-if and a delivery's order now read the same view the merge handler does.
401 lines
16 KiB
Go
401 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 := readForPlanning(ctx, inv)
|
|
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
|
|
}
|
|
refs := besideRefs(dir, current[e.Manifest.Module].Commit)
|
|
beside[dir] = link.CheckedOut{Repository: url, Ref: refs[dir]}
|
|
if dir == "mesh-controller" {
|
|
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]}
|
|
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: refs["mesh-lab"]}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
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"}
|
|
}
|
|
return map[string]string{dir: running}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|