409 lines
17 KiB
Go
409 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// **A merge into main waits for the operator's Approve** (novox/hq ADR 0398 §3, issue 948): a marked, green delivery
|
|
// into main is asked of the operator, and merged only after an Approve whose warrant holds for that ask.
|
|
|
|
// markedGreen is a pull request into main, announced, judged green and marked ready at its head; the operator is not
|
|
// answering by himself (w.op.auto is off).
|
|
func markedGreen(t *testing.T) *world {
|
|
w := newWorld(t)
|
|
w.op.auto = false
|
|
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
|
p.Title = "Fix the apostrophe (issue 948, issue 12)"
|
|
p.Body = "Reviewed-by: reviewer@shanks at " + readyHead
|
|
w.h.PullUpdated(p)
|
|
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
|
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
|
w.h.SeeOpenPulls()
|
|
w.settleAll()
|
|
return w
|
|
}
|
|
|
|
func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing.T) {
|
|
w := markedGreen(t)
|
|
id := IDOf("novox/app", readyHead)
|
|
if len(w.forge.merges) != 0 {
|
|
t.Fatalf("merged before the operator's Approve: %v", w.forge.merges)
|
|
}
|
|
open := w.op.open()
|
|
if len(open) != 1 {
|
|
t.Fatalf("the operator is asked %v; wanted exactly one ask", open)
|
|
}
|
|
a := w.op.ask(open[0])
|
|
if !strings.HasPrefix(a.Headline, "Merge into main: app#7") || a.Who != asks.Operator ||
|
|
len(a.Options) != 2 || a.Options[0].ID != OptionApprove || a.Options[1].ID != OptionDecline {
|
|
t.Fatalf("the ask is not the merge ask: %+v", a)
|
|
}
|
|
for _, want := range []string{"issue 948", "issue 12", "reviewed by reviewer on shanks", "builds app"} {
|
|
if !strings.Contains(a.Explanation, want) {
|
|
t.Fatalf("the ask does not say %q: %s", want, a.Explanation)
|
|
}
|
|
}
|
|
if err := asks.Glance(a.Explanation); err != nil {
|
|
t.Fatalf("the ask does not read at a glance: %v", err)
|
|
}
|
|
// Ticks and minutes pass without an answer: not merged, not asked again.
|
|
for range 3 {
|
|
w.later(2 * time.Minute)
|
|
w.settleAll()
|
|
}
|
|
if len(w.forge.merges) != 0 || w.op.count() != 1 {
|
|
t.Fatalf("while waiting: merges %v, asks %d", w.forge.merges, w.op.count())
|
|
}
|
|
if d := w.delivery(id); d.Ready.AskID != open[0] || !strings.Contains(d.Ready.Waits, "Approve") {
|
|
t.Fatalf("the ask is not kept on the delivery: %+v", d.Ready)
|
|
}
|
|
w.op.answer(open[0], OptionApprove)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
|
|
t.Fatalf("after the Approve it was merged %v; wanted once, at the marked head", w.forge.merges)
|
|
}
|
|
d := w.delivery(id)
|
|
if d.Ready.Approved == nil || !strings.Contains(d.Ready.Warrant, "chose Approve") {
|
|
t.Fatalf("the warrant is not kept: %+v", d.Ready)
|
|
}
|
|
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
|
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
|
|
w.settleAll()
|
|
var last Transition
|
|
for _, tr := range w.delivery(id).Transitions {
|
|
if tr.Event == EvMerged {
|
|
last = tr
|
|
}
|
|
}
|
|
if !strings.Contains(last.By, "approved: the operator, via telegram") {
|
|
t.Fatalf("the merge's transition does not name the operator's Approve: %+v", last)
|
|
}
|
|
}
|
|
|
|
// AC3 of issue 1348: an answer naming old heads merges nothing. The teller's answer to an ask that was withdrawn when the head
|
|
// moved is still in its bucket; the gate acts on the key of the heads asked now alone.
|
|
func TestAnAnswerNamingOldHeadsMergesNothing(t *testing.T) {
|
|
w := markedGreen(t)
|
|
old := w.op.open()[0]
|
|
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
|
w.settleAll()
|
|
w.op.answer(old, OptionApprove) // the teller wrote it after the ask was taken away
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 0 || len(w.merges.puts) != 0 {
|
|
t.Fatalf("an answer to old heads merged: %v, handed %d", w.forge.merges, len(w.merges.puts))
|
|
}
|
|
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready != nil && d.Ready.Approved != nil {
|
|
t.Fatalf("an answer to old heads approved the old delivery: %+v", d.Ready)
|
|
}
|
|
}
|
|
|
|
// An answer under a key that is not the key of the heads the ask covers is refused and said: the gate acts on its own
|
|
// ask's key alone (the heads hash is the key's last part).
|
|
func TestAnAnswerUnderAKeyThatIsNotTheHeadsKeyIsRefused(t *testing.T) {
|
|
w := markedGreen(t)
|
|
id := IDOf("novox/app", readyHead)
|
|
key := w.op.open()[0]
|
|
w.h.mu.Lock()
|
|
w.h.deliveries[id].Ready.Heads[id] = strings.Repeat("9", 40) // the heads no longer give the key
|
|
w.h.mu.Unlock()
|
|
w.op.answer(key, OptionApprove)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 0 {
|
|
t.Fatalf("an answer under another key merged: %v", w.forge.merges)
|
|
}
|
|
d := w.delivery(id)
|
|
if !strings.Contains(d.Ready.Refused, "does not hold") || d.Ready.Approved != nil {
|
|
t.Fatalf("the refused answer is not kept: %+v", d.Ready)
|
|
}
|
|
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "answer") {
|
|
t.Fatalf("a refused answer is not said loudly: %+v", lines)
|
|
}
|
|
}
|
|
|
|
func TestADeclinedMergeIsNotMergedAndSaidOnce(t *testing.T) {
|
|
w := markedGreen(t)
|
|
w.op.answer(w.op.open()[0], OptionDecline)
|
|
for range 3 {
|
|
w.later(2 * time.Minute)
|
|
w.settleAll()
|
|
}
|
|
if len(w.forge.merges) != 0 || w.op.count() != 1 {
|
|
t.Fatalf("declined, yet merges %v and asks %d", w.forge.merges, w.op.count())
|
|
}
|
|
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "declined") ||
|
|
!strings.Contains(w.forge.comments[0], "chose Decline") {
|
|
t.Fatalf("the Decline is not said once on the pull request, with its warrant: %v", w.forge.comments)
|
|
}
|
|
}
|
|
|
|
func TestAPushAfterTheAskCancelsItAndTheNewHeadIsAskedOnItsOwnWhenItsChecksPass(t *testing.T) {
|
|
w := markedGreen(t)
|
|
old := w.op.open()[0]
|
|
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
|
w.settleAll()
|
|
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != old || len(w.op.open()) != 0 {
|
|
t.Fatalf("the ask was not cancelled: cancelled %v, open %v", w.op.cancelled, w.op.open())
|
|
}
|
|
if len(w.forge.merges) != 0 || w.op.count() != 1 {
|
|
t.Fatalf("merges %v, asks %d", w.forge.merges, w.op.count())
|
|
}
|
|
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready.AskID != "" {
|
|
t.Fatalf("the cancelled ask is still kept as waiting: %+v", d.Ready)
|
|
}
|
|
// The new head is asked afresh once its checks pass.
|
|
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
|
|
w.settleAll()
|
|
if open := w.op.open(); len(open) != 1 || open[0] == old {
|
|
t.Fatalf("the new head was not asked afresh: %v", open)
|
|
}
|
|
}
|
|
|
|
// The ask lapses at the router after AskLasts with nobody's answer, and the gate has no word of it: it takes its key away and
|
|
// writes it again, once, which the teller asks afresh (it asks again under the same id when the gate writes its key again).
|
|
func TestAnUnansweredAskIsWrittenAgainOnceWhenItLapses(t *testing.T) {
|
|
w := markedGreen(t)
|
|
first := w.op.open()[0]
|
|
w.later(AskLasts + time.Minute)
|
|
w.settleAll()
|
|
if w.op.count() != 2 || len(w.op.open()) != 1 || w.op.open()[0] != first || len(w.op.cancelled) != 0 {
|
|
t.Fatalf("a lapsed ask was not written again once, over the same key: %d asks, open %v, taken away %v", w.op.count(), w.op.open(), w.op.cancelled)
|
|
}
|
|
// Review round 1, G2: the router keeps an ask by its id and drops a second under it, so the second asking has an id of its own.
|
|
again := w.op.byKey[first]
|
|
if again.Asking != 2 || again.Ask.ID != sdk.AskingID(first, 2) || again.Ask.ID == sdk.AskID(first) {
|
|
t.Fatalf("the second asking is %d under the id %s; wanted asking 2 under %s", again.Asking, again.Ask.ID, sdk.AskingID(first, 2))
|
|
}
|
|
for range 3 {
|
|
w.later(2 * time.Minute)
|
|
w.settleAll()
|
|
}
|
|
if w.op.count() != 2 || len(w.forge.merges) != 0 {
|
|
t.Fatalf("asked more than once again: %d asks, merges %v", w.op.count(), w.forge.merges)
|
|
}
|
|
}
|
|
|
|
func TestAGroupIsAskedOnceForAllItsHeadsAndMergedInOnePassOnOneApprove(t *testing.T) {
|
|
w := newWorld(t)
|
|
w.op.auto = false
|
|
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
|
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
|
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
|
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
|
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
|
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
|
|
openPull("novox/mesh-catalog", 2, catHead)}
|
|
w.h.SeeOpenPulls()
|
|
w.settleAll()
|
|
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
|
w.settleAll()
|
|
if open := w.op.open(); len(open) != 1 || len(w.forge.merges) != 0 {
|
|
t.Fatalf("a group is asked once before any merge: open %v, merges %v", w.op.open(), w.forge.merges)
|
|
}
|
|
key := w.op.open()[0]
|
|
a := w.op.ask(key)
|
|
if !strings.Contains(a.Headline, "group feat") || !strings.Contains(a.Details, "mesh-controller#1") ||
|
|
!strings.Contains(a.Details, "mesh-catalog#2") {
|
|
t.Fatalf("the ask does not name the group and its members: %+v", a)
|
|
}
|
|
w.op.answer(key, OptionApprove)
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 2 || w.op.count() != 1 {
|
|
t.Fatalf("both members were not merged in one pass on the one Approve: merges %v, asks %d", w.forge.merges, w.op.count())
|
|
}
|
|
}
|
|
|
|
// A push to a group member without a mark ends the group's ask (ADR 0398 §3, issue 948; review round 1): the Approve
|
|
// that follows would be for heads the group no longer has.
|
|
func TestAPushToAGroupMemberEndsTheGroupsAskAndApproveAndSaysSoOnEveryMember(t *testing.T) {
|
|
w := newWorld(t)
|
|
w.op.auto = false
|
|
const ctlHead, catHead, catPushed = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2", "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3"
|
|
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
|
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
|
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
|
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
|
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
|
|
openPull("novox/mesh-catalog", 2, catHead)}
|
|
w.h.SeeOpenPulls()
|
|
w.settleAll()
|
|
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
|
w.settleAll()
|
|
first := w.op.open()
|
|
if len(first) != 1 {
|
|
t.Fatalf("asked %v", first)
|
|
}
|
|
// The catalog member is pushed; its new head has no mark yet.
|
|
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catPushed, "feat/x", "modules/app/module.json"))
|
|
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
|
|
{Owner: "novox", Repo: "mesh-catalog", Number: 2, Base: "main", HeadSHA: catPushed}}
|
|
w.h.SeeOpenPulls()
|
|
w.settleAll()
|
|
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != first[0] {
|
|
t.Fatalf("the group's ask was not cancelled by the push: %v", w.op.cancelled)
|
|
}
|
|
w.op.answer(first[0], OptionApprove) // a late answer to the cancelled ask
|
|
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catPushed, "pass", "t3t3t3t3t3t3"))
|
|
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-2", Verdict: "pass", Summary: "both compose again"})
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 0 {
|
|
t.Fatalf("merged on an Approve of heads the group no longer has: %v", w.forge.merges)
|
|
}
|
|
}
|
|
|
|
// mergedSaid is the module events `merged` the world has said.
|
|
func mergedSaid(w *world) []map[string]any {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
var out []map[string]any
|
|
for _, m := range w.said {
|
|
if m["event-kind"] == "merged" {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Issue 1025: a merge on the operator's Approve is announced once, with the warrant's ask, option and heads.
|
|
func TestIssue1025AMergeOnTheApproveIsAnnouncedWithItsWarrant(t *testing.T) {
|
|
w := markedGreen(t)
|
|
id := IDOf("novox/app", readyHead)
|
|
open := w.op.open()
|
|
if len(open) != 1 {
|
|
t.Fatalf("asks %v", open)
|
|
}
|
|
if got := mergedSaid(w); len(got) != 0 {
|
|
t.Fatalf("announced before the merge: %v", got)
|
|
}
|
|
d := w.delivery(id)
|
|
wantHeads := map[string]string{}
|
|
for k, v := range d.Ready.Heads {
|
|
wantHeads[k] = v
|
|
}
|
|
w.op.answer(open[0], OptionApprove)
|
|
w.settleAll()
|
|
got := mergedSaid(w)
|
|
if len(got) != 1 {
|
|
t.Fatalf("announced %d times; wanted once: %v", len(got), got)
|
|
}
|
|
m := got[0]
|
|
if m["owner"] != "novox" || m["repo"] != "app" || m["number"] != float64(7) || m["head_sha"] != readyHead ||
|
|
m["ask_id"] != sdk.AskID(open[0]) || m["asker"] != sdk.TellerModule || m["option"] != OptionApprove {
|
|
t.Fatalf("the event does not name the pull request and its warrant: %v", m)
|
|
}
|
|
hs, _ := m["heads"].(map[string]any)
|
|
if len(hs) == 0 || len(hs) != len(wantHeads) {
|
|
t.Fatalf("heads %v; wanted %v", hs, wantHeads)
|
|
}
|
|
for k, v := range wantHeads {
|
|
if hs[k] != v {
|
|
t.Fatalf("heads %v; wanted %v", hs, wantHeads)
|
|
}
|
|
}
|
|
if g, ok := m["group"]; ok && g != "" {
|
|
t.Fatalf("a lone delivery names group %v", g)
|
|
}
|
|
w.later(2 * time.Minute)
|
|
w.settleAll()
|
|
if n := len(mergedSaid(w)); n != 1 {
|
|
t.Fatalf("announced %d times after more ticks", n)
|
|
}
|
|
}
|
|
|
|
// Issue 1025: a declined merge announces nothing.
|
|
func TestIssue1025ADeclinedMergeAnnouncesNothing(t *testing.T) {
|
|
w := markedGreen(t)
|
|
open := w.op.open()
|
|
w.op.answer(open[0], OptionDecline)
|
|
w.settleAll()
|
|
if got := mergedSaid(w); len(got) != 0 || len(w.forge.merges) != 0 {
|
|
t.Fatalf("declined, yet merges %v, announced %v", w.forge.merges, got)
|
|
}
|
|
}
|
|
|
|
// Issue 1025: a merge of a delivery in a group carries the group id and the heads the group's Approve was bound to.
|
|
func TestIssue1025AMergeInAGroupCarriesTheGroupAndItsHeads(t *testing.T) {
|
|
w := newWorld(t)
|
|
w.op.auto = false
|
|
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
|
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
|
|
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
|
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
|
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
|
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
|
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
|
|
openPull("novox/mesh-catalog", 2, catHead)}
|
|
w.h.SeeOpenPulls()
|
|
w.settleAll()
|
|
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
|
w.settleAll()
|
|
open := w.op.open()
|
|
if len(open) != 1 {
|
|
t.Fatalf("asks %v", open)
|
|
}
|
|
w.op.answer(open[0], OptionApprove)
|
|
w.settleAll()
|
|
got := mergedSaid(w)
|
|
if len(got) != 2 {
|
|
t.Fatalf("announced %d; wanted each member's merge once, from the one pass: %v", len(got), got)
|
|
}
|
|
for _, m := range got {
|
|
hs, _ := m["heads"].(map[string]any)
|
|
if m["group"] != "feat/x" || m["ask_id"] != sdk.AskID(open[0]) || m["asker"] != sdk.TellerModule || m["option"] != OptionApprove ||
|
|
hs[ctl] != ctlHead || hs[cat] != catHead || len(hs) != 2 {
|
|
t.Fatalf("the group's merge event: %v", m)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Issue 1025: the announcement is owed and kept, so it is said after a restart, and once.
|
|
func TestIssue1025AnOwedMergedIsSaidAfterARestartAndOnce(t *testing.T) {
|
|
w := newWorld(t)
|
|
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
|
w.h.PullUpdated(p)
|
|
id := IDOf("novox/app", readyHead)
|
|
w.h.mu.Lock()
|
|
d := w.h.deliveries[id]
|
|
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: `{"owner":"novox","repo":"app","number":7,"head_sha":"` + readyHead + `"}`, Since: w.now})
|
|
if err := w.store.PutDelivery(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w.h.mu.Unlock()
|
|
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
|
|
again.settleAll()
|
|
again.later(time.Hour)
|
|
again.settleAll()
|
|
if n := len(mergedSaid(again)); n != 1 {
|
|
t.Fatalf("said %d times after the restart; wanted once", n)
|
|
}
|
|
}
|
|
|
|
// Review round 1, G2: an answer given to the first asking is not taken for the second, and the second's Approve is announced
|
|
// under the second asking's id.
|
|
func TestAnAnswerToAnEarlierAskingIsNotTakenAndTheMergeNamesTheAnsweredAsking(t *testing.T) {
|
|
w := markedGreen(t)
|
|
key := w.op.open()[0]
|
|
w.later(AskLasts + time.Minute)
|
|
w.settleAll() // asked again: the second asking
|
|
w.op.mu.Lock()
|
|
w.op.answers[key] = &sdk.Answer{Option: OptionApprove, Warrant: sdk.AskingID(key, 1) + "@2026-10-06T12:00:00Z", Words: "the operator, via telegram, chose Approve"}
|
|
w.op.mu.Unlock()
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 0 || len(w.merges.puts) != 0 {
|
|
t.Fatalf("an answer to the first asking merged on the second: %v", w.forge.merges)
|
|
}
|
|
w.op.answer(key, OptionApprove) // the answer to the asking asked now
|
|
w.settleAll()
|
|
if len(w.forge.merges) != 1 {
|
|
t.Fatalf("the answer to the second asking did not merge: %v", w.forge.merges)
|
|
}
|
|
say := mergedSaid(w)
|
|
if len(say) != 1 || say[0]["ask_id"] != sdk.AskingID(key, 2) {
|
|
t.Fatalf("the merge is announced %v; wanted the ask id of asking 2, %s", say, sdk.AskingID(key, 2))
|
|
}
|
|
}
|