Met again 2026-10-25: group fix/puller-wakes-on-capacity was merged half. Three causes in the gate: - The next member's turn compared the group's state with "delivering", a word GroupState no longer says since release 6 renamed it "merging" (ready.go groupTurnToMerge). Once the first member's merge was heard from the forge the group read merging, and the second member waited for ever. Group states are now named constants, the turn goes on in ready and merging (groupMayMerge), and a test holds every word to it. - Members merged in id order, so mesh-catalog went before the mesh-sdk commit it pins. membersOf now orders by mergeRank: mesh-sdk first, then members that change no go.mod or go.sum, then those that do. - No condition was raised: the controller's self-check that read `stalled` went with release 6 (mesh-controller dc456d56). The gate now says its stalled lines as conditions itself on the operator-channel seat (ADR 0437 §9) after every look, clears those listed no more, and keeps which it raised in its new bucket conditions. A held group's line says "half merged" when a member merged. A pass held for a required status no longer retries on a timer: the status turning success is the event that asks the merge again.
129 lines
5.0 KiB
Go
129 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// **A group is merged in one pass on its one Approve, and delivered by one plan** (novox/hq ADR 0418, ADR 0419, ADR
|
|
// 0421, issues 978 and 1047): the members are not ordered, none waits for another's merge or delivery, and a trunk
|
|
// move does not ask the operator a second time.
|
|
|
|
const (
|
|
gCtl, gCat = "novox/mesh-controller", "novox/mesh-catalog"
|
|
gHc, gHk = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
|
gTrunk0, gT1 = "a2c2c35000000000000000000000000000000000", "d28ca95000000000000000000000000000000000"
|
|
)
|
|
|
|
// approvedGroup is a group of two pull requests on one branch, checks green, composed green and the operator's Approve
|
|
// asked and not yet answered (w.op.auto is off).
|
|
func approvedGroup(t *testing.T) *world {
|
|
return approvedGroupOf(t, member{gCtl, 1, gHc, "internal/a.go"}, member{gCat, 2, gHk, "modules/app/module.json"})
|
|
}
|
|
|
|
// member is a pull request of a group in feat/g: its repository, number, head and the paths it changes.
|
|
type member struct {
|
|
repo string
|
|
number int
|
|
head string
|
|
paths string
|
|
}
|
|
|
|
// approvedGroupOf is a group feat/g of these members, each checked, composed together and asked once.
|
|
func approvedGroupOf(t *testing.T, ms ...member) *world {
|
|
t.Helper()
|
|
w := newWorld(t)
|
|
w.op.auto = false
|
|
for _, m := range ms {
|
|
w.h.PullUpdated(pr(m.repo, m.number, m.head, "feat/g", strings.Split(m.paths, ",")...))
|
|
}
|
|
for _, m := range ms {
|
|
w.h.Checked(judged(m.repo, m.number, m.head, "pass", gTrunk0))
|
|
}
|
|
w.settleAll()
|
|
g := w.h.groups["feat/g"]
|
|
if g == nil || g.Check == nil {
|
|
t.Fatalf("no composed check was asked: %+v", g)
|
|
}
|
|
c := CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"}
|
|
for _, m := range ms {
|
|
owner, repo, _ := strings.Cut(m.repo, "/")
|
|
c.Members = append(c.Members, struct {
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
Commit string `json:"commit"`
|
|
Trunk string `json:"trunk,omitempty"`
|
|
}{Owner: owner, Repo: repo, Commit: m.head, Trunk: gTrunk0[:12]})
|
|
}
|
|
w.h.Checked(c)
|
|
w.settleAll()
|
|
if w.op.count() != 1 || len(w.op.open()) != 1 {
|
|
t.Fatalf("the green group is asked once: %d asks, open %v", w.op.count(), w.op.open())
|
|
}
|
|
return w
|
|
}
|
|
|
|
// Issue 978 (the pin): a feature's single pull request in one repository is a lone delivery, asked alone.
|
|
func TestAFeaturesSinglePullRequestIsALoneDeliveryAskedAlone(t *testing.T) {
|
|
w := newWorld(t)
|
|
w.op.auto = false
|
|
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
|
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
|
w.settleAll()
|
|
d := w.delivery(IDOf("novox/app", readyHead))
|
|
if d.Group != "" || len(w.h.groups) != 0 {
|
|
t.Fatalf("a single pull request made a group: %q %v", d.Group, w.h.groups)
|
|
}
|
|
if w.op.count() != 1 {
|
|
t.Fatalf("a lone delivery is asked once, alone: %d", w.op.count())
|
|
}
|
|
}
|
|
|
|
// A group answer says its members' gate is the composed check's (agreed with the controller, issue 977).
|
|
func TestTheGroupsAnswerSaysItsGateIsTheComposedChecks(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
lines := w.h.Groups(false)
|
|
if len(lines) != 1 || !lines[0].ComposedGate {
|
|
t.Fatalf("groups: %+v", lines)
|
|
}
|
|
raw, _ := json.Marshal(lines[0])
|
|
var got map[string]any
|
|
if err := json.Unmarshal(raw, &got); err != nil || got["composed_gate"] != true {
|
|
t.Fatalf("the group line says %s", raw)
|
|
}
|
|
if _, ordered := got["order"]; ordered {
|
|
t.Fatalf("a group line still says an order: %s", raw)
|
|
}
|
|
}
|
|
|
|
// Issue 1082: a refusal loop is not silent. A merge refused past ten minutes in a closed group (ADR 0418 §3) is a stalled
|
|
// line naming the member and the merge tool's last answer, which the controller raises as a condition.
|
|
func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) {
|
|
w := approvedGroup(t)
|
|
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()
|
|
// Each composed pass is followed by a refusal again: the loop.
|
|
for range 12 {
|
|
w.later(time.Minute)
|
|
g := w.h.groups["feat/g"]
|
|
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
|
w.settleAll()
|
|
}
|
|
lines := w.h.Stalled()
|
|
if len(lines) != 1 || lines[0].ID != IDOf(gCtl, gHc) || lines[0].Bound != "0s" ||
|
|
!strings.Contains(lines[0].Says, errAskedAgain.Error()) || !strings.Contains(lines[0].Says, "group feat/g") {
|
|
t.Fatalf("the loop was not said, naming the member, its group and the tool's answer: %+v", lines)
|
|
}
|
|
}
|
|
|
|
// ownPending is the merge tool's refusal of a member whose own mesh/repo-check stands pending after the group's composed
|
|
// check set its gate (issue 1049: the forge's "not a success: mesh/repo-check (pending)").
|
|
var ownPending = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-controller#1 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")
|