mesh/merge-gate out of date: judged against trunk c975dbf7b053, the trunk is now 76304d9db817; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk c975dbf7b053, the trunk is now 76304d9db817; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
Review of #329: a judge built with HOME in the workspace runs git under a configuration a pull request's script can plant, so a hook could rewrite the judge's sources before they compile and the result be kept under an honest key (issue 482, issue 704). Every judge now builds with its own HOME, configuration and Go cache outside the workspace, no git configuration and no VCS stamping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
715 lines
29 KiB
Go
715 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"runtime/debug"
|
|
"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 := directVerdict(p, 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
|
|
}
|
|
|
|
// 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 := 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
|
|
}
|
|
// **A pull request into a release branch is judged by that branch** (novox/hq ADR 0358): asked of the forge once.
|
|
release, err := releaseBranchOf(ctx, p, scope, entries)
|
|
if err != nil {
|
|
return link.BuildRequest{}, err
|
|
}
|
|
beside, err := besideOf(entries, func(module string) string { return current[module].Commit }, clone, release)
|
|
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: &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, Builds: builds, Named: namedForTheCheck(ctx, p, builds),
|
|
Release: release},
|
|
}, nil
|
|
}
|
|
|
|
// 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 — or, for a pull request into a release branch (novox/hq ADR
|
|
// 0358 §3), at its branch of that name where it has one and at its trunk otherwise, the controller's main kept for
|
|
// its judge, and mesh-lab at its trunk whatever the base: its replays run with the container runtime's socket. 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), release string) (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))
|
|
at := func(dir, trunk string) link.CheckedOut {
|
|
if release == "" {
|
|
return link.CheckedOut{Repository: url, Ref: refs[dir]}
|
|
}
|
|
if trunk == "" {
|
|
trunk = "main"
|
|
}
|
|
return link.CheckedOut{Repository: url, Ref: release, Else: trunk}
|
|
}
|
|
beside[dir] = at(dir, followedBranch(e.Source.Ref))
|
|
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 release != "" {
|
|
// mesh-controller has the release branch: that is what makes it one (ADR 0358 §1).
|
|
beside[dir] = link.CheckedOut{Repository: url, Ref: release}
|
|
}
|
|
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 {
|
|
b := link.CheckedOut{Repository: url, Ref: refs[sibling]}
|
|
// **mesh-lab is read at its trunk, always** (ADR 0358): its replays run with the container
|
|
// runtime's socket, so they are only ever reviewed code — never a branch anyone may create.
|
|
if release != "" && sibling != "mesh-lab" {
|
|
b = link.CheckedOut{Repository: url, Ref: release, Else: "main"}
|
|
}
|
|
beside[sibling] = b
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return beside, nil
|
|
}
|
|
|
|
// releaseBranchOf is the release branch a pull request merges into (novox/hq ADR 0358 §1): its base, when that is not
|
|
// its repository's trunk and mesh-controller has a branch of that name; empty otherwise, and the pull request is
|
|
// checked as before it. **A base that is a trunk asks nothing**: the branch a module built from the repository follows
|
|
// (main when it records none), or mesh-controller's own trunk. Any other base is asked of the forge once, and an
|
|
// answer it cannot give is an error, never read as "no release branch".
|
|
func releaseBranchOf(ctx context.Context, p link.PullUpdated, scope checkScope, entries []inventory.Entry) (string, error) {
|
|
base := p.Base
|
|
if base == "" {
|
|
return "", nil
|
|
}
|
|
if scope.From != nil && base == trunkOf(scope.From.Source) {
|
|
return "", nil
|
|
}
|
|
var controller *inventory.Entry
|
|
for i, e := range entries {
|
|
if e.Manifest.Module == "mesh-controller" && !e.Provided && e.Source.Repository != "" {
|
|
controller = &entries[i]
|
|
break
|
|
}
|
|
}
|
|
if controller == nil || base == trunkOf(controller.Source) {
|
|
return "", nil
|
|
}
|
|
if !tryRef.MatchString(base) || strings.Contains(base, "..") {
|
|
return "", nil // no branch the forge could hold: checked as before
|
|
}
|
|
owner, repo := sourceOwner(controller.Source.Repository), sourceName(controller.Source.Repository)
|
|
if owner == "" || repo == "" {
|
|
return "", nil
|
|
}
|
|
has, err := forgeHasBranch(ctx, owner, repo, base)
|
|
if err != nil {
|
|
return "", fmt.Errorf("whether %s/%s#%d merges into a release branch cannot be told — the forge did not say "+
|
|
"whether %s/%s has a branch %s (novox/hq ADR 0358): %w", p.Owner, p.Repo, p.Number, owner, repo, base, err)
|
|
}
|
|
if !has {
|
|
return "", nil
|
|
}
|
|
return base, 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]
|
|
}
|
|
|
|
// forgeHasBranch asks the forge, through its module's tools on the bus, whether a repository has a branch. A
|
|
// variable so a test needs no forge.
|
|
var forgeHasBranch = func(ctx context.Context, owner, repo, branch string) (bool, error) {
|
|
js, err := aBus()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer js.Close()
|
|
raw, _ := json.Marshal(map[string]any{"path": "/repos/" + owner + "/" + repo + "/branches/" + branch})
|
|
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return branchAnswered(answer, branch)
|
|
}
|
|
|
|
// branchAnswered reads the forge's answer about a branch: a 404 is "no such branch"; an answer naming the branch is
|
|
// "it has it"; anything else — another error, an unreadable answer, one naming no branch or another — is an error,
|
|
// never read as "no release branch".
|
|
func branchAnswered(answer link.Answer, branch string) (bool, error) {
|
|
if answer.Error != "" {
|
|
if strings.Contains(answer.Error, " 404 ") {
|
|
return false, nil
|
|
}
|
|
return false, errors.New(answer.Error)
|
|
}
|
|
var found struct {
|
|
Result struct {
|
|
Name string `json:"name"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(answer.Result, &found); err != nil {
|
|
return false, fmt.Errorf("the forge's answer about branch %s cannot be read: %w", branch, err)
|
|
}
|
|
if found.Result.Name != branch {
|
|
return false, fmt.Errorf("the forge answered about branch %s naming %q", branch, found.Result.Name)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// 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) {
|
|
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
|
|
// 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.Release, c.JudgedBy, c.BesideAt = result.Check.Release, result.Check.JudgedBy, result.Check.BesideAt
|
|
c.Validator = result.Check.Validator
|
|
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
|
|
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, 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.Printf("%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.Printf("%s: the verdict could not be said, so the pull request is not told it: %v\n", c.ID, err)
|
|
}
|
|
}
|