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
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.
388 lines
16 KiB
Go
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)
|
|
}
|
|
}
|