mesh-delivery#51 merged 21:43:11 and the gate read the open list at 21:43:14, before the forge's merged event: taken as learnt late, it moved no trunk and its group's finish left it out of source.main, so the checker kept judging every mesh-delivery head on 73be3df. A late merge within replayLate of its landing is now taken as heard as it happened, and one a gate before this left is written at the catch-up of the start (never backwards).
771 lines
33 KiB
Go
771 lines
33 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
const time30s = 31 * time.Second
|
|
|
|
// Issue 1275 (the 2026-10-19 18:05 group): a group is merged whole or not at all, in its order, and a group that is held or
|
|
// half merged is said loudly. Issue 1276/AC1: a refusal naming the member's own repository check as missing asks that check.
|
|
|
|
const (
|
|
gate, repoCheck = "mesh/merge-gate", "mesh/repo-check"
|
|
)
|
|
|
|
// statusOf sets a member head's two merge checks on the forge, and makes both required.
|
|
func setChecks(w *world, repository, sha, gateState, repoState string) {
|
|
w.forge.required = []string{gate, repoCheck}
|
|
for ctx, state := range map[string]string{gate: gateState, repoCheck: repoState} {
|
|
key := repository + "@" + sha + " " + ctx
|
|
if state == "" {
|
|
delete(w.forge.statuses, key) // no status at all: the forge says it is missing
|
|
continue
|
|
}
|
|
w.forge.statuses[key] = state + " said → x"
|
|
}
|
|
}
|
|
|
|
func allGreen(w *world) {
|
|
setChecks(w, gCat, gHk, "success", "success")
|
|
setChecks(w, gCtl, gHc, "success", "success")
|
|
}
|
|
|
|
func refusedWords(repository string) error {
|
|
return errors.New("the forge refused gitea_merge_pull_request: " + repository + " is not merged: main requires " +
|
|
"mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing). A pull request merges once its checks succeed")
|
|
}
|
|
|
|
func urgentLines(w *world) []StalledLine {
|
|
var out []StalledLine
|
|
for _, l := range w.h.Stalled() {
|
|
if l.Severity == "urgent" {
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 1275/AC2: an approved group whose member has a required status that is not a success at its head when the merge starts
|
|
// is merged not at all, though the first member in order is fine; the group says which member and which status.
|
|
func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing after the ask
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 0 {
|
|
t.Fatalf("merged with a required status missing: %v", w.forge.merges)
|
|
}
|
|
lines := urgentLines(w)
|
|
if len(lines) != 1 || !strings.Contains(lines[0].Says, "feat/g") || !strings.Contains(lines[0].Says, IDOf(gCtl, gHc)) ||
|
|
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
|
t.Fatalf("the held group is not said as one urgent line naming group, member, status and need: %+v", lines)
|
|
}
|
|
// The status turns success: the whole group merges.
|
|
setChecks(w, gCtl, gHc, "success", "success")
|
|
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"})
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 2 || len(urgentLines(w)) != 0 {
|
|
t.Fatalf("merges %v, lines %+v; wanted both merged and no line", w.forge.merges, urgentLines(w))
|
|
}
|
|
}
|
|
|
|
// 1275/AC4: a group half merged (the second member refused after the first merged) stays raised, pass after pass, until
|
|
// the second is merged; then it goes.
|
|
func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
|
for range 3 {
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
lines := urgentLines(w)
|
|
if len(lines) != 1 || lines[0].ID != IDOf(gCtl, gHc) || !strings.Contains(lines[0].Says, "feat/g") {
|
|
t.Fatalf("the half merged group is not raised: %+v", lines)
|
|
}
|
|
}
|
|
// A half merged group with no refusal on record (the pass died between members) is raised too.
|
|
d := w.delivery(IDOf(gCtl, gHc))
|
|
d.Ready.RefusedSince = nil
|
|
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "half merged") {
|
|
t.Fatalf("a half merged group without a refusal is not raised: %+v", lines)
|
|
}
|
|
w.forge.refuseRepo = nil
|
|
setChecks(w, gCtl, gHc, "success", "success")
|
|
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
|
w.settleAll()
|
|
if lines := urgentLines(w); len(lines) != 0 {
|
|
t.Fatalf("the group is whole and still raised: %+v", lines)
|
|
}
|
|
}
|
|
|
|
// 1275/AC5: a member's required status turning not-success while the ask waits withdraws the ask at the router; the
|
|
// answer to the withdrawn ask merges nothing; the group is asked again once every member may merge.
|
|
func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
ask := w.op.open()[0]
|
|
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "pending"})
|
|
setChecks(w, gCat, gHk, "success", "pending")
|
|
w.settleAll()
|
|
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != ask {
|
|
t.Fatalf("the ask was not withdrawn at the router: %v", w.op.cancelled)
|
|
}
|
|
w.op.answer(ask, OptionApprove) // an answer that arrives late
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 0 {
|
|
t.Fatalf("the answer to a withdrawn ask merged: %v", w.forge.merges)
|
|
}
|
|
if w.op.count() != 1 {
|
|
t.Fatalf("asked again while a status was still pending: %d", w.op.count())
|
|
}
|
|
setChecks(w, gCat, gHk, "success", "success")
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
if w.op.count() != 2 {
|
|
t.Fatalf("not asked again once every member may merge: %d", w.op.count())
|
|
}
|
|
}
|
|
|
|
// 1275/AC3 (review round 1): a member that is both refused and held for a required status is said by one stalled line,
|
|
// not two with the same id.
|
|
func TestAMemberBothRefusedAndHeldForAStatusIsOneStalledLine(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
setChecks(w, gCat, gHk, "success", "") // now its status is missing too; the next pass is held
|
|
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "success"})
|
|
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
if g := w.h.groups["feat/g"]; g.Hold == nil {
|
|
t.Fatalf("the group was not held for the missing status")
|
|
}
|
|
n := 0
|
|
for _, l := range w.h.Stalled() {
|
|
if l.ID == IDOf(gCat, gHk) {
|
|
n++
|
|
}
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("%d stalled lines for one member: %+v", n, w.h.Stalled())
|
|
}
|
|
}
|
|
|
|
// 1275/AC6, as novox/hq ADR 0505 §4 changed it: a member refused because its verdict is out of date (the trunk moved; its
|
|
// fresh check runs) is no stalled line while the gate answers it, and urgent once ten minutes passed with the group not
|
|
// merged; the pass stops at the refusal all the same.
|
|
func TestAnOutOfDateRefusalIsNoLineWhileAnsweredAndUrgentAfterTenMinutes(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuse = errAskedAgain
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 1 {
|
|
t.Fatalf("the pass did not stop at the refusal: %v", w.forge.merges)
|
|
}
|
|
sev := func() []string {
|
|
var out []string
|
|
for _, l := range w.h.Stalled() {
|
|
out = append(out, l.Severity)
|
|
}
|
|
return out
|
|
}
|
|
if got := sev(); len(got) != 0 {
|
|
t.Fatalf("an out of date refusal is %v at once; wanted no line while it is answered", got)
|
|
}
|
|
w.later(9 * time.Minute)
|
|
if got := sev(); len(got) != 0 {
|
|
t.Fatalf("at nine minutes it is %v; wanted still no line", got)
|
|
}
|
|
w.later(2 * time.Minute)
|
|
if got := sev(); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
|
}
|
|
}
|
|
|
|
// 1275/AC6: every other refusal (here a missing status) is urgent at once.
|
|
func TestAMissingStatusRefusalIsUrgentAtOnce(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if lines := w.h.Stalled(); len(lines) != 1 || lines[0].Severity != "urgent" {
|
|
t.Fatalf("a missing status refusal is not urgent at once: %+v", lines)
|
|
}
|
|
}
|
|
|
|
func severities(w *world) []string {
|
|
var out []string
|
|
for _, l := range w.h.Stalled() {
|
|
out = append(out, l.Severity)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 1275/AC6 (b), as ADR 0505 §4 changed it: the same out of date refusal on a LATER member, after the earlier one merged,
|
|
// is no line at once and urgent after ten minutes: the half merged group does not say it at once.
|
|
func TestAnOutOfDateRefusalOnALaterMemberIsNoLineThenUrgent(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
|
w.settleAll()
|
|
if got := severities(w); len(got) != 0 {
|
|
t.Fatalf("a later member's out of date refusal is %v at once; wanted no line while it is answered", got)
|
|
}
|
|
w.later(11 * time.Minute)
|
|
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
|
}
|
|
}
|
|
|
|
// 1275/AC6 (c): words naming an out of date check and also a missing status are urgent at once.
|
|
func TestARefusalNamingOutOfDateAndAMissingStatusIsUrgentAtOnce(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-catalog#2 is not merged: its verdict was " +
|
|
"judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccc merged into trunk bbbb was " +
|
|
"asked. main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing)")
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("a refusal with two causes is %v; wanted urgent at once", got)
|
|
}
|
|
}
|
|
|
|
// 1275/AC6 (d): words mesh-delivery cannot classify are urgent at once.
|
|
func TestARefusalInUnknownWordsIsUrgentAtOnce(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: something nobody foresaw")
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("an unclassified refusal is %v; wanted urgent at once", got)
|
|
}
|
|
}
|
|
|
|
// The merge tool's refusals, copied from gitea merge.ts with their line, `${...}` filled in.
|
|
var mergeToolRefusals = []struct {
|
|
line string
|
|
words string
|
|
outOfDated bool
|
|
}{
|
|
{"merge.ts:121", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
|
"Its check is already asked and not yet answered (building), so it is not asked again; took 3s. Ask again at cccccccccccc once " +
|
|
"both statuses pass: this tool never merges by itself (novox/hq ADR 0323). A check that never answers is asked again with " +
|
|
"mesh-delivery.recheck. Nothing was merged", true},
|
|
{"merge.ts:147", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
|
"The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both " +
|
|
"statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may move the trunk " +
|
|
"again first; this is then refused and asked once more. A check that never answers is asked again with " +
|
|
"mesh-delivery.recheck. Nothing was merged", true},
|
|
{"merge.ts:144", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb, " +
|
|
"and its check could not be asked: the forge is away. Nothing was merged", false},
|
|
{"merge.ts:156", "novox/app#7 is not merged: main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check " +
|
|
"(pending). A pull request merges once its checks succeed", false},
|
|
{"merge.ts:165", "novox/app#7 is not merged: its verdict read siblings, and which ones is not held here, so the group cannot " +
|
|
"be weighed. Ask its check again at cccccccccccc. Nothing was merged", false},
|
|
{"merge.ts:172", "novox/app#7 is not merged: its verdict was judged beside 1 sibling(s), and a sibling is not ready. A member " +
|
|
"judged beside its siblings merges once its group passed at the heads that verdict read (novox/hq ADR 0382, 0421). " +
|
|
"Nothing was merged", false},
|
|
}
|
|
|
|
// 1275/AC6 (e), as ADR 0505 §4 changed it: only the merge tool's two out of date refusals (:121, :147) are answered by
|
|
// the gate and said as no line at once; the one saying its check could not be asked (:144) and every other is urgent at
|
|
// once.
|
|
func TestOnlyTheMergeToolsTwoOutOfDateRefusalsWaitWhileAnswered(t *testing.T) {
|
|
for _, c := range mergeToolRefusals {
|
|
if got := outOfDateRefusal(c.words); got != c.outOfDated {
|
|
t.Errorf("%s: outOfDateRefusal = %v, wanted %v", c.line, got, c.outOfDated)
|
|
}
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: " + c.words)
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
got := severities(w)
|
|
if c.outOfDated && len(got) != 0 {
|
|
t.Errorf("%s: said %v at once, wanted no line while it is answered", c.line, got)
|
|
}
|
|
if !c.outOfDated && (len(got) != 1 || got[0] != "urgent") {
|
|
t.Errorf("%s: severity %v at once, wanted urgent", c.line, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1275/AC6 (f): the severity never steps down: a missing status refusal, then an out of date one, stays urgent.
|
|
func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
w.forge.refuseRepo = map[string]error{gCat: errAskedAgain}
|
|
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
if d := w.delivery(IDOf(gCat, gHk)); !outOfDateRefusal(d.Ready.Answer) {
|
|
t.Fatalf("the second refusal was not the out of date one: %q", d.Ready.Answer)
|
|
}
|
|
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
|
t.Fatalf("severity %v after an out of date refusal that followed an urgent one; wanted urgent", got)
|
|
}
|
|
}
|
|
|
|
// fakeConditions is the gate's bucket conditions and the operator-channel seat's subject for conditions: what it keeps,
|
|
// and every condition said, in order.
|
|
type fakeConditions struct {
|
|
mu sync.Mutex
|
|
kept map[string]string
|
|
told []asks.Condition
|
|
}
|
|
|
|
func (f *fakeConditions) Said() (map[string]string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := map[string]string{}
|
|
for k, v := range f.kept {
|
|
out[k] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeConditions) PutSaid(said map[string]string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.kept = map[string]string{}
|
|
for k, v := range said {
|
|
f.kept[k] = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeConditions) say(c asks.Condition) error {
|
|
if _, err := asks.EncodeCondition(c); err != nil {
|
|
return err // what the router would refuse
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.told = append(f.told, c)
|
|
return nil
|
|
}
|
|
|
|
// last is the newest condition said for a delivery id, or nil.
|
|
func (f *fakeConditions) last(id string) *asks.Condition {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i := len(f.told) - 1; i >= 0; i-- {
|
|
if f.told[i].Subject.ID == id {
|
|
c := f.told[i]
|
|
return &c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hearConditions has the gate say its conditions into a fake seat and bucket.
|
|
func hearConditions(w *world) *fakeConditions {
|
|
f := &fakeConditions{kept: map[string]string{}}
|
|
w.h.Say, w.h.Conditions = f.say, f
|
|
return f
|
|
}
|
|
|
|
// handedInOrder answers each member the gate hands the forge bridge as the forge does on 2026-10-25: the forge's word that
|
|
// it merged (gitea.pull.merged) first, the bridge's word after; or the bridge's first, as forgeFirst says. It returns the
|
|
// repositories in the order they were handed.
|
|
func handedInOrder(w *world, forgeFirst bool) []string {
|
|
var handed []string
|
|
numbers := map[string]int{}
|
|
for i := 0; i < 4; i++ {
|
|
var next *sdk.MergePull
|
|
for _, p := range w.merges.last("feat/g").Pulls {
|
|
if p.State == sdk.MergeMerging {
|
|
p := p
|
|
next = &p
|
|
}
|
|
}
|
|
if next == nil || numbers[next.Repository] > 0 {
|
|
break
|
|
}
|
|
handed = append(handed, next.Repository)
|
|
numbers[next.Repository] = next.Number
|
|
mergeCommit := strings.Repeat(string(rune('a'+i)), 40)
|
|
forge := func() { w.h.PullMerged(merged(next.Repository, next.Number, next.Head.Head, mergeCommit)) }
|
|
bridge := func() { bridgeMerged(w, next.Repository, next.Number, next.Head.Head, mergeCommit) }
|
|
if forgeFirst {
|
|
forge()
|
|
w.settleAll() // ten seconds pass between the two words on 2026-10-25: the gate looks in between
|
|
bridge()
|
|
} else {
|
|
bridge()
|
|
w.settleAll()
|
|
forge()
|
|
}
|
|
w.settleAll()
|
|
}
|
|
return handed
|
|
}
|
|
|
|
// Issue 1275, met again 2026-10-25 (group fix/puller-wakes-on-capacity): every member of an approved group is handed to
|
|
// the forge bridge, in the group's order, the next on the event that the one before merged; a group whose pass stops is
|
|
// said to the operator as a condition, which is cleared once it is whole; and every word a group's state is said in is
|
|
// one the merge decides on.
|
|
func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
|
const (
|
|
sdkRepo, hS = "novox/mesh-sdk", "a8fbabbfc37369f9d1f6977c72e5523aff3d494c"
|
|
catRepo, hK = "novox/mesh-catalog", "23c827e7910c233608af4e4a8056cbc362dcb755"
|
|
ctlRepo, hC = "novox/mesh-controller", "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0"
|
|
)
|
|
sdkMember := member{sdkRepo, 80, hS, "go/stdio.go,go/buildstep/pull.go"}
|
|
catMember := member{catRepo, 479, hK, "modules/mesh-build-go/asks.go,modules/mesh-build-go/go.mod,modules/mesh-build-go/go.sum"}
|
|
ctlMember := member{ctlRepo, 7, hC, "cmd/mesh-controller/main.go"}
|
|
inOrder := func(members []member, forgeFirst bool, want ...string) func(t *testing.T) {
|
|
return func(t *testing.T) {
|
|
w := approvedGroupOf(t, members...)
|
|
w.merges.manual = true
|
|
allGreenFor(w, members...)
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if got := handedInOrder(w, forgeFirst); strings.Join(got, " ") != strings.Join(want, " ") {
|
|
t.Fatalf("handed %v, wanted %v, every member in the group's order", got, want)
|
|
}
|
|
if g := w.h.groups["feat/g"]; g == nil {
|
|
t.Fatal("the group is gone")
|
|
} else if state, why := GroupState(g, w.h.membersOf(g)); state != GroupMerged {
|
|
t.Fatalf("the group is %s (%s), not merged whole", state, why)
|
|
}
|
|
if w.source.main == nil || len(w.source.main.Heads) != len(want) {
|
|
t.Fatalf("source.main does not hold the group as one revision: %+v", w.source.main)
|
|
}
|
|
}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
run func(t *testing.T)
|
|
}{
|
|
// 1275/AC3 (2026-10-25): the case of the day. The catalogue member pins the sdk member's commit; the forge's word
|
|
// of a merge arrives before the bridge's. Before the fix the catalogue member went first, and the sdk member was
|
|
// never handed: its group read merging, a word the turn to merge did not know.
|
|
{"sdk first, then the member that pins it, the forge's word first", inOrder([]member{catMember, sdkMember}, true, sdkRepo, catRepo)},
|
|
{"sdk first, then the member that pins it, the bridge's word first", inOrder([]member{sdkMember, catMember}, false, sdkRepo, catRepo)},
|
|
{"a member that pins nothing before one that changes a go.mod", inOrder([]member{catMember, ctlMember}, true, ctlRepo, catRepo)},
|
|
{"three members", inOrder([]member{ctlMember, catMember, sdkMember}, true, sdkRepo, ctlRepo, catRepo)},
|
|
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said
|
|
// urgent, naming the group, the refused member and the forge's words, as a stalled line and to the operator.
|
|
{"a refused first member stops the group and is said urgent", func(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
told := hearConditions(w)
|
|
allGreen(w)
|
|
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
|
|
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
|
|
}
|
|
lines := urgentLines(w)
|
|
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
|
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
|
t.Fatalf("not said as one urgent line: %+v", lines)
|
|
}
|
|
if c := told.last(IDOf(gCat, gHk)); c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent ||
|
|
!strings.Contains(c.Summary, "feat/g") {
|
|
t.Fatalf("the operator was not told, urgent: %+v", c)
|
|
}
|
|
}},
|
|
// 1275/AC4: a group half merged (its pass stopped after the first member) raises one urgent condition, said once
|
|
// however many looks follow, and cleared once the group is whole.
|
|
{"a half-merged group is raised once and cleared once whole", func(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
told := hearConditions(w)
|
|
allGreen(w)
|
|
w.merges.manual = true
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
|
bridgeMerged(w, gCat, 2, gHk, gT1)
|
|
setChecks(w, gCtl, gHc, "success", "") // the second member's status went missing: the pass stops at it
|
|
w.settleAll()
|
|
for range 3 {
|
|
w.later(time30s)
|
|
w.settleAll()
|
|
}
|
|
id := IDOf(gCtl, gHc)
|
|
raised := 0
|
|
for _, c := range told.told {
|
|
if c.Subject.ID == id && c.State == asks.StateRaised {
|
|
raised++
|
|
}
|
|
}
|
|
if c := told.last(id); raised != 1 || c == nil || c.Severity != asks.SeverityUrgent || !strings.Contains(c.Summary, "feat/g") ||
|
|
!strings.Contains(c.Summary, "half merged (merged 1 of 2)") {
|
|
t.Fatalf("raised %d times, last %+v; wanted once, urgent, naming the group half merged", raised, c)
|
|
}
|
|
setChecks(w, gCtl, gHc, "success", "success")
|
|
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"})
|
|
w.settleAll() // the status is an event: the pass goes on with no timer
|
|
handed := false
|
|
for _, p := range w.merges.last("feat/g").Pulls {
|
|
handed = handed || (p.Repository == gCtl && p.State == sdk.MergeMerging)
|
|
}
|
|
if !handed {
|
|
t.Fatalf("the status turned success and the second member was not handed: %+v", w.merges.last("feat/g"))
|
|
}
|
|
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
|
bridgeMerged(w, gCtl, 1, gHc, gT1)
|
|
w.settleAll()
|
|
if c := told.last(id); c == nil || c.State != asks.StateCleared {
|
|
t.Fatalf("the group is whole and its condition is not cleared: %+v", c)
|
|
}
|
|
}},
|
|
// A condition the gate raised before it restarted, whose delivery stalls no more, is cleared at the first look.
|
|
{"a condition kept from before a restart is cleared", func(t *testing.T) {
|
|
w := newWorld(t)
|
|
told := hearConditions(w)
|
|
told.kept[conditionKeyOf("novox/app@0123456789ab")] = "urgent||"
|
|
w.settleAll()
|
|
if c := told.last("novox/app@0123456789ab"); c != nil {
|
|
t.Fatalf("cleared before the forge's open pull requests were read: %+v", c)
|
|
}
|
|
w.h.wantForge()
|
|
w.settleAll()
|
|
if c := told.last("novox/app@0123456789ab"); c == nil || c.State != asks.StateCleared || len(told.kept) != 0 {
|
|
t.Fatalf("not cleared: %+v, kept %v", c, told.kept)
|
|
}
|
|
}},
|
|
// Review round 1, blocker 1: a pull request closed or merged on the forge with no event heard (de-spiegel #4,
|
|
// closed 2026-10-17, ready here eight days later) settles at the next complete read of the open list, and no
|
|
// condition is raised for it; a merge learnt so does not move source.main.
|
|
{"a pull request gone from the open list settles and raises nothing", func(t *testing.T) {
|
|
for _, merged := range []bool{false, true} {
|
|
w := newWorld(t)
|
|
w.op.auto = false
|
|
const repo, head = "novox/de-spiegel", "fdcbaefb3123fdcbaefb3123fdcbaefb3123fdcb"
|
|
w.h.PullUpdated(pr(repo, 4, head, "feat/x", "src/a.go"))
|
|
w.h.Checked(judgedAt(repo, 4, head, "pass", "t1t1t1t1t1t1"))
|
|
w.settleAll()
|
|
w.forge.refuse = errors.New("novox/de-spiegel#4 merges into main, which no protection rule requiring the checks guards")
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
id := IDOf(repo, head)
|
|
if len(w.h.Stalled()) == 0 {
|
|
t.Fatalf("the refused head is not stalled to begin with")
|
|
}
|
|
p := ForgePull{Number: 4, State: "closed", HeadSHA: head, Base: "main"}
|
|
if merged {
|
|
p.Merged, p.MergeCommit = true, strings.Repeat("e", 40)
|
|
}
|
|
w.forge.pulls[repo+"#4"] = p
|
|
w.forge.open = []OpenPull{{Owner: "novox", Repo: "other", Number: 1, Base: "main", HeadSHA: "abab"}}
|
|
told := hearConditions(w)
|
|
moves := len(w.source.moves)
|
|
w.h.wantForge()
|
|
w.settleAll()
|
|
want := Closed
|
|
if merged {
|
|
want = Merged
|
|
}
|
|
if st := w.state(id); st != want {
|
|
t.Fatalf("merged %v: the delivery is %s, wanted %s", merged, st, want)
|
|
}
|
|
if c := told.last(id); c != nil {
|
|
t.Fatalf("merged %v: a condition was said for it: %+v", merged, c)
|
|
}
|
|
if len(w.source.moves) != moves {
|
|
t.Fatalf("merged %v: source.main moved for a merge learnt by its absence: %+v", merged, w.source.moves)
|
|
}
|
|
}
|
|
}},
|
|
// Review round 2: a merge learnt after the fact moves no other delivery's trunk (an approved group of the same
|
|
// repository keeps its trunk, its state and its composed check), takes the forge's time, and a catch-up after it
|
|
// does not move source.main.
|
|
{"a merge learnt late moves no trunk and no source", func(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
allGreen(w)
|
|
w.merges.manual = true
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
const lateHead, lateMerge = "9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a", "8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b"
|
|
w.h.PullUpdated(pr(gCat, 9, lateHead, "fix/old", "modules/app/x.go"))
|
|
w.h.Checked(judged(gCat, 9, lateHead, "pass", gTrunk0))
|
|
w.settleAll()
|
|
late := IDOf(gCat, lateHead)
|
|
trunkOf := func() map[string]string {
|
|
out := map[string]string{}
|
|
for _, id := range []string{IDOf(gCat, gHk), IDOf(gCtl, gHc)} {
|
|
out[id] = w.delivery(id).TrunkNow + " " + string(w.delivery(id).State)
|
|
}
|
|
return out
|
|
}
|
|
before, moves := trunkOf(), len(w.source.moves)
|
|
w.forge.pulls[gCat+"#9"] = ForgePull{Number: 9, State: "closed", Merged: true, MergeCommit: lateMerge,
|
|
MergedAt: "2026-10-01T09:00:00Z", HeadSHA: lateHead, Base: "main"}
|
|
w.forge.open = []OpenPull{{Owner: "novox", Repo: "mesh-catalog", Number: 2, Base: "main", HeadSHA: gHk},
|
|
{Owner: "novox", Repo: "mesh-controller", Number: 1, Base: "main", HeadSHA: gHc}}
|
|
w.h.wantForge()
|
|
w.settleAll()
|
|
d := w.delivery(late)
|
|
if d.State != Merged || !d.MergedAt.Equal(time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)) || !d.LearntLate {
|
|
t.Fatalf("the late merge is %s at %v (learnt late %v)", d.State, d.MergedAt, d.LearntLate)
|
|
}
|
|
if after := trunkOf(); fmt.Sprint(after) != fmt.Sprint(before) {
|
|
t.Fatalf("the group's trunk or state moved: %v, was %v", after, before)
|
|
}
|
|
if g := w.h.groups["feat/g"]; g.Check == nil || g.Check.OutOfDate != "" {
|
|
t.Fatalf("the group's composed check went out of date: %+v", g.Check)
|
|
}
|
|
w.h.SourceCatchUp()
|
|
w.settleAll()
|
|
if len(w.source.moves) != moves {
|
|
t.Fatalf("source.main moved for a merge learnt late: %+v", w.source.moves[moves:])
|
|
}
|
|
}},
|
|
// mesh-delivery#51 (2026-10-25 21:43): the open list was read seconds after the bridge merged it and before the forge's
|
|
// merged event, so it was learnt late, moved no trunk, and its group's finish left it out of source.main. A merge
|
|
// learnt within replayLate of its landing is taken as heard as it happened; one a gate before the fix took late is
|
|
// written at the catch-up of the start.
|
|
{"a merge learnt from the open list seconds after it landed moves source.main", func(t *testing.T) {
|
|
const repo, head, merge = "novox/mesh-delivery", "4628ba26b41a4628ba26b41a4628ba26b41a4628", "db53ec975f01db53ec975f01db53ec975f01db53"
|
|
for _, oldGate := range []bool{false, true} {
|
|
w := newWorld(t)
|
|
w.merges.manual = true
|
|
w.h.PullUpdated(pr(repo, 51, head, "fix/1945", "modules/a/a.go"))
|
|
w.h.Checked(judgedAt(repo, 51, head, "pass", "t1t1t1t1t1t1"))
|
|
w.settleAll()
|
|
w.source.main = &sdk.Source{V: sdk.Version, Group: "before", Heads: map[string]string{repo: "73be3df28cd4"}}
|
|
w.forge.pulls[repo+"#51"] = ForgePull{Number: 51, State: "closed", Merged: true, MergeCommit: merge,
|
|
MergedAt: w.h.Now().Add(-3 * time.Second).UTC().Format(time.RFC3339), HeadSHA: head, Base: "main"}
|
|
w.forge.open = []OpenPull{{Owner: "novox", Repo: "other", Number: 1, Base: "main", HeadSHA: "abab"}}
|
|
w.h.wantForge()
|
|
w.settleAll()
|
|
if oldGate { // what the gate before the fix left
|
|
w.h.mu.Lock()
|
|
kept := w.h.deliveries[IDOf(repo, head)]
|
|
kept.LearntLate, kept.InSource, kept.Ready.SourceMoved = true, true, true
|
|
w.h.mu.Unlock()
|
|
w.source.main.Heads[repo] = "73be3df28cd4"
|
|
w.h.SourceCatchUp()
|
|
w.settleAll()
|
|
}
|
|
d := w.delivery(IDOf(repo, head))
|
|
if d.State != Merged || (!oldGate && d.LearntLate) || w.source.main.Heads[repo] != merge {
|
|
t.Fatalf("old gate %v: %s, learnt late %v, source.main %v", oldGate, d.State, d.LearntLate, w.source.main.Heads)
|
|
}
|
|
if heads, _ := w.h.SourceMain()["heads"].(map[string]string); heads[repo] != merge {
|
|
t.Fatalf("delivery_status's source-main says %v", w.h.SourceMain())
|
|
}
|
|
}
|
|
}},
|
|
// Review round 1, blocker 2 (1275/AC6): an out-of-date refusal at t0 with no other event is said urgent at t0+10
|
|
// minutes: the gate asks itself for a look then.
|
|
{"an out-of-date refusal turns urgent on the clock alone", func(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
told := hearConditions(w)
|
|
allGreen(w)
|
|
w.forge.refuse = errAskedAgain
|
|
w.op.answer(w.op.open()[0], OptionApprove)
|
|
w.settleAll()
|
|
t0 := w.h.Now()
|
|
w.h.wanted.mu.Lock()
|
|
at := w.h.wanted.at
|
|
w.h.wanted.mu.Unlock()
|
|
if at.IsZero() || at.Sub(t0) > outOfDateFor {
|
|
t.Fatalf("no look is wanted by t0+%s: %v", outOfDateFor, at)
|
|
}
|
|
for i := 0; i < 5 && w.h.Now().Before(t0.Add(outOfDateFor)); i++ { // the timer fires a look, and nothing else happens
|
|
w.h.wanted.mu.Lock()
|
|
at = w.h.wanted.at
|
|
w.h.wanted.mu.Unlock()
|
|
if at.IsZero() {
|
|
break
|
|
}
|
|
w.later(at.Sub(w.h.Now()))
|
|
w.h.React()
|
|
}
|
|
c := told.last(IDOf(gCat, gHk))
|
|
if c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent {
|
|
t.Fatalf("at t0+%s with no event: %+v", w.h.Now().Sub(t0), c)
|
|
}
|
|
}},
|
|
// Review round 1, blocker 3: a gate that restarts while a head still waits on the forge does not clear its
|
|
// condition (the head's first sight is in memory alone, so its bound runs again), and does not say it again.
|
|
{"a restart keeps the condition of a head still waiting", func(t *testing.T) {
|
|
w := newWorld(t)
|
|
told := hearConditions(w)
|
|
const head = "bfe82315f42c8f4509d0598c32569a202d5eada6"
|
|
id := IDOf("novox/hq", head)
|
|
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 243, Base: "main", HeadSHA: head, Required: []string{"mesh/repo-check"}}}
|
|
w.h.wantForge()
|
|
w.settleAll()
|
|
w.later(UncheckedAfter + time.Minute)
|
|
w.settleAll()
|
|
if c := told.last(id); c == nil || c.State != asks.StateRaised {
|
|
t.Fatalf("the unchecked head is not raised: %+v", c)
|
|
}
|
|
before := len(told.told)
|
|
again := newWorldOver(t, w.store, w.forge, w.h.Now()) // the gate restarts
|
|
again.h.Say, again.h.Conditions = told.say, told
|
|
again.h.wantForge()
|
|
again.settleAll()
|
|
again.later(time.Minute)
|
|
again.settleAll()
|
|
if len(told.told) != before {
|
|
t.Fatalf("the restart said something of a head still waiting: %+v", told.told[before:])
|
|
}
|
|
w.forge.open = nil // it closes
|
|
again.h.wantForge()
|
|
again.settleAll()
|
|
if c := told.last(id); c == nil || c.State != asks.StateCleared {
|
|
t.Fatalf("closed, and its condition is not cleared: %+v", c)
|
|
}
|
|
}},
|
|
// The kind behind the day's fault: a decision on a group's state named a word GroupState no longer says. Every word
|
|
// it says is one groupStatusState knows, and the merge goes on exactly in ready and merging.
|
|
{"every word of a group's state is decided on", func(t *testing.T) {
|
|
for _, word := range groupWords {
|
|
if groupStatusState(word) == "pending" && word != GroupChecking {
|
|
t.Errorf("groupStatusState does not know %q", word)
|
|
}
|
|
if may := groupMayMerge(word); may != (word == GroupReady || word == GroupMerging) {
|
|
t.Errorf("groupMayMerge(%q) = %v", word, may)
|
|
}
|
|
}
|
|
}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, c.run)
|
|
}
|
|
}
|
|
|
|
// allGreenFor makes both merge checks a success at every member's head.
|
|
func allGreenFor(w *world, ms ...member) {
|
|
for _, m := range ms {
|
|
setChecks(w, m.repo, m.head, "success", "success")
|
|
}
|
|
}
|
|
|
|
// bridgeMerged is the forge bridge's word that it merged a member.
|
|
func bridgeMerged(w *world, repository string, number int, head, merge string) {
|
|
w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root",
|
|
Trees: map[string]string{"app": "tree"}})
|
|
}
|