Make the delivery gate ask the controller nothing of checks: no Check or Queue on the port, no checked event, no lost-check sweep, only mesh/check and the checker's verdict (issue 1318, E4a and E4b)
This commit is contained in:
@@ -3,14 +3,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0505 §4 over the real bus, in the bed of bus_test.go: the gate whole, its deliveries kept in its buckets.
|
||||
@@ -33,56 +29,3 @@ func (r *runtime) call(t *testing.T, name string, args map[string]any) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ADR 0505 §4: an approved head the merge tool refused because its trunk moved is shown by `show` at once but is not in
|
||||
// `stalled` while its fresh check is unanswered; once that check passed and the merge is refused again, it is.
|
||||
func TestATrunkMovedRefusalIsStalledOnlyOnceItsFreshCheckDidNotCureItOverARealBus(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
rt := b.start(t)
|
||||
evctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
rt.deliverEvents(evctx)
|
||||
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.subs) >= len(followed)
|
||||
})
|
||||
rt.untilHolding()
|
||||
|
||||
const head = "1111111111111111111111111111111111111111"
|
||||
id := IDOf("novox/app", head)
|
||||
announced := pr("novox/app", 7, head, "fix/x", "src/a.go")
|
||||
announced.BaseSHA = "t1t1t1t1t1t1"
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
|
||||
var key string
|
||||
rt.until("the ask to be written", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
b.answer(t, key, OptionApprove)
|
||||
rt.until("the merge to be handed to the bridge", 10*time.Second, func() bool { return len(mergeAt(b, "fix/x").Pulls) > 0 })
|
||||
|
||||
refused := func() {
|
||||
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeRefused, Act: "merge", Repository: "novox/app", Number: 7, Head: head,
|
||||
Words: errAskedAgain.Error()})
|
||||
}
|
||||
refused()
|
||||
rt.until("show to name the refusal at once", 10*time.Second, func() bool {
|
||||
return strings.Contains(rt.call(t, Seat+".show", map[string]any{"id": id}), "its verdict was judged against trunk")
|
||||
})
|
||||
if got := rt.call(t, Seat+".stalled", map[string]any{}); strings.Contains(got, id) {
|
||||
t.Fatalf("said as stalled while its fresh check runs: %s", got)
|
||||
}
|
||||
|
||||
// The trunk moved; the head is announced again and its fresh check passes; the merge is refused again on it.
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 9, "9999999999999999999999999999999999999999", "t2t2t2t2t2t2"))
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
time.Sleep(time.Second)
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t2t2t2t2t2t2"))
|
||||
rt.until("the fresh verdict to be heard", 10*time.Second, func() bool {
|
||||
return strings.Contains(rt.call(t, Seat+".show", map[string]any{"id": id}), "t2t2t2t2t2t2")
|
||||
})
|
||||
refused()
|
||||
rt.until("the refusal on the fresh verdict to be said as stalled", 10*time.Second, func() bool {
|
||||
return strings.Contains(rt.call(t, Seat+".stalled", map[string]any{}), id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,39 +95,6 @@ func linesFor(w *world, id string) []StalledLine {
|
||||
return out
|
||||
}
|
||||
|
||||
// ADR 0505 §4: a refusal because the trunk moved, whose check the gate asked again, is in the gate's own list at once
|
||||
// but is no stalled line while its check is unanswered; once that check passed and the merge is refused again, it is.
|
||||
func TestATrunkMovedRefusalIsStalledOnlyOnceItsFreshCheckAnsweredAndTheMergeIsRefusedAgain(t *testing.T) {
|
||||
w, id := refusedForAMovedTrunk(t)
|
||||
if got := linesFor(w, id); len(got) != 0 {
|
||||
t.Fatalf("said as stalled while its fresh check runs: %+v", got)
|
||||
}
|
||||
shown, err := w.h.Show(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, _ := json.Marshal(shown); !strings.Contains(string(raw), "is not merged: its verdict was judged against trunk") {
|
||||
t.Fatalf("the gate's own list does not show the refusal at once: %s", raw)
|
||||
}
|
||||
w.later(5 * time.Minute)
|
||||
if got := linesFor(w, id); len(got) != 0 {
|
||||
t.Fatalf("said as stalled at five minutes, its check unanswered: %+v", got)
|
||||
}
|
||||
// As on the live mesh (events_test.go): the trunk moved, the head is announced again, its fresh verdict passes
|
||||
// against the new trunk, and the merge is asked and refused again, the trunk having moved once more.
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.later(time.Second)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("the fresh verdict did not ask the merge again: %v", w.forge.merges)
|
||||
}
|
||||
if got := linesFor(w, id); len(got) != 1 {
|
||||
t.Fatalf("its check answered and the merge refused again, yet no stalled line: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0505 §4: with no answer, the refusal is said at twice the repository's last check time, at least ten minutes.
|
||||
func TestATrunkMovedRefusalIsStalledAtTwiceTheLastCheckTimeAtLeastTheFloor(t *testing.T) {
|
||||
w, id := refusedForAMovedTrunk(t)
|
||||
@@ -180,9 +147,15 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) {
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if _, err := w.h.Recheck(id, "a second look", "tester"); err != nil {
|
||||
// Judged again (the table's recheck, which the checker's verdict on a new trunk makes): proposed once more.
|
||||
w.h.mu.Lock()
|
||||
d := w.h.deliveries[id]
|
||||
if _, err := Apply(d, EvRecheck, true, Facts{Now: w.h.Now(), Why: "a second look", By: "tester"}); err != nil {
|
||||
w.h.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.Check = nil
|
||||
w.h.mu.Unlock()
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
approve(w)
|
||||
w.h.React()
|
||||
|
||||
@@ -380,19 +380,18 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
// A group is not asked while a member's required status is not a success at its head (issue 1275): the group
|
||||
// says which, and is asked once every member may merge.
|
||||
var holdMember, holdSays string
|
||||
var holdOwn bool
|
||||
var readErr error
|
||||
for _, m := range reads {
|
||||
if gid == "" {
|
||||
break
|
||||
}
|
||||
says, ownOnly, err := h.notASuccess(m)
|
||||
says, err := h.notASuccess(m)
|
||||
if err != nil {
|
||||
readErr = err
|
||||
break
|
||||
}
|
||||
if says != "" {
|
||||
holdMember, holdSays, holdOwn = m.id, says, ownOnly
|
||||
holdMember, holdSays = m.id, says
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -422,18 +421,7 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
if readErr != nil || holdSays != "" {
|
||||
stale()
|
||||
h.mu.Lock()
|
||||
var own func()
|
||||
// A member's own repository check pending or missing (a sibling's new head put it out of date, issue 1792) is
|
||||
// asked again here, once for the head: nothing else asks it, and the composed check sets no repository check.
|
||||
if d := h.deliveries[holdMember]; holdOwn && d != nil && d.Ready != nil {
|
||||
own = h.askOwnAfterRefusal(d)
|
||||
}
|
||||
defer func() {
|
||||
h.mu.Unlock()
|
||||
if own != nil {
|
||||
own()
|
||||
}
|
||||
}()
|
||||
defer h.mu.Unlock()
|
||||
undo()
|
||||
if g := h.groups[gid]; g != nil && holdSays != "" {
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Says != holdSays || g.Hold.Member != holdMember {
|
||||
|
||||
@@ -339,21 +339,18 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
case "seat:" + ControllerSeat + ".delivery-plan":
|
||||
return toolAnswer(map[string]any{"plan": DeliveryPlan{Repository: "novox/app", Moved: []string{"app"},
|
||||
Summary: "builds app, sent to the node"}}), nil
|
||||
case "seat:" + ControllerSeat + ".delivery-check":
|
||||
return toolAnswer(map[string]any{"asked": "check-1"}), nil
|
||||
case "gitea.gitea_open_pulls":
|
||||
return toolAnswer(map[string]any{"pulls": pulls}), nil
|
||||
case "gitea.gitea_commit_statuses":
|
||||
// Green: both merge checks a success at the head asked, as the gitea module answers.
|
||||
// Green: mesh/check a success at the head asked, as the gitea module answers.
|
||||
sha, _ := a.Body["sha"].(string)
|
||||
if sha == "" {
|
||||
sha = "0000000"
|
||||
}
|
||||
return toolAnswer(map[string]any{"commit": sha, "statuses": []any{
|
||||
map[string]any{"context": "mesh/merge-gate", "state": "success"},
|
||||
map[string]any{"context": "mesh/repo-check", "state": "success"}},
|
||||
"merge": map[string]any{"branch": "main", "protected": true, "required": []string{"mesh/merge-gate", "mesh/repo-check"},
|
||||
"blocking": []any{}, "mergeable": true, "says": "main requires mesh/merge-gate, mesh/repo-check"}}), nil
|
||||
map[string]any{"context": "mesh/check", "state": "success"}},
|
||||
"merge": map[string]any{"branch": "main", "protected": true, "required": []string{"mesh/check"},
|
||||
"blocking": []any{}, "mergeable": true, "says": "main requires mesh/check"}}), nil
|
||||
case "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", "gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_add_comment":
|
||||
return toolAnswer(map[string]any{}), nil
|
||||
@@ -513,11 +510,11 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
// A pull request into main is announced, and the controller's verdict on its head passes.
|
||||
const head = "1111111111111111111111111111111111111111"
|
||||
announced := pr("novox/app", 7, head, "fix/x", "src/a.go")
|
||||
announced.BaseSHA = "t1t1t1t1t1t1"
|
||||
announced.BaseSHA = "a1a1a1a1a1a1"
|
||||
announced.Title = "Fix the apostrophe (issue 975)"
|
||||
announced.Body = "The description, with no review line yet."
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
passed := b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
|
||||
b.checkPasses(t, rt, "fix/x", "a1a1a1a1a1a1", judgedPull{"novox/app", 7, head})
|
||||
|
||||
// No review names the head, so nothing is asked (ADR 0496, issue 1803): the delivery waits for a review.
|
||||
rt.until("the delivery to wait for a review", eventWithin, func() bool {
|
||||
@@ -535,7 +532,6 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
edit := time.Now()
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.edited", map[string]any{"owner": "novox", "repo": "app", "number": 7,
|
||||
"title": announced.Title, "head_sha": head, "base": "main", "body": "Reviewed-by: lead@shanks at " + head})
|
||||
_ = passed
|
||||
var key string
|
||||
rt.until("the ask to be written", eventWithin, func() bool { key = onlyAsk(b); return key != "" })
|
||||
ask := askAt(t, b, key)
|
||||
@@ -550,7 +546,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
|
||||
rt.until("the pull request's key to hold head, trunk and verdict", eventWithin, func() bool {
|
||||
e, err := b.kvs["pulls"].Get(ctx, "novox.app.7")
|
||||
var k PullKey
|
||||
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
|
||||
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "a1a1a1a1a1a1", Verdict: "pass"}
|
||||
})
|
||||
|
||||
// What the transitions owe the forge is written as owed keys; the gate calls no tool of the forge that writes (issue 1349).
|
||||
@@ -698,34 +694,16 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
for _, p := range []PullEvent{pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"),
|
||||
pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} {
|
||||
p.BaseSHA = "t1t1t1t1t1t1"
|
||||
p.BaseSHA = "a1a1a1a1a1a1"
|
||||
p.Body = "Reviewed-by: lead@shanks at " + p.HeadSHA
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", p)
|
||||
}
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t1t1t1t1t1t1"))
|
||||
|
||||
// Both members green: the composed check is asked, once, for both.
|
||||
// Each member's own check is asked at once (issue 1115), before the group's: wait for the one carrying the group.
|
||||
rt.until("the composed check to be asked", 10*time.Second, func() bool {
|
||||
for _, c := range rt.asksOf("seat:" + ControllerSeat + ".delivery-check") {
|
||||
if c.Body["group"] == "feat/x" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
// Both members pass at the checker, whose one verdict covers both heads: the group is asked once, no composed check is asked
|
||||
// of the controller (issue 1318).
|
||||
b.checkPasses(t, rt, "feat/x", "a1a1a1a1a1a1", judgedPull{"novox/mesh-controller", 1, ctlHead}, judgedPull{"novox/mesh-catalog", 2, catHead})
|
||||
if n := len(rt.asksOf("seat:" + ControllerSeat + ".delivery-order")); n != 0 {
|
||||
t.Fatalf("the controller's delivery-order was asked %d times: no order is asked for", n)
|
||||
}
|
||||
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 0 {
|
||||
t.Fatalf("the operator was asked before the group's composed check passed: %v", keys)
|
||||
}
|
||||
// The composed check passes at both heads: the operator is asked once, for the group.
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", map[string]any{"group": "feat/x", "id": "check-1", "verdict": "pass",
|
||||
"summary": "both compose", "members": []map[string]any{
|
||||
{"owner": "novox", "repo": "mesh-controller", "commit": ctlHead, "trunk": "t1t1t1t1t1t1"},
|
||||
{"owner": "novox", "repo": "mesh-catalog", "commit": catHead, "trunk": "t1t1t1t1t1t1"}}})
|
||||
var key string
|
||||
rt.until("the operator to be asked once for the group", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
@@ -796,7 +774,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA
|
||||
rt.untilHolding()
|
||||
const head, merge = "3333333333333333333333333333333333333333", "4444444444444444444444444444444444444444"
|
||||
announced := pr("novox/app", 9, head, "fix/y", "src/a.go")
|
||||
announced.BaseSHA = "t1t1t1t1t1t1"
|
||||
announced.BaseSHA = "a1a1a1a1a1a1"
|
||||
deliveryOf := func() Delivery {
|
||||
e, err := b.kvs["deliveries"].Get(context.Background(), kvKey(IDOf("novox/app", head)))
|
||||
var d Delivery
|
||||
@@ -809,7 +787,7 @@ func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverA
|
||||
// may hand over a later one first, which the table would rightly judge on its own (merged before checked).
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
|
||||
rt.until("the pull request to be taken", 10*time.Second, func() bool { return deliveryOf().State != "" })
|
||||
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 9, head, "pass", "t1t1t1t1t1t1"))
|
||||
b.checkPasses(t, rt, "fix/y", "a1a1a1a1a1a1", judgedPull{"novox/app", 9, head})
|
||||
rt.until("the verdict to be taken", 10*time.Second, func() bool { d := deliveryOf(); return d.State != "" && d.State != Proposed })
|
||||
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 9, head, merge))
|
||||
rt.until("the merge to be taken", 10*time.Second, func() bool { return deliveryOf().MergedAs != "" })
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A delivery that waits for its check gets one (novox/hq issue 290): a head adopted while it already waited —
|
||||
// its announcement heard again from the bus's history, which the controller took long ago — has its check asked
|
||||
// by this owner; so does one whose announcement the controller missed. Once asked, the proposed bound means
|
||||
// "asked and not answered", and healer H2 may ask it once more before it is the operator's.
|
||||
|
||||
// The live case: a pull request opened before merge checks existed, announced again at this owner's first
|
||||
// start, read back proposed after a restart. Its check is asked at the first tick, once, for that head alone.
|
||||
func TestAProposedDeliveryAdoptedWithNoVerdictHasItsCheckAsked(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/hq", head)
|
||||
w.h.PullUpdated(pr("novox/hq", 94, head, "issues/245-246", "04-ISSUES/245/00-report.md"))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("a head just announced was not asked at once: %v", w.ctl.checks)
|
||||
}
|
||||
|
||||
// A restart, a minute later: read back proposed with its ask kept; it is not asked again.
|
||||
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
|
||||
if s := again.state(id); s != Proposed {
|
||||
t.Fatalf("read back, it is %s", s)
|
||||
}
|
||||
again.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.ctl.checks[0] != ":"+id {
|
||||
t.Fatalf("its check was asked %v, wanted once for its head alone", w.ctl.checks)
|
||||
}
|
||||
d := again.delivery(id)
|
||||
if d.CheckAsk == nil || d.CheckAsk.ID != "check-1" || d.CheckAsk.By != byOwner || d.State != Proposed {
|
||||
t.Fatalf("the ask is not kept: %+v (%s)", d.CheckAsk, d.State)
|
||||
}
|
||||
if notes := w.forge.notesOn(head); len(notes) == 0 || !strings.Contains(notes[len(notes)-1], "its check asked") {
|
||||
t.Fatalf("the ask is not noted on the head: %q", notes)
|
||||
}
|
||||
// Asked, it is not stalled: the bound runs from the ask.
|
||||
if st := again.h.Stalled(); len(st) != 0 {
|
||||
t.Fatalf("a check just asked is stalled: %+v", st)
|
||||
}
|
||||
// Asked once: the ticks after do not ask again.
|
||||
again.later(30 * time.Minute)
|
||||
again.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("a check asked and in flight was asked again: %v", w.ctl.checks)
|
||||
}
|
||||
// The verdict arrives: checked, then ready, by itself.
|
||||
again.h.Checked(verdict("novox/hq", 94, head, "pass"))
|
||||
if s := again.state(id); s != Ready {
|
||||
t.Fatalf("its verdict left it %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The same head adopted at a first start with no restart: its check is asked at once, with no grace (issue 1115).
|
||||
func TestAMissedAnnouncementHasItsCheckAskedAtOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
// The controller does not answer: nothing is kept as asked, and it is tried again a minute on.
|
||||
w.ctl.down = true
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.CheckAsk != nil {
|
||||
t.Fatalf("an ask the controller did not take is kept as asked: %+v", d.CheckAsk)
|
||||
}
|
||||
w.ctl.down = false
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 0 {
|
||||
t.Fatalf("asked again within the minute: %v", w.ctl.checks)
|
||||
}
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.delivery(id).CheckAsk == nil {
|
||||
t.Fatalf("a missed announcement was not asked: %v", w.ctl.checks)
|
||||
}
|
||||
// A verdict that arrived in the meantime, or a delivery past its proposal, is never asked.
|
||||
other := IDOf("novox/app", "bbbbbbbbbbbb2222")
|
||||
w.h.PullUpdated(pr("novox/app", 8, "bbbbbbbbbbbb2222", "feat/y"))
|
||||
w.h.Checked(verdict("novox/app", 8, "bbbbbbbbbbbb2222", "fail"))
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if w.state(other) != Rejected || len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("a head with a verdict was asked: %s %v", w.state(other), w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// An announcement heard again carrying its head's decided verdict takes it; a pending one does not.
|
||||
func TestAnAnnouncementCarryingItsHeadsVerdictTakesIt(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
|
||||
p.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "success"}
|
||||
w.h.PullUpdated(p)
|
||||
if s := w.state(IDOf("novox/app", head)); s != Ready {
|
||||
t.Fatalf("a head whose gate passed on the forge is %s", s)
|
||||
}
|
||||
q := pr("novox/app", 8, "bbbbbbbbbbbb2222", "feat/y")
|
||||
q.HeadChecks = map[string]string{"mesh/merge-gate": "pending"}
|
||||
w.h.PullUpdated(q)
|
||||
if s := w.state(IDOf("novox/app", "bbbbbbbbbbbb2222")); s != Proposed {
|
||||
t.Fatalf("a pending gate on the forge left it %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Asked and not answered past the bound: stalled, and H2's close re-asks once — the second close is refused
|
||||
// by the table, and the delivery is the operator's.
|
||||
func TestH2ReasksAStalledCheckOnceThenItIsTheOperators(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("asked %v", w.ctl.checks)
|
||||
}
|
||||
if _, err := w.h.Close(id, "too early"); err == nil {
|
||||
t.Fatal("re-asked within the bound")
|
||||
}
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
st := w.h.Stalled()
|
||||
if len(st) != 1 || st[0].ID != id || !strings.Contains(st[0].H2, "re-ask") {
|
||||
t.Fatalf("stalled %+v", st)
|
||||
}
|
||||
// The controller does not take the re-ask: nothing is spent.
|
||||
w.ctl.down = true
|
||||
if _, err := w.h.Close(id, "delivery stalled"); err == nil {
|
||||
t.Fatal("a re-ask the controller did not take was said done")
|
||||
}
|
||||
if d := w.delivery(id); d.CheckAsk.Reasked != 0 {
|
||||
t.Fatal("a re-ask that was not made was spent")
|
||||
}
|
||||
w.ctl.down = false
|
||||
said, err := w.h.Close(id, "delivery stalled")
|
||||
if err != nil || !strings.Contains(said, "re-asked") {
|
||||
t.Fatalf("H2's close: %q %v", said, err)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if len(w.ctl.checks) != 2 || d.State != Proposed || d.CheckAsk.Reasked != 1 || d.CheckAsk.By != byH2 ||
|
||||
d.Transitions[len(d.Transitions)-1].Event != EvReask {
|
||||
t.Fatalf("after the re-ask: %v %s %+v", w.ctl.checks, d.State, d.CheckAsk)
|
||||
}
|
||||
if len(w.h.Stalled()) != 0 {
|
||||
t.Fatal("a check just re-asked is stalled")
|
||||
}
|
||||
// Still not answered: stalled again, and the operator's.
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
st = w.h.Stalled()
|
||||
if len(st) != 1 || !strings.Contains(st[0].H2, "operator's") {
|
||||
t.Fatalf("stalled after the re-ask %+v", st)
|
||||
}
|
||||
if _, err := w.h.Close(id, "delivery stalled"); err == nil || !strings.Contains(err.Error(), "once already") {
|
||||
t.Fatalf("a second re-ask: %v", err)
|
||||
}
|
||||
if len(w.ctl.checks) != 2 {
|
||||
t.Fatalf("a second re-ask reached the controller: %v", w.ctl.checks)
|
||||
}
|
||||
// The verdict, late, still settles it.
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("a late verdict left it %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ctxGate and ctxRepo are the two statuses mesh/check replaced: only a base that a revert gives them again requires them
|
||||
// (unchecked.go mergeChecks), so only tests of that name them.
|
||||
const (
|
||||
ctxGate = "mesh/merge-gate"
|
||||
ctxRepo = "mesh/repo-check"
|
||||
)
|
||||
|
||||
// CheckedEvent is a verdict as these tests state it. The gate follows no verdict of the controller any more (issue 1318,
|
||||
// ADR 0524 §5): the shim below says it the way the mesh says it now, as the head's mesh/check and the checker's verdict at
|
||||
// the heads, so the table, the Approve and the merges are exercised on the one path they have.
|
||||
type CheckedEvent struct {
|
||||
Owner string
|
||||
Repo string
|
||||
Number int
|
||||
Commit string
|
||||
Verdict string
|
||||
Summary string
|
||||
ID string
|
||||
On string
|
||||
Report string
|
||||
Gate *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
}
|
||||
RepoCheck *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
Plan *DeliveryPlan
|
||||
Group string
|
||||
Members []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}
|
||||
Trunk string
|
||||
Beside map[string]string
|
||||
Took string
|
||||
}
|
||||
|
||||
// Checked takes a verdict as the checker's: the head's mesh/check, and the checker's verdict at the heads its Approve covers.
|
||||
func (h *Holder) Checked(c CheckedEvent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
state := map[string]string{"pass": "success", "warning": "warning", "fail": "failure", "error": "error"}[c.Verdict]
|
||||
if state == "" {
|
||||
state = "error"
|
||||
}
|
||||
if c.Group != "" {
|
||||
g := h.groups[c.Group]
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
named := map[string]string{}
|
||||
for _, m := range c.Members {
|
||||
if m.Trunk != "" {
|
||||
named[strings.ToLower(m.Owner+"/"+m.Repo)] = m.Trunk
|
||||
}
|
||||
}
|
||||
h.putChecker(h.membersOf(g), c.Verdict, named)
|
||||
return
|
||||
}
|
||||
repository := strings.ToLower(c.Owner + "/" + c.Repo)
|
||||
id := IDOf(repository, c.Commit)
|
||||
if h.deliveries[id] == nil && c.Number != 0 {
|
||||
d := &Delivery{ID: id, Repository: repository, Commit: c.Commit, Number: c.Number, Created: h.Now()}
|
||||
if t, err := Apply(d, EvAnnounced, false, Facts{Now: h.Now(), Why: "its verdict arrived before its head was heard"}); err == nil {
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
}
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
if c.Plan != nil {
|
||||
d.Plan = c.Plan
|
||||
}
|
||||
words := map[string]string{"success": "pass", "warning": "pass", "failure": "failed: " + c.Summary, "error": "error"}[state]
|
||||
line := words + " " + shortOf(c.Commit, 12)
|
||||
if c.Trunk != "" {
|
||||
line += " on " + shortOf(c.Trunk, 12) // a verdict that names no trunk commit is judged against none
|
||||
}
|
||||
h.takeCheckStatus(repository, c.Commit, state, line+" | "+c.Summary)
|
||||
if d := h.deliveries[id]; d != nil && state == "success" && d.Ready != nil && d.Ready.Merged == nil {
|
||||
h.mergeAgainNow(d) // as the forge's success status event does (CommitStatus)
|
||||
}
|
||||
if d := h.deliveries[id]; d != nil && d.Check != nil && c.Beside != nil {
|
||||
d.Check.Beside, d.Check.Took = c.Beside, c.Took
|
||||
}
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
h.putChecker(h.askMembers(d), c.Verdict, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// putChecker keeps the checker's verdict at these members' heads, judged on each repository's trunk the members name or, for
|
||||
// none named, the one the check line named.
|
||||
func (h *Holder) putChecker(members []*Delivery, verdict string, named map[string]string) {
|
||||
state := "pass"
|
||||
switch verdict {
|
||||
case "pass", "warning":
|
||||
default:
|
||||
state = "fail"
|
||||
}
|
||||
hash, err := sdk.HeadsHash(headsOf(members))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
trunks := map[string]string{}
|
||||
for _, d := range members {
|
||||
if d.Check != nil && d.Check.Trunk != "" {
|
||||
trunks[strings.ToLower(d.Repository)] = d.Check.Trunk
|
||||
}
|
||||
if d.TrunkNow != "" && trunks[strings.ToLower(d.Repository)] == "" {
|
||||
trunks[strings.ToLower(d.Repository)] = d.TrunkNow
|
||||
}
|
||||
}
|
||||
for repository, trunk := range named {
|
||||
trunks[repository] = trunk
|
||||
}
|
||||
value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "trunk": trunks, "state": state})
|
||||
if h.checker == nil {
|
||||
h.checker = map[string]checkerVerdict{}
|
||||
}
|
||||
var v checkerVerdict
|
||||
_ = json.Unmarshal(value, &v)
|
||||
h.checker[hash] = v
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
var _ = time.Second
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// A pull request and the head the checker judged, for checkPasses.
|
||||
type judgedPull struct {
|
||||
repository string
|
||||
number int
|
||||
head string
|
||||
}
|
||||
|
||||
// checkPasses says on the bus what the mesh says of heads the checker passed (issue 1302, ADR 0447): its verdict at these
|
||||
// heads in its verdicts bucket, each pull request's base requiring mesh/check and the forge's word that its bridge set it, the
|
||||
// line naming the head and the trunk commit it judged. The gate follows no verdict of the controller (issue 1318).
|
||||
func (b *testBus) checkPasses(t *testing.T, rt *runtime, group, trunk string, pulls ...judgedPull) {
|
||||
t.Helper()
|
||||
var heads []sdk.Head
|
||||
trunks := map[string]string{}
|
||||
for _, p := range pulls {
|
||||
heads = append(heads, sdk.Head{Repository: p.repository, Number: p.number, Head: p.head})
|
||||
trunks[p.repository] = trunk
|
||||
}
|
||||
hash, err := sdk.HeadsHash(heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verdict, _ := json.Marshal(map[string]any{"v": 1, "group": group, "heads": hash, "trunk": trunks, "state": "pass"})
|
||||
if _, err := b.kvs[checkerVerdicts].Put(context.Background(), group+"."+hash, verdict); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range pulls {
|
||||
owner, repo := splitRepository(p.repository)
|
||||
line := "pass " + p.head[:12] + " on " + trunk[:12] + " | unit 3 run, 0 kept"
|
||||
rt.mu.Lock()
|
||||
found := false
|
||||
for i := range rt.openPulls {
|
||||
o := &rt.openPulls[i]
|
||||
if o.Owner == owner && o.Repo == repo && o.Number == p.number {
|
||||
o.Required, o.Statuses, o.Checks, found = []string{ctxCheck}, []string{ctxCheck}, map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
rt.openPulls = append(rt.openPulls, OpenPull{Owner: owner, Repo: repo, Number: p.number, Base: "main", HeadSHA: p.head,
|
||||
Required: []string{ctxCheck}, Statuses: []string{ctxCheck}, Checks: map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}})
|
||||
}
|
||||
rt.mu.Unlock()
|
||||
b.publish(t, "mesh.mod.gitea.event.forge.status", map[string]any{"owner": owner, "repo": repo, "sha": p.head,
|
||||
"context": ctxCheck, "state": "success", "description": line})
|
||||
}
|
||||
}
|
||||
|
||||
func splitRepository(r string) (owner, repo string) {
|
||||
for i := range r {
|
||||
if r[i] == '/' {
|
||||
return r[:i], r[i+1:]
|
||||
}
|
||||
}
|
||||
return "", r
|
||||
}
|
||||
@@ -18,9 +18,8 @@ import (
|
||||
|
||||
// Context names of the statuses this owner can say more of than the forge keeps.
|
||||
const (
|
||||
// ctxGate is the controller's merge gate, kept where a base still requires it; mesh/check is ctxCheck (onestatus.go).
|
||||
ctxGate = "mesh/merge-gate"
|
||||
ctxRepo = "mesh/repo-check"
|
||||
// mesh/check, the one status main requires, is ctxCheck (onestatus.go); the controller's mesh/merge-gate and hq's
|
||||
// mesh/repo-check are gone with the merge gate (ADR 0524 §5, ADR 0527 §2).
|
||||
ctxDelivery = "mesh/delivery"
|
||||
ctxGroup = "mesh/delivery-group"
|
||||
)
|
||||
@@ -136,10 +135,7 @@ func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAn
|
||||
} else {
|
||||
// The forge away: the statuses the verdict set, as the forge holder sets them from it, said as such.
|
||||
if v := a.Verdict; v != nil {
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: ctxGate, State: forgeStateOf(v.Gate), Source: "mesh-delivery's verdict; the forge could not be read"})
|
||||
if v.Repo != "" {
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: ctxRepo, State: forgeStateOf(v.Repo), Source: "mesh-delivery's verdict; the forge could not be read"})
|
||||
}
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: ctxCheck, State: forgeStateOf(v.Gate), Source: "mesh-delivery's verdict; the forge could not be read"})
|
||||
}
|
||||
a.Merge = ForgeMerge{Branch: firstOf(base, "main"), Says: "not known: the forge could not be read, and only it holds the protection"}
|
||||
}
|
||||
@@ -211,29 +207,12 @@ func newerHead(d, e *Delivery) bool {
|
||||
// addWhatIsKnown joins this owner's verdict to the status it set, and the delivery to its own statuses.
|
||||
func (h *Holder) addWhatIsKnown(s *CheckStatus, v *Verdict, d *checksDelivery) {
|
||||
switch s.Name {
|
||||
case ctxGate, ctxRepo:
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
s.Build, s.CheckedOn = v.ID, v.On
|
||||
if !v.At.IsZero() {
|
||||
s.CheckedAt = v.At.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if s.Name == ctxGate {
|
||||
s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary
|
||||
if len(v.Modules) > 0 {
|
||||
s.Verdict += " (modules: " + strings.Join(v.Modules, ", ")
|
||||
if len(v.Dependents) > 0 {
|
||||
s.Verdict += "; built after them: " + strings.Join(v.Dependents, ", ")
|
||||
}
|
||||
s.Verdict += ")"
|
||||
}
|
||||
} else if v.Repo != "" {
|
||||
s.Verdict = strings.ToUpper(v.Repo) + " — " + v.RepoSaid
|
||||
}
|
||||
case ctxCheck:
|
||||
if v != nil && v.Status == ctxCheck {
|
||||
s.Verdict = strings.ToUpper(firstOf(v.Gate, "error")) + " — " + v.Summary
|
||||
if !v.At.IsZero() {
|
||||
s.CheckedAt = v.At.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
case ctxDelivery:
|
||||
if d != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -10,26 +9,17 @@ import (
|
||||
// the forge's statuses, the verdict whole as the controller said it, and whether the base's protection lets
|
||||
// it merge.
|
||||
|
||||
// checkedAs is the controller's `checked` as it comes off the bus, both layers said.
|
||||
func checkedAs(t *testing.T, repo string, number int, sha, gate, gateSaid, repoVerdict, repoSaid string) CheckedEvent {
|
||||
// checkedAs is a verdict as the checker says it on the head's mesh/check.
|
||||
func checkedAs(t *testing.T, repo string, number int, sha, gate, gateSaid string) CheckedEvent {
|
||||
t.Helper()
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
raw := map[string]any{"owner": owner, "repo": name, "number": number, "commit": sha, "verdict": gate,
|
||||
"summary": gateSaid, "id": "build-" + sha[:8], "on": "the-build-node", "report": "== compose\n" + gateSaid,
|
||||
"gate": map[string]any{"verdict": gate, "summary": gateSaid, "modules": []string{"app"}, "dependents": []string{"web"}},
|
||||
"repo-check": map[string]any{"verdict": repoVerdict, "summary": repoSaid}}
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(mustJSON(raw), &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
return CheckedEvent{Owner: owner, Repo: name, Number: number, Commit: sha, Verdict: gate, Summary: gateSaid, ID: "build-" + sha[:8]}
|
||||
}
|
||||
|
||||
// statusesAsTheForgeSets puts the two statuses the forge's holder sets from a verdict: clipped, as it keeps them.
|
||||
// statusesAsTheForgeSets puts the status the forge's bridge sets from a verdict: clipped, as the forge keeps it.
|
||||
func statusesAsTheForgeSets(w *world, repo, sha string, c CheckedEvent) {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
_ = w.forge.Status(owner, name, sha, ctxGate, forgeStateOf(c.Gate.Verdict), clip(c.Gate.Verdict+": "+c.Gate.Summary, 40), "https://forge.invalid/pr")
|
||||
_ = w.forge.Status(owner, name, sha, ctxRepo, forgeStateOf(c.RepoCheck.Verdict), clip(c.RepoCheck.Verdict+": "+c.RepoCheck.Summary, 40), "https://forge.invalid/pr")
|
||||
_ = w.forge.Status(owner, name, sha, ctxCheck, forgeStateOf(c.Verdict), clip(c.Verdict+": "+c.Summary, 40), "https://forge.invalid/pr")
|
||||
}
|
||||
|
||||
func statusNamed(a *ChecksAnswer, name string) *CheckStatus {
|
||||
@@ -43,11 +33,11 @@ func statusNamed(a *ChecksAnswer, name string) *CheckStatus {
|
||||
|
||||
func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.required = []string{ctxGate, ctxRepo}
|
||||
w.forge.required = []string{ctxCheck}
|
||||
w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, Title: "a change", State: "open", Base: "main", Head: "feat/x", HeadSHA: head}
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
said := "0 of 4 compose: anchor refuses app's manifest — a provision nothing provides, and three more machines alike"
|
||||
c := checkedAs(t, "novox/app", 7, head, "fail", said, "pass", "its merge-check.sh passed")
|
||||
c := checkedAs(t, "novox/app", 7, head, "fail", said)
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
statusesAsTheForgeSets(w, "novox/app", head, c)
|
||||
@@ -59,35 +49,30 @@ func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *
|
||||
if a.Commit != head || a.Pull == nil || a.Pull.Number != 7 || a.Forge != "" {
|
||||
t.Fatalf("the head read is %q, pull %+v, forge %q", a.Commit, a.Pull, a.Forge)
|
||||
}
|
||||
gate := statusNamed(a, ctxGate)
|
||||
gate := statusNamed(a, ctxCheck)
|
||||
if gate == nil || gate.State != "failure" || gate.Source != "the forge" || gate.SetBy != "mesh-admin" {
|
||||
t.Fatalf("the gate's status is %+v", gate)
|
||||
t.Fatalf("the check's status is %+v", gate)
|
||||
}
|
||||
// The forge clipped it; the verdict says it whole, with the machine, the build and the modules.
|
||||
// The forge clipped it; the verdict says it whole.
|
||||
if strings.Contains(gate.Description, "three more machines") {
|
||||
t.Fatalf("the fake forge did not clip: %q", gate.Description)
|
||||
}
|
||||
if !strings.Contains(gate.Verdict, "FAIL — 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") ||
|
||||
!strings.Contains(gate.Verdict, "modules: app; built after them: web") {
|
||||
t.Fatalf("the gate's verdict is %q", gate.Verdict)
|
||||
if !strings.Contains(gate.Verdict, "FAIL — failed: 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") {
|
||||
t.Fatalf("the check's verdict is %q", gate.Verdict)
|
||||
}
|
||||
if gate.Build != "build-"+head[:8] || gate.CheckedOn != "the-build-node" || gate.CheckedAt == "" {
|
||||
t.Fatalf("the gate was checked as %q on %q at %q", gate.Build, gate.CheckedOn, gate.CheckedAt)
|
||||
}
|
||||
repo := statusNamed(a, ctxRepo)
|
||||
if repo == nil || repo.State != "success" || repo.Verdict != "PASS — its merge-check.sh passed" || repo.CheckedOn != "the-build-node" {
|
||||
t.Fatalf("the repository check's status is %+v", repo)
|
||||
if gate.CheckedAt == "" {
|
||||
t.Fatal("the check says no time it was heard")
|
||||
}
|
||||
if d := statusNamed(a, ctxDelivery); d == nil || !strings.HasPrefix(d.Verdict, "rejected: waits for a new head") {
|
||||
t.Fatalf("the delivery's status is %+v", d)
|
||||
}
|
||||
if a.Verdict == nil || !strings.Contains(a.Verdict.Report, "== compose") {
|
||||
if a.Verdict == nil || !strings.Contains(a.Verdict.Summary, "three more machines") {
|
||||
t.Fatalf("the verdict whole is %+v", a.Verdict)
|
||||
}
|
||||
if a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", head) || a.Delivery.State != Rejected {
|
||||
t.Fatalf("the delivery is %+v", a.Delivery)
|
||||
}
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].Context != ctxGate {
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].Context != ctxCheck {
|
||||
t.Fatalf("the merge is %+v", a.Merge)
|
||||
}
|
||||
if !strings.Contains(a.Says, "novox/app#7 at aaaaaaaa") || !strings.Contains(a.Says, "NOT mergeable") {
|
||||
@@ -101,10 +86,10 @@ func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *
|
||||
|
||||
func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.required = []string{ctxGate, ctxRepo}
|
||||
w.forge.required = []string{ctxCheck}
|
||||
newer := "bbbbbbbbbbbb2222"
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "fail", "0 of 4 compose", "pass", "passed"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "fail", "0 of 4 compose"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, newer, "feat/x"))
|
||||
w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, State: "open", Base: "main", HeadSHA: newer}
|
||||
|
||||
@@ -118,27 +103,27 @@ func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) {
|
||||
if a.Verdict != nil {
|
||||
t.Fatalf("the older head's verdict was given for the newer: %+v", a.Verdict)
|
||||
}
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 2 || a.Merge.Blocking[0].State != "missing" {
|
||||
if a.Merge.Mergeable == nil || *a.Merge.Mergeable || len(a.Merge.Blocking) != 1 || a.Merge.Blocking[0].State != "missing" {
|
||||
t.Fatalf("a head not checked yet merges as %+v", a.Merge)
|
||||
}
|
||||
|
||||
// Passed, it merges.
|
||||
c := checkedAs(t, "novox/app", 7, newer, "pass", "every machine composes", "pass", "its merge-check.sh passed")
|
||||
c := checkedAs(t, "novox/app", 7, newer, "pass", "every machine composes")
|
||||
w.h.Checked(c)
|
||||
statusesAsTheForgeSets(w, "novox/app", newer, c)
|
||||
a, _ = w.h.Checks("novox/app", 7, "")
|
||||
if a.Merge.Mergeable == nil || !*a.Merge.Mergeable || !strings.Contains(a.Says, "mergeable") || strings.Contains(a.Says, "NOT") {
|
||||
t.Fatalf("a head that passed: %+v, says %q", a.Merge, a.Says)
|
||||
}
|
||||
if g := statusNamed(a, ctxGate); g == nil || g.Verdict != "PASS — every machine composes (modules: app; built after them: web)" {
|
||||
t.Fatalf("its gate: %+v", g)
|
||||
if g := statusNamed(a, ctxCheck); g == nil || !strings.HasPrefix(g.Verdict, "PASS — pass ") || !strings.Contains(g.Verdict, "every machine composes") {
|
||||
t.Fatalf("its check: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommitIsReadByTheStartOfItsShaAndAMergeByItsDelivery(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
c := checkedAs(t, "novox/app", 7, head, "pass", "every machine composes", "pass", "passed")
|
||||
c := checkedAs(t, "novox/app", 7, head, "pass", "every machine composes")
|
||||
w.h.Checked(c)
|
||||
statusesAsTheForgeSets(w, "novox/app", head, c)
|
||||
|
||||
@@ -175,7 +160,7 @@ func TestACommitIsReadByTheStartOfItsShaAndAMergeByItsDelivery(t *testing.T) {
|
||||
func TestWithTheForgeAwayTheVerdictIsSaidAndWhetherItMergesIsNotKnown(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "warning", "every machine composes, with a note", "error", "the toolchain was not there"))
|
||||
w.h.Checked(checkedAs(t, "novox/app", 7, head, "warning", "every machine composes, with a note"))
|
||||
w.forge.down = true
|
||||
|
||||
a, err := w.h.Checks("novox/app", 7, "")
|
||||
@@ -185,10 +170,9 @@ func TestWithTheForgeAwayTheVerdictIsSaidAndWhetherItMergesIsNotKnown(t *testing
|
||||
if a.Forge == "" || a.Commit != head || a.Verdict == nil {
|
||||
t.Fatalf("with the forge away: forge %q, commit %q, verdict %+v", a.Forge, a.Commit, a.Verdict)
|
||||
}
|
||||
gate, repo := statusNamed(a, ctxGate), statusNamed(a, ctxRepo)
|
||||
if gate == nil || gate.State != "success" || !strings.Contains(gate.Source, "could not be read") ||
|
||||
repo == nil || repo.State != "error" || repo.Verdict != "ERROR — the toolchain was not there" {
|
||||
t.Fatalf("the statuses from the verdict: %+v %+v", gate, repo)
|
||||
gate := statusNamed(a, ctxCheck)
|
||||
if gate == nil || gate.State != "success" || !strings.Contains(gate.Source, "could not be read") {
|
||||
t.Fatalf("the status from the verdict: %+v", gate)
|
||||
}
|
||||
if a.Merge.Mergeable != nil || !strings.Contains(a.Says, "mergeable: not known") {
|
||||
t.Fatalf("whether it merges, with the forge away: %+v, %q", a.Merge, a.Says)
|
||||
|
||||
@@ -95,59 +95,8 @@ func (h *Holder) askAgainForConfig(id, why string) {
|
||||
}
|
||||
}
|
||||
|
||||
// askProposedAgain asks a proposed delivery's check again, its merge check put back to pending first.
|
||||
// askProposedAgain says why a proposed delivery's check is not asked again for a config value: the checker's (issue 1302), and
|
||||
// whether a held config value is weighed is its composition's. Nothing is asked of the controller (issue 1318).
|
||||
func (h *Holder) askProposedAgain(id, why string) {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.State != Proposed {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if d.judgedByCheck() {
|
||||
// The checker's (issue 1302): its check is not asked here, and whether a held config value is weighed is the
|
||||
// checker's composition's.
|
||||
h.Logf("[mesh-delivery] %s: a held config value changed; %v", id, checkerRefusal(id))
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number
|
||||
h.mu.Unlock()
|
||||
if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil {
|
||||
h.Logf("[mesh-delivery] %s: its check is not asked again for a config value: %v", id, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.State != Proposed {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
d.Check = nil
|
||||
if g := h.groups[d.Group]; g != nil && g.Check != nil {
|
||||
now := h.Now()
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was rechecked (%s)", d.ID, why)
|
||||
g.Check.OutOfDateAt = now
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
m := MemberOf(d)
|
||||
h.mu.Unlock()
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's check cannot be asked again yet (%v); it is asked at the next look", id, err)
|
||||
h.again("a check asked again for a config value that could not be asked")
|
||||
return
|
||||
}
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.State != Proposed {
|
||||
return
|
||||
}
|
||||
d.CheckAsk = &CheckAsk{ID: asked, At: h.Now(), By: byOwner, Why: why}
|
||||
h.Logf("[mesh-delivery] %s: its check asked again as %s — %s", id, asked, why)
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Since: h.Now(),
|
||||
Line: oneLine(fmt.Sprintf("%s delivery %s: its check asked again by %s as %s: %s", h.Now().UTC().Format("2006-01-02T15:04:05Z07:00"),
|
||||
id, byOwner, asked, why))})
|
||||
h.keep(d)
|
||||
h.Logf("[mesh-delivery] %s: a held config value changed; %v", id, checkerRefusal(id))
|
||||
}
|
||||
|
||||
@@ -392,100 +392,6 @@ func TestHeldDeliveriesReadBackCloseByThemselvesOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **A group member is not held for a composed check that has not answered** (issue 316): it waits published,
|
||||
// and goes on once the check passes for the heads that merged.
|
||||
func TestAGroupMemberWaitsForItsComposedCheckToAnswer(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const a, b = "novox/a", "novox/b"
|
||||
const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2"
|
||||
aID := IDOf(a, aHead)
|
||||
pa, pb := pr(a, 1, aHead, "feat/k", "x"), pr(b, 2, bHead, "feat/k", "y")
|
||||
w.h.PullUpdated(pa)
|
||||
w.h.PullUpdated(pb)
|
||||
w.h.Checked(verdictMoving(a, 1, aHead, "a"))
|
||||
w.h.Checked(verdictMoving(b, 2, bHead, "b"))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("the composed check was asked %v", w.ctl.checks)
|
||||
}
|
||||
w.h.PullMerged(mergedAt(pa, "m1m1m1m1m1m1", t0))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true)))
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != Published {
|
||||
t.Fatalf("merged while its group's composed check had not answered, it is %s (%s)", s, w.delivery(aID).HeldWhy)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != Published || len(w.ctl.delivers) != 0 {
|
||||
t.Fatalf("its composed check passed, but the other member is not merged: %s, let go %v", s, w.ctl.delivers)
|
||||
}
|
||||
// The other member merges: every member is on the trunk, and the walk is let go (ADR 0418 §2).
|
||||
w.h.PullMerged(mergedAt(pb, "m2m2m2m2m2m2", t0))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-b", b, "m2m2m2m2m2m2", true)))
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != Delivering || !containsString(w.ctl.delivers, "plan-a") {
|
||||
t.Fatalf("every member merged, and it is %s, let go %v", s, w.ctl.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// **A member already held for its group's composed check goes on when that check passes for the heads that
|
||||
// merged** (issue 316); a check that failed, or passed for other heads, keeps it held for a person.
|
||||
func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
|
||||
const a, b = "novox/a", "novox/b"
|
||||
const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2"
|
||||
aID, bID := IDOf(a, aHead), IDOf(b, bHead)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
verdict string
|
||||
commits map[string]string
|
||||
want State
|
||||
}{
|
||||
{"passed for the heads that merged", "pass", map[string]string{aID: aHead, bID: bHead}, Delivering},
|
||||
{"warned for the heads that merged", "warning", map[string]string{aID: aHead, bID: bHead}, Delivering},
|
||||
{"failed", "fail", map[string]string{aID: aHead, bID: bHead}, Held},
|
||||
{"passed for other heads", "pass", map[string]string{aID: "a9a9a9a9a9a9a9a9", bID: bHead}, Held},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
store := newMemStore()
|
||||
why := "its group feat/k's composed check did not pass for the heads that merged; a person decides that it goes on"
|
||||
da := &Delivery{ID: aID, Repository: a, Commit: aHead, Number: 1, Base: "main", Branch: "feat/k", Group: "feat/k",
|
||||
State: Held, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m1m1m1m1m1m1", MergedAt: t0, HeldWhy: why,
|
||||
Plan: planMoving(a, "a"), Check: &Verdict{Gate: "pass"},
|
||||
Walk: &WalkSeen{ID: "plan-a", Commit: "m1m1m1m1m1m1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}}
|
||||
db := &Delivery{ID: bID, Repository: b, Commit: bHead, Number: 2, Base: "main", Branch: "feat/k", Group: "feat/k",
|
||||
State: Published, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m2m2m2m2m2m2", MergedAt: t0,
|
||||
Plan: planMoving(b, "b"), Check: &Verdict{Gate: "pass"},
|
||||
Walk: &WalkSeen{ID: "plan-b", Commit: "m2m2m2m2m2m2", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}}
|
||||
g := &Group{ID: "feat/k", Members: []string{aID, bID}, Closed: true,
|
||||
Check: &GroupCheck{Asked: "check-old", At: t0, Commits: c.commits}}
|
||||
for _, d := range []*Delivery{da, db} {
|
||||
_ = store.PutDelivery(d)
|
||||
}
|
||||
_ = store.PutGroup(g)
|
||||
ctl := newFakeController()
|
||||
ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true))
|
||||
ctl.put(aWalk("plan-b", b, "m2m2m2m2m2m2", true))
|
||||
w := newWorldOver(t, store, ctl, newFakeForge(), t0.Add(time.Minute))
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != Held {
|
||||
t.Fatalf("before its composed verdict it is %s", s)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-old", Verdict: c.verdict, Summary: "composed: " + c.verdict})
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != c.want {
|
||||
t.Fatalf("its composed check %s, and it is %s (%s), want %s", c.name, s, w.delivery(aID).HeldWhy, c.want)
|
||||
}
|
||||
if c.want == Delivering && !containsString(ctl.delivers, "plan-a") {
|
||||
t.Fatalf("lifted, its walk was not let go: %v", ctl.delivers)
|
||||
}
|
||||
// A composed check of other heads is asked again for these heads (novox/hq ADR 0323 §6).
|
||||
if c.name == "passed for other heads" && !containsString(ctl.checks, "feat/k:"+aID+","+bID) {
|
||||
t.Fatalf("the composed check was not asked again for the heads that merged: %v", ctl.checks)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **A module a release carried to every machine the held delivery sends it to covers it** (novox/hq issue
|
||||
// 351). A build the controller held back for a person and released on their word reaches each machine by a
|
||||
// backlog walk, which credits the delivery that published the build one step per machine — never the walk's
|
||||
|
||||
@@ -47,8 +47,8 @@ func (h *Holder) AnswersChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check; hq's mesh/repo-check and, on a
|
||||
// base that still requires it, mesh/merge-gate) says a
|
||||
// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check and, on a
|
||||
// base that still requires it after a revert, the two it replaced) says a
|
||||
// check started, answered, or was put back, so the forge's open pull requests are read to see which heads still wait on
|
||||
// one (SeeOpenPulls). A status of any other context shows nothing the merge needs.
|
||||
//
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -20,7 +19,14 @@ func hear(t *testing.T, w *world, key string, body any) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := handle(w.h, stdio.Envelope{Key: key, Body: raw}); err != nil {
|
||||
if key == ControllerSeat+".checked" {
|
||||
// The gate follows no verdict of the controller any more (issue 1318): a test says it as the checker's.
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.Checked(c)
|
||||
} else if err := handle(w.h, stdio.Envelope{Key: key, Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.React() // the look the event pokes, taken by the holder's worker
|
||||
@@ -85,53 +91,6 @@ func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §3): a passing verdict that arrives out of date is not asked; its check against the trunk's
|
||||
// head is, and the Approve is asked on the passing verdict.
|
||||
func TestAStalePassingVerdictAsksTheCheckAgainBeforeTheOperator(t *testing.T) {
|
||||
w := announced(t)
|
||||
// Another merge moved the trunk before the verdict, judged against the trunk before, arrived.
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked for a verdict out of date")
|
||||
}
|
||||
if len(w.ctl.checks) != 1 || w.ctl.baseSHAs[0] != "t2t2t2t2t2t2" {
|
||||
t.Fatalf("its check against the trunk's head was not asked once: %v %v", w.ctl.checks, w.ctl.baseSHAs)
|
||||
}
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("the passing verdict at the trunk's head asked the operator %d times, wanted once", w.op.count())
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("its check was asked again: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §3, §4): an ask raised stays when the trunk moves; the Approve is given; the merge waits for the
|
||||
// fresh verdict and lands on it; no second Approve is asked.
|
||||
func TestAnAskRaisedStaysThroughATrunkMoveAndTheMergeLandsOnTheFreshVerdict(t *testing.T) {
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.h.React()
|
||||
if w.state(id) != Proposed || w.op.count() != 1 || len(w.op.open()) != 1 {
|
||||
t.Fatalf("after the trunk moved: %s, asks %d, open %v", w.state(id), w.op.count(), w.op.open())
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("its check against the trunk's head was not asked at once: %v", w.ctl.checks)
|
||||
}
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged on a verdict out of date: %v", w.forge.merges)
|
||||
}
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
if len(w.forge.merges) != 1 || w.delivery(id).Ready.Merged == nil || w.op.count() != 1 {
|
||||
t.Fatalf("the passing verdict did not ask the merge at once, on the same Approve: %v %+v asks %d", w.forge.merges,
|
||||
w.delivery(id).Ready, w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022: an Approve whose merge the tool refused is asked again at once on the verdict that decides it.
|
||||
func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
|
||||
w := announced(t)
|
||||
@@ -229,37 +188,3 @@ func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) {
|
||||
t.Fatalf("said more than once: %v", w.forge.comments)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §7), as novox/hq ADR 0505 §5 changed it: a check that failed after the ask merges nothing, is
|
||||
// said once on the pull request at once, and is a stalled line only after two hours with no new head.
|
||||
func TestACheckThatFailsAfterTheAskIsNotMergedAndIsSaid(t *testing.T) {
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if _, err := w.h.Recheck(id, "a second look", "tester"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged past a failed check: %v", w.forge.merges)
|
||||
}
|
||||
w.h.React()
|
||||
said := 0
|
||||
for _, c := range w.forge.comments {
|
||||
if strings.Contains(c, "not merged") {
|
||||
said++
|
||||
}
|
||||
}
|
||||
if said != 1 {
|
||||
t.Fatalf("the failure is not said once: %v", w.forge.comments)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("the failure is the operator's before its author had two hours: %+v", lines)
|
||||
}
|
||||
w.later(2*time.Hour + time.Minute)
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != id || strings.Contains(lines[0].Says, "marked") {
|
||||
t.Fatalf("the failure is not a stalled line: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,19 +128,6 @@ type fakeController struct {
|
||||
planned *DeliveryPlan
|
||||
// forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n".
|
||||
forgot []string
|
||||
// queue is the build queue as Check fills it: every ask made is held, waiting, until a test takes it out (a lost
|
||||
// ask, issue 1274); queueDown makes the queue unreadable.
|
||||
queue []QueuedAsk
|
||||
queueDown bool
|
||||
}
|
||||
|
||||
func (c *fakeController) Queue() ([]QueuedAsk, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.queueDown {
|
||||
return nil, errors.New("the queue cannot be read")
|
||||
}
|
||||
return append([]QueuedAsk(nil), c.queue...), nil
|
||||
}
|
||||
|
||||
// BackupsAgain records the word, and clears the walk's failed backups as the controller does.
|
||||
@@ -192,32 +179,6 @@ func (c *fakeController) Plan(repository, base, head string, paths, dirs, remove
|
||||
}
|
||||
return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil
|
||||
}
|
||||
func (c *fakeController) Check(group string, ms []Member) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.down {
|
||||
return "", errors.New("down")
|
||||
}
|
||||
if c.onCheck != nil {
|
||||
c.onCheck()
|
||||
}
|
||||
c.asked++
|
||||
id := fmt.Sprintf("check-%d", c.asked)
|
||||
if c.emptyCheck {
|
||||
id = ""
|
||||
}
|
||||
var heads []string
|
||||
for _, m := range ms {
|
||||
heads = append(heads, m.ID)
|
||||
}
|
||||
c.checks = append(c.checks, group+":"+strings.Join(heads, ","))
|
||||
for _, m := range ms {
|
||||
c.queue = append(c.queue, QueuedAsk{ID: id, State: "waiting", Kind: "check", Repository: m.Repository, Commit: m.Head})
|
||||
c.checkReady = append(c.checkReady, m.Ready)
|
||||
c.baseSHAs = append(c.baseSHAs, m.BaseSHA)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func (c *fakeController) Deliver(walk, why string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@@ -29,29 +29,25 @@ func readOf(d *Delivery, head string) memberRead {
|
||||
}
|
||||
|
||||
// notAsSuccess reads a member's required statuses at its head and says, in the forge's words, which are not a success;
|
||||
// empty when all are. ownOnly is whether only the member's own repository check is missing or pending. An error is a
|
||||
// empty when all are. An error is a
|
||||
// read that could not be made: the member then counts as not mergeable.
|
||||
func (h *Holder) notASuccess(m memberRead) (says string, ownOnly bool, err error) {
|
||||
func (h *Holder) notASuccess(m memberRead) (says string, err error) {
|
||||
fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return "", err
|
||||
}
|
||||
if fc.Merge.Mergeable == nil {
|
||||
return "", false, fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
return "", fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
}
|
||||
if *fc.Merge.Mergeable {
|
||||
return "", false, nil
|
||||
return "", nil
|
||||
}
|
||||
var parts []string
|
||||
ownOnly = len(fc.Merge.Blocking) > 0
|
||||
for _, b := range fc.Merge.Blocking {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State))
|
||||
if b.Context != ctxRepo || (b.State != "pending" && b.State != "missing") {
|
||||
ownOnly = false
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s requires %s; not a success: %s", fc.Merge.Branch, strings.Join(fc.Merge.Required, ", "),
|
||||
strings.Join(parts, ", ")), ownOnly, nil
|
||||
strings.Join(parts, ", ")), nil
|
||||
}
|
||||
|
||||
// unmergedLive are a group's members not yet merged and not over, in the group's order. Under the lock.
|
||||
@@ -76,7 +72,7 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
}
|
||||
}
|
||||
for _, m := range reads {
|
||||
says, ownOnly, err := h.notASuccess(m)
|
||||
says, err := h.notASuccess(m)
|
||||
if err == nil && says == "" {
|
||||
continue
|
||||
}
|
||||
@@ -88,7 +84,6 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
var own func()
|
||||
if g := h.groups[gid]; g != nil {
|
||||
if g.Hold == nil || g.Hold.Kind != "blocked" || g.Hold.Says != says || g.Hold.Member != m.id {
|
||||
h.Logf("[mesh-delivery] group %s is approved and NOT merged: %s at %s: %s", gid, m.id, shortOf(m.head, 12), says)
|
||||
@@ -103,15 +98,9 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
if d := h.deliveries[m.id]; ownOnly && d != nil && d.Ready != nil {
|
||||
own = h.askOwnAfterRefusal(d) // its own repository check is what is missing: asked, once for the head
|
||||
}
|
||||
release()
|
||||
h.again("a group's merge held for a required status")
|
||||
h.mu.Unlock()
|
||||
if own != nil {
|
||||
own()
|
||||
}
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -48,73 +47,6 @@ func approvedGroup(t *testing.T) *world {
|
||||
return w
|
||||
}
|
||||
|
||||
// Issue 1047 (the rule the lead settled, ADR 0421 §3): an approved group whose trunk moved through a merge that is not
|
||||
// its member's has each out-of-date member's check asked at once, then the composed check, and merges on the same
|
||||
// Approve — with no second one asked.
|
||||
func TestAnApprovedGroupWhoseTrunkMovedIsCheckedAgainAndMergedOnTheSameApprove(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.PullMerged(merged(gCtl, 5, "5555555555555555", gT1))
|
||||
w.settleAll()
|
||||
// The member whose trunk moved has its own check asked, in the same look; the other member's trunk did not move.
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got)
|
||||
}
|
||||
if w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1] != gT1[:12] && w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1] != gT1 {
|
||||
t.Fatalf("its check was not asked against the trunk's head: %v", w.ctl.baseSHAs)
|
||||
}
|
||||
// The Approve is given while the checks run: nothing merges on the verdict out of date.
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged while the group's checks were out of date: %v", w.forge.merges)
|
||||
}
|
||||
// The member is judged again: the composed check is asked on the new verdicts.
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gT1))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
last := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if last != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("the composed check was not asked again: %v", w.ctl.checks)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
sort.Strings(w.forge.merges)
|
||||
if len(w.forge.merges) != 2 || w.forge.merges[0] != "novox/mesh-catalog#2@"+gHk || w.forge.merges[1] != "novox/mesh-controller#1@"+gHc {
|
||||
t.Fatalf("both members were not merged on the new composed verdict: %v", w.forge.merges)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a second Approve was asked: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0418 §4, issue 1275/AC3: a member the forge refuses stops the pass (the members after it are not asked), and is
|
||||
// merged again on its fresh check, the others after it in the same pass.
|
||||
func TestAMemberTheForgeRefusesStopsThePassAndIsMergedAgainOnItsFreshCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the pass stops at the first refusal: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("a refused member is asked again with no event, on a timer or at each look: %v", w.forge.merges)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: w.h.groups["feat/g"].Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 3 {
|
||||
t.Fatalf("a refused member is asked again on the next composed verdict, then the next: %v", w.forge.merges)
|
||||
}
|
||||
if d := w.delivery(IDOf(gCat, gHk)); d.Ready.Merged == nil {
|
||||
t.Fatalf("not merged: %+v", d.Ready)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -148,125 +80,6 @@ func TestTheGroupsAnswerSaysItsGateIsTheComposedChecks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0403 §1: a member whose own gate the group's composed check judges instead (the controller says its gate layer
|
||||
// "composed") counts as passed here once its repository's check did not fail, so the group's composed check is asked.
|
||||
func TestAMemberWhoseGateIsTheComposedChecksCountsAsPassed(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
for _, c := range []struct {
|
||||
repo string
|
||||
n int
|
||||
head string
|
||||
}{{gCtl, 1, gHc}, {gCat, 2, gHk}} {
|
||||
v := judged(c.repo, c.n, c.head, "pass", gTrunk0)
|
||||
v.Gate = &struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
}{Verdict: GateComposed, Summary: "judged by its group's composed check"}
|
||||
v.RepoCheck = &struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
}{Verdict: "pass", Summary: "the repository's own check passed"}
|
||||
w.h.Checked(v)
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.ctl.checks[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("the composed check was not asked for members judged by it: %v", w.ctl.checks)
|
||||
}
|
||||
// A member whose repository check failed is not passed, whatever the composed check will say.
|
||||
w2 := newWorld(t)
|
||||
w2.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
v := judged(gCtl, 1, gHc, "pass", gTrunk0)
|
||||
v.Gate = &struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
}{Verdict: GateComposed}
|
||||
v.RepoCheck = &struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
}{Verdict: "fail", Summary: "a test failed"}
|
||||
w2.h.Checked(v)
|
||||
if w2.state(IDOf(gCtl, gHc)) == Ready {
|
||||
t.Fatalf("a member whose repository check failed is ready")
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1049 (survey question 1, confirmed here): a composed check asked after one member has already merged is asked
|
||||
// with ALL the members, the merged one at the head it merged, never with the unmerged one alone. The controller needs two
|
||||
// or more members to compose (mesh-controller delivery.go askGroupCheck), and a merge of a contained head is a no-op
|
||||
// there.
|
||||
func TestAComposedCheckAskedAfterOneMemberMergedIsAskedWithAllMembers(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()
|
||||
if g := w.h.groups["feat/g"]; g == nil || !g.Closed {
|
||||
t.Fatalf("the group did not close when a member merged: %+v", g)
|
||||
}
|
||||
// The unmerged member gets a new head and its own verdict: the group's composed check is due again.
|
||||
const gHc2 = "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3"
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc2, "feat/g", "internal/a.go"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc2, "pass", gT1))
|
||||
w.settleAll()
|
||||
var composed []string
|
||||
for _, c := range w.ctl.checks[asked:] {
|
||||
if len(c) > 7 && c[:7] == "feat/g:" {
|
||||
composed = append(composed, c)
|
||||
}
|
||||
}
|
||||
if len(composed) != 1 || composed[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc2) {
|
||||
t.Fatalf("the composed check was asked with %v; wanted once, with both members (the merged one at its head)", composed)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1082 (the 2026-10-17 23:48 group): a closed group's member whose merge the tool refused after its sibling merged
|
||||
// asks the group's composed check again at once, and merges on its pass with the one Approve (ADR 0418 §4, ADR 0421).
|
||||
func TestAClosedGroupsMemberWhoseMergeWasRefusedAsksTheComposedCheckAgainAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
// The catalogue member merged, the controller's was refused.
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
var composed []string
|
||||
for _, c := range w.ctl.checks[asked:] {
|
||||
if strings.HasPrefix(c, "feat/g:") {
|
||||
composed = append(composed, c)
|
||||
}
|
||||
}
|
||||
if len(composed) != 1 || composed[0] != "feat/g:"+IDOf(gCat, gHk)+","+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("the refusal asked the composed check %v; wanted once, at once, with both members", composed)
|
||||
}
|
||||
// Not again while that check is on its way.
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 {
|
||||
t.Fatalf("asked again while the composed check was on its way: %v", got)
|
||||
}
|
||||
// It passes; the controller's member merges on the same Approve.
|
||||
w.forge.refuseRepo = nil
|
||||
g := w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
if d := w.delivery(IDOf(gCtl, gHc)); d.Ready.Merged == nil {
|
||||
t.Fatalf("the refused member was not merged on the new composed verdict: %+v", d.Ready)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a second Approve was asked: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -290,116 +103,6 @@ func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1047 (the live case of 2026-10-17 23:34, ADR 0395 §1, design 47): a group member that is ready when a third party's
|
||||
// merge moves its trunk, with no Approve asked yet (the composed check has not passed), has its own check asked at once,
|
||||
// and the Approve is asked once the group's composed check has passed on it.
|
||||
func TestATrunkMoveAsksTheCheckOfAGroupMemberWhoseApproveIsNotAskedYet(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the composed check has not passed, yet the operator was asked: %d", w.op.count())
|
||||
}
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.PullMerged(merged(gCat, 5, "5555555555555555", gT1))
|
||||
w.settleAll()
|
||||
got := w.ctl.checks[asked:]
|
||||
if len(got) != 1 || got[0] != ":"+IDOf(gCat, gHk) {
|
||||
t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got)
|
||||
}
|
||||
if last := w.ctl.baseSHAs[len(w.ctl.baseSHAs)-1]; last != gT1[:12] && last != gT1 {
|
||||
t.Fatalf("its check was not asked against the trunk's head: %v", w.ctl.baseSHAs)
|
||||
}
|
||||
// Not asked again at the next look while it is on its way.
|
||||
w.later(time.Second)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks[asked:]) != 1 {
|
||||
t.Fatalf("asked twice: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
// The composed check that was on its way when the trunk moved answers, judged against the old trunk (ADR 0403 §6): it is
|
||||
// out of date as it lands, so the group does not become ready on it and the operator is not asked.
|
||||
g := w.h.groups["feat/g"]
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the old trunk",
|
||||
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: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked on a composed pass judged against the old trunk: %d", w.op.count())
|
||||
}
|
||||
// The moved member's fresh verdict arrives: the composed check is asked again, and only its pass asks the operator.
|
||||
w.later(time.Minute)
|
||||
composedBefore := len(w.ctl.checks)
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gT1))
|
||||
w.settleAll()
|
||||
last := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if len(w.ctl.checks) == composedBefore || !strings.HasPrefix(last, "feat/g:") {
|
||||
t.Fatalf("the composed check was not asked again on the fresh verdict: %v", w.ctl.checks[composedBefore:])
|
||||
}
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked before the new composed check passed: %d", w.op.count())
|
||||
}
|
||||
g = w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the new trunk",
|
||||
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: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gT1[:12]}}})
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("the operator is asked once, on the new pass: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1047 (review round 2): the other order. The moved member's fresh verdict lands before the stale composed pass (one
|
||||
// build against two): the pass is out of date as it lands, the composed check is asked again, and the operator is asked
|
||||
// only after that check passes.
|
||||
func TestAStaleComposedPassLandingAfterTheMovedMembersFreshVerdictIsAskedAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
w.later(time.Minute)
|
||||
w.h.PullMerged(merged(gCat, 5, "5555555555555555", gT1))
|
||||
w.settleAll()
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gT1)) // the fresh verdict first
|
||||
w.settleAll()
|
||||
w.later(time.Minute)
|
||||
before := len(w.ctl.checks)
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed on the old trunk",
|
||||
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: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) == before || !strings.HasPrefix(w.ctl.checks[len(w.ctl.checks)-1], "feat/g:") {
|
||||
t.Fatalf("the composed check was not asked again after the stale pass: %v", w.ctl.checks[before:])
|
||||
}
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked before the new composed check passed: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// 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 " +
|
||||
@@ -416,28 +119,3 @@ func ownChecks(w *world, from int) (own, composed []string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Issue 1049 (the 2026-10-18 02:04 group): a closed group's member refused for a pending status of its OWN (the repository
|
||||
// check a sibling's new head put back) asks its own check, once for the head, and not the composed check, which sets no
|
||||
// repository check; a second pass at the same head asks nothing more.
|
||||
func TestAClosedGroupsMemberRefusedForItsOwnPendingStatusAsksItsOwnCheckOncePerHead(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: ownPending}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed checks %v; wanted the member's own check once and no composed check", own, composed)
|
||||
}
|
||||
// Later passes, the same head, the same refusal: nothing more is asked.
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if got := len(w.ctl.checks) - asked; got != 1 {
|
||||
t.Fatalf("asked %d checks at the same head, wanted the one: %v", got, w.ctl.checks[asked:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,16 +623,15 @@ func (h *Holder) takePlaceOf(old string, d *Delivery) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// decidedOnHead is the verdict the forge's statuses on a head hold, when the gate decided one: passed, warned
|
||||
// or failed. Nil for none, pending, or a check that could not run — those are asked again.
|
||||
// decidedOnHead is the verdict the forge's statuses on a head hold, when the checker decided one: passed, warned or
|
||||
// failed. Nil for none, pending, or a check that could not run — the checker checks those again.
|
||||
func decidedOnHead(checks map[string]string, now time.Time) *Verdict {
|
||||
switch checks["mesh/merge-gate"] {
|
||||
switch checks[ctxCheck] {
|
||||
case "success", "warning", "failure":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &Verdict{At: now, Gate: verdictOfState(checks["mesh/merge-gate"]),
|
||||
Summary: "the head's mesh/merge-gate, as the forge holds it", Repo: verdictOfState(checks["mesh/repo-check"])}
|
||||
return verdictOfCheck(checks[ctxCheck], "", now)
|
||||
}
|
||||
|
||||
// joinGroup puts a new head in the group its branch name makes with the other repositories' open pull
|
||||
@@ -739,9 +738,8 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
return
|
||||
}
|
||||
fresh.FromForge = true
|
||||
if s := p.HeadChecks["mesh/merge-gate"]; s != "" {
|
||||
fresh.Check = &Verdict{At: now, Gate: verdictOfState(s), Summary: "the head's mesh/merge-gate, as the forge holds it",
|
||||
Repo: verdictOfState(p.HeadChecks["mesh/repo-check"])}
|
||||
if s := p.HeadChecks[ctxCheck]; s != "" {
|
||||
fresh.Check = verdictOfCheck(s, "", now)
|
||||
}
|
||||
d, ts = fresh, []Transition{t}
|
||||
if d.Check != nil {
|
||||
@@ -757,9 +755,8 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
|
||||
After: AfterIn(p.Body), Issues: IssuesIn(p.Title + "\n" + p.Body), Created: now, FromForge: true}
|
||||
// Its check, as the forge holds it on the head: the verdict the pull request merged with.
|
||||
if s := p.HeadChecks["mesh/merge-gate"]; s != "" {
|
||||
d.Check = &Verdict{At: now, Gate: verdictOfState(s), Summary: "the head's mesh/merge-gate, as the forge holds it",
|
||||
Repo: verdictOfState(p.HeadChecks["mesh/repo-check"])}
|
||||
if s := p.HeadChecks[ctxCheck]; s != "" {
|
||||
d.Check = verdictOfCheck(s, "", now)
|
||||
}
|
||||
t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: fmt.Sprintf("%s#%d merged; its head was not "+
|
||||
"heard by this owner, so it is made from the forge's word", d.Repository, d.Number)})
|
||||
@@ -904,151 +901,6 @@ func (h *Holder) PullClosed(p PullEvent) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// CheckedEvent is the controller's `checked`.
|
||||
type CheckedEvent struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Commit string `json:"commit"`
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
ID string `json:"id"`
|
||||
On string `json:"on,omitempty"`
|
||||
Report string `json:"report,omitempty"`
|
||||
Gate *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
} `json:"gate,omitempty"`
|
||||
RepoCheck *struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"repo-check,omitempty"`
|
||||
Plan *DeliveryPlan `json:"plan,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Members []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
} `json:"members,omitempty"`
|
||||
// Trunk is the trunk commit the head was merged into and judged as, Beside the other repositories' commits the
|
||||
// check read, and Took how long it ran (novox/hq ADR 0323 §3).
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
Beside map[string]string `json:"beside,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
}
|
||||
|
||||
// Checked takes a verdict: a head's own, or a group's composed check.
|
||||
func (h *Holder) Checked(c CheckedEvent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := h.Now()
|
||||
if c.Group != "" {
|
||||
g := h.groups[c.Group]
|
||||
if g == nil || g.Check == nil || g.Check.Asked != c.ID {
|
||||
h.Logf("[mesh-delivery] a composed check of %s (%s) that is not the one asked: history", c.Group, c.ID)
|
||||
return
|
||||
}
|
||||
g.Check.Verdict, g.Check.Summary = c.Verdict, c.Summary
|
||||
for _, m := range h.membersOf(g) {
|
||||
h.mergeAgainNow(m) // the group's composed check answered: its members' merges are asked at once
|
||||
}
|
||||
// Each member's head and the trunk commit it was merged into (novox/hq ADR 0323 §6).
|
||||
for _, m := range c.Members {
|
||||
for id := range g.Check.Commits {
|
||||
if d := h.deliveries[id]; d != nil && strings.EqualFold(d.Repository, m.Owner+"/"+m.Repo) &&
|
||||
sameCommit(d.Commit, m.Commit) && m.Trunk != "" {
|
||||
if g.Check.Trunks == nil {
|
||||
g.Check.Trunks = map[string]string{}
|
||||
}
|
||||
g.Check.Trunks[id] = m.Trunk
|
||||
}
|
||||
}
|
||||
}
|
||||
// A composed check that was on its way when a trunk moved lands judged against the old trunk: out of date as it lands
|
||||
// (novox/hq ADR 0403 §6, issue 1047), and asked again once a member is judged again.
|
||||
if g.Check.OutOfDate == "" {
|
||||
for id, judged := range g.Check.Trunks {
|
||||
if d := h.deliveries[id]; d != nil && d.MergedAs == "" && !d.State.Final() && d.TrunkNow != "" && !sameCommit(judged, d.TrunkNow) {
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was judged against trunk %s, the trunk is now %s",
|
||||
id, shortOf(judged, 12), shortOf(d.TrunkNow, 12))
|
||||
// From when the composed check was asked: a member judged since then, even before this verdict landed,
|
||||
// counts as judged again.
|
||||
g.Check.OutOfDateAt = g.Check.At
|
||||
h.Logf("[mesh-delivery] group %s: %s", g.ID, g.Check.OutOfDate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
h.Logf("[mesh-delivery] group %s composed: %s — %s", g.ID, c.Verdict, c.Summary)
|
||||
return
|
||||
}
|
||||
repository := strings.ToLower(c.Owner + "/" + c.Repo)
|
||||
id := IDOf(repository, c.Commit)
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
if c.Number == 0 {
|
||||
return // a check of no pull request: nothing to deliver
|
||||
}
|
||||
d = &Delivery{ID: id, Repository: repository, Commit: c.Commit, Number: c.Number, Created: now}
|
||||
t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: "its verdict arrived before its head was heard"})
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
return
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.Ready != nil && !d.Ready.OwnAskedAt.IsZero() {
|
||||
// The own check asked for this head answered (issue 1792): one own ask is in flight at a time, so a status still
|
||||
// pending or missing after its answer is asked again.
|
||||
d.Ready.OwnAskedAt = time.Time{}
|
||||
h.keep(d)
|
||||
}
|
||||
if d.State == Rejected && c.Trunk != "" && d.Check != nil && !sameCommit(d.Check.Trunk, c.Trunk) {
|
||||
// Judged again against another trunk commit — the merge tool's ask of a verdict out of date, or a check asked
|
||||
// elsewhere (novox/hq ADR 0323 §5, §7): taken as the recheck it is.
|
||||
t, err := Apply(d, EvRecheck, true, Facts{Now: now, By: byOwner, Why: fmt.Sprintf("judged again against trunk %s "+
|
||||
"(it was rejected against trunk %s)", shortOf(c.Trunk, 12), shortOf(d.Check.Trunk, 12))})
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
return
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.State != Proposed {
|
||||
h.Logf("[mesh-delivery] %s is %s: a verdict for it now is history (%s)", d.ID, d.State, c.Verdict)
|
||||
return
|
||||
}
|
||||
v := &Verdict{ID: c.ID, On: c.On, At: now, Gate: c.Verdict, Summary: c.Summary, Report: c.Report,
|
||||
Head: shortOf(c.Commit, 12), Trunk: c.Trunk, Beside: c.Beside, Took: c.Took}
|
||||
if c.Gate != nil {
|
||||
v.Gate, v.Summary = c.Gate.Verdict, c.Gate.Summary
|
||||
v.Modules, v.Dependents = c.Gate.Modules, c.Gate.Dependents
|
||||
}
|
||||
if c.RepoCheck != nil {
|
||||
v.Repo, v.RepoSaid = c.RepoCheck.Verdict, c.RepoCheck.Summary
|
||||
}
|
||||
if v.Gate == "" {
|
||||
v.Gate = "error"
|
||||
}
|
||||
d.Check, d.AskedAgain = v, nil
|
||||
h.mergeAgainNow(d) // a verdict taken: the merge is asked at once, not a minute after a refusal
|
||||
if c.Plan != nil {
|
||||
d.Plan = c.Plan
|
||||
}
|
||||
if why := d.outOfDate(); why != "" {
|
||||
// Kept and shown, and it does not count: the delivery stays proposed (ADR 0323 §7). If it passes, its check is
|
||||
// asked against the trunk's head before the operator is (ADR 0421 §3, askFresh).
|
||||
d.FreshWanted = d.Check.Passes()
|
||||
h.Logf("[mesh-delivery] %s: its verdict %s is %s", d.ID, c.ID, why)
|
||||
}
|
||||
h.settleAndKeep(d)
|
||||
}
|
||||
|
||||
// WalkMoved takes a walk's record, from `plan-moved` or the controller's walks: the delivery it walks
|
||||
// moves with it; a backlog walk's carried builds are credited to the deliveries that published them.
|
||||
func (h *Holder) WalkMoved(w Walk) {
|
||||
@@ -1384,15 +1236,11 @@ func (h *Holder) plan() []func() {
|
||||
}
|
||||
}
|
||||
|
||||
// Proposed, with no verdict and no check asked: its check is asked at once (issue 1115), or the verdict that
|
||||
// stands on its head at the forge is adopted.
|
||||
// Proposed, with no verdict: the checker's mesh/check that stands on its head at the forge is adopted (issue 1115);
|
||||
// nothing is asked of the controller (issue 1318).
|
||||
if a := h.askChecks(now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
// Proposed, its check asked and held by no entry of the build queue past the bound: asked again (issue 1274).
|
||||
if a := h.lostChecks(now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
|
||||
// Merged with no walk yet: tied to the newest walk heard that answers it — by name, by its own commit, or by
|
||||
// the forge's word that the walk's commit contains it, asked here (novox/hq issue 362).
|
||||
@@ -1947,59 +1795,24 @@ func heldForGroup(d *Delivery, g *Group) bool {
|
||||
return d.State == Held && strings.HasPrefix(d.HeldWhy, fmt.Sprintf("its group %s's composed check", g.ID))
|
||||
}
|
||||
|
||||
// standingVerdict is the passing verdict an open pull request's head holds at the forge, when every merge check and
|
||||
// every context its base requires stands as success or warning; nil otherwise, a missing context included (issue 1115).
|
||||
func standingVerdict(p OpenPull, now time.Time) *Verdict {
|
||||
if p.Checks == nil {
|
||||
return nil
|
||||
}
|
||||
contexts := []string{"mesh/merge-gate", "mesh/repo-check"} // the controller's; mesh/check is taken in askChecks
|
||||
for _, c := range p.Required {
|
||||
if !slices.Contains(contexts, c) {
|
||||
contexts = append(contexts, c)
|
||||
}
|
||||
}
|
||||
for _, c := range contexts {
|
||||
if st := p.Checks[c].State; st != "success" && st != "warning" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &Verdict{At: now, Gate: verdictOfState(p.Checks["mesh/merge-gate"].State),
|
||||
Summary: "the head's mesh/merge-gate, as the forge holds it", Repo: verdictOfState(p.Checks["mesh/repo-check"].State)}
|
||||
}
|
||||
|
||||
// askedCheck is whether a proposed delivery's own check stands asked: a kept ask with no id is none.
|
||||
func askedCheck(d *Delivery) bool { return d.CheckAsk != nil && d.CheckAsk.ID != "" }
|
||||
|
||||
// askChecks are the asks of proposed deliveries with no verdict, at each look, at once (issue 1115): the forge's open
|
||||
// pull requests are read once, a passing verdict standing on a head is adopted (the delivery goes on to ready with no
|
||||
// new check), and a head with no check asked is asked its check. Under the lock; the asks run outside it.
|
||||
// askChecks reads the forge's open pull requests once for the proposed deliveries with no verdict (issue 1115), and takes the
|
||||
// checker's mesh/check where one stands on a head (issue 1302); the base's required statuses are kept. Nothing is asked of
|
||||
// the controller: the checker checks every head on every move (ADR 0447 §4, issue 1318). Under the lock; the read runs outside it.
|
||||
func (h *Holder) askChecks(now time.Time) func() {
|
||||
type want struct {
|
||||
id, owner, repo, head string
|
||||
number int
|
||||
member Member
|
||||
ask bool
|
||||
why string
|
||||
}
|
||||
var wants []want
|
||||
for _, d := range h.deliveries {
|
||||
if d.State != Proposed || d.Check != nil || d.MergedAs != "" || d.Number == 0 {
|
||||
continue
|
||||
}
|
||||
w := want{id: d.ID, head: d.Commit, number: d.Number, member: MemberOf(d)}
|
||||
w := want{id: d.ID, head: d.Commit, number: d.Number}
|
||||
w.owner, w.repo, _ = strings.Cut(d.Repository, "/")
|
||||
// A base that requires mesh/check is the checker's: nothing is asked of the controller (issue 1302).
|
||||
if !askedCheck(d) && !d.judgedByCheck() {
|
||||
if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute {
|
||||
h.lookAt(tried.Add(time.Minute), "a proposed delivery's check that could not be asked")
|
||||
} else {
|
||||
w.ask = true
|
||||
h.askTried[d.ID] = now
|
||||
h.lookAt(now.Add(time.Minute), "a proposed delivery's check that could not be asked")
|
||||
w.why = fmt.Sprintf("proposed for %s with no verdict and no check asked", now.Sub(d.Since).Round(time.Second))
|
||||
}
|
||||
}
|
||||
wants = append(wants, w)
|
||||
}
|
||||
if len(wants) == 0 {
|
||||
@@ -2007,77 +1820,25 @@ func (h *Holder) askChecks(now time.Time) func() {
|
||||
}
|
||||
return func() {
|
||||
open, err := h.Forge.OpenPulls()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, w := range wants {
|
||||
var standing *Verdict
|
||||
oneStatus := false
|
||||
if err == nil {
|
||||
for _, p := range open {
|
||||
if p.Number == w.number && strings.EqualFold(p.Owner, w.owner) && strings.EqualFold(p.Repo, w.repo) &&
|
||||
sameCommit(p.HeadSHA, w.head) {
|
||||
if slices.Contains(p.Required, ctxCheck) {
|
||||
// Its base requires mesh/check (issue 1302): its verdict is that status, when it stands, and
|
||||
// nothing is asked of the controller.
|
||||
oneStatus = true
|
||||
h.mu.Lock()
|
||||
h.noteRequired(h.deliveries[w.id], p.Required)
|
||||
if c := p.Checks[ctxCheck]; c.State != "" {
|
||||
h.takeCheckStatus(w.owner+"/"+w.repo, w.head, c.State, c.Description)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
standing = standingVerdict(p, h.Now())
|
||||
for _, p := range open {
|
||||
if p.Number == w.number && strings.EqualFold(p.Owner, w.owner) && strings.EqualFold(p.Repo, w.repo) &&
|
||||
sameCommit(p.HeadSHA, w.head) {
|
||||
h.mu.Lock()
|
||||
h.noteRequired(h.deliveries[w.id], p.Required)
|
||||
if c := p.Checks[ctxCheck]; c.State != "" {
|
||||
h.takeCheckStatus(w.owner+"/"+w.repo, w.head, c.State, c.Description)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if oneStatus {
|
||||
continue
|
||||
}
|
||||
if standing != nil {
|
||||
h.mu.Lock()
|
||||
if d := h.deliveries[w.id]; d != nil && d.State == Proposed && d.Check == nil {
|
||||
delete(h.askTried, w.id)
|
||||
d.Check = standing
|
||||
h.Logf("[mesh-delivery] %s: a passing verdict already stands on its head at the forge; adopted, no check asked", w.id)
|
||||
h.settleAndKeep(d)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if w.ask {
|
||||
h.askOwnCheck(w.id, w.member, w.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askOwnCheck asks the controller a proposed delivery's own check and keeps the ask.
|
||||
func (h *Holder) askOwnCheck(id string, m Member, why string) {
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's check cannot be asked yet: %v", id, err)
|
||||
return
|
||||
}
|
||||
if asked == "" {
|
||||
h.Logf("[mesh-delivery] ERROR: %s's check was asked and the controller's answer held no check id: it is not taken as asked, "+
|
||||
"and asked again in a minute at most", id)
|
||||
return
|
||||
}
|
||||
delete(h.askTried, id)
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.State != Proposed || d.Check != nil || askedCheck(d) {
|
||||
return
|
||||
}
|
||||
d.CheckAsk = &CheckAsk{ID: asked, At: h.Now(), By: byOwner, Why: why}
|
||||
h.Logf("[mesh-delivery] %s: its check asked of the controller as %s — %s", id, asked, why)
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectNote, Commit: d.Commit, Since: h.Now(),
|
||||
Line: oneLine(fmt.Sprintf("%s delivery %s: its check asked by %s as %s: %s", h.Now().UTC().Format(time.RFC3339),
|
||||
id, byOwner, asked, why))})
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// adopt makes a delivery of a walk no pull request is known for: running already — at the switch, or on the
|
||||
// controller's own path — it is delivering; waiting for a word, it waits for a person.
|
||||
func (h *Holder) adopt(w Walk) {
|
||||
@@ -2168,69 +1929,10 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
h.keepGroup(g)
|
||||
return nil
|
||||
}
|
||||
commits := commitsOf(members)
|
||||
// The composed check, once every member is ready. Asked again for new heads, even once the
|
||||
// group is closed: a member rebased or pushed is a new head, and the group waits for its composed check rather
|
||||
// than being held for a person (novox/hq ADR 0323 §6, issue 406). One out of date is asked again once a member
|
||||
// was judged again after it went out of date: a recheck, or the merge tool's ask.
|
||||
allReady := true
|
||||
for _, d := range members {
|
||||
// In a closed group a member on the trunk counts — merged and on its way, or already delivered (reached).
|
||||
if d.State != Ready && !(g.Closed && d.MergedAs != "" && (!d.State.Final() || d.State.Reached())) {
|
||||
allReady = false
|
||||
}
|
||||
}
|
||||
// A group judged by mesh/check (issue 1302, ADR 0447 §2): its verdict is the checker's one verdict at its members'
|
||||
// heads, read, not asked; nothing below asks a composed check or owes a member's mesh/merge-gate.
|
||||
oneStatus := oneStatusGroup(members)
|
||||
if oneStatus {
|
||||
h.composeFromChecker(g, members, now)
|
||||
}
|
||||
again := !oneStatus && g.Check != nil && (!sameMembers(g.Check.Commits, commits) || g.Check.OutOfDate != "" && rejudgedSince(members, g.Check.OutOfDateAt))
|
||||
// A closed group's member whose merge the tool refused, after the composed check answered, is judged again at once
|
||||
// (novox/hq ADR 0418 §4, issue 1082): the refusal is a sibling's merge having moved what the verdict stands on, and
|
||||
// nothing else asks the composed check again. Not while a check is on its way (its verdict is empty then).
|
||||
// **A refusal that names the member's OWN pending status** (mesh/repo-check, with the gate not pending: the composed
|
||||
// check set it) is not that: the composed check sets no repository check, so asking it again changes nothing. The
|
||||
// member's own check is asked, once for the head (issue 1049); its verdict sets the status.
|
||||
if !oneStatus && !again && g.Closed && g.Check != nil && g.Check.Verdict != "" {
|
||||
for _, d := range members {
|
||||
if !refusedAfter(d, g.Check.At) {
|
||||
continue
|
||||
}
|
||||
if ownStatusPending(d.Ready.Answer) {
|
||||
if a := h.askOwnAfterRefusal(d); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
continue
|
||||
}
|
||||
again = true
|
||||
h.Logf("[mesh-delivery] group %s: the merge of %s was refused (%s), so its composed check is asked again", g.ID,
|
||||
d.ID, d.Ready.Answer)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !oneStatus && allReady && (g.Check == nil && !g.Closed || again) {
|
||||
ms := membersOfAsk(members)
|
||||
id := g.ID
|
||||
asks = append(asks, func() {
|
||||
asked, err := h.Controller.Check(id, ms)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
g := h.groups[id]
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s's composed check cannot be asked yet: %v", id, err)
|
||||
h.again("a group's composed check that could not be asked")
|
||||
return
|
||||
}
|
||||
g.Check = &GroupCheck{Asked: asked, At: h.Now(), Commits: commits}
|
||||
g.Updated = h.Now()
|
||||
h.keepGroup(g)
|
||||
})
|
||||
}
|
||||
// A group is judged by mesh/check (issue 1302, ADR 0447 §2): its verdict is the checker's one verdict at its members'
|
||||
// heads, read, not asked; nothing asks a composed check of the controller and no member is owed a mesh/merge-gate
|
||||
// (issue 1318).
|
||||
h.composeFromChecker(g, members, now)
|
||||
// A superseded member of a closed group: whether a later delivered walk on its trunk carried every module it
|
||||
// moves, ADR 0270's covered test, kept on it as CoveredBy. Its own state stays superseded — a final state never
|
||||
// moves — and its group counts it as reached (novox/hq issue 442).
|
||||
@@ -2277,26 +1979,6 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
// Each member's mesh/merge-gate is the composed check's (ADR 0403 §1, §6): pending, saying why, while no composed
|
||||
// verdict stands at these heads. The verdict itself sets it, success or failure, in the forge module.
|
||||
if why := composedPending(g, members); why != "" && !oneStatus {
|
||||
if g.SaidGate == nil {
|
||||
g.SaidGate = map[string]string{}
|
||||
}
|
||||
for _, d := range members {
|
||||
if d.MergedAs != "" || d.State.Final() || g.SaidGate[d.ID] == why {
|
||||
continue
|
||||
}
|
||||
g.SaidGate[d.ID] = why
|
||||
g.Owed = append(g.Owed, Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/merge-gate", State: "pending",
|
||||
Line: clip("group "+g.ID+": "+why, 140), Since: now, Last: d.Repository + "#" + fmt.Sprint(d.Number) + " " + d.HTMLURL})
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
} else if len(g.SaidGate) > 0 {
|
||||
g.SaidGate = nil
|
||||
h.keepGroup(g)
|
||||
}
|
||||
// Its word, once, when every member is merged (ADR 0418 §2).
|
||||
if g.Closed {
|
||||
asks = append(asks, h.groupWord(g, members)...)
|
||||
@@ -2304,22 +1986,6 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
return asks
|
||||
}
|
||||
|
||||
// composedPending is why a group member's gate stands pending: the composed check has given no verdict at the members'
|
||||
// heads as they are, or its verdict is out of date; empty once one stands, which the forge module then sets as every
|
||||
// member's gate (novox/hq ADR 0403 §6).
|
||||
func composedPending(g *Group, members []*Delivery) string {
|
||||
c := g.Check
|
||||
switch {
|
||||
case c == nil || c.Verdict == "":
|
||||
return "its group's composed check has not given a verdict at the members' heads yet"
|
||||
case !sameMembers(c.Commits, commitsOf(members)):
|
||||
return "composed again for the group's new heads"
|
||||
case c.OutOfDate != "":
|
||||
return c.OutOfDate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// membersOfAsk are a group's members as the controller is asked about them: by id. No order is asked for or kept; the
|
||||
// plan's tiers take it from the module graph (novox/hq ADR 0418 §2, ADR 0419).
|
||||
func membersOfAsk(members []*Delivery) []Member {
|
||||
@@ -2823,50 +2489,3 @@ func refusedAfter(d *Delivery, at time.Time) bool {
|
||||
r := d.Ready
|
||||
return r != nil && r.Merged == nil && d.MergedAs == "" && r.RefusedSince != nil && !r.LastTry.Before(at)
|
||||
}
|
||||
|
||||
// ownStatusPending is whether the merge tool refused a merge because a status of the member's own merge check is
|
||||
// pending or missing (issue 1276) while its gate is not: the forge's "not a success: mesh/repo-check (pending)" (gitea merge.ts, mergeableUnder).
|
||||
// A refusal that also names mesh/merge-gate is the group's composed check's to answer, as is a verdict out of date.
|
||||
func ownStatusPending(answer string) bool {
|
||||
_, listed, ok := strings.Cut(answer, "not a success:")
|
||||
return ok && (strings.Contains(listed, "mesh/repo-check (pending)") || strings.Contains(listed, "mesh/repo-check (missing)")) &&
|
||||
!strings.Contains(listed, "mesh/merge-gate")
|
||||
}
|
||||
|
||||
// askOwnAfterRefusal asks a member's own check once for its head, after the merge tool refused it for a pending status of
|
||||
// its own. Under the lock; the ask is made outside it. Nil when it was asked already for this head.
|
||||
func (h *Holder) askOwnAfterRefusal(d *Delivery) func() {
|
||||
r := d.Ready
|
||||
if !r.OwnAskedAt.IsZero() || d.judgedByCheck() {
|
||||
return nil // asked already; or the checker's, who checks it again on its own (issue 1302)
|
||||
}
|
||||
r.OwnAskedAt = h.Now()
|
||||
h.keep(d)
|
||||
id, m := d.ID, MemberOf(d)
|
||||
return func() {
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.deliveries[id]
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's own check cannot be asked yet: %v", id, err)
|
||||
if d != nil && d.Ready != nil {
|
||||
d.Ready.OwnAskedAt = time.Time{}
|
||||
h.keep(d)
|
||||
}
|
||||
h.again("a member's own check that could not be asked")
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: the merge tool refused it for a pending status of its own, so its own check is asked as %s",
|
||||
id, asked)
|
||||
}
|
||||
}
|
||||
|
||||
func rejudgedSince(members []*Delivery, at time.Time) bool {
|
||||
for _, d := range members {
|
||||
if d.Check != nil && d.Check.At.After(at) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -251,21 +251,21 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) {
|
||||
}
|
||||
w.store.failing = false
|
||||
w.settleAll()
|
||||
if len(w.said) != 1 || len(w.forge.notesOn(head)) != 2 {
|
||||
if len(w.said) != 1 || len(w.forge.notesOn(head)) != 1 {
|
||||
t.Fatalf("once kept, said %d and noted %d", len(w.said), len(w.forge.notesOn(head)))
|
||||
}
|
||||
// A bucket the bus cannot take is written again; a note is appended once however often it is asked.
|
||||
w.owed.down = true
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.settleAll()
|
||||
if len(w.forge.notesOn(head)) != 2 {
|
||||
if len(w.forge.notesOn(head)) != 1 {
|
||||
t.Fatal("noted while the bus was away")
|
||||
}
|
||||
w.owed.down = false
|
||||
w.later(time.Hour) // each failure put its next try later: past every one of them
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if notes := w.forge.notesOn(head); len(notes) != 4 {
|
||||
if notes := w.forge.notesOn(head); len(notes) != 3 {
|
||||
t.Fatalf("after the bus came back the note has %d line(s): %q", len(notes), notes)
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", head)); len(d.Owed) != 0 {
|
||||
@@ -343,7 +343,7 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
|
||||
}
|
||||
// Never heard: the forge's statuses on its head decide.
|
||||
m := merged("novox/lab", 3, "eeeeeeeeeeee5555", "ffffffffffff6666")
|
||||
m.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "warning"}
|
||||
m.HeadChecks = map[string]string{"mesh/check": "success"}
|
||||
m.Paths = []string{"README.md"}
|
||||
w.h.PullMerged(m)
|
||||
w.settleAll()
|
||||
@@ -354,68 +354,6 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery group (ADR 0239 decision 4, ADR 0418): two heads sharing a branch name across repositories, checked
|
||||
// together, ready only when both are and their composition passed — and let go for delivery once, when every member is
|
||||
// merged, whatever order they merged in. No member waits for another's delivery.
|
||||
func TestAGroupIsCheckedTogetherAndLetGoOnceEveryMemberIsMerged(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
|
||||
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"))
|
||||
// These heads are not full commit ids, so their description lines cannot name them: the reviews are set as read.
|
||||
w.h.deliveries[ctl].ReviewedBy, w.h.deliveries[ctl].ReviewedAt = "tester@shanks", ctlHead
|
||||
w.h.deliveries[cat].ReviewedBy, w.h.deliveries[cat].ReviewedAt = "tester@shanks", catHead
|
||||
if w.delivery(ctl).Group != "feat/x" || w.delivery(cat).Group != "feat/x" {
|
||||
t.Fatalf("one branch name in two repositories made no group: %q %q", w.delivery(ctl).Group, w.delivery(cat).Group)
|
||||
}
|
||||
w.h.Checked(verdict("novox/mesh-controller", 1, ctlHead, "pass"))
|
||||
w.settleAll()
|
||||
if len(composed(w.ctl.checks)) != 0 {
|
||||
t.Fatal("the group was checked composed before every member was ready")
|
||||
}
|
||||
w.h.Checked(verdict("novox/mesh-catalog", 2, catHead, "pass"))
|
||||
w.settleAll()
|
||||
if c := composed(w.ctl.checks); len(c) != 1 || c[0] != "feat/x:"+cat+","+ctl {
|
||||
t.Fatalf("the composed check asked %v, wanted the members by id", w.ctl.checks)
|
||||
}
|
||||
if state, _ := GroupState(w.h.groups["feat/x"], w.h.membersOf(w.h.groups["feat/x"])); state != "checking" {
|
||||
t.Fatalf("before its composed verdict the group is %s", state)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: w.h.groups["feat/x"].Check.Asked, Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if state, _ := GroupState(w.h.groups["feat/x"], w.h.membersOf(w.h.groups["feat/x"])); state != "ready" {
|
||||
t.Fatalf("composed and ready, the group is %s", state)
|
||||
}
|
||||
if !strings.HasPrefix(w.forge.statuses["novox/mesh-catalog@"+catHead+" mesh/delivery-group"], "success") {
|
||||
t.Fatalf("the group's status on a member's head: %v", w.forge.statuses)
|
||||
}
|
||||
|
||||
// The catalogue merges first; its walk waits — the controller's member is not merged yet, so nothing is let go.
|
||||
w.h.PullMerged(merged("novox/mesh-catalog", 2, catHead, "m2m2m2m2m2m2"))
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-cat", "novox/mesh-catalog", "m2m2m2m2m2m2", true)))
|
||||
w.settleAll()
|
||||
if w.state(cat) != Published || len(w.ctl.delivers) != 0 {
|
||||
t.Fatalf("a member was let go before every member merged: %s %v", w.state(cat), w.ctl.delivers)
|
||||
}
|
||||
// The controller merges: its walk is on the controller's own path, started at the merge. Every member is merged,
|
||||
// so the catalogue's walk is let go at once, without waiting for the controller's delivery.
|
||||
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
|
||||
ctlWalk := w.ctl.put(aWalk("plan-ctl", "novox/mesh-controller", "m1m1m1m1m1m1", false))
|
||||
w.h.WalkMoved(ctlWalk)
|
||||
w.settleAll()
|
||||
if w.state(ctl) != Delivering || len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-cat" {
|
||||
t.Fatalf("every member merged, the catalogue was not let go once: %s %v", w.state(ctl), w.ctl.delivers)
|
||||
}
|
||||
if w.state(cat) != Delivering {
|
||||
t.Fatalf("the catalogue's member is %s", w.state(cat))
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.ctl.delivers) != 1 {
|
||||
t.Fatalf("the word was given more than once: %v", w.ctl.delivers)
|
||||
}
|
||||
}
|
||||
|
||||
// A member that fails stops no other (ADR 0418 §5): the plan that fails fails as every plan does, and what was delivered
|
||||
// stays. Nothing is stopped member by member.
|
||||
func TestAFailedMemberStopsNoOtherMember(t *testing.T) {
|
||||
@@ -471,12 +409,6 @@ func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T)
|
||||
w.settleAll()
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("the composed check was not asked once: %v", w.ctl.checks)
|
||||
}
|
||||
if got := w.ctl.checks[0]; got != "feat/z:"+IDOf("novox/a", "a1a1a1a1a1a1a1")+","+IDOf("novox/b", "b2b2b2b2b2b2b2") {
|
||||
t.Fatalf("the members asked: %s", got)
|
||||
}
|
||||
said := 0
|
||||
for _, c := range w.forge.comments {
|
||||
if strings.Contains(c, "not read") && strings.Contains(c, "novox/a#1") {
|
||||
@@ -502,43 +434,6 @@ func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// A group's members have their mesh/merge-gate said pending, saying why, until a composed verdict stands at their heads
|
||||
// (ADR 0403 §1, §6); the verdict then sets the gate in the forge module.
|
||||
func TestAGroupMembersGateIsPendingUntilItsComposedVerdictStands(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
const ha, hb = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"
|
||||
w.h.PullUpdated(pr("novox/a", 1, ha, "feat/z"))
|
||||
w.h.PullUpdated(pr("novox/b", 2, hb, "feat/z"))
|
||||
w.h.Checked(verdict("novox/a", 1, ha, "pass"))
|
||||
w.h.Checked(verdict("novox/b", 2, hb, "pass"))
|
||||
w.settleAll()
|
||||
for _, at := range []string{"novox/a@" + ha, "novox/b@" + hb} {
|
||||
if s := w.forge.statuses[at+" mesh/merge-gate"]; !strings.HasPrefix(s, "pending") || !strings.Contains(s, "composed check") {
|
||||
t.Fatalf("the member's gate on %s is %q, wanted pending with its reason", at, s)
|
||||
}
|
||||
}
|
||||
// Said once while the reason stands.
|
||||
w.later(time.Minute)
|
||||
before := w.forge.statusWrites
|
||||
w.settleAll()
|
||||
if w.forge.statusWrites != before {
|
||||
t.Fatalf("the pending gate was written again with no change")
|
||||
}
|
||||
// A composed verdict stands: mesh-delivery says nothing more of the gate (the forge module sets it).
|
||||
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "pass", Summary: "compose"})
|
||||
w.settleAll()
|
||||
if g := w.h.groups["feat/z"]; len(g.SaidGate) != 0 {
|
||||
t.Fatalf("a standing verdict left a gate pending: %v", g.SaidGate)
|
||||
}
|
||||
// A member's push puts it back to pending.
|
||||
w.h.PullUpdated(pr("novox/a", 1, "a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9", "feat/z"))
|
||||
w.settleAll()
|
||||
if s := w.forge.statuses["novox/b@"+hb+" mesh/merge-gate"]; !strings.HasPrefix(s, "pending") {
|
||||
t.Fatalf("the sibling's gate after a push is %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Stalled: a delivery past its state's bound is listed with what H2 may do; close takes only that.
|
||||
func TestStalledAndCloseWorkFromTheTable(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 1274: a proposed delivery whose asked check the build queue no longer holds is asked again, and a recheck from
|
||||
// proposed is taken when no check of its head is waiting or in flight.
|
||||
|
||||
// 1274/AC1: the queue holds nothing for the ask, the sweep runs after the bound: the check is asked once more and the
|
||||
// transition says the earlier ask was lost.
|
||||
func TestACheckTheQueueLostIsAskedAgainBySweepAfterTheBound(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
first := w.delivery(id).CheckAsk
|
||||
if len(w.ctl.checks) != 1 || first == nil {
|
||||
t.Fatalf("asked %v, ask %+v", w.ctl.checks, first)
|
||||
}
|
||||
w.ctl.queue = nil // the queue lost it
|
||||
w.later(9 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("asked again inside the bound: %v", w.ctl.checks)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if len(w.ctl.checks) != 2 || d.CheckAsk == nil || d.CheckAsk.ID == first.ID || last.Event != EvLost ||
|
||||
!strings.Contains(last.Why, first.ID) || !strings.Contains(last.Why, "lost") {
|
||||
t.Fatalf("asked %v; ask %+v; last transition %+v", w.ctl.checks, d.CheckAsk, last)
|
||||
}
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 2 {
|
||||
t.Fatalf("an ask the queue holds was asked again: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// 1274/AC1: a verdict that went out of date, its check against the trunk's head asked and lost, is asked again too (the
|
||||
// case of novox/mesh-controller@28a278c1462a), and the ask's bound runs from the new ask.
|
||||
func TestACheckAgainstTheTrunkTheQueueLostIsAskedAgain(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
id := IDOf(gCtl, gHc)
|
||||
w.h.PullMerged(merged(gCtl, 5, "5555555555555555", gT1))
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.State != Proposed || d.AskedAgain == nil {
|
||||
t.Fatalf("it is %s, asked again %v", d.State, d.AskedAgain)
|
||||
}
|
||||
asked := len(w.ctl.checks)
|
||||
w.ctl.queue = nil
|
||||
w.later(11 * time.Minute)
|
||||
w.settleAll()
|
||||
d = w.delivery(id)
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if len(w.ctl.checks) != asked+1 || last.Event != EvLost || d.AskedAgain == nil || !d.AskedAgain.After(w.now.Add(-time.Minute)) {
|
||||
t.Fatalf("asked %v; last transition %+v; asked again %v", w.ctl.checks[asked:], last, d.AskedAgain)
|
||||
}
|
||||
}
|
||||
|
||||
// 1274/AC2: the queue still holds the ask, waiting or in flight, or cannot be read: nothing is asked again.
|
||||
func TestACheckTheQueueStillHoldsIsNotAskedAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
w.ctl.queue[0].State = "in flight"
|
||||
w.later(3 * time.Hour)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("an ask in flight was asked again: %v", w.ctl.checks)
|
||||
}
|
||||
w.ctl.queue = nil
|
||||
w.ctl.queueDown = true
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.delivery(id).State != Proposed {
|
||||
t.Fatalf("an unreadable queue asked again: %v", w.ctl.checks)
|
||||
}
|
||||
w.ctl.queueDown = false
|
||||
w.later(3 * time.Minute)
|
||||
w.ctl.queue = []QueuedAsk{{ID: "elsewhere", State: "dead", Kind: "check", Repository: "novox/app", Commit: head}}
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 2 {
|
||||
t.Fatalf("a dead ask counted as held: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
// 1274/AC3: recheck of a proposed delivery with no check waiting or in flight is taken and asks its check; with one
|
||||
// held it is refused naming it.
|
||||
func TestARecheckFromProposedIsTakenOnlyWhenNoCheckOfItsHeadIsHeld(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
held := w.delivery(id).CheckAsk.ID
|
||||
if _, err := w.h.Recheck(id, "nothing came", "jochen"); err == nil || !strings.Contains(err.Error(), held) {
|
||||
t.Fatalf("a recheck with a check waiting: %v", err)
|
||||
}
|
||||
if _, err := w.h.Recheck(id, "", "jochen"); err == nil {
|
||||
t.Fatal("a recheck with no why was taken")
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("a refused recheck asked: %v", w.ctl.checks)
|
||||
}
|
||||
w.ctl.queue = nil
|
||||
said, err := w.h.Recheck(id, "nothing came", "jochen")
|
||||
d := w.delivery(id)
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if err != nil || len(w.ctl.checks) != 2 || d.State != Proposed || last.Event != EvLost || last.By != "jochen" ||
|
||||
!strings.Contains(last.Why, "nothing came") || d.CheckAsk.ID != "check-2" || !strings.Contains(said, "check-2") {
|
||||
t.Fatalf("%q %v; asked %v; last transition %+v", said, err, w.ctl.checks, last)
|
||||
}
|
||||
// Taken, it is now held: a second recheck is refused.
|
||||
if _, err := w.h.Recheck(id, "again", "jochen"); err == nil || !strings.Contains(err.Error(), "check-2") {
|
||||
t.Fatalf("a second recheck: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1274/AC4: a group member re-asked this way has its group's composed check asked again at the members' heads once its
|
||||
// own check answers.
|
||||
func TestAGroupMemberReaskedAfterALostCheckHasTheComposedCheckAskedAgain(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
id := IDOf(gCat, gHk)
|
||||
if _, err := w.h.Recheck(id, "its verdict is wanted again", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll() // its own check is recorded as asked
|
||||
w.h.mu.Lock()
|
||||
w.h.groups["feat/g"].Check.OutOfDate = "" // as if the composed check stood: only the lost ask's own act marks it
|
||||
w.h.mu.Unlock()
|
||||
w.ctl.queue = nil
|
||||
w.later(11 * time.Minute)
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g.Check.OutOfDate == "" || !strings.Contains(g.Check.OutOfDate, "lost") {
|
||||
t.Fatalf("the composed check stands: %+v", g.Check)
|
||||
}
|
||||
first := g.Check.Asked
|
||||
asked := len(w.ctl.checks)
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
last := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if len(w.ctl.checks) <= asked || !strings.HasPrefix(last, "feat/g:") || w.h.groups["feat/g"].Check.Asked == first {
|
||||
t.Fatalf("the composed check was not asked again: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
}
|
||||
@@ -75,22 +75,6 @@ func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC2 with 1276/AC1: a member whose own repository check is the missing status has that check asked, once.
|
||||
func TestAHeldGroupWhoseMemberLacksItsOwnRepositoryCheckAsksThatCheck(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
setChecks(w, gCtl, gHc, "success", "")
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed %v; wanted the member's own check once", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestARefusedFirstMemberStopsTheGroupAndIsSaidUrgent(t *testing.T) {
|
||||
@@ -141,42 +125,6 @@ func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC1: a group with a member whose required status is not a success is not asked its Approve; the group says which
|
||||
// member and status; it is asked once every member may merge.
|
||||
func TestAGroupIsNotAskedWhileAMembersRequiredStatusIsNotASuccess(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
setChecks(w, gCat, gHk, "success", "success") // the composed verdict set every member's gate
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
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: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked with a required status pending: %d asks", w.op.count())
|
||||
}
|
||||
if g.Hold == nil || g.Hold.Kind != "unasked" || g.Hold.Member != IDOf(gCtl, gHc) || !strings.Contains(g.Hold.Says, "mesh/repo-check (pending)") {
|
||||
t.Fatalf("the group does not say which member and status it waits for: %+v", g.Hold)
|
||||
}
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || g.Hold != nil {
|
||||
t.Fatalf("asks %d, hold %+v; wanted the group asked once its statuses are green", w.op.count(), g.Hold)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -205,23 +153,6 @@ func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t
|
||||
}
|
||||
}
|
||||
|
||||
// 1276/AC1: given a closed group whose member's merge was refused with "not a success: mesh/repo-check (missing)", then
|
||||
// the member's own check is asked, not the composed check.
|
||||
func TestAClosedGroupsMemberRefusedForItsOwnMissingStatusAsksItsOwnCheck(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))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed checks %v; wanted the member's own check once and no composed check", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
@@ -96,8 +95,9 @@ func TestTheGatesCodeCallsNoForgeWriteToolAndNoDeliveryPlan(t *testing.T) {
|
||||
}
|
||||
forbidden := []string{"delivery-plan", "gitea_commit_status\"", "gitea_delivery_view", "gitea_note_append", "gitea_add_comment",
|
||||
"gitea_merge_pull_request\"", "gitea_open_pulls_write"}
|
||||
// The controller's verbs the gate calls: ADR 0450 §7.
|
||||
allowed := []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "forget-checks", "plans", "queue"}
|
||||
// The controller's verbs the gate calls: ADR 0450 §7, less the checks and the queue, which the controller no longer
|
||||
// keeps (issue 1318, ADR 0524 §5).
|
||||
allowed := []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "plans"}
|
||||
called := map[string]bool{}
|
||||
fset := token.NewFileSet()
|
||||
for _, f := range files {
|
||||
@@ -132,7 +132,7 @@ func TestTheGatesCodeCallsNoForgeWriteToolAndNoDeliveryPlan(t *testing.T) {
|
||||
return true
|
||||
})
|
||||
}
|
||||
for _, verb := range []string{"plans", "delivery-check"} {
|
||||
for _, verb := range []string{"plans"} {
|
||||
if !called[verb] {
|
||||
t.Errorf("the gate calls no %s: the test reads nothing", verb)
|
||||
}
|
||||
@@ -194,38 +194,3 @@ func TestADeliveryWithNoPlanGetsItFromPlansAsData(t *testing.T) {
|
||||
t.Fatalf("the delivery's plan is %+v", d.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
// AC4: a delivery-check answer that holds no id is an error said aloud, naming the delivery, and not taken as asked.
|
||||
func TestAnEmptyDeliveryCheckAnswerIsAnErrorNamingTheDelivery(t *testing.T) {
|
||||
stub := &stubController{answer: `{"ok":true,"output":"","answer":{}}`}
|
||||
if id, err := (seatController{ask: stub.ask}).Check("", []Member{{ID: "novox/app@x"}}); err == nil || id != "" ||
|
||||
!strings.Contains(err.Error(), "no check id") {
|
||||
t.Fatalf("an empty check answer gave %q and %v", id, err)
|
||||
}
|
||||
w := newWorld(t)
|
||||
var said []string
|
||||
var mu sync.Mutex
|
||||
w.h.Logf = func(format string, a ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
said = append(said, strings.TrimSpace(fmt.Sprintf(format, a...)))
|
||||
}
|
||||
w.h.Controller = seatController{ask: stub.ask}
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
found := false
|
||||
for _, line := range said {
|
||||
if strings.Contains(line, id) && strings.Contains(line, "no check id") && !strings.Contains(line, "taken as refused") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no error naming %s and the empty answer was logged: %q", id, said)
|
||||
}
|
||||
if d := w.delivery(id); d.CheckAsk != nil {
|
||||
t.Fatalf("an empty answer was kept as an ask: %+v", d.CheckAsk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,42 +4,6 @@ import "testing"
|
||||
|
||||
const gHk2 = "f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4f4"
|
||||
|
||||
// Issue 1792: a sibling's new head put a member's own mesh/repo-check back to pending ("out of date: judged beside ..."),
|
||||
// and nothing asked it again. The composed check passed, the group stayed held ("unasked") on the member's pending
|
||||
// status, and the operator's Approve was never asked. The group's hold on a member's own pending repository check asks
|
||||
// that check, once for the head, with no composed check (ADR 0382 §2: each member's own check stays required).
|
||||
func TestAGroupHeldBeforeItsAskOnAMembersOwnPendingRepoCheckAsksThatCheckOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
asked := len(w.ctl.checks)
|
||||
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: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own checks %v, composed %v; wanted the member's own check asked once", own, composed)
|
||||
}
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the operator was asked with a required status pending: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
func composedPassAt(w *world, hc, hk string) {
|
||||
g := w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
@@ -73,48 +37,3 @@ func heldOnOwnPending(t *testing.T) *world {
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// A second sibling push puts the member's repo-check out of date again after its own check answered: asked again.
|
||||
func TestAMembersOwnCheckIsAskedAgainWhenASecondSiblingHeadPutsItOutOfDate(t *testing.T) {
|
||||
w := heldOnOwnPending(t)
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0)) // its own check answered
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.settleAll()
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk2, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCat, 2, gHk2, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
setChecks(w, gCat, gHk2, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "pending")
|
||||
asked := len(w.ctl.checks)
|
||||
composedPassAt(w, gHc, gHk2)
|
||||
w.settleAll()
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own %v composed %v; wanted the member's own check asked again, once", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
// An own ask before the Approve, answered, and then a merge refused for the member's own pending status at the same head:
|
||||
// asked again (the one ask is per answer, not per head's life).
|
||||
func TestAMergeRefusedForOwnPendingAfterAnAnsweredOwnAskAsksItAgain(t *testing.T) {
|
||||
w := heldOnOwnPending(t)
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if len(w.op.open()) != 1 {
|
||||
t.Fatalf("the group is not asked once its statuses are green: %d", w.op.count())
|
||||
}
|
||||
w.forge.refuseRepo = map[string]error{gCtl: ownPending}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
asked := len(w.ctl.checks)
|
||||
w.settleAll()
|
||||
own, composed := ownChecks(w, asked)
|
||||
if len(composed) != 0 || len(own) != 1 || own[0] != ":"+IDOf(gCtl, gHc) {
|
||||
t.Fatalf("own %v composed %v; wanted the member's own check asked again after the refusal", own, composed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Issue 1826 (novox/hq ADR 0502 clause 5): when mesh-config holds or drops a value for a pull request that is a
|
||||
// delivery, its check, or its group's composed check, is asked again, and the transition says why.
|
||||
|
||||
func configChanged(action, pull string) ConfigChange {
|
||||
c := ConfigChange{Action: action, Pull: pull, Head: head, Key: "media-gid"}
|
||||
c.Level.Node, c.Level.Module = "ace", "n8n"
|
||||
return c
|
||||
}
|
||||
|
||||
// 1826/K4: a lone delivery that was ready is proposed again with its merge check pending, its check asked, and the
|
||||
// transition says that a config value was held.
|
||||
func TestAHeldValueAsksTheReadyDeliverysCheckAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := heldGreen(t, w, "novox/app", 7)
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("hold", "novox/app#7"))
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+id {
|
||||
t.Fatalf("asked %v; wanted its own check once", got)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if d.State != Proposed || last.Event != EvRecheck || !strings.Contains(last.Why, "media-gid") ||
|
||||
!strings.Contains(last.Why, "held") || last.By != byOwner {
|
||||
t.Errorf("state %s, last transition %+v", d.State, last)
|
||||
}
|
||||
if s := w.forge.statuses["novox/app@"+head+" "+ctxGate]; !strings.HasPrefix(s, "pending checking again") {
|
||||
t.Errorf("the merge check says %q: the old verdict would still let it merge", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: a rejected delivery (the very case: the composed check failed on the missing value) is asked again on a
|
||||
// hold; and a drop asks it again too.
|
||||
func TestAHeldValueAsksARejectedDeliverysCheckAgainAndADropToo(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 8, head, "feat/y", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 8, head, "fail"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
if w.state(id) != Rejected {
|
||||
t.Fatalf("it is %s", w.state(id))
|
||||
}
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("hold", "novox/app#8"))
|
||||
if w.state(id) != Proposed || len(w.ctl.checks) != asked+1 {
|
||||
t.Fatalf("a hold left it %s with %v asked", w.state(id), w.ctl.checks[asked:])
|
||||
}
|
||||
w.h.Checked(verdict("novox/app", 8, head, "pass"))
|
||||
w.settleAll()
|
||||
asked = len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("drop", "novox/app#8"))
|
||||
d := w.delivery(id)
|
||||
if len(w.ctl.checks) != asked+1 || !strings.Contains(d.Transitions[len(d.Transitions)-1].Why, "dropped") {
|
||||
t.Errorf("a drop asked %v; last transition %+v", w.ctl.checks[asked:], d.Transitions[len(d.Transitions)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: a delivery whose check is on its way is asked again, its old ask replaced, and says why in its note.
|
||||
func TestAHeldValueAsksTheCheckOfAProposedDeliveryAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 9, head, "feat/z", "src/a.go"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
before := w.delivery(id).CheckAsk
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("hold", "novox/app#9"))
|
||||
d := w.delivery(id)
|
||||
if len(w.ctl.checks) != asked+1 || d.CheckAsk == nil || (before != nil && d.CheckAsk.ID == before.ID) ||
|
||||
!strings.Contains(d.CheckAsk.Why, "held") {
|
||||
t.Errorf("asked %v; ask %+v (was %+v)", w.ctl.checks[asked:], d.CheckAsk, before)
|
||||
}
|
||||
if d.State != Proposed {
|
||||
t.Errorf("it is %s", d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: a hold for a member of a group asks the member's check again and then the group's composed check, which
|
||||
// the group waits for.
|
||||
func TestAHeldValueForAGroupMemberAsksTheComposedCheckAgain(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
g := w.h.groups["feat/g"]
|
||||
firstComposed := g.Check.Asked
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.ConfigChanged(configChanged("hold", gCat+"#2"))
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+IDOf(gCat, gHk) {
|
||||
t.Fatalf("a hold asked %v; wanted the member's own check", got)
|
||||
}
|
||||
if g = w.h.groups["feat/g"]; g.Check.OutOfDate == "" || !strings.Contains(g.Check.OutOfDate, "rechecked") {
|
||||
t.Fatalf("the composed check stands: %+v", g.Check)
|
||||
}
|
||||
w.later(60e9)
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
last := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if !strings.HasPrefix(last, "feat/g:") || w.h.groups["feat/g"].Check.Asked == firstComposed {
|
||||
t.Errorf("the composed check was not asked again: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: what is not a hold or a drop for a delivery of this owner asks nothing: the merge's set, another action,
|
||||
// another pull request, a pull request with no delivery, and a delivery already merged.
|
||||
func TestOnlyAHoldOrADropForADeliveryAsksACheckAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
heldGreen(t, w, "novox/app", 7)
|
||||
asked := len(w.ctl.checks)
|
||||
for _, c := range []ConfigChange{configChanged("set", "novox/app#7"), configChanged("resolve", ""),
|
||||
configChanged("hold", "novox/app#99"), configChanged("hold", "novox/other#7"), configChanged("hold", ""),
|
||||
configChanged("hold", "garbage")} {
|
||||
w.h.ConfigChanged(c)
|
||||
}
|
||||
if len(w.ctl.checks) != asked || w.state(IDOf("novox/app", head)) != Ready {
|
||||
t.Errorf("asked %v; it is %s", w.ctl.checks[asked:], w.state(IDOf("novox/app", head)))
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/K4: the event is heard as mesh-config's config-changed, and it is no recheck verb.
|
||||
func TestTheConfigChangedEventIsFollowed(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
heldGreen(t, w, "novox/app", 7)
|
||||
found := false
|
||||
for _, f := range followed {
|
||||
found = found || f == "mesh-config.config-changed"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("not followed: %v", followed)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"action": "hold", "pull": "novox/app#7", "head": head, "key": "media-gid",
|
||||
"level": map[string]any{"level": "module", "node": "ace", "module": "n8n"}})
|
||||
asked := len(w.ctl.checks)
|
||||
if err := handle(w.h, stdio.Envelope{Key: "mesh-config.config-changed", Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.ctl.checks) != asked+1 {
|
||||
t.Errorf("the event asked %v", w.ctl.checks[asked:])
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func TestATrunkMoveSendsAReadyDeliveryBackToProposed(t *testing.T) {
|
||||
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" {
|
||||
if d.State != Proposed || len(d.Transitions) != before || d.Check == nil {
|
||||
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)
|
||||
}
|
||||
@@ -163,156 +163,6 @@ func TestChecksAndShowNameTheHeadAndTheTrunkCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **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)
|
||||
}
|
||||
}
|
||||
// The group's Approve stays; each member whose verdict went out of date has its check asked at once, against the
|
||||
// trunk's head (ADR 0421 §3, issue 1047): only the member whose trunk moved, and the composed check is asked
|
||||
// after it.
|
||||
if got := w.ctl.checks[asked:]; len(got) != 1 || got[0] != ":"+idc {
|
||||
t.Fatalf("the trunk's move asked %v; wanted the moved member's own check once", got)
|
||||
}
|
||||
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.op.auto = false // the merges are heard as events below, as when the forge refused one
|
||||
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"]
|
||||
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.Members) != 2 {
|
||||
t.Fatalf("the group's members are %v", g.Members)
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -199,26 +199,10 @@ func unansweredOf(w *world) []StalledLine {
|
||||
return out
|
||||
}
|
||||
|
||||
// **A said line clears when its delivery's state changes** (novox/hq issue 441): a recheck proposes it again, under
|
||||
// the proposed state's own bound, and a merge publishes it, past the pull request. Both clear the line at once —
|
||||
// **A said line clears when its delivery's state changes** (novox/hq issue 441): a merge publishes it, past the pull
|
||||
// request. It clears the line at once —
|
||||
// before the forge is read again, which may lag — and stay cleared once it is read.
|
||||
func TestASaidLineClearsWhenItsDeliveryMovesOn(t *testing.T) {
|
||||
t.Run("a recheck proposes it", func(t *testing.T) {
|
||||
w, id := saidReady(t)
|
||||
if _, err := w.h.Recheck(id, "its repo-check status was lost", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := w.state(id); s != Proposed {
|
||||
t.Fatalf("a recheck left it %s", s)
|
||||
}
|
||||
if lines := unansweredOf(w); len(lines) != 0 {
|
||||
t.Fatalf("a rechecked delivery, proposed again, is still said as unanswered: %+v", lines)
|
||||
}
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := unansweredOf(w); len(lines) != 0 {
|
||||
t.Fatalf("a rechecked delivery is said again once the forge is read: %+v", lines)
|
||||
}
|
||||
})
|
||||
t.Run("a merge publishes it", func(t *testing.T) {
|
||||
w, id := saidReady(t)
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/mesh-controller", merge, true)))
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestAMergeWithoutAnAnnouncementStartsOverAStoppedDuplicateToo(t *testing.T)
|
||||
walk := w.ctl.put(aWalk("plan-783b", catalog783, merge783b, true))
|
||||
w.h.WalkMoved(walk)
|
||||
m := merged(catalog783, 316, head783b, merge783b)
|
||||
m.HeadChecks = map[string]string{"mesh/merge-gate": "success", "mesh/repo-check": "success"}
|
||||
m.HeadChecks = map[string]string{"mesh/check": "success"}
|
||||
w.h.PullMerged(m)
|
||||
if d := w.delivery(id); d.State == Stopped || d.Number != 316 {
|
||||
t.Fatalf("#316's merge, heard with no announcement of its own, left it %s, Number %d; want started over for #316",
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A check the build queue lost (novox/hq issue 1274).
|
||||
//
|
||||
// A proposed delivery waits for a check it asked of the controller. When the build queue loses that ask — taken by a
|
||||
// build agent that was replaced, or dropped with the queue's own state — nothing answers and nothing asks again: the
|
||||
// delivery stays proposed until a new head is pushed, and a recheck from proposed was refused. The sweep now reads the
|
||||
// controller's `queue` and asks again a check that no entry holds (neither waiting nor in flight; a dead one is not
|
||||
// held) for longer than lostCheckBound. A recheck of a proposed delivery is taken on the same fact. Both are the
|
||||
// table's EvLost, said in the delivery's transitions; neither asks while the queue holds an ask of the head, so no
|
||||
// ask is made twice. A queue that cannot be read asks nothing: a guess would risk a duplicate.
|
||||
|
||||
// lostReadEvery is how often the queue is read for one proposed delivery's ask: the read is one bus call, and a check
|
||||
// the queue holds is looked at again no sooner.
|
||||
const lostReadEvery = 2 * time.Minute
|
||||
|
||||
// lostCheckBound is how long a proposed delivery's asked check may go with no entry in the build queue before it is
|
||||
// asked again: the repository's last measured check time (ADR 0505 §4, lastCheckTime), at least UncheckedAfter (ten
|
||||
// minutes, the bound a check never asked is said at). Under the lock.
|
||||
func (h *Holder) lostCheckBound(repository string) time.Duration {
|
||||
return max(UncheckedAfter, h.lastCheckTime(repository))
|
||||
}
|
||||
|
||||
// awaitedSince is when a proposed delivery's check was asked, when it waits for one this owner asked: no verdict yet,
|
||||
// or a verdict out of date with its check against the trunk's head asked. False for a delivery waiting for none.
|
||||
func awaitedSince(d *Delivery) (time.Time, bool) {
|
||||
if d.State != Proposed || d.MergedAs != "" || d.Number == 0 || d.judgedByCheck() {
|
||||
return time.Time{}, false // a head on mesh/check asks the controller nothing (issue 1302)
|
||||
}
|
||||
switch {
|
||||
case d.Check == nil && askedCheck(d):
|
||||
return d.CheckAsk.At, true
|
||||
case d.Check != nil && d.outOfDate() != "" && d.AskedAgain != nil:
|
||||
return *d.AskedAgain, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// heldBy is the queue's entry that holds a check of the delivery's head: its own ask by id, or any check of its
|
||||
// repository at its head — a group's composed check included, which a second ask would only repeat.
|
||||
func heldBy(d *Delivery, asks []QueuedAsk) *QueuedAsk {
|
||||
for i, a := range asks {
|
||||
if !a.Holds() {
|
||||
continue
|
||||
}
|
||||
if d.CheckAsk != nil && d.CheckAsk.ID != "" && a.ID == d.CheckAsk.ID {
|
||||
return &asks[i]
|
||||
}
|
||||
if a.Kind == "check" && a.Commit != "" && sameCommit(a.Commit, d.Commit) &&
|
||||
strings.Contains(strings.ToLower(a.Repository), strings.ToLower(d.Repository)) {
|
||||
return &asks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a QueuedAsk) words() string {
|
||||
// The controller's `queue` says the state "in flight", its table's word.
|
||||
state := strings.ReplaceAll(a.State, "-", " ")
|
||||
if state == "in flight" && a.Machine != "" {
|
||||
return a.ID + " is in flight on " + a.Machine
|
||||
}
|
||||
return a.ID + " is " + state + " in the build queue"
|
||||
}
|
||||
|
||||
// lostChecks are the asks again of proposed deliveries whose asked check the build queue holds no entry of, past the
|
||||
// bound, at each look. Under the lock; the queue is read, and the asks made, outside it.
|
||||
func (h *Holder) lostChecks(now time.Time) func() {
|
||||
var ids []string
|
||||
for id, d := range h.deliveries {
|
||||
since, ok := awaitedSince(d)
|
||||
if !ok {
|
||||
delete(h.lostRead, id)
|
||||
continue
|
||||
}
|
||||
due := since.Add(h.lostCheckBound(d.Repository))
|
||||
if now.Before(due) {
|
||||
h.lookAt(due, "a proposed delivery's asked check, read in the build queue once its bound passed")
|
||||
continue
|
||||
}
|
||||
if read, was := h.lostRead[id]; was && now.Sub(read) < lostReadEvery {
|
||||
h.lookAt(read.Add(lostReadEvery), "a proposed delivery's asked check, looked for in the build queue again")
|
||||
continue
|
||||
}
|
||||
h.lostRead[id] = now
|
||||
h.lookAt(now.Add(lostReadEvery), "a proposed delivery's asked check, looked for in the build queue again")
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return func() {
|
||||
asks, err := h.Controller.Queue()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the build queue cannot be read, so no check is asked again for a lost ask: %v", err)
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
since, ok := time.Time{}, false
|
||||
if d != nil {
|
||||
since, ok = awaitedSince(d)
|
||||
}
|
||||
var held *QueuedAsk
|
||||
if ok {
|
||||
held = heldBy(d, asks)
|
||||
}
|
||||
var why string
|
||||
if ok && held == nil {
|
||||
what := "its check against the trunk's head"
|
||||
if d.Check == nil {
|
||||
what = "its check " + d.CheckAsk.ID
|
||||
}
|
||||
why = fmt.Sprintf("%s, asked %s ago, is held by no entry of the build queue (neither waiting nor in flight): "+
|
||||
"lost, asked again", what, h.Now().Sub(since).Round(time.Second))
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if why == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := h.askLost(id, byOwner, why, asks); err != nil {
|
||||
h.Logf("[mesh-delivery] %s: its lost check could not be asked again: %v", id, err)
|
||||
h.again("a proposed delivery's lost check that could not be asked again")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recheckProposed is `recheck` of a proposed delivery (issue 1274): taken, with why, when no check of its head is
|
||||
// waiting or in flight in the build queue, and refused naming the one that is. A queue that cannot be read refuses it.
|
||||
func (h *Holder) recheckProposed(id, why, by string) (string, error) {
|
||||
if why == "" {
|
||||
return "", fmt.Errorf("a recheck says why")
|
||||
}
|
||||
asks, err := h.Controller.Queue()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s is not rechecked: the build queue cannot be read (%v), so whether its check is still held is not known", id, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
var held *QueuedAsk
|
||||
if d != nil {
|
||||
held = heldBy(d, asks)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if held != nil {
|
||||
return "", fmt.Errorf("%s is not rechecked: its check is still held, %s", id, held.words())
|
||||
}
|
||||
return h.askLost(id, by, why, asks)
|
||||
}
|
||||
|
||||
// askLost asks a proposed delivery's check again, the table's EvLost: tried first on a copy, so an ask the controller
|
||||
// does not take spends nothing. Its group's composed check goes out of date, and is asked once the member answers.
|
||||
func (h *Holder) askLost(id, by, why string, asks []QueuedAsk) (string, error) {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
h.mu.Unlock()
|
||||
return "", fmt.Errorf("no delivery %q", id)
|
||||
}
|
||||
if held := heldBy(d, asks); held != nil {
|
||||
h.mu.Unlock()
|
||||
return "", fmt.Errorf("%s is not asked again: its check is still held, %s", id, held.words())
|
||||
}
|
||||
probe := *d
|
||||
probe.Transitions = nil
|
||||
if _, err := Apply(&probe, EvLost, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil {
|
||||
h.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
m := MemberOf(d)
|
||||
if d.Check != nil && d.outOfDate() != "" && d.TrunkNow != "" {
|
||||
m.BaseSHA = d.TrunkNow
|
||||
}
|
||||
h.mu.Unlock()
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s's check could not be asked again yet: %w", id, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil || d.State != Proposed {
|
||||
return "", fmt.Errorf("%s moved while its check was asked again", id)
|
||||
}
|
||||
now := h.Now()
|
||||
t, err := Apply(d, EvLost, true, Facts{Now: now, Why: why + " — asked as " + asked, By: by})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d.CheckAsk = &CheckAsk{ID: asked, At: now, By: by, Why: why}
|
||||
if d.Check != nil {
|
||||
d.AskedAgain, d.FreshWanted = &now, false
|
||||
}
|
||||
delete(h.askTried, id)
|
||||
if g := h.groups[d.Group]; g != nil && g.Check != nil {
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s's check was lost in the build queue and asked again", d.ID)
|
||||
g.Check.OutOfDateAt = now
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
return fmt.Sprintf("%s: its check was held by no entry of the build queue and is asked again as %s (%s)", id, asked, why), nil
|
||||
}
|
||||
@@ -30,10 +30,10 @@ type listening struct {
|
||||
func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() }
|
||||
func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now }
|
||||
|
||||
// The events this owner follows: the forge's pull requests, and the controller's verdicts and walks.
|
||||
// The events this owner follows: the forge's pull requests, and the controller's walks.
|
||||
// The forge's status events say that a check answered (events.go).
|
||||
var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed",
|
||||
"gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".checked", ControllerSeat + ".plan-moved",
|
||||
"gitea.forge.status", "gitea.forge.pull-request.edited", "gitea.forge.pull-request.closed", ControllerSeat + ".plan-moved",
|
||||
forgePattern, ConfigChangedEvent}
|
||||
|
||||
// forgePattern is the forge bridge's messages on FORGE (novox/hq ADR 0437 §8): the gate follows a merge and the refusal of one.
|
||||
@@ -219,14 +219,7 @@ func take(h *Holder, e stdio.Envelope) error {
|
||||
return nil
|
||||
}
|
||||
h.CommitStatus(s)
|
||||
case ControllerSeat + ".checked":
|
||||
var c CheckedEvent
|
||||
if err := json.Unmarshal(e.Body, &c); err != nil {
|
||||
h.Refuse(fmt.Errorf("a verdict is not readable: %v", err))
|
||||
return nil
|
||||
}
|
||||
h.Checked(c)
|
||||
h.KeepVerdict(c)
|
||||
h.KeepCheckVerdict(s)
|
||||
case ConfigChangedEvent:
|
||||
var c ConfigChange
|
||||
if err := json.Unmarshal(e.Body, &c); err != nil {
|
||||
@@ -354,9 +347,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
}},
|
||||
{Name: seat + "checks",
|
||||
Description: "What the mesh's checks said of a pull request's head or of one commit: each of the commit's " +
|
||||
"statuses (mesh/check, mesh/delivery, …; hq's mesh/repo-check) with its state, description and when it " +
|
||||
"was set; the merge check's full verdict as the controller said it — each layer's summary, the machine " +
|
||||
"that ran it, when, its build id and its report; and whether the branch's protection would let it " +
|
||||
"statuses (mesh/check, mesh/delivery, …) with its state, description and when it " +
|
||||
"was set; the merge check's verdict as the checker's mesh/check line says it, and when it was heard; and whether the branch's protection would let it " +
|
||||
"merge, every required status being success.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"repository": str("owner/repository"),
|
||||
"number": str("a pull request's number: its head is read"),
|
||||
@@ -389,8 +381,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
return h.Stalled(), nil
|
||||
}},
|
||||
{Name: seat + "recheck",
|
||||
Description: "Check a rejected or ready delivery again: its merge check statuses put back to pending on the forge, " +
|
||||
"so nothing merges on the old verdict; proposed again, and its check asked. With why.",
|
||||
Description: "Refused for every delivery: the checker checks a head again on every move of the head, its trunk or its " +
|
||||
"group, and nothing is asked of anyone here (novox/hq ADR 0447, issue 1318). Kept so a caller is told where to read.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("the delivery's id"),
|
||||
"why": str("why, kept with the transition")}, "required": []string{"id", "why"}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
|
||||
@@ -72,6 +72,11 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
if len(m.Claims[0].Serves) != 13 {
|
||||
t.Fatalf("serves %d verbs, wanted 13: %v", len(m.Claims[0].Serves), m.Claims[0].Serves)
|
||||
}
|
||||
for _, never := range []string{"mesh-controller.checked", "seat:mesh-controller.delivery-check", "seat:mesh-controller.queue"} {
|
||||
if slices.Contains(m.Consumes, never) || slices.Contains(m.Invokes, never) {
|
||||
t.Errorf("the manifest still names %s (issue 1318)", never)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(m.Consumes, followed) || !slices.Contains(m.Consumes, "mesh-delivery-forge-bridge.forge.**") {
|
||||
t.Fatalf("consumes %v, follows %v (the bridge's forge messages among them)", m.Consumes, followed)
|
||||
}
|
||||
@@ -89,11 +94,11 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
"mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts", "mesh-controller.assignments", "node-engine.have"}) {
|
||||
t.Fatalf("reads %v", m.Reads)
|
||||
}
|
||||
// It calls exactly the controller's seven verbs (ADR 0450 §7; queue since issue 1274, forget-checks no more since issue 1318) and reads the forge with
|
||||
// It calls exactly the controller's five verbs (ADR 0450 §7; no delivery-check, no queue and no forget-checks since issue 1318, record D5) and reads the forge with
|
||||
// three tools; the forge's
|
||||
// writes are the bridge's (ADR 0437 §8). It uses no operator-channel: the teller asks (ADR 0437 §6).
|
||||
want := []string{}
|
||||
for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-check", "queue", "plans"} {
|
||||
for _, v := range []string{"deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "plans"} {
|
||||
want = append(want, "seat:"+ControllerSeat+"."+v)
|
||||
}
|
||||
want = append(want, "gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
|
||||
|
||||
@@ -109,10 +109,9 @@ func verdictOfCheck(state, desc string, now time.Time) *Verdict {
|
||||
Status: ctxCheck}
|
||||
}
|
||||
|
||||
// judgedByCheck is whether the delivery's base requires mesh/check, as the forge said it, or its verdict is mesh/check's.
|
||||
func (d *Delivery) judgedByCheck() bool {
|
||||
return slices.Contains(d.Required, ctxCheck) || (d.Check != nil && d.Check.Status == ctxCheck)
|
||||
}
|
||||
// judgedByCheck is whether the checker judges the delivery: always. Every base requires mesh/check since hq's did (ADR 0527
|
||||
// §2), and the controller judges no pull request any more (ADR 0524 §5), so nothing is asked of it (issue 1318).
|
||||
func (d *Delivery) judgedByCheck() bool { return true }
|
||||
|
||||
// noteRequired keeps what the forge said the delivery's base requires. Under the lock.
|
||||
func (h *Holder) noteRequired(d *Delivery, required []string) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -229,17 +228,3 @@ func TestTheCheckLineGivesHeadAndTrunk(t *testing.T) {
|
||||
t.Fatalf("a line that names no commits gave %q %q", head, trunk)
|
||||
}
|
||||
}
|
||||
|
||||
// 1302 with issue 1274's lost checks: a head on mesh/check never awaits a controller's check, so the build queue is not
|
||||
// read for it and nothing is asked again.
|
||||
func TestAHeadOnMeshCheckAwaitsNoControllersCheck(t *testing.T) {
|
||||
at := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
|
||||
d := &Delivery{State: Proposed, Number: 1, CheckAsk: &CheckAsk{ID: "check-1", At: at}}
|
||||
if _, ok := awaitedSince(d); !ok {
|
||||
t.Fatal("the controller's asked check is not awaited")
|
||||
}
|
||||
d.Required = []string{ctxCheck}
|
||||
if _, ok := awaitedSince(d); ok {
|
||||
t.Fatal("a head on mesh/check awaits a controller's check")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ func MemberOf(d *Delivery) Member {
|
||||
type Controller interface {
|
||||
// Plan is the delivery plan of a diffset, kept nowhere.
|
||||
Plan(repository, base, head string, paths, moduleDirs, removed []string) (*DeliveryPlan, error)
|
||||
// Check asks a group's composed check, or — with no group and one member — that head's own check again.
|
||||
Check(group string, members []Member) (string, error)
|
||||
// Deliver lets a waiting walk start.
|
||||
Deliver(walk, why string) error
|
||||
// BackupsAgain asks the controller to take a held walk's failed backups again, and to go on when they pass
|
||||
@@ -64,24 +62,8 @@ type Controller interface {
|
||||
StopWithout(walk, why, by string, without []string) (StopAnswer, error)
|
||||
// Walks are the walks the controller keeps — one, given its id — and whether this seat has a holder on record.
|
||||
Walks(walk string) (bool, []Walk, error)
|
||||
// Queue is every ask the build queue holds, waiting or in flight (the controller's `queue`, novox/hq issue 455): what
|
||||
// tells a check still on its way from one the queue lost (issue 1274). A dead ask is not held.
|
||||
Queue() ([]QueuedAsk, error)
|
||||
}
|
||||
|
||||
// QueuedAsk is one ask of the build queue as the controller's `queue` answers it as data.
|
||||
type QueuedAsk struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"` // waiting, in flight or dead
|
||||
Kind string `json:"kind"` // check or delivery
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
}
|
||||
|
||||
// Holds is whether the ask is still the queue's to run: waiting or in flight, never dead.
|
||||
func (q QueuedAsk) Holds() bool { return q.State != "dead" }
|
||||
|
||||
// Forge is what the forge's holder does for a delivery.
|
||||
type Forge interface {
|
||||
// Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch
|
||||
@@ -240,43 +222,6 @@ func (c seatController) Plan(repository, base, head string, paths, moduleDirs, r
|
||||
return a.Plan, nil
|
||||
}
|
||||
|
||||
func (c seatController) Check(group string, members []Member) (string, error) {
|
||||
args := map[string]any{"members": string(mustJSON(members))}
|
||||
if group != "" {
|
||||
args["group"] = group
|
||||
}
|
||||
raw, err := c.verb("delivery-check", args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a struct {
|
||||
Asked string `json:"asked"`
|
||||
Rechecked string `json:"rechecked"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
if a.Asked != "" {
|
||||
return a.Asked, nil
|
||||
}
|
||||
if a.Rechecked != "" {
|
||||
return a.Rechecked, nil
|
||||
}
|
||||
return "", fmt.Errorf("the controller's delivery-check answered no check id (%s): it is not taken as asked", clip(string(raw), 200))
|
||||
}
|
||||
|
||||
func (c seatController) Queue() ([]QueuedAsk, error) {
|
||||
raw, err := c.verb("queue", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a struct {
|
||||
Asks []QueuedAsk `json:"asks"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("the controller's queue is not readable: %w", err)
|
||||
}
|
||||
return a.Asks, nil
|
||||
}
|
||||
|
||||
func (c seatController) Deliver(walk, why string) error {
|
||||
_, err := c.verb("deliver", map[string]any{"plan": walk, "why": why})
|
||||
return err
|
||||
|
||||
@@ -54,24 +54,35 @@ func (h *Holder) KeepPull(p PullEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// KeepVerdict writes a verdict into the key of its pull request, when the key holds that head and trunk commit.
|
||||
func (h *Holder) KeepVerdict(c CheckedEvent) {
|
||||
if h.Pulls == nil || c.Group != "" || c.Number == 0 || c.Commit == "" || c.Trunk == "" {
|
||||
// KeepCheckVerdict writes the verdict of a head's mesh/check into the key of its pull request, when the key holds that head
|
||||
// and the trunk commit the check line names (issue 1302; the controller's verdict wrote it before issue 1318). Outside the lock.
|
||||
func (h *Holder) KeepCheckVerdict(e ForgeStatusEvent) {
|
||||
if h.Pulls == nil || e.Context != ctxCheck || e.Description == "" || e.SHA == "" {
|
||||
return
|
||||
}
|
||||
verdict := c.Verdict
|
||||
if verdict == "" {
|
||||
verdict := map[string]string{"success": "pass", "warning": "warning", "failure": "fail", "error": "error"}[e.State]
|
||||
_, trunk := checkLine(e.Description)
|
||||
if verdict == "" || trunk == "" {
|
||||
return // pending is no verdict; a line naming no trunk commit is judged against none
|
||||
}
|
||||
h.mu.Lock()
|
||||
number := 0
|
||||
if d := h.deliveries[IDOf(strings.ToLower(e.Owner+"/"+e.Repo), e.SHA)]; d != nil {
|
||||
number = d.Number
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if number == 0 {
|
||||
return
|
||||
}
|
||||
err := h.Pulls.Change(pullKeyName(c.Owner, c.Repo, c.Number), func(k PullKey) (PullKey, bool) {
|
||||
if !sameCommit(k.Head, c.Commit) || !sameCommit(k.Trunk, c.Trunk) || k.Verdict == verdict {
|
||||
err := h.Pulls.Change(pullKeyName(e.Owner, e.Repo, number), func(k PullKey) (PullKey, bool) {
|
||||
if !sameCommit(k.Head, e.SHA) || !sameCommit(k.Trunk, trunk) || k.Verdict == verdict {
|
||||
return k, false
|
||||
}
|
||||
k.Verdict = verdict
|
||||
return k, true
|
||||
})
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", c.Owner, c.Repo, c.Number, err)
|
||||
h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", e.Owner, e.Repo, number, err)
|
||||
h.again("a pull request's key that could not be written")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,33 +71,11 @@ func TestAnAnnouncedHeadWritesTheKeyAndTheSameAnnouncementAgainWritesNothing(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVerdictForTheKeysHeadAndTrunkIsWrittenAndOneForAnotherIsNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
old := judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1")
|
||||
w.h.KeepVerdict(old)
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another head was written")
|
||||
}
|
||||
other := judgedAt("novox/app", 7, readyHead, "pass", "t9t9t9t9t9t9")
|
||||
w.h.KeepVerdict(other)
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another trunk commit was written")
|
||||
}
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if got := pulls.get(appKey); got.Verdict != "pass" {
|
||||
t.Fatalf("the key holds %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.KeepPull(announcedAt(readyHead, "t2t2t2t2t2t2"))
|
||||
if got := pulls.get(appKey); got.Trunk != "t2t2t2t2t2t2" || got.Verdict != "" {
|
||||
t.Fatalf("the check asked again against a new trunk left %+v", got)
|
||||
@@ -108,6 +86,34 @@ func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshCheckForTheKeysHeadAndTrunkIsWrittenAndOneForAnotherIsNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.PullUpdated(announcedAt(readyHead, "a1a1a1a1a1a1"))
|
||||
w.h.KeepPull(announcedAt(readyHead, "a1a1a1a1a1a1"))
|
||||
status := func(head, trunk, state string) ForgeStatusEvent {
|
||||
return ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: head, Context: ctxCheck, State: state,
|
||||
Description: "pass " + head[:12] + " on " + trunk + " | unit 3 run"}
|
||||
}
|
||||
w.h.KeepCheckVerdict(status(pushed, "a1a1a1a1a1a1", "success"))
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another head was written")
|
||||
}
|
||||
w.h.KeepCheckVerdict(status(readyHead, "b2b2b2b2b2b2", "success"))
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a verdict for another trunk commit was written")
|
||||
}
|
||||
w.h.KeepCheckVerdict(status(readyHead, "a1a1a1a1a1a1", "pending"))
|
||||
if pulls.get(appKey).Verdict != "" {
|
||||
t.Fatal("a pending check was written as a verdict")
|
||||
}
|
||||
w.h.KeepCheckVerdict(status(readyHead, "a1a1a1a1a1a1", "success"))
|
||||
if got := pulls.get(appKey); got.Verdict != "pass" {
|
||||
t.Fatalf("the key holds %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
@@ -120,39 +126,6 @@ func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// A check asked for a head whose Approve is asked is on the ready stream (ADR 0406 §3, ADR 0421 §10); one for a head
|
||||
// with no Approve is asked as before.
|
||||
func TestAPullRequestWithItsApproveAskedHasItsCheckAskedOnTheReadyStream(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()
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checkReady) == 0 || !w.ctl.checkReady[0] {
|
||||
t.Fatalf("the check of a pull request whose Approve is asked was asked with ready %v", w.ctl.checkReady)
|
||||
}
|
||||
// One with no Approve asked is asked as before.
|
||||
w2 := newWorld(t)
|
||||
w2.h.PullUpdated(pr("novox/app", 8, pushed, "feat/y", "src/a.go"))
|
||||
w2.settleAll()
|
||||
if len(w2.ctl.checkReady) == 0 || w2.ctl.checkReady[0] {
|
||||
t.Fatalf("the check of a pull request with no Approve asked was asked with ready %v", w2.ctl.checkReady)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrunkLessVerdictWritesNothingToTheKey(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", ""))
|
||||
if got := pulls.get(appKey); got.Verdict != "" {
|
||||
t.Fatalf("a verdict naming no trunk commit was written: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's catch-up after a restart can announce a merge again past the bus's de-duplication window (issue 975):
|
||||
// heard again, it changes nothing, and moves no trunk back.
|
||||
func TestTheSameMergeHeardAgainAfterALaterMergeMovesNoTrunkBack(t *testing.T) {
|
||||
|
||||
@@ -23,9 +23,9 @@ import (
|
||||
// or second Approve); it is asked once for a head, and once for a group's heads together;
|
||||
// - it merges through the forge's merge tool (gitea_merge_pull_request), the one path every merge takes, at the head
|
||||
// the Approve names, handing the tool the heads the Approve covers (ADR 0421 §9);
|
||||
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is asked
|
||||
// again by this owner at once (askFresh), and an Approve already asked stays through a trunk move (ADR 0421 §3): the
|
||||
// merge is asked again on the passing verdict, with no second Approve;
|
||||
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
|
||||
// again by the checker on its trunk's move (ADR 0447 §4), and an Approve already asked stays through a trunk move
|
||||
// (ADR 0421 §3): the merge is asked again on the passing verdict, with no second Approve;
|
||||
// - a group member only once the group's composed check passed for its members' heads as they are;
|
||||
// - a push ends the Approve and says so once on the pull request (ADR 0421 §6); the new head is asked on its own;
|
||||
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
|
||||
@@ -175,9 +175,6 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
if a := h.sayAfterLine(d); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
if a := h.askFresh(d, now); a != nil {
|
||||
asks = append(asks, a)
|
||||
}
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || r.Declined != nil {
|
||||
continue
|
||||
@@ -293,50 +290,6 @@ func (h *Holder) sayAfterLine(d *Delivery) func() {
|
||||
"the order of a delivery comes from the module graph alone, and nothing asks for the line (novox/hq ADR 0419).")
|
||||
}
|
||||
|
||||
// askFresh is the ask of a head's check against its trunk's head, when its passing verdict is out of date and either
|
||||
// arrived so, or has an Approve asked or given for it (novox/hq ADR 0421 §3, issue 1022; issue 1047 for a group's
|
||||
// member): the Approve stays through a trunk move, and the merge is asked again on the verdict this brings, with no
|
||||
// second Approve. Nil when nothing needs asking. Under the lock; the ask runs outside it.
|
||||
func (h *Holder) askFresh(d *Delivery, now time.Time) func() {
|
||||
if d.State != Proposed || d.outOfDate() == "" || d.AskedAgain != nil || !d.Check.Passes() || d.Base != MergeBranch ||
|
||||
d.Number == 0 || d.TrunkNow == "" || d.judgedByCheck() {
|
||||
return nil // a head on mesh/check is the checker's to judge again on its trunk's move (ADR 0447 §4)
|
||||
}
|
||||
// A group's member too (issue 1047, design 47: a move of the trunk asks every check again): the group's one Approve is
|
||||
// asked after its composed check, so a member ready before that has no Approve to carry; its check is asked at once
|
||||
// all the same, and the composed check follows on the new verdict. The rest of ADR 0395 §1 stays with issue 888.
|
||||
if !d.FreshWanted && h.covering(d) == nil && h.groups[d.Group] == nil {
|
||||
return nil
|
||||
}
|
||||
if tried, was := h.askTried[d.ID]; was && now.Sub(tried) < time.Minute {
|
||||
h.lookAt(tried.Add(time.Minute), "a check against the trunk's head that could not be asked")
|
||||
return nil
|
||||
}
|
||||
h.askTried[d.ID] = now
|
||||
h.lookAt(now.Add(time.Minute), "a check against the trunk's head that could not be asked")
|
||||
id, m := d.ID, MemberOf(d)
|
||||
m.BaseSHA = d.TrunkNow
|
||||
return func() {
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s's check against the trunk's head cannot be asked yet: %v", id, err)
|
||||
return
|
||||
}
|
||||
delete(h.askTried, id)
|
||||
d := h.deliveries[id]
|
||||
if d == nil || d.State != Proposed || d.outOfDate() == "" {
|
||||
return
|
||||
}
|
||||
t := h.Now()
|
||||
d.AskedAgain, d.FreshWanted = &t, false
|
||||
h.Logf("[mesh-delivery] %s: its verdict is out of date; its check against the trunk's head %s asked as %s", id,
|
||||
shortOf(d.TrunkNow, 12), asked)
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeAgainNow lets an approved delivery's merge be asked at once: something that decides whether it may merge happened (a
|
||||
// verdict, an Approve, a group's composed check), so the wait after a refusal does not hold it. Under the lock.
|
||||
func (h *Holder) mergeAgainNow(d *Delivery) {
|
||||
|
||||
@@ -186,9 +186,6 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 || w.op.count() != 0 {
|
||||
t.Fatalf("a member was merged or asked before its group's composed check passed: %v, %d", w.forge.merges, w.op.count())
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || len(w.forge.merges) != 0 {
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A recheck puts the merge check's statuses back to pending before it asks the check: the forge keeps the
|
||||
// newest status of each context on the head, and a recheck asks of that same head, so the old verdict would
|
||||
// stand — and branch protection would let the pull request merge on it — until the fresh one lands.
|
||||
|
||||
// heldGreen is a head whose merge check passed, as the forge's holder set it from the verdict.
|
||||
func heldGreen(t *testing.T, w *world, repo string, number int) string {
|
||||
t.Helper()
|
||||
w.h.PullUpdated(pr(repo, number, head, "feat/x", "src/a.go"))
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
_ = w.forge.Status(owner, name, head, ctxGate, "success", "pass: the gate said pass", "")
|
||||
_ = w.forge.Status(owner, name, head, ctxRepo, "success", "pass: merge-check.sh passed", "")
|
||||
w.forge.required = []string{ctxGate, ctxRepo}
|
||||
w.h.Checked(verdict(repo, number, head, "pass"))
|
||||
w.settleAll()
|
||||
id := IDOf(repo, head)
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("a passing verdict left it %s", s)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestARecheckPutsTheMergeCheckBackToPendingBeforeItAsks(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := heldGreen(t, w, "novox/app", 7)
|
||||
at := "novox/app@" + head + " "
|
||||
var whenAsked map[string]string
|
||||
w.ctl.onCheck = func() {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
whenAsked = map[string]string{ctxGate: w.forge.statuses[at+ctxGate], ctxRepo: w.forge.statuses[at+ctxRepo]}
|
||||
}
|
||||
if _, err := w.h.Recheck(id, "main moved on", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("its check was asked %v", w.ctl.checks)
|
||||
}
|
||||
for _, c := range []string{ctxGate, ctxRepo} {
|
||||
if !strings.HasPrefix(whenAsked[c], "pending checking again: main moved on") {
|
||||
t.Fatalf("as its check was asked, the forge held %s %q: the old verdict still lets it merge", c, whenAsked[c])
|
||||
}
|
||||
}
|
||||
checks, err := w.forge.Statuses("novox", "app", 0, head, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checks.Merge.Mergeable == nil || *checks.Merge.Mergeable {
|
||||
t.Fatalf("rechecked, the forge would still merge it: %+v", checks.Merge)
|
||||
}
|
||||
if s := w.state(id); s != Proposed {
|
||||
t.Fatalf("rechecked, it is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the statuses the head holds are put back: a verdict that says no repository check would leave a
|
||||
// pending one standing for ever.
|
||||
func TestARecheckLeavesAStatusTheHeadNeverHadAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 8, head, "feat/y", "src/a.go"))
|
||||
_ = w.forge.Status("novox", "app", head, ctxGate, "failure", "fail: the gate said fail", "")
|
||||
w.h.Checked(verdict("novox/app", 8, head, "fail"))
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", head)
|
||||
if _, err := w.h.Recheck(id, "flaky", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := w.forge.statuses["novox/app@"+head+" "+ctxGate]; !strings.HasPrefix(s, "pending checking again: flaky") {
|
||||
t.Fatalf("the gate is %q", s)
|
||||
}
|
||||
if s, set := w.forge.statuses["novox/app@"+head+" "+ctxRepo]; set {
|
||||
t.Fatalf("a repository check the head never had was set: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A bus that cannot take the pending statuses refuses the recheck whole: nothing is asked on a stale verdict, and the
|
||||
// delivery stays where it stood, for the person to ask again.
|
||||
func TestARecheckTheBusCannotTakeIsRefused(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := heldGreen(t, w, "novox/app", 9)
|
||||
w.owed.down = true
|
||||
if _, err := w.h.Recheck(id, "main moved on", "a person"); err == nil {
|
||||
t.Fatal("a recheck whose statuses could not be put back was taken")
|
||||
}
|
||||
if len(w.ctl.checks) != 0 {
|
||||
t.Fatalf("its check was asked on a stale verdict: %v", w.ctl.checks)
|
||||
}
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("refused, it moved to %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 867, ADR 0323 §6: "the composed check runs again when a member is rechecked". A group whose composed
|
||||
// check failed stayed rejected after a member was rechecked and passed, because the kept verdict was for the same heads
|
||||
// and nothing marked it out of date.
|
||||
func TestAMembersRecheckAsksTheGroupsComposedCheckAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z"))
|
||||
w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z"))
|
||||
w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass"))
|
||||
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
|
||||
w.settleAll()
|
||||
groupAsks := func() (n int) {
|
||||
for _, c := range w.ctl.checks {
|
||||
if strings.HasPrefix(c, "feat/z:") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if groupAsks() != 1 {
|
||||
t.Fatalf("the group was asked %v", w.ctl.checks)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "they do not compose"})
|
||||
w.settleAll()
|
||||
if state, _ := GroupState(w.h.groups["feat/z"], w.h.membersOf(w.h.groups["feat/z"])); state != "rejected" {
|
||||
t.Fatalf("the group is %s", state)
|
||||
}
|
||||
if _, err := w.h.Recheck(IDOf("novox/b", "b2b2b2b2b2b2b2"), "the judge was fixed", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The member's own check passes again, at the same head, a while later.
|
||||
w.later(5 * time.Minute)
|
||||
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
|
||||
w.settleAll()
|
||||
if groupAsks() != 2 {
|
||||
t.Fatalf("a member's recheck did not ask the group again: %v", w.ctl.checks)
|
||||
}
|
||||
if g := w.h.groups["feat/z"]; g.Check == nil || g.Check.Verdict != "" || g.Check.Asked == "check-1" {
|
||||
t.Fatalf("the old verdict still stands: %+v", g.Check)
|
||||
}
|
||||
}
|
||||
|
||||
// A recheck made while the group's composed check is still running is not lost: the old run's answer lands, and the
|
||||
// group is still asked again once the member is ready.
|
||||
func TestARecheckWhileTheComposedCheckRunsAsksTheGroupAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z"))
|
||||
w.h.PullUpdated(pr("novox/b", 2, "b2b2b2b2b2b2b2", "feat/z"))
|
||||
w.h.Checked(verdict("novox/a", 1, "a1a1a1a1a1a1a1", "pass"))
|
||||
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
|
||||
w.settleAll() // the composed check check-1 is asked and running: no verdict yet
|
||||
if _, err := w.h.Recheck(IDOf("novox/b", "b2b2b2b2b2b2b2"), "the judge was fixed", "a person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The old run answers.
|
||||
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "they do not compose"})
|
||||
w.later(5 * time.Minute)
|
||||
w.h.Checked(verdict("novox/b", 2, "b2b2b2b2b2b2b2", "pass"))
|
||||
w.settleAll()
|
||||
n := 0
|
||||
for _, c := range w.ctl.checks {
|
||||
if strings.HasPrefix(c, "feat/z:") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("the group was not asked again after a recheck made while its check ran: %v", w.ctl.checks)
|
||||
}
|
||||
if g := w.h.groups["feat/z"]; g.Check == nil || g.Check.Verdict != "" || g.Check.OutOfDate != "" {
|
||||
t.Fatalf("the old run's answer stands: %+v", g.Check)
|
||||
}
|
||||
// The old run answering again is history.
|
||||
w.h.Checked(CheckedEvent{Group: "feat/z", ID: "check-1", Verdict: "fail", Summary: "late"})
|
||||
if g := w.h.groups["feat/z"]; g.Check.Verdict != "" {
|
||||
t.Fatalf("a late answer of the old run was taken: %+v", g.Check)
|
||||
}
|
||||
}
|
||||
@@ -27,23 +27,13 @@ func TestALookThatMeetsNothingWaitingOnTheClockWantsNoOtherLook(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProposedDeliveryWithNoVerdictHasItsCheckAskedAtOnceWithNoGrace(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.ctl.down = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.React()
|
||||
if w.delivery(id).CheckAsk == nil || len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("the first look did not ask the check: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProposedDeliveryWhoseHeadHoldsAPassingVerdictAtTheForgeAdoptsItAndAsksNoCheck(t *testing.T) {
|
||||
func TestAProposedDeliveryWhoseHeadHoldsAPassingMeshCheckAtTheForgeAdoptsItAndAsksNoCheck(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
op := openPull("novox/app", 7, readyHead)
|
||||
op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}, "mesh/repo-check": {State: "success"}}
|
||||
op.Required = []string{ctxCheck}
|
||||
op.Checks = map[string]HeadCheck{ctxCheck: {State: "success", Description: "pass " + readyHead[:12] + " | the checker's"}}
|
||||
w.forge.open = []OpenPull{op}
|
||||
w.h.React()
|
||||
w.settleAll()
|
||||
@@ -142,71 +132,3 @@ func TestAnAskTheControllerCouldNotAnswerIsAskedAgainAfterAWhileByTheTimerAlone(
|
||||
t.Fatalf("the look wanted at %s for %q; wanted %s for the plan that could not be asked", at, step, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStandingGateWithNoRepoCheckIsNotAdoptedAndTheCheckIsAsked(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
op := openPull("novox/app", 7, readyHead)
|
||||
op.Required = []string{"mesh/merge-gate", "mesh/repo-check"}
|
||||
op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}}
|
||||
w.forge.open = []OpenPull{op}
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.Check != nil || d.State != Proposed {
|
||||
t.Fatalf("a gate with no repo-check was adopted: %+v (%s)", d.Check, d.State)
|
||||
}
|
||||
if len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("its check was not asked: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeptAskWithNoIdIsNoAskAndAStandingVerdictIsAdopted(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.mu.Lock()
|
||||
w.h.deliveries[id].CheckAsk = &CheckAsk{At: w.now, By: byOwner}
|
||||
w.h.mu.Unlock()
|
||||
op := openPull("novox/app", 7, readyHead)
|
||||
op.Required = []string{"mesh/merge-gate", "mesh/repo-check"}
|
||||
op.Checks = map[string]HeadCheck{"mesh/merge-gate": {State: "success"}, "mesh/repo-check": {State: "success"}}
|
||||
w.forge.open = []OpenPull{op}
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.State != Ready || d.Check == nil {
|
||||
t.Fatalf("the standing verdict was not adopted over an ask with no id: %s", d.State)
|
||||
}
|
||||
if len(w.ctl.checks) != 0 || w.op.count() != 1 {
|
||||
t.Fatalf("checks %v, Approves asked %d; wanted none and one", w.ctl.checks, w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeptAskWithNoIdAndNoStandingVerdictIsAskedAgainAtOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.mu.Lock()
|
||||
w.h.deliveries[id].CheckAsk = &CheckAsk{At: w.now, By: byOwner}
|
||||
w.h.mu.Unlock()
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); d.CheckAsk == nil || d.CheckAsk.ID == "" || len(w.ctl.checks) != 1 {
|
||||
t.Fatalf("not asked again: %+v %v", d.CheckAsk, w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyAnswerToACheckAskIsARefusalAskedAtMostOnceAMinute(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.ctl.emptyCheck = true
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 1 || w.delivery(id).CheckAsk != nil || len(w.forge.notesOn(readyHead)) > 1 {
|
||||
t.Fatalf("asked %v, ask kept %v; wanted one ask and none kept", w.ctl.checks, w.delivery(id).CheckAsk)
|
||||
}
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) != 2 {
|
||||
t.Fatalf("not asked again after the minute: %v", w.ctl.checks)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,98 +290,16 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
return out
|
||||
}
|
||||
|
||||
// Recheck is the `recheck` verb: a rejected or ready delivery proposed again, and its check asked again.
|
||||
//
|
||||
// A recheck asks of the head the forge already judged, and the forge keeps the newest status of each
|
||||
// context on it: left alone, the old verdict stands, and a branch that requires it lets the pull request merge
|
||||
// on it while the fresh check runs. So the merge check's statuses are put back to pending first — by the
|
||||
// forge's holder, which keeps them — and only then is the check asked. A forge that cannot be told refuses the
|
||||
// recheck whole: the delivery stays where it stood, and nothing is asked on a stale verdict.
|
||||
// Recheck is the `recheck` verb. The checker judges every head and checks it again on every move of its trunk (ADR 0447 §4), and
|
||||
// the controller judges none (ADR 0524 §5), so there is no ask to make: a delivery that exists is refused, naming the checker
|
||||
// (the lead's decision for issue 1302), and an unknown id is refused as before.
|
||||
func (h *Holder) Recheck(id, why, by string) (string, error) {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
h.mu.Unlock()
|
||||
defer h.mu.Unlock()
|
||||
if h.deliveries[id] == nil {
|
||||
return "", fmt.Errorf("no delivery %q", id)
|
||||
}
|
||||
if d.judgedByCheck() {
|
||||
h.mu.Unlock()
|
||||
return "", checkerRefusal(id) // the lead's decision for issue 1302: the checker serves no ask
|
||||
}
|
||||
if d.State == Proposed {
|
||||
// A proposed delivery's check is asked again when the build queue holds none of its head (issue 1274).
|
||||
h.mu.Unlock()
|
||||
return h.recheckProposed(id, why, by)
|
||||
}
|
||||
// Whether the table takes it, tried on a copy: nothing is said on the forge for a recheck it refuses.
|
||||
probe := *d
|
||||
probe.Transitions = append([]Transition(nil), d.Transitions...)
|
||||
if _, err := Apply(&probe, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil {
|
||||
h.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
owner, repo, commit, page, number := d.Owner(), d.Repo(), d.Commit, d.HTMLURL, d.Number
|
||||
h.mu.Unlock()
|
||||
if err := h.checkingAgain(owner, repo, number, commit, page, why); err != nil {
|
||||
return "", fmt.Errorf("%s is not rechecked: %v; its merge check must say pending before it is asked again, "+
|
||||
"so nothing merges on the old verdict — ask again once the forge answers", id, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
t, err := Apply(d, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by})
|
||||
if err != nil {
|
||||
h.mu.Unlock()
|
||||
return "", fmt.Errorf("%v (its merge check says pending; the forge's next announcement asks it)", err)
|
||||
}
|
||||
d.Check = nil
|
||||
// The composed check of its group goes out of date (novox/hq ADR 0323 §6, issue 867): it was judged for these very
|
||||
// heads (running or answered: a run's answer is taken by its id and must not stand for the recheck), so without this the kept verdict (a failure above all) stands and the group is never asked again. It is
|
||||
// asked again once the member is judged again, when every member is ready.
|
||||
if g := h.groups[d.Group]; g != nil && g.Check != nil {
|
||||
now := h.Now()
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was rechecked (%s)", d.ID, why)
|
||||
g.Check.OutOfDateAt = now
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
m := MemberOf(d)
|
||||
h.mu.Unlock()
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s is proposed again and its check could not be asked yet (%v); the forge's next "+
|
||||
"announcement asks it", id, err)
|
||||
}
|
||||
return fmt.Sprintf("%s is proposed again, its merge check pending; its check was asked (%s)", id, asked), nil
|
||||
}
|
||||
|
||||
// checkingAgain puts a head's merge check statuses — those a branch's protection may require — back to
|
||||
// pending, so the forge refuses the merge until the fresh verdict sets them. The gate always, since every
|
||||
// verdict sets it; the repository's own check only when the head holds one, since a verdict that says none
|
||||
// would leave a pending one standing for ever. Asked of the forge's holder, which keeps them; it takes
|
||||
// pending on them and nothing else, a verdict being the controller's alone (novox/hq issue 293).
|
||||
func (h *Holder) checkingAgain(owner, repo string, number int, commit, page, why string) error {
|
||||
if h.Forge == nil {
|
||||
return errors.New("no forge")
|
||||
}
|
||||
// The statuses are owed as keys the forge bridge performs, on the pull request number the delivery has.
|
||||
contexts := []string{ctxGate, ctxRepo}
|
||||
if held, err := h.Forge.Statuses(owner, repo, 0, commit, ""); err == nil {
|
||||
contexts = []string{ctxGate}
|
||||
for _, s := range held.Statuses {
|
||||
if s.Context == ctxRepo {
|
||||
contexts = append(contexts, ctxRepo)
|
||||
}
|
||||
}
|
||||
}
|
||||
line := clip(oneLine("checking again: "+why), 140)
|
||||
for _, c := range contexts {
|
||||
act, value, num, head := owedOf(Effect{Kind: EffectStatus, Commit: commit, Context: c, State: "pending", Line: line},
|
||||
owner+"/"+repo, number, commit, "", page)
|
||||
if err := h.owe(owner+"/"+repo, num, head, act, value); err != nil {
|
||||
return fmt.Errorf("%s could not be owed as pending on %s: %w", c, shortOf(commit, 8), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return "", checkerRefusal(id)
|
||||
}
|
||||
|
||||
// Release is the `release` verb: a held delivery goes on, on a person's word.
|
||||
@@ -670,42 +588,11 @@ func (h *Holder) Close(id, why string) (string, error) {
|
||||
return fmt.Sprintf("%s: %s → %s, as its walk's record says (%s)", id, was, d.State, why), nil
|
||||
}
|
||||
|
||||
// reask is H2's close of a proposed delivery past its bound: its check, asked and not answered, asked once
|
||||
// more — the table's re-ask, which holds only once. The row is tried first on a copy, so an ask the controller
|
||||
// does not take spends nothing. Called under the lock; it unlocks.
|
||||
// reask is H2's close of a proposed delivery past its bound. A head's check is the checker's, which asks nothing of anyone
|
||||
// and checks again on its own (ADR 0447 §4), so there is no check to ask once more. Called under the lock; it unlocks.
|
||||
func (h *Holder) reask(d *Delivery, why string) (string, error) {
|
||||
id := d.ID
|
||||
if d.judgedByCheck() {
|
||||
h.mu.Unlock()
|
||||
return "", checkerRefusal(id)
|
||||
}
|
||||
trial := *d
|
||||
trial.Transitions = nil
|
||||
if _, err := Apply(&trial, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why}); err != nil {
|
||||
h.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
m := MemberOf(d)
|
||||
h.mu.Unlock()
|
||||
asked, err := h.Controller.Check("", []Member{m})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s's check could not be re-asked yet: %w", id, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d = h.deliveries[id]
|
||||
if d == nil {
|
||||
return "", fmt.Errorf("no delivery %q", id)
|
||||
}
|
||||
t, err := Apply(d, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why + " — re-asked as " + asked})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d.CheckAsk.Reasked++
|
||||
d.CheckAsk.ID, d.CheckAsk.At, d.CheckAsk.By, d.CheckAsk.Why = asked, h.Now(), byH2, why
|
||||
h.moved(d, []Transition{t})
|
||||
return fmt.Sprintf("%s: its check re-asked as %s, once (%s); if that is not answered either, the operator's", id,
|
||||
asked, why), nil
|
||||
return "", checkerRefusal(d.ID)
|
||||
}
|
||||
|
||||
// WhatIf is the `what-if` verb.
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"gitea.forge.status",
|
||||
"gitea.forge.pull-request.edited",
|
||||
"gitea.forge.pull-request.closed",
|
||||
"mesh-controller.checked",
|
||||
"mesh-controller.plan-moved",
|
||||
"mesh-delivery-forge-bridge.forge.**",
|
||||
"mesh-config.config-changed"
|
||||
@@ -45,8 +44,6 @@
|
||||
"seat:mesh-controller.delivery-stop",
|
||||
"seat:mesh-controller.delivery-backups-again",
|
||||
"seat:mesh-controller.delivery-walks",
|
||||
"seat:mesh-controller.delivery-check",
|
||||
"seat:mesh-controller.queue",
|
||||
"seat:mesh-controller.plans",
|
||||
"gitea.gitea_commit_statuses",
|
||||
"gitea.gitea_contains",
|
||||
|
||||
Reference in New Issue
Block a user