Files
mesh-catalog/modules/mesh-delivery/cmd/mesh-delivery/issue341_test.go
T
jschoubben 82cca650a2
mesh/delivery-group group feat/341-a-merge-check-judges-the-merge checking: 0 of 3 member(s) ready
mesh/merge-gate pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Put a verdict out of date when its trunk moves, and refuse a merge on one
A merge check's verdict now names the trunk commit it judged (novox/hq ADR
0323, issue 341), but the forge kept it green after another merge moved the
trunk, the merge tool merged on it, and mesh-delivery kept the delivery ready.
The forge's holder puts both statuses of every open pull request into a moved
trunk back to pending with the reason and runs nothing; the merge tool compares
the verdict's trunk commit with the trunk's head, asks the check once and never
merges by itself; mesh-delivery sends a ready delivery back to proposed, counts
only a verdict for the trunk's head, and composes a group again for a rebased
member instead of holding it.
2026-10-12 17:29:58 +02:00

388 lines
16 KiB
Go

package main
import (
"strings"
"testing"
"time"
)
// **A verdict holds for the trunk commit it judged** (novox/hq ADR 0323, issue 341). The check judges the head
// merged into a named trunk commit; when another merge moves that trunk, a ready delivery goes back to proposed,
// a rejected one stays rejected, and nothing is asked. A verdict for an old trunk is kept and shown and does not
// count, so the table reaches a fixed point. A group's composed check goes out of date on a merge that is not one of
// its members, and runs again for new heads instead of holding the group.
const (
trunk0 = "a2c2c35000000000000000000000000000000000"
merge1 = "d28ca95000000000000000000000000000000000"
)
// judged is a verdict of a head merged into a trunk commit.
func judged(repo string, number int, sha, gate, trunk string) CheckedEvent {
c := verdict(repo, number, sha, gate)
c.Trunk = shortOf(trunk, 12)
c.ID = "check-of-" + sha + "-at-" + shortOf(trunk, 8)
c.Took = "4m12s"
c.Beside = map[string]string{"mesh-catalog": "0123456789ab"}
plan := *c.Plan
plan.Head, plan.Trunk = sha, c.Trunk
c.Plan = &plan
return c
}
func TestATrunkMoveSendsAReadyDeliveryBackToProposed(t *testing.T) {
const host = "novox/mesh-host"
const h64, h66, h67 = "6464646464646464", "dfb1415dfb1415df", "6767676767676767"
w := newWorld(t)
for _, p := range []PullEvent{pr(host, 64, h64, "a"), pr(host, 66, h66, "b"), pr(host, 67, h67, "c")} {
w.h.PullUpdated(p)
}
w.h.Checked(judged(host, 64, h64, "pass", trunk0))
w.h.Checked(judged(host, 66, h66, "pass", trunk0))
w.h.Checked(judged(host, 67, h67, "fail", trunk0))
id66, id67 := IDOf(host, h66), IDOf(host, h67)
if s := w.state(id66); s != Ready {
t.Fatalf("#66 checked green against the trunk is %s", s)
}
asked := len(w.ctl.checks)
// #64 merges: the trunk moves to its merge commit.
w.h.PullMerged(merged(host, 64, h64, merge1))
w.settleAll()
d := w.delivery(id66)
if d.State != Proposed {
t.Fatalf("#66's verdict names trunk %s, the trunk is now %s, and it is %s", trunk0[:12], merge1[:12], d.State)
}
last := d.Transitions[len(d.Transitions)-1]
if last.Event != EvTrunkMoved || !strings.Contains(last.Why, trunk0[:12]) || !strings.Contains(last.Why, merge1[:12]) {
t.Fatalf("its transition was %s, %q: not trunk moved with both commits", last.Event, last.Why)
}
if d.Check == nil || d.Check.Trunk != trunk0[:12] {
t.Fatalf("its out-of-date verdict was not kept to be shown: %+v", d.Check)
}
if s := w.state(id67); s != Rejected {
t.Fatalf("a rejected delivery went to %s on the trunk's move", s)
}
if len(w.ctl.checks) != asked {
t.Fatalf("the trunk's move asked a check: %v", w.ctl.checks[asked:])
}
// Out of date and not asked: no bound, so stalled does not list it, however long it waits.
w.later(5 * time.Hour)
w.settleAll()
for _, l := range w.h.Stalled() {
if l.ID == id66 {
t.Fatalf("stalled lists a proposed delivery whose check is out of date and not asked: %+v", l)
}
}
if len(w.ctl.checks) != asked {
t.Fatalf("an out-of-date check was asked by this owner: %v", w.ctl.checks[asked:])
}
// A late verdict for the old trunk is kept, does not count, and moves nothing: the table's fixed point.
before := len(w.delivery(id66).Transitions)
late := judged(host, 66, h66, "pass", trunk0)
late.ID = "check-late"
w.h.Checked(late)
w.settleAll()
d = w.delivery(id66)
if d.State != Proposed || len(d.Transitions) != before || d.Check == nil || d.Check.ID != "check-late" {
t.Fatalf("a late verdict for an old trunk left it %s after %d transition(s), verdict %+v", d.State,
len(d.Transitions)-before, d.Check)
}
if ts := settle(d, Facts{Now: w.now}); len(ts) != 0 {
t.Fatalf("the table took %d more transition(s) on a verdict that does not count", len(ts))
}
// The check asked again judges the head merged into the trunk's head: it counts.
w.h.Checked(judged(host, 66, h66, "pass", merge1))
w.settleAll()
if s := w.state(id66); s != Ready {
t.Fatalf("a verdict judged against the trunk's head left it %s", s)
}
}
// A rejected delivery judged again against another trunk — the merge tool's ask — is taken as its recheck.
func TestARejectedDeliveryJudgedAgainstANewTrunkIsRecheckedByTheVerdict(t *testing.T) {
const repo = "novox/mesh-lab"
const head = "1a2b3c4d5e6f7a8b"
w := newWorld(t)
w.h.PullUpdated(pr(repo, 9, head, "x"))
w.h.Checked(judged(repo, 9, head, "fail", trunk0))
id := IDOf(repo, head)
if s := w.state(id); s != Rejected {
t.Fatalf("it is %s", s)
}
w.h.PullMerged(merged(repo, 8, "8888888888888888", merge1))
w.h.Checked(judged(repo, 9, head, "pass", merge1))
w.settleAll()
d := w.delivery(id)
if d.State != Ready || d.Check.Trunk != merge1[:12] {
t.Fatalf("judged green against the trunk's head it is %s with %+v", d.State, d.Check)
}
}
// **A verdict names the trunk commit it judged** in `checks` and `show`, and the kept plan records it (ADR 0323 §3).
func TestChecksAndShowNameTheHeadAndTheTrunkCommit(t *testing.T) {
const repo = "novox/mesh-lab"
const head = "1a2b3c4d5e6f7a8b"
w := newWorld(t)
w.h.PullUpdated(pr(repo, 9, head, "x"))
w.h.Checked(judged(repo, 9, head, "pass", trunk0))
id := IDOf(repo, head)
a, err := w.h.Checks(repo, 0, head)
if err != nil {
t.Fatal(err)
}
want := "head " + head[:12] + " merged into trunk " + trunk0[:12]
if a.Judged != want || a.Verdict == nil || a.Verdict.Head != head[:12] || a.Verdict.Trunk != trunk0[:12] {
t.Fatalf("checks says %q, verdict %+v; want %q", a.Judged, a.Verdict, want)
}
if !strings.Contains(a.Says, want) {
t.Fatalf("checks' words do not say what was judged: %s", a.Says)
}
if a.Verdict.Beside["mesh-catalog"] != "0123456789ab" || a.Verdict.Took != "4m12s" {
t.Fatalf("checks does not name the other repositories' commits or how long it took: %+v", a.Verdict)
}
shown, err := w.h.Show(id)
if err != nil {
t.Fatal(err)
}
d := shown.(map[string]any)["delivery"].(*Delivery)
if d.Check.Head != head[:12] || d.Check.Trunk != trunk0[:12] {
t.Fatalf("show gives the verdict %+v", d.Check)
}
if d.Plan == nil || d.Plan.Trunk != trunk0[:12] || d.Plan.Head != head {
t.Fatalf("the kept delivery plan records %+v", d.Plan)
}
w.h.PullMerged(merged(repo, 8, "8888888888888888", merge1))
if a, _ = w.h.Checks(repo, 0, head); !strings.Contains(a.OutOfDate, merge1[:12]) {
t.Fatalf("checks does not say the verdict is out of date: %+v", a)
}
}
// **A delivery group's composed check follows the same rule** (ADR 0323 §6, §7): a merge that is not one of its
// members into a member's trunk puts it out of date and the group reads checking; its own members merging in its
// order do not; and it runs again once a member is judged again.
func TestAGroupsComposedCheckGoesOutOfDateOnAMergeThatIsNotItsMember(t *testing.T) {
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
const hc, hk = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1"
idc, idk := IDOf(ctl, hc), IDOf(cat, hk)
start := func(t *testing.T) *world {
w := newWorld(t)
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
w.settleAll()
g := w.h.groups["feat/g"]
if g == nil || g.Check == nil {
t.Fatalf("no composed check was asked: %+v", g)
}
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
Members: []struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Commit string `json:"commit"`
Trunk string `json:"trunk,omitempty"`
}{{Owner: "novox", Repo: "mesh-controller", Commit: hc, Trunk: trunk0[:12]},
{Owner: "novox", Repo: "mesh-catalog", Commit: hk, Trunk: trunk0[:12]}}})
w.settleAll()
if state, _ := GroupState(g, w.h.membersOf(g)); state != "ready" {
t.Fatalf("the group composed green is %s", state)
}
if g.Check.Trunks[idc] != trunk0[:12] {
t.Fatalf("the composed check names its members' trunks %v", g.Check.Trunks)
}
return w
}
t.Run("a merge that is not a member", func(t *testing.T) {
w := start(t)
asked := len(w.ctl.checks)
w.h.PullMerged(merged(ctl, 5, "5555555555555555", merge1))
w.settleAll()
g := w.h.groups["feat/g"]
state, why := GroupState(g, w.h.membersOf(g))
if state != "checking" || g.Check.OutOfDate == "" {
t.Fatalf("the group is %s (%s) after a merge that is not its member moved a member's trunk", state, why)
}
for _, head := range []string{ctl + "@" + hc, cat + "@" + hk} {
s := w.forge.statuses[head+" "+ctxGroup]
if !strings.HasPrefix(s, "pending ") || !strings.Contains(s, "out of date") {
t.Fatalf("mesh/delivery-group on %s says %q, not pending with the reason", head, s)
}
}
if len(w.ctl.checks) != asked {
t.Fatalf("the trunk's move asked a check: %v", w.ctl.checks[asked:])
}
// The controller's member judged again against the trunk's head: the composed check runs again.
w.later(time.Minute)
w.h.Checked(judged(ctl, 1, hc, "pass", merge1))
w.settleAll()
if len(w.ctl.checks) == asked || !strings.HasPrefix(w.ctl.checks[len(w.ctl.checks)-1], "feat/g:") {
t.Fatalf("the composed check was not asked again once a member was judged again: %v", w.ctl.checks)
}
})
t.Run("its own member merging", func(t *testing.T) {
w := start(t)
mc := merged(ctl, 1, hc, merge1)
mc.Head = "feat/g"
w.h.PullMerged(mc)
w.settleAll()
g := w.h.groups["feat/g"]
if g.Check.OutOfDate != "" {
t.Fatalf("the group's own member merging put its composed check out of date: %s", g.Check.OutOfDate)
}
if s := w.state(idk); s != Ready {
t.Fatalf("the catalogue's member, in another repository, is %s", s)
}
})
}
// **After a member is rebased or pushed, the composed check runs again for the new heads** and the group reads
// checking, not held (ADR 0323 §6, issue 406's second symptom): its first member merged, closing the group, and then
// its second was rebased just before its own merge.
func TestARebasedMemberIsComposedAgainNotHeld(t *testing.T) {
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
const hc, hk, hc2 = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1", "c2c2c2c2c2c2c2c2"
idc, idk, idc2 := IDOf(ctl, hc), IDOf(cat, hk), IDOf(ctl, hc2)
w := newWorld(t)
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
w.settleAll()
g := w.h.groups["feat/g"]
if len(g.Order) != 2 || g.Order[0] != idk {
t.Fatalf("the group's order is %v: the catalogue's member first is what this replays", g.Order)
}
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"})
w.settleAll()
// The catalogue's member merges first: the group is closed.
mk := merged(cat, 2, hk, merge1)
mk.Head = "feat/g"
w.h.PullMerged(mk)
w.h.WalkMoved(w.ctl.put(aWalk("plan-cat", cat, merge1, true)))
w.settleAll()
if !g.Closed {
t.Fatal("the group is not closed once a member merged")
}
// The controller's member is rebased just before its merge: a new head, with its own check.
w.h.PullUpdated(pr(ctl, 1, hc2, "feat/g"))
w.settleAll()
if d := w.delivery(idc2); d.Group != "feat/g" {
t.Fatalf("the rebased head did not take its place in its closed group: %q", d.Group)
}
if containsString(g.Members, idc) || !containsString(g.Members, idc2) || len(g.Order) != 2 || g.Order[1] != idc2 {
t.Fatalf("the group's members are %v in the order %v", g.Members, g.Order)
}
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
t.Fatalf("the group reads %s (%s), not checking", state, why)
}
w.h.Checked(judged(ctl, 1, hc2, "pass", trunk0))
w.settleAll()
asked := w.ctl.checks[len(w.ctl.checks)-1]
if !strings.HasPrefix(asked, "feat/g:") || !strings.Contains(asked, idc2) || !strings.Contains(asked, idk) {
t.Fatalf("the composed check was not asked again for the new heads: %v", w.ctl.checks)
}
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
t.Fatalf("the group reads %s (%s), not checking", state, why)
}
for _, id := range []string{idk, idc2} {
if d := w.delivery(id); d.State == Held {
t.Fatalf("%s is held for a person: %s", id, d.HeldWhy)
}
}
// Its verdict for the new heads: the rebased member merges and takes its turn.
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
mc := merged(ctl, 1, hc2, "e0e0e0e0e0e0e0e0")
mc.Head = "feat/g"
w.h.PullMerged(mc)
w.h.WalkMoved(w.ctl.put(aWalk("plan-ctl", ctl, "e0e0e0e0e0e0e0e0", true)))
w.settleAll()
if d := w.delivery(idc2); d.State == Held {
t.Fatalf("the rebased member, composed green for the new heads, is held: %s", d.HeldWhy)
}
}
// A head whose merge check the forge's holder put out of date is not a check asked and not answered: `stalled` does
// not list it, however long it waits — rejected, or with no delivery here — until its check is asked (ADR 0323 §7).
func TestAnOutOfDateCheckOnTheForgeIsNotSaidUnanswered(t *testing.T) {
const repo = "novox/mesh-lab"
const head = "1a2b3c4d5e6f7a8b9c0d"
w := newWorld(t)
w.h.PullUpdated(pr(repo, 9, head, "x"))
w.h.Checked(judged(repo, 9, head, "fail", trunk0))
words := outOfDateWords(trunk0, merge1)
pending := func(description, at string) map[string]HeadCheck {
return map[string]HeadCheck{ctxGate: {State: "pending", Description: description, UpdatedAt: at},
ctxRepo: {State: "pending", Description: description, UpdatedAt: at}}
}
w.forge.open = []OpenPull{{Owner: "novox", Repo: "mesh-lab", Number: 9, Base: "main", HeadSHA: head,
Required: []string{ctxGate, ctxRepo}, Statuses: []string{ctxGate, ctxRepo}, Checks: pending(words, "2026-10-06T12:01:00Z")}}
w.h.SeeOpenPulls()
w.later(3 * time.Hour)
w.h.SeeOpenPulls()
for _, l := range w.h.Stalled() {
if strings.HasPrefix(l.ID, repo) {
t.Fatalf("an out-of-date check nobody asked is said stalled: %+v", l)
}
}
// Asked by the merge tool: pending anew, and its bound runs from then.
w.forge.open[0].Checks = pending("waiting in the build queue since 15:01: asked by the merge tool", "2026-10-06T15:01:00Z")
w.h.SeeOpenPulls()
w.later(2 * time.Hour)
w.h.SeeOpenPulls()
said := false
for _, l := range w.h.Stalled() {
said = said || l.State == Unanswered
}
if !said {
t.Fatal("a check the merge tool asked and that never answered is not said")
}
}
// The merge tool's ask announces an out-of-date head again: its one-hour bound runs from then, and a check that never
// answers is said stalled (ADR 0323 §7).
func TestAnOutOfDateDeliveryAskedAgainIsBoundFromTheAsk(t *testing.T) {
const repo = "novox/mesh-lab"
const head = "1a2b3c4d5e6f7a8b"
w := newWorld(t)
w.h.PullUpdated(pr(repo, 9, head, "x"))
w.h.Checked(judged(repo, 9, head, "pass", trunk0))
w.h.PullMerged(merged(repo, 8, "8888888888888888", merge1))
id := IDOf(repo, head)
w.later(3 * time.Hour)
w.h.PullUpdated(pr(repo, 9, head, "x")) // the merge tool asked its check
w.later(30 * time.Minute)
for _, l := range w.h.Stalled() {
if l.ID == id {
t.Fatalf("listed half an hour after its check was asked again: %+v", l)
}
}
w.later(time.Hour)
said := false
for _, l := range w.h.Stalled() {
if l.ID == id {
said = true
if !strings.Contains(l.Says, "asked again") || !strings.Contains(l.H2, "operator") {
t.Fatalf("its stalled line says %+v", l)
}
}
}
if !said {
t.Fatal("a check asked again that never answered is not said")
}
// Its verdict judged against the trunk's head: ready, and nothing bounds it as proposed.
w.h.Checked(judged(repo, 9, head, "pass", merge1))
if d := w.delivery(id); d.State != Ready || d.AskedAgain != nil {
t.Fatalf("judged again it is %s, asked again %v", d.State, d.AskedAgain)
}
}