GATE A-24, §172, §431, §592, §593, §613: the gate keeps no merge states; deliveries, show, groups and stalled answer the delivery records
Removes holder, table, approval, ready, onestatus, pullkey, checks, group_merge, the effects, the forge reads, the teller's and the view's reads, kvOperator, kvPulls, delivery_merge_again and their tests. deliveries, show, groups and stalled answer the records as kept (stalled: a record open past a day, and an act owed unanswered past 15 minutes, read from the record's acts); the switch reads the old section deliveries and says approval-carried for each unperformed merges key. The manifest drops pulls, asks, reads, the forge, teller and view consumes and invokes, and the events group and merged. Builds: design 47 G §172, §431, §592, §593, §613, A-24, A-35 (G-3, G-9, G-16, G-17, G-20) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
cat = "novox/mesh-catalog"
|
||||
headCC = "cc00000000000001"
|
||||
headDu = "dd00000000000002"
|
||||
)
|
||||
|
||||
var atCC = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// A pull request closed unmerged is closed. Since issue 1318 (ADR 0503 §3) the controller is no longer asked to let go
|
||||
// of what its checks built, and since ADR 0539 §17 the gate asks it nothing at all.
|
||||
func TestAPullRequestClosedUnmergedIsClosedAndAsksNoOne(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), atCC)
|
||||
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
|
||||
w.h.PullUpdated(cc)
|
||||
w.h.PullClosed(cc)
|
||||
if s := w.state(IDOf(cat, headCC)); s != Closed {
|
||||
t.Fatalf("closed unmerged it is %s", s)
|
||||
}
|
||||
du := pr(cat, 102, headDu, "feat/dunst", "modules/dunst/module.json")
|
||||
w.h.PullUpdated(du)
|
||||
w.h.PullClosed(du)
|
||||
if s := w.state(IDOf(cat, headDu)); s != Closed {
|
||||
t.Fatalf("a closed delivery is %s", s)
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The tests of novox/hq ADR 0505 "How it is checked" for the gate: a refusal the gate is already answering is no
|
||||
// stall yet (§4), and a delivery whose cure is its author's new head is its author's for two hours (§5).
|
||||
|
||||
// ADR 0505 §4, §5: the two bounds are settings with their decided defaults.
|
||||
func TestTheBoundsAreSettingsWithTheirDefaults(t *testing.T) {
|
||||
read := func(body string) (Settings, error) {
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return readSettings(p)
|
||||
}
|
||||
h := &Holder{}
|
||||
if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour {
|
||||
t.Fatalf("no settings: floor %s, author %s", h.refusalFloor(), h.authorBound())
|
||||
}
|
||||
s, err := read(`{"refusal-stall-floor-minutes": 5, "author-new-head-minutes": 60}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.Settings = func() Settings { return s }
|
||||
if h.refusalFloor() != 5*time.Minute || h.authorBound() != time.Hour {
|
||||
t.Fatalf("set: floor %s, author %s", h.refusalFloor(), h.authorBound())
|
||||
}
|
||||
s, _ = read(`{}`)
|
||||
if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour {
|
||||
t.Fatalf("empty file: floor %s, author %s", h.refusalFloor(), h.authorBound())
|
||||
}
|
||||
if _, err := read(`{"author-new-head-minutes": 0}`); err == nil {
|
||||
t.Fatalf("a bound of zero minutes was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0505 §4: a repository's last measured check time is its newest verdict's (`took`, as mesh-controller
|
||||
// cmd/mesh-builder main.go writes it: a duration rounded to the second); none, and the floor alone holds.
|
||||
func TestTheLastMeasuredCheckTimeIsTheNewestVerdictsOfItsRepository(t *testing.T) {
|
||||
h := &Holder{}
|
||||
h.init()
|
||||
at := time.Date(2026, 10, 22, 10, 0, 0, 0, time.UTC)
|
||||
h.deliveries["novox/mesh-catalog@a"] = &Delivery{ID: "novox/mesh-catalog@a", Repository: "novox/mesh-catalog",
|
||||
Check: &Verdict{At: at, Took: "4m0s"}}
|
||||
h.deliveries["novox/mesh-catalog@b"] = &Delivery{ID: "novox/mesh-catalog@b", Repository: "novox/mesh-catalog",
|
||||
Check: &Verdict{At: at.Add(time.Hour), Took: "7m30s"}}
|
||||
h.deliveries["novox/hq@c"] = &Delivery{ID: "novox/hq@c", Repository: "novox/hq",
|
||||
Check: &Verdict{At: at.Add(2 * time.Hour), Took: "1m0s"}}
|
||||
if got := h.lastCheckTime("novox/mesh-catalog"); got != 7*time.Minute+30*time.Second {
|
||||
t.Fatalf("the newer of two: %s", got)
|
||||
}
|
||||
if got := h.answeredRefusalBound("novox/mesh-catalog"); got != 15*time.Minute {
|
||||
t.Fatalf("twice 7m30s: %s", got)
|
||||
}
|
||||
if got := h.answeredRefusalBound("novox/hq"); got != 10*time.Minute {
|
||||
t.Fatalf("twice one minute is under the floor: %s", got)
|
||||
}
|
||||
if got := h.answeredRefusalBound("novox/mesh-sdk"); got != 10*time.Minute {
|
||||
t.Fatalf("no verdict: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// refusedForAMovedTrunk is a lone delivery approved and refused by the merge tool because its trunk moved and its check
|
||||
// was asked again (gitea merge.ts:147, errAskedAgain).
|
||||
func refusedForAMovedTrunk(t *testing.T) (*world, string) {
|
||||
t.Helper()
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errAskedAgain
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 1 || w.delivery(id).Ready.RefusedSince == nil {
|
||||
t.Fatalf("the merge was not asked and refused: %v %+v", w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
return w, id
|
||||
}
|
||||
|
||||
func linesFor(w *world, id string) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == id {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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)
|
||||
w.later(9 * time.Minute)
|
||||
if got := linesFor(w, id); len(got) != 0 {
|
||||
t.Fatalf("said before the floor: %+v", got)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
if got := linesFor(w, id); len(got) != 1 {
|
||||
t.Fatalf("not said past the floor of ten minutes: %+v", got)
|
||||
}
|
||||
|
||||
w, id = refusedForAMovedTrunk(t)
|
||||
w.h.mu.Lock()
|
||||
w.h.deliveries[id].Check.Took = "7m0s" // its repository's last check ran seven minutes: the bound is fourteen
|
||||
w.h.mu.Unlock()
|
||||
w.later(11 * time.Minute)
|
||||
if got := linesFor(w, id); len(got) != 0 {
|
||||
t.Fatalf("said at eleven minutes, under twice the last check time: %+v", got)
|
||||
}
|
||||
w.later(4 * time.Minute)
|
||||
if got := linesFor(w, id); len(got) != 1 {
|
||||
t.Fatalf("not said past twice the last check time: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0505 §4: any other refusal is said at once, as issue 1115 has it.
|
||||
func TestAnyOtherRefusalIsStalledAtOnce(t *testing.T) {
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = refusedWords("novox/app#7")
|
||||
approve(w)
|
||||
if got := linesFor(w, id); len(got) != 1 {
|
||||
t.Fatalf("a missing status refusal is not said at once: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// rejectedBeforeItsAnswer is a delivery approved and then rejected: its check failed after the ask (as in events_test.go),
|
||||
// its pull request opened by author as the forge's record says, when given.
|
||||
func rejectedBeforeItsAnswer(t *testing.T, author string) (*world, string) {
|
||||
t.Helper()
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Author = author
|
||||
w.h.PullUpdated(p)
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
// 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, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
// A check that fails before the operator answers withdraws the ask (novox/hq ADR 0661 §5, ADR 0441 §1).
|
||||
if open := w.op.open(); len(open) != 0 {
|
||||
t.Fatalf("the ask stands after its check failed: %v", open)
|
||||
}
|
||||
w.h.React()
|
||||
if w.state(id) != Rejected {
|
||||
t.Fatalf("not rejected: %s", w.state(id))
|
||||
}
|
||||
return w, id
|
||||
}
|
||||
|
||||
// ADR 0505 §5: a rejected delivery is told on its pull request at once, and reaches the operator only after two hours
|
||||
// with no new head, in words naming its author (its branch, and the session that pushed it) and needing a new head —
|
||||
// never "stop it", never "the operator's".
|
||||
func TestARejectedDeliveryIsItsAuthorsForTwoHours(t *testing.T) {
|
||||
w, id := rejectedBeforeItsAnswer(t, "quiet-deliveries")
|
||||
said := false
|
||||
for _, c := range w.forge.comments {
|
||||
said = said || strings.Contains(c, "not merged")
|
||||
}
|
||||
if !said {
|
||||
t.Fatalf("its author was not told on the pull request at once: %v", w.forge.comments)
|
||||
}
|
||||
w.later(2*time.Hour - time.Minute)
|
||||
if got := linesFor(w, id); len(got) != 0 {
|
||||
t.Fatalf("the operator's before two hours: %+v", got)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
got := linesFor(w, id)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("not the operator's after two hours with no new head: %+v", got)
|
||||
}
|
||||
l := got[0]
|
||||
if l.Needs != NeedsNewHead || l.Author != "the author of feat/x (quiet-deliveries)" || l.Branch != "feat/x" {
|
||||
t.Fatalf("the line does not name its author and the new head it needs: %+v", l)
|
||||
}
|
||||
for _, never := range []string{"operator's", "stop it"} {
|
||||
if strings.Contains(l.Says+l.H2, never) {
|
||||
t.Fatalf("the line says %q: %+v", never, l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(l.Says, "needs a new head from the author of feat/x (quiet-deliveries)") {
|
||||
t.Fatalf("the words do not say a new head is needed from its author: %q", l.Says)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0505 §5: with no author in the forge's record, the branch alone names its author.
|
||||
func TestARejectedDeliveryWithNoAuthorIsNamedByItsBranch(t *testing.T) {
|
||||
w, id := rejectedBeforeItsAnswer(t, "")
|
||||
w.later(2*time.Hour + time.Minute)
|
||||
if got := linesFor(w, id); len(got) != 1 || got[0].Author != "the author of feat/x" {
|
||||
t.Fatalf("not named by its branch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0505 §5: a new head within the two hours ends it: the rejected head is never the operator's.
|
||||
func TestANewHeadWithinTwoHoursKeepsTheRejectedHeadFromTheOperator(t *testing.T) {
|
||||
w, id := rejectedBeforeItsAnswer(t, "quiet-deliveries")
|
||||
w.later(time.Hour)
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
w.later(2 * time.Hour)
|
||||
if got := linesFor(w, id); len(got) != 0 {
|
||||
t.Fatalf("the superseded head reached the operator: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// No check gates a merge (novox/hq ADR 0661), the gate's part: no approved merge waits for another delivery's running check,
|
||||
// and no turn or stalled line holds it (§1); the merge judges the operator's Approve alone, so an Approve given before a trunk
|
||||
// move merges onto the new trunk with no wait for its carry (§3); a failure after the Approve is said once on the pull
|
||||
// request and as a stalled line naming the merge it follows, and refuses nothing (§5).
|
||||
|
||||
const otherHead = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"
|
||||
|
||||
// approvedLone is a lone delivery into oneRepo whose check passed, approved by the operator (the world approves at once).
|
||||
func approvedLone(w *world) string {
|
||||
w.t.Helper()
|
||||
p := pr(oneRepo, 1, oneHead, "feat/one")
|
||||
requiresCheck(w, p)
|
||||
w.h.PullUpdated(p)
|
||||
putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead))
|
||||
w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk))
|
||||
return IDOf(oneRepo, oneHead)
|
||||
}
|
||||
|
||||
// sayingFailed is the forge's word that oneHead's check failed on the trunk commit given (a carry's line).
|
||||
func sayingFailed(trunk string) ForgeStatusEvent {
|
||||
e := checkStatus(oneRepo, oneHead, "failure", trunk)
|
||||
e.Description = "failed " + oneHead[:12] + " on " + trunk[:12] + " | a test failed"
|
||||
return e
|
||||
}
|
||||
|
||||
func lineOf(w *world, id string) *StalledLine {
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == id {
|
||||
return &l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNoCheckGatesAMergeAtTheGate(t *testing.T) {
|
||||
for name, run := range map[string]func(t *testing.T){
|
||||
// §1: a running check of other heads on the same repository holds nothing, and no stalled line is raised for it.
|
||||
"an approved merge into a repository under a running check merges at once": func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
putCheckerVerdict(w, "running", headOf(oneRepo, 9, otherHead))
|
||||
id := approvedLone(w)
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked %d times, merges %v: want asked once and merged at once (%s)", w.op.count(), w.forge.merges, w.logs())
|
||||
}
|
||||
w.h.PullMerged(merged(oneRepo, 1, oneHead, strings.Repeat("c", 40))) // the seat git says the merge too
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if l := lineOf(w, id); l != nil {
|
||||
t.Fatalf("a stalled line for a merge a running check once held: %+v", l)
|
||||
}
|
||||
},
|
||||
"the delivery's own running verdict does not hold it": func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
id := approvedLone(w)
|
||||
w.settleAll()
|
||||
putCheckerVerdict(w, "running", headOf(oneRepo, 1, oneHead)) // its own heads judged again (a carry)
|
||||
for _, k := range w.op.open() {
|
||||
w.op.answer(k, OptionApprove)
|
||||
}
|
||||
w.settleAll()
|
||||
if d := w.delivery(id); len(w.forge.merges) != 1 || d.Ready == nil {
|
||||
t.Fatalf("merges %v, run %+v: its own check held it", w.forge.merges, d.Ready)
|
||||
}
|
||||
},
|
||||
// §3 (and ADR 0548 §8): the ask stands through a trunk move; the Approve given then merges onto the new trunk on the
|
||||
// verdict judged against the old one, with no wait for its carry and no second ask.
|
||||
"an Approve given after a trunk move merges at once, its carry after": func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
id := approvedLone(w)
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("asked %d times, merges %v: want asked and not merged yet", w.op.count(), w.forge.merges)
|
||||
}
|
||||
w.h.PullMerged(merged(oneRepo, 5, otherHead, oneTrunk2)) // a merge at the forge moves the trunk
|
||||
w.settleAll()
|
||||
for _, k := range w.op.open() {
|
||||
w.op.answer(k, OptionApprove)
|
||||
}
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if len(w.forge.merges) != 1 || w.op.count() != 1 || d.State != Ready && d.State != Merged {
|
||||
t.Fatalf("merges %v, asked %d times, %s (waits %q): want merged at once on the old verdict (%s)", w.forge.merges,
|
||||
w.op.count(), d.State, d.Ready.Waits, w.logs())
|
||||
}
|
||||
},
|
||||
// §5: a failure after the Approve, a carry's, is said once on the pull request and as one stalled line naming the merge;
|
||||
// it refuses nothing, and a later pass clears the line.
|
||||
"a failure after the Approve is said once and refuses nothing": func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := approvedLone(w)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("merges %v: want merged at once", w.forge.merges)
|
||||
}
|
||||
before := len(w.forge.comments)
|
||||
w.h.CommitStatus(sayingFailed(oneTrunk2))
|
||||
w.settleAll()
|
||||
w.h.CommitStatus(sayingFailed(oneTrunk2))
|
||||
w.settleAll()
|
||||
said := 0
|
||||
for _, c := range w.forge.comments[before:] {
|
||||
if strings.Contains(c, "failed after the Approve") {
|
||||
said++
|
||||
}
|
||||
}
|
||||
if said != 1 {
|
||||
t.Fatalf("said %d times on the pull request, want once: %v", said, w.forge.comments[before:])
|
||||
}
|
||||
l := lineOf(w, id)
|
||||
if l == nil || !strings.Contains(l.Says, "after the operator's Approve") || !strings.Contains(l.Says, oneHead[:12]) {
|
||||
t.Fatalf("the stalled line = %+v", l)
|
||||
}
|
||||
if d := w.delivery(id); d.State == Rejected || len(w.forge.merges) != 1 {
|
||||
t.Fatalf("%s, merges %v: the failure refused the approved merge", d.State, w.forge.merges)
|
||||
}
|
||||
w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk2))
|
||||
w.settleAll()
|
||||
if l := lineOf(w, id); l != nil {
|
||||
t.Fatalf("a later pass left the stalled line: %+v", l)
|
||||
}
|
||||
},
|
||||
} {
|
||||
t.Run(name, run)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1794: the gate keeps no mark of an own ask of a check, so no verdict of another check of the head can clear one while
|
||||
// the gate's own is in flight: since issue 1318 the gate asks no check, only the checker's verdict at the heads is read, and a
|
||||
// run holds no field that marks an ask of one.
|
||||
func TestTheGateAsksNoCheckOfItsOwnSoNoMarkOfOneCanBeClearedByAnother(t *testing.T) {
|
||||
typ := reflect.TypeOf(ReadyRun{})
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
if f := typ.Field(i).Name; strings.HasPrefix(f, "OwnAsk") || strings.HasPrefix(f, "OwnCheck") {
|
||||
t.Errorf("ReadyRun holds %s: a mark of a check the gate asked itself, which it no longer asks", f)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"own_asked_at", "own_ask"} {
|
||||
if raw, _ := json.Marshal(ReadyRun{}); strings.Contains(string(raw), key) {
|
||||
t.Errorf("a run is kept with %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,835 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// A merge into main waits for the operator's Approve (novox/hq ADR 0398 §3, issue 948, ADR 0421).
|
||||
//
|
||||
// This owner writes the ask into its bucket asks (novox/hq ADR 0437 §6) as soon as a pull request's checks pass:
|
||||
// Approve or Decline, each option binding the act "merge these heads". The teller publishes it to the operator on the
|
||||
// router and, on a warrant that holds the ask (its asker, the ask's digest, an option it offered, in time, and the act
|
||||
// that option bound), writes the answer into its bucket answers. This owner reads the answer and acts on it only while
|
||||
// the heads of the key it was written under are still its group's heads; it asks the router nothing.
|
||||
//
|
||||
// - Asked once per head. A group is asked once, for all its members' heads together, by the first member that reaches
|
||||
// this point; the Approve covers every member at those heads.
|
||||
// - A push after the ask ends it, said once on the pull request: the new head is asked on its own.
|
||||
// - A Decline merges nothing and is said once on the pull request.
|
||||
// - An ask that ended without a choice (expired, cancelled, refused by the router) is asked again when the router says so.
|
||||
// - The ask, its state and the warrant's words are kept on the delivery's ReadyRun and shown by `show`.
|
||||
//
|
||||
// The terminal Approve (ADR 0398 §3) is issue 953; today the proving channel is the operator's phone.
|
||||
|
||||
// Module is this module's name: the one the bus names its buckets and events under (novox/hq ADR 0437 §3). Seat is
|
||||
// the seat it claims, whose name stays `mesh-delivery` (ADR 0437 §11).
|
||||
const (
|
||||
Module = "mesh-delivery-gate"
|
||||
Seat = "mesh-delivery"
|
||||
)
|
||||
|
||||
// The two answers, both at the level approve.
|
||||
const (
|
||||
OptionApprove = "approve"
|
||||
OptionDecline = "decline"
|
||||
)
|
||||
|
||||
// AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
|
||||
const AskLasts = occ.ApproveLasts - 10*time.Minute
|
||||
|
||||
// Operator is how this owner asks the operator: through the keys of its bucket asks and the teller's answers. kvOperator in
|
||||
// the running module.
|
||||
type Operator interface {
|
||||
// Ask writes the ask under its key (mdc.MergeAskKey); it writes nothing when that key holds the same ask already.
|
||||
Ask(key string, a Ask) error
|
||||
// Cancel takes the ask at a key away, which the teller reads as the ask withdrawn.
|
||||
Cancel(key string) error
|
||||
// Where is the teller's answer to the ask at a key, or nil when there is none.
|
||||
Where(key string) (*Answer, error)
|
||||
}
|
||||
|
||||
// askGroup is the name a delivery's ask and merge are kept under: its group's, or, for a lone pull request, a group of one
|
||||
// under its branch name (novox/hq ADR 0437 §6).
|
||||
func askGroup(d *Delivery) string {
|
||||
switch {
|
||||
case d.Group != "":
|
||||
return d.Group
|
||||
case d.Branch != "":
|
||||
return d.Branch
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// headsString is the heads an act names, as an act's value: "<id>=<sha>" for each, sorted, joined by commas.
|
||||
func headsString(heads map[string]string) string {
|
||||
var out []string
|
||||
for id, sha := range heads {
|
||||
out = append(out, id+"="+sha)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// mergeAct is what an answer binds: this ask, the answer, and the heads it covers.
|
||||
func mergeAct(ask, option string, heads map[string]string) occ.Act {
|
||||
return occ.Act{"verb": Seat + ".merge", "ask": ask, "option": option, "heads": headsString(heads)}
|
||||
}
|
||||
|
||||
var unshownPath = regexp.MustCompile(`[/\\@~$]|\d+\.\d+\.\d+|[A-Za-z0-9+/=_]{24,}|(?i)\b[a-z0-9-]+\.[a-z]{2,12}\b`)
|
||||
|
||||
// phone is a line the operator's phone may be shown: what the router refuses in an ask's words (a path, an address, a
|
||||
// host-like name, a long unbroken word) is left out, and the line is cut to n characters.
|
||||
func phone(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(unshownPath.ReplaceAllString(s, "…")), " ")
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// issuesSaid is the issues linked to the deliveries' pull requests in the issue tracker, as the ask names them.
|
||||
func issuesSaid(ds []*Delivery) string {
|
||||
seen := map[int]bool{}
|
||||
var out []string
|
||||
for _, d := range ds {
|
||||
if d.IssuesUnread != "" {
|
||||
out = append(out, pullSaid(d)+"'s unread (the issue tracker did not answer)")
|
||||
}
|
||||
for _, n := range d.Issues {
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, "issue "+strconv.Itoa(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "none linked"
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func repoName(repository string) string {
|
||||
_, name, ok := strings.Cut(repository, "/")
|
||||
if !ok {
|
||||
return repository
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func pullSaid(d *Delivery) string { return fmt.Sprintf("%s#%d", repoName(d.Repository), d.Number) }
|
||||
|
||||
// touchesSaid is the modules the delivery's check says its change touches, for the ask; the gate plans no delivery.
|
||||
func touchesSaid(d *Delivery) string {
|
||||
if d.Check == nil || len(d.Check.Modules) == 0 {
|
||||
return "no module the check named"
|
||||
}
|
||||
return strings.Join(d.Check.Modules, ", ")
|
||||
}
|
||||
|
||||
// checkClause is the check's state as the ask's summary says it after the pull request's or the group's name: sep and
|
||||
// "passed its check", or ": its check has no verdict yet: it is running (since 21:05 UTC)" and the like.
|
||||
func checkClause(c checkSaid, sep string) string {
|
||||
if c.passed() {
|
||||
return sep + c.Words
|
||||
}
|
||||
return ": " + c.Words
|
||||
}
|
||||
|
||||
// approveCaveat is what an Approve does beyond the merge while the check has not passed: it merges although it failed (the check gates
|
||||
// no merge, check-gates-merge false). Empty once it passed; an ask is made only with a verdict.
|
||||
func approveCaveat(c checkSaid) string {
|
||||
switch c.Key {
|
||||
case checkSaidPass:
|
||||
return ""
|
||||
case checkSaidFail:
|
||||
return " although its check failed"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mergeHeadline opens the ask's headline: "Merge into main: ", and "Check failed - merge into main: " once the check failed,
|
||||
// since a merge is not gated on the check and the headline is the phone's only warning (issue 1166). A passed or still
|
||||
// running check keeps the plain headline. The prefix leads, so phone's cut of a long headline never drops it.
|
||||
func mergeHeadline(c checkSaid) string {
|
||||
if c.Key == checkSaidFail {
|
||||
return "Check failed \u2014 merge into main: "
|
||||
}
|
||||
return "Merge into main: "
|
||||
}
|
||||
|
||||
// composeMergeAsk is the ask for a delivery, or for a group's members together.
|
||||
// check is the state of the check at the heads it covers (checkAt): the ask says "passed its check" only when it did, and
|
||||
// otherwise that Approve merges before the check ends, or although it failed (issue 1733).
|
||||
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, check checkSaid,
|
||||
now time.Time) oc.Ask {
|
||||
var a oc.Ask
|
||||
a.ID, a.Who, a.Expires = id, occ.Operator, occ.FormatTime(now.Add(AskLasts))
|
||||
a.OnExpiry = "nothing is merged; the ask is made again"
|
||||
a.About = "delivery." + phone(repoName(d.Repository), 40) + "." + strconv.Itoa(d.Number)
|
||||
a.About = strings.ReplaceAll(a.About, " ", "-")
|
||||
var reaches, details []string
|
||||
for _, m := range members {
|
||||
touches := touchesSaid(m)
|
||||
reaches = append(reaches, touches)
|
||||
details = append(details, fmt.Sprintf("%s %q at %s: issues: %s; touches: %s",
|
||||
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), touches))
|
||||
}
|
||||
if group == "" {
|
||||
a.Headline = phone(mergeHeadline(check)+pullSaid(d)+" "+d.Title, occ.HeadlineLength)
|
||||
summary := fmt.Sprintf("%s %s%s. Approve merges it into main at %s%s; Decline leaves it unmerged.",
|
||||
pullSaid(d), d.Title, checkClause(check, " "), shortOf(d.Commit, 12), approveCaveat(check))
|
||||
a.Explanation = strings.Join([]string{
|
||||
phone(summary, occ.SummaryLength),
|
||||
phone("Issues: "+issuesSaid(members), occ.PointLength),
|
||||
phone("Touches: "+reaches[0], occ.PointLength),
|
||||
}, "\n")
|
||||
} else {
|
||||
var names []string
|
||||
for _, m := range members {
|
||||
names = append(names, pullSaid(m))
|
||||
}
|
||||
a.Headline = phone(mergeHeadline(check)+"group "+group+" ("+strings.Join(names, ", ")+")", occ.HeadlineLength)
|
||||
caveat := approveCaveat(check)
|
||||
if caveat != "" {
|
||||
caveat = "," + caveat
|
||||
}
|
||||
summary := fmt.Sprintf("The group %s, %d pull requests%s. Approve merges all of them into main in one pass, at the heads named%s; Decline leaves them unmerged.",
|
||||
group, len(members), checkClause(check, ", "), caveat)
|
||||
a.Explanation = strings.Join([]string{
|
||||
phone(summary, occ.SummaryLength),
|
||||
phone("Issues: "+issuesSaid(members), occ.PointLength),
|
||||
phone("Touches: "+strings.Join(reaches, "; "), occ.PointLength),
|
||||
}, "\n")
|
||||
}
|
||||
a.Details = phone(strings.Join(details, "\n"), 1500)
|
||||
if len(details) > 0 { // keep the lines apart: phone joined them
|
||||
a.Details = strings.Join(func() []string {
|
||||
var out []string
|
||||
for _, l := range details {
|
||||
out = append(out, phone(l, 400))
|
||||
}
|
||||
return out
|
||||
}(), "\n")
|
||||
}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Approve", "the pull request is merged into main at the head named (a group: all its members, in one pass)"},
|
||||
{OptionDecline, "Decline", "nothing is merged"},
|
||||
} {
|
||||
binds, _ := occ.ActDigest(mergeAct(id, o.id, heads))
|
||||
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// askMembers are the deliveries one ask covers: the group's members not yet merged, by id, or the delivery.
|
||||
func (h *Holder) askMembers(d *Delivery) []*Delivery {
|
||||
g := h.groups[d.Group]
|
||||
if d.Group == "" || g == nil {
|
||||
return []*Delivery{d}
|
||||
}
|
||||
var out []*Delivery
|
||||
seen := map[string]bool{}
|
||||
ids := append([]string(nil), g.Members...)
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
if o := h.deliveries[id]; o != nil && !seen[id] && o.MergedAs == "" && !o.State.Final() {
|
||||
seen[id] = true
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []*Delivery{d}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// covering is the ready run holding an ask (pending or approved) that covers d at its head: its own, or a group
|
||||
// sibling's, whose ask named d's head too.
|
||||
func (h *Holder) covering(d *Delivery) *ReadyRun {
|
||||
has := func(o *Delivery) *ReadyRun {
|
||||
if o != nil && o.Ready != nil && (o.Ready.AskID != "" || o.Ready.Approved != nil) && o.Ready.Heads[d.ID] == d.Commit {
|
||||
return o.Ready
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if r := has(d); r != nil {
|
||||
return r
|
||||
}
|
||||
if g := h.groups[d.Group]; d.Group != "" && g != nil {
|
||||
for _, id := range g.Members {
|
||||
if r := has(h.deliveries[id]); r != nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func headsEqual(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if b[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// headsHold is whether an ask's heads are still the heads of d's group: every member now at the head asked, and every
|
||||
// head asked that is no member now a member already merged.
|
||||
func (h *Holder) headsHold(d *Delivery, c *ReadyRun) bool {
|
||||
now := map[string]bool{}
|
||||
for _, m := range h.askMembers(d) {
|
||||
now[m.ID] = true
|
||||
if c.Heads[m.ID] != m.Commit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for id := range c.Heads {
|
||||
if !now[id] {
|
||||
if o := h.deliveries[id]; o == nil || o.MergedAs == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// endStaleAsks ends every ask whose heads are no longer its group's: a push to any member ends the approval (ADR 0398
|
||||
// §3), and says so once on each pull request it covered (ADR 0421 §6). An ask for a head a newer one replaced ends too.
|
||||
// A Decline stands, and is not ended. An ask none of whose deliveries can still merge by it is taken away after
|
||||
// (endAsksOfFinished). Under the lock; the cancels run with the look's asks.
|
||||
func (h *Holder) endStaleAsks() {
|
||||
ending := map[string]bool{}
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" || r.declined() || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 {
|
||||
continue
|
||||
}
|
||||
id, before := r.AskID, len(h.cancels)
|
||||
switch {
|
||||
case d.State.Final():
|
||||
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "its head "+shortOf(d.Commit, 12)+" is no longer the pull request's head, or the pull request closed"))
|
||||
case !h.headsHold(d, r):
|
||||
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "a head it covers moved on: the heads it covers are no longer the group's heads"))
|
||||
case r.Approved == nil && r.AskID != "" && r.AskCheck != "" && h.checkMoved(d, r.AskCheck):
|
||||
// The check gates the ask, so it was made on a pass: once that pass is gone (a failure, or a check of these heads
|
||||
// running again), the ask not yet answered is withdrawn in words that say so (novox/hq ADR 0661 §5, ADR 0441 §1).
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, "the check at its heads is now: "+h.checkAt(h.askMembers(d)).Words))
|
||||
case r.Approved == nil && r.AskID != "" && !r.AskedAt.IsZero() && !h.Now().Before(r.AskedAt.Add(AskLasts)):
|
||||
// The ask lapsed at the router with nobody's answer, and nothing in the gate's inputs says so: it is taken away and
|
||||
// written again, which the teller asks afresh (ADR 0441 §1).
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, "nobody answered in "+AskLasts.String()))
|
||||
}
|
||||
if id != "" && (len(h.cancels) > before || h.withdrawn[id]) {
|
||||
ending[id] = true
|
||||
}
|
||||
}
|
||||
h.endAsksOfFinished(ending)
|
||||
}
|
||||
|
||||
// endAsksOfFinished takes away every ask that stands in the gate's asks while none of the deliveries it was asked for can still
|
||||
// be merged by it: each merged (on the operator's Approve or by another path) or ended. The ask is about a merge, so it ends
|
||||
// once that merge cannot follow from it, however the deliveries got there (issue 774: on 2026-10-31 two pull requests the lead
|
||||
// merged once their checks were green left their asks standing for a day, and the teller's asks held two of the router's three
|
||||
// places, so a newer ask was refused for want of room). It reads what the gate keeps (AskedKey, Written), so an ask whose
|
||||
// taking away failed is taken away again at the next look. An ask another end of this look takes away (ending, withdrawn) is
|
||||
// left to it. Nothing is said on a pull request: its merge or its end said it. Under the lock.
|
||||
func (h *Holder) endAsksOfFinished(ending map[string]bool) {
|
||||
standing, live := map[string]bool{}, map[string]bool{}
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if r == nil || r.AskedKey == "" {
|
||||
continue
|
||||
}
|
||||
if r.Written {
|
||||
standing[r.AskedKey] = true
|
||||
}
|
||||
if d.MergedAs == "" && !d.State.Final() {
|
||||
live[r.AskedKey] = true
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(standing))
|
||||
for key := range standing {
|
||||
if !live[key] && !ending[key] && !h.withdrawn[key] {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
for _, d := range h.deliveries {
|
||||
if r := d.Ready; r != nil && r.AskID == key && r.Approved == nil {
|
||||
r.AskID, r.Ask, r.Heads = "", nil, nil
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] the operator's ask %s is taken away: every pull request it was asked for is merged or ended", key)
|
||||
if h.Operator == nil {
|
||||
continue
|
||||
}
|
||||
h.cancels = append(h.cancels, func() {
|
||||
if err := h.Operator.Cancel(key); err != nil {
|
||||
h.Logf("[mesh-delivery] the operator's ask %s could not be taken away, and is taken away at the next look (%v)", key, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.markWritten(key, false)
|
||||
h.mu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// checkMoved is whether the check at the heads of d's ask reached a verdict its pending ask does not say: it passed or failed
|
||||
// since, or the pass it said is gone. Under the lock.
|
||||
func (h *Holder) checkMoved(d *Delivery, said string) bool {
|
||||
now := h.checkAt(h.askMembers(d)).Key
|
||||
if now == said {
|
||||
return false
|
||||
}
|
||||
return now == checkSaidPass || now == checkSaidFail || said == checkSaidPass || said == checkSaidFail
|
||||
}
|
||||
|
||||
// approval is what a delivery that may merge waits on for the operator: empty once its head is approved, and the ask
|
||||
// to make when none is made yet. Under the lock.
|
||||
func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
r := d.Ready
|
||||
c := h.covering(d)
|
||||
if c != nil {
|
||||
if c.Approved != nil {
|
||||
r.Approved, r.Warrant, r.AskID, r.Ask, r.Heads = c.Approved, c.Warrant, c.AskID, c.Ask, c.Heads
|
||||
return "", nil
|
||||
}
|
||||
if !c.AskedAt.IsZero() {
|
||||
h.lookAt(c.AskedAt.Add(AskLasts), "the operator's Approve, unanswered until the ask lapses")
|
||||
}
|
||||
return "the operator's Approve is asked (" + c.AskID + ")", nil
|
||||
}
|
||||
if r.Refused != "" {
|
||||
return "the operator's answer was refused here: " + r.Refused, nil
|
||||
}
|
||||
if h.Operator == nil {
|
||||
return "the operator cannot be asked from here", nil
|
||||
}
|
||||
members := h.askMembers(d)
|
||||
if waits := h.writeWaits(d, members, now); waits != "" {
|
||||
return waits, nil // asked only once the forge account can carry the merge out (writes.go, issue 1945)
|
||||
}
|
||||
if read := h.issuesToRead(members); read != nil {
|
||||
return "the issues linked to its pull request are read from the issue tracker", read
|
||||
}
|
||||
group := ""
|
||||
if len(members) > 1 {
|
||||
group = d.Group
|
||||
}
|
||||
heads := map[string]string{}
|
||||
var covered []mdc.Head
|
||||
for _, m := range members {
|
||||
heads[m.ID] = m.Commit
|
||||
covered = append(covered, mdc.Head{Repository: m.Repository, Number: m.Number, Head: m.Commit})
|
||||
}
|
||||
name := askGroup(d)
|
||||
id, err := mdc.MergeAskKey(name, covered)
|
||||
if err != nil {
|
||||
return "the operator cannot be asked: " + err.Error(), nil
|
||||
}
|
||||
r.Asks++
|
||||
// Each asking at these heads has its own router id (mdc.AskingID): the router keeps an ask by its id and drops a second ask
|
||||
// under it as a redelivery, so an ask written again at unchanged heads (a lapse, or a status changing) is
|
||||
// the next asking, with a new id (review round 1, G2; issue 1370).
|
||||
asking := 1
|
||||
for _, m := range members {
|
||||
if m.Ready != nil && m.Ready.AskedKey == id {
|
||||
asking = max(asking, m.Ready.Asking+1)
|
||||
}
|
||||
}
|
||||
check := h.checkAt(members)
|
||||
ask := composeMergeAsk(mdc.AskingID(id, asking), d, group, members, heads, check, now)
|
||||
taken := []*Delivery{}
|
||||
var reads []memberRead
|
||||
for _, m := range members {
|
||||
h.runOf(m, now)
|
||||
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks)
|
||||
m.Ready.AskedKey, m.Ready.Asking, m.Ready.AskCheck = id, asking, check.Key
|
||||
m.Ready.Declined, m.Ready.AnsweredAsk = nil, "" // a new ask: an answer to an earlier one does not stand on it
|
||||
h.keep(m)
|
||||
taken = append(taken, m)
|
||||
reads = append(reads, readOf(m, m.Commit))
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s (%s): the operator is asked to Approve its merge into main (%s, ask %d)",
|
||||
d.ID, check.Words, id, r.Asks)
|
||||
gid := ""
|
||||
if group != "" {
|
||||
gid = d.Group
|
||||
}
|
||||
return "the operator's Approve is asked (" + id + ")", 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 readErr error
|
||||
for _, m := range reads {
|
||||
if gid == "" {
|
||||
break
|
||||
}
|
||||
says, forge, err := h.notASuccess(m)
|
||||
if err != nil {
|
||||
readErr = err
|
||||
break
|
||||
}
|
||||
if says != "" && !forge { // the forge's merge answer is weighed at the merge, where it is asked again (group_merge.go)
|
||||
holdMember, holdSays = m.id, says
|
||||
break
|
||||
}
|
||||
}
|
||||
undo := func() {
|
||||
for _, m := range taken {
|
||||
if m.Ready != nil && m.Ready.AskID == id {
|
||||
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads = "", nil, nil
|
||||
h.keep(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
// An earlier asking of this key may still stand in the bucket: not written again, it is taken away.
|
||||
stale := func() {
|
||||
h.mu.Lock()
|
||||
standing := h.standing(id)
|
||||
h.mu.Unlock()
|
||||
if standing {
|
||||
if err := h.Operator.Cancel(id); err != nil {
|
||||
h.Logf("[mesh-delivery] the operator's ask %s could not be withdrawn (%v)", id, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.markWritten(id, false)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
// Nothing is asked that the forge account could not merge (writes.go, issue 1945): said at the next look.
|
||||
if says := h.cannotWrite(reads); says != "" {
|
||||
stale()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
undo()
|
||||
h.Logf("[mesh-delivery] %s (%s): the operator is not asked: %s", d.ID, check.Words, says)
|
||||
h.lookAt(h.Now(), "an ask not made: the forge account cannot write a repository it covers")
|
||||
return
|
||||
}
|
||||
if readErr != nil || holdSays != "" {
|
||||
stale()
|
||||
h.mu.Lock()
|
||||
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 {
|
||||
h.Logf("[mesh-delivery] group %s is not asked for its Approve: %s waits on a required status: %s", gid, holdMember, holdSays)
|
||||
g.Hold = &GroupHold{Kind: "unasked", Member: holdMember, Says: holdSays, Since: h.Now()}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
h.Logf("[mesh-delivery] group %s: the required statuses cannot be read, so the operator is not asked yet: %v", gid, readErr)
|
||||
}
|
||||
h.again("a group's Approve held for a required status")
|
||||
return
|
||||
}
|
||||
if g := func() *Group { h.mu.Lock(); defer h.mu.Unlock(); return h.groups[gid] }(); g != nil && g.Hold != nil && g.Hold.Kind == "unasked" {
|
||||
h.mu.Lock()
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
toAsk, err := gateAsk(name, covered, asking, ask)
|
||||
if err == nil {
|
||||
err = h.Operator.Ask(id, toAsk)
|
||||
}
|
||||
if err == nil {
|
||||
h.mu.Lock()
|
||||
h.markWritten(id, true)
|
||||
h.mu.Unlock()
|
||||
} else {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
undo()
|
||||
h.again("the operator's Approve, which could not be asked")
|
||||
h.Logf("[mesh-delivery] the operator could not be asked about %s (%v); asked again in %s", d.ID, err, askAgainAfter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropAsk forgets a pending ask on every delivery it covers and takes it back from the router; under the lock. What it
|
||||
// says is said once on each pull request it covered (ADR 0421 §6): the Approve is ended, and why.
|
||||
func (h *Holder) dropAsk(id, why string) func() {
|
||||
var said []func()
|
||||
seen := map[string]bool{}
|
||||
var covered []*Delivery
|
||||
for _, d := range h.deliveries {
|
||||
if d.Ready != nil && d.Ready.AskID == id {
|
||||
covered = append(covered, d)
|
||||
}
|
||||
}
|
||||
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
|
||||
for _, d := range covered {
|
||||
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
|
||||
if d.Ready.Merged == nil {
|
||||
d.Ready.Approved, d.Ready.Warrant = nil, ""
|
||||
}
|
||||
h.keep(d)
|
||||
if k := fmt.Sprintf("%s#%d", d.Repository, d.Number); !seen[k] && d.Number != 0 {
|
||||
seen[k] = true
|
||||
said = append(said, h.sayOn(d, "mesh-delivery: the operator's Approve of this pull request's merge into main is ended: "+why+
|
||||
". Nothing is merged on it. The operator is asked again once the checks pass at the new head."))
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] the operator's ask %s is cancelled: %s", id, why)
|
||||
return func() {
|
||||
for _, s := range said {
|
||||
s()
|
||||
}
|
||||
if h.Operator == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Operator.Cancel(id); err != nil {
|
||||
h.Logf("[mesh-delivery] the operator's ask %s could not be cancelled (%v)", id, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.markWritten(id, false)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawAsk takes back an ask nobody answered, on every delivery it covers, so the next look asks again with what is
|
||||
// known now (issue 1079). Unlike dropAsk it ends no Approve and says nothing on a pull request: there was none. Under the
|
||||
// lock; the router's cancel runs with the look's asks.
|
||||
func (h *Holder) withdrawAsk(id, why string) func() {
|
||||
for _, d := range h.deliveries {
|
||||
if d.Ready != nil && d.Ready.AskID == id && d.Ready.Approved == nil {
|
||||
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
// The key is not taken away: the next look writes it again with the next asking, if the group may be asked, and takes it away
|
||||
// only if it may not (readyAsks, withdrawnKeys), so the key is never absent between the two.
|
||||
h.withdrawn[id] = true
|
||||
h.Logf("[mesh-delivery] the operator's ask %s is withdrawn to be asked again: %s", id, why)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// withdrawnKeys are, after a look, the closures that take away the asks withdrawn and not written again by it. Under the lock.
|
||||
func (h *Holder) withdrawnKeys() []func() {
|
||||
var out []func()
|
||||
for key := range h.withdrawn {
|
||||
delete(h.withdrawn, key)
|
||||
again := false
|
||||
for _, d := range h.deliveries {
|
||||
again = again || (d.Ready != nil && d.Ready.AskID == key)
|
||||
}
|
||||
if again || h.Operator == nil {
|
||||
continue
|
||||
}
|
||||
key := key
|
||||
out = append(out, func() {
|
||||
if err := h.Operator.Cancel(key); err != nil {
|
||||
h.Logf("[mesh-delivery] the operator's ask %s could not be withdrawn (%v)", key, err)
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.markWritten(key, false)
|
||||
h.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pendingAsks are the asks waiting for an answer, by id; under the lock.
|
||||
func (h *Holder) pendingAsks() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, d := range h.deliveries {
|
||||
if r := d.Ready; r != nil && r.AskID != "" && r.Approved == nil && !r.declined() && !seen[r.AskID] {
|
||||
seen[r.AskID] = true
|
||||
out = append(out, r.AskID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// hearAsks reads the teller's answer to every pending ask and applies each that is there.
|
||||
func (h *Holder) hearAsks() {
|
||||
if h.Operator == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
keys := h.pendingAsks()
|
||||
h.mu.Unlock()
|
||||
for _, key := range keys {
|
||||
answer, err := h.Operator.Where(key)
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the teller's answer to the ask %s cannot be read: %v", key, err)
|
||||
continue
|
||||
}
|
||||
if answer == nil {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
said := h.answered(key, *answer)
|
||||
h.mu.Unlock()
|
||||
for _, s := range said {
|
||||
s()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// answered applies the teller's answer to the ask at a key to the deliveries it covers; under the lock. The teller has
|
||||
// held the warrant to the ask (novox/hq ADR 0437 §6); this owner acts on the answer only if the key it was written under is
|
||||
// still the key of the heads asked: an answer to an ask taken away, or to other heads, merges nothing (ADR 0441 §1). It
|
||||
// returns what to say on the pull requests.
|
||||
func (h *Holder) answered(key string, ans Answer) []func() {
|
||||
var covered []*Delivery
|
||||
for _, d := range h.deliveries {
|
||||
if d.Ready != nil && d.Ready.AskID == key {
|
||||
covered = append(covered, d)
|
||||
}
|
||||
}
|
||||
if len(covered) == 0 {
|
||||
h.Logf("[mesh-delivery] the answer to the ask %s names no ask that is asked now: nothing is merged on it", key)
|
||||
return nil
|
||||
}
|
||||
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
|
||||
first, now := covered[0].Ready, h.Now()
|
||||
refuse := func(why string) {
|
||||
h.Logf("[mesh-delivery] REFUSED the answer to the ask %s: %s", key, why)
|
||||
for _, d := range covered {
|
||||
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
|
||||
d.Ready.Refused = "was answered, and the answer does not hold here: " + clip(oneLine(why), 300)
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
var heads []mdc.Head
|
||||
for id, sha := range first.Heads {
|
||||
m := h.deliveries[id]
|
||||
if m == nil {
|
||||
refuse("the head " + id + " it covers is no delivery of this owner")
|
||||
return nil
|
||||
}
|
||||
heads = append(heads, mdc.Head{Repository: m.Repository, Number: m.Number, Head: sha})
|
||||
}
|
||||
if want, err := mdc.MergeAskKey(askGroup(covered[0]), heads); err != nil || want != key {
|
||||
refuse(fmt.Sprintf("its key %s is not the key %s of the heads asked", key, want))
|
||||
return nil
|
||||
}
|
||||
// The answer is to the asking asked now: the teller names the router id it was given in the warrant's id (`<ask id>@<time>`),
|
||||
// and an answer to an earlier asking is never taken for a later one (issue 1370).
|
||||
if now, at, ok := strings.Cut(ans.Warrant, "@"); ok && at != "" && now != mdc.AskingID(key, first.Asking) {
|
||||
h.Logf("[mesh-delivery] the answer to the ask %s was given to an earlier asking (%s), not the one asked now (%s): not taken",
|
||||
key, now, mdc.AskingID(key, first.Asking))
|
||||
return nil
|
||||
}
|
||||
if ans.Option != OptionApprove && ans.Option != OptionDecline {
|
||||
refuse("it chose " + ans.Option + ", which the ask did not offer")
|
||||
return nil
|
||||
}
|
||||
var said []func()
|
||||
for _, d := range covered {
|
||||
r := d.Ready
|
||||
r.Warrant = ans.Words
|
||||
r.Repeats = 0 // the operator chose: whatever the tool refused before is asked afresh (issue 1106)
|
||||
h.mergeAgainNow(d) // the operator chose: the merge is asked at once
|
||||
switch ans.Option {
|
||||
case OptionApprove:
|
||||
r.Approved, r.Declined, r.AnsweredAsk = &now, nil, key
|
||||
h.Logf("[mesh-delivery] %s: the operator approved its merge into main (%s)", d.ID, r.Warrant)
|
||||
case OptionDecline:
|
||||
r.Declined, r.AnsweredAsk = &now, key
|
||||
h.Logf("[mesh-delivery] %s: the operator declined its merge into main (%s)", d.ID, r.Warrant)
|
||||
said = append(said, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** The merge of this pull request into main at %s was "+
|
||||
"declined: %s. A new head asks the operator again.", d.Commit, r.Warrant)))
|
||||
}
|
||||
h.keep(d)
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// gateAsk is the gate's ask for a group at heads: the router's ask as its JSON in the value's ask, after the checks
|
||||
// mesh-sdk no longer makes for it (novox/hq ADR 0627 §4): the ask's id is the one its group, heads and asking give, and
|
||||
// it offers at least one option.
|
||||
func gateAsk(group string, heads []mdc.Head, asking int, ask oc.Ask) (Ask, error) {
|
||||
raw, err := json.Marshal(ask)
|
||||
if err != nil {
|
||||
return Ask{}, err
|
||||
}
|
||||
a := Ask{Group: group, Heads: heads, Asking: asking, Ask: raw}
|
||||
if _, err := askOf(a); err != nil {
|
||||
return Ask{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// askOf reads the router's ask a gate ask carries and holds it to what the gate owes: the id its group, heads and asking
|
||||
// give, and at least one option (occ.Carried). kvOperator.Ask holds every ask it writes to it, too.
|
||||
func askOf(a Ask) (oc.Ask, error) {
|
||||
key, err := mdc.MergeAskKey(a.Group, a.Heads)
|
||||
if err != nil {
|
||||
return oc.Ask{}, fmt.Errorf("an ask needs its group and at least one pull request's head: %w", err)
|
||||
}
|
||||
return occ.Carried(a.Ask, mdc.AskingID(key, a.Asking))
|
||||
}
|
||||
|
||||
// kvOperator is the operator over the runtime: the ask is a key of the gate's bucket asks, the answer the teller's, read
|
||||
// through its tool teller_answers.
|
||||
type kvOperator struct{}
|
||||
|
||||
func (kvOperator) Ask(key string, a Ask) error {
|
||||
if _, err := askOf(a); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := EncodeAsk(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e, err := stdio.State("asks").Get(key); err == nil && e != nil {
|
||||
if kept, err := DecodeAsk(e.Value); err == nil && SameAsk(kept, a) {
|
||||
return nil // the same ask is there: the teller has it
|
||||
}
|
||||
}
|
||||
_, err = stdio.State("asks").Put(key, json.RawMessage(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
func (kvOperator) Cancel(key string) error { return stdio.State("asks").Delete(key) }
|
||||
|
||||
// Where reads the teller's answer through its tool (ADR 0596 §3), never its bucket.
|
||||
func (kvOperator) Where(key string) (*Answer, error) { return answersOf(stdio.Ask, key) }
|
||||
|
||||
// standing is whether an ask is written under a key in the gate's asks, as this owner last knew. Under the lock.
|
||||
func (h *Holder) standing(key string) bool {
|
||||
for _, d := range h.deliveries {
|
||||
if d.Ready != nil && d.Ready.AskedKey == key && d.Ready.Written {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// markWritten keeps whether an ask stands under a key, on every delivery that asked it. Under the lock.
|
||||
func (h *Holder) markWritten(key string, written bool) {
|
||||
for _, d := range h.deliveries {
|
||||
if d.Ready != nil && d.Ready.AskedKey == key && d.Ready.Written != written {
|
||||
d.Ready.Written = written
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// **A merge into main waits for the operator's Approve** (novox/hq ADR 0398 §3, issue 948): a marked, green delivery
|
||||
// into main is asked of the operator, and merged only after an Approve whose warrant holds for that ask.
|
||||
|
||||
// markedGreen is a pull request into main, announced, judged green and marked ready at its head; the operator is not
|
||||
// answering by himself (w.op.auto is off).
|
||||
func markedGreen(t *testing.T) *world {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Title = "Fix the apostrophe (issue 948, issue 12)"
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
return w
|
||||
}
|
||||
|
||||
func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged before the operator's Approve: %v", w.forge.merges)
|
||||
}
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("the operator is asked %v; wanted exactly one ask", open)
|
||||
}
|
||||
a := w.op.ask(open[0])
|
||||
if !strings.HasPrefix(a.Headline, "Merge into main: app#7") || a.Who != occ.Operator ||
|
||||
len(a.Options) != 2 || a.Options[0].ID != OptionApprove || a.Options[1].ID != OptionDecline {
|
||||
t.Fatalf("the ask is not the merge ask: %+v", a)
|
||||
}
|
||||
for _, want := range []string{"issue 948", "issue 12", "Touches: "} {
|
||||
if !strings.Contains(a.Explanation, want) {
|
||||
t.Fatalf("the ask does not say %q: %s", want, a.Explanation)
|
||||
}
|
||||
}
|
||||
if err := occ.Glance(a.Explanation); err != nil {
|
||||
t.Fatalf("the ask does not read at a glance: %v", err)
|
||||
}
|
||||
// Ticks and minutes pass without an answer: not merged, not asked again.
|
||||
for range 3 {
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
}
|
||||
if len(w.forge.merges) != 0 || w.op.count() != 1 {
|
||||
t.Fatalf("while waiting: merges %v, asks %d", w.forge.merges, w.op.count())
|
||||
}
|
||||
if d := w.delivery(id); d.Ready.AskID != open[0] || !strings.Contains(d.Ready.Waits, "Approve") {
|
||||
t.Fatalf("the ask is not kept on the delivery: %+v", d.Ready)
|
||||
}
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
|
||||
t.Fatalf("after the Approve it was merged %v; wanted once, at the marked head", w.forge.merges)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if d.Ready.Approved == nil || !strings.Contains(d.Ready.Warrant, "chose Approve") {
|
||||
t.Fatalf("the warrant is not kept: %+v", d.Ready)
|
||||
}
|
||||
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||
w.settleAll()
|
||||
var last Transition
|
||||
for _, tr := range w.delivery(id).Transitions {
|
||||
if tr.Event == EvMerged {
|
||||
last = tr
|
||||
}
|
||||
}
|
||||
if !strings.Contains(last.By, "approved: the operator, via telegram") {
|
||||
t.Fatalf("the merge's transition does not name the operator's Approve: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// AC3 of issue 1348: an answer naming old heads merges nothing. The teller's answer to an ask that was withdrawn when the head
|
||||
// moved is still in its bucket; the gate acts on the key of the heads asked now alone.
|
||||
func TestAnAnswerNamingOldHeadsMergesNothing(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
old := w.op.open()[0]
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
w.op.answer(old, OptionApprove) // the teller wrote it after the ask was taken away
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 || len(w.merges.puts) != 0 {
|
||||
t.Fatalf("an answer to old heads merged: %v, handed %d", w.forge.merges, len(w.merges.puts))
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready != nil && d.Ready.Approved != nil {
|
||||
t.Fatalf("an answer to old heads approved the old delivery: %+v", d.Ready)
|
||||
}
|
||||
}
|
||||
|
||||
// An answer under a key that is not the key of the heads the ask covers is refused and said: the gate acts on its own
|
||||
// ask's key alone (the heads hash is the key's last part).
|
||||
func TestAnAnswerUnderAKeyThatIsNotTheHeadsKeyIsRefused(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
key := w.op.open()[0]
|
||||
w.h.mu.Lock()
|
||||
w.h.deliveries[id].Ready.Heads[id] = strings.Repeat("9", 40) // the heads no longer give the key
|
||||
w.h.mu.Unlock()
|
||||
w.op.answer(key, OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("an answer under another key merged: %v", w.forge.merges)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if !strings.Contains(d.Ready.Refused, "does not hold") || d.Ready.Approved != nil {
|
||||
t.Fatalf("the refused answer is not kept: %+v", d.Ready)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "answer") {
|
||||
t.Fatalf("a refused answer is not said loudly: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeclinedMergeIsNotMergedAndSaidOnce(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
w.op.answer(w.op.open()[0], OptionDecline)
|
||||
for range 3 {
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
}
|
||||
if len(w.forge.merges) != 0 || w.op.count() != 1 {
|
||||
t.Fatalf("declined, yet merges %v and asks %d", w.forge.merges, w.op.count())
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "declined") ||
|
||||
!strings.Contains(w.forge.comments[0], "chose Decline") {
|
||||
t.Fatalf("the Decline is not said once on the pull request, with its warrant: %v", w.forge.comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPushAfterTheAskCancelsItAndTheNewHeadIsAskedOnItsOwnWhenItsChecksPass(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
old := w.op.open()[0]
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != old || len(w.op.open()) != 0 {
|
||||
t.Fatalf("the ask was not cancelled: cancelled %v, open %v", w.op.cancelled, w.op.open())
|
||||
}
|
||||
if len(w.forge.merges) != 0 || w.op.count() != 1 {
|
||||
t.Fatalf("merges %v, asks %d", w.forge.merges, w.op.count())
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready.AskID != "" {
|
||||
t.Fatalf("the cancelled ask is still kept as waiting: %+v", d.Ready)
|
||||
}
|
||||
// The new head is asked afresh once its checks pass.
|
||||
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
if open := w.op.open(); len(open) != 1 || open[0] == old {
|
||||
t.Fatalf("the new head was not asked afresh: %v", open)
|
||||
}
|
||||
// Issue 774 (2026-10-31): a pull request merged by another path than the Approve (the lead merged it once its check was
|
||||
// green) left its ask in the gate's asks for a day, and the teller's ask held one of the router's three places. An ask whose
|
||||
// every delivery is merged or ended is taken away at the next look, saying nothing on the pull request.
|
||||
t.Run("774: an ask whose pull request merged without it is taken away", func(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
ask := w.op.open()[0]
|
||||
comments := len(w.forge.comments)
|
||||
w.h.PullMerged(merged("novox/app", 7, readyHead, "m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1m1"))
|
||||
w.settleAll()
|
||||
if len(w.op.open()) != 0 || len(w.op.cancelled) != 1 || w.op.cancelled[0] != ask {
|
||||
t.Fatalf("the ask of a merged pull request stands: open %v, taken away %v", w.op.open(), w.op.cancelled)
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/app", readyHead)); d.Ready.AskID != "" || d.Ready.Written {
|
||||
t.Errorf("the merged delivery still keeps its ask: %+v", d.Ready)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if len(w.op.cancelled) != 1 || w.op.count() != 1 || len(w.forge.comments) != comments {
|
||||
t.Errorf("taken away %v, asks %d, comments %v: once, asking nothing again, saying nothing", w.op.cancelled, w.op.count(), w.forge.comments[comments:])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The ask lapses at the router after AskLasts with nobody's answer, and the gate has no word of it: it takes its key away and
|
||||
// writes it again, once, which the teller asks afresh (it asks again under the same id when the gate writes its key again).
|
||||
func TestAnUnansweredAskIsWrittenAgainOnceWhenItLapses(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
first := w.op.open()[0]
|
||||
w.later(AskLasts + time.Minute)
|
||||
w.settleAll()
|
||||
if w.op.count() != 2 || len(w.op.open()) != 1 || w.op.open()[0] != first || len(w.op.cancelled) != 0 {
|
||||
t.Fatalf("a lapsed ask was not written again once, over the same key: %d asks, open %v, taken away %v", w.op.count(), w.op.open(), w.op.cancelled)
|
||||
}
|
||||
// Review round 1, G2: the router keeps an ask by its id and drops a second under it, so the second asking has an id of its own.
|
||||
again := w.op.byKey[first]
|
||||
againAsk := w.op.ask(first)
|
||||
if again.Asking != 2 || againAsk.ID != mdc.AskingID(first, 2) || againAsk.ID == mdc.AskID(first) {
|
||||
t.Fatalf("the second asking is %d under the id %s; wanted asking 2 under %s", again.Asking, againAsk.ID, mdc.AskingID(first, 2))
|
||||
}
|
||||
for range 3 {
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
}
|
||||
if w.op.count() != 2 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("asked more than once again: %d asks, merges %v", w.op.count(), w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupIsAskedOnceForAllItsHeadsAndMergedInOnePassOnOneApprove(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
|
||||
openPull("novox/mesh-catalog", 2, catHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
if open := w.op.open(); len(open) != 1 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a group is asked once before any merge: open %v, merges %v", w.op.open(), w.forge.merges)
|
||||
}
|
||||
key := w.op.open()[0]
|
||||
a := w.op.ask(key)
|
||||
if !strings.Contains(a.Headline, "group feat") || !strings.Contains(a.Details, "mesh-controller#1") ||
|
||||
!strings.Contains(a.Details, "mesh-catalog#2") {
|
||||
t.Fatalf("the ask does not name the group and its members: %+v", a)
|
||||
}
|
||||
w.op.answer(key, OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || w.op.count() != 1 {
|
||||
t.Fatalf("both members were not merged in one pass on the one Approve: merges %v, asks %d", w.forge.merges, w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// A push to a group member without a mark ends the group's ask (ADR 0398 §3, issue 948; review round 1): the Approve
|
||||
// that follows would be for heads the group no longer has.
|
||||
func TestAPushToAGroupMemberEndsTheGroupsAskAndApproveAndSaysSoOnEveryMember(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
const ctlHead, catHead, catPushed = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2", "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3"
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
|
||||
openPull("novox/mesh-catalog", 2, catHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
first := w.op.open()
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("asked %v", first)
|
||||
}
|
||||
// The catalog member is pushed; its new head has no mark yet.
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catPushed, "feat/x", "modules/app/module.json"))
|
||||
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
|
||||
{Owner: "novox", Repo: "mesh-catalog", Number: 2, Base: "main", HeadSHA: catPushed}}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != first[0] {
|
||||
t.Fatalf("the group's ask was not cancelled by the push: %v", w.op.cancelled)
|
||||
}
|
||||
w.op.answer(first[0], OptionApprove) // a late answer to the cancelled ask
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catPushed, "pass", "t3t3t3t3t3t3"))
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-2", Verdict: "pass", Summary: "both compose again"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("merged on an Approve of heads the group no longer has: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
// mergedSaid is the module events `merged` the world has said.
|
||||
func mergedSaid(w *world) []map[string]any {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
var out []map[string]any
|
||||
for _, m := range w.said {
|
||||
if m["event-kind"] == "merged" {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Issue 1025: a merge on the operator's Approve is announced once, with the warrant's ask, option and heads.
|
||||
func TestIssue1025AMergeOnTheApproveIsAnnouncedWithItsWarrant(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("asks %v", open)
|
||||
}
|
||||
if got := mergedSaid(w); len(got) != 0 {
|
||||
t.Fatalf("announced before the merge: %v", got)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
wantHeads := map[string]string{}
|
||||
for k, v := range d.Ready.Heads {
|
||||
wantHeads[k] = v
|
||||
}
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
w.settleAll()
|
||||
got := mergedSaid(w)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("announced %d times; wanted once: %v", len(got), got)
|
||||
}
|
||||
m := got[0]
|
||||
if m["owner"] != "novox" || m["repo"] != "app" || m["number"] != float64(7) || m["head_sha"] != readyHead ||
|
||||
m["ask_id"] != mdc.AskID(open[0]) || m["asker"] != tellerModule || m["option"] != OptionApprove {
|
||||
t.Fatalf("the event does not name the pull request and its warrant: %v", m)
|
||||
}
|
||||
hs, _ := m["heads"].(map[string]any)
|
||||
if len(hs) == 0 || len(hs) != len(wantHeads) {
|
||||
t.Fatalf("heads %v; wanted %v", hs, wantHeads)
|
||||
}
|
||||
for k, v := range wantHeads {
|
||||
if hs[k] != v {
|
||||
t.Fatalf("heads %v; wanted %v", hs, wantHeads)
|
||||
}
|
||||
}
|
||||
if g, ok := m["group"]; ok && g != "" {
|
||||
t.Fatalf("a lone delivery names group %v", g)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
if n := len(mergedSaid(w)); n != 1 {
|
||||
t.Fatalf("announced %d times after more ticks", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1025: a declined merge announces nothing.
|
||||
func TestIssue1025ADeclinedMergeAnnouncesNothing(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
open := w.op.open()
|
||||
w.op.answer(open[0], OptionDecline)
|
||||
w.settleAll()
|
||||
if got := mergedSaid(w); len(got) != 0 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("declined, yet merges %v, announced %v", w.forge.merges, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1025: a merge of a delivery in a group carries the group id and the heads the group's Approve was bound to.
|
||||
func TestIssue1025AMergeInAGroupCarriesTheGroupAndItsHeads(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.forge.open = []OpenPull{openPull("novox/mesh-controller", 1, ctlHead),
|
||||
openPull("novox/mesh-catalog", 2, catHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("asks %v", open)
|
||||
}
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
w.settleAll()
|
||||
got := mergedSaid(w)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("announced %d; wanted each member's merge once, from the one pass: %v", len(got), got)
|
||||
}
|
||||
for _, m := range got {
|
||||
hs, _ := m["heads"].(map[string]any)
|
||||
if m["group"] != "feat/x" || m["ask_id"] != mdc.AskID(open[0]) || m["asker"] != tellerModule || m["option"] != OptionApprove ||
|
||||
hs[ctl] != ctlHead || hs[cat] != catHead || len(hs) != 2 {
|
||||
t.Fatalf("the group's merge event: %v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1025: the announcement is owed and kept, so it is said after a restart, and once.
|
||||
func TestIssue1025AnOwedMergedIsSaidAfterARestartAndOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
w.h.PullUpdated(p)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.mu.Lock()
|
||||
d := w.h.deliveries[id]
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: `{"owner":"novox","repo":"app","number":7,"head_sha":"` + readyHead + `"}`, Since: w.now})
|
||||
if err := w.store.PutDelivery(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.mu.Unlock()
|
||||
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Minute))
|
||||
again.settleAll()
|
||||
again.later(time.Hour)
|
||||
again.settleAll()
|
||||
if n := len(mergedSaid(again)); n != 1 {
|
||||
t.Fatalf("said %d times after the restart; wanted once", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, G2: an answer given to the first asking is not taken for the second, and the second's Approve is announced
|
||||
// under the second asking's id.
|
||||
func TestAnAnswerToAnEarlierAskingIsNotTakenAndTheMergeNamesTheAnsweredAsking(t *testing.T) {
|
||||
w := markedGreen(t)
|
||||
key := w.op.open()[0]
|
||||
w.later(AskLasts + time.Minute)
|
||||
w.settleAll() // asked again: the second asking
|
||||
w.op.mu.Lock()
|
||||
w.op.answers[key] = &Answer{Option: OptionApprove, Warrant: mdc.AskingID(key, 1) + "@2026-10-06T12:00:00Z", Words: "the operator, via telegram, chose Approve"}
|
||||
w.op.mu.Unlock()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 || len(w.merges.puts) != 0 {
|
||||
t.Fatalf("an answer to the first asking merged on the second: %v", w.forge.merges)
|
||||
}
|
||||
w.op.answer(key, OptionApprove) // the answer to the asking asked now
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the answer to the second asking did not merge: %v", w.forge.merges)
|
||||
}
|
||||
say := mergedSaid(w)
|
||||
if len(say) != 1 || say[0]["ask_id"] != mdc.AskingID(key, 2) {
|
||||
t.Fatalf("the merge is announced %v; wanted the ask id of asking 2, %s", say, mdc.AskingID(key, 2))
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-10-29 (hq#495): a group declined under one ask, a member pushed, and the group asked and approved again at the new
|
||||
// heads: the member whose head did not move kept the earlier Decline beside the new Approve, and nothing was handed, with
|
||||
// no word. An answer stands only for the ask it answers; whichever member is pushed, both merge on the new Approve.
|
||||
func TestADeclineOfAnEarlierAskDoesNotHoldTheApproveOfTheNext(t *testing.T) {
|
||||
const ctlHead, catHead, pushed = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2", "e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3"
|
||||
for _, push := range []struct {
|
||||
repo string
|
||||
number int
|
||||
trunk string
|
||||
}{{"novox/mesh-catalog", 2, "t3t3t3t3t3t3"}, {"novox/mesh-controller", 1, "t1t1t1t1t1t1"}} {
|
||||
t.Run(push.repo, func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
heads := map[string]string{"novox/mesh-controller": ctlHead, "novox/mesh-catalog": catHead}
|
||||
paths := map[string]string{"novox/mesh-controller": "internal/a.go", "novox/mesh-catalog": "modules/app/module.json"}
|
||||
numbers := map[string]int{"novox/mesh-controller": 1, "novox/mesh-catalog": 2}
|
||||
trunks := map[string]string{"novox/mesh-controller": "t1t1t1t1t1t1", "novox/mesh-catalog": "t3t3t3t3t3t3"}
|
||||
round := func(check string) {
|
||||
var open []OpenPull
|
||||
for _, repo := range []string{"novox/mesh-controller", "novox/mesh-catalog"} {
|
||||
w.h.PullUpdated(pr(repo, numbers[repo], heads[repo], "feat/x", paths[repo]))
|
||||
w.h.Checked(judgedAt(repo, numbers[repo], heads[repo], "pass", trunks[repo]))
|
||||
open = append(open, openPull(repo, numbers[repo], heads[repo]))
|
||||
}
|
||||
w.forge.open = open
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: check, Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
}
|
||||
round("check-1")
|
||||
w.op.answer(w.op.open()[0], OptionDecline)
|
||||
w.settleAll()
|
||||
heads[push.repo] = pushed
|
||||
round("check-2")
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("the group at its new heads is not asked once: %v", open)
|
||||
}
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("approved at the new heads, yet merged %v; stalled %+v", w.forge.merges, w.h.Stalled())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mesh-sdk no longer refuses an ask whose id is another's or that offers no option (novox/hq ADR 0627 §4, ruling 1): the gate
|
||||
// holds both, when it composes its ask and when kvOperator writes it.
|
||||
func TestTheGateRefusesAnAskWithAnotherIdOrNoOption(t *testing.T) {
|
||||
heads := []mdc.Head{{Repository: "novox/app", Number: 7, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
|
||||
key, err := mdc.MergeAskKey("feat/g", heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
option := oc.Option{ID: OptionApprove, Label: "Approve", Level: occ.Approve}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
asking int
|
||||
ask oc.Ask
|
||||
refuse string
|
||||
}{
|
||||
{"the first asking's id", 1, oc.Ask{ID: mdc.AskingID(key, 1), Options: []oc.Option{option}}, ""},
|
||||
{"the second asking's id", 2, oc.Ask{ID: mdc.AskingID(key, 2), Options: []oc.Option{option}}, ""},
|
||||
// The refusals are operator-channel's client's (occ.Carried), held to the id the gate's key and asking give.
|
||||
{"another id", 1, oc.Ask{ID: "another-id", Options: []oc.Option{option}}, "the id it was kept under"},
|
||||
{"the first asking's id at the second asking", 2, oc.Ask{ID: mdc.AskingID(key, 1), Options: []oc.Option{option}}, "the id it was kept under"},
|
||||
{"no option", 1, oc.Ask{ID: mdc.AskingID(key, 1)}, "offers no option"},
|
||||
} {
|
||||
a, err := gateAsk("feat/g", heads, c.asking, c.ask)
|
||||
if c.refuse == "" {
|
||||
if err != nil {
|
||||
t.Errorf("%s: refused: %v", c.name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
||||
t.Errorf("%s: composed (%v), wanted a refusal saying %q", c.name, err, c.refuse)
|
||||
}
|
||||
// The same ask written by hand, past gateAsk, is refused where kvOperator writes it.
|
||||
raw, _ := json.Marshal(c.ask)
|
||||
if err := (kvOperator{}).Ask(key, Ask{Group: "feat/g", Heads: heads, Asking: c.asking, Ask: raw}); err == nil || !strings.Contains(err.Error(), c.refuse) {
|
||||
t.Errorf("%s: kvOperator wrote it (%v, %v)", c.name, err, a)
|
||||
}
|
||||
}
|
||||
// A value that carries no router's ask is refused before it is written.
|
||||
if err := (kvOperator{}).Ask(key, Ask{Group: "feat/g", Heads: heads, Asking: 1, Ask: json.RawMessage(`"an ask"`)}); err == nil ||
|
||||
!strings.Contains(err.Error(), "carries no operator-channel ask") {
|
||||
t.Errorf("a value carrying no ask: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// fakeMerges is the gate's bucket merges with the forge bridge behind it. Every pull request the gate writes in the state
|
||||
// merging is merged on the fake forge, as the bridge's merge does (mesh-delivery modules/mesh-delivery-forge-bridge
|
||||
// internal/bridge acts.go MergeActs): the forge's merge tool is asked at the head with the approved heads of the whole
|
||||
// group, `<repository>@<full sha>` sorted, and what the fake forge answers is written on FORGE as the bridge writes it, a
|
||||
// merged message or a refused one with the forge's words. With manual set the bridge says nothing.
|
||||
type fakeMerges struct {
|
||||
mu sync.Mutex
|
||||
forge *fakeForge
|
||||
h *Holder
|
||||
values map[string]Merge
|
||||
puts []Merge
|
||||
manual bool
|
||||
failed error
|
||||
// said are the messages the bridge wrote on FORGE, in order.
|
||||
said []forgemsg.Forge
|
||||
}
|
||||
|
||||
func newFakeMerges(f *fakeForge) *fakeMerges {
|
||||
return &fakeMerges{forge: f, values: map[string]Merge{}}
|
||||
}
|
||||
|
||||
func (m *fakeMerges) Get(group string) (*Merge, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if v, ok := m.values[group]; ok {
|
||||
return &v, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *fakeMerges) Put(group string, v Merge) error {
|
||||
if _, err := EncodeMerge(v); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.failed != nil {
|
||||
defer m.mu.Unlock()
|
||||
return m.failed
|
||||
}
|
||||
m.values[group] = v
|
||||
m.puts = append(m.puts, v)
|
||||
manual := m.manual
|
||||
m.mu.Unlock()
|
||||
if manual {
|
||||
return nil
|
||||
}
|
||||
for _, p := range v.Pulls {
|
||||
if p.State != mdc.MergeMerging {
|
||||
continue
|
||||
}
|
||||
owner, repo, _ := strings.Cut(p.Repository, "/")
|
||||
var approved []string
|
||||
for key, sha := range v.Approved {
|
||||
repository, _, _ := strings.Cut(key, "@")
|
||||
approved = append(approved, repository+"@"+sha)
|
||||
}
|
||||
sortStrings(approved)
|
||||
f := m.forge
|
||||
f.mu.Lock()
|
||||
f.merges = append(f.merges, owner+"/"+repo+"#"+itoa(p.Number)+"@"+p.Head.Head)
|
||||
f.approvedHeads = append(f.approvedHeads, approved)
|
||||
err := f.refuseRepo[owner+"/"+repo]
|
||||
if err == nil {
|
||||
err = f.refuse
|
||||
}
|
||||
f.mu.Unlock()
|
||||
msg := forgemsg.Forge{Repository: p.Repository, Number: p.Number, Head: p.Head.Head}
|
||||
if err != nil {
|
||||
msg.Kind, msg.Act, msg.Words = forgemsg.ForgeRefused, "merge", err.Error()
|
||||
} else {
|
||||
msg.Kind, msg.Merge, msg.Root = forgemsg.ForgeMerged, strings.Repeat("c", 40), "root"
|
||||
msg.Trees = map[string]string{"app": "tree"}
|
||||
}
|
||||
m.say(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// say writes a message on FORGE and has the gate hear it, as the runtime does.
|
||||
func (m *fakeMerges) say(msg forgemsg.Forge) {
|
||||
body, err := forgemsg.EncodeForge(msg)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
event, err := forgemsg.ForgeEvent(msg.Repository, msg.Kind)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.said = append(m.said, msg)
|
||||
m.mu.Unlock()
|
||||
m.h.Forged("mesh-delivery-forge-bridge."+event, body)
|
||||
}
|
||||
|
||||
// last is the newest value written for a group.
|
||||
func (m *fakeMerges) last(group string) Merge {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.values[group]
|
||||
}
|
||||
|
||||
// fakeOld is the gate's old states merges and owed, which only the switch reads.
|
||||
type fakeOld struct {
|
||||
mu sync.Mutex
|
||||
states map[string]map[string]oldEntry
|
||||
}
|
||||
|
||||
type oldEntry struct {
|
||||
raw []byte
|
||||
revision uint64
|
||||
}
|
||||
|
||||
func (o *fakeOld) put(state, key string, raw []byte, revision uint64) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.states[state] == nil {
|
||||
o.states[state] = map[string]oldEntry{}
|
||||
}
|
||||
o.states[state][key] = oldEntry{raw: raw, revision: revision}
|
||||
}
|
||||
|
||||
func (o *fakeOld) Keys(state string) ([]string, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
var out []string
|
||||
for k := range o.states[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (o *fakeOld) Get(state, key string) ([]byte, uint64, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
e, ok := o.states[state][key]
|
||||
if !ok {
|
||||
return nil, 0, nil
|
||||
}
|
||||
return e.raw, e.revision, nil
|
||||
}
|
||||
|
||||
// fakeSource is the gate's bucket source with its one key.
|
||||
type fakeSource struct {
|
||||
mu sync.Mutex
|
||||
main *Source
|
||||
moves []Source
|
||||
}
|
||||
|
||||
func (s *fakeSource) Move(update func(prev *Source) (Source, bool)) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next, write := update(s.main)
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
if _, err := mdc.EncodeSource(next); err != nil {
|
||||
return err
|
||||
}
|
||||
s.main = &next
|
||||
s.moves = append(s.moves, next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read is a branch's source key as held, and its revision: the number of writes. The fake holds the key of MergeBranch only.
|
||||
func (s *fakeSource) Read(branch string) (*Source, uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.main == nil || branch != MergeBranch {
|
||||
return nil, 0, nil
|
||||
}
|
||||
held := *s.main
|
||||
return &held, uint64(len(s.moves)), nil
|
||||
}
|
||||
|
||||
// Branches are the branches whose source key the fake holds.
|
||||
func (s *fakeSource) Branches() ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.main == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []string{MergeBranch}, nil
|
||||
}
|
||||
|
||||
func sortStrings(s []string) { sort.Strings(s) }
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// fakeOwed is the seat's event owed with the forge bridge behind it: every act said is performed on the fake forge, as the
|
||||
// bridge performs it through the seat git (mesh-delivery modules/mesh-delivery-forge-bridge internal/bridge owed.go), once
|
||||
// for each id, and answered as owed.ended performed unless silent is set. With down set nothing can be said (the bus is
|
||||
// away); the forge's own refusals are the bridge's to say, not the gate's to retry.
|
||||
type fakeOwed struct {
|
||||
mu sync.Mutex
|
||||
forge *fakeForge
|
||||
h *Holder
|
||||
down bool
|
||||
silent bool
|
||||
// keys are the latest act said under each key, in the gate's working form, writes every key said in order, and said
|
||||
// every event in order.
|
||||
keys map[string]Owed
|
||||
writes []string
|
||||
said []seat.Owed
|
||||
ids map[string]bool
|
||||
}
|
||||
|
||||
func newFakeOwed(f *fakeForge) *fakeOwed {
|
||||
return &fakeOwed{forge: f, keys: map[string]Owed{}, ids: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (o *fakeOwed) Say(v seat.Owed) error {
|
||||
if _, _, _, switched := mdc.ParseSwitchedActID(v.ID); switched {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.said = append(o.said, v) // the bridge counts it against what the old bridge performed
|
||||
return nil
|
||||
}
|
||||
key, _, found := strings.Cut(v.ID, "@")
|
||||
if !found {
|
||||
return fmt.Errorf("the act's id %q holds no @", v.ID)
|
||||
}
|
||||
repository, number, head, _, err := mdc.ParseOwedKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if repository != v.Repository || int64(number) != v.Number || head != v.Head && v.Commit != head {
|
||||
return fmt.Errorf("the act %s names %s#%d at %s", v.ID, v.Repository, v.Number, v.Head)
|
||||
}
|
||||
o.mu.Lock()
|
||||
if o.down {
|
||||
defer o.mu.Unlock()
|
||||
return errors.New("the bus is away")
|
||||
}
|
||||
o.said = append(o.said, v)
|
||||
if o.ids[v.ID] {
|
||||
// Said again: the bridge performs it once and answers it again from what it performed.
|
||||
silent, h := o.silent, o.h
|
||||
o.mu.Unlock()
|
||||
if !silent && h != nil {
|
||||
return h.OwedEnded(owedEndedBody(v.ID, forgemsg.OwedPerformed, ""))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
o.ids[v.ID] = true
|
||||
working := Owed{Act: v.Act, Context: v.Context, State: v.State, Target: v.Target, Text: v.Text}
|
||||
switch v.Act {
|
||||
case seat.OwedActStatus:
|
||||
working.Text = v.Description
|
||||
case seat.OwedActNote:
|
||||
working.Text = v.Line
|
||||
}
|
||||
o.keys[key] = working
|
||||
o.writes = append(o.writes, key)
|
||||
silent, h := o.silent, o.h
|
||||
o.mu.Unlock()
|
||||
owner, repo, _ := strings.Cut(repository, "/")
|
||||
f := o.forge
|
||||
switch v.Act {
|
||||
case seat.OwedActNote:
|
||||
_ = f.Note(owner, repo, head, v.Line)
|
||||
case seat.OwedActView:
|
||||
_ = f.View(owner, repo, number, v.Text)
|
||||
case seat.OwedActStatus:
|
||||
_ = f.Status(owner, repo, head, v.Context, v.State, v.Description, v.Target)
|
||||
case seat.OwedActComment:
|
||||
_ = f.Comment(owner, repo, number, v.Text)
|
||||
}
|
||||
if !silent && h != nil {
|
||||
return h.OwedEnded(owedEndedBody(v.ID, forgemsg.OwedPerformed, ""))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// owedEndedBody is the forge bridge's owed.ended for one of the gate's ids.
|
||||
func owedEndedBody(id, outcome, words string) []byte {
|
||||
raw, err := forgemsg.EncodeOwedEnded(forgemsg.OwedEnded{Of: seat.Seat, ID: id, Outcome: outcome, Words: words,
|
||||
At: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// acts are the keys written whose act is of a kind, in order.
|
||||
func (o *fakeOwed) acts(kind string) []string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
var out []string
|
||||
for _, k := range o.writes {
|
||||
if _, _, _, act, err := mdc.ParseOwedKey(k); err == nil && actKind(act) == kind {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -5,8 +5,6 @@ package main
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -15,16 +13,12 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -133,8 +127,7 @@ type runtime struct {
|
||||
// operatorCancels are the ids of the asks taken back from the router, in order (issue 1079).
|
||||
operatorCancels []string
|
||||
// owedSaid are the subjects of the seat events owed the gate said, in order (W11.17).
|
||||
owedSaid []string
|
||||
openPulls []OpenPull
|
||||
owedSaid []string
|
||||
}
|
||||
|
||||
type askedOfMesh struct {
|
||||
@@ -358,43 +351,10 @@ func (r *runtime) answer(m rpcMessage) {
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.asked = append(r.asked, askedOfMesh{Key: a.Key, Body: a.Body, At: time.Now()})
|
||||
pulls := append([]OpenPull{}, r.openPulls...)
|
||||
r.mu.Unlock()
|
||||
switch a.Key {
|
||||
case "":
|
||||
return nil, errors.New("an ask with no key")
|
||||
case "seat:git.open-pulls":
|
||||
var open []gitseat.OpenPull
|
||||
for _, p := range pulls {
|
||||
open = append(open, seatOpenPull(p))
|
||||
}
|
||||
return gitseat.OpenPullsAnswer{Pulls: open}, nil
|
||||
case "seat:git.pull":
|
||||
number, _ := a.Body["number"].(float64)
|
||||
repository, _ := a.Body["repository"].(string)
|
||||
for _, p := range pulls {
|
||||
if p.Owner+"/"+p.Repo == repository && p.Number == int(number) {
|
||||
return gitseat.PullAnswer{Pull: &gitseat.Pull{Repository: repository, Number: int64(p.Number), Base: p.Base,
|
||||
Head: p.HeadSHA, State: gitseat.PullStateOpen}, Merging: &gitseat.Merging{Mergeable: true, Protected: true,
|
||||
Required: []string{"mesh/check"}, Writes: true}}, nil
|
||||
}
|
||||
}
|
||||
return gitseat.PullAnswer{Error: "no such pull request"}, nil
|
||||
case "seat:git.statuses":
|
||||
// Green: mesh/check a success at the head asked, as the seat answers.
|
||||
commit, _ := a.Body["commit"].(string)
|
||||
return gitseat.StatusesAnswer{Commit: commit, Statuses: []gitseat.Status{{Context: "mesh/check", State: "success"}}}, nil
|
||||
case "mesh-delivery-teller.teller_answers":
|
||||
key, _ := a.Body["key"].(string)
|
||||
r.b.mu.Lock()
|
||||
raw, found := r.b.answers[key]
|
||||
r.b.mu.Unlock()
|
||||
if !found {
|
||||
return map[string]any{"key": key, "found": false}, nil
|
||||
}
|
||||
return map[string]any{"key": key, "found": true, "answer": json.RawMessage(raw)}, nil
|
||||
case "mesh-delivery-view.view_reach":
|
||||
return map[string]any{"reach": []json.RawMessage{}}, 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
|
||||
@@ -557,377 +517,3 @@ func (b *testBus) publish(t *testing.T, subject string, body any) time.Time {
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealBus(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
rt := b.start(t)
|
||||
evctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
rt.deliverEvents(evctx)
|
||||
// The module has started when it hears what it follows.
|
||||
rt.untilListening()
|
||||
for _, k := range followed {
|
||||
if strings.Contains(k, "labeled") || strings.Contains(k, "commented") {
|
||||
t.Fatalf("the module follows %s: a label or a comment starts nothing here (ADR 0421 §4)", k)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = "a1a1a1a1a1a1"
|
||||
announced.Title = "Fix the apostrophe (issue 975)"
|
||||
b.publish(t, "mesh.seat.git.event.pull-updated", seatUpdated(announced))
|
||||
rt.until("the pull request's key to hold its head", 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.Head == head && k.Trunk == "a1a1a1a1a1a1"
|
||||
})
|
||||
b.checkPasses(t, rt, "fix/x", "a1a1a1a1a1a1", judgedPull{"novox/app", 7, head})
|
||||
|
||||
// The description holds no review line and nothing is waited for: the ask is written into the gate's bucket asks within
|
||||
// seconds of the verdict, with no label, comment or mark written by anyone (the gate reads no description, ADR 0493 §6, ADR 0659).
|
||||
edit := time.Now()
|
||||
var key string
|
||||
rt.until("the ask to be written", eventWithin, func() bool { key = onlyAsk(b); return key != "" })
|
||||
ask, err := askOf(askAt(t, b, key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("the ask was written %s after the verdict", time.Since(edit))
|
||||
if strings.Contains(strings.ToLower(ask.Explanation), "review") || ask.ID != mdc.AskID(key) {
|
||||
t.Fatalf("the ask names a review or has the wrong id: %s", ask.Explanation)
|
||||
}
|
||||
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 {
|
||||
t.Fatalf("merged before the Approve")
|
||||
}
|
||||
// The pull request's key holds its head, the trunk commit it was asked against and the verdict.
|
||||
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: "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).
|
||||
for _, tool := range []string{"gitea.gitea_commit_status", "gitea.gitea_delivery_view", "gitea.gitea_note_append", "gitea.gitea_add_comment"} {
|
||||
if n := len(rt.asksOf(tool)); n != 0 {
|
||||
t.Fatalf("the gate asked %s %d times: the bridge writes to the forge", tool, n)
|
||||
}
|
||||
}
|
||||
rt.until("the acts owed to be said as the seat's event owed", eventWithin, func() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
return len(rt.owedSaid) > 0 && strings.HasSuffix(rt.owedSaid[0], ".7")
|
||||
})
|
||||
// The operator approves; the teller holds the warrant and writes the answer: the merge is handed to the bridge within
|
||||
// seconds, with the Approve's heads, and the gate asks the forge nothing.
|
||||
approved := b.answer(t, key, OptionApprove)
|
||||
var handed Merge
|
||||
rt.until("the merge to be handed to the bridge", eventWithin, func() bool { handed = mergeAt(b, "fix/x"); return len(handed.Pulls) > 0 })
|
||||
if len(handed.Pulls) != 1 || handed.Pulls[0].Head.Head != head || handed.Pulls[0].Number != 7 || handed.Pulls[0].State != mdc.MergeMerging ||
|
||||
handed.Approved[mdc.ApprovedKey("novox/app", 7)] != head || handed.Ask != key {
|
||||
t.Fatalf("the merge handed is %+v; wanted pull request 7 at the approved head, merging, under the ask %s", handed, key)
|
||||
}
|
||||
t.Logf("the merge was handed %s after the answer", time.Since(approved))
|
||||
if n := len(rt.asksOf("gitea.gitea_merge_pull_request")); n != 0 {
|
||||
t.Fatalf("the gate asked the forge to merge %d times: the bridge merges", n)
|
||||
}
|
||||
// The bridge says it merged; the mesh's source then holds the merge, in one write.
|
||||
b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40),
|
||||
Root: "r", Trees: map[string]string{"app": "t"}})
|
||||
// The forge's own word that the pull request merged follows the bridge's (its webhook): the delivery ends merged.
|
||||
b.publish(t, "mesh.seat.git.event.pull-merged", seatMerged(merged("novox/app", 7, head, strings.Repeat("b", 40))))
|
||||
rt.until("source.main to hold the merge", eventWithin, func() bool {
|
||||
e, err := b.kvs["source"].Get(ctx, mdc.SourceKey(MergeBranch))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
src, err := mdc.DecodeSource(e.Value())
|
||||
return err == nil && src.Group == "fix/x" && src.Heads["novox/app"] == strings.Repeat("b", 40)
|
||||
})
|
||||
// The seat's verb source answers it from the bucket, with its revision on the bus.
|
||||
said := strings.ReplaceAll(rt.call(t, Seat+".source", map[string]any{}), `\"`, `"`)
|
||||
if !strings.Contains(said, `{"head":"`+strings.Repeat("b", 40)+`","repository":"novox/app","trunk":"main"}`) || !strings.Contains(said, `"revision":`) {
|
||||
t.Fatalf("source answers %s", said)
|
||||
}
|
||||
// The delivery ends merged (ADR 0539 §17), and the gate asked the controller nothing (R6-C3).
|
||||
rt.until("the delivery to be merged", eventWithin, func() bool {
|
||||
e, err := b.kvs["deliveries"].Get(ctx, kvKey(IDOf("novox/app", head)))
|
||||
var d Delivery
|
||||
return err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Merged
|
||||
})
|
||||
rt.noControllerAsk()
|
||||
// W11.16 (acceptance 34, 39): the delivery is traced in one record of the ledger, which says its fill; the gate is stopped
|
||||
// and started again over the same bus and ledger: the record is whole and opened once, the merge is not handed again, and
|
||||
// the ask of the Approve already given is not asked again.
|
||||
recordsNow := func(r *runtime) map[string]any {
|
||||
var said struct {
|
||||
Result struct {
|
||||
Content []struct{ Text string } `json:"content"`
|
||||
} `json:"result"`
|
||||
}
|
||||
raw := r.call(t, "gate_records", map[string]any{})
|
||||
if err := json.Unmarshal([]byte(raw), &said); err != nil || len(said.Result.Content) == 0 {
|
||||
t.Fatalf("gate_records answered %s", raw)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(said.Result.Content[0].Text), &out); err != nil {
|
||||
t.Fatalf("gate_records says %s", said.Result.Content[0].Text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before := recordsNow(rt)
|
||||
if kept, _ := before["kept"].(float64); kept != 1 || before["fill"] == nil || before["fill"] == "" {
|
||||
t.Fatalf("one delivery is traced in %v records, fill %v", before["kept"], before["fill"])
|
||||
}
|
||||
merges := len(rt.mergesSaid())
|
||||
_ = rt.in.Close()
|
||||
_ = rt.cmd.Wait()
|
||||
rt2 := b.start(t)
|
||||
evctx2, cancel2 := context.WithCancel(ctx)
|
||||
defer cancel2()
|
||||
rt2.deliverEvents(evctx2)
|
||||
rt2.untilListening()
|
||||
after := recordsNow(rt2)
|
||||
if !reflect.DeepEqual(before["records"], after["records"]) || after["kept"] != before["kept"] {
|
||||
t.Fatalf("the record changed over a restart: %v then %v", before["records"], after["records"])
|
||||
}
|
||||
rt2.until("the restarted gate to hold the delivery merged", eventWithin, func() bool {
|
||||
e, err := b.kvs["deliveries"].Get(ctx, kvKey(IDOf("novox/app", head)))
|
||||
var d Delivery
|
||||
return err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Merged
|
||||
})
|
||||
if n := len(rt2.mergesSaid()); n != merges || len(onlyAskKeys(b)) > 1 {
|
||||
t.Fatalf("the restarted gate said the merge %d more times (%d before), or asked again: %v", n, merges, onlyAskKeys(b))
|
||||
}
|
||||
// Quiet follows: nothing asks the forge or the controller again with no event to say so.
|
||||
quiet := len(rt.asked)
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
rt.mu.Lock()
|
||||
more := len(rt.asked) - quiet
|
||||
rt.mu.Unlock()
|
||||
if more > 3 {
|
||||
t.Fatalf("%d asks of the mesh in a quiet second and a half after the merge: something polls", more)
|
||||
}
|
||||
}
|
||||
|
||||
// onlyAskKeys are the keys of every ask in the gate's bucket asks.
|
||||
func onlyAskKeys(b *testBus) []string {
|
||||
keys, _ := b.kvs["asks"].Keys(context.Background())
|
||||
return keys
|
||||
}
|
||||
|
||||
// onlyAsk is the key of the one ask in the gate's bucket asks, or empty.
|
||||
func onlyAsk(b *testBus) string {
|
||||
keys, err := b.kvs["asks"].Keys(context.Background())
|
||||
if err != nil || len(keys) != 1 {
|
||||
return ""
|
||||
}
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
// askNow is the ask under a key at this moment; false in the instant a rewrite has taken the key away and not yet written it.
|
||||
func askNow(b *testBus, key string) (Ask, bool) {
|
||||
e, err := b.kvs["asks"].Get(context.Background(), key)
|
||||
if err != nil {
|
||||
return Ask{}, false
|
||||
}
|
||||
a, err := DecodeAsk(e.Value())
|
||||
return a, err == nil
|
||||
}
|
||||
|
||||
func askNowOrFatal(t *testing.T, b *testBus, key string) Ask {
|
||||
t.Helper()
|
||||
a, ok := askNow(b, key)
|
||||
if !ok {
|
||||
t.Fatalf("no ask under %s to answer", key)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func askAt(t *testing.T, b *testBus, key string) Ask {
|
||||
t.Helper()
|
||||
e, err := b.kvs["asks"].Get(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatalf("the ask %s: %v", key, err)
|
||||
}
|
||||
a, err := DecodeAsk(e.Value())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func mergeAt(b *testBus, group string) Merge {
|
||||
e, err := b.kvs["merges"].Get(context.Background(), group)
|
||||
if err != nil {
|
||||
return Merge{}
|
||||
}
|
||||
m, _ := DecodeMerge(e.Value())
|
||||
return m
|
||||
}
|
||||
|
||||
// answer is the teller's answer to an ask, written under the ask's own key in its bucket answers.
|
||||
func (b *testBus) answer(t *testing.T, key, option string) time.Time {
|
||||
t.Helper()
|
||||
// The warrant's id is the router id of the asking answered and the time (the teller's warrantID).
|
||||
asked, err := askOf(askNowOrFatal(t, b, key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := mdc.EncodeAnswer(Answer{Option: option, Warrant: asked.ID + "@2026-10-20T03:00:00Z", Words: "the operator, via telegram, chose " + option})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The teller writes the answer under the ask's key and says it as the event answered (ADR 0596 §2).
|
||||
b.mu.Lock()
|
||||
b.answers[key] = raw
|
||||
b.mu.Unlock()
|
||||
b.publish(t, "mesh.mod.mesh-delivery-teller.event.answered", map[string]any{"key": key, "ask": asked.ID, "answer": json.RawMessage(raw)})
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// forgeSays is the forge bridge's message on FORGE, as its stream carries it.
|
||||
func (b *testBus) forgeSays(t *testing.T, f forgemsg.Forge) {
|
||||
t.Helper()
|
||||
raw, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, err := forgemsg.ForgeEvent(f.Repository, f.Kind)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.js.Publish(context.Background(), "mesh.mod."+forgeBridgeModule+".event."+event, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// **The acceptance of one group, one Approve** (novox/hq ADR 0418 §4, ADR 0421, ADR 0441 §5, design 47 milestone 3, issues 978
|
||||
// and 1022): a group of two pull requests on one branch name, in two repositories, with green checks, gets one ask of the
|
||||
// operator — no label, comment or mark written by anyone — and on the Approve the members are handed to the bridge one at a time,
|
||||
// the second after the first merged, each with the approved heads of both.
|
||||
func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterTheOtherOverARealBus(t *testing.T) {
|
||||
b := newTestBus(t)
|
||||
ctx := context.Background()
|
||||
rt := b.start(t)
|
||||
evctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
rt.deliverEvents(evctx)
|
||||
rt.untilListening()
|
||||
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 = "a1a1a1a1a1a1"
|
||||
b.publish(t, "mesh.seat.git.event.pull-updated", seatUpdated(p))
|
||||
}
|
||||
// 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})
|
||||
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)
|
||||
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 1 {
|
||||
t.Fatalf("the operator was asked %d times for one group", len(keys))
|
||||
}
|
||||
if g := askAt(t, b, key).Group; g != "feat/x" {
|
||||
t.Fatalf("the ask is for the group %q", g)
|
||||
}
|
||||
if len(rt.asksOf("gitea.gitea_merge_pull_request")) != 0 || mergeAt(b, "feat/x").Group != "" {
|
||||
t.Fatalf("merged before the Approve")
|
||||
}
|
||||
approved := b.answer(t, key, OptionApprove)
|
||||
|
||||
// One member is handed, with both heads approved, within seconds of the answer; the second only after the bridge says the
|
||||
// first merged.
|
||||
var first Merge
|
||||
rt.until("the first member to be handed", eventWithin, func() bool { first = mergeAt(b, "feat/x"); return len(first.Pulls) > 0 })
|
||||
if len(first.Pulls) != 1 || first.Pulls[0].State != mdc.MergeMerging || len(first.Approved) != 2 ||
|
||||
first.Approved[mdc.ApprovedKey("novox/mesh-controller", 1)] != ctlHead || first.Approved[mdc.ApprovedKey("novox/mesh-catalog", 2)] != catHead {
|
||||
t.Fatalf("the first merge handed is %+v", first)
|
||||
}
|
||||
t.Logf("the first member was handed %s after the answer", time.Since(approved))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if again := mergeAt(b, "feat/x"); len(again.Pulls) != 1 {
|
||||
t.Fatalf("the second member was handed before the first merged: %+v", again.Pulls)
|
||||
}
|
||||
one := first.Pulls[0]
|
||||
b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: one.Repository, Number: one.Number, Head: one.Head.Head,
|
||||
Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}})
|
||||
var both Merge
|
||||
rt.until("the second member to be handed", eventWithin, func() bool { both = mergeAt(b, "feat/x"); return len(both.Pulls) == 2 })
|
||||
if both.Pulls[0].State != mdc.MergeMerged || both.Pulls[1].State != mdc.MergeMerging || both.Pulls[1].Repository == one.Repository {
|
||||
t.Fatalf("the second merge handed is %+v", both.Pulls)
|
||||
}
|
||||
two := both.Pulls[1]
|
||||
b.forgeSays(t, forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: two.Repository, Number: two.Number, Head: two.Head.Head,
|
||||
Merge: strings.Repeat("b", 40), Root: "r", Trees: map[string]string{"x": "t"}})
|
||||
rt.until("source.main to hold both merges", eventWithin, func() bool {
|
||||
e, err := b.kvs["source"].Get(ctx, mdc.SourceKey(MergeBranch))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
src, err := mdc.DecodeSource(e.Value())
|
||||
return err == nil && src.Group == "feat/x" && len(src.Heads) == 2
|
||||
})
|
||||
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 1 {
|
||||
t.Fatalf("a second ask was made: %v", keys)
|
||||
}
|
||||
// The forge's own word that each pull request merged follows the bridge's (its webhook), after the second was handed (the
|
||||
// checker judges the members still open by the trunk the first merge moved, which this test does not play): both members
|
||||
// end merged, the group with them, and the controller was never called (R6-C3).
|
||||
b.publish(t, "mesh.seat.git.event.pull-merged", seatMerged(merged(one.Repository, one.Number, one.Head.Head, strings.Repeat("a", 40))))
|
||||
b.publish(t, "mesh.seat.git.event.pull-merged", seatMerged(merged(two.Repository, two.Number, two.Head.Head, strings.Repeat("b", 40))))
|
||||
for _, id := range []string{IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)} {
|
||||
id := id
|
||||
rt.until(id+" to be merged", eventWithin, func() bool {
|
||||
e, err := b.kvs["deliveries"].Get(ctx, kvKey(id))
|
||||
var d Delivery
|
||||
return err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Merged
|
||||
})
|
||||
}
|
||||
rt.until("the group to read merged", eventWithin, func() bool {
|
||||
e, err := b.kvs["groups"].Get(ctx, kvKey("feat/x"))
|
||||
var g Group
|
||||
return err == nil && json.Unmarshal(e.Value(), &g) == nil && g.Said == "merged"
|
||||
})
|
||||
rt.noControllerAsk()
|
||||
}
|
||||
|
||||
func mustDecode[T any](t *testing.T, raw []byte) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// eventWithin is how long a test waits for one step of the gate's path over the real bus. On an idle machine each step
|
||||
// takes milliseconds (the tests log it), but a slow, loaded build node ran the ask step in 6.69 s against the old 5 s
|
||||
// (issue 1404). A failed first ask is asked again only after askAgainAfter (30 s, react.go), and 20 s deliberately does not
|
||||
// cover that: a failed first ask still fails the test.
|
||||
const eventWithin = 20 * time.Second
|
||||
|
||||
// mergesSaid are the writes of the gate's state merges, the merges it handed to the forge bridge, in order (the old form of
|
||||
// the merge: eb131beb, the merge as an owed act, is left out of the join).
|
||||
func (r *runtime) mergesSaid() []string {
|
||||
ctx := context.Background()
|
||||
kv := r.b.kvs["merges"]
|
||||
keys, err := kv.Keys(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, k := range keys {
|
||||
hist, err := kv.History(ctx, k)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range hist {
|
||||
out = append(out, k+"@"+strconv.FormatUint(e.Revision(), 10))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// shimCheckedKey is the key a test hears a verdict under: said as the checker's (see Checked).
|
||||
const shimCheckedKey = "mesh-controller.checked"
|
||||
|
||||
// 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"`
|
||||
}
|
||||
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
|
||||
}
|
||||
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 := mdc.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
|
||||
@@ -1,60 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// 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 []mdc.Head
|
||||
trunks := map[string]string{}
|
||||
for _, p := range pulls {
|
||||
heads = append(heads, mdc.Head{Repository: p.repository, Number: p.number, Head: p.head})
|
||||
trunks[p.repository] = trunk
|
||||
}
|
||||
hash, err := mdc.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.seat.git.event.forge-status", gitseat.ForgeStatusEvent{Repository: owner + "/" + repo, Commit: p.head,
|
||||
Context: ctxCheck, State: "success", Description: line})
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The `checks` verb (novox/hq ADR 0239): what the mesh's checks said of a pull request's head, or of one
|
||||
// commit. Two records answer it, and neither alone is enough. The forge keeps each status — the newest of
|
||||
// each context, a description clipped to what it shows, and the protection that decides whether the merge
|
||||
// button works. This owner keeps the verdict whole as the controller said it: each layer's summary
|
||||
// unclipped ("0 of 4 compose" is not lost to a 140-character description), the build seat's ask that ran
|
||||
// it, the machine it ran on, and its report. So the forge is asked for its statuses and this owner joins
|
||||
// its own verdict to the two statuses that verdict set. Nothing is written: a read, by anyone.
|
||||
|
||||
// Context names of the statuses this owner can say more of than the forge keeps.
|
||||
const (
|
||||
// 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"
|
||||
)
|
||||
|
||||
// CheckStatus is one status of the commit, with what this owner knows of it beyond the forge.
|
||||
type CheckStatus struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SetAt string `json:"set_at,omitempty"`
|
||||
SetBy string `json:"set_by,omitempty"`
|
||||
Link string `json:"link,omitempty"`
|
||||
// From the verdict that set it, when this owner holds it: the build seat's ask, the machine that ran it,
|
||||
// when it was heard, and the layer's verdict in full.
|
||||
Build string `json:"build,omitempty"`
|
||||
CheckedOn string `json:"checked_on,omitempty"`
|
||||
CheckedAt string `json:"checked_at,omitempty"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
// Source says where the status was read: the forge, or — the forge unreachable — this owner's verdict.
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ChecksAnswer is the `checks` verb's answer.
|
||||
type ChecksAnswer struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Pull *ForgePull `json:"pull,omitempty"`
|
||||
Delivery *ChecksOf `json:"delivery,omitempty"`
|
||||
Statuses []CheckStatus `json:"statuses"`
|
||||
// Verdict is the merge check's verdict whole, as this owner heard it for this commit.
|
||||
Verdict *Verdict `json:"verdict,omitempty"`
|
||||
// Judged is what the verdict judged: the head merged into the trunk commit (novox/hq ADR 0323 §3); OutOfDate
|
||||
// why it no longer counts, when its trunk has moved since.
|
||||
Judged string `json:"judged,omitempty"`
|
||||
OutOfDate string `json:"out_of_date,omitempty"`
|
||||
// Merge is whether the branch's protection lets the commit merge; Mergeable nil when it is not known.
|
||||
Merge ForgeMerge `json:"merge"`
|
||||
// Forge is why the forge could not be read, when it could not.
|
||||
Forge string `json:"forge,omitempty"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// ChecksOf is the delivery of the commit, in brief: `show` has it whole.
|
||||
type ChecksOf struct {
|
||||
ID string `json:"id"`
|
||||
State State `json:"state"`
|
||||
Since string `json:"since"`
|
||||
Waits string `json:"waits,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
// MergedAs is the commit its merge landed as on the trunk, once merged: what the delivery view's `delivered` event is
|
||||
// matched to a fix by (ADR 0539 clause 17).
|
||||
MergedAs string `json:"merged_as,omitempty"`
|
||||
// Opened and MergedAt are what the issue tracker measures a fix's time by (novox/hq ADR 0313): when its pull request
|
||||
// was first heard, and when it merged.
|
||||
Opened string `json:"opened,omitempty"`
|
||||
MergedAt string `json:"merged_at,omitempty"`
|
||||
}
|
||||
|
||||
// Checks is the `checks` verb: a repository and a pull request's number, or a repository and a commit.
|
||||
func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAnswer, error) {
|
||||
repository = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repository), ".git"))
|
||||
owner, repo, ok := strings.Cut(repository, "/")
|
||||
if !ok || owner == "" || repo == "" || strings.Contains(repo, "/") {
|
||||
return nil, fmt.Errorf("%q is not owner/repository", repository)
|
||||
}
|
||||
commit = strings.ToLower(strings.TrimSpace(commit))
|
||||
if number < 0 {
|
||||
return nil, fmt.Errorf("%d is not a pull request's number", number)
|
||||
}
|
||||
if number == 0 && commit == "" {
|
||||
return nil, errors.New("name a pull request's number or a commit")
|
||||
}
|
||||
if commit != "" && (len(commit) < 7 || strings.Trim(commit, "0123456789abcdef") != "") {
|
||||
return nil, fmt.Errorf("%q is not a commit: seven or more hexadecimal characters", commit)
|
||||
}
|
||||
|
||||
// What this owner knows first, for the base whose protection is read; the forge then says the head.
|
||||
base := ""
|
||||
if d := h.deliveryOf(repository, number, commit); d != nil {
|
||||
base = d.Base
|
||||
}
|
||||
a := &ChecksAnswer{Repository: repository, Commit: commit}
|
||||
fc, err := h.Forge.Statuses(owner, repo, number, commit, base)
|
||||
if err != nil {
|
||||
a.Forge = err.Error()
|
||||
} else {
|
||||
a.Commit, a.Pull = fc.Commit, fc.Pull
|
||||
}
|
||||
|
||||
d := h.deliveryOf(repository, number, a.Commit)
|
||||
if a.Commit == "" && d != nil {
|
||||
a.Commit = d.Commit // the forge away: the newest head this owner holds for the pull request
|
||||
}
|
||||
if d != nil {
|
||||
a.Delivery = &ChecksOf{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), Waits: d.waits,
|
||||
Group: d.Group, MergedAs: d.MergedAs}
|
||||
if !d.opened.IsZero() {
|
||||
a.Delivery.Opened = d.opened.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !d.MergedAt.IsZero() {
|
||||
a.Delivery.MergedAt = d.MergedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if d.Check != nil && sameCommit(d.Commit, a.Commit) {
|
||||
a.Verdict = d.Check
|
||||
a.Judged, a.OutOfDate = d.Check.Judged(), d.outOfDate()
|
||||
}
|
||||
}
|
||||
|
||||
if fc != nil {
|
||||
for _, s := range fc.Statuses {
|
||||
a.Statuses = append(a.Statuses, CheckStatus{Name: s.Context, State: s.State, Description: s.Description,
|
||||
SetAt: firstOf(s.UpdatedAt, s.CreatedAt), SetBy: s.Creator, Link: s.TargetURL, Source: "the forge"})
|
||||
}
|
||||
a.Merge = fc.Merge
|
||||
} 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: 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"}
|
||||
}
|
||||
for i := range a.Statuses {
|
||||
h.addWhatIsKnown(&a.Statuses[i], a.Verdict, d)
|
||||
}
|
||||
sort.SliceStable(a.Statuses, func(i, j int) bool { return a.Statuses[i].Name < a.Statuses[j].Name })
|
||||
a.Says = a.says()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// checksDelivery is the part of a delivery `checks` reads, copied under the lock.
|
||||
type checksDelivery struct {
|
||||
Delivery
|
||||
waits string
|
||||
groupSaid string
|
||||
opened time.Time
|
||||
}
|
||||
|
||||
// deliveryOf is the delivery of a commit — its head, or the commit it merged as — else, for a pull request,
|
||||
// its newest head that was not superseded, else its newest.
|
||||
func (h *Holder) deliveryOf(repository string, number int, commit string) *checksDelivery {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var found *Delivery
|
||||
if commit != "" {
|
||||
for _, d := range h.deliveries {
|
||||
if strings.EqualFold(d.Repository, repository) && (sameCommit(d.Commit, commit) || sameCommit(d.MergedAs, commit)) {
|
||||
found = d
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if number > 0 {
|
||||
for _, d := range h.deliveries {
|
||||
if !strings.EqualFold(d.Repository, repository) || d.Number != number {
|
||||
continue
|
||||
}
|
||||
if found == nil || newerHead(d, found) {
|
||||
found = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return nil
|
||||
}
|
||||
c := &checksDelivery{Delivery: *found, waits: h.waitsFor(found), opened: h.openedOf(found)}
|
||||
if found.Check != nil {
|
||||
v := *found.Check
|
||||
c.Check = &v
|
||||
}
|
||||
if g := h.groups[found.Group]; g != nil {
|
||||
state, why := GroupState(g, h.membersOf(g))
|
||||
c.groupSaid = state + ": " + why
|
||||
if g.Check != nil && g.Check.Verdict != "" {
|
||||
c.groupSaid += "; composed check " + g.Check.Verdict + " — " + g.Check.Summary
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// newerHead is whether d is a pull request's head to read before e: one not superseded first, then the newest.
|
||||
func newerHead(d, e *Delivery) bool {
|
||||
if (d.State == Closed) != (e.State == Closed) {
|
||||
return e.State == Closed
|
||||
}
|
||||
return d.Created.After(e.Created)
|
||||
}
|
||||
|
||||
// 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 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 {
|
||||
s.Verdict = string(d.State)
|
||||
if d.waits != "" {
|
||||
s.Verdict += ": waits for " + d.waits
|
||||
}
|
||||
}
|
||||
case ctxGroup:
|
||||
if d != nil && d.groupSaid != "" {
|
||||
s.Verdict = d.groupSaid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forgeStateOf is the forge state the forge's holder sets for a verdict (the gitea module's pulls.ts): a
|
||||
// warning is a pass with a note, an error never a success.
|
||||
func forgeStateOf(verdict string) string {
|
||||
switch verdict {
|
||||
case "pass", "warning":
|
||||
return "success"
|
||||
case "fail":
|
||||
return "failure"
|
||||
}
|
||||
return "error"
|
||||
}
|
||||
|
||||
func (a *ChecksAnswer) says() string {
|
||||
what := a.Repository + "@" + shortOf(a.Commit, 8)
|
||||
if a.Pull != nil {
|
||||
what = fmt.Sprintf("%s#%d at %s", a.Repository, a.Pull.Number, shortOf(a.Commit, 8))
|
||||
}
|
||||
var parts []string
|
||||
for _, s := range a.Statuses {
|
||||
parts = append(parts, s.Name+" "+s.State)
|
||||
}
|
||||
statuses := "no status"
|
||||
if len(parts) > 0 {
|
||||
statuses = strings.Join(parts, ", ")
|
||||
}
|
||||
merge := a.Merge.Says
|
||||
switch {
|
||||
case a.Merge.Mergeable == nil:
|
||||
merge = "mergeable: not known — " + merge
|
||||
case *a.Merge.Mergeable:
|
||||
merge = "mergeable — " + merge
|
||||
default:
|
||||
merge = "NOT mergeable — " + merge
|
||||
}
|
||||
if a.Judged != "" {
|
||||
what += " (" + a.Judged + ")"
|
||||
}
|
||||
if a.OutOfDate != "" {
|
||||
merge += "; its verdict is " + a.OutOfDate
|
||||
}
|
||||
return what + ": " + statuses + "; " + merge
|
||||
}
|
||||
|
||||
func firstOf(s ...string) string {
|
||||
for _, x := range s {
|
||||
if x != "" {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The `checks` verb (novox/hq ADR 0239): what the mesh's checks said of a pull request's head or a commit —
|
||||
// the forge's statuses, the verdict whole as the controller said it, and whether the base's protection lets
|
||||
// it merge.
|
||||
|
||||
// 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, "/")
|
||||
return CheckedEvent{Owner: owner, Repo: name, Number: number, Commit: sha, Verdict: gate, Summary: gateSaid, ID: "build-" + sha[:8]}
|
||||
}
|
||||
|
||||
// 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, ctxCheck, forgeStateOf(c.Verdict), clip(c.Verdict+": "+c.Summary, 40), "https://forge.invalid/pr")
|
||||
}
|
||||
|
||||
func statusNamed(a *ChecksAnswer, name string) *CheckStatus {
|
||||
for i := range a.Statuses {
|
||||
if a.Statuses[i].Name == name {
|
||||
return &a.Statuses[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestChecksOfAPullRequestSayEveryStatusTheVerdictWholeAndWhetherItMerges(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
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)
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
statusesAsTheForgeSets(w, "novox/app", head, c)
|
||||
|
||||
a, err := w.h.Checks("novox/app", 7, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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, ctxCheck)
|
||||
if gate == nil || gate.State != "failure" || gate.Source != "the forge" || gate.SetBy != "mesh-admin" {
|
||||
t.Fatalf("the check's status is %+v", gate)
|
||||
}
|
||||
// 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 — failed: 0 of 4 compose") || !strings.Contains(gate.Verdict, "three more machines") {
|
||||
t.Fatalf("the check's verdict is %q", gate.Verdict)
|
||||
}
|
||||
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.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 != 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") {
|
||||
t.Fatalf("it says %q", a.Says)
|
||||
}
|
||||
// The forge was asked for the pull request, with the base this owner knew.
|
||||
if len(w.forge.asked) != 1 || w.forge.asked[0] != "novox/app#7@ base=main" {
|
||||
t.Fatalf("the forge was asked %v", w.forge.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewHeadIsReadWithItsOwnVerdictNeverTheOlderOnes(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
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"))
|
||||
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}
|
||||
|
||||
a, err := w.h.Checks("novox/app", 7, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Commit != newer || a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", newer) || a.Delivery.State != Proposed {
|
||||
t.Fatalf("the new head reads as %q, %+v", a.Commit, a.Delivery)
|
||||
}
|
||||
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) != 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")
|
||||
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, 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")
|
||||
w.h.Checked(c)
|
||||
statusesAsTheForgeSets(w, "novox/app", head, c)
|
||||
|
||||
a, err := w.h.Checks("Novox/App.git", 0, head[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Commit != head || a.Verdict == nil || a.Delivery == nil || a.Pull != nil {
|
||||
t.Fatalf("by the start of its sha: %q, %+v, %+v", a.Commit, a.Verdict, a.Delivery)
|
||||
}
|
||||
// No protection: nothing is required, so it merges.
|
||||
if a.Merge.Mergeable == nil || !*a.Merge.Mergeable {
|
||||
t.Fatalf("with no protection: %+v", a.Merge)
|
||||
}
|
||||
|
||||
mergedAs := "dddddddddddd9999"
|
||||
w.h.PullMerged(merged("novox/app", 7, head, mergedAs))
|
||||
_ = w.forge.Status("novox", "app", mergedAs, ctxDelivery, "pending", "published", "")
|
||||
a, err = w.h.Checks("novox/app", 0, mergedAs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Delivery == nil || a.Delivery.ID != IDOf("novox/app", head) {
|
||||
t.Fatalf("the merge commit's delivery is %+v", a.Delivery)
|
||||
}
|
||||
if a.Verdict != nil {
|
||||
t.Fatalf("the head's verdict was given for the merge commit: %+v", a.Verdict)
|
||||
}
|
||||
if s := statusNamed(a, ctxDelivery); s == nil || s.State != "pending" || s.Verdict == "" {
|
||||
t.Fatalf("the merge's delivery status: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
w.forge.down = true
|
||||
|
||||
a, err := w.h.Checks("novox/app", 7, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 := 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)
|
||||
}
|
||||
|
||||
// The protection unreadable: the statuses are said, and whether it merges is not known.
|
||||
w.forge.down, w.forge.protectionDown = false, true
|
||||
w.forge.pulls["novox/app#7"] = ForgePull{Number: 7, Base: "main", HeadSHA: head}
|
||||
a, _ = w.h.Checks("novox/app", 7, "")
|
||||
if a.Merge.Mergeable != nil || a.Forge != "" {
|
||||
t.Fatalf("with the protection unreadable: %+v", a.Merge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksRefuseWhatNamesNoCommit(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
for _, c := range []struct {
|
||||
repo string
|
||||
number int
|
||||
commit string
|
||||
says string
|
||||
}{
|
||||
{"novox/app", 0, "", "number or a commit"},
|
||||
{"app", 7, "", "owner/repository"},
|
||||
{"novox/app/x", 7, "", "owner/repository"},
|
||||
{"novox/app", 0, "abc", "not a commit"},
|
||||
{"novox/app", 0, "zzzzzzzzzz", "not a commit"},
|
||||
{"novox/app", -1, "", "not a pull request's number"},
|
||||
} {
|
||||
if _, err := w.h.Checks(c.repo, c.number, c.commit); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s #%d %q: %v", c.repo, c.number, c.commit, err)
|
||||
}
|
||||
}
|
||||
if len(w.forge.asked) != 0 {
|
||||
t.Fatalf("the forge was asked for what names nothing: %v", w.forge.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANumberIsTakenAsANumberOrAsText(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in any
|
||||
want int
|
||||
ok bool
|
||||
}{{float64(168), 168, true}, {"168", 168, true}, {"#168", 168, true}, {nil, 0, true}, {"", 0, true},
|
||||
{"x", 0, false}, {1.5, 0, false}} {
|
||||
got, err := intArg(map[string]any{"number": c.in}, "number")
|
||||
if (err == nil) != c.ok || got != c.want {
|
||||
t.Errorf("%v → %d %v", c.in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
// lastCheckTime is how long the newest answered check of a repository ran, as its verdict says it (`took`): the
|
||||
// repository's last measured check time (novox/hq ADR 0505 §4). Zero when no verdict of the repository says it.
|
||||
// Under the lock.
|
||||
func (h *Holder) lastCheckTime(repository string) time.Duration {
|
||||
var newest *Verdict
|
||||
var took time.Duration
|
||||
for _, d := range h.deliveries {
|
||||
v := d.Check
|
||||
if d.Repository != repository || v == nil || v.Took == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.ParseDuration(v.Took)
|
||||
if err != nil || t <= 0 {
|
||||
continue
|
||||
}
|
||||
if newest == nil || v.At.After(newest.At) {
|
||||
newest, took = v, t
|
||||
}
|
||||
}
|
||||
return took
|
||||
}
|
||||
|
||||
// answeredRefusalBound is how long a refusal the gate is already answering waits before it is said as stalled: twice
|
||||
// the repository's last measured check time, at least the floor (ADR 0505 §4). Under the lock.
|
||||
func (h *Holder) answeredRefusalBound(repository string) time.Duration {
|
||||
if b := 2 * h.lastCheckTime(repository); b > h.refusalFloor() {
|
||||
return b
|
||||
}
|
||||
return h.refusalFloor()
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func conditionKeyOf(id string) string { return "mesh-delivery-gate.delivery." +
|
||||
|
||||
// conditionSaid is what a raised condition said that, when it changes, raises it again: its severity and what it needs.
|
||||
// The words of a line hold durations, which change at every look and are no reason to tell the operator again.
|
||||
func conditionSaid(l StalledLine) string { return severityOfLine(l) + "|" + l.H2 + "|" + l.Needs }
|
||||
func conditionSaid(l StalledLine) string { return severityOfLine(l) + "|" + l.H2 }
|
||||
|
||||
func severityOfLine(l StalledLine) string {
|
||||
if l.Severity == occ.SeverityUrgent {
|
||||
@@ -79,10 +79,6 @@ func severityOfLine(l StalledLine) string {
|
||||
// conditionOf is a stalled line as the condition the operator is told.
|
||||
func conditionOf(l StalledLine) oc.Condition {
|
||||
headline := "stalled: " + l.ID
|
||||
if strings.HasPrefix(l.Says, "group ") {
|
||||
group, _, _ := strings.Cut(strings.TrimPrefix(l.Says, "group "), " ")
|
||||
headline = "group " + group + " is not merged whole"
|
||||
}
|
||||
if r := []rune(headline); len(r) > occ.HeadlineLength {
|
||||
headline = string(r[:occ.HeadlineLength-1]) + "…"
|
||||
}
|
||||
@@ -109,16 +105,9 @@ func (h *Holder) sayConditions() {
|
||||
}
|
||||
h.said = said
|
||||
}
|
||||
h.mu.Lock()
|
||||
if at := h.nextStall(h.Now()); !at.IsZero() {
|
||||
h.lookAt(at, "a delivery whose stalled line, or its severity, comes due with no event")
|
||||
h.lookAt(at, "a record whose stalled line comes due with no event")
|
||||
}
|
||||
forgeRead := h.forgeRead
|
||||
waitingOnForge := map[string]bool{}
|
||||
for id := range h.unchecked {
|
||||
waitingOnForge[id] = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
lines := map[string]StalledLine{}
|
||||
for _, l := range h.Stalled() {
|
||||
// One condition for an id: the most severe of its lines.
|
||||
@@ -146,13 +135,11 @@ func (h *Holder) sayConditions() {
|
||||
h.Logf("[mesh-delivery] %s is said to the operator as %s: %s", id, severityOfLine(l), l.Says)
|
||||
h.said[key], changed = said, true
|
||||
}
|
||||
// Cleared only on what was read: not before the forge's open pull requests were read once since the gate started (the
|
||||
// heads waiting on the forge are kept in memory alone), and not while the forge still shows the head open and waiting:
|
||||
// its line is gone only because its bound runs again from a later first sight, and it comes back with the same words.
|
||||
// Cleared when the line is gone: what it raised is kept in the bucket conditions, so a gate that restarts clears what ended.
|
||||
var gone []string
|
||||
for key := range h.said {
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(key, "mesh-delivery-gate.delivery."), ".stalled")
|
||||
if _, ok := lines[id]; !ok && forgeRead && !waitingOnForge[id] {
|
||||
if _, ok := lines[id]; !ok {
|
||||
gone = append(gone, key)
|
||||
}
|
||||
}
|
||||
@@ -194,36 +181,30 @@ func (h *Holder) sayConditions() {
|
||||
// sayTries is how many looks in a row a condition that could not be said is tried again on the clock.
|
||||
const sayTries = 10
|
||||
|
||||
// nextStall is the earliest time after now when a delivery's stalled line comes due, or its severity changes, with nothing
|
||||
// else happening: a state's bound, the author's bound of a rejected head, a head never announced, a refusal the gate
|
||||
// answers, and an out-of-date refusal turning urgent (1275/AC6). Zero when none. Under the lock.
|
||||
// nextStall is the earliest time after now when a record's stalled line comes due with nothing else happening: a record open
|
||||
// past its bound, an act unanswered past its. Zero when none.
|
||||
func (h *Holder) nextStall(now time.Time) time.Time {
|
||||
var next time.Time
|
||||
if h.Records == nil {
|
||||
return next
|
||||
}
|
||||
consider := func(at time.Time) {
|
||||
if at.After(now) && (next.IsZero() || at.Before(next)) {
|
||||
next = at
|
||||
}
|
||||
}
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() {
|
||||
continue
|
||||
kept, err := h.Records.Kept()
|
||||
if err != nil {
|
||||
return next
|
||||
}
|
||||
for _, r := range kept {
|
||||
if r.Ended == nil {
|
||||
consider(r.Opened.Add(recordOpenBound).Add(time.Second))
|
||||
}
|
||||
if b, bounded := boundOf(d); bounded && b.For > 0 {
|
||||
consider(boundSince(d).Add(b.For).Add(time.Second)) // pastBound is strictly after the bound
|
||||
}
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" {
|
||||
continue
|
||||
}
|
||||
if d.Base == "" && d.Number != 0 {
|
||||
consider(r.Seen.Add(headHeardBound))
|
||||
}
|
||||
if r.Failed != "" && d.State == Rejected {
|
||||
consider(d.Since.Add(h.authorBound()))
|
||||
}
|
||||
if r.RefusedSince != nil {
|
||||
consider(r.RefusedSince.Add(h.answeredRefusalBound(d.Repository)))
|
||||
consider(r.RefusedSince.Add(outOfDateFor))
|
||||
for _, a := range r.Acts {
|
||||
if a.Outcome == "" {
|
||||
consider(a.At.Add(owedBound).Add(time.Second))
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A config value held or dropped for a pull request asks its check again (novox/hq ADR 0502 clause 5, issue 1826).
|
||||
//
|
||||
// The controller's composed check lays the approved values held for a pull request (mesh-config's `held`) when it
|
||||
// composes, so a check run before a value was held, or after it was dropped, judged another mesh. mesh-config
|
||||
// raises `config-changed` with the action hold or drop and the pull request; this owner then asks that pull
|
||||
// request's check, or its group's composed check, again, the way it asks a moved head: its own path, with the
|
||||
// reason in the delivery's transition, and not the `recheck` verb, which stays an exception (ADR 0421 §12).
|
||||
|
||||
// ConfigChangedEvent is the event this owner follows from mesh-config.
|
||||
const ConfigChangedEvent = "mesh-config.config-changed"
|
||||
|
||||
// ConfigChange is mesh-config's `config-changed` as far as this owner reads it.
|
||||
type ConfigChange struct {
|
||||
Action string `json:"action"`
|
||||
Pull string `json:"pull,omitempty"`
|
||||
Head string `json:"head,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Level struct {
|
||||
Node string `json:"node,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
} `json:"level"`
|
||||
}
|
||||
|
||||
var pullNamed = regexp.MustCompile(`^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#([0-9]+)$`)
|
||||
|
||||
// ConfigChanged takes a hold or a drop of a value for a pull request: every live delivery of that pull request has its
|
||||
// check asked again. Anything else mesh-config says (a value set, a resolution, the merge's set) asks nothing.
|
||||
func (h *Holder) ConfigChanged(c ConfigChange) {
|
||||
if c.Action != "hold" && c.Action != "drop" {
|
||||
return
|
||||
}
|
||||
m := pullNamed.FindStringSubmatch(strings.TrimSpace(c.Pull))
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
number, _ := strconv.Atoi(m[3])
|
||||
repository := strings.ToLower(m[1] + "/" + m[2])
|
||||
verb := "held for"
|
||||
if c.Action == "drop" {
|
||||
verb = "dropped from"
|
||||
}
|
||||
why := fmt.Sprintf("a config value was %s this pull request: %s of %s on %s (mesh-config)", verb, c.Key,
|
||||
orWord(c.Level.Module, "a module"), orWord(c.Level.Node, "a node"))
|
||||
h.mu.Lock()
|
||||
var ids []string
|
||||
for id, d := range h.deliveries {
|
||||
if d.Number == number && strings.EqualFold(d.Repository, repository) && d.MergedAs == "" && !d.State.Final() {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
h.askAgainForConfig(id, why)
|
||||
}
|
||||
}
|
||||
|
||||
func orWord(s, word string) string {
|
||||
if s == "" {
|
||||
return word
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// askAgainForConfig asks one delivery's check again: a rejected or ready one is proposed again, as any judged again
|
||||
// head is; a proposed one has its pending ask replaced. Its group's composed check goes out of date, and is asked
|
||||
// once a member is judged again.
|
||||
func (h *Holder) askAgainForConfig(id, why string) {
|
||||
h.mu.Lock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
probe := *d
|
||||
probe.Transitions = append([]Transition(nil), d.Transitions...)
|
||||
_, refused := Apply(&probe, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: byOwner})
|
||||
proposed := d.State == Proposed
|
||||
h.mu.Unlock()
|
||||
switch {
|
||||
case refused == nil:
|
||||
h.Logf("[mesh-delivery] %s: its check could not be asked again for a config value: %v", id, checkerRefusal(id))
|
||||
case proposed:
|
||||
h.askProposedAgain(id, why)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.Logf("[mesh-delivery] %s: a held config value changed; %v", id, checkerRefusal(id))
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What a transition owes outside the state (novox/hq ADR 0239 decision 5), each by its owner: the event on
|
||||
// the bus (said by this module), a line on the commit's note and the pull request's view and statuses (by
|
||||
// the forge's holder, asked through its tools). Owed, never lost: kept with the delivery before anything is
|
||||
// tried, and tried again until done.
|
||||
|
||||
// transitionEvent is what `mesh-delivery-gate.transition` says: no secret, no address. opened is when its pull
|
||||
// request was first heard (zero when not known); with its class and its merge's moment it is what the issue
|
||||
// tracker measures a fix's time by (novox/hq ADR 0313 §2, §4).
|
||||
func transitionEvent(d *Delivery, t Transition, opened time.Time) map[string]any {
|
||||
e := map[string]any{"id": d.ID, "repository": d.Repository, "commit": d.Commit, "from": t.From, "to": t.To,
|
||||
"event": t.Event, "why": t.Why, "at": t.At}
|
||||
if t.By != "" {
|
||||
e["by"] = t.By
|
||||
}
|
||||
if d.Number > 0 {
|
||||
e["number"] = d.Number
|
||||
}
|
||||
if d.Group != "" {
|
||||
e["group"] = d.Group
|
||||
}
|
||||
if d.MergedAs != "" {
|
||||
e["merged_as"] = d.MergedAs
|
||||
}
|
||||
if !opened.IsZero() {
|
||||
e["opened"] = opened.UTC()
|
||||
}
|
||||
if !d.MergedAt.IsZero() {
|
||||
e["merged_at"] = d.MergedAt.UTC()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// openedOf is when a delivery's pull request was first heard: the earliest head of it this owner holds, never
|
||||
// one made from the forge's word at its merge; zero when none is held or it has no pull request. Under the lock.
|
||||
func (h *Holder) openedOf(d *Delivery) time.Time {
|
||||
if d.Number <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
var first time.Time
|
||||
for _, c := range h.deliveries {
|
||||
if c.FromForge || c.Number != d.Number || !strings.EqualFold(c.Repository, d.Repository) || c.Created.IsZero() {
|
||||
continue
|
||||
}
|
||||
if first.IsZero() || c.Created.Before(first) {
|
||||
first = c.Created
|
||||
}
|
||||
}
|
||||
if first.IsZero() && !d.FromForge {
|
||||
first = d.Created
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
// noteLine is one transition as the commit's note keeps it.
|
||||
func noteLine(d *Delivery, t Transition) string {
|
||||
by := ""
|
||||
if t.By != "" {
|
||||
by = " by " + t.By
|
||||
}
|
||||
line := fmt.Sprintf("%s delivery %s: %s -> %s (%s%s): %s", t.At.UTC().Format(time.RFC3339), d.ID, orNothing(t.From),
|
||||
t.To, t.Event, by, t.Why)
|
||||
if d.Group != "" && (t.To == Proposed || t.To == Merged) {
|
||||
line += "; group " + d.Group
|
||||
}
|
||||
return oneLine(line)
|
||||
}
|
||||
|
||||
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
|
||||
|
||||
// statusOf is the commit status `mesh/delivery`: where the delivery stands, in the forge's words. It says the merge's
|
||||
// states only (ADR 0539 §17): whether a merged change reached the machines is the view's.
|
||||
func statusOf(d *Delivery) (string, string) {
|
||||
why := ""
|
||||
if len(d.Transitions) > 0 {
|
||||
why = d.Transitions[len(d.Transitions)-1].Why
|
||||
}
|
||||
switch d.State {
|
||||
case Proposed:
|
||||
return "pending", "checking"
|
||||
case Ready:
|
||||
return "success", "ready: it is merged on the Approve"
|
||||
case Rejected:
|
||||
return "failure", clip("rejected: "+why, 140)
|
||||
case Merged:
|
||||
return "success", clip("merged: "+d.sourceSaid(), 140)
|
||||
case Held:
|
||||
return "pending", clip("held for a person: "+d.HeldWhy, 140)
|
||||
case Closed:
|
||||
return "warning", clip("closed: "+why, 140)
|
||||
}
|
||||
return "pending", string(d.State)
|
||||
}
|
||||
|
||||
// sourceSaid names the source revision a merge moved: the trunk commit of its repository that source.main now holds
|
||||
// (ADR 0539 §17: the gate's last word on a change; what the machines run is the view's).
|
||||
func (d *Delivery) sourceSaid() string {
|
||||
if d.MergedAs == "" {
|
||||
return "not on the trunk"
|
||||
}
|
||||
return "source.main moved to " + d.Repository + "@" + shortOf(d.MergedAs, 12)
|
||||
}
|
||||
|
||||
// viewMarker is how the forge's holder finds the one view comment of a pull request.
|
||||
const viewMarker = "<!-- mesh-delivery:view -->"
|
||||
|
||||
// ViewBody is a delivery's view on its pull request, written from the delivery whole.
|
||||
func ViewBody(d *Delivery, g *Group) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339))
|
||||
if d.HeldWhy != "" && d.State == Held {
|
||||
fmt.Fprintf(&b, "Held for a person: %s — the merge reached the trunk without a passing check.\n\n", d.HeldWhy)
|
||||
}
|
||||
if d.MergedAs != "" {
|
||||
fmt.Fprintf(&b, "**Source** — %s. Whether it reached the machines is `mesh-delivery-view`'s, derived from "+
|
||||
"`source.main`, the wants and the haves.\n\n", d.sourceSaid())
|
||||
}
|
||||
if g != nil {
|
||||
fmt.Fprintf(&b, "**Group** `%s`: %s\n", g.ID, strings.Join(g.Members, ", "))
|
||||
if g.Check != nil && g.Check.Verdict != "" {
|
||||
fmt.Fprintf(&b, "- composed together: %s — %s\n", g.Check.Verdict, g.Check.Summary)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("**Transitions**\n")
|
||||
from := 0
|
||||
if len(d.Transitions) > 12 {
|
||||
from = len(d.Transitions) - 12
|
||||
}
|
||||
for _, t := range d.Transitions[from:] {
|
||||
fmt.Fprintf(&b, "- %s %s → %s (%s): %s\n", t.At.UTC().Format("2006-01-02 15:04"), orNothing(t.From), t.To, t.Event, t.Why)
|
||||
}
|
||||
b.WriteString("\nThe commit's note under `refs/notes/mesh-plan` keeps every transition of this change up to its merge: `git log --notes=mesh-plan`.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Flush does what is owed, kept deliveries first: a delivery not yet kept says nothing.
|
||||
func (h *Holder) Flush() {
|
||||
type job struct {
|
||||
id string
|
||||
group bool
|
||||
e Effect
|
||||
body string
|
||||
repo string
|
||||
num int
|
||||
head string
|
||||
url string
|
||||
}
|
||||
h.mu.Lock()
|
||||
now := h.Now()
|
||||
for id := range h.dirty {
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
for id := range h.dirtyGroup {
|
||||
if g := h.groups[id]; g != nil {
|
||||
h.keepGroup(g)
|
||||
}
|
||||
}
|
||||
var jobs []job
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
// An act said and not yet answered by its owed.ended waits for that answer; at this owner's start it is said once more,
|
||||
// under the same id (design 47: every unanswered act said again at its start).
|
||||
again := !h.saidAtStart
|
||||
h.saidAtStart = true
|
||||
waiting := func(e Effect) bool {
|
||||
return e.Next != nil && now.Before(*e.Next) || e.Said != nil && !again
|
||||
}
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
if h.dirty[id] {
|
||||
continue
|
||||
}
|
||||
for i, e := range d.Owed {
|
||||
if waiting(e) {
|
||||
continue
|
||||
}
|
||||
if e.ID == "" {
|
||||
// The id is kept before the act is said, so its owed.ended finds it however soon it is heard.
|
||||
if e.ID = actIDOf(e, d.Repository, d.Number, d.Commit, d.HTMLURL, false); e.ID != "" {
|
||||
d.Owed[i].ID = e.ID
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
j := job{id: id, e: e, repo: d.Repository, num: d.Number, head: d.Commit, url: d.HTMLURL}
|
||||
if e.Kind == EffectView {
|
||||
j.body = ViewBody(d, h.groups[d.Group])
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
}
|
||||
for id, g := range h.groups {
|
||||
if h.dirtyGroup[id] {
|
||||
continue
|
||||
}
|
||||
for i, e := range g.Owed {
|
||||
if waiting(e) {
|
||||
continue
|
||||
}
|
||||
if e.ID == "" {
|
||||
if e.ID = actIDOf(e, "", 0, "", "", true); e.ID != "" {
|
||||
g.Owed[i].ID = e.ID
|
||||
h.keepGroup(g)
|
||||
}
|
||||
}
|
||||
jobs = append(jobs, job{id: id, group: true, e: e})
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, j := range jobs {
|
||||
said, err := h.do(j.e, j.repo, j.num, j.head, j.body, j.url, j.group)
|
||||
h.mu.Lock()
|
||||
// Why it is given up, when it is: a failure of the bus tried retryLimit times, the last ones retryCap apart.
|
||||
dropped := ""
|
||||
if err != nil && j.e.Tries+1 >= retryLimit {
|
||||
dropped = fmt.Sprintf("tried %d times since %s", j.e.Tries+1, j.e.Since.UTC().Format(time.RFC3339))
|
||||
}
|
||||
owed := func(list []Effect) []Effect {
|
||||
for i, o := range list {
|
||||
if o.Kind == j.e.Kind && o.Commit == j.e.Commit && o.Context == j.e.Context && o.Line == j.e.Line &&
|
||||
o.Since.Equal(j.e.Since) {
|
||||
if err == nil && said != "" {
|
||||
// Said: it stays until its owed.ended is heard (OwedEnded).
|
||||
at := h.Now()
|
||||
if o.Said != nil {
|
||||
at = *o.Said
|
||||
}
|
||||
list[i].ID, list[i].Said, list[i].Next, list[i].Tries = said, &at, nil, 0
|
||||
return list
|
||||
}
|
||||
if err == nil || dropped != "" {
|
||||
return append(list[:i:i], list[i+1:]...)
|
||||
}
|
||||
list[i].Tries++
|
||||
if o.Kind != EffectStatus || !j.group {
|
||||
list[i].Last = err.Error() // a group's status names its member in Last, which stays
|
||||
}
|
||||
next := h.Now().Add(retryAfter(list[i].Tries))
|
||||
list[i].Next = &next
|
||||
return list
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
if j.group {
|
||||
if g := h.groups[j.id]; g != nil {
|
||||
g.Owed = owed(g.Owed)
|
||||
h.keepGroup(g)
|
||||
}
|
||||
} else if d := h.deliveries[j.id]; d != nil {
|
||||
d.Owed = owed(d.Owed)
|
||||
h.keep(d)
|
||||
}
|
||||
// Counted for delivery_status, so a window's writes can be judged against its tries (novox/hq issue 407).
|
||||
h.tried.last = h.Now()
|
||||
switch {
|
||||
case err == nil:
|
||||
h.tried.done++
|
||||
case dropped != "":
|
||||
h.tried.givenUp++
|
||||
default:
|
||||
h.tried.failed++
|
||||
}
|
||||
if dropped != "" {
|
||||
h.refuse(fmt.Errorf("%s: its owed %s given up — %s: %v", j.id, j.e.Kind, dropped, err))
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if err != nil && dropped == "" && j.e.Tries%10 == 0 {
|
||||
h.Logf("[mesh-delivery] %s: %s not done yet (%v); tried again in %s", j.id, j.e.Kind, err,
|
||||
retryAfter(j.e.Tries+1))
|
||||
}
|
||||
}
|
||||
h.owedAgain()
|
||||
}
|
||||
|
||||
// owedAgain says when what is still owed is tried again: an effect the forge or the bus could not take, at the time
|
||||
// retryAfter gave it, and a delivery or group not yet kept, after retryFirst. Flush is the bounded retry of both.
|
||||
func (h *Holder) owedAgain() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for id := range h.dirty {
|
||||
if h.deliveries[id] != nil {
|
||||
h.lookAt(h.Now().Add(retryFirst), "a delivery not yet kept, kept again before it is said")
|
||||
}
|
||||
}
|
||||
for id := range h.dirtyGroup {
|
||||
if h.groups[id] != nil {
|
||||
h.lookAt(h.Now().Add(retryFirst), "a group not yet kept, kept again before it is said")
|
||||
}
|
||||
}
|
||||
for _, d := range h.deliveries {
|
||||
for _, e := range d.Owed {
|
||||
if e.Next != nil {
|
||||
h.lookAt(*e.Next, "an owed effect the forge or the bus could not take")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, g := range h.groups {
|
||||
for _, e := range g.Owed {
|
||||
if e.Next != nil {
|
||||
h.lookAt(*e.Next, "an owed effect the forge or the bus could not take")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An owed effect that fails is tried again later each time: retryFirst after the first failure, doubling up
|
||||
// to retryCap, and given up after retryLimit tries — a little over two days — saying so (novox/hq issue 313).
|
||||
const (
|
||||
retryFirst = 10 * time.Second
|
||||
retryCap = time.Hour
|
||||
retryLimit = 60
|
||||
)
|
||||
|
||||
// retryAfter is how long after its tries-th failure an effect is tried again.
|
||||
func retryAfter(tries int) time.Duration {
|
||||
wait := retryFirst
|
||||
for i := 1; i < tries && wait < retryCap; i++ {
|
||||
wait *= 2
|
||||
}
|
||||
return min(wait, retryCap)
|
||||
}
|
||||
|
||||
// do is one owed effect: said on the bus, or said as the seat's event owed, which the forge bridge performs; said is the
|
||||
// act's id when it was said as owed, and empty when nothing waits for an answer.
|
||||
func (h *Holder) do(e Effect, repository string, number int, head, body, url string, group bool) (said string, err error) {
|
||||
switch e.Kind {
|
||||
case EffectEmit:
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(e.Line), &payload); err != nil {
|
||||
return "", nil // nothing sayable: dropped
|
||||
}
|
||||
event := "transition"
|
||||
if group {
|
||||
event = "group"
|
||||
}
|
||||
if e.Event != "" {
|
||||
event = e.Event
|
||||
}
|
||||
return "", h.Emit(event, payload)
|
||||
case EffectView:
|
||||
if number == 0 {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
page := url
|
||||
if group && e.Kind == EffectStatus {
|
||||
// A group's status names its member's repository and page in Last.
|
||||
repository, number, page = memberOfLast(e.Last)
|
||||
head = e.Commit
|
||||
}
|
||||
act, value, num, hd := owedOf(e, repository, number, head, body, page)
|
||||
if act == "" {
|
||||
return "", nil
|
||||
}
|
||||
return h.owe(repository, num, hd, act, value, e.Since, actSays(e), e.ID)
|
||||
}
|
||||
|
||||
// actIDOf is the id an effect is said under as owed, as do says it; empty for an effect said otherwise or not at all.
|
||||
func actIDOf(e Effect, repository string, number int, head, url string, group bool) string {
|
||||
switch {
|
||||
case e.Kind == EffectEmit, e.Kind == EffectView && number == 0:
|
||||
return ""
|
||||
case group && e.Kind == EffectStatus:
|
||||
repository, number, url = memberOfLast(e.Last)
|
||||
head = e.Commit
|
||||
}
|
||||
act, _, num, hd := owedOf(e, repository, number, head, "", url)
|
||||
if act == "" {
|
||||
return ""
|
||||
}
|
||||
key, err := mdc.OwedKey(repository, num, hd, act)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return actID(key, e.Since, actSays(e))
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R6-C4 (ADR 0539 §17): the delivery's status and view say the merge's six states and nothing after the merge: no delivery plan,
|
||||
// no machine step, no covered or walk line; a merged delivery's view names the source revision its merge moved.
|
||||
func TestStatusAndViewSayEachOfTheSixStatesWithNoPlanAndNoMachineStep(t *testing.T) {
|
||||
at := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
|
||||
base := Delivery{ID: "novox/app@c0ffee000000", Repository: "novox/app", Commit: "c0ffee000000", Number: 7, Since: at,
|
||||
Transitions: []Transition{{At: at, From: Proposed, To: Rejected, Event: EvRefused, Why: "the gate said fail"}}}
|
||||
for _, c := range []struct {
|
||||
state State
|
||||
mutate func(d *Delivery)
|
||||
status string
|
||||
desc string
|
||||
inView []string
|
||||
keepOut []string
|
||||
}{
|
||||
{Proposed, nil, "pending", "checking", nil, nil},
|
||||
{Ready, nil, "success", "ready: it is merged on the Approve", nil, nil},
|
||||
{Rejected, nil, "failure", "rejected: the gate said fail", nil, nil},
|
||||
{Merged, func(d *Delivery) { d.MergedAs = "bbbbbbbbbbbb1111" }, "success",
|
||||
"merged: source.main moved to novox/app@bbbbbbbbbbbb", []string{"**Source**", "source.main moved to novox/app@bbbbbbbbbbbb", "mesh-delivery-view"}, nil},
|
||||
{Held, func(d *Delivery) { d.MergedAs, d.HeldWhy = "bbbbbbbbbbbb1111", "merged without a passing check" }, "pending",
|
||||
"held for a person: merged without a passing check", []string{"Held for a person: merged without a passing check"}, nil},
|
||||
{Closed, nil, "warning", "closed: the gate said fail", nil, nil},
|
||||
} {
|
||||
d := base
|
||||
d.State = c.state
|
||||
if c.mutate != nil {
|
||||
c.mutate(&d)
|
||||
}
|
||||
state, desc := statusOf(&d)
|
||||
if state != c.status || desc != c.desc {
|
||||
t.Errorf("%s: the status is %s %q, wanted %s %q", c.state, state, desc, c.status, c.desc)
|
||||
}
|
||||
view := ViewBody(&d, nil)
|
||||
if !strings.Contains(view, "**"+string(c.state)+"**") {
|
||||
t.Errorf("%s: the view does not say the state: %s", c.state, view)
|
||||
}
|
||||
for _, want := range c.inView {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("%s: the view lacks %q: %s", c.state, want, view)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{"**Delivery plan**", "**Machines**", "tier ", "receives", "walk", "Covered", "plans go", "mesh-delivery.release"} {
|
||||
if strings.Contains(view, never) {
|
||||
t.Errorf("%s: the view still says %q: %s", c.state, never, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A merge not on the trunk names no source revision.
|
||||
d := base
|
||||
d.State = Merged
|
||||
if s := d.sourceSaid(); s != "not on the trunk" {
|
||||
t.Errorf("a merged delivery with no merge commit says %q", s)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Events that start a step (novox/hq ADR 0406 §1, issue 975, ADR 0421 issue 1022).
|
||||
//
|
||||
// This owner acts on events: a verdict, a commit status, an Approve. The operator is asked within the event that makes
|
||||
// the Approve possible (the checks passing), with no label or comment written by anyone: the forge's label and comment
|
||||
// events start nothing here.
|
||||
|
||||
// ForgeStatusEvent is the forge module's `forge.status`: a commit's status was set.
|
||||
type ForgeStatusEvent struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
SHA string `json:"sha"`
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
// Description is the status's words (the seat git's forge.status carries them, issue 1302): mesh/check's
|
||||
// line names the head and the trunk it judged.
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// wantForge says the forge's open pull requests are to be read at the next look: a check may have answered.
|
||||
func (h *Holder) wantForge() {
|
||||
h.mu.Lock()
|
||||
h.forgeWanted = true
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// takeForgeWanted answers whether the forge's open pull requests are to be read, and takes the want.
|
||||
func (h *Holder) takeForgeWanted() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
wanted := h.forgeWanted
|
||||
h.forgeWanted = false
|
||||
return wanted
|
||||
}
|
||||
|
||||
// AnswersChanged takes a change of the teller's answers: the answers to the asks pending are read at the look it asks for.
|
||||
func (h *Holder) AnswersChanged() {
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// A merge check turning success for the head an approved, ready, not-yet-merged delivery's merge was refused at is
|
||||
// itself an event that decides whether it may merge (issue 1144): the merge tool may have refused it on a check still
|
||||
// pending, which this status answers. The merge is asked again at once (mergeAgainNow), under the lock, so the next
|
||||
// look asks it; no timer does this (issue 1115). A status of another context, another commit or a state other than
|
||||
// success changes nothing.
|
||||
func (h *Holder) CommitStatus(e ForgeStatusEvent) {
|
||||
if !slices.Contains(mergeChecks, e.Context) {
|
||||
return
|
||||
}
|
||||
h.wantForge()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if e.Context == ctxCheck && (e.Description != "" || e.State == "pending") {
|
||||
// mesh/check is the verdict on its base (issue 1302): taken at once, as the controller's `checked` was.
|
||||
h.takeCheckStatus(e.Owner+"/"+e.Repo, e.SHA, e.State, e.Description)
|
||||
}
|
||||
if e.State != "success" {
|
||||
h.withdrawForStatus(e) // a status not a success takes back an ask not yet answered (novox/hq ADR 0661 §5)
|
||||
return
|
||||
}
|
||||
repository := strings.ToLower(e.Owner + "/" + e.Repo)
|
||||
for _, d := range h.deliveries {
|
||||
if !strings.EqualFold(d.Repository, repository) || d.Ready == nil || d.Ready.Merged != nil || d.MergedAs != "" ||
|
||||
d.State.Final() {
|
||||
continue
|
||||
}
|
||||
head := d.Commit
|
||||
if sha := d.Ready.Heads[d.ID]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
if sameCommit(head, e.SHA) {
|
||||
h.causeChanged(d) // its approved head's check turned success on the forge: the merge is asked again at once, the count anew
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawForStatus takes back the ask of an Approve nobody answered yet when a merge check of a head it covers is no
|
||||
// longer a success (issue 1275, AC5): the answer to a withdrawn ask merges nothing, and the operator is asked again once
|
||||
// every member may merge. Under the lock.
|
||||
func (h *Holder) withdrawForStatus(e ForgeStatusEvent) {
|
||||
repository := strings.ToLower(e.Owner + "/" + e.Repo)
|
||||
done := map[string]bool{}
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if !strings.EqualFold(d.Repository, repository) || r == nil || r.AskID == "" || r.Approved != nil || r.declined() || done[r.AskID] {
|
||||
continue
|
||||
}
|
||||
head := d.Commit
|
||||
if sha := r.Heads[d.ID]; sha != "" {
|
||||
head = sha
|
||||
}
|
||||
if sameCommit(head, e.SHA) {
|
||||
done[r.AskID] = true
|
||||
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, fmt.Sprintf("%s of %s is %s at its head %s", e.Context, d.ID, e.State, shortOf(head, 12))))
|
||||
h.Poke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
"reflect"
|
||||
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// **A verdict and an Approve are acted on as events** (novox/hq ADR 0406 §1, issue 975, ADR 0421 §1, issue 1022): the
|
||||
// operator is asked within the event that makes the Approve possible, and the merge within the Approve's event, never at
|
||||
// the next tick or the next minute, and with no label or comment written by anyone.
|
||||
|
||||
// hear is the runtime handing this owner one event, as handle takes it.
|
||||
func hear(t *testing.T, w *world, key string, body any) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key == shimCheckedKey {
|
||||
// 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
|
||||
}
|
||||
|
||||
// greenAnnounced is a pull request into main, announced, the operator not answering by himself.
|
||||
func announced(t *testing.T) *world {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
return w
|
||||
}
|
||||
|
||||
// Issue 1022, design 47 acceptance test 6: the Approve is asked within the event of the checks passing, with no ready
|
||||
// mark, label or comment written by anyone.
|
||||
func TestAPassingVerdictAsksTheOperatorAtOnceWithNoMark(t *testing.T) {
|
||||
w := announced(t)
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("asked before the checks passed: %d", w.op.count())
|
||||
}
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a pull request whose checks passed asked the operator %d times, wanted once, with no mark", w.op.count())
|
||||
}
|
||||
if len(w.forge.comments) != 0 || len(w.forge.calls) != 0 {
|
||||
t.Fatalf("something was written on the forge to ask: %v", w.forge.calls)
|
||||
}
|
||||
// Asked once for the head, however often it is looked at again.
|
||||
hear(t, w, gitEvent(gitseat.EventForgeStatus), gitseat.ForgeStatusEvent{Repository: "novox/app", Commit: readyHead, Context: "mesh/merge-gate", State: "success"})
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("asked %d times for one head", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedVerdictAsksNothing(t *testing.T) {
|
||||
w := announced(t)
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("a pull request whose checks failed asked the operator %d times", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// approve is the operator's Approve of the one open ask, heard as the router's `decided` event.
|
||||
func approve(w *world) {
|
||||
open := w.op.open()
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
w.settleAll()
|
||||
}
|
||||
|
||||
func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
|
||||
w := announced(t)
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
if len(w.op.open()) != 1 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("asks %v, merges %v", w.op.open(), w.forge.merges)
|
||||
}
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
|
||||
t.Fatalf("the Approve, heard as an event, merged %v; wanted once at the approved head", w.forge.merges)
|
||||
}
|
||||
if got := w.forge.approvedHeads[0]; len(got) != 1 || got[0] != "novox/app@"+readyHead {
|
||||
t.Fatalf("the merge tool was not handed the approved heads: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errAskedAgain
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the merge tool was asked %v; wanted once", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
|
||||
if len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
|
||||
t.Fatalf("the passing verdict did not ask the merge at once: %v %+v", w.forge.merges, w.delivery(id).Ready)
|
||||
}
|
||||
}
|
||||
|
||||
var errAskedAgain = &askedAgainError{}
|
||||
|
||||
type askedAgainError struct{}
|
||||
|
||||
func (*askedAgainError) Error() string {
|
||||
return "the forge refused gitea_merge_pull_request: novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both statuses pass. Nothing was merged"
|
||||
}
|
||||
|
||||
// The gate reads nothing from a pull request's description (novox/hq ADR 0493 §6, ADR 0659, which ends ADR 0496): its form
|
||||
// of a pull request and its delivery hold no description, no review line, no pushed-by and no after: line, so no wait can
|
||||
// read one; a pull request is asked once its other waits are met, and the ask names no review.
|
||||
func TestAPullRequestIsAskedWhateverItsDescriptionSays(t *testing.T) {
|
||||
for _, form := range []any{PullEvent{}, Delivery{}} {
|
||||
ty := reflect.TypeOf(form)
|
||||
for i := 0; i < ty.NumField(); i++ {
|
||||
name := strings.ToLower(ty.Field(i).Name)
|
||||
for _, never := range []string{"body", "description", "pushed", "after", "review"} {
|
||||
if strings.Contains(name, never) {
|
||||
t.Errorf("%s holds the field %s, a description's", ty.Name(), ty.Field(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("an unreviewed pull request with its other waits met was not asked: %d", len(open))
|
||||
}
|
||||
if e := w.op.ask(open[0]).Explanation; strings.Contains(strings.ToLower(e), "review") {
|
||||
t.Fatalf("the ask names a review:\n%s", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1022 (ADR 0421 §6): a push ends the Approve and says so once on the pull request.
|
||||
func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) {
|
||||
w := announced(t)
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
old := w.op.open()[0]
|
||||
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
|
||||
w.h.React()
|
||||
w.h.React()
|
||||
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != old || len(w.op.open()) != 0 {
|
||||
t.Fatalf("the ask was not cancelled: %v, open %v", w.op.cancelled, w.op.open())
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "Approve") ||
|
||||
!strings.Contains(w.forge.comments[0], "ended") {
|
||||
t.Fatalf("the end of the Approve is not said once on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
// The new head is asked on its own when its checks pass.
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
|
||||
if open := w.op.open(); len(open) != 1 || open[0] == old {
|
||||
t.Fatalf("the new head was not asked afresh: %v", open)
|
||||
}
|
||||
if len(w.forge.comments) != 1 {
|
||||
t.Fatalf("said more than once: %v", w.forge.comments)
|
||||
}
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What a test stands the holder on: a store in memory that can be made to fail, a controller that records
|
||||
// what it was asked and answers what it is told, a forge that records every note, view and status.
|
||||
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
deliveries map[string][]byte
|
||||
groups map[string][]byte
|
||||
failing bool
|
||||
// puts counts the deliveries written, failed or not.
|
||||
puts int
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
return &memStore{deliveries: map[string][]byte{}, groups: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (s *memStore) PutDelivery(d *Delivery) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.puts++
|
||||
if s.failing {
|
||||
return errors.New("the bus is away")
|
||||
}
|
||||
s.deliveries[d.ID] = mustJSON(d)
|
||||
return nil
|
||||
}
|
||||
func (s *memStore) DeleteDelivery(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.deliveries, id)
|
||||
return nil
|
||||
}
|
||||
func (s *memStore) PutGroup(g *Group) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failing {
|
||||
return errors.New("the bus is away")
|
||||
}
|
||||
s.groups[g.ID] = mustJSON(g)
|
||||
return nil
|
||||
}
|
||||
func (s *memStore) DeleteGroup(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.groups, id)
|
||||
return nil
|
||||
}
|
||||
func (s *memStore) Deliveries() ([]*Delivery, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []*Delivery
|
||||
for key, raw := range s.deliveries {
|
||||
if key == carriedKey { // the mark an earlier owner's carry-over wrote, as the real store skips it
|
||||
continue
|
||||
}
|
||||
var d Delivery
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *memStore) Groups() ([]*Group, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []*Group
|
||||
for _, raw := range s.groups {
|
||||
var g Group
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &g)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakeForge struct {
|
||||
mu sync.Mutex
|
||||
notes map[string][]string
|
||||
views map[string]string
|
||||
statuses map[string]string
|
||||
// statusWrites counts the statuses set.
|
||||
statusWrites int
|
||||
down bool
|
||||
// viewErr is what View answers instead, when set: a pull request the forge no longer holds.
|
||||
viewErr error
|
||||
viewCalls int
|
||||
// What Statuses reads: the pull requests by owner/repo#number, the base's required statuses, whether the
|
||||
// protection cannot be read, and what it was asked.
|
||||
pulls map[string]ForgePull
|
||||
// unwritable are the repositories (owner/repo) the forge's merging account cannot write (issue 1945).
|
||||
unwritable map[string]bool
|
||||
required []string
|
||||
protectionDown bool
|
||||
asked []string
|
||||
// contains answers gitea_contains by "<commit> in <in>"; a pair it does not hold is a forge that cannot say.
|
||||
contains map[string]bool
|
||||
containsDown bool
|
||||
containsAsks []string
|
||||
// open is what OpenPulls answers; openDown a forge that cannot say.
|
||||
open []OpenPull
|
||||
openDown bool
|
||||
openCalls int
|
||||
// merges and comments are what was asked of the forge for an approved merge (novox/hq issue 722); approvedHeads the
|
||||
// heads each merge was handed as approved (ADR 0421 §9).
|
||||
merges, comments []string
|
||||
approvedHeads [][]string
|
||||
refuse error
|
||||
// refuseRepo refuses the merges of one repository only ("owner/name"), beside refuse (issue 1049).
|
||||
refuseRepo map[string]error
|
||||
// calls are the comments in the order asked.
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.openCalls++
|
||||
if f.openDown {
|
||||
return nil, errors.New("the forge is away")
|
||||
}
|
||||
return append([]OpenPull(nil), f.open...), nil
|
||||
}
|
||||
|
||||
func newFakeForge() *fakeForge {
|
||||
return &fakeForge{notes: map[string][]string{}, views: map[string]string{}, statuses: map[string]string{},
|
||||
pulls: map[string]ForgePull{}, contains: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (f *fakeForge) Note(owner, repo, commit, line string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.down {
|
||||
return errors.New("the forge is away")
|
||||
}
|
||||
key := owner + "/" + repo + "@" + commit
|
||||
for _, l := range f.notes[key] {
|
||||
if l == line {
|
||||
return nil // appended once
|
||||
}
|
||||
}
|
||||
f.notes[key] = append(f.notes[key], line)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeForge) View(owner, repo string, number int, body string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.viewCalls++
|
||||
if f.viewErr != nil {
|
||||
return f.viewErr
|
||||
}
|
||||
if f.down {
|
||||
return errors.New("the forge is away")
|
||||
}
|
||||
f.views[fmt.Sprintf("%s/%s#%d", owner, repo, number)] = body
|
||||
return nil
|
||||
}
|
||||
func (f *fakeForge) Status(owner, repo, commit, context, state, description, target string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.down {
|
||||
return errors.New("the forge is away")
|
||||
}
|
||||
f.statusWrites++
|
||||
f.statuses[owner+"/"+repo+"@"+commit+" "+context] = state + " " + description + " → " + target
|
||||
return nil
|
||||
}
|
||||
|
||||
// Statuses answers what the fake was told: a pull request's head from pulls, the statuses set on the commit
|
||||
// (those Status set too, as the forge would keep them), and the base's required statuses from required.
|
||||
func (f *fakeForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.down {
|
||||
return nil, errors.New("the forge is away")
|
||||
}
|
||||
f.asked = append(f.asked, fmt.Sprintf("%s/%s#%d@%s base=%s", owner, repo, number, commit, base))
|
||||
out := &ForgeChecks{}
|
||||
if number > 0 {
|
||||
if p, ok := f.pulls[fmt.Sprintf("%s/%s#%d", owner, repo, number)]; ok {
|
||||
out.Pull = &p
|
||||
if commit == "" {
|
||||
commit = p.HeadSHA
|
||||
}
|
||||
if base == "" {
|
||||
base = p.Base
|
||||
}
|
||||
} else if commit == "" {
|
||||
return nil, fmt.Errorf("no pull request %d", number)
|
||||
}
|
||||
}
|
||||
for key, said := range f.statuses {
|
||||
at, context, _ := strings.Cut(key, " ")
|
||||
if !strings.HasPrefix(at, owner+"/"+repo+"@") || !sameCommit(strings.TrimPrefix(at, owner+"/"+repo+"@"), commit) {
|
||||
continue
|
||||
}
|
||||
if len(strings.TrimPrefix(at, owner+"/"+repo+"@")) > len(commit) {
|
||||
commit = strings.TrimPrefix(at, owner+"/"+repo+"@")
|
||||
}
|
||||
state, rest, _ := strings.Cut(said, " ")
|
||||
description, link, _ := strings.Cut(rest, " → ")
|
||||
out.Statuses = append(out.Statuses, ForgeStatus{Context: context, State: state, Description: description,
|
||||
TargetURL: link, UpdatedAt: "2026-10-06T12:00:00Z", Creator: "mesh-admin"})
|
||||
}
|
||||
sort.Slice(out.Statuses, func(i, j int) bool { return out.Statuses[i].Context < out.Statuses[j].Context })
|
||||
out.Commit = commit
|
||||
if base == "" {
|
||||
base = "main"
|
||||
}
|
||||
out.Merge = ForgeMerge{Branch: base, Protected: f.required != nil, Required: f.required}
|
||||
if f.unwritable[owner+"/"+repo] {
|
||||
no := false
|
||||
out.Merge.Writes, out.Merge.CannotWrite = &no, "the forge account cannot write "+owner+"/"+repo+": gitea_grant_write"
|
||||
}
|
||||
if f.protectionDown {
|
||||
out.Merge.Says = "the protection of " + base + " could not be read"
|
||||
return out, nil
|
||||
}
|
||||
ok := true
|
||||
for _, r := range f.required {
|
||||
state := "missing"
|
||||
for _, st := range out.Statuses {
|
||||
if st.Context == r {
|
||||
state = st.State
|
||||
}
|
||||
}
|
||||
if state != "success" {
|
||||
ok = false
|
||||
out.Merge.Blocking = append(out.Merge.Blocking, struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
}{r, state})
|
||||
}
|
||||
}
|
||||
out.Merge.Mergeable = &ok
|
||||
out.Merge.Says = fmt.Sprintf("%s requires %v", base, f.required)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Contains answers what the fake was told of two commits.
|
||||
func (f *fakeForge) Contains(owner, repo, commit, in string) (bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.containsAsks = append(f.containsAsks, commit+" in "+in)
|
||||
if f.containsDown || f.down {
|
||||
return false, errors.New("the forge is away")
|
||||
}
|
||||
yes, said := f.contains[commit+" in "+in]
|
||||
if !said {
|
||||
return false, fmt.Errorf("the forge has no commit %s or %s", commit, in)
|
||||
}
|
||||
return yes, nil
|
||||
}
|
||||
|
||||
func (f *fakeForge) notesOn(commit string) []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for key, lines := range f.notes {
|
||||
if strings.HasSuffix(key, "@"+commit) {
|
||||
return append([]string(nil), lines...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// world is a holder over fakes, with a clock a test moves.
|
||||
type world struct {
|
||||
t *testing.T
|
||||
h *Holder
|
||||
store *memStore
|
||||
forge *fakeForge
|
||||
op *fakeOperator
|
||||
merges *fakeMerges
|
||||
old *fakeOld
|
||||
source *fakeSource
|
||||
owed *fakeOwed
|
||||
// asked is the key of the ask a test answered.
|
||||
asked string
|
||||
now time.Time
|
||||
logbuf strings.Builder
|
||||
said []map[string]any
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newWorld(t *testing.T) *world {
|
||||
return newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
func newWorldOver(t *testing.T, store *memStore, forge *fakeForge, now time.Time) *world {
|
||||
w := &world{t: t, store: store, forge: forge, now: now}
|
||||
w.h = &Holder{Store: store, Forge: forge,
|
||||
Now: func() time.Time { w.mu.Lock(); defer w.mu.Unlock(); return w.now },
|
||||
Logf: func(format string, a ...any) { t.Logf(format, a...); fmt.Fprintf(&w.logbuf, format+"\n", a...) },
|
||||
Emit: func(event string, body any) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(mustJSON(body), &m)
|
||||
m["event-kind"] = event
|
||||
w.said = append(w.said, m)
|
||||
return nil
|
||||
}}
|
||||
// The operator approves at once unless a test says otherwise (w.op.auto = false): most tests are not about the Approve.
|
||||
w.op = newFakeOperator(w.h.Now)
|
||||
w.op.auto = true
|
||||
w.h.Operator = w.op
|
||||
w.merges, w.source = newFakeMerges(forge), &fakeSource{}
|
||||
w.old = &fakeOld{states: map[string]map[string]oldEntry{}}
|
||||
w.h.Merges, w.h.Old, w.h.Source = w.merges, w.old, w.source
|
||||
w.merges.h = w.h
|
||||
w.owed = newFakeOwed(forge)
|
||||
w.owed.h = w.h
|
||||
w.h.Owed = w.owed
|
||||
w.h.init()
|
||||
if err := w.h.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *world) later(d time.Duration) {
|
||||
w.mu.Lock()
|
||||
w.now = w.now.Add(d)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// settleAll takes a look a few times: the answers the teller wrote are read at each, and each member of a group handed to the
|
||||
// forge bridge is merged by the fake bridge, whose word is heard by the next look.
|
||||
func (w *world) settleAll() {
|
||||
for range 4 {
|
||||
w.h.React()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *world) delivery(id string) *Delivery {
|
||||
w.t.Helper()
|
||||
w.h.mu.Lock()
|
||||
defer w.h.mu.Unlock()
|
||||
d := w.h.deliveries[id]
|
||||
if d == nil {
|
||||
w.t.Fatalf("no delivery %s; there are %v", id, w.ids())
|
||||
}
|
||||
c := *d
|
||||
return &c
|
||||
}
|
||||
|
||||
func (w *world) ids() []string {
|
||||
var out []string
|
||||
for id := range w.h.deliveries {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *world) state(id string) State { return w.delivery(id).State }
|
||||
|
||||
// pr is a pull request's head as the forge announces it.
|
||||
func pr(repo string, number int, sha, branch string, paths ...string) PullEvent {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
return PullEvent{Owner: owner, Repo: name, Number: number, Base: "main", Head: branch, HeadSHA: sha,
|
||||
HTMLURL: fmt.Sprintf("https://forge.invalid/%s/pulls/%d", repo, number), Paths: paths}
|
||||
}
|
||||
|
||||
// seatUpdated is a pull request's new head as the seat git says it (pull.updated).
|
||||
func seatUpdated(p PullEvent) gitseat.PullUpdatedEvent {
|
||||
e := gitseat.PullUpdatedEvent{Repository: p.Owner + "/" + p.Repo, Number: int64(p.Number), Base: p.Base, Branch: p.Head,
|
||||
Head: p.HeadSHA, URL: p.HTMLURL, Title: p.Title, BaseHead: p.BaseSHA, Author: p.Author}
|
||||
if p.Paths != nil {
|
||||
e.Change = &gitseat.Change{Paths: p.Paths, PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// seatMerged is a pull request merged as the seat git says it (pull.merged).
|
||||
func seatMerged(p PullEvent) gitseat.PullMergedEvent {
|
||||
e := gitseat.PullMergedEvent{Repository: p.Owner + "/" + p.Repo, Number: int64(p.Number), Base: p.Base, Branch: p.Head,
|
||||
Head: p.HeadSHA, MergeCommit: p.MergeCommit, URL: p.HTMLURL, HeadChecks: p.HeadChecks}
|
||||
if at, err := time.Parse(time.RFC3339, p.MergedAt); err == nil {
|
||||
e.MergedAt = &at
|
||||
}
|
||||
if p.Paths != nil {
|
||||
e.Change = &gitseat.Change{Paths: p.Paths}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// seatOpenPull is an open pull request as the seat git's open-pulls answers it.
|
||||
func seatOpenPull(p OpenPull) gitseat.OpenPull {
|
||||
o := gitseat.OpenPull{Pull: gitseat.Pull{Repository: p.Owner + "/" + p.Repo, Number: int64(p.Number), Base: p.Base,
|
||||
Head: p.HeadSHA, URL: p.HTMLURL, State: gitseat.PullStateOpen}, Required: p.Required, Checks: map[string]gitseat.Status{}}
|
||||
for context, c := range p.Checks {
|
||||
o.Checks[context] = gitseat.Status{Context: context, State: c.State, Description: c.Description}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// seatClosed is a pull request closed as the seat git says it (pull.closed).
|
||||
func seatClosed(p PullEvent) gitseat.PullClosedEvent {
|
||||
return gitseat.PullClosedEvent{Repository: p.Owner + "/" + p.Repo, Number: int64(p.Number), Base: p.Base, Branch: p.Head,
|
||||
Head: p.HeadSHA}
|
||||
}
|
||||
|
||||
// verdict is the controller's `checked` for a head.
|
||||
func verdict(repo string, number int, sha, gate string) CheckedEvent {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
c := CheckedEvent{Owner: owner, Repo: name, Number: number, Commit: sha, Verdict: gate, Summary: "the gate said " + gate,
|
||||
ID: "check-of-" + sha}
|
||||
return c
|
||||
}
|
||||
|
||||
// merged is the forge's `pull.merged`.
|
||||
func merged(repo string, number int, head, merge string) PullEvent {
|
||||
p := pr(repo, number, head, "feature")
|
||||
p.MergeCommit = merge
|
||||
p.MergedAt = "2026-10-06T12:30:00Z"
|
||||
return p
|
||||
}
|
||||
|
||||
// Comment records what was asked (novox/hq issue 722).
|
||||
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body))
|
||||
f.calls = append(f.calls, "comment "+body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *world) logs() string { return w.logbuf.String() }
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// The holder of the seat mesh-delivery (novox/hq ADR 0239, to-be 47): from milestone G's delivery it keeps no merge states
|
||||
// and asks no Approve per merge (design 47 G §172, §431, §592, §593, §613). What it keeps is the delivery records
|
||||
// (record.go), the source revision per watched branch (merges.go) and what it owes the forge. It merges nothing.
|
||||
|
||||
// Seat is the seat the gate holds.
|
||||
const Seat = "mesh-delivery"
|
||||
|
||||
// Holder is the seat's holder.
|
||||
type Holder struct {
|
||||
// Settings reads the operator's values (settings.go); nil keeps every default.
|
||||
Settings func() Settings
|
||||
Now func() time.Time
|
||||
Logf func(format string, a ...any)
|
||||
// Merges and Old are the old sections the switch reads (switch.go); Source is the gate's source (merges.go).
|
||||
Merges MergeStore
|
||||
Old OldState
|
||||
Source SourceStore
|
||||
// Owed says what the gate owes the forge as the seat's event owed (owed.go).
|
||||
Owed OwedSayer
|
||||
// Records keeps the delivery records on the gate's ledger (record.go, ADR 0656 §7); nil keeps none.
|
||||
Records *Records
|
||||
// Consumer applies the step events to the records (consumer.go); nil applies none.
|
||||
Consumer *StepConsumer
|
||||
// SaySeat says an event of the seat mesh-delivery (recordevents.go); nil says none.
|
||||
SaySeat SeatSay
|
||||
// Say says a condition to the operator, and Conditions keeps which the gate raised (conditions.go); nil says none.
|
||||
Say func(c oc.Condition) error
|
||||
Conditions ConditionStore
|
||||
// said is what Conditions keeps, read at the first look; sayFailures the looks in a row a condition could not be said.
|
||||
// Touched by React alone.
|
||||
said map[string]string
|
||||
sayFailures int
|
||||
|
||||
mu sync.Mutex
|
||||
started time.Time
|
||||
refusals []string
|
||||
// tried counts the owed effects tried since this owner started, by how each ended (novox/hq issue 407).
|
||||
tried owedTries
|
||||
// reactMu keeps one React at a time; poke asks for a look (react.go); wanted is when the next is wanted, and its timer.
|
||||
reactMu sync.Mutex
|
||||
poke chan struct{}
|
||||
wanted lookWanted
|
||||
}
|
||||
|
||||
// owedTries is how many owed effects were tried since this owner started, and when the last was.
|
||||
type owedTries struct {
|
||||
done, failed, givenUp int
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// The word this owner signs its acts with.
|
||||
const byOwner = "mesh-delivery"
|
||||
|
||||
func (h *Holder) init() {
|
||||
if h.Now == nil {
|
||||
h.Now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
if h.Logf == nil {
|
||||
h.Logf = func(string, ...any) {}
|
||||
}
|
||||
h.poke = make(chan struct{}, 1)
|
||||
h.started = h.Now()
|
||||
}
|
||||
|
||||
// Refuse keeps a refusal from outside the lock.
|
||||
func (h *Holder) Refuse(err error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.refuse(err)
|
||||
}
|
||||
|
||||
// refuse keeps a refusal, said, for the status; under the lock.
|
||||
func (h *Holder) refuse(err error) {
|
||||
h.Logf("[mesh-delivery] %v", err)
|
||||
h.refusals = append(h.refusals, h.Now().Format(time.RFC3339)+" "+err.Error())
|
||||
if len(h.refusals) > 50 {
|
||||
h.refusals = h.refusals[len(h.refusals)-50:]
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
)
|
||||
|
||||
// The forge as the seat `git` says it (design 47 *The seat git*): its events heard as the seat's, by the key the runtime
|
||||
// hands them under (`git.<event>`), each read into the gate's own forms here and nowhere else.
|
||||
|
||||
// gitEvent is the key a seat `git` event arrives under.
|
||||
func gitEvent(event string) string { return gitseat.Seat + "." + event }
|
||||
|
||||
// gitEvents are the seat's events the gate hears.
|
||||
var gitEvents = []string{
|
||||
gitEvent(gitseat.EventPullUpdated), gitEvent(gitseat.EventPullMerged), gitEvent(gitseat.EventPullClosed),
|
||||
gitEvent(gitseat.EventForgePull), gitEvent(gitseat.EventForgeStatus), gitEvent(gitseat.EventRepoWritable),
|
||||
}
|
||||
|
||||
// splitRepository is a repository's owner and name.
|
||||
func splitRepository(repository string) (owner, repo string) {
|
||||
owner, repo, _ = strings.Cut(repository, "/")
|
||||
return owner, repo
|
||||
}
|
||||
|
||||
// timeText is a time as the gate keeps it, RFC 3339; empty when not said.
|
||||
func timeText(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// withChange carries a pull request's changed paths into the gate's form; a list of module directories absent is one the
|
||||
// forge could not read.
|
||||
func withChange(p *PullEvent, c *gitseat.Change) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
p.Paths, p.PathsTruncated, p.Removed = c.Paths, c.PathsTruncated, c.Removed
|
||||
p.ModuleDirs, p.ModuleDirsSaid = c.ModuleDirs, c.ModuleDirs != nil
|
||||
}
|
||||
|
||||
// gitPullEvent reads a seat `git` event of a pull request into the gate's PullEvent. The event's head is its commit and its
|
||||
// branch the head branch, as the gate's Head and HeadSHA name them.
|
||||
func gitPullEvent(key string, body json.RawMessage) (PullEvent, error) {
|
||||
var p PullEvent
|
||||
switch key {
|
||||
case gitEvent(gitseat.EventPullUpdated):
|
||||
var e gitseat.PullUpdatedEvent
|
||||
if err := json.Unmarshal(body, &e); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p = PullEvent{Number: int(e.Number), Title: e.Title, Base: e.Base, Head: e.Branch, HeadSHA: e.Head, HTMLURL: e.URL,
|
||||
BaseSHA: e.BaseHead, Author: e.Author}
|
||||
p.Owner, p.Repo = splitRepository(e.Repository)
|
||||
withChange(&p, e.Change)
|
||||
case gitEvent(gitseat.EventPullMerged):
|
||||
var e gitseat.PullMergedEvent
|
||||
if err := json.Unmarshal(body, &e); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p = PullEvent{Number: int(e.Number), Title: e.Title, Base: e.Base, Head: e.Branch, HeadSHA: e.Head, HTMLURL: e.URL,
|
||||
MergeCommit: e.MergeCommit, MergedAt: timeText(e.MergedAt), HeadChecks: e.HeadChecks, Merged: true, Author: e.Author}
|
||||
p.Owner, p.Repo = splitRepository(e.Repository)
|
||||
withChange(&p, e.Change)
|
||||
case gitEvent(gitseat.EventPullClosed):
|
||||
var e gitseat.PullClosedEvent
|
||||
if err := json.Unmarshal(body, &e); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p = PullEvent{Number: int(e.Number), Title: e.Title, Base: e.Base, Head: e.Branch, HeadSHA: e.Head, HTMLURL: e.URL,
|
||||
Author: e.Author}
|
||||
p.Owner, p.Repo = splitRepository(e.Repository)
|
||||
case gitEvent(gitseat.EventForgePull):
|
||||
var e gitseat.ForgePullEvent
|
||||
if err := json.Unmarshal(body, &e); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p = PullEvent{Number: int(e.Number), Base: e.Base, Head: e.Branch, HeadSHA: e.Head, MergeCommit: e.MergeCommit,
|
||||
Merged: e.Merged, Author: e.Author}
|
||||
p.Owner, p.Repo = splitRepository(e.Repository)
|
||||
default:
|
||||
return p, fmt.Errorf("%s is no pull request event of the seat git", key)
|
||||
}
|
||||
if p.Owner == "" || p.Repo == "" {
|
||||
return p, fmt.Errorf("names no repository <owner>/<name>")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// forgeAction is the forge's own word in a `forge.pull` event: what it did to the pull request.
|
||||
func forgeAction(body json.RawMessage) string {
|
||||
var e struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &e)
|
||||
return e.Action
|
||||
}
|
||||
|
||||
// gitStatusEvent reads the seat's `forge.status` into the gate's ForgeStatusEvent.
|
||||
func gitStatusEvent(body json.RawMessage) (ForgeStatusEvent, error) {
|
||||
var e gitseat.ForgeStatusEvent
|
||||
if err := json.Unmarshal(body, &e); err != nil {
|
||||
return ForgeStatusEvent{}, err
|
||||
}
|
||||
s := ForgeStatusEvent{SHA: e.Commit, Context: e.Context, State: e.State, Description: e.Description}
|
||||
s.Owner, s.Repo = splitRepository(e.Repository)
|
||||
if s.Owner == "" || s.Repo == "" || s.SHA == "" {
|
||||
return s, fmt.Errorf("names no repository <owner>/<name> or no commit")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// forgePullOf is the seat's pull request in the gate's form: a merged one is closed and merged, as the forge said it before.
|
||||
func forgePullOf(p gitseat.Pull) *ForgePull {
|
||||
out := &ForgePull{Number: int(p.Number), Title: p.Title, State: p.State, Base: p.Base, Head: p.Branch, HeadSHA: p.Head,
|
||||
MergeCommit: p.MergeCommit, MergedAt: timeText(p.MergedAt), HTMLURL: p.URL}
|
||||
if p.State == gitseat.PullStateMerged {
|
||||
out.State, out.Merged = gitseat.PullStateClosed, true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// forgeStatusOf is one status of the seat in the gate's form.
|
||||
func forgeStatusOf(s gitseat.Status) ForgeStatus {
|
||||
return ForgeStatus{Context: s.Context, State: s.State, Description: s.Description, TargetURL: s.Target,
|
||||
UpdatedAt: timeText(s.At), Creator: s.By}
|
||||
}
|
||||
|
||||
// openPullOf is one of the seat's open pull requests in the gate's form.
|
||||
func openPullOf(p gitseat.OpenPull) OpenPull {
|
||||
out := OpenPull{Number: int(p.Pull.Number), Base: p.Pull.Base, HeadSHA: p.Pull.Head, HTMLURL: p.Pull.URL,
|
||||
Required: p.Required, Statuses: []string{}}
|
||||
out.Owner, out.Repo = splitRepository(p.Pull.Repository)
|
||||
if out.Required == nil {
|
||||
out.Required = []string{}
|
||||
}
|
||||
if len(p.Checks) > 0 {
|
||||
out.Checks = map[string]HeadCheck{}
|
||||
}
|
||||
for context, s := range p.Checks {
|
||||
out.Statuses = append(out.Statuses, context)
|
||||
out.Checks[context] = HeadCheck{State: s.State, Description: s.Description, UpdatedAt: timeText(s.At)}
|
||||
}
|
||||
slices.Sort(out.Statuses)
|
||||
return out
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
)
|
||||
|
||||
// W11.18 (design 47 *The seat git*): every forge read of the gate is a verb of the seat git, its answer typed in the seat's
|
||||
// contract, and a failed read is an error, never "none"; every forge event is heard as the seat's.
|
||||
func TestTheGateReadsAndHearsTheForgeThroughTheSeatGit(t *testing.T) {
|
||||
head := strings.Repeat("c", 40)
|
||||
pull := gitseat.Pull{Repository: "novox/app", Number: 7, Base: "main", Branch: "feat/x", Head: head, State: "merged",
|
||||
MergeCommit: strings.Repeat("d", 40), URL: "https://forge.invalid/novox/app/pulls/7"}
|
||||
answers := map[string]any{
|
||||
"seat:git.open-pulls": gitseat.OpenPullsAnswer{Pulls: []gitseat.OpenPull{{Pull: pull, Required: []string{"mesh/check"},
|
||||
Checks: map[string]gitseat.Status{"mesh/check": {Context: "mesh/check", State: "pending"}}}}},
|
||||
"seat:git.contains": gitseat.ContainsAnswer{Contains: true},
|
||||
"seat:git.pull": gitseat.PullAnswer{Pull: &pull, Merging: &gitseat.Merging{Mergeable: true, Protected: true,
|
||||
Required: []string{"mesh/check"}, Writes: true}},
|
||||
"seat:git.statuses": gitseat.StatusesAnswer{Commit: head, Combined: "pending",
|
||||
Statuses: []gitseat.Status{{Context: "mesh/check", State: "pending", By: "mesh"}}},
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
failed string // the verb whose answer is an error
|
||||
read func(seatForge) (any, error)
|
||||
want any
|
||||
asked []string
|
||||
}{
|
||||
{name: "open pull requests", read: func(f seatForge) (any, error) { return f.OpenPulls() },
|
||||
want: []OpenPull{{Owner: "novox", Repo: "app", Number: 7, Base: "main", HeadSHA: head, HTMLURL: pull.URL,
|
||||
Required: []string{"mesh/check"}, Statuses: []string{"mesh/check"},
|
||||
Checks: map[string]HeadCheck{"mesh/check": {State: "pending"}}}},
|
||||
asked: []string{"seat:git.open-pulls"}},
|
||||
{name: "open pull requests not read", failed: "seat:git.open-pulls",
|
||||
read: func(f seatForge) (any, error) { return f.OpenPulls() }, asked: []string{"seat:git.open-pulls"}},
|
||||
{name: "contains", read: func(f seatForge) (any, error) { return f.Contains("novox", "app", "a", "b") }, want: true,
|
||||
asked: []string{"seat:git.contains"}},
|
||||
{name: "contains not read", failed: "seat:git.contains",
|
||||
read: func(f seatForge) (any, error) { return f.Contains("novox", "app", "a", "b") }, asked: []string{"seat:git.contains"}},
|
||||
{name: "a pull request by its number", read: func(f seatForge) (any, error) {
|
||||
c, err := f.Statuses("novox", "app", 7, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := *c.Pull
|
||||
conflict := *p.Mergeable
|
||||
p.Mergeable = nil
|
||||
return []any{c.Commit, p, c.Statuses, conflict, *c.Merge.Mergeable, c.Merge.Blocking[0].Context + " " +
|
||||
c.Merge.Blocking[0].State, c.Merge.Says, *c.Merge.Writes}, nil
|
||||
}, want: []any{head, ForgePull{Number: 7, State: "closed", Merged: true, Base: "main", Head: "feat/x", HeadSHA: head,
|
||||
MergeCommit: pull.MergeCommit, HTMLURL: pull.URL}, []ForgeStatus{{Context: "mesh/check", State: "pending", Creator: "mesh"}},
|
||||
true, false, "mesh/check pending", "main requires mesh/check", true},
|
||||
asked: []string{"seat:git.pull", "seat:git.statuses"}},
|
||||
// What the forge would merge now comes from the pull answer's merging alone: none for a commit no pull request names.
|
||||
{name: "no merge state for a commit alone", read: func(f seatForge) (any, error) {
|
||||
c, err := f.Statuses("novox", "app", 0, head, "main")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []any{c.Merge.Mergeable == nil, c.Merge.Writes == nil}, nil
|
||||
}, want: []any{true, true}, asked: []string{"seat:git.statuses"}},
|
||||
{name: "a pull request not read", failed: "seat:git.pull",
|
||||
read: func(f seatForge) (any, error) { return f.Statuses("novox", "app", 7, "", "") },
|
||||
asked: []string{"seat:git.pull"}},
|
||||
{name: "statuses not read", failed: "seat:git.statuses",
|
||||
read: func(f seatForge) (any, error) { return f.Statuses("novox", "app", 0, head, "main") },
|
||||
asked: []string{"seat:git.statuses"}},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var asked []string
|
||||
f := seatForge{ask: func(key string, body any) (json.RawMessage, error) {
|
||||
asked = append(asked, key)
|
||||
if key == c.failed {
|
||||
return json.Marshal(map[string]string{"error": "the forge answered 502"})
|
||||
}
|
||||
a, ok := answers[key]
|
||||
if !ok {
|
||||
return nil, errors.New("no such verb: " + key)
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}}
|
||||
got, err := c.read(f)
|
||||
if !reflect.DeepEqual(asked, c.asked) {
|
||||
t.Fatalf("asked %v, wanted %v", asked, c.asked)
|
||||
}
|
||||
if c.failed != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), "the forge answered 502") {
|
||||
t.Fatalf("a failed read answered %v, %v; wanted its error", got, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("read %#v, %v; wanted %#v", got, err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a merge heard", func(t *testing.T) {
|
||||
body, _ := json.Marshal(gitseat.PullMergedEvent{Repository: "novox/app", Number: 7, Base: "main", Branch: "feat/x",
|
||||
Head: head, MergeCommit: pull.MergeCommit, Author: "jo", Change: &gitseat.Change{Paths: []string{"a.go"}}})
|
||||
p, err := gitPullEvent(gitEvent(gitseat.EventPullMerged), body)
|
||||
want := PullEvent{Owner: "novox", Repo: "app", Number: 7, Base: "main", Head: "feat/x", HeadSHA: head,
|
||||
MergeCommit: pull.MergeCommit, Merged: true, Author: "jo", Paths: []string{"a.go"}}
|
||||
if err != nil || !reflect.DeepEqual(p, want) {
|
||||
t.Fatalf("heard %+v, %v; wanted %+v", p, err, want)
|
||||
}
|
||||
if _, err := gitPullEvent(gitEvent(gitseat.EventPullMerged), []byte(`{"number":7}`)); err == nil {
|
||||
t.Fatal("a merge naming no repository was taken")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A group is merged whole or not at all, in its order (issue 1275, novox/hq ADR 0418).
|
||||
//
|
||||
// - Before the operator is asked, and again before any member is merged, every member's required statuses are read at
|
||||
// its head on the forge; one that is not a success holds the group, and nothing is merged.
|
||||
// - The members are merged one after the other in the group's order, and the pass stops at the first refusal: no later
|
||||
// member is merged.
|
||||
// - A group held or half merged is said as a stalled line, urgent, until every member is merged or a person decides.
|
||||
|
||||
// memberRead is what a status read of one member needs, taken under the lock.
|
||||
type memberRead struct {
|
||||
id, owner, repo, base, head string
|
||||
number int
|
||||
}
|
||||
|
||||
// readOf is a delivery as a status read needs it, at the head the Approve or the ask covers.
|
||||
func readOf(d *Delivery, head string) memberRead {
|
||||
if head == "" {
|
||||
head = d.Commit
|
||||
}
|
||||
return memberRead{id: d.ID, owner: d.Owner(), repo: d.Repo(), base: d.Base, head: head, number: d.Number}
|
||||
}
|
||||
|
||||
// forgeMergeGrace is how long the forge may say a member does not merge now before the group is held for it (issue 1275):
|
||||
// the forge says so for a few seconds after a base moved, while it works out whether the pull request still merges; within
|
||||
// the grace the merge is asked again every forgeMergeAgain. Past it, the member is in conflict with its trunk: held, said
|
||||
// urgent, and asked again on the next event (a new head, a status).
|
||||
const (
|
||||
forgeMergeGrace = 2 * time.Minute
|
||||
forgeMergeAgain = 5 * time.Second
|
||||
)
|
||||
|
||||
// notASuccess reads a member's required statuses at its head and says, in the forge's words, which are not a success,
|
||||
// and whether the forge says the pull request merges into its base now (its head still the one approved, and not in
|
||||
// conflict); empty when all hold. forge says the reason is the forge's merge answer rather than a status. 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, forge bool, err error) {
|
||||
return h.memberRead(m, true)
|
||||
}
|
||||
|
||||
// notMergeableNow is notASuccess at the merge: the head the Approve covers and the forge's merge answer, and no required
|
||||
// status read again, for the merge judges the operator's Approve alone (novox/hq ADR 0661 §4).
|
||||
func (h *Holder) notMergeableNow(m memberRead) (says string, forge bool, err error) {
|
||||
return h.memberRead(m, false)
|
||||
}
|
||||
|
||||
func (h *Holder) memberRead(m memberRead, statuses bool) (says string, forge bool, err error) {
|
||||
fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if statuses && fc.Merge.Mergeable == nil {
|
||||
return "", false, fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says)
|
||||
}
|
||||
if statuses && !*fc.Merge.Mergeable {
|
||||
var parts []string
|
||||
for _, b := range fc.Merge.Blocking {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State))
|
||||
}
|
||||
return fmt.Sprintf("%s requires %s; not a success: %s", fc.Merge.Branch, strings.Join(fc.Merge.Required, ", "),
|
||||
strings.Join(parts, ", ")), false, nil
|
||||
}
|
||||
if p := fc.Pull; p != nil && m.number > 0 {
|
||||
if p.HeadSHA != "" && !sameCommit(p.HeadSHA, m.head) {
|
||||
return fmt.Sprintf("its head is now %s, not the %s approved", shortOf(p.HeadSHA, 12), shortOf(m.head, 12)), false, nil
|
||||
}
|
||||
if p.Mergeable != nil && !*p.Mergeable {
|
||||
base := firstOf(p.Base, m.base)
|
||||
return fmt.Sprintf("the forge says it does not merge into %s now (in conflict with it, a work in progress, or still "+
|
||||
"being worked out after %s moved)", base, base), true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// unmergedLive are a group's members not yet merged and not over, in the group's order. Under the lock.
|
||||
func (h *Holder) unmergedLive(g *Group) []*Delivery {
|
||||
var out []*Delivery
|
||||
for _, d := range h.membersOf(g) {
|
||||
if d.MergedAs == "" && !d.State.Final() && (d.Ready == nil || d.Ready.Merged == nil) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// groupMergeable reads every member's head and the forge's merge answer at the head the Approve covers, no required status
|
||||
// (novox/hq ADR 0661 §4), and says whether all hold: no member is merged unless every member merges now (issue 1275, 2026-10-26: two of
|
||||
// three merged and the third refused). When one does not, or cannot be read, nothing is handed to the bridge: the group is
|
||||
// held, said urgent (ADR 0441 §2, §4), and the target's turn is given back so the next event that decides whether it may
|
||||
// merge asks it again. A read that could not be made is a failed ask, tried again after askAgainAfter; the forge saying a
|
||||
// member does not merge now is asked again every forgeMergeAgain for forgeMergeGrace before the group is held. Outside
|
||||
// the lock.
|
||||
func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
release := func() {
|
||||
if d := h.deliveries[target]; d != nil && d.Ready != nil {
|
||||
d.Ready.merging = false
|
||||
d.Ready.LastTry = time.Time{}
|
||||
}
|
||||
}
|
||||
for _, m := range reads {
|
||||
says, forge, err := h.notMergeableNow(m)
|
||||
if err == nil && says == "" {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s: the required statuses of %s cannot be read, so nothing is merged yet: %v", gid, m.id, err)
|
||||
release()
|
||||
h.again("a group's merge whose required statuses could not be read")
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if forge {
|
||||
if h.unmergeableSince == nil {
|
||||
h.unmergeableSince = map[string]time.Time{}
|
||||
}
|
||||
key, now := gid+" "+m.id, h.Now()
|
||||
first, seen := h.unmergeableSince[key]
|
||||
if !seen {
|
||||
first = now
|
||||
h.unmergeableSince[key] = now
|
||||
h.Logf("[mesh-delivery] group %s: nothing is merged yet: %s at %s: %s; asked again every %s for %s", gid, m.id,
|
||||
shortOf(m.head, 12), says, forgeMergeAgain, forgeMergeGrace)
|
||||
}
|
||||
if now.Sub(first) < forgeMergeGrace {
|
||||
release()
|
||||
h.lookAt(now.Add(forgeMergeAgain), "a group's member the forge was still weighing")
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
since := time.Time{}
|
||||
if g.Hold != nil && g.Hold.Kind == "blocked" {
|
||||
since = g.Hold.Since
|
||||
}
|
||||
if since.IsZero() {
|
||||
since = h.Now()
|
||||
}
|
||||
g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
// No timer: the status turning success, or a new head, is an event on the forge (CommitStatus, mergeAgainNow), and
|
||||
// it asks the merge again at once (issue 1275: a pass that stops part way resumes on an event).
|
||||
release()
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
for key := range h.unmergeableSince {
|
||||
if strings.HasPrefix(key, gid+" ") {
|
||||
delete(h.unmergeableSince, key)
|
||||
}
|
||||
}
|
||||
if g := h.groups[gid]; g != nil && g.Hold != nil {
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// passWaits is why an approved merge is not handed yet because another approved merge's pass is under way into a
|
||||
// repository it goes into; empty when none is (issue 1275, 2026-10-26 13:40: group feat/890-assignments merged
|
||||
// mesh-delivery#72 between the members of fix/734-lost-ask, whose mesh-delivery#73 then conflicted). A pass is under way
|
||||
// from its first member handed until every member is merged; one half merged stays under way while it is refused or held,
|
||||
// until it is whole, declined, or its remaining members are gone or asked anew (a new head is a new Approve). The first
|
||||
// merge handed has the repositories; the other is handed once that pass ends, on the event that ends it. This holds
|
||||
// whether or not mesh/check gates the merge: it waits for a merge, never for a check (ADR 0551). Under the lock.
|
||||
func (h *Holder) passWaits(d *Delivery) string {
|
||||
c := h.covering(d)
|
||||
if c == nil || c.Approved == nil || c.AskID == "" {
|
||||
return ""
|
||||
}
|
||||
mine := h.unmergedRepos(c)
|
||||
for _, p := range h.passes() {
|
||||
if p.run.AskID == c.AskID || !h.passUnderWay(p.run) {
|
||||
continue
|
||||
}
|
||||
for repo := range h.unmergedRepos(p.run) {
|
||||
if mine[repo] {
|
||||
return fmt.Sprintf("approved; waits for the merge of %s into %s to end, so no other merge lands there between "+
|
||||
"its members (issue 1275)", p.name, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pass is an approved merge not merged whole: the run holding its Approve and the name of its group (or pull request).
|
||||
type pass struct {
|
||||
run *ReadyRun
|
||||
name string
|
||||
}
|
||||
|
||||
// passes are the approved merges not merged whole and not declined, each ask once. Unlike approvedMerges it keeps one whose
|
||||
// members merged or were refused: a half-merged pass still holds its repositories (issue 1275). Under the lock.
|
||||
func (h *Holder) passes() []pass {
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
seen := map[string]bool{}
|
||||
var out []pass
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
r := d.Ready
|
||||
if r == nil || r.Approved == nil || r.declined() || r.AskID == "" || len(r.Heads) == 0 || seen[r.AskID] ||
|
||||
d.Base != MergeBranch || h.allMerged(r.Heads) {
|
||||
continue
|
||||
}
|
||||
seen[r.AskID] = true
|
||||
out = append(out, pass{run: r, name: askGroup(d)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unmergedRepos are the repositories (lowercase) of the deliveries an Approve covers that are not merged and may still be
|
||||
// merged on it: not over (closed, held), at the head approved, and covered by this Approve, not a newer one. Under the lock.
|
||||
func (h *Holder) unmergedRepos(r *ReadyRun) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for id, sha := range r.Heads {
|
||||
m := h.deliveries[id]
|
||||
if m == nil || m.MergedAs != "" || (m.Ready != nil && m.Ready.Merged != nil) || m.State.Final() || !sameCommit(m.Commit, sha) {
|
||||
continue
|
||||
}
|
||||
if c := h.covering(m); c == nil || c.AskID != r.AskID {
|
||||
continue // asked anew (its new head, or a new Approve): that ask is the pass now
|
||||
}
|
||||
out[strings.ToLower(m.Repository)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// passUnderWay is whether an approved merge's pass has started and not ended: a member is handed to the bridge, or some
|
||||
// are merged and others may still merge on this Approve, refused or held or not (issue 1275, review: a held half-merged
|
||||
// group that let another group start, then asked again, waited on that group while it waited on it). Under the lock.
|
||||
func (h *Holder) passUnderWay(r *ReadyRun) bool {
|
||||
if len(h.unmergedRepos(r)) == 0 {
|
||||
return false
|
||||
}
|
||||
for id := range r.Heads {
|
||||
m := h.deliveries[id]
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m.MergedAs != "" || (m.Ready != nil && m.Ready.Merged != nil) || (m.Ready != nil && m.Ready.merging) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// groupLines are the groups that are held or half merged, as urgent stalled lines the gate says as conditions (conditions.go):
|
||||
// one for each, on its first member not merged, until every member is merged or a person decides (issue 1275). A member
|
||||
// the merge tool refused is said by readyLines, with its group; this adds a group held for a required status, and a
|
||||
// group half merged with no refusal. Under the lock.
|
||||
func (h *Holder) groupLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, g := range h.groups {
|
||||
live := h.unmergedLive(g)
|
||||
if len(live) == 0 {
|
||||
continue
|
||||
}
|
||||
first, refused, running, merged := live[0], false, false, 0
|
||||
for _, d := range live {
|
||||
if d.Ready != nil && (d.Ready.RefusedSince != nil || d.Ready.Failed != "" || d.Ready.Refused != "") {
|
||||
refused = true
|
||||
}
|
||||
if d.Ready != nil && d.Ready.merging {
|
||||
running = true
|
||||
}
|
||||
}
|
||||
for _, d := range h.membersOf(g) {
|
||||
if d.MergedAs != "" || (d.Ready != nil && d.Ready.Merged != nil) {
|
||||
merged++
|
||||
}
|
||||
}
|
||||
heldOn := h.deliveries[holdMember(g)]
|
||||
switch {
|
||||
// A held member the merge tool refused is said by readyLines already: one line for an id, not two.
|
||||
case g.Hold != nil && g.Hold.Kind == "blocked" && !running && !(heldOn != nil && heldOn.Ready != nil && heldOn.Ready.RefusedSince != nil):
|
||||
stands := "is approved and nothing is merged"
|
||||
if merged > 0 {
|
||||
stands = "is half merged" // a member merged before the pass stopped (issue 1275)
|
||||
}
|
||||
out = append(out, StalledLine{ID: g.Hold.Member, Number: numberOf(h.deliveries[g.Hold.Member]), State: first.State,
|
||||
For: now.Sub(g.Hold.Since).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
H2: "none: the required status turns success or a new head merges, which asks the merge again — or the operator's decision",
|
||||
Says: fmt.Sprintf("group %s %s (merged %d of %d): %s does not merge now "+
|
||||
"at its head: %s. Needed: that status turns success (its check asked again), a new head that merges "+
|
||||
"(rebased on its trunk), or the operator decides.",
|
||||
g.ID, stands, merged, len(h.membersOf(g)), g.Hold.Member, g.Hold.Says)})
|
||||
case merged > 0 && !refused && !running:
|
||||
out = append(out, StalledLine{ID: first.ID, Number: first.Number, State: first.State,
|
||||
For: now.Sub(g.Updated).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
H2: "none: every member merged, or the operator decides",
|
||||
Says: fmt.Sprintf("group %s is half merged (%d of %d members merged): %s is not merged. Needed: it merges "+
|
||||
"(its Approve and checks hold at its head), or the operator decides.", g.ID, merged, len(h.membersOf(g)), first.ID)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func numberOf(d *Delivery) int {
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
return d.Number
|
||||
}
|
||||
|
||||
func holdMember(g *Group) string {
|
||||
if g.Hold == nil {
|
||||
return ""
|
||||
}
|
||||
return g.Hold.Member
|
||||
}
|
||||
|
||||
// MergeAgain is the tool delivery_merge_again: on the operator's word, the merge of an approved delivery the forge refused,
|
||||
// or of a group held part way, is asked again at once at the heads the Approve named (issue 1275): a refusal the forge
|
||||
// meant as "later", a conflict cleared on the trunk, or a refusal repeated until the gate stopped asking. A delivery whose
|
||||
// head moved is asked again by its new head, never by this. Refused, naming why, for one that is not an approved merge
|
||||
// waiting after a refusal or a hold.
|
||||
func (h *Holder) MergeAgain(id, why string) (string, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.deliveries[id]
|
||||
if d == nil {
|
||||
return "", fmt.Errorf("no delivery %q", id)
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return "", fmt.Errorf("say why: the merge asked again is the operator's word, kept in the log")
|
||||
}
|
||||
c := h.covering(d)
|
||||
if c == nil || c.Approved == nil || c.declined() || c.Heads[d.ID] != d.Commit {
|
||||
return "", fmt.Errorf("%s is not approved at its head %s: nothing to ask again (a new head is asked on its own)", id, shortOf(d.Commit, 12))
|
||||
}
|
||||
if d.MergedAs != "" || (d.Ready != nil && d.Ready.Merged != nil) {
|
||||
return "", fmt.Errorf("%s is merged already", id)
|
||||
}
|
||||
g := h.groups[d.Group]
|
||||
if d.Group == "" {
|
||||
g = nil
|
||||
}
|
||||
held := g != nil && g.Hold != nil && g.Hold.Kind == "blocked"
|
||||
refused := false
|
||||
for hid := range c.Heads {
|
||||
if m := h.deliveries[hid]; m != nil && m.Ready != nil && m.Ready.Merged == nil && m.Ready.RefusedSince != nil {
|
||||
refused = true
|
||||
}
|
||||
}
|
||||
if !held && !refused {
|
||||
return "", fmt.Errorf("%s was neither refused nor held: %s", id, firstOf(d.readyWords(), "it is merged at its turn"))
|
||||
}
|
||||
if waits := h.passWaits(d); waits != "" {
|
||||
return "", fmt.Errorf("%s is not asked again yet: it %s; it is asked at that pass's end", id, strings.TrimPrefix(waits, "approved; "))
|
||||
}
|
||||
for hid := range c.Heads {
|
||||
if m := h.deliveries[hid]; m != nil && m.Ready != nil && m.Ready.Merged == nil {
|
||||
m.Ready.Repeats, m.Ready.LastTry, m.Ready.merging = 0, time.Time{}, false
|
||||
h.keep(m)
|
||||
}
|
||||
}
|
||||
if held {
|
||||
g.Hold = nil
|
||||
h.keepGroup(g)
|
||||
}
|
||||
for key := range h.unmergeableSince {
|
||||
if g != nil && strings.HasPrefix(key, g.ID+" ") {
|
||||
delete(h.unmergeableSince, key)
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: its merge is asked again on the operator's word, at the heads approved: %s", id, why)
|
||||
return fmt.Sprintf("%s's merge is asked again at the next look, every member read first; nothing merges unless every member merges now", id), nil
|
||||
}
|
||||
|
||||
// readyWords is what an approved delivery's run says it waits for, if anything.
|
||||
func (d *Delivery) readyWords() string {
|
||||
if d.Ready == nil {
|
||||
return ""
|
||||
}
|
||||
return d.Ready.Waits
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A group is merged in one pass on its one Approve, and delivered by one plan** (novox/hq ADR 0418, ADR 0419, ADR
|
||||
// 0421, issues 978 and 1047): the members are not ordered, none waits for another's merge or delivery, and a trunk
|
||||
// move does not ask the operator a second time.
|
||||
|
||||
const (
|
||||
gCtl, gCat = "novox/mesh-controller", "novox/mesh-catalog"
|
||||
gHc, gHk = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
gTrunk0, gT1 = "a2c2c35000000000000000000000000000000000", "d28ca95000000000000000000000000000000000"
|
||||
)
|
||||
|
||||
// approvedGroup is a group of two pull requests on one branch, checks green, composed green and the operator's Approve
|
||||
// asked and not yet answered (w.op.auto is off).
|
||||
func approvedGroup(t *testing.T) *world {
|
||||
return approvedGroupOf(t, member{gCtl, 1, gHc, "internal/a.go"}, member{gCat, 2, gHk, "modules/app/module.json"})
|
||||
}
|
||||
|
||||
// member is a pull request of a group in feat/g: its repository, number, head and the paths it changes.
|
||||
type member struct {
|
||||
repo string
|
||||
number int
|
||||
head string
|
||||
paths string
|
||||
}
|
||||
|
||||
// approvedGroupOf is a group feat/g of these members, each checked, composed together and asked once.
|
||||
func approvedGroupOf(t *testing.T, ms ...member) *world {
|
||||
t.Helper()
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
for _, m := range ms {
|
||||
w.h.PullUpdated(pr(m.repo, m.number, m.head, "feat/g", strings.Split(m.paths, ",")...))
|
||||
}
|
||||
for _, m := range ms {
|
||||
w.h.Checked(judged(m.repo, m.number, m.head, "pass", gTrunk0))
|
||||
}
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g == nil || g.Check == nil {
|
||||
t.Fatalf("no composed check was asked: %+v", g)
|
||||
}
|
||||
c := CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"}
|
||||
for _, m := range ms {
|
||||
owner, repo, _ := strings.Cut(m.repo, "/")
|
||||
c.Members = append(c.Members, struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{Owner: owner, Repo: repo, Commit: m.head, Trunk: gTrunk0[:12]})
|
||||
}
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || len(w.op.open()) != 1 {
|
||||
t.Fatalf("the green group is asked once: %d asks, open %v", w.op.count(), w.op.open())
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Issue 978 (the pin): a feature's single pull request in one repository is a lone delivery, asked alone.
|
||||
func TestAFeaturesSinglePullRequestIsALoneDeliveryAskedAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
d := w.delivery(IDOf("novox/app", readyHead))
|
||||
if d.Group != "" || len(w.h.groups) != 0 {
|
||||
t.Fatalf("a single pull request made a group: %q %v", d.Group, w.h.groups)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a lone delivery is asked once, alone: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// W11.10 (design 47; F-forge-7, F-forge-8): groups answers the seat's groups: the members in merge order (the SDK first,
|
||||
// whatever its id), the acts said and not answered with their ids, the acts a record holds with their outcomes, and for a
|
||||
// group it does not know no groups and no error.
|
||||
func TestTheGroupsAnswerIsTheSeatsGroupInMergeOrderWithItsActs(t *testing.T) {
|
||||
w := approvedGroupOf(t, member{"novox/app", 1, gHc, "go.mod"}, member{SDKRepository, 2, gHk, "go/x.go"})
|
||||
app := w.h.deliveries[IDOf("novox/app", gHc)]
|
||||
said := w.now
|
||||
app.Owed = append(app.Owed, Effect{Kind: EffectStatus, Context: "mesh/merge-gate", State: "pending", Line: "waits",
|
||||
Commit: gHc, Since: said, ID: "status.x", Said: &said})
|
||||
a := w.h.Groups(false, "")
|
||||
if a.Error != "" || len(a.Groups) != 1 || len(a.Groups[0].Members) != 2 {
|
||||
t.Fatalf("groups answers %+v", a)
|
||||
}
|
||||
g := a.Groups[0]
|
||||
if m := g.Members[0]; m.Repository != SDKRepository || m.Number != 2 || m.Head != gHk || m.Base != "main" || !m.Open {
|
||||
t.Fatalf("the first member in merge order is %+v, not the SDK", m)
|
||||
}
|
||||
var act *seat.Act
|
||||
for i := range g.Acts {
|
||||
if g.Acts[i].Owed.ID == "status.x" {
|
||||
act = &g.Acts[i]
|
||||
}
|
||||
}
|
||||
if act == nil || act.Outcome != "" || act.Owed.Act != seat.OwedActStatus || act.Owed.Repository != "novox/app" ||
|
||||
act.Owed.Number != 1 || act.Owed.Head != gHc || act.Owed.Context != "mesh/merge-gate" || act.Owed.Said == nil {
|
||||
t.Fatalf("the act said and not answered is %+v in %+v", act, g.Acts)
|
||||
}
|
||||
if one := w.h.Groups(false, "feat/g"); len(one.Groups) != 1 || one.Groups[0].ID != "feat/g" {
|
||||
t.Fatalf("groups of feat/g answers %+v", one)
|
||||
}
|
||||
none := w.h.Groups(true, "feat/nothing")
|
||||
if raw, _ := json.Marshal(none); none.Error != "" || len(none.Groups) != 0 || strings.Contains(string(raw), "error") {
|
||||
t.Fatalf("a group it does not know is answered %s", raw)
|
||||
}
|
||||
at := w.now
|
||||
recorded := recordedActs([]*Record{{Acts: map[string]RecordAct{
|
||||
"note.y": {ID: "note.y", Act: EffectNote, Repository: "novox/App", Number: 1, Head: gHc, Outcome: seat.ActOutcomeRefused, Words: "no", At: at},
|
||||
"other": {ID: "other", Act: EffectNote, Repository: "novox/other", Number: 9, Head: gHc, Outcome: seat.ActOutcomePerformed, At: at},
|
||||
}}}, map[string]bool{"novox/app@" + gHc: true})
|
||||
if len(recorded) != 1 || recorded[0].Owed.ID != "note.y" || recorded[0].Outcome != seat.ActOutcomeRefused ||
|
||||
recorded[0].Words != "no" || recorded[0].Owed.Act != seat.OwedActNote || recorded[0].At == nil {
|
||||
t.Fatalf("the recorded acts are %+v", recorded)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1082: a refusal loop is not silent. A merge refused past ten minutes in a closed group (ADR 0418 §3) is a stalled
|
||||
// line naming the member and the merge tool's last answer, which the controller raises as a condition.
|
||||
func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
w.settleAll()
|
||||
// Each composed pass is followed by a refusal again: the loop.
|
||||
for range 12 {
|
||||
w.later(time.Minute)
|
||||
g := w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
w.settleAll()
|
||||
}
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCtl, gHc) || lines[0].Bound != "0s" ||
|
||||
!strings.Contains(lines[0].Says, errAskedAgain.Error()) || !strings.Contains(lines[0].Says, "group feat/g") {
|
||||
t.Fatalf("the loop was not said, naming the member, its group and the tool's answer: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// ownPending is the merge tool's refusal of a member whose own mesh/repo-check stands pending after the group's composed
|
||||
// check set its gate (issue 1049: the forge's "not a success: mesh/repo-check (pending)").
|
||||
var ownPending = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-controller#1 is not merged: main requires " +
|
||||
"mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (pending). A pull request merges once its checks succeed")
|
||||
@@ -1,35 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A member that joins a group whose state stays checking is said on mesh/delivery-group, and the members already there
|
||||
// read the new count: on 2026-10-30 mesh-catalog #571 joined a group of three at 21:59, and the forge kept saying
|
||||
// "3 of 3 member(s) ready" of the group of four, since the line was written again only when the state changed.
|
||||
func TestAGroupSaysAJoiningMember(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
const ctlHead, catHead, hostHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2", "e3e3e3e3e3e3e3e3"
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
w.h.Checked(verdict("novox/mesh-controller", 1, ctlHead, "pass"))
|
||||
w.settleAll()
|
||||
first := w.forge.statuses["novox/mesh-controller@"+ctlHead+" mesh/delivery-group"]
|
||||
if !strings.Contains(first, "1 of 2") {
|
||||
t.Fatalf("before the join the group says %q, want 1 of 2", first)
|
||||
}
|
||||
w.h.PullUpdated(pr("novox/mesh-host", 3, hostHead, "feat/x", "cmd/a.go"))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/x"]
|
||||
st, why := GroupState(g, w.h.membersOf(g))
|
||||
for _, c := range []struct{ name, key, want string }{
|
||||
{"a member already there", "novox/mesh-controller@" + ctlHead, "1 of 3"},
|
||||
{"the member that joined", "novox/mesh-host@" + hostHead, "1 of 3"},
|
||||
} {
|
||||
s := w.forge.statuses[c.key+" mesh/delivery-group"]
|
||||
if !strings.Contains(s, c.want) {
|
||||
t.Errorf("%s: mesh/delivery-group says %q while the group is %s (%s); want %q", c.name, s, st, why, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// mergedAt is the forge's `pull.merged` at a moment.
|
||||
func mergedAt(p PullEvent, merge string, at time.Time) PullEvent {
|
||||
p.MergeCommit = merge
|
||||
p.MergedAt = at.UTC().Format(time.RFC3339)
|
||||
return p
|
||||
}
|
||||
|
||||
// verdictMoving is a passing verdict. The gate plans no delivery any more (ADR 0539 §17), so it names no module.
|
||||
func verdictMoving(repo string, number int, sha string, _ ...string) CheckedEvent {
|
||||
return verdict(repo, number, sha, "pass")
|
||||
}
|
||||
|
||||
func statusOn(w *world, repo, commit string) string {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
|
||||
}
|
||||
|
||||
// checkerSays puts the checker's verdict at these heads together into its verdicts bucket, as captured on the bus
|
||||
// (mesh-delivery-checker, key `<group>.<heads hash>`).
|
||||
func checkerSays(w *world, group, state, failed string, heads ...mdc.Head) {
|
||||
checkerSaysWith(w, group, state, failed, nil, heads...)
|
||||
}
|
||||
|
||||
// checkerSaysWith is checkerSays with more of the verdict's fields (its composition); the state "gone" deletes the key.
|
||||
func checkerSaysWith(w *world, group, state, failed string, more map[string]any, heads ...mdc.Head) {
|
||||
hash, _ := mdc.HeadsHash(heads)
|
||||
if state == "gone" {
|
||||
w.h.CheckerVerdictChanged(group+"."+hash, nil)
|
||||
return
|
||||
}
|
||||
v := map[string]any{"v": 1, "group": group, "heads": hash, "state": state, "failed": map[string]string{"words": failed}}
|
||||
for k, x := range more {
|
||||
v[k] = x
|
||||
}
|
||||
value, _ := json.Marshal(v)
|
||||
w.h.CheckerVerdictChanged(group+"."+hash, value)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,399 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// The holder, end to end over fakes (novox/hq ADR 0239 *How it is checked*, cut by ADR 0539 §17): a pull request's head
|
||||
// proposed and ready; merged; a newer head closing the older; a restart resuming; the trunk rule; and every transition
|
||||
// kept before it is said, noted on the commit and shown on the pull request.
|
||||
|
||||
const (
|
||||
head = "aaaaaaaaaaaa1111"
|
||||
merge = "mmmmmmmmmmmm9999"
|
||||
)
|
||||
|
||||
func TestAPullRequestsHeadIsProposedAndReady(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
|
||||
w.h.PullUpdated(p)
|
||||
id := IDOf("novox/app", head)
|
||||
if w.state(id) != Proposed {
|
||||
t.Fatalf("a head is %s", w.state(id))
|
||||
}
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
d := w.delivery(id)
|
||||
if d.State != Ready {
|
||||
t.Fatalf("a passing verdict left it %s", d.State)
|
||||
}
|
||||
var path []State
|
||||
for _, tr := range d.Transitions {
|
||||
path = append(path, tr.To)
|
||||
}
|
||||
if strings.Join(statesOf(path), ",") != "proposed,ready" {
|
||||
t.Fatalf("its transitions are %v", path)
|
||||
}
|
||||
w.settleAll()
|
||||
notes := w.forge.notesOn(head)
|
||||
if len(notes) != 2 || !strings.Contains(notes[1], "proposed -> ready") {
|
||||
t.Fatalf("the head's note is %q", notes)
|
||||
}
|
||||
if !strings.Contains(w.forge.statuses["novox/app@"+head+" mesh/delivery"], "success ready") {
|
||||
t.Fatalf("the head's status is %q", w.forge.statuses)
|
||||
}
|
||||
if v := w.forge.views["novox/app#7"]; !strings.Contains(v, viewMarker) || !strings.Contains(v, "**ready**") {
|
||||
t.Fatalf("the pull request's view is %q", v)
|
||||
}
|
||||
// A failing verdict for another head rejects it, and a newer head closes the older.
|
||||
w.h.PullUpdated(pr("novox/app", 7, "bbbbbbbbbbbb2222", "feat/x"))
|
||||
if w.state(id) != Closed {
|
||||
t.Fatalf("a newer head left the older %s", w.state(id))
|
||||
}
|
||||
w.h.Checked(verdict("novox/app", 7, "bbbbbbbbbbbb2222", "fail"))
|
||||
if s := w.state(IDOf("novox/app", "bbbbbbbbbbbb2222")); s != Rejected {
|
||||
t.Fatalf("a failing verdict left it %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func statesOf(ss []State) []string {
|
||||
var out []string
|
||||
for _, s := range ss {
|
||||
out = append(out, string(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A merge puts the delivery on the trunk and ends it there: merged, with the merge commit's note and the pull request's
|
||||
// status naming the source revision it moved. Nothing after the merge is derived (ADR 0539 §17).
|
||||
func TestAMergeIsMergedAndTheGateDerivesNothingAfterIt(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.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
d := w.delivery(id)
|
||||
if d.State != Merged || d.MergedAs != merge {
|
||||
t.Fatalf("a merge left it %s as %q", d.State, d.MergedAs)
|
||||
}
|
||||
w.settleAll()
|
||||
w.later(72 * time.Hour)
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Merged {
|
||||
t.Fatalf("time moved a merged delivery to %s", s)
|
||||
}
|
||||
if st := w.forge.statuses["novox/app@"+head+" mesh/delivery"]; !strings.HasPrefix(st, "success merged: source.main moved to novox/app@"+merge[:12]) {
|
||||
t.Fatalf("the pull request's mesh/delivery status is %q", st)
|
||||
}
|
||||
view := w.forge.views["novox/app#7"]
|
||||
if !strings.Contains(view, "**Source**") || !strings.Contains(view, "source.main moved to novox/app@"+merge[:12]) ||
|
||||
!strings.Contains(view, "mesh-delivery-view") || strings.Contains(view, "**Delivery plan**") || strings.Contains(view, "**Machines**") {
|
||||
t.Fatalf("the pull request's view is %q", view)
|
||||
}
|
||||
n := 0
|
||||
for _, s := range w.said {
|
||||
if s["id"] == id {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != len(d.Transitions) {
|
||||
t.Fatalf("%d transitions, %d said", len(d.Transitions), n)
|
||||
}
|
||||
}
|
||||
|
||||
// The trunk rule: a ready delivery whose commit never reached the trunk is never merged.
|
||||
func TestACommitOffTheTrunkIsNeverMerged(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.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("an off-trunk head is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A restart resumes: a new holder over the same state stands every delivery where it was.
|
||||
func TestARestartResumesFromTheState(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.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Merged {
|
||||
t.Fatalf("before the restart it is %s", s)
|
||||
}
|
||||
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Hour))
|
||||
if s := again.state(id); s != Merged {
|
||||
t.Fatalf("read back, it is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery kept in a state an earlier owner had and this one does not is read as the state it came to and kept so.
|
||||
func TestADeliveryKeptInAGoneStateIsReadAsTheMergeStateItCameTo(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.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.settleAll()
|
||||
raw := w.store.deliveries[id]
|
||||
w.store.deliveries[id] = []byte(strings.Replace(string(raw), `"state":"merged"`, `"state":"delivering"`, 1))
|
||||
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Hour))
|
||||
if s := again.state(id); s != Merged {
|
||||
t.Fatalf("a delivering delivery was read as %s", s)
|
||||
}
|
||||
again.settleAll()
|
||||
if !strings.Contains(string(w.store.deliveries[id]), `"state":"merged"`) {
|
||||
t.Fatalf("the read state was not kept: %s", w.store.deliveries[id])
|
||||
}
|
||||
}
|
||||
|
||||
// Kept before it is said: with the state unreachable, nothing is emitted or noted; once it is kept, all of it.
|
||||
func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.store.failing = true
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if len(w.said) != 0 || len(w.forge.notesOn(head)) != 0 {
|
||||
t.Fatalf("said before it was kept: %v %v", w.said, w.forge.notesOn(head))
|
||||
}
|
||||
w.store.failing = false
|
||||
w.settleAll()
|
||||
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)) != 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) != 2 {
|
||||
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 {
|
||||
t.Fatalf("still owed: %+v", d.Owed)
|
||||
}
|
||||
}
|
||||
|
||||
// A merge before the check passed is held for a person; a merge this owner never heard the head of is made
|
||||
// from the forge's word and merged when the forge says its gate passed.
|
||||
func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "fail"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
if s := w.state(IDOf("novox/app", head)); s != Held {
|
||||
t.Fatalf("a rejected head merged is %s", s)
|
||||
}
|
||||
// Never heard: the forge's statuses on its head decide.
|
||||
m := merged("novox/lab", 3, "eeeeeeeeeeee5555", "ffffffffffff6666")
|
||||
m.HeadChecks = map[string]string{"mesh/check": "success"}
|
||||
m.Paths = []string{"README.md"}
|
||||
w.h.PullMerged(m)
|
||||
w.settleAll()
|
||||
d := w.delivery(IDOf("novox/lab", "eeeeeeeeeeee5555"))
|
||||
if d.State != Merged {
|
||||
t.Fatalf("a merge whose head passed by the forge's word is %s", d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// A group merged whole ends merged, member by member and as a group, and source.main holds its heads (ADR 0539 §17, R6-C2).
|
||||
func TestAMergedGroupEndsMergedWithSourceMainMoved(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false // the merges here are the forge's, not the gate's on an Approve
|
||||
heads := map[string]string{"novox/a": "a1a1a1a1a1a1a1", "novox/b": "b2b2b2b2b2b2b2"}
|
||||
ids := map[string]string{}
|
||||
for i, repo := range []string{"novox/a", "novox/b"} {
|
||||
ids[repo] = IDOf(repo, heads[repo])
|
||||
w.h.PullUpdated(pr(repo, i+1, heads[repo], "feat/y", "x"))
|
||||
w.h.Checked(verdict(repo, i+1, heads[repo], "pass"))
|
||||
}
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/y", ID: "check-1", Verdict: "pass", Summary: "compose"})
|
||||
for i, repo := range []string{"novox/a", "novox/b"} {
|
||||
w.h.PullMerged(merged(repo, i+1, heads[repo], strings.Repeat(string(rune('p'+i)), 12)))
|
||||
}
|
||||
w.settleAll()
|
||||
for repo, id := range ids {
|
||||
if s := w.state(id); s != Merged {
|
||||
t.Errorf("%s is %s, wanted merged", repo, s)
|
||||
}
|
||||
}
|
||||
g := w.h.groups["feat/y"]
|
||||
if state, why := GroupState(g, w.h.membersOf(g)); state != "merged" {
|
||||
t.Fatalf("the group is %s (%s), wanted merged", state, why)
|
||||
}
|
||||
if w.source.main == nil || w.source.main.Heads["novox/a"] != strings.Repeat("p", 12) || w.source.main.Heads["novox/b"] != strings.Repeat("q", 12) {
|
||||
t.Fatalf("source.main is %+v, wanted both merges", w.source.main)
|
||||
}
|
||||
// A merge that leaves no module.json carries no trees (issue 1367): the gate reads it, and refuses nothing.
|
||||
msg := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/a", Number: 9, Head: strings.Repeat("e", 40),
|
||||
Merge: strings.Repeat("f", 40), Root: "r"}
|
||||
body, err := forgemsg.EncodeForge(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("a merged message with no trees is refused: %v", err)
|
||||
}
|
||||
w.h.Forged("mesh-delivery-forge-bridge.novox.a.merged", body)
|
||||
if len(w.h.refusals) != 0 {
|
||||
t.Errorf("the gate refused a merged message with no trees: %v", w.h.refusals)
|
||||
}
|
||||
// A group's end is the indexer's: the gate ignores it before decoding, and says no refusal (1585/T25).
|
||||
w.h.Forged("mesh-delivery-forge-bridge.forge.group.merged", []byte(`{"v":1,"group":"feat/y"}`))
|
||||
if len(w.h.refusals) != 0 {
|
||||
t.Errorf("the gate refused a group's end: %v", w.h.refusals)
|
||||
}
|
||||
// The repository forge/group's merged message ends the same way: it is decoded as a forge message, and this one,
|
||||
// which is none, is refused.
|
||||
w.h.Forged("mesh-delivery-forge-bridge.forge.forge.group.merged", []byte(`{"v":1,"group":"feat/y"}`))
|
||||
if len(w.h.refusals) != 1 {
|
||||
t.Errorf("forge/group's merged message was passed by as a group's end: refusals %v", w.h.refusals)
|
||||
}
|
||||
}
|
||||
|
||||
// W11.5 (novox/hq ADR 0659 §4, ADR 0493 §4): the issues an ask names are the issue tracker's links of each member's pull
|
||||
// request, read through the seat issue-tracker, never the title; a tracker that does not answer is said, and holds nothing;
|
||||
// the author is the forge's pull request record's. ADR 0419 §2's after: notice is gone with the description.
|
||||
func TestTheAskNamesTheIssuesLinkedInTheIssueTracker(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
links := &fakeLinks{linked: map[string][]int{"novox/a#1": {948, 12}}, failing: map[string]bool{"novox/b#2": true}}
|
||||
w.h.Links = links
|
||||
pa := pr("novox/a", 1, "a1a1a1a1a1a1a1", "feat/z")
|
||||
pa.Title, pa.Author = "Fix issue 77", "quiet-deliveries"
|
||||
w.h.PullUpdated(pa)
|
||||
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()
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("the group with an unread tracker is not asked: %v", open)
|
||||
}
|
||||
e := w.op.ask(open[0]).Explanation
|
||||
if !strings.Contains(e, "Issues: issue 12, issue 948, b#2's unread (the issue tracker did not answer)") || strings.Contains(e, "77") {
|
||||
t.Fatalf("the ask's issues are not the links:\n%s", e)
|
||||
}
|
||||
if d := w.delivery(IDOf("novox/a", "a1a1a1a1a1a1a1")); d.Author != "quiet-deliveries" || authorOf(d) != "the author of feat/z (quiet-deliveries)" {
|
||||
t.Fatalf("the author is %q, said %q", d.Author, authorOf(d))
|
||||
}
|
||||
if links.asked["novox/a#1"] != 1 {
|
||||
t.Fatalf("the links of novox/a#1 were read %d times at one head, want once", links.asked["novox/a#1"])
|
||||
}
|
||||
for _, c := range w.forge.comments {
|
||||
if strings.Contains(c, "after:") {
|
||||
t.Fatalf("an after: notice is said: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLinks is the issue tracker's links of pull requests, by `<repository>#<number>`.
|
||||
type fakeLinks struct {
|
||||
mu sync.Mutex
|
||||
linked map[string][]int
|
||||
failing map[string]bool
|
||||
asked map[string]int
|
||||
}
|
||||
|
||||
func (f *fakeLinks) Linked(repository string, number int) ([]int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
key := repository + "#" + strconv.Itoa(number)
|
||||
if f.asked == nil {
|
||||
f.asked = map[string]int{}
|
||||
}
|
||||
f.asked[key]++
|
||||
if f.failing[key] {
|
||||
return nil, errors.New("no responders")
|
||||
}
|
||||
out := append([]int(nil), f.linked[key]...)
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stalled: a delivery past its state's bound is listed with what H2 may do. Only a proposed
|
||||
// delivery has a bound now: its check was asked and not answered.
|
||||
func TestStalledWorksFromTheTable(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.later(3 * time.Hour)
|
||||
stalled := w.h.Stalled()
|
||||
if len(stalled) != 1 || stalled[0].ID != id || stalled[0].State != Proposed {
|
||||
t.Fatalf("stalled %+v", stalled)
|
||||
}
|
||||
// A merge is the gate's last word: it is never stalled, however old.
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.later(72 * time.Hour)
|
||||
for _, s := range w.h.Stalled() {
|
||||
if s.ID == id {
|
||||
t.Fatalf("a merged delivery is stalled: %+v", s)
|
||||
}
|
||||
}
|
||||
// A merge without a passing check is held: a person's, with no transition for H2.
|
||||
w.h.PullUpdated(pr("novox/app", 8, "ffffffffffff0000", "feat/q"))
|
||||
w.h.PullMerged(merged("novox/app", 8, "ffffffffffff0000", "eeeeeeeeeeee0000"))
|
||||
w.later(48 * time.Hour)
|
||||
}
|
||||
|
||||
// novox/hq issue 867, ADR 0382 §1 (contract A): each member of a group in `groups` says its pull request number, full
|
||||
// head, base branch and whether it is open, under the seat's keys.
|
||||
func TestGroupsNamesEachMembersPullRequestHeadBaseAndOpen(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
headA := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||
headB := "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"
|
||||
pb := pr("novox/b", 2, headB, "feat/z")
|
||||
pb.Base = "release/1"
|
||||
w.h.PullUpdated(pr("novox/a", 1, headA, "feat/z"))
|
||||
w.h.PullUpdated(pb)
|
||||
lines := w.h.Groups(false, "").Groups
|
||||
if len(lines) != 1 || len(lines[0].Members) != 2 {
|
||||
t.Fatalf("groups: %+v", lines)
|
||||
}
|
||||
got := map[string]seat.Member{}
|
||||
for _, m := range lines[0].Members {
|
||||
got[m.Repository] = m
|
||||
}
|
||||
a, b := got["novox/a"], got["novox/b"]
|
||||
if a.Number != 1 || a.Head != headA || a.Base != "main" || !a.Open || a.ID == "" || a.State == "" {
|
||||
t.Fatalf("member a: %+v", a)
|
||||
}
|
||||
if b.Number != 2 || b.Head != headB || b.Base != "release/1" || !b.Open {
|
||||
t.Fatalf("member b: %+v", b)
|
||||
}
|
||||
// Closed unmerged: still listed while the group is, but not open.
|
||||
w.h.PullClosed(pr("novox/b", 2, headB, "feat/z"))
|
||||
for _, l := range w.h.Groups(true, "").Groups {
|
||||
for _, m := range l.Members {
|
||||
if m.Repository == "novox/b" && m.Open {
|
||||
t.Fatalf("a closed pull request is open: %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
// JSON keys are the contract's.
|
||||
raw, _ := json.Marshal(a)
|
||||
for _, k := range []string{`"number":1`, `"head":"` + headA + `"`, `"base":"main"`, `"open":true`} {
|
||||
if !strings.Contains(string(raw), k) {
|
||||
t.Fatalf("%s lacks %s", raw, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 1083: mesh-controller #375's delivery was stopped by a person, then #375 was closed and the same commit
|
||||
// opened as #376 from another branch. The delivery is named repository@commit; it was Stopped before its pull request
|
||||
// closed, so it was not ClosedUnmerged, and #376 got no delivery of its own: its Approve was never asked. A new pull
|
||||
// request at a commit whose delivery is Stopped and unmerged, under another number, takes that record over (the key stays
|
||||
// repository@commit); the take-over is said in its transitions, with who stopped it and why.
|
||||
|
||||
const (
|
||||
head1083 = "e375e375e375e375"
|
||||
branch1083 = "feat/backup-before-send"
|
||||
)
|
||||
|
||||
// A closed record that was merged is not taken over, and the same pull request announced again stays as it was.
|
||||
func TestAStoppedRecordThatWasMergedIsNotTakenOverByAnotherPullRequest(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf(catalog783, head783c+"1")
|
||||
w.h.PullUpdated(pr(catalog783, 400, head783c+"1", branch1083, "src/a.go"))
|
||||
w.h.PullMerged(merged(catalog783, 400, head783c+"1", "f400f400f400f400"))
|
||||
w.h.PullUpdated(pr(catalog783, 401, head783c+"1", branch1083+"-2", "src/a.go"))
|
||||
if d := w.delivery(id); d.Number != 400 || d.MergedAs == "" {
|
||||
t.Fatalf("a merged record was taken over: Number %d, merged as %q", d.Number, d.MergedAs)
|
||||
}
|
||||
}
|
||||
|
||||
// The take-over leaves the stopped record's group: the new record joins its own group by its branch.
|
||||
func TestATakenOverRecordLeavesTheGroupItWasStoppedIn(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
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"))
|
||||
id := IDOf(gCtl, gHc)
|
||||
w.h.PullClosed(pr(gCtl, 1, gHc, "feat/g"))
|
||||
w.h.PullUpdated(pr(gCtl, 9, gHc, "feat/other", "internal/a.go"))
|
||||
w.h.mu.Lock()
|
||||
defer w.h.mu.Unlock()
|
||||
for _, g := range w.h.groups {
|
||||
if g.ID == "feat/g" {
|
||||
for _, m := range g.Members {
|
||||
if m == id {
|
||||
t.Fatalf("group feat/g still lists %s, now #9 on feat/other: %v", id, g.Members)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if d := w.h.deliveries[id]; d.Number != 9 || d.State != Proposed || d.Group == "feat/g" {
|
||||
t.Fatalf("taken over: Number %d, %s, group %q", d.Number, d.State, d.Group)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1107: a pull request closed unmerged in a quiet repository is heard from the forge's own event
|
||||
// (gitea.forge.pull-request.closed), not only from the poll, which skips a repository whose update time did not move.
|
||||
// Its delivery leaves its group and stops; a merged one is ignored (the webhook says merged for it); a close heard
|
||||
// twice, from the event and the poll, changes nothing the second time.
|
||||
func forgeClosed(owner, repo string, number int, merged bool) gitseat.ForgePullEvent {
|
||||
return gitseat.ForgePullEvent{Action: "closed", Repository: owner + "/" + repo, Number: int64(number), Merged: merged}
|
||||
}
|
||||
|
||||
func TestAForgeClosedEventTakesAGroupMembersDeliveryOutOfItsGroupAndStopsIt(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
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"))
|
||||
id := IDOf(gCtl, gHc)
|
||||
hear(t, w, gitEvent(gitseat.EventForgePull), forgeClosed("novox", "mesh-controller", 1, false))
|
||||
d := w.delivery(id)
|
||||
if !d.ClosedUnmerged || !d.State.Final() || inGroup(w, "feat/g", id) {
|
||||
t.Fatalf("closed %v, state %s, in its group %v; wanted closed, stopped, out of its group", d.ClosedUnmerged, d.State,
|
||||
inGroup(w, "feat/g", id))
|
||||
}
|
||||
// Heard again, from the event and from the poll: nothing changes.
|
||||
before, _ := json.Marshal(d)
|
||||
hear(t, w, gitEvent(gitseat.EventForgePull), forgeClosed("novox", "mesh-controller", 1, false))
|
||||
hear(t, w, gitEvent(gitseat.EventPullClosed), gitseat.PullClosedEvent{Repository: "novox/mesh-controller", Number: 1})
|
||||
if after, _ := json.Marshal(w.delivery(id)); string(after) != string(before) {
|
||||
t.Fatalf("a second close changed it:\n%s\n%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAForgeClosedEventOfAMergedPullRequestIsIgnored(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
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"))
|
||||
id := IDOf(gCtl, gHc)
|
||||
hear(t, w, gitEvent(gitseat.EventForgePull), forgeClosed("novox", "mesh-controller", 1, true))
|
||||
if d := w.delivery(id); d.ClosedUnmerged || !inGroup(w, "feat/g", id) || d.State.Final() {
|
||||
t.Fatalf("a merged pull request's close stopped it: closed %v, %s", d.ClosedUnmerged, d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// inGroup is whether the group still lists the delivery as a member.
|
||||
func inGroup(w *world, group, id string) bool {
|
||||
w.h.mu.Lock()
|
||||
defer w.h.mu.Unlock()
|
||||
g := w.h.groups[group]
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
for _, m := range g.Members {
|
||||
if m == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 1114: mesh-controller #379 was opened at the head of the still-open #378. Its announcement read as the
|
||||
// same delivery announced again, so when #378 closed unmerged the shared record went Stopped and #379 had no delivery.
|
||||
// The other open pull request at the head is kept on the record; a close of the record's own pull request starts the
|
||||
// record over for it at once (as issue 1083's take-over does for a later announcement), and an edit of it does so too,
|
||||
// so a case stuck before the fix frees itself. A merged record is never taken over.
|
||||
|
||||
const head1114 = "0149a3d70149a3d70149a3d70149a3d70149a3d7"
|
||||
|
||||
func TestClosingThePullRequestOfASharedHeadStartsTheOtherOnesDeliveryOver(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
id := IDOf(gCtl, head1114)
|
||||
w.h.PullUpdated(pr(gCtl, 378, head1114, "feat/chain", "internal/a.go"))
|
||||
b := pr(gCtl, 379, head1114, "feat/chain-2", "internal/a.go")
|
||||
w.h.PullUpdated(b) // announced while #378 is live: the same delivery heard again
|
||||
if d := w.delivery(id); d.Number != 378 {
|
||||
t.Fatalf("setup: the record is #%d, wanted #378 still", d.Number)
|
||||
}
|
||||
w.h.PullClosed(pr(gCtl, 378, head1114, "feat/chain"))
|
||||
d := w.delivery(id)
|
||||
if d.Number != 379 || d.State.Final() || d.ClosedUnmerged {
|
||||
t.Fatalf("after #378 closed the record is #%d %s (closed %v); wanted #379, proposed", d.Number, d.State, d.ClosedUnmerged)
|
||||
}
|
||||
w.h.Checked(verdict(gCtl, 379, head1114, "pass"))
|
||||
w.settleAll()
|
||||
if open := w.op.open(); w.state(id) != Ready || len(open) != 1 {
|
||||
t.Fatalf("#379's delivery is %s with asks %v; wanted ready and one Approve ask", w.state(id), w.op.open())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosingTheOtherPullRequestOfASharedHeadLeavesTheRecordAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf(gCtl, head1114)
|
||||
w.h.PullUpdated(pr(gCtl, 378, head1114, "feat/chain", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCtl, 379, head1114, "feat/chain-2", "internal/a.go"))
|
||||
w.h.PullClosed(pr(gCtl, 379, head1114, "feat/chain-2"))
|
||||
w.h.PullClosed(pr(gCtl, 378, head1114, "feat/chain"))
|
||||
if d := w.delivery(id); d.Number != 378 || !d.ClosedUnmerged || !d.State.Final() {
|
||||
t.Fatalf("with #379 closed first, #378's close left %d %s; wanted it stopped under #378", d.Number, d.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEditOfAPullRequestWhoseSharedHeadsRecordIsStoppedUnderAnotherTakesItOver(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf(gCtl, head1114)
|
||||
w.h.PullUpdated(pr(gCtl, 378, head1114, "feat/chain", "internal/a.go"))
|
||||
w.h.PullClosed(pr(gCtl, 378, head1114, "feat/chain")) // #379 was never heard: the stuck case
|
||||
if d := w.delivery(id); !d.State.Final() {
|
||||
t.Fatalf("setup: %s", d.State)
|
||||
}
|
||||
e := pr(gCtl, 379, head1114, "feat/chain-2")
|
||||
w.h.PullEdited(e)
|
||||
d := w.delivery(id)
|
||||
if d.Number != 379 || d.State != Proposed || len(d.Paths) == 0 {
|
||||
t.Fatalf("after #379's edit the record is #%d %s paths %v; wanted #379 proposed, read, with the paths", d.Number,
|
||||
d.State, d.Paths)
|
||||
}
|
||||
w.h.PullEdited(e) // an edit heard again changes nothing
|
||||
if again := w.delivery(id); again.Number != 379 || again.State != Proposed {
|
||||
t.Fatalf("a second edit moved it: #%d %s", again.Number, again.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMergedRecordIsNeverTakenOverByTheOtherPullRequestOfItsHead(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf(gCtl, head1114)
|
||||
w.h.PullUpdated(pr(gCtl, 378, head1114, "feat/chain", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCtl, 379, head1114, "feat/chain-2", "internal/a.go"))
|
||||
w.h.PullMerged(merged(gCtl, 378, head1114, "f378f378f378f378"))
|
||||
w.h.PullClosed(pr(gCtl, 378, head1114, "feat/chain"))
|
||||
e := pr(gCtl, 379, head1114, "feat/chain-2")
|
||||
w.h.PullEdited(e)
|
||||
if d := w.delivery(id); d.Number != 378 || d.MergedAs == "" {
|
||||
t.Fatalf("a merged record was taken over: #%d merged as %q", d.Number, d.MergedAs)
|
||||
}
|
||||
}
|
||||
@@ -1,742 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
const time30s = 31 * time.Second
|
||||
|
||||
// Issue 1275 (the 2026-10-19 18:05 group): a group is merged whole or not at all, in its order, and a group that is held or
|
||||
// half merged is said loudly. Issue 1276/AC1: a refusal naming the member's own repository check as missing asks that check.
|
||||
|
||||
const (
|
||||
gate, repoCheck = "mesh/merge-gate", "mesh/repo-check"
|
||||
)
|
||||
|
||||
// statusOf sets a member head's two merge checks on the forge, and makes both required.
|
||||
func setChecks(w *world, repository, sha, gateState, repoState string) {
|
||||
w.forge.required = []string{gate, repoCheck}
|
||||
for ctx, state := range map[string]string{gate: gateState, repoCheck: repoState} {
|
||||
key := repository + "@" + sha + " " + ctx
|
||||
if state == "" {
|
||||
delete(w.forge.statuses, key) // no status at all: the forge says it is missing
|
||||
continue
|
||||
}
|
||||
w.forge.statuses[key] = state + " said → x"
|
||||
}
|
||||
}
|
||||
|
||||
func allGreen(w *world) {
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
}
|
||||
|
||||
func refusedWords(repository string) error {
|
||||
return errors.New("the forge refused gitea_merge_pull_request: " + repository + " is not merged: main requires " +
|
||||
"mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing). A pull request merges once its checks succeed")
|
||||
}
|
||||
|
||||
func urgentLines(w *world) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.Severity == "urgent" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// novox/hq ADR 0661 §4 (changing 1275/AC2 and ADR 0441 §2): an approved group whose member's required status turned
|
||||
// failed after the Approve merges whole, with no read of its statuses at the merge, and raises no line.
|
||||
func TestAnApprovedGroupWhoseMemberStatusTurnedAfterTheApproveMergesWhole(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing after the ask
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || len(urgentLines(w)) != 0 {
|
||||
t.Fatalf("merges %v, lines %+v; wanted both merged on the Approve and no line", w.forge.merges, urgentLines(w))
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC4: a group half merged (the second member refused after the first merged) stays raised, pass after pass, until
|
||||
// the second is merged; then it goes.
|
||||
func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: refusedWords("novox/mesh-controller#1")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
for range 3 {
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCtl, gHc) || !strings.Contains(lines[0].Says, "feat/g") {
|
||||
t.Fatalf("the half merged group is not raised: %+v", lines)
|
||||
}
|
||||
}
|
||||
// A half merged group with no refusal on record (the pass died between members) is raised too.
|
||||
d := w.delivery(IDOf(gCtl, gHc))
|
||||
d.Ready.RefusedSince = nil
|
||||
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "half merged") {
|
||||
t.Fatalf("a half merged group without a refusal is not raised: %+v", lines)
|
||||
}
|
||||
w.forge.refuseRepo = nil
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
||||
w.settleAll()
|
||||
if lines := urgentLines(w); len(lines) != 0 {
|
||||
t.Fatalf("the group is whole and still raised: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC5: a member's required status turning not-success while the ask waits withdraws the ask at the router; the
|
||||
// answer to the withdrawn ask merges nothing; the group is asked again once every member may merge.
|
||||
func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
ask := w.op.open()[0]
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "pending"})
|
||||
setChecks(w, gCat, gHk, "success", "pending")
|
||||
w.settleAll()
|
||||
if len(w.op.cancelled) != 1 || w.op.cancelled[0] != ask {
|
||||
t.Fatalf("the ask was not withdrawn at the router: %v", w.op.cancelled)
|
||||
}
|
||||
w.op.answer(ask, OptionApprove) // an answer that arrives late
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("the answer to a withdrawn ask merged: %v", w.forge.merges)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("asked again while a status was still pending: %d", w.op.count())
|
||||
}
|
||||
setChecks(w, gCat, gHk, "success", "success")
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if w.op.count() != 2 {
|
||||
t.Fatalf("not asked again once every member may merge: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6, as novox/hq ADR 0505 §4 changed it: a member refused because its verdict is out of date (the trunk moved; its
|
||||
// fresh check runs) is no stalled line while the gate answers it, and urgent once ten minutes passed with the group not
|
||||
// merged; the pass stops at the refusal all the same.
|
||||
func TestAnOutOfDateRefusalIsNoLineWhileAnsweredAndUrgentAfterTenMinutes(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("the pass did not stop at the refusal: %v", w.forge.merges)
|
||||
}
|
||||
sev := func() []string {
|
||||
var out []string
|
||||
for _, l := range w.h.Stalled() {
|
||||
out = append(out, l.Severity)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := sev(); len(got) != 0 {
|
||||
t.Fatalf("an out of date refusal is %v at once; wanted no line while it is answered", got)
|
||||
}
|
||||
w.later(9 * time.Minute)
|
||||
if got := sev(); len(got) != 0 {
|
||||
t.Fatalf("at nine minutes it is %v; wanted still no line", got)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
if got := sev(); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6: every other refusal (here a missing status) is urgent at once.
|
||||
func TestAMissingStatusRefusalIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || lines[0].Severity != "urgent" {
|
||||
t.Fatalf("a missing status refusal is not urgent at once: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func severities(w *world) []string {
|
||||
var out []string
|
||||
for _, l := range w.h.Stalled() {
|
||||
out = append(out, l.Severity)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1275/AC6 (b), as ADR 0505 §4 changed it: the same out of date refusal on a LATER member, after the earlier one merged,
|
||||
// is no line at once and urgent after ten minutes: the half merged group does not say it at once.
|
||||
func TestAnOutOfDateRefusalOnALaterMemberIsNoLineThenUrgent(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCtl: errAskedAgain}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 0 {
|
||||
t.Fatalf("a later member's out of date refusal is %v at once; wanted no line while it is answered", got)
|
||||
}
|
||||
w.later(11 * time.Minute)
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("after ten minutes it is %v; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (c): words naming an out of date check and also a missing status are urgent at once.
|
||||
func TestARefusalNamingOutOfDateAndAMissingStatusIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-catalog#2 is not merged: its verdict was " +
|
||||
"judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. The check of head cccc merged into trunk bbbb was " +
|
||||
"asked. main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (missing)")
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("a refusal with two causes is %v; wanted urgent at once", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (d): words mesh-delivery cannot classify are urgent at once.
|
||||
func TestARefusalInUnknownWordsIsUrgentAtOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: something nobody foresaw")
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("an unclassified refusal is %v; wanted urgent at once", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The merge tool's refusals, copied from gitea merge.ts with their line, `${...}` filled in.
|
||||
var mergeToolRefusals = []struct {
|
||||
line string
|
||||
words string
|
||||
outOfDated bool
|
||||
}{
|
||||
{"merge.ts:121", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
||||
"Its check is already asked and not yet answered (building), so it is not asked again; took 3s. Ask again at cccccccccccc once " +
|
||||
"both statuses pass: this tool never merges by itself (novox/hq ADR 0323). A check that never answers is asked again with " +
|
||||
"mesh-delivery.recheck. Nothing was merged", true},
|
||||
{"merge.ts:147", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb. " +
|
||||
"The check of head cccccccccccc merged into trunk bbbbbbbbbbbb was asked; took 3s. Ask again at cccccccccccc once both " +
|
||||
"statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may move the trunk " +
|
||||
"again first; this is then refused and asked once more. A check that never answers is asked again with " +
|
||||
"mesh-delivery.recheck. Nothing was merged", true},
|
||||
{"merge.ts:144", "novox/app#7 is not merged: its verdict was judged against trunk aaaaaaaaaaaa, and main is now bbbbbbbbbbbb, " +
|
||||
"and its check could not be asked: the forge is away. Nothing was merged", false},
|
||||
{"merge.ts:156", "novox/app#7 is not merged: main requires mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check " +
|
||||
"(pending). A pull request merges once its checks succeed", false},
|
||||
{"merge.ts:165", "novox/app#7 is not merged: its verdict read siblings, and which ones is not held here, so the group cannot " +
|
||||
"be weighed. Ask its check again at cccccccccccc. Nothing was merged", false},
|
||||
{"merge.ts:172", "novox/app#7 is not merged: its verdict was judged beside 1 sibling(s), and a sibling is not ready. A member " +
|
||||
"judged beside its siblings merges once its group passed at the heads that verdict read (novox/hq ADR 0382, 0421). " +
|
||||
"Nothing was merged", false},
|
||||
}
|
||||
|
||||
// 1275/AC6 (e), as ADR 0505 §4 changed it: only the merge tool's two out of date refusals (:121, :147) are answered by
|
||||
// the gate and said as no line at once; the one saying its check could not be asked (:144) and every other is urgent at
|
||||
// once.
|
||||
func TestOnlyTheMergeToolsTwoOutOfDateRefusalsWaitWhileAnswered(t *testing.T) {
|
||||
for _, c := range mergeToolRefusals {
|
||||
if got := outOfDateRefusal(c.words); got != c.outOfDated {
|
||||
t.Errorf("%s: outOfDateRefusal = %v, wanted %v", c.line, got, c.outOfDated)
|
||||
}
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: " + c.words)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
got := severities(w)
|
||||
if c.outOfDated && len(got) != 0 {
|
||||
t.Errorf("%s: said %v at once, wanted no line while it is answered", c.line, got)
|
||||
}
|
||||
if !c.outOfDated && (len(got) != 1 || got[0] != "urgent") {
|
||||
t.Errorf("%s: severity %v at once, wanted urgent", c.line, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC6 (f): the severity never steps down: a missing status refusal, then an out of date one, stays urgent.
|
||||
func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.forge.refuseRepo = map[string]error{gCat: errAskedAgain}
|
||||
w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk)))
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if d := w.delivery(IDOf(gCat, gHk)); !outOfDateRefusal(d.Ready.Answer) {
|
||||
t.Fatalf("the second refusal was not the out of date one: %q", d.Ready.Answer)
|
||||
}
|
||||
if got := severities(w); len(got) != 1 || got[0] != "urgent" {
|
||||
t.Fatalf("severity %v after an out of date refusal that followed an urgent one; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeConditions is the gate's bucket conditions and the operator-channel seat's subject for conditions: what it keeps,
|
||||
// and every condition said, in order.
|
||||
type fakeConditions struct {
|
||||
mu sync.Mutex
|
||||
kept map[string]string
|
||||
told []oc.Condition
|
||||
}
|
||||
|
||||
func (f *fakeConditions) Said() (map[string]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := map[string]string{}
|
||||
for k, v := range f.kept {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeConditions) PutSaid(said map[string]string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.kept = map[string]string{}
|
||||
for k, v := range said {
|
||||
f.kept[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeConditions) say(c oc.Condition) error {
|
||||
if _, err := occ.EncodeCondition(c); err != nil {
|
||||
return err // what the router would refuse
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.told = append(f.told, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// last is the newest condition said for a delivery id, or nil.
|
||||
func (f *fakeConditions) last(id string) *oc.Condition {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := len(f.told) - 1; i >= 0; i-- {
|
||||
if f.told[i].Subject.ID == id {
|
||||
c := f.told[i]
|
||||
return &c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hearConditions has the gate say its conditions into a fake seat and bucket.
|
||||
func hearConditions(w *world) *fakeConditions {
|
||||
f := &fakeConditions{kept: map[string]string{}}
|
||||
w.h.Say, w.h.Conditions = f.say, f
|
||||
return f
|
||||
}
|
||||
|
||||
// handedInOrder answers each member the gate hands the forge bridge as the forge does on 2026-10-25: the forge's word that
|
||||
// it merged (gitea.pull.merged) first, the bridge's word after; or the bridge's first, as forgeFirst says. It returns the
|
||||
// repositories in the order they were handed.
|
||||
func handedInOrder(w *world, forgeFirst bool) []string {
|
||||
var handed []string
|
||||
numbers := map[string]int{}
|
||||
for i := 0; i < 4; i++ {
|
||||
var next *MergePull
|
||||
for _, p := range w.merges.last("feat/g").Pulls {
|
||||
if p.State == mdc.MergeMerging {
|
||||
p := p
|
||||
next = &p
|
||||
}
|
||||
}
|
||||
if next == nil || numbers[next.Repository] > 0 {
|
||||
break
|
||||
}
|
||||
handed = append(handed, next.Repository)
|
||||
numbers[next.Repository] = next.Number
|
||||
mergeCommit := strings.Repeat(string(rune('a'+i)), 40)
|
||||
forge := func() { w.h.PullMerged(merged(next.Repository, next.Number, next.Head.Head, mergeCommit)) }
|
||||
bridge := func() { bridgeMerged(w, next.Repository, next.Number, next.Head.Head, mergeCommit) }
|
||||
if forgeFirst {
|
||||
forge()
|
||||
w.settleAll() // ten seconds pass between the two words on 2026-10-25: the gate looks in between
|
||||
bridge()
|
||||
} else {
|
||||
bridge()
|
||||
w.settleAll()
|
||||
forge()
|
||||
}
|
||||
w.settleAll()
|
||||
}
|
||||
return handed
|
||||
}
|
||||
|
||||
// Issue 1275, met again 2026-10-25 (group fix/puller-wakes-on-capacity): every member of an approved group is handed to
|
||||
// the forge bridge, in the group's order, the next on the event that the one before merged; a group whose pass stops is
|
||||
// said to the operator as a condition, which is cleared once it is whole; and every word a group's state is said in is
|
||||
// one the merge decides on.
|
||||
func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
const (
|
||||
sdkRepo, hS = "novox/mesh-sdk", "a8fbabbfc37369f9d1f6977c72e5523aff3d494c"
|
||||
catRepo, hK = "novox/mesh-catalog", "23c827e7910c233608af4e4a8056cbc362dcb755"
|
||||
ctlRepo, hC = "novox/mesh-controller", "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0"
|
||||
)
|
||||
sdkMember := member{sdkRepo, 80, hS, "go/stdio.go,go/buildstep/pull.go"}
|
||||
catMember := member{catRepo, 479, hK, "modules/mesh-build-go/asks.go,modules/mesh-build-go/go.mod,modules/mesh-build-go/go.sum"}
|
||||
ctlMember := member{ctlRepo, 7, hC, "cmd/mesh-controller/main.go"}
|
||||
inOrder := func(members []member, forgeFirst bool, want ...string) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
w := approvedGroupOf(t, members...)
|
||||
w.merges.manual = true
|
||||
allGreenFor(w, members...)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := handedInOrder(w, forgeFirst); strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("handed %v, wanted %v, every member in the group's order", got, want)
|
||||
}
|
||||
if g := w.h.groups["feat/g"]; g == nil {
|
||||
t.Fatal("the group is gone")
|
||||
} else if state, why := GroupState(g, w.h.membersOf(g)); state != GroupMerged {
|
||||
t.Fatalf("the group is %s (%s), not merged whole", state, why)
|
||||
}
|
||||
if w.source.main == nil || len(w.source.main.Heads) != len(want) {
|
||||
t.Fatalf("source.main does not hold the group as one revision: %+v", w.source.main)
|
||||
}
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T)
|
||||
}{
|
||||
// 1275/AC3 (2026-10-25): the case of the day. The catalogue member pins the sdk member's commit; the forge's word
|
||||
// of a merge arrives before the bridge's. Before the fix the catalogue member went first, and the sdk member was
|
||||
// never handed: its group read merging, a word the turn to merge did not know.
|
||||
{"sdk first, then the member that pins it, the forge's word first", inOrder([]member{catMember, sdkMember}, true, sdkRepo, catRepo)},
|
||||
{"sdk first, then the member that pins it, the bridge's word first", inOrder([]member{sdkMember, catMember}, false, sdkRepo, catRepo)},
|
||||
{"a member that pins nothing before one that changes a go.mod", inOrder([]member{catMember, ctlMember}, true, ctlRepo, catRepo)},
|
||||
{"three members", inOrder([]member{ctlMember, catMember, sdkMember}, true, sdkRepo, ctlRepo, catRepo)},
|
||||
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said
|
||||
// urgent, naming the group, the refused member and the forge's words, as a stalled line and to the operator.
|
||||
{"a refused first member stops the group and is said urgent", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
told := hearConditions(w)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
|
||||
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
||||
t.Fatalf("not said as one urgent line: %+v", lines)
|
||||
}
|
||||
if c := told.last(IDOf(gCat, gHk)); c == nil || c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent ||
|
||||
!strings.Contains(c.Summary, "feat/g") {
|
||||
t.Fatalf("the operator was not told, urgent: %+v", c)
|
||||
}
|
||||
}},
|
||||
// 1275/AC4: a group half merged (its pass stopped after the first member) raises one urgent condition, said once
|
||||
// however many looks follow, and cleared once the group is whole.
|
||||
{"a half-merged group is raised once and cleared once whole", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
told := hearConditions(w)
|
||||
allGreen(w)
|
||||
w.merges.manual = true
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
bridgeMerged(w, gCat, 2, gHk, gT1)
|
||||
moveForgeHead(w, gCtl, 1, otherHead) // the second member's head moved at the forge: the pass stops at it
|
||||
w.settleAll()
|
||||
for range 3 {
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
}
|
||||
id := IDOf(gCtl, gHc)
|
||||
raised := 0
|
||||
for _, c := range told.told {
|
||||
if c.Subject.ID == id && c.State == occ.StateRaised {
|
||||
raised++
|
||||
}
|
||||
}
|
||||
if c := told.last(id); raised != 1 || c == nil || c.Severity != occ.SeverityUrgent || !strings.Contains(c.Summary, "feat/g") ||
|
||||
!strings.Contains(c.Summary, "half merged (merged 1 of 2)") {
|
||||
t.Fatalf("raised %d times, last %+v; wanted once, urgent, naming the group half merged", raised, c)
|
||||
}
|
||||
moveForgeHead(w, gCtl, 1, gHc)
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"})
|
||||
w.settleAll() // the status is an event: the pass goes on with no timer
|
||||
handed := false
|
||||
for _, p := range w.merges.last("feat/g").Pulls {
|
||||
handed = handed || (p.Repository == gCtl && p.State == mdc.MergeMerging)
|
||||
}
|
||||
if !handed {
|
||||
t.Fatalf("the status turned success and the second member was not handed: %+v", w.merges.last("feat/g"))
|
||||
}
|
||||
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
||||
bridgeMerged(w, gCtl, 1, gHc, gT1)
|
||||
w.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != occ.StateCleared {
|
||||
t.Fatalf("the group is whole and its condition is not cleared: %+v", c)
|
||||
}
|
||||
}},
|
||||
// A condition the gate raised before it restarted, whose delivery stalls no more, is cleared at the first look.
|
||||
{"a condition kept from before a restart is cleared", func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
told := hearConditions(w)
|
||||
told.kept[conditionKeyOf("novox/app@0123456789ab")] = "urgent||"
|
||||
w.settleAll()
|
||||
if c := told.last("novox/app@0123456789ab"); c != nil {
|
||||
t.Fatalf("cleared before the forge's open pull requests were read: %+v", c)
|
||||
}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
if c := told.last("novox/app@0123456789ab"); c == nil || c.State != occ.StateCleared || len(told.kept) != 0 {
|
||||
t.Fatalf("not cleared: %+v, kept %v", c, told.kept)
|
||||
}
|
||||
}},
|
||||
// Review round 1, blocker 1: a pull request closed or merged on the forge with no event heard (de-spiegel #4,
|
||||
// closed 2026-10-17, ready here eight days later) settles at the next complete read of the open list, and no
|
||||
// condition is raised for it; a merge learnt so does not move source.main.
|
||||
{"a pull request gone from the open list settles and raises nothing", func(t *testing.T) {
|
||||
for _, merged := range []bool{false, true} {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
const repo, head = "novox/de-spiegel", "fdcbaefb3123fdcbaefb3123fdcbaefb3123fdcb"
|
||||
w.h.PullUpdated(pr(repo, 4, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt(repo, 4, head, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
w.forge.refuse = errors.New("novox/de-spiegel#4 merges into main, which no protection rule requiring the checks guards")
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
id := IDOf(repo, head)
|
||||
if len(w.h.Stalled()) == 0 {
|
||||
t.Fatalf("the refused head is not stalled to begin with")
|
||||
}
|
||||
p := ForgePull{Number: 4, State: "closed", HeadSHA: head, Base: "main"}
|
||||
if merged {
|
||||
p.Merged, p.MergeCommit = true, strings.Repeat("e", 40)
|
||||
}
|
||||
w.forge.pulls[repo+"#4"] = p
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "other", Number: 1, Base: "main", HeadSHA: "abab"}}
|
||||
told := hearConditions(w)
|
||||
moves := len(w.source.moves)
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
want := Closed
|
||||
if merged {
|
||||
want = Merged
|
||||
}
|
||||
if st := w.state(id); st != want {
|
||||
t.Fatalf("merged %v: the delivery is %s, wanted %s", merged, st, want)
|
||||
}
|
||||
if c := told.last(id); c != nil {
|
||||
t.Fatalf("merged %v: a condition was said for it: %+v", merged, c)
|
||||
}
|
||||
if len(w.source.moves) != moves {
|
||||
t.Fatalf("merged %v: source.main moved for a merge learnt by its absence: %+v", merged, w.source.moves)
|
||||
}
|
||||
}
|
||||
}},
|
||||
// Review round 2: a merge learnt after the fact moves no other delivery's trunk (an approved group of the same
|
||||
// repository keeps its trunk, its state and its composed check), takes the forge's time, and a catch-up after it
|
||||
// does not move source.main.
|
||||
{"a merge learnt late moves no trunk and no source", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.merges.manual = true
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
const lateHead, lateMerge = "9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a", "8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b"
|
||||
w.h.PullUpdated(pr(gCat, 9, lateHead, "fix/old", "modules/app/x.go"))
|
||||
w.h.Checked(judged(gCat, 9, lateHead, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
late := IDOf(gCat, lateHead)
|
||||
trunkOf := func() map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, id := range []string{IDOf(gCat, gHk), IDOf(gCtl, gHc)} {
|
||||
out[id] = w.delivery(id).TrunkNow + " " + string(w.delivery(id).State)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before, moves := trunkOf(), len(w.source.moves)
|
||||
w.forge.pulls[gCat+"#9"] = ForgePull{Number: 9, State: "closed", Merged: true, MergeCommit: lateMerge,
|
||||
MergedAt: "2026-10-01T09:00:00Z", HeadSHA: lateHead, Base: "main"}
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "mesh-catalog", Number: 2, Base: "main", HeadSHA: gHk},
|
||||
{Owner: "novox", Repo: "mesh-controller", Number: 1, Base: "main", HeadSHA: gHc}}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
d := w.delivery(late)
|
||||
if d.State != Merged || !d.MergedAt.Equal(time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)) || !d.LearntLate {
|
||||
t.Fatalf("the late merge is %s at %v (learnt late %v)", d.State, d.MergedAt, d.LearntLate)
|
||||
}
|
||||
if after := trunkOf(); fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Fatalf("the group's trunk or state moved: %v, was %v", after, before)
|
||||
}
|
||||
if g := w.h.groups["feat/g"]; g.Check == nil || g.Check.OutOfDate != "" {
|
||||
t.Fatalf("the group's composed check went out of date: %+v", g.Check)
|
||||
}
|
||||
w.h.SourceCatchUp()
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != moves {
|
||||
t.Fatalf("source.main moved for a merge learnt late: %+v", w.source.moves[moves:])
|
||||
}
|
||||
}},
|
||||
// mesh-delivery#51 (2026-10-25 21:43): the open list was read seconds after the bridge merged it and before the forge's
|
||||
// merged event, so it was learnt late, moved no trunk, and its group's finish left it out of source.main. A merge
|
||||
// learnt within replayLate of its landing is taken as heard as it happened; one a gate before the fix took late is
|
||||
// written at the catch-up of the start.
|
||||
{"a merge learnt from the open list seconds after it landed moves source.main", func(t *testing.T) {
|
||||
const repo, head, merge = "novox/mesh-delivery", "4628ba26b41a4628ba26b41a4628ba26b41a4628", "db53ec975f01db53ec975f01db53ec975f01db53"
|
||||
for _, oldGate := range []bool{false, true} {
|
||||
w := newWorld(t)
|
||||
w.merges.manual = true
|
||||
w.h.PullUpdated(pr(repo, 51, head, "fix/1945", "modules/a/a.go"))
|
||||
w.h.Checked(judgedAt(repo, 51, head, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
w.source.main = &Source{V: mdc.Version, Group: "before", Heads: map[string]string{repo: "73be3df28cd4"}}
|
||||
w.forge.pulls[repo+"#51"] = ForgePull{Number: 51, State: "closed", Merged: true, MergeCommit: merge,
|
||||
MergedAt: w.h.Now().Add(-3 * time.Second).UTC().Format(time.RFC3339), HeadSHA: head, Base: "main"}
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "other", Number: 1, Base: "main", HeadSHA: "abab"}}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
if oldGate { // what the gate before the fix left
|
||||
w.h.mu.Lock()
|
||||
kept := w.h.deliveries[IDOf(repo, head)]
|
||||
kept.LearntLate, kept.InSource, kept.Ready.SourceMoved = true, true, true
|
||||
w.h.mu.Unlock()
|
||||
w.source.main.Heads[repo] = "73be3df28cd4"
|
||||
w.h.SourceCatchUp()
|
||||
w.settleAll()
|
||||
}
|
||||
d := w.delivery(IDOf(repo, head))
|
||||
if d.State != Merged || (!oldGate && d.LearntLate) || w.source.main.Heads[repo] != merge {
|
||||
t.Fatalf("old gate %v: %s, learnt late %v, source.main %v", oldGate, d.State, d.LearntLate, w.source.main.Heads)
|
||||
}
|
||||
if heads, _ := w.h.SourceMain()["heads"].(map[string]string); heads[repo] != merge {
|
||||
t.Fatalf("delivery_status's source-main says %v", w.h.SourceMain())
|
||||
}
|
||||
}
|
||||
}},
|
||||
// Review round 1, blocker 2 (1275/AC6): an out-of-date refusal at t0 with no other event is said urgent at t0+10
|
||||
// minutes: the gate asks itself for a look then.
|
||||
{"an out-of-date refusal turns urgent on the clock alone", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
told := hearConditions(w)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
t0 := w.h.Now()
|
||||
w.h.wanted.mu.Lock()
|
||||
at := w.h.wanted.at
|
||||
w.h.wanted.mu.Unlock()
|
||||
if at.IsZero() || at.Sub(t0) > outOfDateFor {
|
||||
t.Fatalf("no look is wanted by t0+%s: %v", outOfDateFor, at)
|
||||
}
|
||||
for i := 0; i < 5 && w.h.Now().Before(t0.Add(outOfDateFor)); i++ { // the timer fires a look, and nothing else happens
|
||||
w.h.wanted.mu.Lock()
|
||||
at = w.h.wanted.at
|
||||
w.h.wanted.mu.Unlock()
|
||||
if at.IsZero() {
|
||||
break
|
||||
}
|
||||
w.later(at.Sub(w.h.Now()))
|
||||
w.h.React()
|
||||
}
|
||||
c := told.last(IDOf(gCat, gHk))
|
||||
if c == nil || c.State != occ.StateRaised || c.Severity != occ.SeverityUrgent {
|
||||
t.Fatalf("at t0+%s with no event: %+v", w.h.Now().Sub(t0), c)
|
||||
}
|
||||
}},
|
||||
// Review round 1, blocker 3: a gate that restarts while a head still waits on the forge does not clear its
|
||||
// condition (the head's first sight is in memory alone, so its bound runs again), and does not say it again.
|
||||
{"a restart keeps the condition of a head still waiting", func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
told := hearConditions(w)
|
||||
const head = "bfe82315f42c8f4509d0598c32569a202d5eada6"
|
||||
id := IDOf("novox/hq", head)
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 243, Base: "main", HeadSHA: head, Required: []string{"mesh/repo-check"}}}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
w.later(UncheckedAfter + time.Minute)
|
||||
w.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != occ.StateRaised {
|
||||
t.Fatalf("the unchecked head is not raised: %+v", c)
|
||||
}
|
||||
before := len(told.told)
|
||||
again := newWorldOver(t, w.store, w.forge, w.h.Now()) // the gate restarts
|
||||
again.h.Say, again.h.Conditions = told.say, told
|
||||
again.h.wantForge()
|
||||
again.settleAll()
|
||||
again.later(time.Minute)
|
||||
again.settleAll()
|
||||
if len(told.told) != before {
|
||||
t.Fatalf("the restart said something of a head still waiting: %+v", told.told[before:])
|
||||
}
|
||||
w.forge.open = nil // it closes
|
||||
again.h.wantForge()
|
||||
again.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != occ.StateCleared {
|
||||
t.Fatalf("closed, and its condition is not cleared: %+v", c)
|
||||
}
|
||||
}},
|
||||
// The kind behind the day's fault: a decision on a group's state named a word GroupState no longer says. Every word
|
||||
// it says is one groupStatusState knows, and the merge goes on exactly in ready and merging.
|
||||
{"every word of a group's state is decided on", func(t *testing.T) {
|
||||
for _, word := range groupWords {
|
||||
if groupStatusState(word) == "pending" && word != GroupChecking {
|
||||
t.Errorf("groupStatusState does not know %q", word)
|
||||
}
|
||||
if may := groupMayMerge(word); may != (word == GroupReady || word == GroupMerging) {
|
||||
t.Errorf("groupMayMerge(%q) = %v", word, may)
|
||||
}
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, c.run)
|
||||
}
|
||||
}
|
||||
|
||||
// allGreenFor makes both merge checks a success at every member's head.
|
||||
func allGreenFor(w *world, ms ...member) {
|
||||
for _, m := range ms {
|
||||
setChecks(w, m.repo, m.head, "success", "success")
|
||||
}
|
||||
}
|
||||
|
||||
// bridgeMerged is the forge bridge's word that it merged a member.
|
||||
func bridgeMerged(w *world, repository string, number int, head, merge string) {
|
||||
w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root",
|
||||
Trees: map[string]string{"app": "tree"}})
|
||||
}
|
||||
|
||||
// moveForgeHead sets the head the forge says a pull request has, as a push the gate has not heard yet does.
|
||||
func moveForgeHead(w *world, repo string, number int, head string) {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
key := fmt.Sprintf("%s#%d", repo, number)
|
||||
p := w.forge.pulls[key]
|
||||
p.HeadSHA = head
|
||||
w.forge.pulls[key] = p
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// Issue 1275, met a third time (2026-10-26 13:40, group fix/734-lost-ask): the operator approved mesh-sdk#92,
|
||||
// mesh-controller#472 and mesh-delivery#73; the first two merged, the forge answered #73's merge 405 "Please try again
|
||||
// later", and in the seconds between, group feat/890-assignments merged mesh-delivery#72, so #73 then conflicted. A group is
|
||||
// merged only when every member merges now, and no other approved merge lands in a repository between its members.
|
||||
|
||||
const (
|
||||
wSDK, wCtl, wDel = "novox/mesh-sdk", "novox/mesh-controller", "novox/mesh-delivery"
|
||||
wH92, wH472, wH73 = "3203358c4cb5fa5bed223f7ce85577bbb70a346e", "ccc7ba334c82554d809b5b38d8eb0eb5f36be7be", "bf574172154a9f6ed7d4e790643e0dcab2a10a91"
|
||||
wH72, wH73b = "72727272727272727272727272727272727272aa", "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0"
|
||||
forgeLater = "Gitea API /repos/novox/mesh-delivery/pulls/73/merge: 405 {\"message\":\"Please try again later\"}"
|
||||
conflictWords = "does not merge into main now"
|
||||
headMovedWords = "its head is now"
|
||||
)
|
||||
|
||||
var lostAsk = []member{
|
||||
{wSDK, 92, wH92, "go/buildstep/ask.go"},
|
||||
{wCtl, 472, wH472, "internal/grants.go"},
|
||||
{wDel, 73, wH73, "modules/mesh-delivery-checker/internal/check/lostask.go,modules/mesh-delivery-checker/go.mod"},
|
||||
}
|
||||
|
||||
// forgePull says what the forge answers of a pull request: its head and whether it merges now.
|
||||
func forgePull(w *world, repository string, number int, head string, mergeable bool) {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
m := mergeable
|
||||
w.forge.pulls[fmt.Sprintf("%s#%d", repository, number)] = ForgePull{Number: number, State: "open", Base: "main", HeadSHA: head, Mergeable: &m}
|
||||
}
|
||||
|
||||
func TestAnApprovedGroupMergesNoMemberUnlessEveryMemberMergesNow(t *testing.T) {
|
||||
approve := func(t *testing.T) *world {
|
||||
w := approvedGroupOf(t, lostAsk...)
|
||||
allGreenFor(w, lostAsk...)
|
||||
for _, m := range lostAsk {
|
||||
forgePull(w, m.repo, m.number, m.head, true)
|
||||
}
|
||||
return w
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
// before is the forge as the Approve lands; after, once the grace has passed (nil: as before).
|
||||
before, after func(w *world)
|
||||
// merged is how many members merge in the end; line what the urgent line says, when one stands.
|
||||
merged int
|
||||
line string
|
||||
}{
|
||||
{name: "a member in conflict with its trunk: none merged, the group held and said urgent", merged: 0, line: conflictWords,
|
||||
before: func(w *world) {
|
||||
forgePull(w, wDel, 73, wH73, false)
|
||||
w.forge.refuseRepo = map[string]error{wDel: errors.New(forgeLater)} // what the merge tool would answer
|
||||
}},
|
||||
{name: "the forge still weighing a member: none merged, then every member once it says yes", merged: 3,
|
||||
before: func(w *world) { forgePull(w, wDel, 73, wH73, false) },
|
||||
after: func(w *world) { forgePull(w, wDel, 73, wH73, true) }},
|
||||
{name: "a member's head moved after the Approve: none merged, said urgent", merged: 0, line: headMovedWords,
|
||||
before: func(w *world) { forgePull(w, wDel, 73, wH73b, true) }},
|
||||
{name: "every member merges now: all merged in one pass", merged: 3, before: func(*world) {}},
|
||||
}
|
||||
// The recovery of a group that ended half merged: its remaining member's new head, or the operator's word.
|
||||
t.Run("a half-merged group is made whole by its remaining member's new head", halfMergedMadeWholeByANewHead)
|
||||
t.Run("a member refused part way is asked again on the operator's word", refusedPartWayAskedAgainOnTheOperatorsWord)
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := approve(t)
|
||||
c.before(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if c.after == nil && c.merged == 3 {
|
||||
if len(w.forge.merges) != 3 {
|
||||
t.Fatalf("merged %v, wanted all three", w.forge.merges)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a member was merged while another does not merge now: %v", w.forge.merges)
|
||||
}
|
||||
if c.after != nil {
|
||||
c.after(w)
|
||||
}
|
||||
w.later(forgeMergeGrace + time30s)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != c.merged {
|
||||
t.Fatalf("merged %v, wanted %d", w.forge.merges, c.merged)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if c.line == "" {
|
||||
if len(lines) != 0 {
|
||||
t.Fatalf("a group merged whole still says %+v", lines)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(lines) != 1 || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, IDOf(wDel, wH73)) || !strings.Contains(lines[0].Says, c.line) ||
|
||||
!strings.Contains(lines[0].Says, "nothing is merged") {
|
||||
t.Fatalf("the held group is not one urgent line naming the member and why: %+v", lines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handing are the pull requests the gate has handed the forge bridge and the bridge has not answered, in every merges key.
|
||||
func handing(w *world) []MergePull {
|
||||
w.merges.mu.Lock()
|
||||
defer w.merges.mu.Unlock()
|
||||
var out []MergePull
|
||||
for _, v := range w.merges.values {
|
||||
for _, p := range v.Pulls {
|
||||
if p.State == mdc.MergeMerging {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Repository < out[j].Repository })
|
||||
return out
|
||||
}
|
||||
|
||||
func handedWords(ps []MergePull) string {
|
||||
var out []string
|
||||
for _, p := range ps {
|
||||
out = append(out, fmt.Sprintf("%s#%d", p.Repository, p.Number))
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// Two approved merges into one repository: the one whose pass started has the repository until every member merged, and
|
||||
// the other is handed only then (2026-10-26: mesh-delivery#72 of feat/890-assignments merged between fix/734-lost-ask's
|
||||
// members). It waits for a merge, never for a check: once the group's pass ended #72 is handed on its Approve, its verdict
|
||||
// gone out of date with #73's merge or not (novox/hq ADR 0661 §3).
|
||||
func TestTwoApprovedMergesIntoOneRepositoryDoNotInterleave(t *testing.T) {
|
||||
w := approvedGroupOf(t, lostAsk...)
|
||||
w.merges.manual = true
|
||||
allGreenFor(w, lostAsk...)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := handedWords(handing(w)); got != "novox/mesh-sdk#92" {
|
||||
t.Fatalf("handed %q, wanted the group's first member", got)
|
||||
}
|
||||
// feat/890-assignments: a lone pull request into mesh-delivery, approved while the group's pass is under way.
|
||||
w.h.PullUpdated(pr(wDel, 72, wH72, "feat/890-assignments", "modules/mesh-delivery-checker/go.mod"))
|
||||
w.h.Checked(judged(wDel, 72, wH72, "pass", gTrunk0))
|
||||
setChecks(w, wDel, wH72, "success", "success")
|
||||
w.settleAll()
|
||||
if open := w.op.open(); len(open) != 1 {
|
||||
t.Fatalf("the lone pull request is asked once: %v", open)
|
||||
} else {
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
}
|
||||
w.settleAll()
|
||||
var order []string
|
||||
steps := []struct {
|
||||
repo string
|
||||
number int
|
||||
head string
|
||||
}{{wSDK, 92, wH92}, {wCtl, 472, wH472}, {wDel, 73, wH73}, {wDel, 72, wH72}}
|
||||
for _, next := range steps {
|
||||
handed := handing(w)
|
||||
if len(handed) != 1 || handed[0].Repository != next.repo || handed[0].Number != next.number {
|
||||
t.Fatalf("after %v: handed %q, wanted only %s#%d", order, handedWords(handed), next.repo, next.number)
|
||||
}
|
||||
merge := strings.Repeat(string(rune('a'+next.number%20)), 40)
|
||||
bridgeMerged(w, next.repo, next.number, next.head, merge)
|
||||
w.h.PullMerged(merged(next.repo, next.number, next.head, merge))
|
||||
w.settleAll()
|
||||
order = append(order, fmt.Sprintf("%s#%d", next.repo, next.number))
|
||||
}
|
||||
if left := handing(w); len(left) != 0 {
|
||||
t.Fatalf("still handed: %q", handedWords(left))
|
||||
}
|
||||
}
|
||||
|
||||
// A group that ended half merged (its last member refused after the others merged) is made whole by its remaining member's
|
||||
// new head: rebased on its trunk, checked, composed with the merged members, asked once and merged, and the urgent line
|
||||
// is cleared (issue 1275, the recovery of 2026-10-26 13:40).
|
||||
func halfMergedMadeWholeByANewHead(t *testing.T) {
|
||||
w := approvedGroupOf(t, lostAsk...)
|
||||
allGreenFor(w, lostAsk...)
|
||||
w.forge.refuseRepo = map[string]error{wDel: errors.New(forgeLater)}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 3 || len(urgentLines(w)) != 1 {
|
||||
t.Fatalf("the case: two merged and the third refused, said urgent: %v %+v", w.forge.merges, urgentLines(w))
|
||||
}
|
||||
w.forge.refuseRepo = nil
|
||||
// #73 rebased on its trunk: a new head, checked, composed with the members merged.
|
||||
w.h.PullUpdated(pr(wDel, 73, wH73b, "feat/g", "modules/mesh-delivery-checker/go.mod"))
|
||||
w.h.Checked(judged(wDel, 73, wH73b, "pass", gT1))
|
||||
setChecks(w, wDel, wH73b, "success", "success")
|
||||
w.settleAll()
|
||||
open := w.op.open()
|
||||
if len(open) != 1 {
|
||||
t.Fatalf("the remaining member's new head is asked once: %v", open)
|
||||
}
|
||||
w.op.answer(open[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if last := w.forge.merges[len(w.forge.merges)-1]; len(w.forge.merges) != 4 || !strings.Contains(last, "mesh-delivery#73@"+wH73b) {
|
||||
t.Fatalf("the new head is not merged alone: %v", w.forge.merges)
|
||||
}
|
||||
if lines := urgentLines(w); len(lines) != 0 {
|
||||
t.Fatalf("the group is whole and still said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// delivery_merge_again (issue 1275, the tools rule): a member refused after its siblings merged, with no new head and no
|
||||
// trunk move to ask it again, is asked again on the operator's word, at the head approved; a delivery neither refused nor
|
||||
// held is refused, naming why.
|
||||
func refusedPartWayAskedAgainOnTheOperatorsWord(t *testing.T) {
|
||||
w := approvedGroupOf(t, lostAsk...)
|
||||
allGreenFor(w, lostAsk...)
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), "too early"); err == nil {
|
||||
t.Fatal("a delivery not approved is asked again")
|
||||
}
|
||||
w.forge.refuseRepo = map[string]error{wDel: errors.New(forgeLater)}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.forge.refuseRepo = nil
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 3 {
|
||||
t.Fatalf("the case: the third member refused and not asked again by itself: %v", w.forge.merges)
|
||||
}
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), ""); err == nil {
|
||||
t.Fatal("asked again with no why")
|
||||
}
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), "the forge said try again later"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 4 || len(urgentLines(w)) != 0 {
|
||||
t.Fatalf("not merged on the operator's word: %v %+v", w.forge.merges, urgentLines(w))
|
||||
}
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), "again"); err == nil || !strings.Contains(err.Error(), "merged already") {
|
||||
t.Fatalf("a merged delivery is asked again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// approveSecondGroup makes a second group of these members on branch, checked, composed together and approved; the world
|
||||
// already holds group feat/g.
|
||||
func approveSecondGroup(t *testing.T, w *world, branch string, ms ...member) {
|
||||
t.Helper()
|
||||
for _, m := range ms {
|
||||
w.h.PullUpdated(pr(m.repo, m.number, m.head, branch, strings.Split(m.paths, ",")...))
|
||||
w.h.Checked(judged(m.repo, m.number, m.head, "pass", gTrunk0))
|
||||
}
|
||||
allGreenFor(w, ms...)
|
||||
w.settleAll()
|
||||
g := w.h.groups[branch]
|
||||
if g == nil || g.Check == nil {
|
||||
t.Fatalf("no composed check was asked for %s: %+v", branch, g)
|
||||
}
|
||||
c := CheckedEvent{Group: branch, ID: g.Check.Asked, Verdict: "pass", Summary: "composed"}
|
||||
for _, m := range ms {
|
||||
owner, repo, _ := strings.Cut(m.repo, "/")
|
||||
c.Members = append(c.Members, struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{Owner: owner, Repo: repo, Commit: m.head, Trunk: gTrunk0[:12]})
|
||||
}
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
for _, key := range w.op.open() {
|
||||
if strings.Contains(key, strings.ReplaceAll(branch, "/", "_")) || strings.Contains(key, branch) {
|
||||
w.op.answer(key, OptionApprove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A group held half merged keeps its repositories until it is whole or declined: another group sharing one waits, and the
|
||||
// operator's word that asks the held group again never leaves the two waiting on each other (issue 1275, review round 1:
|
||||
// a2 waited for group B, b2 waited for group A, for ever). And the operator's word is refused while another pass it would
|
||||
// wait for is under way, naming it.
|
||||
func TestAHeldHalfMergedGroupKeepsItsRepositoriesAndNoTwoPassesWaitOnEachOther(t *testing.T) {
|
||||
groupA := []member{lostAsk[0], lostAsk[2]} // mesh-sdk#92, then mesh-delivery#73
|
||||
groupB := []member{lostAsk[1], {wDel, 72, wH72, "modules/mesh-delivery-checker/go.mod"}}
|
||||
t.Run("half merged and held: the other group waits, and both merge once the held one is asked again", func(t *testing.T) {
|
||||
w := approvedGroupOf(t, groupA...)
|
||||
allGreenFor(w, groupA...)
|
||||
w.forge.refuseRepo = map[string]error{wDel: errors.New(forgeLater)}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("the case: mesh-sdk#92 merged, mesh-delivery#73 refused: %v", w.forge.merges)
|
||||
}
|
||||
approveSecondGroup(t, w, "feat/890-assignments", groupB...)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("group feat/890-assignments merged while feat/g is half merged into a repository both go into: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuseRepo = nil
|
||||
if _, err := w.h.MergeAgain(IDOf(wDel, wH73), "the forge said try again later"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
want := []string{"mesh-sdk#92", "mesh-delivery#73", "mesh-delivery#73", "mesh-controller#472", "mesh-delivery#72"}
|
||||
if len(w.forge.merges) != len(want) {
|
||||
t.Fatalf("merged %v, wanted %v: the held group first, then the other", w.forge.merges, want)
|
||||
}
|
||||
for i, m := range want {
|
||||
if !strings.Contains(w.forge.merges[i], m+"@") {
|
||||
t.Fatalf("merged %v, wanted %v in that order", w.forge.merges, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("the operator's word is refused while another pass it would wait for is under way", func(t *testing.T) {
|
||||
w := approvedGroupOf(t, groupA...)
|
||||
allGreenFor(w, groupA...)
|
||||
w.forge.refuseRepo = map[string]error{wSDK: errors.New(forgeLater)} // refused first: nothing of feat/g merged
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.merges.manual = true
|
||||
approveSecondGroup(t, w, "feat/890-assignments", groupB...)
|
||||
w.settleAll()
|
||||
if got := handedWords(handing(w)); got != "novox/mesh-controller#472" {
|
||||
t.Fatalf("the other group's pass did not start: handed %q", got)
|
||||
}
|
||||
_, err := w.h.MergeAgain(IDOf(wSDK, wH92), "asked again")
|
||||
if err == nil || !strings.Contains(err.Error(), "feat/890-assignments") {
|
||||
t.Fatalf("asked again while a pass it waits for is under way: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// **The gate asks through its bucket asks and merges through the bridge** (novox/hq ADR 0437 §6, §8, ADR 0441 §1 to §5,
|
||||
// ADR 0450 §3, issue 1348). Fixture sources: the key and the id of an ask and the layout of a merge are meshdeliveryclient's
|
||||
// (MergeAskKey, AskID, Merge); the act an ask binds is spelled as mesh-controller cmd/mesh-controller/moved.go:98-106 verifies
|
||||
// it and as the teller checks it (mesh-delivery modules/mesh-delivery-teller teller.go mergeAct, headsOf); the bridge's
|
||||
// messages are the ones mesh-delivery modules/mesh-delivery-forge-bridge internal/bridge acts.go writes (RefusedMessage) and
|
||||
// bridge.go merged writes, and the fake bridge here (bridge_test.go) writes them in that layout.
|
||||
|
||||
// AC1: a ready group writes one ask key, with today's words, and the gate calls no router.
|
||||
func TestAReadyGroupWritesOneAskKeyWithTodaysWordsAndCallsNoRouter(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("%d asks written, wanted one", w.op.count())
|
||||
}
|
||||
key := w.op.open()[0]
|
||||
kind, group, hash, err := mdc.ParseAskKey(key)
|
||||
if err != nil || kind != "merge" || group != "feat/g" {
|
||||
t.Fatalf("the key %q is not merge.<group>.<heads hash>: %q %q %v", key, kind, group, err)
|
||||
}
|
||||
ask := w.op.byKey[key]
|
||||
routed := w.op.ask(key)
|
||||
if ask.Group != "feat/g" || len(ask.Heads) != 2 {
|
||||
t.Fatalf("the ask value %+v does not name the group and both heads", ask)
|
||||
}
|
||||
if want, err := mdc.MergeAskKey("feat/g", ask.Heads); err != nil || want != key || !strings.HasSuffix(key, hash) {
|
||||
t.Fatalf("the heads %v give the key %q, the ask is under %q (%v)", ask.Heads, want, key, err)
|
||||
}
|
||||
if routed.ID != mdc.AskID(key) {
|
||||
t.Fatalf("the router's id of the ask is %q, wanted %q", routed.ID, mdc.AskID(key))
|
||||
}
|
||||
// Today's words (approval.go composeMergeAsk): the headline names the group, the details its members.
|
||||
if !strings.Contains(routed.Headline, "group feat") || !strings.Contains(routed.Details, "mesh-controller#1") ||
|
||||
!strings.Contains(routed.Details, "mesh-catalog#2") || len(routed.Options) != 2 || routed.Who != occ.Operator {
|
||||
t.Fatalf("the ask is not today's merge ask: %+v", routed)
|
||||
}
|
||||
// A group's headline says a failed check first (issue 1166); a passed or a running one keeps the plain words.
|
||||
members := []*Delivery{{Repository: "novox/mesh-controller", Number: 1}, {Repository: "novox/mesh-catalog", Number: 2}}
|
||||
for _, c := range []struct{ key, head string }{
|
||||
{checkSaidFail, "Check failed \u2014 merge into main: group feat"},
|
||||
{checkSaidPass, "Merge into main: group feat"},
|
||||
{"running", "Merge into main: group feat"},
|
||||
} {
|
||||
if h := composeMergeAsk("id", members[0], "feat/g", members, nil, checkSaid{Key: c.key, Words: "w"}, time.Time{}).Headline; !strings.HasPrefix(h, c.head) {
|
||||
t.Fatalf("a %s check gives the headline %q, wanted it to start %q", c.key, h, c.head)
|
||||
}
|
||||
}
|
||||
// The act the Approve binds is the one the teller checks and the controller verifies: this ask's router id, the option, and
|
||||
// the heads as `owner/repo@<first 12 characters>` to the whole commit.
|
||||
approve, _ := occ.OptionOf(routed, OptionApprove)
|
||||
heads := map[string]string{IDOf(gCtl, gHc): gHc, IDOf(gCat, gHk): gHk}
|
||||
want, _ := occ.ActDigest(occ.Act{"verb": "mesh-delivery.merge", "ask": mdc.AskID(key), "option": "approve",
|
||||
"heads": "novox/mesh-catalog@d2d2d2d2d2d2=" + gHk + ",novox/mesh-controller@c1c1c1c1c1c1=" + gHc})
|
||||
if approve.Binds != want {
|
||||
t.Fatalf("the Approve binds %s, wanted the act %v of %v", approve.Binds, mergeAct(mdc.AskID(key), "approve", heads), heads)
|
||||
}
|
||||
if len(w.forge.calls) != 0 || len(w.merges.puts) != 0 || len(w.forge.merges) != 0 {
|
||||
t.Fatalf("something was written beside the ask: forge %v, merges %d", w.forge.calls, len(w.merges.puts))
|
||||
}
|
||||
}
|
||||
|
||||
// answeredFirst is a group approved by the teller's answer, with the bridge saying nothing yet.
|
||||
func answeredFirst(t *testing.T) *world {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.merges.manual = true
|
||||
w.asked = w.op.open()[0]
|
||||
w.op.answer(w.asked, OptionApprove)
|
||||
w.settleAll()
|
||||
return w
|
||||
}
|
||||
|
||||
// AC2: an answer naming the heads asked now hands the first member only; the bridge's merged hands the next; its refused
|
||||
// hands none.
|
||||
func TestAnAnswerHandsTheFirstMemberOnlyAndTheBridgesMergedHandsTheNext(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
if len(w.merges.puts) != 1 {
|
||||
t.Fatalf("the gate wrote %d merges, wanted the first member handed once", len(w.merges.puts))
|
||||
}
|
||||
first := w.merges.last("feat/g")
|
||||
if len(first.Pulls) != 1 || first.Pulls[0].Repository != gCat || first.Pulls[0].State != mdc.MergeMerging || first.Pulls[0].Head.Head != gHk {
|
||||
t.Fatalf("the first member is not handed alone, merging, at its head: %+v", first.Pulls)
|
||||
}
|
||||
if first.Approved[mdc.ApprovedKey(gCat, 2)] != gHk || first.Approved[mdc.ApprovedKey(gCtl, 1)] != gHc || len(first.Approved) != 2 {
|
||||
t.Fatalf("the merge does not carry the Approve's heads of both members: %v", first.Approved)
|
||||
}
|
||||
if first.Ask != w.asked {
|
||||
t.Fatalf("the merge names no ask: %+v", first)
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.merges.puts) != 1 {
|
||||
t.Fatalf("the next member was handed before the bridge said the first merged: %d merges written", len(w.merges.puts))
|
||||
}
|
||||
w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}})
|
||||
w.settleAll()
|
||||
if len(w.merges.puts) != 2 {
|
||||
t.Fatalf("the second member was not handed after the first merged: %d merges written", len(w.merges.puts))
|
||||
}
|
||||
second := w.merges.last("feat/g")
|
||||
if len(second.Pulls) != 2 || second.Pulls[0].State != mdc.MergeMerged || second.Pulls[0].Commit != strings.Repeat("a", 40) ||
|
||||
second.Pulls[1].Repository != gCtl || second.Pulls[1].State != mdc.MergeMerging {
|
||||
t.Fatalf("the next merge is not the first merged and the second merging: %+v", second.Pulls)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2: the bridge's refused hands no member, and the group is said urgent, naming the member and the forge's words.
|
||||
func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge",
|
||||
Words: "the forge refused gitea_merge_pull_request: mesh/repo-check (missing)"})
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
if len(w.merges.puts) != 2 {
|
||||
t.Fatalf("%d merges written; wanted the first handed and then written refused, and none handed after", len(w.merges.puts))
|
||||
}
|
||||
refused := w.merges.last("feat/g")
|
||||
if len(refused.Pulls) != 1 || refused.Pulls[0].State != mdc.MergeRefused || !strings.Contains(refused.Pulls[0].Words, "mesh/repo-check (missing)") {
|
||||
t.Fatalf("the refusal is not written into the merge: %+v", refused.Pulls)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") {
|
||||
t.Fatalf("the refusal is not said as one urgent line: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0661 §4 (changing ADR 0441 §2): the next member is handed on the Approve covering its head, with no read of
|
||||
// the group's required statuses, after the first member merged.
|
||||
func TestTheNextMemberIsHandedWithNoReadOfTheGroupsRequiredStatuses(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}})
|
||||
setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing meanwhile
|
||||
w.settleAll()
|
||||
if len(w.merges.puts) != 2 || len(urgentLines(w)) != 0 {
|
||||
t.Fatalf("%d merges written, lines %+v: wanted the second member handed on its Approve", len(w.merges.puts), urgentLines(w))
|
||||
}
|
||||
}
|
||||
|
||||
// AC4: once every member is merged the gate writes source.main once, and each merge is announced with the ask, its asker the
|
||||
// teller, and the heads.
|
||||
func TestAGroupMergedWholeMovesSourceMainOnceAndAnnouncesWithTheTeller(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("merges %v", w.forge.merges)
|
||||
}
|
||||
if len(w.source.moves) != 1 {
|
||||
t.Fatalf("source.main moved %d times, wanted once", len(w.source.moves))
|
||||
}
|
||||
main := w.source.moves[0]
|
||||
commit := strings.Repeat("c", 40)
|
||||
if main.Group != "feat/g" || main.Heads[gCat] != commit || main.Heads[gCtl] != commit || len(main.Heads) != 2 {
|
||||
t.Fatalf("source.main is %+v; wanted both repositories at their merge commits, moved by the group", main)
|
||||
}
|
||||
final := w.merges.last("feat/g")
|
||||
if len(final.Pulls) != 2 || final.Pulls[0].State != mdc.MergeMerged || final.Pulls[1].State != mdc.MergeMerged {
|
||||
t.Fatalf("the merge is not written as all merged: %+v", final.Pulls)
|
||||
}
|
||||
say := mergedSaid(w)
|
||||
if len(say) != 2 {
|
||||
t.Fatalf("announced %d merges, wanted each member's once: %v", len(say), say)
|
||||
}
|
||||
for _, m := range say {
|
||||
if m["asker"] != tellerModule || !strings.HasPrefix(m["ask_id"].(string), "merge-feat_g-") || m["option"] != OptionApprove {
|
||||
t.Fatalf("the announcement does not name the ask and its asker the teller: %v", m)
|
||||
}
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != 1 {
|
||||
t.Fatalf("source.main moved again: %d", len(w.source.moves))
|
||||
}
|
||||
}
|
||||
|
||||
// 1106: the same refusal again and again with the trunk not moved is not asked on without end. Three refusals in a row with
|
||||
// the same cause end the asking by a verdict, a status or a composed check, and say it urgent; a trunk's move, or the
|
||||
// operator's Approve, starts it again.
|
||||
func TestARefusalThatRepeatsWithNoTrunkMoveIsNotAskedAgainWithoutEnd(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
d := w.h.deliveries[IDOf(gCat, gHk)]
|
||||
refuse := func(took string) {
|
||||
w.h.ForgeRefused(forgemsg.Forge{Kind: forgemsg.ForgeRefused, Repository: gCat, Number: 2, Head: gHk, Act: "merge",
|
||||
Words: "novox/mesh-catalog#2 is not merged: its verdict names no trunk commit it was judged against, and main is now f70a99dbf485. " +
|
||||
"The check of head d2d2d2d2d2d2 merged into trunk f70a99dbf485 was asked; the last check took " + took + ". Ask again once both statuses pass"})
|
||||
}
|
||||
tried := w.now
|
||||
for i, took := range []string{"4m1s", "4m9s"} {
|
||||
refuse(took)
|
||||
d.Ready.LastTry = tried
|
||||
w.h.mergeAgainNow(d)
|
||||
if !d.Ready.LastTry.IsZero() {
|
||||
t.Fatalf("refusal %d: the merge was not asked again at once on a verdict, as after a first refusal", i+1)
|
||||
}
|
||||
}
|
||||
refuse("4m5s") // the third in a row, with only the check's duration changed
|
||||
if d.Ready.Repeats != 3 {
|
||||
t.Fatalf("%d refusals counted for the same cause, wanted 3", d.Ready.Repeats)
|
||||
}
|
||||
d.Ready.LastTry = tried
|
||||
w.h.mergeAgainNow(d) // the composed check or a verdict answers: nothing is asked
|
||||
if d.Ready.LastTry.IsZero() {
|
||||
t.Fatal("the merge was asked again after the same refusal three times with the trunk not moved")
|
||||
}
|
||||
if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "names no trunk commit") {
|
||||
t.Fatalf("the repeated refusal is not said urgent: %+v", lines)
|
||||
}
|
||||
// The trunk moves: the merge is asked again.
|
||||
d.TrunkNow = "a1a1a1a1a1a1a1a1"
|
||||
w.h.mergeAgainNow(d)
|
||||
if !d.Ready.LastTry.IsZero() {
|
||||
t.Fatal("a trunk's move did not let the merge be asked again")
|
||||
}
|
||||
}
|
||||
|
||||
// 1785: a verdict, a composed check or a status of the same heads that changes what the refusal waited on starts the count of
|
||||
// the same words again, so an approved merge whose cause cleared is asked again without a second Approve; a status that is no
|
||||
// success changes nothing.
|
||||
func TestANewCauseOfTheSameHeadsStartsTheCountOfARepeatedRefusalAgain(t *testing.T) {
|
||||
w := answeredFirst(t)
|
||||
d := w.h.deliveries[IDOf(gCat, gHk)]
|
||||
d.Ready.Repeats, d.Ready.RepeatTrunk = maxRepeatedRefusals, d.TrunkNow
|
||||
d.Ready.LastTry = w.now
|
||||
owner, repo, _ := strings.Cut(gCat, "/")
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: owner, Repo: repo, SHA: gHk, Context: "mesh/check", State: "pending"})
|
||||
if d.Ready.Repeats != maxRepeatedRefusals || d.Ready.LastTry.IsZero() {
|
||||
t.Fatalf("a status that is no success changed the refusal: %d repeats", d.Ready.Repeats)
|
||||
}
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: owner, Repo: repo, SHA: gHk, Context: "mesh/check", State: "success"})
|
||||
if d.Ready.Repeats != 0 || !d.Ready.LastTry.IsZero() {
|
||||
t.Fatalf("the check turned success on the same head: %d repeats, last try %v; the merge is asked again and the count starts anew",
|
||||
d.Ready.Repeats, d.Ready.LastTry)
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// **The gate writes nothing to the forge: its statuses, views, notes and comments are owed keys, and its plan is read from
|
||||
// `plans`** (novox/hq ADR 0437 §2, §8, ADR 0450 §2, §4, §7, issue 1349; the empty check answer is the mesh-delivery half of
|
||||
// issue 1311). Fixture sources: the key and the value of an owed act are meshdeliveryclient's (OwedKey, Owed); the arguments
|
||||
// of `plans` are mesh-controller cmd/mesh-controller/seatverbs.go's (repository, paths, module-dirs, head, base, removed, data)
|
||||
// and its answer the document of deliveryPlanDocument (plan, reach, mesh, gated).
|
||||
|
||||
// AC1: a transition that today sets a status, a view, a note or a comment writes the matching owed key.
|
||||
func TestATransitionWritesItsStatusViewAndNoteAsOwedKeys(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()
|
||||
page := "https://forge.invalid/novox/app/pulls/7"
|
||||
statusKey, _ := mdc.OwedKey("novox/app", 7, head, "status=mesh/delivery")
|
||||
viewKey, _ := mdc.OwedKey("novox/app", 7, head, "view")
|
||||
noteKey, _ := mdc.OwedKey("novox/app", 7, head, "note")
|
||||
for _, key := range []string{statusKey, viewKey, noteKey} {
|
||||
if _, there := w.owed.keys[key]; !there {
|
||||
t.Fatalf("the key %s was not written; the keys are %v", key, w.owed.writes)
|
||||
}
|
||||
}
|
||||
if s := w.owed.keys[statusKey]; s.Act != mdc.OwedStatus || s.Context != "mesh/delivery" || s.State != "pending" || s.Target != page || s.Text == "" {
|
||||
t.Fatalf("the owed status is %+v; wanted mesh/delivery pending, pointing at %s", s, page)
|
||||
}
|
||||
if v := w.owed.keys[viewKey]; v.Act != mdc.OwedView || !strings.Contains(v.Text, id) {
|
||||
t.Fatalf("the owed view is %+v", v)
|
||||
}
|
||||
if n := w.owed.keys[noteKey]; n.Act != mdc.OwedNote || !strings.Contains(n.Text, "proposed") {
|
||||
t.Fatalf("the owed note is %+v", n)
|
||||
}
|
||||
// What the bridge would perform is what the forge holds.
|
||||
if len(w.forge.notesOn(head)) == 0 || !strings.HasPrefix(statusOn(w, "novox/app", head), "pending") {
|
||||
t.Fatalf("the fake bridge did not perform what was owed: %v", w.forge.notesOn(head))
|
||||
}
|
||||
}
|
||||
|
||||
// AC1: a note on the merge commit, which no pull request names, is keyed with the number 0.
|
||||
func TestANoteOnTheMergeCommitIsOwedWithNumberZero(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.settleAll()
|
||||
key, _ := mdc.OwedKey("novox/app", 0, merge, "note")
|
||||
if n, there := w.owed.keys[key]; !there || n.Act != mdc.OwedNote {
|
||||
t.Fatalf("no note was owed on the merge commit under %s; the keys are %v", key, w.owed.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// AC1: two comments on one head are two acts: the sequence number is in the act.
|
||||
func TestTwoCommentsOnOneHeadAreTwoOwedKeys(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.h.mu.Lock()
|
||||
d := w.h.deliveries[id]
|
||||
w.h.sayOn(d, "the first thing said")
|
||||
w.h.sayOn(d, "the second thing said")
|
||||
w.h.mu.Unlock()
|
||||
w.settleAll()
|
||||
one, _ := mdc.OwedKey("novox/app", 7, head, "comment.1")
|
||||
two, _ := mdc.OwedKey("novox/app", 7, head, "comment.2")
|
||||
if w.owed.keys[one].Text != "the first thing said" || w.owed.keys[two].Text != "the second thing said" {
|
||||
t.Fatalf("the comments are %+v and %+v", w.owed.keys[one], w.owed.keys[two])
|
||||
}
|
||||
if len(w.forge.comments) != 2 {
|
||||
t.Fatalf("the bridge would say %d comments: %v", len(w.forge.comments), w.forge.comments)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2 (R6-C1): the gate's code calls no gitea write tool and no controller verb at all (ADR 0539 §17: the gate asks the
|
||||
// controller nothing, and holds no port to it). Tests are left out: they name the words to prove them gone.
|
||||
func TestTheGatesCodeCallsNoForgeWriteToolAndNoControllerVerb(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forbidden := []string{"delivery-plan", "gitea_commit_status\"", "gitea_delivery_view", "gitea_note_append", "gitea_add_comment",
|
||||
"gitea_merge_pull_request\"", "gitea_open_pulls_write", "seat:mesh-controller", "plan-moved"}
|
||||
fset := token.NewFileSet()
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fset, f, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.BasicLit:
|
||||
if x.Kind == token.STRING {
|
||||
for _, word := range forbidden {
|
||||
if strings.Contains(x.Value, word) {
|
||||
t.Errorf("%s holds the string %s", f, x.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 1726: a delivery made from a check's verdict that arrived before its pull request's head was heard
|
||||
// (transition "its verdict arrived before its head was heard", as mesh-controller#419 at a47476252772 was) holds no base,
|
||||
// branch, title, URLs or review record. The ready pass skipped it in silence, so no Approve was ever asked, and the
|
||||
// announcement of the same head, heard later, filled none of it.
|
||||
|
||||
// verdictFirst is the live shape: the verdict heard, the head not, and the delivery ready.
|
||||
func verdictFirst(t *testing.T) (*world, string) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
id := IDOf("novox/app", readyHead)
|
||||
w.h.Checked(judgedAt("novox/app", 419, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.State != Ready || d.Base != "" || d.Branch != "" || d.Title != "" || d.HTMLURL != "" {
|
||||
t.Fatalf("not the live shape: %s base %q branch %q title %q", d.State, d.Base, d.Branch, d.Title)
|
||||
}
|
||||
return w, id
|
||||
}
|
||||
|
||||
func TestADeliveryWithoutItsHeadSaysWhatItWaitsForAndAsksNoOne(t *testing.T) {
|
||||
w, id := verdictFirst(t)
|
||||
if n := len(w.op.open()); n != 0 {
|
||||
t.Fatalf("an ask was made for a delivery with no base: %d", n)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if d.Ready == nil || !strings.Contains(d.Ready.Waits, "announcement") {
|
||||
t.Fatalf("it does not say what it waits for: %+v", d.Ready)
|
||||
}
|
||||
shown, err := w.h.Show(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if waits, _ := shown.(map[string]any)["waits"].(string); !strings.Contains(waits, "announcement") {
|
||||
t.Fatalf("show waits %q", waits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnnouncementFillsADeliveryMadeFromItsVerdictAndTheOperatorIsAsked(t *testing.T) {
|
||||
w, id := verdictFirst(t)
|
||||
p := pr("novox/app", 419, readyHead, "fix/x", "src/a.go")
|
||||
p.Title, p.CloneURL = "Fix x", "https://forge.invalid/novox/app.git"
|
||||
w.h.PullUpdated(p)
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.Base != "main" || d.Branch != "fix/x" || d.Title != "Fix x" || d.HTMLURL == "" || d.CloneURL == "" || len(d.Paths) != 1 {
|
||||
t.Fatalf("the announcement did not fill the delivery: %+v", d)
|
||||
}
|
||||
if n := len(w.op.open()); n != 1 {
|
||||
t.Fatalf("the operator was asked %d times; wanted once", n)
|
||||
}
|
||||
if d.Ready.Waits != "" && !strings.Contains(d.Ready.Waits, "Approve") {
|
||||
t.Fatalf("still waits: %q", d.Ready.Waits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeliveryWithoutItsHeadPastItsBoundIsStalled(t *testing.T) {
|
||||
w, id := verdictFirst(t)
|
||||
if s := w.h.Stalled(); len(s) != 0 {
|
||||
t.Fatalf("stalled before its bound: %+v", s)
|
||||
}
|
||||
w.later(headHeardBound + time.Minute)
|
||||
w.settleAll()
|
||||
var found bool
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == id && strings.Contains(l.Says, "announcement") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("not stalled past its bound: %+v", w.h.Stalled())
|
||||
}
|
||||
}
|
||||
|
||||
// A head asked, then a push whose verdict is heard before its head: the announcement of the new head supersedes the old
|
||||
// one exactly as a first announcement does, so one Approve stands, for the new head.
|
||||
func TestAnAnnouncementFillingAVerdictFirstHeadSupersedesTheOlderHeadAndItsAsk(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
a, b := IDOf("novox/app", readyHead), IDOf("novox/app", pushed)
|
||||
w.h.PullUpdated(pr("novox/app", 419, readyHead, "fix/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 419, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
if n := len(w.op.open()); n != 1 {
|
||||
t.Fatalf("head A: %d asks open; wanted 1", n)
|
||||
}
|
||||
w.h.Checked(judgedAt("novox/app", 419, pushed, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
w.h.PullUpdated(pr("novox/app", 419, pushed, "fix/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if got := w.delivery(a); got.NewerHead != pushed || got.State != Closed {
|
||||
t.Fatalf("head A is %s with newer head %q; wanted closed by %s", got.State, got.NewerHead, pushed)
|
||||
}
|
||||
if got := w.delivery(b); got.Base != "main" || got.Branch != "fix/x" {
|
||||
t.Fatalf("head B not filled: %+v", got)
|
||||
}
|
||||
if n := len(w.op.open()); n != 1 {
|
||||
t.Fatalf("%d asks open; wanted one, for the new head", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A description edit is a natural trigger too: it fills a delivery made from its verdict.
|
||||
func TestAnEditOfThePullRequestFillsADeliveryMadeFromItsVerdict(t *testing.T) {
|
||||
w, id := verdictFirst(t)
|
||||
p := pr("novox/app", 419, readyHead, "fix/x")
|
||||
p.Title = "Fix x"
|
||||
w.h.PullEdited(p)
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.Base != "main" || d.Branch != "fix/x" || d.Title != "Fix x" {
|
||||
t.Fatalf("the edit did not fill the delivery: %+v", d)
|
||||
}
|
||||
if n := len(w.op.open()); n != 1 {
|
||||
t.Fatalf("the operator was asked %d times; wanted once", n)
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// Issue 1726 (2026-10-24, mesh-catalog #471 and #472), kept in release 6 where no seat holder arrives to run a catch-up: the
|
||||
// checker takes a repository's trunk head only from source.main, and the gate moves it only on a merge it hears. A merge marked
|
||||
// in the source and never written (as a gate before the fix left #471) reaches source.main when the gate starts, once; one older
|
||||
// than the head source.main holds never moves it back.
|
||||
|
||||
const (
|
||||
i1726Repo = "novox/mesh-catalog"
|
||||
i1726Head1 = "aaaa000000000000000000000000000000000001"
|
||||
i1726Merge1 = "dddd000000000000000000000000000000000002"
|
||||
i1726Merge2 = "bbbb000000000000000000000000000000000003"
|
||||
)
|
||||
|
||||
func TestStartingWritesTheNewestMergeOfEachRepositoryNotYetInSourceMain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(i1726Repo, 470, i1726Head1, "fix/a", "modules/app/a.go"))
|
||||
w.h.PullMerged(i888MergedAt(i1726Repo, 470, i1726Head1, i1726Merge1, w.now.Add(-time.Hour)))
|
||||
const head2 = "cccc000000000000000000000000000000000001"
|
||||
w.h.PullUpdated(pr(i1726Repo, 471, head2, "fix/b", "modules/app/b.go"))
|
||||
w.h.PullMerged(i888MergedAt(i1726Repo, 471, head2, i1726Merge2, w.now.Add(-time.Minute)))
|
||||
w.settleAll()
|
||||
if got := w.source.main.Heads[i1726Repo]; got != i1726Merge2 {
|
||||
t.Fatalf("the heard merges left source.main at %s", got)
|
||||
}
|
||||
// What a gate before the fix left: the merge marked in the source, source.main still at the merge before.
|
||||
w.source.main = &Source{V: mdc.Version, Group: "before", Heads: map[string]string{i1726Repo: i1726Merge1, "novox/other": "b0"}}
|
||||
w.settleAll()
|
||||
if got := w.source.main.Heads[i1726Repo]; got != i1726Merge1 {
|
||||
t.Fatalf("source.main moved to %s with no catch-up asked", got)
|
||||
}
|
||||
w.h.SourceCatchUp()
|
||||
w.settleAll()
|
||||
if got := w.source.main.Heads[i1726Repo]; got != i1726Merge2 || w.source.main.Heads["novox/other"] != "b0" {
|
||||
t.Fatalf("source.main is %+v after the catch-up; wanted %s at %s and the other kept", w.source.main.Heads, i1726Repo, i1726Merge2)
|
||||
}
|
||||
n := len(w.source.moves)
|
||||
w.h.SourceCatchUp()
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != n {
|
||||
t.Fatalf("a second catch-up moved source.main again: %d moves, then %d", n, len(w.source.moves))
|
||||
}
|
||||
|
||||
// Issue 1422: a repository never merged through the gate gets main's head as the forge announced its open pull request
|
||||
// with, once, beside the others; a pull request into another base, or one announced with no base head (the forge could
|
||||
// not read it), seeds nothing; a head source.main holds is never replaced.
|
||||
const never, trunkN, trunkM = "novox/never-merged", "eeee000000000000000000000000000000000001", "eeee000000000000000000000000000000000002"
|
||||
s := newWorld(t)
|
||||
s.source.main = &Source{V: mdc.Version, Group: "before", Heads: map[string]string{i1726Repo: i1726Merge1}}
|
||||
other := pr("novox/other-base", 6, "f1f1000000000000000000000000000000000001", "fix/c", "a.go")
|
||||
other.Base, other.BaseSHA = "nox-mesh", trunkM
|
||||
s.h.PullUpdated(other)
|
||||
unread := pr("novox/unread", 2, "f2f2000000000000000000000000000000000002", "fix/d", "a.go")
|
||||
s.h.PullUpdated(unread)
|
||||
seed := pr(never, 4, "f3f3000000000000000000000000000000000003", "fix/e", "a.go")
|
||||
seed.BaseSHA = trunkN
|
||||
s.h.PullUpdated(seed)
|
||||
s.settleAll()
|
||||
if h := s.source.main.Heads; h[never] != trunkN || h[i1726Repo] != i1726Merge1 || h["novox/other-base"] != "" || h["novox/unread"] != "" || len(s.source.moves) != 1 {
|
||||
t.Fatalf("source.main is %v after %d moves; wanted %s seeded at %s, the catalogue kept and nothing else, in one move", h, len(s.source.moves), never, trunkN)
|
||||
}
|
||||
if !strings.Contains(s.logs(), "seeded with "+trunkN[:12]) {
|
||||
t.Errorf("the seed was not said: %s", s.logs())
|
||||
}
|
||||
again := pr(never, 5, "f4f4000000000000000000000000000000000004", "fix/f", "a.go")
|
||||
again.BaseSHA = trunkM
|
||||
s.h.PullUpdated(again)
|
||||
s.h.SourceCatchUp()
|
||||
s.settleAll()
|
||||
if got := s.source.main.Heads[never]; got != trunkN || len(s.source.moves) != 1 {
|
||||
t.Fatalf("a seeded head was replaced by %s (%d moves)", got, len(s.source.moves))
|
||||
}
|
||||
|
||||
// The freshest announcement's base head is the seed, not the newest delivery's: #4, made first, is announced again after #5
|
||||
// with main's head moved on.
|
||||
const trunkF = "eeee000000000000000000000000000000000003"
|
||||
f := newWorld(t)
|
||||
first := pr(never, 4, "f5f5000000000000000000000000000000000005", "fix/g", "a.go")
|
||||
first.BaseSHA = trunkN
|
||||
f.h.PullUpdated(first)
|
||||
f.later(time.Minute)
|
||||
second := pr(never, 5, "f6f6000000000000000000000000000000000006", "fix/h", "a.go")
|
||||
second.BaseSHA = trunkM
|
||||
f.h.PullUpdated(second)
|
||||
f.later(time.Minute)
|
||||
first.BaseSHA = trunkF
|
||||
f.h.PullUpdated(first)
|
||||
f.settleAll()
|
||||
if got := f.source.main.Heads[never]; got != trunkF {
|
||||
t.Fatalf("seeded with %s; wanted %s, the base head of the latest announcement", got, trunkF)
|
||||
}
|
||||
|
||||
// A green pull request retargeted from nox-mesh to main is seeded and asked; one retargeted from main to nox-mesh has
|
||||
// its ask taken back and is not merged (review round 2).
|
||||
retarget := func(from, to string) (*world, int) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
p := pr(never, 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Base, p.BaseSHA = from, trunkN
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(judgedAt(never, 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
open := openPull(never, 7, readyHead)
|
||||
open.Base = from
|
||||
w.forge.open = []OpenPull{open}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
before := len(w.op.open())
|
||||
p.Base, open.Base = to, to
|
||||
w.forge.open = []OpenPull{open}
|
||||
w.h.PullUpdated(p)
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(2 * time.Minute)
|
||||
w.settleAll()
|
||||
return w, before
|
||||
}
|
||||
if r, before := retarget("nox-mesh", "main"); before != 0 || r.source.main == nil || r.source.main.Heads[never] != trunkN || len(r.op.open()) != 1 {
|
||||
t.Fatalf("retargeted to main: %d asks before, source.main %+v, open asks %v; wanted none before, then seeded at %s and asked", before, r.source.main, r.op.open(), trunkN)
|
||||
}
|
||||
if r, before := retarget("main", "nox-mesh"); before != 1 || len(r.op.open()) != 0 || len(r.forge.merges) != 0 || !strings.Contains(r.logs(), "retargeted") {
|
||||
t.Fatalf("retargeted to nox-mesh: %d asks before, open asks %v, merges %v; wanted one before, then none and the retarget said", before, r.op.open(), r.forge.merges)
|
||||
}
|
||||
|
||||
o := newWorld(t)
|
||||
o.source.main = &Source{V: mdc.Version, Group: "newer", Heads: map[string]string{i1726Repo: i1726Merge2}}
|
||||
o.h.PullUpdated(pr(i1726Repo, 471, head2, "fix/b", "modules/app/b.go"))
|
||||
o.h.PullMerged(i888MergedAt(i1726Repo, 471, head2, i1726Merge2, o.now.Add(-time.Minute)))
|
||||
o.h.PullMerged(i888MergedAt(i1726Repo, 470, i1726Head1, i1726Merge1, o.now.Add(-time.Hour)))
|
||||
o.settleAll()
|
||||
o.h.SourceCatchUp()
|
||||
o.settleAll()
|
||||
if got := o.source.main.Heads[i1726Repo]; got != i1726Merge2 {
|
||||
t.Fatalf("the catch-up moved source.main back to %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery kept with no merge time (adopted from a walk by an earlier owner) is dated by when the gate made it, so it does not
|
||||
// make every older merge look newer, and the catch-up at the start weighs it.
|
||||
func TestAMergeKeptWithNoMergeTimeIsDatedByWhenTheGateMadeIt(t *testing.T) {
|
||||
d := &Delivery{MergedAs: i1726Merge2, Created: time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)}
|
||||
if got := mergedWhen(d); !got.Equal(d.Created) {
|
||||
t.Fatalf("dated %s", got)
|
||||
}
|
||||
d.MergedAt = d.Created.Add(-time.Hour)
|
||||
if got := mergedWhen(d); !got.Equal(d.MergedAt) {
|
||||
t.Fatalf("dated %s with a merge time", got)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 326: a delivery whose state did not change is not written again — a pass over deliveries that did not
|
||||
// move writes nothing — and one that changed is written, and said.
|
||||
func TestADeliveryIsWrittenOnlyWhenItChanged(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.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.settleAll()
|
||||
if w.state(id) != Ready {
|
||||
t.Fatalf("it is %s", w.state(id))
|
||||
}
|
||||
|
||||
// Passes over the same deliveries, as every event asks for one: nothing changed, nothing written.
|
||||
before, said := w.store.puts, len(w.said)
|
||||
for range 5 {
|
||||
w.later(30 * time.Second)
|
||||
w.settleAll()
|
||||
}
|
||||
if w.store.puts != before {
|
||||
t.Fatalf("five passes over deliveries that did not move wrote %d delivery(ies)", w.store.puts-before)
|
||||
}
|
||||
if len(w.said) != said {
|
||||
t.Fatalf("five passes over deliveries that did not move said %d event(s)", len(w.said)-said)
|
||||
}
|
||||
|
||||
// The merge is heard: the delivery changes, is written, and is said.
|
||||
w.h.PullMerged(merged("novox/app", 7, head, merge))
|
||||
w.settleAll()
|
||||
if w.state(id) != Merged {
|
||||
t.Fatalf("a merge left it %s", w.state(id))
|
||||
}
|
||||
if w.store.puts == before {
|
||||
t.Fatal("a delivery that changed was not written")
|
||||
}
|
||||
back, err := w.store.Deliveries()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range back {
|
||||
if d.ID == id && d.State != Merged {
|
||||
t.Fatalf("the store holds it %s, the holder %s", d.State, Merged)
|
||||
}
|
||||
}
|
||||
if len(w.said) == said {
|
||||
t.Fatal("a delivery that changed was not said")
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery that could not be written is written again, even though it did not change since.
|
||||
func TestADeliveryNotWrittenIsWrittenAgainUnchanged(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.store.failing = true
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
if _, there := w.store.deliveries[id]; there {
|
||||
t.Fatal("a failing store kept it")
|
||||
}
|
||||
w.store.failing = false
|
||||
w.h.Flush()
|
||||
if _, there := w.store.deliveries[id]; !there {
|
||||
t.Fatal("a delivery that could not be written was not written again")
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A verdict holds for the trunk commit it judged** (novox/hq ADR 0323, issue 341). The check judges the head
|
||||
// merged into a named trunk commit; when another merge moves that trunk, a ready delivery goes back to proposed,
|
||||
// a rejected one stays rejected, and nothing is asked. A verdict for an old trunk is kept and shown and does not
|
||||
// count, so the table reaches a fixed point. A group's composed check goes out of date on a merge that is not one of
|
||||
// its members, and runs again for new heads instead of holding the group.
|
||||
|
||||
const (
|
||||
trunk0 = "a2c2c35000000000000000000000000000000000"
|
||||
merge1 = "d28ca95000000000000000000000000000000000"
|
||||
)
|
||||
|
||||
// judged is a verdict of a head merged into a trunk commit.
|
||||
func judged(repo string, number int, sha, gate, trunk string) CheckedEvent {
|
||||
c := verdict(repo, number, sha, gate)
|
||||
c.Trunk = shortOf(trunk, 12)
|
||||
c.ID = "check-of-" + sha + "-at-" + shortOf(trunk, 8)
|
||||
c.Took = "4m12s"
|
||||
c.Beside = map[string]string{"mesh-catalog": "0123456789ab"}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestATrunkMoveSendsAReadyDeliveryBackToProposed(t *testing.T) {
|
||||
const host = "novox/mesh-host"
|
||||
const h64, h66, h67 = "6464646464646464", "dfb1415dfb1415df", "6767676767676767"
|
||||
w := newWorld(t)
|
||||
for _, p := range []PullEvent{pr(host, 64, h64, "a"), pr(host, 66, h66, "b"), pr(host, 67, h67, "c")} {
|
||||
w.h.PullUpdated(p)
|
||||
}
|
||||
w.h.Checked(judged(host, 64, h64, "pass", trunk0))
|
||||
w.h.Checked(judged(host, 66, h66, "pass", trunk0))
|
||||
w.h.Checked(judged(host, 67, h67, "fail", trunk0))
|
||||
id66, id67 := IDOf(host, h66), IDOf(host, h67)
|
||||
if s := w.state(id66); s != Ready {
|
||||
t.Fatalf("#66 checked green against the trunk is %s", s)
|
||||
}
|
||||
|
||||
// #64 merges: the trunk moves to its merge commit.
|
||||
w.h.PullMerged(merged(host, 64, h64, merge1))
|
||||
w.settleAll()
|
||||
d := w.delivery(id66)
|
||||
if d.State != Proposed {
|
||||
t.Fatalf("#66's verdict names trunk %s, the trunk is now %s, and it is %s", trunk0[:12], merge1[:12], d.State)
|
||||
}
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if last.Event != EvTrunkMoved || !strings.Contains(last.Why, trunk0[:12]) || !strings.Contains(last.Why, merge1[:12]) {
|
||||
t.Fatalf("its transition was %s, %q: not trunk moved with both commits", last.Event, last.Why)
|
||||
}
|
||||
if d.Check == nil || d.Check.Trunk != trunk0[:12] {
|
||||
t.Fatalf("its out-of-date verdict was not kept to be shown: %+v", d.Check)
|
||||
}
|
||||
if s := w.state(id67); s != Rejected {
|
||||
t.Fatalf("a rejected delivery went to %s on the trunk's move", s)
|
||||
}
|
||||
|
||||
// Out of date and not asked: no bound, so stalled does not list it, however long it waits.
|
||||
w.later(5 * time.Hour)
|
||||
w.settleAll()
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == id66 {
|
||||
t.Fatalf("stalled lists a proposed delivery whose check is out of date and not asked: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
// A late verdict for the old trunk is kept, does not count, and moves nothing: the table's fixed point.
|
||||
before := len(w.delivery(id66).Transitions)
|
||||
late := judged(host, 66, h66, "pass", trunk0)
|
||||
late.ID = "check-late"
|
||||
w.h.Checked(late)
|
||||
w.settleAll()
|
||||
d = w.delivery(id66)
|
||||
if d.State != Proposed || len(d.Transitions) != before || d.Check == nil {
|
||||
t.Fatalf("a late verdict for an old trunk left it %s after %d transition(s), verdict %+v", d.State,
|
||||
len(d.Transitions)-before, d.Check)
|
||||
}
|
||||
if ts := settle(d, Facts{Now: w.now}); len(ts) != 0 {
|
||||
t.Fatalf("the table took %d more transition(s) on a verdict that does not count", len(ts))
|
||||
}
|
||||
|
||||
// The check asked again judges the head merged into the trunk's head: it counts.
|
||||
w.h.Checked(judged(host, 66, h66, "pass", merge1))
|
||||
w.settleAll()
|
||||
if s := w.state(id66); s != Ready {
|
||||
t.Fatalf("a verdict judged against the trunk's head left it %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected delivery judged again against another trunk — the merge tool's ask — is taken as its recheck.
|
||||
func TestARejectedDeliveryJudgedAgainstANewTrunkIsRecheckedByTheVerdict(t *testing.T) {
|
||||
const repo = "novox/mesh-lab"
|
||||
const head = "1a2b3c4d5e6f7a8b"
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(repo, 9, head, "x"))
|
||||
w.h.Checked(judged(repo, 9, head, "fail", trunk0))
|
||||
id := IDOf(repo, head)
|
||||
if s := w.state(id); s != Rejected {
|
||||
t.Fatalf("it is %s", s)
|
||||
}
|
||||
w.h.PullMerged(merged(repo, 8, "8888888888888888", merge1))
|
||||
w.h.Checked(judged(repo, 9, head, "pass", merge1))
|
||||
w.settleAll()
|
||||
d := w.delivery(id)
|
||||
if d.State != Ready || d.Check.Trunk != merge1[:12] {
|
||||
t.Fatalf("judged green against the trunk's head it is %s with %+v", d.State, d.Check)
|
||||
}
|
||||
}
|
||||
|
||||
// **A verdict names the trunk commit it judged** in `checks` and `show`, and the kept plan records it (ADR 0323 §3).
|
||||
func TestChecksAndShowNameTheHeadAndTheTrunkCommit(t *testing.T) {
|
||||
const repo = "novox/mesh-lab"
|
||||
const head = "1a2b3c4d5e6f7a8b"
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(repo, 9, head, "x"))
|
||||
w.h.Checked(judged(repo, 9, head, "pass", trunk0))
|
||||
id := IDOf(repo, head)
|
||||
|
||||
a, err := w.h.Checks(repo, 0, head)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "head " + head[:12] + " merged into trunk " + trunk0[:12]
|
||||
if a.Judged != want || a.Verdict == nil || a.Verdict.Head != head[:12] || a.Verdict.Trunk != trunk0[:12] {
|
||||
t.Fatalf("checks says %q, verdict %+v; want %q", a.Judged, a.Verdict, want)
|
||||
}
|
||||
if !strings.Contains(a.Says, want) {
|
||||
t.Fatalf("checks' words do not say what was judged: %s", a.Says)
|
||||
}
|
||||
if a.Verdict.Beside["mesh-catalog"] != "0123456789ab" || a.Verdict.Took != "4m12s" {
|
||||
t.Fatalf("checks does not name the other repositories' commits or how long it took: %+v", a.Verdict)
|
||||
}
|
||||
shown, err := w.h.Show(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := shown.(map[string]any)["delivery"].(*Delivery)
|
||||
if d.Check.Head != head[:12] || d.Check.Trunk != trunk0[:12] {
|
||||
t.Fatalf("show gives the verdict %+v", d.Check)
|
||||
}
|
||||
|
||||
w.h.PullMerged(merged(repo, 8, "8888888888888888", merge1))
|
||||
if a, _ = w.h.Checks(repo, 0, head); !strings.Contains(a.OutOfDate, merge1[:12]) {
|
||||
t.Fatalf("checks does not say the verdict is out of date: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A head whose merge check the forge's holder put out of date is not a check asked and not answered: `stalled` does
|
||||
// not list it, however long it waits — rejected, or with no delivery here — until its check is asked (ADR 0323 §7).
|
||||
func TestAnOutOfDateCheckOnTheForgeIsNotSaidUnanswered(t *testing.T) {
|
||||
const repo = "novox/mesh-lab"
|
||||
const head = "1a2b3c4d5e6f7a8b9c0d"
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(repo, 9, head, "x"))
|
||||
w.h.Checked(judged(repo, 9, head, "fail", trunk0))
|
||||
words := outOfDateWords(trunk0, merge1)
|
||||
pending := func(description, at string) map[string]HeadCheck {
|
||||
return map[string]HeadCheck{ctxGate: {State: "pending", Description: description, UpdatedAt: at},
|
||||
ctxRepo: {State: "pending", Description: description, UpdatedAt: at}}
|
||||
}
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "mesh-lab", Number: 9, Base: "main", HeadSHA: head,
|
||||
Required: []string{ctxGate, ctxRepo}, Statuses: []string{ctxGate, ctxRepo}, Checks: pending(words, "2026-10-06T12:01:00Z")}}
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(3 * time.Hour)
|
||||
w.h.SeeOpenPulls()
|
||||
for _, l := range w.h.Stalled() {
|
||||
if strings.HasPrefix(l.ID, repo) {
|
||||
t.Fatalf("an out-of-date check nobody asked is said stalled: %+v", l)
|
||||
}
|
||||
}
|
||||
// Asked by the merge tool: pending anew, and its bound runs from then.
|
||||
w.forge.open[0].Checks = pending("waiting in the build queue since 15:01: asked by the merge tool", "2026-10-06T15:01:00Z")
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(2 * time.Hour)
|
||||
w.h.SeeOpenPulls()
|
||||
said := false
|
||||
for _, l := range w.h.Stalled() {
|
||||
said = said || l.State == Unanswered
|
||||
}
|
||||
if !said {
|
||||
t.Fatal("a check the merge tool asked and that never answered is not said")
|
||||
}
|
||||
}
|
||||
|
||||
// The merge tool's ask announces an out-of-date head again: its one-hour bound runs from then, and a check that never
|
||||
// answers is said stalled (ADR 0323 §7).
|
||||
func TestAnOutOfDateDeliveryAskedAgainIsBoundFromTheAsk(t *testing.T) {
|
||||
const repo = "novox/mesh-lab"
|
||||
const head = "1a2b3c4d5e6f7a8b"
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(repo, 9, head, "x"))
|
||||
w.h.Checked(judged(repo, 9, head, "pass", trunk0))
|
||||
w.h.PullMerged(merged(repo, 8, "8888888888888888", merge1))
|
||||
id := IDOf(repo, head)
|
||||
w.later(3 * time.Hour)
|
||||
w.h.PullUpdated(pr(repo, 9, head, "x")) // the merge tool asked its check
|
||||
w.later(30 * time.Minute)
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == id {
|
||||
t.Fatalf("listed half an hour after its check was asked again: %+v", l)
|
||||
}
|
||||
}
|
||||
w.later(time.Hour)
|
||||
said := false
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == id {
|
||||
said = true
|
||||
if !strings.Contains(l.Says, "asked again") || !strings.Contains(l.H2, "operator") {
|
||||
t.Fatalf("its stalled line says %+v", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !said {
|
||||
t.Fatal("a check asked again that never answered is not said")
|
||||
}
|
||||
// Its verdict judged against the trunk's head: ready, and nothing bounds it as proposed.
|
||||
w.h.Checked(judged(repo, 9, head, "pass", merge1))
|
||||
if d := w.delivery(id); d.State != Ready || d.AskedAgain != nil {
|
||||
t.Fatalf("judged again it is %s, asked again %v", d.State, d.AskedAgain)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 403, ADR 0313: the issue tracker measures a fix's time from the move to fixing to delivered and
|
||||
// splits it at the pull request's opening and its merge. A transition says what it reads them by: when the pull
|
||||
// request was first heard (its first head, not a later one), when it merged, and the class of what it moves —
|
||||
// core, leaf, or none for a change that moves no module. `checks` says the same for a fix the tracker hears of late.
|
||||
|
||||
// transitionsTo are the transition events said for a delivery that moved to a state.
|
||||
func transitionsTo(w *world, id string, to State) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, e := range w.said {
|
||||
if e["event-kind"] == "transition" && e["id"] == id && e["to"] == string(to) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestATransitionSaysWhenItsPullRequestOpenedAndMerged(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
repo := "novox/mesh-catalog"
|
||||
opened := w.now
|
||||
first := pr(repo, 9, "head9first", "fix/403-x", "modules/app/a.go")
|
||||
w.h.PullUpdated(first)
|
||||
w.later(3 * time.Minute)
|
||||
second := pr(repo, 9, "head9second", "fix/403-x", "modules/app/a.go")
|
||||
w.h.PullUpdated(second)
|
||||
w.h.Checked(verdictMoving(repo, 9, "head9second", "app"))
|
||||
w.later(2 * time.Minute)
|
||||
mergedWhen := w.now
|
||||
w.h.PullMerged(mergedAt(second, "merge9aaaaaa", mergedWhen))
|
||||
w.settleAll()
|
||||
|
||||
id := IDOf(repo, "head9second")
|
||||
if s := w.state(id); s != Merged {
|
||||
t.Fatalf("the merge is %s, not merged", s)
|
||||
}
|
||||
said := transitionsTo(w, id, Merged)
|
||||
if len(said) == 0 {
|
||||
t.Fatalf("no transition to merged was said: %v", w.said)
|
||||
}
|
||||
e := said[len(said)-1]
|
||||
if e["opened"] != opened.UTC().Format(time.RFC3339) {
|
||||
t.Errorf("opened %v, want the first head's %s (not the second head's)", e["opened"], opened.Format(time.RFC3339))
|
||||
}
|
||||
if e["merged_at"] != mergedWhen.UTC().Format(time.RFC3339) {
|
||||
t.Errorf("merged_at %v, want %s", e["merged_at"], mergedWhen.Format(time.RFC3339))
|
||||
}
|
||||
if _, has := e["class"]; has {
|
||||
t.Errorf("the transition names a class %v: the gate plans nothing", e["class"])
|
||||
}
|
||||
if _, has := e["plan"]; has {
|
||||
t.Errorf("the transition names a plan %v", e["plan"])
|
||||
}
|
||||
|
||||
a, err := w.h.Checks(repo, 9, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Delivery == nil || a.Delivery.Opened != opened.UTC().Format(time.RFC3339) ||
|
||||
a.Delivery.MergedAt != mergedWhen.UTC().Format(time.RFC3339) || a.Delivery.MergedAs != "merge9aaaaaa" {
|
||||
t.Errorf("checks says %+v", a.Delivery)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// triedSaid is delivery_status's count of owed effects tried (novox/hq issue 407), read as a test reads it.
|
||||
func triedSaid(t *testing.T, w *world) map[string]any {
|
||||
t.Helper()
|
||||
got, ok := w.h.Status("listening")["owed-tried"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("delivery_status says no owed-tried: %v", w.h.Status("listening"))
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func countOf(t *testing.T, m map[string]any, key string) int {
|
||||
t.Helper()
|
||||
n, ok := m[key].(int)
|
||||
if !ok {
|
||||
t.Fatalf("owed-tried has no count %q: %v", key, m)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// novox/hq issue 407: delivery_status says how many owed effects were tried since this owner started, how
|
||||
// many were done, failed to be tried again, or were given up, and when the last was tried — so a window's
|
||||
// writes can be checked against the tries made in it (issue 326's rule) by reading it at both ends.
|
||||
func TestDeliveryStatusCountsTheOwedEffectsTried(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
start := w.now
|
||||
tried := triedSaid(t, w)
|
||||
if countOf(t, tried, "tried") != 0 || countOf(t, tried, "done") != 0 || countOf(t, tried, "failed") != 0 ||
|
||||
countOf(t, tried, "given-up") != 0 {
|
||||
t.Fatalf("a holder that tried nothing says %v", tried)
|
||||
}
|
||||
if tried["since"] != start.Format(time.RFC3339) {
|
||||
t.Fatalf("its count is since %v, the holder started %s", tried["since"], start.Format(time.RFC3339))
|
||||
}
|
||||
if _, there := tried["last"]; there {
|
||||
t.Fatalf("nothing tried, yet a last try is said: %v", tried)
|
||||
}
|
||||
|
||||
// A head seen: its event and its note are owed and done.
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
tried = triedSaid(t, w)
|
||||
done := countOf(t, tried, "done")
|
||||
if done < len(w.said)+len(w.forge.notesOn(head)) || done == 0 {
|
||||
t.Fatalf("said %d and noted %d, yet done counts %d", len(w.said), len(w.forge.notesOn(head)), done)
|
||||
}
|
||||
if countOf(t, tried, "tried") != done || countOf(t, tried, "failed") != 0 {
|
||||
t.Fatalf("every try succeeded, yet %v", tried)
|
||||
}
|
||||
if tried["last"] != w.now.Format(time.RFC3339) {
|
||||
t.Fatalf("last try said %v, tried at %s", tried["last"], w.now.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// The bus away: its effects fail and are counted failed; quiet passes try nothing more.
|
||||
w.owed.down = true
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(verdict("novox/app", 7, head, "pass"))
|
||||
w.h.Flush()
|
||||
tried = triedSaid(t, w)
|
||||
failed := countOf(t, tried, "failed")
|
||||
if failed == 0 {
|
||||
t.Fatalf("the bus away failed nothing: %v", tried)
|
||||
}
|
||||
if countOf(t, tried, "tried") != countOf(t, tried, "done")+failed+countOf(t, tried, "given-up") {
|
||||
t.Fatalf("tries do not add up: %v", tried)
|
||||
}
|
||||
if tried["last"] != w.now.Format(time.RFC3339) {
|
||||
t.Fatalf("last try said %v, tried at %s", tried["last"], w.now.Format(time.RFC3339))
|
||||
}
|
||||
before := countOf(t, tried, "tried")
|
||||
w.h.Flush() // before each failure's next try: nothing is tried
|
||||
if n := countOf(t, triedSaid(t, w), "tried"); n != before {
|
||||
t.Fatalf("a pass before any next try tried %d", n-before)
|
||||
}
|
||||
|
||||
// The forge back, past every next try: the failed effects are done.
|
||||
w.owed.down = false
|
||||
w.later(time.Hour)
|
||||
w.h.Flush()
|
||||
tried = triedSaid(t, w)
|
||||
if countOf(t, tried, "tried") != before+failed || countOf(t, tried, "done") <= done {
|
||||
t.Fatalf("the forge back: tried %d (was %d, %d failed), %v", countOf(t, tried, "tried"), before, failed, tried)
|
||||
}
|
||||
}
|
||||
|
||||
// The count is this run's: a restarted holder says it counts since its own start, from nothing.
|
||||
func TestOwedTriedCountsSinceTheStart(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if countOf(t, triedSaid(t, w), "tried") == 0 {
|
||||
t.Fatal("nothing counted before the restart")
|
||||
}
|
||||
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Minute))
|
||||
tried := triedSaid(t, again)
|
||||
if countOf(t, tried, "tried") != 0 || tried["since"] != again.now.Format(time.RFC3339) {
|
||||
t.Fatalf("a restarted holder says %v", tried)
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// openPullsOf reads gitea_open_pulls' answer as the forge module gives it, so the tests read the real shape: the
|
||||
// pull requests below are mesh-controller #212's and #211's heads and mesh-catalog #194's, as the forge said them on
|
||||
// 2026-10-11 (novox/hq issue 438), with only their numbers and shas as they were.
|
||||
func openPullsOf(t *testing.T, answer string) []OpenPull {
|
||||
t.Helper()
|
||||
var open struct {
|
||||
Pulls []OpenPull `json:"pulls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(answer), &open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return open.Pulls
|
||||
}
|
||||
|
||||
// A head the mesh's check started on and never answered: mesh/merge-gate pending since the controller began mapping
|
||||
// it, mesh/repo-check never set. Its base requires both.
|
||||
const startedNeverAnswered = `{"pulls": [{
|
||||
"base": "main", "head_sha": "c11222026a3b26a0cc4b277119e1ea059b82a36b",
|
||||
"html_url": "http://127.0.0.1:20000/novox/mesh-controller/pulls/212", "number": 212,
|
||||
"owner": "novox", "repo": "mesh-controller",
|
||||
"required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||
"statuses": ["mesh/merge-gate", "mesh/delivery"],
|
||||
"checks": {
|
||||
"mesh/delivery": {"state": "pending", "description": "checking", "updated_at": "2026-10-11T00:11:46Z"},
|
||||
"mesh/merge-gate": {"state": "pending", "description": "the mesh is mapping this head onto its module graph",
|
||||
"updated_at": "2026-10-11T00:11:39Z"}
|
||||
}
|
||||
}]}`
|
||||
|
||||
// **A head whose merge check started and never answered is said** (novox/hq issue 438): pending, or missing a check
|
||||
// its base requires, for as long as the delivery table gives a check asked and not answered (Bounds[Proposed]) since
|
||||
// this owner first saw it so, it is a stalled line naming each check that did not answer and since when. Before,
|
||||
// any merge-check status counted as checked, pending included, and such a head was never said.
|
||||
func TestAHeadWhoseCheckStartedAndNeverAnsweredIsSaid(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||
w.forge.open = openPullsOf(t, startedNeverAnswered)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For - time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a head still within the bound of a check asked and not answered is said: %+v", lines)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("a head whose check started and never answered is not said: %+v", lines)
|
||||
}
|
||||
l := lines[0]
|
||||
if l.ID != IDOf("novox/mesh-controller", "c11222026a3b26a0cc4b277119e1ea059b82a36b") || l.Number != 212 ||
|
||||
l.State != Unanswered || l.Bound != Bounds[Proposed].For.String() || l.For != "1h1m0s" {
|
||||
t.Fatalf("the line is not this head's, past the table's bound: %+v", l)
|
||||
}
|
||||
for _, want := range []string{"mesh/merge-gate", "pending since 2026-10-11T00:11:39Z", "mesh/repo-check", "never set",
|
||||
"never answered"} {
|
||||
if !strings.Contains(l.Says, want) {
|
||||
t.Fatalf("the line does not say %q: %s", want, l.Says)
|
||||
}
|
||||
}
|
||||
if want := []string{"mesh/merge-gate pending since 2026-10-11T00:11:39Z", "mesh/repo-check never set"}; !slices.Equal(l.Waiting, want) {
|
||||
t.Fatalf("the line's checks waited on are %q, not %q", l.Waiting, want)
|
||||
}
|
||||
raw, _ := json.Marshal(l)
|
||||
t.Logf("the line as the controller reads it: %s", raw) // mesh-controller's test of its words reads this line
|
||||
if strings.Contains(l.Says, "mesh/delivery") {
|
||||
t.Fatalf("the line names a status that is not the merge check: %s", l.Says)
|
||||
}
|
||||
|
||||
// Announced at last: a delivery of its own, with its own bound — no longer this line.
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 212, "c11222026a3b26a0cc4b277119e1ea059b82a36b", "fix/402"))
|
||||
w.h.SeeOpenPulls()
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.State == Unannounced || l.State == Unanswered {
|
||||
t.Fatalf("an announced head is still said unannounced: %+v", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A check that answers does not start the bound again**: merge-gate answers at fifty minutes, repo-check is never
|
||||
// set, and the head is said on the bound from this owner's first sight of it, not fifty minutes later.
|
||||
func TestACheckThatAnswersDoesNotStartTheBoundAgain(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||
w.forge.open = openPullsOf(t, startedNeverAnswered)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(50 * time.Minute)
|
||||
w.forge.open = openPullsOf(t, strings.Replace(startedNeverAnswered, `"state": "pending", "description": "the mesh is mapping`,
|
||||
`"state": "success", "description": "pass: the mesh is mapping`, 1))
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(11 * time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].For != "1h1m0s" || !strings.Contains(lines[0].Says, "mesh/repo-check never set") ||
|
||||
strings.Contains(lines[0].Says, "mesh/merge-gate") {
|
||||
t.Fatalf("a head whose merge-gate answered at fifty minutes is not said at sixty-one on what is left: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// A forge time with an offset is said in UTC, as the module's other lines say their times.
|
||||
func TestAPendingTimeIsSaidInUTC(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||
w.forge.open = openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", "2026-10-11T02:11:39+02:00", 1))
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "pending since 2026-10-11T00:11:39Z") {
|
||||
t.Fatalf("the pending time is not said in UTC: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// A check asked again — its pending status set anew, as a recheck or a new announcement does — starts the bound again:
|
||||
// the head is said only once that ask too has gone unanswered past it.
|
||||
func TestACheckAskedAgainStartsItsBoundAgain(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||
w.forge.open = openPullsOf(t, startedNeverAnswered)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(50 * time.Minute)
|
||||
again := openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", "2026-10-11T01:02:00Z", 1))
|
||||
w.forge.open = again
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(20 * time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a check asked again twenty minutes ago is said on the first ask's time: %+v", lines)
|
||||
}
|
||||
w.later(41 * time.Minute)
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "pending since 2026-10-11T01:02:00Z") {
|
||||
t.Fatalf("a check asked again and not answered past the bound is not said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// **A head whose required checks all answered is not said**, whatever they answered: a failure is a verdict, and the
|
||||
// pull request says it itself. Nor is one whose only pending check is one its base does not require: hq requires
|
||||
// mesh/repo-check alone.
|
||||
func TestAHeadWhoseRequiredChecksAnsweredIsNotSaid(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||
w.forge.open = openPullsOf(t, `{"pulls": [{
|
||||
"base": "main", "head_sha": "99df6b999bad77c51dfdd13470cf57b1f38e8c24", "number": 194, "owner": "novox", "repo": "mesh-catalog",
|
||||
"required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||
"statuses": ["mesh/merge-gate", "mesh/repo-check", "mesh/delivery"],
|
||||
"checks": {
|
||||
"mesh/delivery": {"state": "failure", "description": "rejected", "updated_at": "2026-10-11T00:05:37Z"},
|
||||
"mesh/merge-gate": {"state": "success", "description": "pass: builds mounts → shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)", "updated_at": "2026-10-11T00:05:28Z"},
|
||||
"mesh/repo-check": {"state": "failure", "description": "fail: its merge-check.sh failed", "updated_at": "2026-10-11T00:05:28Z"}
|
||||
}
|
||||
}, {
|
||||
"base": "main", "head_sha": "11b719fd5cdec43970e513a10a44a558042599e6", "number": 308, "owner": "novox", "repo": "hq",
|
||||
"required": ["mesh/repo-check"],
|
||||
"statuses": ["mesh/merge-gate", "mesh/repo-check", "mesh/delivery"],
|
||||
"checks": {
|
||||
"mesh/merge-gate": {"state": "pending", "description": "the mesh is mapping this head onto its module graph", "updated_at": "2026-10-11T00:01:00Z"},
|
||||
"mesh/repo-check": {"state": "success", "description": "pass: its merge-check.sh passed", "updated_at": "2026-10-11T00:02:00Z"}
|
||||
}
|
||||
}]}`)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(3 * Bounds[Proposed].For)
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a head whose required checks answered is said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// A forge module that says only which contexts a head has, not their states — the gitea module before issue 438 —
|
||||
// is read as before: a head with any merge check is not said, and nothing is guessed of what it does not say.
|
||||
func TestAForgeThatSaysNoStatesIsReadAsBefore(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.open = openPullsOf(t, `{"pulls": [{"base": "main", "head_sha": "c11222026a3b26a0cc4b277119e1ea059b82a36b",
|
||||
"number": 212, "owner": "novox", "repo": "mesh-controller", "required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||
"statuses": ["mesh/merge-gate", "mesh/delivery"]}]}`)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(3 * Bounds[Proposed].For)
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a head is said on states the forge did not give: %+v", lines)
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A delivery held in ready or rejected whose required merge check reads pending or missing on the forge
|
||||
// (novox/hq issue 441). The pull requests below are mesh-controller #215's head, ready, and mesh-lab #76's, rejected,
|
||||
// as gitea_open_pulls said them on 2026-10-11, with only the one status changed that the case is about: a status
|
||||
// write that lagged or was lost (issue 423), or a check asked again after the verdict.
|
||||
|
||||
const readyHeadSHA = "d2e9dc61590f950b74ae2e93282c354646705188"
|
||||
|
||||
// readyRepoCheckPending is #215 with mesh/repo-check pending since its delivery was ready.
|
||||
const readyRepoCheckPending = `{"pulls": [{
|
||||
"base": "main", "head_sha": "d2e9dc61590f950b74ae2e93282c354646705188",
|
||||
"html_url": "http://127.0.0.1:20000/novox/mesh-controller/pulls/215", "number": 215,
|
||||
"owner": "novox", "repo": "mesh-controller",
|
||||
"required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||
"statuses": ["mesh/merge-gate", "mesh/repo-check", "mesh/delivery"],
|
||||
"checks": {
|
||||
"mesh/delivery": {"state": "success", "description": "ready: it delivers once merged", "updated_at": "2026-10-11T00:42:06Z"},
|
||||
"mesh/merge-gate": {"state": "success", "description": "pass: builds build-agent → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)", "updated_at": "2026-10-11T00:42:01Z"},
|
||||
"mesh/repo-check": {"state": "pending", "description": "the repository's own check runs", "updated_at": "2026-10-11T00:42:01Z"}
|
||||
}
|
||||
}]}`
|
||||
|
||||
// readyRepoCheckMissing is #215 whose mesh/repo-check was never written.
|
||||
const readyRepoCheckMissing = `{"pulls": [{
|
||||
"base": "main", "head_sha": "d2e9dc61590f950b74ae2e93282c354646705188", "number": 215,
|
||||
"owner": "novox", "repo": "mesh-controller",
|
||||
"required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||
"statuses": ["mesh/merge-gate", "mesh/delivery"],
|
||||
"checks": {
|
||||
"mesh/delivery": {"state": "success", "description": "ready: it delivers once merged", "updated_at": "2026-10-11T00:42:06Z"},
|
||||
"mesh/merge-gate": {"state": "success", "description": "pass", "updated_at": "2026-10-11T00:42:01Z"}
|
||||
}
|
||||
}]}`
|
||||
|
||||
// readyAnswered is #215 as the forge said it: every required check answered.
|
||||
const readyAnswered = `{"pulls": [{
|
||||
"base": "main", "head_sha": "d2e9dc61590f950b74ae2e93282c354646705188", "number": 215,
|
||||
"owner": "novox", "repo": "mesh-controller",
|
||||
"required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||
"statuses": ["mesh/merge-gate", "mesh/repo-check", "mesh/delivery"],
|
||||
"checks": {
|
||||
"mesh/merge-gate": {"state": "success", "description": "pass", "updated_at": "2026-10-11T00:42:01Z"},
|
||||
"mesh/repo-check": {"state": "success", "description": "pass: its merge-check.sh passed", "updated_at": "2026-10-11T00:42:01Z"}
|
||||
}
|
||||
}]}`
|
||||
|
||||
// heldIn is a delivery of #215's head in ready (a passing verdict) or rejected (a failing one).
|
||||
func heldIn(t *testing.T, w *world, gate string, want State) string {
|
||||
t.Helper()
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 215, readyHeadSHA, "fix/215"))
|
||||
w.h.Checked(verdict("novox/mesh-controller", 215, readyHeadSHA, gate))
|
||||
id := IDOf("novox/mesh-controller", readyHeadSHA)
|
||||
if s := w.state(id); s != want {
|
||||
t.Fatalf("a %s verdict left it %s", gate, s)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// **A delivery in ready whose required check reads pending on the forge is said** (novox/hq issue 441) on the same
|
||||
// rule as a head with no delivery (issue 438): past the table's bound for a check asked and not answered, since this
|
||||
// owner first saw it so, as an unanswered line naming the check and since when. Before, any head with a delivery was
|
||||
// skipped, and ready has no bound of its own, so it waited for ever — unable to merge — with nothing said.
|
||||
func TestADeliveryInReadyWhoseCheckIsPendingIsSaid(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC))
|
||||
id := heldIn(t, w, "pass", Ready)
|
||||
w.forge.open = openPullsOf(t, readyRepoCheckPending)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For - time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a ready delivery still within the bound is said: %+v", lines)
|
||||
}
|
||||
w.later(2 * time.Minute)
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("a ready delivery whose required check is pending past the bound is not said: %+v", lines)
|
||||
}
|
||||
l := lines[0]
|
||||
if l.ID != id || l.Number != 215 || l.State != Unanswered || l.Bound != Bounds[Proposed].For.String() || l.For != "1h1m0s" {
|
||||
t.Fatalf("the line is not this delivery's, past the table's bound: %+v", l)
|
||||
}
|
||||
if want := []string{"mesh/repo-check pending since 2026-10-11T00:42:01Z"}; !slices.Equal(l.Waiting, want) {
|
||||
t.Fatalf("the line's checks waited on are %q, not %q", l.Waiting, want)
|
||||
}
|
||||
for _, want := range []string{"novox/mesh-controller#215", "ready", "mesh/repo-check pending since 2026-10-11T00:42:01Z", "recheck"} {
|
||||
if !strings.Contains(l.Says, want) {
|
||||
t.Fatalf("the line does not say %q: %s", want, l.Says)
|
||||
}
|
||||
}
|
||||
if strings.Contains(l.Says, "No delivery of it is held") {
|
||||
t.Fatalf("the line says no delivery is held of a head whose delivery is ready: %s", l.Says)
|
||||
}
|
||||
|
||||
// The forge's status catches up: the check answered, and the line is gone.
|
||||
w.forge.open = openPullsOf(t, readyAnswered)
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a ready delivery whose check answered is still said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// **A delivery in rejected whose required check was never set is said** too: a lost status write leaves the pull
|
||||
// request with no verdict to show, and nothing here asks again. It was announced — a delivery exists — so it is
|
||||
// unanswered, never unannounced, though its head has no merge check at all.
|
||||
func TestADeliveryInRejectedWhoseCheckIsMissingIsSaid(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC))
|
||||
id := heldIn(t, w, "fail", Rejected)
|
||||
w.forge.open = openPullsOf(t, strings.Replace(readyRepoCheckMissing,
|
||||
`"mesh/merge-gate": {"state": "success", "description": "pass", "updated_at": "2026-10-11T00:42:01Z"}`,
|
||||
`"mesh/merge-gate": {"state": "failure", "description": "fail", "updated_at": "2026-10-11T00:42:01Z"}`, 1))
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Unanswered ||
|
||||
!slices.Equal(lines[0].Waiting, []string{"mesh/repo-check never set"}) || !strings.Contains(lines[0].Says, "rejected") {
|
||||
t.Fatalf("a rejected delivery whose required check was never set is not said: %+v", lines)
|
||||
}
|
||||
|
||||
// No merge check on its head at all — both writes lost — is still a delivery's: unanswered, never unannounced.
|
||||
w2 := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC))
|
||||
heldIn(t, w2, "fail", Rejected)
|
||||
w2.forge.open = openPullsOf(t, `{"pulls": [{"base": "main", "head_sha": "`+readyHeadSHA+`", "number": 215,
|
||||
"owner": "novox", "repo": "mesh-controller", "required": ["mesh/merge-gate", "mesh/repo-check"],
|
||||
"statuses": ["mesh/delivery"], "checks": {"mesh/delivery": {"state": "failure", "description": "rejected"}}}]}`)
|
||||
w2.h.SeeOpenPulls()
|
||||
if lines := w2.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a delivery's head without a check is said at once, before the table's bound: %+v", lines)
|
||||
}
|
||||
w2.later(Bounds[Proposed].For + time.Minute)
|
||||
w2.h.SeeOpenPulls()
|
||||
lines = w2.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].State != Unanswered ||
|
||||
!slices.Equal(lines[0].Waiting, []string{"mesh/merge-gate never set", "mesh/repo-check never set"}) {
|
||||
t.Fatalf("a rejected delivery whose head has no merge check is not said as unanswered: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// **A delivery in a state with a bound of its own is not said here**: a proposed one waits on its check under
|
||||
// Bounds[Proposed] and H2's re-ask, and a second line for it would say the same thing twice.
|
||||
func TestAProposedDeliveryIsSaidByItsOwnBoundOnly(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC))
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 215, readyHeadSHA, "fix/215"))
|
||||
id := IDOf("novox/mesh-controller", readyHeadSHA)
|
||||
w.forge.open = openPullsOf(t, readyRepoCheckPending)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(3 * Bounds[Proposed].For)
|
||||
w.h.SeeOpenPulls()
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.State == Unanswered || l.State == Unannounced {
|
||||
t.Fatalf("a proposed delivery %s is said as a head without a delivery: %+v", id, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A ready delivery whose required checks answered is not said**, as the forge said every one on 2026-10-11.
|
||||
func TestAReadyDeliveryWhoseChecksAnsweredIsNotSaid(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC))
|
||||
heldIn(t, w, "pass", Ready)
|
||||
w.forge.open = openPullsOf(t, readyAnswered)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(3 * Bounds[Proposed].For)
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a ready delivery whose checks answered is said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// saidReady is a ready delivery of #215's head whose mesh/repo-check reads pending on the forge, said past the bound.
|
||||
func saidReady(t *testing.T) (*world, string) {
|
||||
t.Helper()
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC))
|
||||
id := heldIn(t, w, "pass", Ready)
|
||||
w.forge.open = openPullsOf(t, readyRepoCheckPending)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || lines[0].ID != id || lines[0].State != Unanswered {
|
||||
t.Fatalf("a ready delivery whose check is pending past the bound is not said: %+v", lines)
|
||||
}
|
||||
return w, id
|
||||
}
|
||||
|
||||
// unansweredOf are the unanswered lines among what `stalled` says.
|
||||
func unansweredOf(w *world) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.State == Unanswered {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// **A said line clears when its delivery's state changes** (novox/hq issue 441): a merge ends 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 merge ends it", func(t *testing.T) {
|
||||
w, id := saidReady(t)
|
||||
w.h.PullMerged(merged("novox/mesh-controller", 215, readyHeadSHA, merge))
|
||||
if s := w.state(id); s != Merged {
|
||||
t.Fatalf("a merge left it %s", s)
|
||||
}
|
||||
if lines := unansweredOf(w); len(lines) != 0 {
|
||||
t.Fatalf("a merged delivery is still said as unanswered: %+v", lines)
|
||||
}
|
||||
w.h.SeeOpenPulls() // the forge's open list lags the merge: the head is still on it
|
||||
if lines := unansweredOf(w); len(lines) != 0 {
|
||||
t.Fatalf("a merged delivery still on a lagging open list is said: %+v", lines)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// **A said line clears when its pull request leaves the open list** — closed, its event to this owner lost, so the
|
||||
// delivery is still ready: the forge no longer lists it, and nothing is said of it.
|
||||
func TestASaidLineClearsWhenItsPullRequestIsNoLongerOpen(t *testing.T) {
|
||||
w, id := saidReady(t)
|
||||
w.forge.open = nil
|
||||
w.h.SeeOpenPulls()
|
||||
if s := w.state(id); s != Ready {
|
||||
t.Fatalf("the delivery moved to %s with no event: the case is not the one meant", s)
|
||||
}
|
||||
if lines := unansweredOf(w); len(lines) != 0 {
|
||||
t.Fatalf("a pull request no longer open is still said: %+v", lines)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **An unanswered line gives the checks it waits on as data** (novox/hq issue 443): each check's context, its state
|
||||
// and, for a pending one, since when in RFC 3339 — so the controller says the time in the operator's own zone. A time
|
||||
// inside a finished sentence cannot be said again in another zone, and the operator read "since
|
||||
// 2026-10-11T00:11:39Z". The finished words stay beside the data, for a controller that does not read it yet.
|
||||
func TestAnUnansweredLineGivesItsChecksAsData(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
|
||||
w.forge.open = openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", "2026-10-11T02:11:39+02:00", 1))
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("the head is not said: %+v", lines)
|
||||
}
|
||||
want := []WaitingCheck{{Context: "mesh/merge-gate", State: CheckPending, Since: "2026-10-11T00:11:39Z"},
|
||||
{Context: "mesh/repo-check", State: CheckNeverSet}}
|
||||
if !slices.Equal(lines[0].Checks, want) {
|
||||
t.Fatalf("the line's checks are %+v, not %+v", lines[0].Checks, want)
|
||||
}
|
||||
if words := []string{"mesh/merge-gate pending since 2026-10-11T00:11:39Z", "mesh/repo-check never set"}; !slices.Equal(lines[0].Waiting, words) {
|
||||
t.Fatalf("the finished words a controller of before reads are gone: %q", lines[0].Waiting)
|
||||
}
|
||||
raw, _ := json.Marshal(lines[0])
|
||||
t.Logf("the line as the controller reads it: %s", raw)
|
||||
if !strings.Contains(string(raw), `"checks":[{"context":"mesh/merge-gate","state":"pending","since":"2026-10-11T00:11:39Z"},{"context":"mesh/repo-check","state":"never-set"}]`) {
|
||||
t.Fatalf("the line's checks are not in the shape the controller reads: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The data and the words never disagree: for every head the words are made from, the checks give the same contexts
|
||||
// in the same order, and a pending time the forge did not say, or said unreadably, is given as no time at all.
|
||||
func TestAnUnansweredLinesChecksAgreeWithItsWords(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, at string
|
||||
since string
|
||||
}{{"a time in UTC", "2026-10-11T00:11:39Z", "2026-10-11T00:11:39Z"},
|
||||
{"a time with an offset", "2026-10-11T02:11:39+02:00", "2026-10-11T00:11:39Z"},
|
||||
{"no time", "", ""},
|
||||
{"an unreadable time", "yesterday", ""}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", tc.at, 1))[0]
|
||||
words, _, _ := p.unanswered()
|
||||
checks := p.waitingChecks()
|
||||
if len(words) != len(checks) {
|
||||
t.Fatalf("words %q, checks %+v", words, checks)
|
||||
}
|
||||
for i, c := range checks {
|
||||
if !strings.HasPrefix(words[i], c.Context+" ") {
|
||||
t.Fatalf("word %q is not of check %+v", words[i], c)
|
||||
}
|
||||
}
|
||||
if checks[0].State != CheckPending || checks[0].Since != tc.since {
|
||||
t.Fatalf("the pending check is %+v, not since %q", checks[0], tc.since)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **An unanswered line says the state of the delivery it holds, as data** (novox/hq issue 451): for a delivery in
|
||||
// ready or rejected, whose check answered once, the controller advises a recheck rather than the build queue, and
|
||||
// it reads that state from the line, never from its words. A head with no delivery held says none.
|
||||
func TestAnUnansweredLineNamesTheStateOfItsDelivery(t *testing.T) {
|
||||
at := time.Date(2026, 10, 11, 0, 43, 0, 0, time.UTC)
|
||||
for _, c := range []struct {
|
||||
gate string
|
||||
want State
|
||||
}{{"pass", Ready}, {"fail", Rejected}} {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), at)
|
||||
id := heldIn(t, w, c.gate, c.want)
|
||||
w.forge.open = openPullsOf(t, readyRepoCheckPending)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Unanswered || lines[0].Held != c.want {
|
||||
t.Fatalf("a %s delivery's unanswered line does not say its state as data: %+v", c.want, lines)
|
||||
}
|
||||
raw, _ := json.Marshal(lines[0])
|
||||
if !strings.Contains(string(raw), `"held":"`+string(c.want)+`"`) {
|
||||
t.Fatalf("the line the controller reads carries no held state: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The same head with no delivery held here: unanswered, and no state to say.
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), at)
|
||||
w.forge.open = openPullsOf(t, readyRepoCheckPending)
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(Bounds[Proposed].For + time.Minute)
|
||||
w.h.SeeOpenPulls()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].State != Unanswered || lines[0].Held != "" {
|
||||
t.Fatalf("a head with no delivery held says a state: %+v", lines)
|
||||
}
|
||||
if raw, _ := json.Marshal(lines[0]); strings.Contains(string(raw), `"held"`) {
|
||||
t.Fatalf("a line with no delivery held carries a held field: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 783: mesh-catalog commit b5417c28 was pushed while #305 (from branch adr-359-mesh-mail-733) was
|
||||
// open, so this owner recorded its delivery as #305's. #305 then closed unmerged (its base branch had been
|
||||
// deleted), and #315, opened from the same branch and head, merged it as 0914a33f. The delivery stayed closed
|
||||
// ("the pull request closed unmerged"), though it recorded merged_as 0914a33f: a rule did not hold — a delivery
|
||||
// keyed by commit follows the pull request that merges that commit, and a closed duplicate does not end it — and
|
||||
// the walk that carried the merge waited 64 minutes for this owner's word until a person's `plans go` released it.
|
||||
//
|
||||
// A delivery closed only because its pull request closed unmerged, with no merge recorded, does not end its
|
||||
// commit's delivery for another pull request with the same head: it is started over, under the same id, for the
|
||||
// pull request that announces or merges it next.
|
||||
|
||||
const (
|
||||
head783, merge783 = "d305d305d305d305", "d315d315d315d315"
|
||||
head783b, merge783b = "d306d306d306d306", "d316d316d316d316"
|
||||
head783c = "d307d307d307d307"
|
||||
head783d, merge783d = "d308d308d308d308", "d318d318d318d318"
|
||||
catalog783 = "novox/mesh-catalog"
|
||||
branch783 = "adr-359-mesh-mail-733"
|
||||
)
|
||||
|
||||
// TestADeliveryClosedByAClosedDuplicateFollowsThePullRequestThatMergesItsCommit is the issue's own shape: #305
|
||||
// announced, closed unmerged (closed); #315 announced from the same head, checked and readied; #315 merges it,
|
||||
// and the walk that answers that merge is let go — the delivery follows #315, never stuck on #305's stop.
|
||||
func TestADeliveryClosedByAClosedDuplicateFollowsThePullRequestThatMergesItsCommit(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf(catalog783, head783)
|
||||
|
||||
w.h.PullUpdated(pr(catalog783, 305, head783, branch783, "src/a.go"))
|
||||
w.h.PullClosed(pr(catalog783, 305, head783, branch783))
|
||||
if w.state(id) != Closed {
|
||||
t.Fatalf("#305 closed unmerged left it %s; want closed", w.state(id))
|
||||
}
|
||||
|
||||
w.h.PullUpdated(pr(catalog783, 315, head783, branch783, "src/a.go"))
|
||||
if d := w.delivery(id); d.State == Closed || d.Number != 315 {
|
||||
t.Fatalf("#315, the same head, left it %s, Number %d; want started over for #315, not closed", d.State, d.Number)
|
||||
}
|
||||
if d := w.delivery(id); !strings.Contains(d.Transitions[len(d.Transitions)-1].Why, "#305 with the same head closed unmerged") {
|
||||
t.Fatalf("the start-over's transition names what it replaces: %q", d.Transitions[len(d.Transitions)-1].Why)
|
||||
}
|
||||
|
||||
w.h.Checked(verdict(catalog783, 315, head783, "pass"))
|
||||
if d := w.delivery(id); d.State != Ready {
|
||||
t.Fatalf("a passing verdict for #315 left it %s", d.State)
|
||||
}
|
||||
|
||||
w.h.PullMerged(merged(catalog783, 315, head783, merge783))
|
||||
if d := w.delivery(id); d.State != Merged || d.Number != 315 {
|
||||
t.Fatalf("#315's merge left it %s, Number %d; want merged under #315", d.State, d.Number)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAMergeWithoutAnAnnouncementStartsOverAClosedDuplicateToo is decision 4: the merging pull request is never
|
||||
// announced on its own (heard only as `pull.merged`) — the start-over happens in PullMerged itself, not only in
|
||||
// PullUpdated, so a merge heard without the open announcement is not lost either.
|
||||
func TestAMergeWithoutAnAnnouncementStartsOverAClosedDuplicateToo(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf(catalog783, head783b)
|
||||
|
||||
w.h.PullUpdated(pr(catalog783, 306, head783b, branch783+"-b", "src/b.go"))
|
||||
w.h.PullClosed(pr(catalog783, 306, head783b, branch783+"-b"))
|
||||
if w.state(id) != Closed {
|
||||
t.Fatalf("#306 closed unmerged left it %s; want closed", w.state(id))
|
||||
}
|
||||
|
||||
m := merged(catalog783, 316, head783b, merge783b)
|
||||
m.HeadChecks = map[string]string{"mesh/check": "success"}
|
||||
w.h.PullMerged(m)
|
||||
if d := w.delivery(id); d.State == Closed || 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",
|
||||
d.State, d.Number)
|
||||
}
|
||||
if d := w.delivery(id); d.State != Merged {
|
||||
t.Fatalf("#316's merge left it %s; want merged", d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAClosedDeliveryAnnouncedAgainByItsOwnPullRequestStaysClosed guards decision 3's "any other closed
|
||||
// delivery ... is untouched": the same pull request announced again after its own close is the ordinary
|
||||
// 'announced again' path, never a start-over, and stays closed.
|
||||
func TestAClosedDeliveryAnnouncedAgainByItsOwnPullRequestStaysClosed(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf(catalog783, head783c)
|
||||
|
||||
w.h.PullUpdated(pr(catalog783, 307, head783c, branch783+"-c", "src/c.go"))
|
||||
w.h.PullClosed(pr(catalog783, 307, head783c, branch783+"-c"))
|
||||
if w.state(id) != Closed {
|
||||
t.Fatalf("#307 closed unmerged left it %s; want closed", w.state(id))
|
||||
}
|
||||
|
||||
w.h.PullUpdated(pr(catalog783, 307, head783c, branch783+"-c", "src/c.go"))
|
||||
if d := w.delivery(id); d.State != Closed || d.Number != 307 {
|
||||
t.Fatalf("#307 announced again by itself left it %s, Number %d; want it to stay closed", d.State, d.Number)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAMergeHeardWithoutAnAnnouncementStartsOverAClosedDuplicateAsMadeFromTheForgesWord: a delivery started over from a
|
||||
// merge heard without its open announcement takes FromForge exactly as a first-heard merge does. History is gone with the
|
||||
// states after the merge (ADR 0539 §17): a merge heard long after it happened is merged, or held when no check passed.
|
||||
func TestAMergeHeardWithoutAnAnnouncementStartsOverAClosedDuplicateAsMadeFromTheForgesWord(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
id := IDOf(catalog783, head783d)
|
||||
|
||||
w.h.PullUpdated(pr(catalog783, 308, head783d, branch783+"-d", "src/d.go"))
|
||||
w.h.PullClosed(pr(catalog783, 308, head783d, branch783+"-d"))
|
||||
if w.state(id) != Closed {
|
||||
t.Fatalf("#308 closed unmerged left it %s; want closed", w.state(id))
|
||||
}
|
||||
|
||||
m := merged(catalog783, 318, head783d, merge783d)
|
||||
m.MergedAt = w.now.Add(-48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
m.HeadChecks = map[string]string{"mesh/check": "success"}
|
||||
w.h.PullMerged(m)
|
||||
|
||||
d := w.delivery(id)
|
||||
if d.State != Merged || d.Number != 318 {
|
||||
t.Fatalf("#318's merge, heard with no announcement of its own, left it %s, Number %d; want merged under #318", d.State, d.Number)
|
||||
}
|
||||
if !d.FromForge {
|
||||
t.Fatal("a merge heard without its own announcement is made from the forge's word: FromForge is false")
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
)
|
||||
|
||||
// Issue 888: the checker judges on the trunk source.main names (mesh-delivery modules/mesh-delivery-checker main.go), and the
|
||||
// gate moved it only for a merge it handed to the forge bridge (finish). A merge made at the forge directly moved every other
|
||||
// delivery's trunk (trunkMoved) and left source.main, so the checker kept judging on the trunk before it.
|
||||
|
||||
const (
|
||||
i888Other = "novox/other"
|
||||
i888OtherH = "0000000000000000000000000000000000000001"
|
||||
i888App = "novox/app"
|
||||
i888Head1 = "1111111111111111111111111111111111111111"
|
||||
i888Head2 = "2222222222222222222222222222222222222222"
|
||||
i888Merge1 = "3333333333333333333333333333333333333333"
|
||||
i888Merge2 = "4444444444444444444444444444444444444444"
|
||||
)
|
||||
|
||||
func i888MergedAt(repo string, number int, head, merge string, at time.Time) PullEvent {
|
||||
p := merged(repo, number, head, merge)
|
||||
p.MergedAt = at.UTC().Format(time.RFC3339)
|
||||
return p
|
||||
}
|
||||
|
||||
// 888/AC1: a merge into main the gate did not hand is heard from the forge: source.main holds it, and keeps the others.
|
||||
func TestAMergeHeardOnlyFromTheForgeMovesSourceMain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.Records = &Records{Ledger: newLedgerFake(t), Now: w.h.Now}
|
||||
w.source.main = &Source{V: mdc.Version, Group: "earlier", Heads: map[string]string{i888Other: i888OtherH}}
|
||||
w.h.PullUpdated(pr(i888App, 3, i888Head1, "fix/x", "internal/a.go"))
|
||||
w.settleAll()
|
||||
w.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, w.now.Add(-time.Minute)))
|
||||
w.settleAll()
|
||||
if w.source.main == nil || w.source.main.Heads[i888App] != i888Merge1 || w.source.main.Heads[i888Other] != i888OtherH {
|
||||
t.Fatalf("source.main is %+v; wanted %s at %s and %s kept", w.source.main, i888App, i888Merge1, i888Other)
|
||||
}
|
||||
// The seat's verb source answers each repository's trunk branch and head, and the revision, from the same key.
|
||||
if a := w.h.SourceRevision(MergeBranch); a.Error != "" || a.Revision != 1 || len(a.Repositories) != 2 ||
|
||||
a.Repositories[0] != (seat.SourceHead{Repository: i888App, Trunk: MergeBranch, Head: i888Merge1}) ||
|
||||
a.Repositories[1].Repository != i888Other || a.Repositories[1].Head != i888OtherH {
|
||||
t.Fatalf("source answers %+v", a)
|
||||
}
|
||||
// §690: given no branch it answers the branches of the keys it keeps, and no repository; a branch it keeps no key of has none.
|
||||
if a := w.h.SourceRevision(""); a.Error != "" || !reflect.DeepEqual(a.Branches, []string{MergeBranch}) || len(a.Repositories) != 0 || a.Revision != 0 {
|
||||
t.Fatalf("source with no branch answers %+v, want the branches %v alone", a, []string{MergeBranch})
|
||||
}
|
||||
if a := w.h.SourceRevision("beta"); a.Error != "" || a.Branch != "beta" || a.Revision != 0 || len(a.Repositories) != 0 {
|
||||
t.Fatalf("source of a branch with no key answers %+v", a)
|
||||
}
|
||||
n := len(w.source.moves)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, w.now.Add(-time.Minute))) // heard again
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != n || n != 1 {
|
||||
t.Fatalf("source.main moved %d then %d times; wanted once", n, len(w.source.moves))
|
||||
}
|
||||
// W11.17e: a merge with no record's merge-pull opens a forge-made record at its head, said once as a transition
|
||||
// with why forge-made.
|
||||
records, err := w.h.Records.Current()
|
||||
if err != nil || len(records) != 1 || !records[0].ForgeMade || records[0].Heads[mdc.ApprovedKey(i888App, 3)] != i888Head1 {
|
||||
t.Fatalf("the forge-made record is %+v (%v)", records, err)
|
||||
}
|
||||
var said []map[string]any
|
||||
for _, e := range w.said {
|
||||
if e["event-kind"] == "transition" && e["why"] == "forge-made" {
|
||||
said = append(said, e)
|
||||
}
|
||||
}
|
||||
if len(said) != 1 || said[0]["record"] != records[0].Instance {
|
||||
t.Fatalf("the forge-made record is said %d times: %v", len(said), said)
|
||||
}
|
||||
}
|
||||
|
||||
// 888/AC2: a group the gate merged through the bridge moves source.main once, in finish, though the forge's word of each
|
||||
// member's merge is heard too.
|
||||
func TestAGroupTheGateMergedMovesSourceMainOnceThoughTheForgeSaysItsMerges(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || len(w.source.moves) != 1 {
|
||||
t.Fatalf("merges %v, source.main moved %d times", w.forge.merges, len(w.source.moves))
|
||||
}
|
||||
commit := w.source.moves[0].Heads[gCat]
|
||||
w.h.PullMerged(i888MergedAt(gCtl, 1, gHc, w.source.moves[0].Heads[gCtl], w.now))
|
||||
w.h.PullMerged(i888MergedAt(gCat, 2, gHk, commit, w.now))
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != 1 {
|
||||
t.Fatalf("source.main moved %d times; wanted once, by the group: %+v", len(w.source.moves), w.source.moves)
|
||||
}
|
||||
}
|
||||
|
||||
// 888/AC3: an older merge heard after a newer one, or replayed as history, never moves source.main backwards.
|
||||
func TestAnOlderMergeHeardLaterNeverMovesSourceMainBack(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullMerged(i888MergedAt(i888App, 4, i888Head2, i888Merge2, w.now.Add(-time.Minute)))
|
||||
w.settleAll()
|
||||
w.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, w.now.Add(-2*time.Minute)))
|
||||
w.settleAll()
|
||||
if w.source.main == nil || w.source.main.Heads[i888App] != i888Merge2 || len(w.source.moves) != 1 {
|
||||
t.Fatalf("source.main is %+v after %d moves; wanted the newer merge %s, moved once", w.source.main, len(w.source.moves), i888Merge2)
|
||||
}
|
||||
|
||||
h := newWorld(t)
|
||||
h.source.main = &Source{V: mdc.Version, Group: "now", Heads: map[string]string{i888App: i888Merge2}}
|
||||
h.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, h.now.Add(-4*24*time.Hour)))
|
||||
h.settleAll()
|
||||
if h.source.main.Heads[i888App] != i888Merge2 || len(h.source.moves) != 0 {
|
||||
t.Fatalf("a merge replayed as history moved source.main to %+v", h.source.main)
|
||||
}
|
||||
}
|
||||
|
||||
// 888/AC4: an approved group whose members were merged at the forge before the gate handed them moves source.main once.
|
||||
func TestAnApprovedGroupMergedAtTheForgeMovesSourceMainOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
mc, mk := "5555555555555555555555555555555555555555", "6666666666666666666666666666666666666666"
|
||||
w.h.PullMerged(i888MergedAt(gCtl, 1, gHc, mc, w.now))
|
||||
w.h.PullMerged(i888MergedAt(gCat, 2, gHk, mk, w.now))
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != 1 || w.source.main.Heads[gCtl] != mc || w.source.main.Heads[gCat] != mk {
|
||||
t.Fatalf("source.main moved %d times to %+v; wanted once, both members at their merge commits", len(w.source.moves), w.source.main)
|
||||
}
|
||||
}
|
||||
|
||||
// 888/AC5 (review round 1): an older merge heard from the forge after the bridge merged a newer one, whose own pull.merged is
|
||||
// not heard yet, never moves source.main back; nor does one older than the gate keeps records for, over a head it does not know.
|
||||
func TestAnOlderForgeMergeNeverOverwritesANewerBridgeMergeOrAnUnknownHead(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != 1 {
|
||||
t.Fatalf("the group did not move source.main once: %d", len(w.source.moves))
|
||||
}
|
||||
bridged := w.source.main.Heads[gCat]
|
||||
w.h.PullUpdated(pr(gCat, 7, "7777777777777777777777777777777777777777", "fix/old", "modules/app/module.json"))
|
||||
w.h.PullMerged(i888MergedAt(gCat, 7, "7777777777777777777777777777777777777777", "8888888888888888888888888888888888888888", w.now.Add(-10*time.Minute)))
|
||||
w.settleAll()
|
||||
if w.source.main.Heads[gCat] != bridged || len(w.source.moves) != 1 {
|
||||
t.Fatalf("an older forge merge moved source.main from the bridge's %s to %s", bridged, w.source.main.Heads[gCat])
|
||||
}
|
||||
|
||||
o := newWorld(t)
|
||||
o.source.main = &Source{V: mdc.Version, Group: "pruned", Heads: map[string]string{i888App: i888Merge2}}
|
||||
at := o.now
|
||||
o.h.PullUpdated(pr(i888App, 3, i888Head1, "fix/x", "internal/a.go"))
|
||||
o.later(keptFinal + 24*time.Hour)
|
||||
o.h.PullMerged(i888MergedAt(i888App, 3, i888Head1, i888Merge1, at.Add(time.Minute)))
|
||||
o.settleAll()
|
||||
if o.source.main.Heads[i888App] != i888Merge2 || len(o.source.moves) != 0 {
|
||||
t.Fatalf("a merge older than the records kept overwrote a head the gate does not know: %+v", o.source.main)
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 902: a group that is delivering passed its composed check for its members' heads, so
|
||||
// mesh/delivery-group says success there; pending would keep its later members from ever merging.
|
||||
func TestReplay902(t *testing.T) {
|
||||
for state, want := range map[string]string{
|
||||
"ready": "success", "merging": "success", "merged": "success",
|
||||
"checking": "pending", "rejected": "failure", "held": "error", "closed": "error",
|
||||
} {
|
||||
if got := groupStatusState(state); got != want {
|
||||
t.Errorf("a group %s says %s on mesh/delivery-group, want %s", state, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// group902 is a two-member group whose first member in its order merged.
|
||||
func group902(t *testing.T) (*world, string, string, string, string) {
|
||||
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"))
|
||||
w.h.Checked(verdict("novox/mesh-controller", 1, ctlHead, "pass"))
|
||||
w.h.Checked(verdict("novox/mesh-catalog", 2, catHead, "pass"))
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged("novox/mesh-catalog", 2, catHead, "m2m2m2m2m2m2"))
|
||||
w.settleAll()
|
||||
return w, ctl, cat, ctlHead, catHead
|
||||
}
|
||||
|
||||
func TestReplay902TheNextMemberOfADeliveringGroupReadsSuccess(t *testing.T) {
|
||||
w, _, _, ctlHead, _ := group902(t)
|
||||
g := w.h.groups["feat/x"]
|
||||
st, _ := GroupState(g, w.h.membersOf(g))
|
||||
s := w.forge.statuses["novox/mesh-controller@"+ctlHead+" mesh/delivery-group"]
|
||||
t.Logf("state=%s status=%q", st, s)
|
||||
if st != "merging" || !strings.HasPrefix(s, "success") {
|
||||
t.Fatalf("want merging/success, got %s %q", st, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplay902AClosedGroupComposedAgainAndFailingSaysNoSuccess(t *testing.T) {
|
||||
w, ctl, _, _, _ := group902(t)
|
||||
const newHead = "e3e3e3e3e3e3e3e3"
|
||||
_ = ctl
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, newHead, "feat/x", "internal/a.go"))
|
||||
w.h.Checked(verdict("novox/mesh-controller", 1, newHead, "pass"))
|
||||
w.settleAll()
|
||||
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-2", Verdict: "fail", Summary: "no longer compose"})
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/x"]
|
||||
st, why := GroupState(g, w.h.membersOf(g))
|
||||
s := w.forge.statuses["novox/mesh-controller@"+newHead+" mesh/delivery-group"]
|
||||
t.Logf("closed=%v check=%+v state=%s (%s) status=%q", g.Closed, g.Check, st, why, s)
|
||||
if strings.HasPrefix(s, "success") {
|
||||
t.Fatalf("a closed group whose composed check failed for its heads says success: %q", s)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package main
|
||||
|
||||
// The issues an ask names, from the issue tracker's links (novox/hq ADR 0493 §4, ADR 0659 §4): a pull request is linked to
|
||||
// the issue it fixes by a tool call of the seat issue-tracker, never matched from its title or description, and the gate
|
||||
// reads those links through the seat's verb `list`. Words only: an ask is never held for them; a read that fails is said in
|
||||
// the ask as the tracker unread, and read again at the pull request's next head.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// TrackerListVerb is the seat issue-tracker's verb the gate reads a pull request's issues with (the manifest invokes it).
|
||||
const TrackerListVerb = "seat:issue-tracker.list"
|
||||
|
||||
// IssueLinks reads the issues linked to a pull request in the issue tracker.
|
||||
type IssueLinks interface {
|
||||
// Linked is the issues linked to the pull request number of repository (owner/name), ascending.
|
||||
Linked(repository string, number int) ([]int, error)
|
||||
}
|
||||
|
||||
// seatIssues is the issue tracker through its seat.
|
||||
type seatIssues struct{ ask asking }
|
||||
|
||||
func (s seatIssues) Linked(repository string, number int) ([]int, error) {
|
||||
// A pull request is linked as `<repository's name>#<number>`, lower-case, as the tracker keeps it.
|
||||
target := "pull-request:" + repoName(repository) + "#" + strconv.Itoa(number)
|
||||
raw, err := s.ask(TrackerListVerb, map[string]any{"linked": target, "limit": 200})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the seat issue-tracker refused list: %s", lastLine(output))
|
||||
}
|
||||
var listed struct {
|
||||
Rows []struct {
|
||||
Number int `json:"number"`
|
||||
} `json:"rows"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &listed); err != nil {
|
||||
return nil, fmt.Errorf("the seat issue-tracker's list is not readable: %s", clip(string(answer), 200))
|
||||
}
|
||||
if listed.Error != "" {
|
||||
return nil, fmt.Errorf("the seat issue-tracker could not list %s: %s", target, listed.Error)
|
||||
}
|
||||
out := make([]int, 0, len(listed.Rows))
|
||||
for _, r := range listed.Rows {
|
||||
out = append(out, r.Number)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// issuesToRead is the read of the linked issues of every member not read at its head yet, done outside the lock; nil when
|
||||
// all are read or no issue tracker is reached. Under the lock.
|
||||
func (h *Holder) issuesToRead(members []*Delivery) func() {
|
||||
if h.Links == nil {
|
||||
return nil
|
||||
}
|
||||
type read struct {
|
||||
id, repository, head, why string
|
||||
number int
|
||||
issues []int
|
||||
}
|
||||
var reads []read
|
||||
for _, m := range members {
|
||||
if m.Number != 0 && m.IssuesRead != m.Commit {
|
||||
reads = append(reads, read{id: m.ID, repository: m.Repository, head: m.Commit, number: m.Number})
|
||||
}
|
||||
}
|
||||
if len(reads) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, m := range members {
|
||||
if m.issuesReading == m.Commit && m.IssuesRead != m.Commit {
|
||||
return func() {} // read already: its end looks again
|
||||
}
|
||||
}
|
||||
for _, m := range members {
|
||||
m.issuesReading = m.Commit
|
||||
}
|
||||
return func() {
|
||||
for i := range reads {
|
||||
r := &reads[i]
|
||||
issues, err := h.Links.Linked(r.repository, r.number)
|
||||
r.issues = issues
|
||||
if err != nil {
|
||||
r.why = err.Error()
|
||||
h.Logf("[mesh-delivery] %s: the issues linked to its pull request cannot be read: %v; its ask says so", r.id, err)
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
for _, r := range reads {
|
||||
if d := h.deliveries[r.id]; d != nil && d.Commit == r.head {
|
||||
d.Issues, d.IssuesRead, d.IssuesUnread = r.issues, r.head, r.why
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.lookAt(h.Now(), "an ask whose issues were read from the issue tracker")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import "git.novox.be/novox/mesh-sdk/go/announce"
|
||||
// issue 1790): the module says it itself, from its code, before it serves, and a reader is granted what it said
|
||||
// (clause 3). A state is readable when another module's manifest reads it (keeps_test.go names the readers). keeps_test.go holds the list to the
|
||||
// manifest's `state`, and keeps_integration_test.go that the module announces it on a bus.
|
||||
var Kept = []announce.State{{Name: "asks", Readable: true}, {Name: "conditions"}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "pulls", Readable: true}, {Name: "source", Readable: true}}
|
||||
var Kept = []announce.State{{Name: "conditions"}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "source", Readable: true}}
|
||||
|
||||
// announceKept says Kept through keeps, which is stdio.Keeps.
|
||||
func announceKept(keeps func(...announce.State) error) error { return keeps(Kept...) }
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// mesh-delivery-gate: the holder of the mesh-delivery seat (novox/hq ADR 0239, to-be 47). A Go bundle the node's
|
||||
// runtime launches. It owns the delivery — one commit in one repository, from its pull request's head to
|
||||
// every machine — and the delivery group above it: their states in one compiled table, kept in its own state
|
||||
// on the bus, every transition said as an event, noted on the commit and shown on the pull request through
|
||||
// the forge's holder. It asks the Approve of a merge and merges once; it asks the controller nothing and records nothing
|
||||
// after the merge (ADR 0539 §17): whether a merge is on the machines it derives when asked from the delivery view's reach
|
||||
// (ADR 0556 clause 4), and a merge held without a passing check is settled on the operator's answer (clause 5). stdout
|
||||
// is the MCP channel; what this module says, it says on stderr.
|
||||
// runtime launches. From milestone G's delivery it keeps no merge states and asks no Approve per merge (design 47 G §172,
|
||||
// §431, §592): it keeps the delivery records, one per source revision of a watched branch the indexer takes in, and the
|
||||
// source revision per watched branch, and says what it owes the forge as events the forge bridge performs. It merges
|
||||
// nothing and asks the controller nothing. stdout is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -19,7 +15,6 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
@@ -34,12 +29,9 @@ 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, as the seat git says them (gitseat.go). The forge's status events
|
||||
// say that a check answered (events.go). The step events the delivery record is built from follow (consumer.go).
|
||||
var followed = append(append(append([]string{}, gitEvents...), forgePattern, owedEndedEvent, answeredEvent, reachedEvent, ConfigChangedEvent), stepPatterns()...)
|
||||
|
||||
// forgePattern is the forge bridge's messages on FORGE (novox/hq ADR 0437 §8): the gate follows a merge and the refusal of one.
|
||||
const forgePattern = "mesh-delivery-forge-bridge.forge.**"
|
||||
// The events this owner follows: the forge bridge's word on an act owed, and the step events the delivery records are built
|
||||
// from (consumer.go).
|
||||
var followed = append([]string{owedEndedEvent}, stepPatterns()...)
|
||||
|
||||
func main() {
|
||||
settingsFile := os.Getenv("MESH_DELIVERY_GATE_SETTINGS")
|
||||
@@ -60,16 +52,10 @@ func main() {
|
||||
}
|
||||
h := &Holder{
|
||||
Settings: settings,
|
||||
Store: kvStore{},
|
||||
Forge: seatForge{ask: stdio.Ask},
|
||||
Links: seatIssues{ask: stdio.Ask},
|
||||
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
|
||||
Logf: logf,
|
||||
Operator: kvOperator{},
|
||||
Merges: kvMerges{},
|
||||
Source: kvSource{},
|
||||
Owed: seatOwed{say: stdio.SeatPublish},
|
||||
Pulls: kvPulls{},
|
||||
// The gate says what stalled to the operator itself (issue 1275, ADR 0437 §9).
|
||||
Say: sayOnSeat,
|
||||
Conditions: kvConditions{},
|
||||
@@ -77,7 +63,6 @@ func main() {
|
||||
h.init()
|
||||
h.SaySeat = stdio.SeatPublish
|
||||
h.Records = &Records{Ledger: sdkLedger{}, Now: h.Now, Retention: h.recordRetention, Say: stdio.SeatPublish, Logf: logf}
|
||||
h.Merges = recordedMerges{MergeStore: kvMerges{}, records: h.Records, members: h.groupMembers}
|
||||
h.Old = kvOld{}
|
||||
h.Consumer = &StepConsumer{Records: h.Records, Families: stepFamilies, Now: h.Now, Logf: logf}
|
||||
l := &listening{now: "not yet: starting"}
|
||||
@@ -92,13 +77,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// run takes the events and the operator's answers. The state is read back at the start (start), and what the gate may have
|
||||
// missed meanwhile is read once then (readOnStart): the forge's open pull requests, the operator's asks — and it acts on
|
||||
// events from then on (novox/hq ADR 0406 §1, issue 975): no tick, no forge poll, no reconcile loop. Each step is retried,
|
||||
// each failure said, never given up on quietly.
|
||||
// run switches to the records, then takes the events, and acts on events from then on (novox/hq ADR 0406 §1, issue 975): no
|
||||
// tick, no forge poll, no reconcile loop. Each step is retried, each failure said, never given up on quietly.
|
||||
func run(h *Holder, l *listening) {
|
||||
<-stdio.Served() // Serve first: the state is reached through it, and an event is heard from as soon as it serves
|
||||
loadKept(h, l)
|
||||
switchToRecords(h, l)
|
||||
go h.Work()
|
||||
for _, pattern := range followed {
|
||||
@@ -113,41 +95,8 @@ func run(h *Holder, l *listening) {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
// The checker's verdicts (issue 1302, ADR 0447 §2): the Approve on mesh/check is asked only on its verdict at exactly the
|
||||
// heads an ask covers, and a group's verdict is that one; each change asks for a look.
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.State(checkerVerdicts).Watch("", func(c stdio.StateChange) error {
|
||||
if c.Op == "delete" {
|
||||
h.CheckerVerdictChanged(c.Key, nil)
|
||||
} else {
|
||||
h.CheckerVerdictChanged(c.Key, c.Value)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
logf("[mesh-delivery] not hearing the checker's verdicts yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}()
|
||||
l.set("listening")
|
||||
logf("[mesh-delivery] listening: %s", strings.Join(followed, ", "))
|
||||
readOnStart(h)
|
||||
}
|
||||
|
||||
// loadKept reads the kept deliveries and groups back, retrying until it can: no event is taken before.
|
||||
func loadKept(h *Holder, l *listening) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := h.Load()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
l.set("not yet: " + err.Error())
|
||||
logf("[mesh-delivery] the kept state cannot be read yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// switchToRecords carries the groups in flight into their delivery records (switch.go), retrying until it can: no event is
|
||||
@@ -165,18 +114,6 @@ func switchToRecords(h *Holder, l *listening) {
|
||||
}
|
||||
}
|
||||
|
||||
// readOnStart reads once what happened while the gate was away: the forge's open pull requests (heads nobody checks) are
|
||||
// read by the first look, and the asks the router closed meanwhile are read here.
|
||||
func readOnStart(h *Holder) {
|
||||
h.wantForge()
|
||||
// The delivery view's reach, said while the gate was away, is read once through its tool; from then on its events carry it.
|
||||
if err := h.ReachRead(stdio.Ask); err != nil {
|
||||
logf("[mesh-delivery] the delivery view's reach cannot be read yet, so merges read as not known until it says one (%v)", err)
|
||||
}
|
||||
h.hearAsks()
|
||||
h.SourceCatchUp() // a merge missed or marked unwritten while the gate was away reaches source.main, and so the checker (issue 1726)
|
||||
}
|
||||
|
||||
// handle takes one event, then asks for a look at every step the event makes possible (Work, React). An event that cannot be read is
|
||||
// said and acknowledged: read again it would fail again.
|
||||
func handle(h *Holder, e stdio.Envelope) error {
|
||||
@@ -192,71 +129,6 @@ func take(h *Holder, e stdio.Envelope) error {
|
||||
if e.Key == owedEndedEvent {
|
||||
return h.OwedEnded(e.Body)
|
||||
}
|
||||
if strings.HasPrefix(e.Key, "mesh-delivery-forge-bridge.forge.") {
|
||||
h.Forged(e.Key, e.Body)
|
||||
return nil
|
||||
}
|
||||
switch e.Key {
|
||||
case answeredEvent:
|
||||
h.AnswersChanged() // the teller wrote an answer: the look that follows reads the answers to the asks pending
|
||||
return nil
|
||||
case reachedEvent:
|
||||
return h.ReachHeard(e.Body)
|
||||
}
|
||||
switch e.Key {
|
||||
case gitEvent(gitseat.EventPullUpdated), gitEvent(gitseat.EventPullMerged), gitEvent(gitseat.EventPullClosed):
|
||||
p, err := gitPullEvent(e.Key, e.Body)
|
||||
if err != nil {
|
||||
h.Refuse(fmt.Errorf("%s is not readable: %v", e.Key, err))
|
||||
return nil
|
||||
}
|
||||
switch e.Key {
|
||||
case gitEvent(gitseat.EventPullUpdated):
|
||||
if h.PullUpdated(p) {
|
||||
h.wantForge() // a head not seen before: the forge shows whether anyone checks it
|
||||
}
|
||||
h.KeepPull(p)
|
||||
case gitEvent(gitseat.EventPullMerged):
|
||||
h.PullMerged(p)
|
||||
default:
|
||||
h.PullClosed(p)
|
||||
}
|
||||
case gitEvent(gitseat.EventForgePull):
|
||||
action := forgeAction(e.Body)
|
||||
if action != "edited" && action != "closed" {
|
||||
return nil // a head and a merge are the seat's pull.updated and pull.merged
|
||||
}
|
||||
p, err := gitPullEvent(e.Key, e.Body)
|
||||
if err != nil {
|
||||
h.Refuse(fmt.Errorf("%s is not readable: %v", e.Key, err))
|
||||
return nil
|
||||
}
|
||||
if action == "edited" {
|
||||
h.PullEdited(p) // an edit after a head was announced takes over a stopped record (issue 1114)
|
||||
} else if !p.Merged {
|
||||
// The forge's own close (issue 1107): a merged pull request is the merged event, never this one.
|
||||
h.PullClosed(p)
|
||||
}
|
||||
case gitEvent(gitseat.EventForgeStatus):
|
||||
s, err := gitStatusEvent(e.Body)
|
||||
if err != nil {
|
||||
h.Refuse(fmt.Errorf("a commit status is not readable: %v", err))
|
||||
return nil
|
||||
}
|
||||
h.CommitStatus(s)
|
||||
h.KeepCheckVerdict(s)
|
||||
case writableEvent:
|
||||
if err := h.WriteGranted(e.Body); err != nil {
|
||||
h.Refuse(err)
|
||||
}
|
||||
case ConfigChangedEvent:
|
||||
var c ConfigChange
|
||||
if err := json.Unmarshal(e.Body, &c); err != nil {
|
||||
h.Refuse(fmt.Errorf("a config change is not readable: %v", err))
|
||||
return nil
|
||||
}
|
||||
h.ConfigChanged(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -312,29 +184,26 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
seat := Seat + "."
|
||||
list := []stdio.Tool{
|
||||
{Name: seat + "deliveries",
|
||||
Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " +
|
||||
"(owner/repository@commit), its state, its group, what it waits for and since when, and for a merged one whether it is on " +
|
||||
"the machines (machines, derived from the delivery view's reach, novox/hq ADR 0556). Narrowed by state, " +
|
||||
"repository or group.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"state": str("one state"),
|
||||
Description: "Every delivery record the gate keeps, as it is kept (design 47 G A-24): the open ones, and those that ended in " +
|
||||
"the last thirty days (all: every one kept), each with its milestone or group, its pull requests, its entries' outcomes " +
|
||||
"per node, its acts owed the forge and why it is not ended. Narrowed by state (open or ended), repository or group.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"state": str("open or ended"),
|
||||
"repository": str("owner/repository"), "group": str("a group's id (its branch name)"),
|
||||
"all": map[string]any{"type": "string", "enum": []string{"true", "false"}}}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return map[string]any{
|
||||
"deliveries": h.Deliveries(strArg(a, "state"), strArg(a, "repository"), strArg(a, "group"), boolArg(a, "all"))}, nil
|
||||
return h.Deliveries(strArg(a, "state"), strArg(a, "repository"), strArg(a, "group"), boolArg(a, "all"))
|
||||
}},
|
||||
{Name: seat + "show",
|
||||
Description: "One delivery or group whole: every transition with when and why, and its group. " +
|
||||
"For a merged delivery, whether its merge is on the machines (machines), derived from the delivery view's reach of its " +
|
||||
"repository (novox/hq ADR 0556), and else what holds it.",
|
||||
Description: "One delivery record whole, as it is kept: by its instance, its milestone, its group's id or the id of one of " +
|
||||
"its pull requests (owner/repository@commit).",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("a delivery's id, or a group's")},
|
||||
"required": []string{"id"}},
|
||||
Run: func(a map[string]any) (any, error) { return h.Show(strArg(a, "id")) }},
|
||||
{Name: seat + "groups",
|
||||
Description: "Every delivery group: its members in merge order, its check and its state, derived from its members, " +
|
||||
"and the acts the gate said it owes the forge for it, each with its id and its recorded outcome (design 47 *What the " +
|
||||
"gate owes the forge*). With a group, that group alone, ended or not, and no groups for a group it does not know. " +
|
||||
"The group moves the source revision once (novox/hq ADR 0418); what reached the machines is the delivery view's.",
|
||||
Description: "Every delivery group, from the delivery records: its members, its state (open or ended), why, and the acts the " +
|
||||
"gate said it owes the forge for it, each with its id and its recorded outcome (design 47 *What the gate owes the " +
|
||||
"forge*). With a group, that group alone, ended or not, and no groups for a group it does not know. What reached the " +
|
||||
"machines is the delivery view's.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"all": map[string]any{"type": "string", "enum": []string{"true", "false"}, "description": "\"true\": the groups that ended too"},
|
||||
"group": str("one group's id: that group alone, ended or not")}},
|
||||
@@ -362,28 +231,13 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
return h.Records.Recovery(strArg(a, "repository"), strArg(a, "commit"), strArg(a, "why"), stdio.CallerOf(a))
|
||||
}},
|
||||
{Name: seat + "stalled",
|
||||
Description: "Every delivery held past its state's bound, with the transition the table lets healer H2 take. " +
|
||||
"Also a merge refused or not made past its bound, a group half merged, and a head with no merge check.",
|
||||
Description: "Every delivery record whose open wait is past its bound, with that wait (a record open a day after it opened), " +
|
||||
"and every act owed the forge unanswered past fifteen minutes (design 47 G A-24, §165).",
|
||||
Run: func(a map[string]any) (any, error) { return h.Stalled(), nil }},
|
||||
{Name: "delivery_merge_again",
|
||||
Description: "On the operator's word, ask again at once the merge of an approved delivery the forge refused, or of an approved " +
|
||||
"group held or half merged, at the heads the Approve named (novox/hq issue 1275): every member is read first and " +
|
||||
"nothing merges unless every member merges now. For a refusal that was the forge's \"try again later\", a conflict " +
|
||||
"cleared on the trunk, or a refusal the gate stopped asking after it repeated. A member whose head moved is asked by its " +
|
||||
"new head, never by this. Refused, naming why, for a delivery that is not an approved merge waiting after a refusal or a hold.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("the delivery's id (owner/repository@commit), any member of the group"),
|
||||
"why": str("the operator's word, kept in the log")}, "required": []string{"id", "why"}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if err := need(a, "id", "why"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.MergeAgain(strArg(a, "id"), strArg(a, "why"))
|
||||
}},
|
||||
{Name: "delivery_status",
|
||||
Description: "Whether this owner works: how many deliveries in each state, the groups, what is owed to " +
|
||||
"the forge and the bus and not yet done, how many owed effects were tried since it started (done, failed " +
|
||||
"and tried again, given up) and when the last was, whether its events arrive, the last refusals, and " +
|
||||
"source.main as the gate's bucket source holds it (each repository's trunk head the checker judges on).",
|
||||
Description: "Whether this owner works: how many delivery records are open and ended, what is owed to the forge and not yet " +
|
||||
"answered, how many owed effects were tried since it started (done, failed and tried again, given up) and when the last " +
|
||||
"was, whether its events arrive, the last refusals, and source.main as the gate's bucket source holds it.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
out := h.Status(l.get())
|
||||
out["source-main"] = h.SourceMain()
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
@@ -116,16 +115,26 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
// From G's delivery the gate hears neither the bridge's merged and pushed messages nor the teller's answers nor the view's
|
||||
// reach (design 47 G §592, §593): it consumes the indexer's take-in.taken and the bridge's owed.ended, and the git events that clear
|
||||
// an approval carried.
|
||||
if !reflect.DeepEqual(m.Consumes, followed) {
|
||||
t.Fatalf("consumes %v, follows %v", m.Consumes, followed)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged"}) {
|
||||
for _, c := range m.Consumes {
|
||||
for _, gone := range []string{".forge.", "teller", "view", "forge-pull", "forge-status", "pull-updated"} {
|
||||
if strings.Contains(c, gone) {
|
||||
t.Errorf("the manifest still consumes %s, which G's gate does not hear (§431, §592, §593)", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, []string{"transition"}) { // `group` and `merged` go with the merge states (§431)
|
||||
t.Fatalf("emits %v", m.Emits)
|
||||
}
|
||||
// Four buckets still in the old `state`, the four of the template `state` moved to `requires` (design 47 G §613), and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3 and the conditions it raised (issue 1275). No
|
||||
// Two buckets still in the old `state` (merges, source), `pulls` and `asks` gone (§613), the four of the template `state` moved to `requires` (design 47 G §613), and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3 and the conditions it raised (issue 1275). No
|
||||
// `turns`, no turn tool and no `check-gates-merge`: no check gates a merge (novox/hq ADR 0661 §1, §2, §6).
|
||||
buckets := m.buckets()
|
||||
if !reflect.DeepEqual(buckets, []string{"pulls", "asks", "merges", "source"}) {
|
||||
if !reflect.DeepEqual(buckets, []string{"merges", "source"}) {
|
||||
t.Fatalf("state %v", buckets)
|
||||
}
|
||||
for _, tool := range m.Tools {
|
||||
@@ -141,26 +150,14 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
if !reflect.DeepEqual(m.Capabilities, []string{buscalls.LedgerCalls, buscalls.LedgerUpkeepCalls}) {
|
||||
t.Fatalf("capabilities %v, want %s and %s", m.Capabilities, buscalls.LedgerCalls, buscalls.LedgerUpkeepCalls)
|
||||
}
|
||||
// What it reads: the checker's verdicts alone (issue 1302; they go with the checker). The teller's answers and the delivery
|
||||
// view's reach, which `deliveries` and `show` derive whether a merge is on the machines from (ADR 0556 clause 4), are no
|
||||
// state it reads (ADR 0596 §2-§4): it hears their owners' events answered and reached and asks their tools.
|
||||
if !reflect.DeepEqual(m.Reads, []string{checkerVerdicts}) {
|
||||
// It reads no other module's state (ADR 0596 §2): the checker's verdicts went with the checks, and the teller's answers and the
|
||||
// view's reach are neither state it reads nor events it hears (§593).
|
||||
if len(m.Reads) != 0 {
|
||||
t.Fatalf("reads %v", m.Reads)
|
||||
}
|
||||
for _, c := range []string{"mesh-delivery-teller.answered", "mesh-delivery-view.reached"} {
|
||||
if !slices.Contains(m.Consumes, c) {
|
||||
t.Errorf("the manifest does not consume %s", c)
|
||||
}
|
||||
}
|
||||
if answeredEvent != "mesh-delivery-teller.answered" || reachedEvent != "mesh-delivery-view.reached" {
|
||||
t.Errorf("the events heard are %q and %q", answeredEvent, reachedEvent)
|
||||
}
|
||||
// It calls no controller verb and reads the forge through the seat git's verbs (design 47 *The seat git*), and the forge's
|
||||
// merge state through the one statuses tool the seat does not carry yet (seatForge.mergeState); the forge's writes are the
|
||||
// bridge's (ADR 0437 §8). It uses operator-channel only to say its conditions (ADR 0437 §9, issue 1275): the teller asks
|
||||
// (ADR 0437 §6).
|
||||
want := []string{"seat:git.pull", "seat:git.statuses", "seat:git.open-pulls", "seat:git.contains", "seat:mesh-hand-acts.hand-act",
|
||||
TrackerListVerb, WantsEntryVerb, EngineCompositionVerb, tellerAnswersTool, viewReachTool}
|
||||
// It calls no controller verb and no forge verb: the forge's reads and writes are the bridge's (ADR 0437 §8; design 47 §592).
|
||||
// It uses operator-channel only to say its conditions (ADR 0437 §9, issue 1275): the teller asks no more (§431).
|
||||
want := []string{"seat:mesh-hand-acts.hand-act", WantsEntryVerb, EngineCompositionVerb}
|
||||
if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
|
||||
t.Fatalf("uses %v: operator-channel, for its conditions alone", m.Uses)
|
||||
}
|
||||
@@ -246,22 +243,6 @@ func TestNoReadyMarkIsLeftInTheModule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A row written while the ready mark stood still reads: its mark's fields are ignored, its ask and merge kept (ADR 0421
|
||||
// §4 consequences, issue 1022).
|
||||
func TestARowWrittenWithTheReadyMarkStillReads(t *testing.T) {
|
||||
store := newMemStore()
|
||||
row := `{"id":"novox/app@111111111111","repository":"novox/app","commit":"` + readyHead + `","number":7,"base":"main",
|
||||
"state":"ready","since":"2026-10-17T10:00:00Z","created":"2026-10-17T10:00:00Z","let_go":"0001-01-01T00:00:00Z",
|
||||
"ready":{"head":"` + readyHead + `","by":"lead@shanks","marked_at":"2026-10-17T10:01:00Z","seen":"2026-10-17T10:02:00Z",
|
||||
"last_try":"0001-01-01T00:00:00Z","asked_at":"2026-10-17T10:03:00Z","ask_id":"merge-7-111111111111-1","heads":{"novox/app@111111111111":"` + readyHead + `"}}}`
|
||||
store.deliveries["novox/app@111111111111"] = []byte(row)
|
||||
w := newWorldOver(t, store, newFakeForge(), time.Date(2026, 10, 17, 11, 0, 0, 0, time.UTC))
|
||||
d := w.delivery("novox/app@111111111111")
|
||||
if d.Ready == nil || d.Ready.AskID != "merge-7-111111111111-1" || d.Ready.Head != readyHead {
|
||||
t.Fatalf("the old row is not read: %+v", d.Ready)
|
||||
}
|
||||
}
|
||||
|
||||
// R6-C3 (ADR 0539 §17): the gate holds the seat as the SDK's table of release 6 has it. Every verb of the table that is not
|
||||
// optional is served; the verbs release 6 removed are optional rows the SDK answers by name, and the gate serves none of them;
|
||||
// and the SDK's own test of whether a module can hold a seat (CanHold) finds nothing missing or unpromised.
|
||||
@@ -285,7 +266,7 @@ func TestTheGateHoldsTheSeatAsTheSDKsTableOfRelease6HasIt(t *testing.T) {
|
||||
served[verb] = true
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"settle", "checks"} { // design 47 G §431
|
||||
for _, removed := range []string{"settle", "checks", "table", "recheck", "close"} { // design 47 G §431
|
||||
if served[removed] {
|
||||
t.Errorf("the gate serves %s, which G removed with the merge states", removed)
|
||||
}
|
||||
|
||||
@@ -4,29 +4,27 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// The gate merges through the forge bridge, never itself (novox/hq ADR 0437 §8, ADR 0441 §5, ADR 0450 §3). It writes into
|
||||
// `merges.<group>` the Approve's heads and ONE member at a time, the next only after the bridge's message on FORGE says the
|
||||
// one before it merged, and none after a refusal. It re-reads every member's required statuses before it hands a member
|
||||
// (ADR 0441 §2). Once every member is merged it moves `source.main` in one compare-and-set write and the mesh's source has
|
||||
// the group as one revision. What it announces for each merge (`merged`) names the ask, its asker the teller, and the heads.
|
||||
// The gate merges nothing and hands nothing to the forge (design 47 G §592): `merges` stays an old section the switch reads
|
||||
// (switch.go, §613), never written; `source` is the gate's source revision per watched branch (§15, §291), served by the verb
|
||||
// `source` below.
|
||||
|
||||
// MergeBranch is the trunk branch each repository's merge branch names in a source key.
|
||||
const MergeBranch = "main"
|
||||
|
||||
// changeAttempts is how often a lost compare-and-set is read and applied again before it is said.
|
||||
const changeAttempts = 10
|
||||
|
||||
// MergeStore is the gate's bucket merges.
|
||||
type MergeStore interface {
|
||||
Get(group string) (*Merge, error)
|
||||
Put(group string, m Merge) error
|
||||
}
|
||||
|
||||
// OldState reads the gate's old state owed, which the switch says again as switched acts (switch.go): each key, and one
|
||||
@@ -79,19 +77,6 @@ func (kvMerges) Get(group string) (*Merge, error) {
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (kvMerges) Put(group string, m Merge) error {
|
||||
key, err := mdc.MergesKey(group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := EncodeMerge(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stdio.State("merges").Put(key, json.RawMessage(raw))
|
||||
return err
|
||||
}
|
||||
|
||||
// kvSource is source over the runtime.
|
||||
type kvSource struct{}
|
||||
|
||||
@@ -217,674 +202,3 @@ func (h *Holder) SourceMain() map[string]any {
|
||||
}
|
||||
return map[string]any{"group": held.Group, "heads": held.Heads}
|
||||
}
|
||||
|
||||
// approvedOf are the heads an Approve covers as a merge carries them: `<repository>@<number>` to the full head.
|
||||
func (h *Holder) approvedOf(heads map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for id, sha := range heads {
|
||||
if m := h.deliveries[id]; m != nil {
|
||||
out[mdc.ApprovedKey(m.Repository, m.Number)] = sha
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handTurn is d's turn to be handed to the bridge, when it is: the first member of its group not merged (a lone pull request
|
||||
// is a group of one), every member approved at its head and ready to merge. Nil when it is not. Under the lock; the hand
|
||||
// runs outside it.
|
||||
func (h *Holder) handTurn(g *Group, d *Delivery, now time.Time) func() {
|
||||
var reads []memberRead
|
||||
c := h.covering(d)
|
||||
if g != nil {
|
||||
order := h.unmergedLive(g)
|
||||
if len(order) == 0 || order[0] != d {
|
||||
return nil
|
||||
}
|
||||
for _, m := range order {
|
||||
if h.notHandable(m) != "" {
|
||||
return nil // the others are not approved at their heads yet: nothing is merged before they are (said by notHanded)
|
||||
}
|
||||
reads = append(reads, readOf(m, h.covering(m).Heads[m.ID]))
|
||||
}
|
||||
}
|
||||
if c == nil || c.Approved == nil || c.Heads[d.ID] == "" {
|
||||
return nil
|
||||
}
|
||||
d.Ready.Tries++
|
||||
d.Ready.LastTry, d.Ready.merging = now, true
|
||||
h.keep(d)
|
||||
gid, name, key, target, approved := "", askGroup(d), c.AskID, d.ID, h.approvedOf(c.Heads)
|
||||
if g != nil {
|
||||
gid = g.ID
|
||||
}
|
||||
return func() { h.hand(gid, name, key, target, reads, approved) }
|
||||
}
|
||||
|
||||
// notHandable is why a group member cannot be handed in its group's pass, empty when it can: handTurn's own test, in words.
|
||||
// Under the lock.
|
||||
func (h *Holder) notHandable(m *Delivery) string {
|
||||
mc := h.covering(m)
|
||||
switch {
|
||||
case m.Ready == nil:
|
||||
return m.ID + " has no ready run (" + string(m.State) + ")"
|
||||
case m.Ready.declined():
|
||||
return m.ID + " stands declined since " + m.Ready.Declined.UTC().Format(time.RFC3339)
|
||||
case mc == nil || mc.Approved == nil:
|
||||
return m.ID + " is not approved at its head " + shortOf(m.Commit, 12)
|
||||
case mc.Heads[m.ID] != m.Commit:
|
||||
return m.ID + "'s head " + shortOf(m.Commit, 12) + " is not the head approved"
|
||||
}
|
||||
if w := h.readyWaits(m); w != "" {
|
||||
return m.ID + " waits: " + w
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// notHanded is why the approved merge of d, which waits for nothing, is not handed: its group's pass cannot start, and
|
||||
// says which member holds it and why. Empty when nothing holds it, or when the pass is under way or its refusal is said
|
||||
// already. Under the lock.
|
||||
func (h *Holder) notHanded(g *Group, d *Delivery) string {
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
order := h.unmergedLive(g)
|
||||
if len(order) == 0 {
|
||||
return ""
|
||||
}
|
||||
if first := order[0].Ready; first != nil && (first.merging || !first.LastTry.IsZero() || first.RefusedSince != nil) {
|
||||
return "" // the pass is under way, or its refusal is said on its own line
|
||||
}
|
||||
for _, m := range order {
|
||||
if why := h.notHandable(m); why != "" {
|
||||
return fmt.Sprintf("its group %s is not handed to the forge bridge: %s", g.ID, why)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// noteNotHanded keeps on d's run why its approved merge is not handed, said once in the log when it changes, and as a
|
||||
// stalled line meanwhile (readyLines). Under the lock.
|
||||
func (h *Holder) noteNotHanded(d *Delivery, why string, now time.Time) {
|
||||
r := d.Ready
|
||||
if r == nil || r.NotHanded == why {
|
||||
return
|
||||
}
|
||||
r.NotHanded = why
|
||||
if why == "" {
|
||||
r.NotHandedSince = nil
|
||||
} else {
|
||||
at := now
|
||||
r.NotHandedSince = &at
|
||||
h.Logf("[mesh-delivery] %s is approved, waits for nothing, and is NOT handed to merge: %s", d.ID, why)
|
||||
}
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// hand reads a group's required statuses and, when all are a success, writes the target as the member being merged.
|
||||
func (h *Holder) hand(gid, name, key, target string, reads []memberRead, approved map[string]string) {
|
||||
if gid != "" && !h.groupMergeable(gid, target, reads) {
|
||||
return
|
||||
}
|
||||
if h.waitForRoom(target) {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
value, ok := h.mergeValue(name, key, approved, target, mdc.MergeMerging)
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
h.giveBack(target, nil)
|
||||
return
|
||||
}
|
||||
cur, err := h.Merges.Get(name)
|
||||
if err == nil && cur != nil && SameMerge(*cur, value) {
|
||||
return // handed already, the bridge has the revision
|
||||
}
|
||||
if err == nil {
|
||||
err = h.Merges.Put(name, value)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrRecordsFull) {
|
||||
h.mu.Lock()
|
||||
h.holdFull(target, h.Now())
|
||||
h.mu.Unlock()
|
||||
}
|
||||
h.giveBack(target, err)
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s is handed to the forge bridge to merge, at the heads the operator approved", target)
|
||||
}
|
||||
|
||||
// giveBack gives a target its turn back after a hand that could not be made, to be asked again after a while.
|
||||
func (h *Holder) giveBack(target string, err error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if d := h.deliveries[target]; d != nil && d.Ready != nil {
|
||||
d.Ready.merging = false
|
||||
d.Ready.LastTry = time.Time{}
|
||||
}
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s could not be handed to the forge bridge: %v; asked again in %s", target, err, askAgainAfter)
|
||||
}
|
||||
h.again("a merge that could not be handed to the forge bridge")
|
||||
}
|
||||
|
||||
// mergeValue is the value of a group's merges key with these members merged, and the target in the state asked (merging, or
|
||||
// refused with the forge's words): the group's members in order, those the Approve covers. Under the lock. False when it
|
||||
// cannot be made (the target is not among the heads the Approve covers).
|
||||
func (h *Holder) mergeValue(name, key string, approved map[string]string, target string, state mdc.MergeState) (Merge, bool) {
|
||||
t := h.deliveries[target]
|
||||
if t == nil {
|
||||
return Merge{}, false
|
||||
}
|
||||
members := []*Delivery{t}
|
||||
if g := h.groups[t.Group]; t.Group != "" && g != nil {
|
||||
members = h.membersOf(g)
|
||||
}
|
||||
m := Merge{Group: name, Ask: key, Approved: approved}
|
||||
for _, o := range members {
|
||||
head := approved[mdc.ApprovedKey(o.Repository, o.Number)]
|
||||
if head == "" {
|
||||
continue
|
||||
}
|
||||
pull := MergePull{Head: mdc.Head{Repository: o.Repository, Number: o.Number, Head: head}}
|
||||
switch {
|
||||
case o.MergedAs != "" || (o.Ready != nil && o.Ready.Merged != nil):
|
||||
commit := o.MergedAs
|
||||
if o.Ready != nil && o.Ready.MergeCommit != "" {
|
||||
commit = o.Ready.MergeCommit
|
||||
}
|
||||
if commit == "" {
|
||||
continue
|
||||
}
|
||||
pull.State, pull.Commit = mdc.MergeMerged, commit
|
||||
case o == t:
|
||||
pull.State = state
|
||||
if state == mdc.MergeRefused {
|
||||
pull.Words = o.Ready.Answer
|
||||
}
|
||||
m.Pulls = append(m.Pulls, pull)
|
||||
return m, true
|
||||
default:
|
||||
return m, false
|
||||
}
|
||||
m.Pulls = append(m.Pulls, pull)
|
||||
}
|
||||
return m, len(m.Pulls) > 0 && state == mdc.MergeMerged
|
||||
}
|
||||
|
||||
// forgeGroupEnd is the whole event of a group's end on FORGE, the forge bridge's event name under its module: compared
|
||||
// exactly, so the merged message of a repository named forge/group, which ends the same way, is read as one.
|
||||
const forgeGroupEnd = forgeBridgeModule + "." + forgemsg.ForgeGroupEndEvent
|
||||
|
||||
// Forged takes a message of the forge bridge on FORGE: the gate follows a merge and the refusal of a merge, and nothing else.
|
||||
//
|
||||
// The end of a group's merges (`forge.group.merged`, a body this gate has no use for: the gate wrote the merges itself) is
|
||||
// the indexer's, and is ignored before it is decoded, so no refusal is said for it (issue 1585, ADR 0633 clause 4).
|
||||
func (h *Holder) Forged(event string, body []byte) {
|
||||
if event == forgeGroupEnd {
|
||||
return
|
||||
}
|
||||
f, err := forgemsg.DecodeForge(body)
|
||||
if err != nil {
|
||||
h.Refuse(fmt.Errorf("a message of the forge bridge is not readable: %v", err))
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case f.Kind == forgemsg.ForgeMerged:
|
||||
h.ForgeMerged(f)
|
||||
case f.Kind == forgemsg.ForgeRefused && f.Act == "merge":
|
||||
h.ForgeRefused(f)
|
||||
}
|
||||
}
|
||||
|
||||
// memberOfForge is the delivery a forge message is about: at the head it names.
|
||||
func (h *Holder) memberOfForge(f forgemsg.Forge) *Delivery {
|
||||
if d := h.deliveries[IDOf(f.Repository, f.Head)]; d != nil && d.Number == f.Number {
|
||||
return d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForgeMerged takes the bridge's word that a pull request the gate handed to it merged: the merge is announced once,
|
||||
// with the ask, its asker the teller and the heads (issue 1025), and the next member's turn comes at the next look.
|
||||
func (h *Holder) ForgeMerged(f forgemsg.Forge) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.memberOfForge(f)
|
||||
if d == nil || d.Ready == nil || d.Ready.Approved == nil || d.Ready.Merged != nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, f.Head))), Since: now})
|
||||
r.Merged, r.Answer, r.RefusedSince, r.merging, r.MergeCommit = &now, "merged", nil, false, f.Merge
|
||||
h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s (%s)", d.ID, shortOf(f.Head, 12), h.checkAt(h.askMembers(d)).Words)
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// ForgeRefused takes the bridge's word that the forge refused a merge: nothing after it is merged, and the refusal is said
|
||||
// as a stalled line (ADR 0441 §3, §4). The merge is handed again only at the next event that decides whether it may
|
||||
// merge (mergeAgainNow).
|
||||
func (h *Holder) ForgeRefused(f forgemsg.Forge) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.memberOfForge(f)
|
||||
if d == nil || d.Ready == nil || d.Ready.Merged != nil {
|
||||
return
|
||||
}
|
||||
r, now := d.Ready, h.Now()
|
||||
r.merging, r.refusedNoted = false, false
|
||||
answer := oneLine(f.Words)
|
||||
var on time.Time
|
||||
if d.Check != nil {
|
||||
on = d.Check.At
|
||||
}
|
||||
if r.RefusedSince == nil {
|
||||
r.RefusedSince, r.FirstRefusedOn = &now, on
|
||||
}
|
||||
r.LastRefusedOn = on
|
||||
r.noteRefusal(d, answer)
|
||||
if !outOfDateRefusal(answer) {
|
||||
r.Urgent = true // never a warning again for this head
|
||||
}
|
||||
if r.Repeats >= maxRepeatedRefusals {
|
||||
// Loud, not endless (novox/hq issue 1106): the same refusal again with the trunk not moved is no trunk's move, so
|
||||
// the merge is not asked again by a verdict or a status; the operator is told, urgent, and decides.
|
||||
r.Urgent = true
|
||||
if r.Repeats == maxRepeatedRefusals {
|
||||
h.Logf("[mesh-delivery] %s: the merge tool refused it %d times in a row with the same words and the trunk not moved, so "+
|
||||
"the merge is asked no more until the trunk moves, a new head is announced or the operator approves again: %s",
|
||||
d.ID, r.Repeats, answer)
|
||||
}
|
||||
}
|
||||
if answer != r.Answer {
|
||||
h.Logf("[mesh-delivery] %s is approved, and the merge tool did not merge it (asked again at the next verdict, Approve or composed check): %s", d.ID,
|
||||
answer)
|
||||
}
|
||||
r.Answer = answer
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// settleMerges works out, under the lock, what a merge owes its key and the source once the bridge has spoken: a refused
|
||||
// member written refused, and a group whose members are all merged written merged and moved onto source.main in one write.
|
||||
// The writing happens outside the lock.
|
||||
func (h *Holder) settleMerges() []func() {
|
||||
var out []func()
|
||||
done := map[string]bool{}
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
r := d.Ready
|
||||
if r == nil || r.Approved == nil || r.AskID == "" || len(r.Heads) == 0 {
|
||||
continue
|
||||
}
|
||||
name, key, approved := askGroup(d), r.AskID, h.approvedOf(r.Heads)
|
||||
switch {
|
||||
case r.Merged == nil && r.RefusedSince != nil && !r.refusedNoted && r.Answer != "" && r.Answer != "merged":
|
||||
r.refusedNoted = true
|
||||
target := d.ID
|
||||
out = append(out, func() {
|
||||
h.mu.Lock()
|
||||
value, ok := h.mergeValue(name, key, approved, target, mdc.MergeRefused)
|
||||
h.mu.Unlock()
|
||||
if ok {
|
||||
if err := h.Merges.Put(name, value); err != nil {
|
||||
h.Logf("[mesh-delivery] the refusal of %s could not be written to its merge: %v", target, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
case (r.Merged != nil || d.MergedAs != "") && !r.SourceMoved && !done[key]:
|
||||
// Merged through the bridge, or at the forge directly after the Approve (issue 888): the group is moved once.
|
||||
if !h.allMerged(r.Heads) {
|
||||
continue
|
||||
}
|
||||
done[key] = true
|
||||
heads := map[string]string{}
|
||||
var members []string
|
||||
for hid := range r.Heads {
|
||||
m := h.deliveries[hid]
|
||||
if m.LearntLate {
|
||||
continue // learnt after the fact: never written to source.main (settleGone)
|
||||
}
|
||||
commit := m.MergedAs
|
||||
if m.Ready != nil && m.Ready.MergeCommit != "" {
|
||||
commit = m.Ready.MergeCommit
|
||||
}
|
||||
heads[m.Repository] = commit
|
||||
members = append(members, hid)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
continue // every member learnt after the fact: nothing to write
|
||||
}
|
||||
sort.Strings(members)
|
||||
last := h.deliveries[members[len(members)-1]]
|
||||
out = append(out, func() { h.finish(name, key, approved, last.ID, heads, members) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allMerged is whether every delivery an Approve covers is merged.
|
||||
func (h *Holder) allMerged(heads map[string]string) bool {
|
||||
for id := range heads {
|
||||
m := h.deliveries[id]
|
||||
if m == nil || (m.MergedAs == "" && (m.Ready == nil || m.Ready.Merged == nil)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// finish writes a group's merge as all merged and moves source.main to the merge commits in one compare-and-set write: the
|
||||
// group is one revision of the mesh's source (ADR 0437 §1, §8).
|
||||
func (h *Holder) finish(name, key string, approved map[string]string, last string, heads map[string]string, members []string) {
|
||||
h.mu.Lock()
|
||||
value, ok := h.mergeValue(name, key, approved, last, mdc.MergeMerged)
|
||||
h.mu.Unlock()
|
||||
var err error
|
||||
if ok {
|
||||
err = h.Merges.Put(name, value)
|
||||
}
|
||||
if err == nil {
|
||||
err = h.Source.Move(func(prev *Source) (Source, bool) {
|
||||
next := Source{Group: name, Heads: map[string]string{}}
|
||||
if prev != nil {
|
||||
for repository, head := range prev.Heads {
|
||||
next.Heads[repository] = head
|
||||
}
|
||||
}
|
||||
for repository, commit := range heads {
|
||||
next.Heads[repository] = commit
|
||||
}
|
||||
return next, prev == nil || !reflect.DeepEqual(prev.Heads, next.Heads)
|
||||
})
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] group %s merged, and its place in the source cannot be written yet: %v; asked again in %s", name, err, askAgainAfter)
|
||||
h.again("source.main, moved once every member is merged")
|
||||
return
|
||||
}
|
||||
for _, id := range members {
|
||||
if m := h.deliveries[id]; m != nil && m.Ready != nil {
|
||||
m.Ready.SourceMoved = true
|
||||
h.keep(m)
|
||||
}
|
||||
}
|
||||
h.Logf("[mesh-delivery] group %s is merged whole: source.main holds %s", name, strings.Join(members, ", "))
|
||||
}
|
||||
|
||||
// gateMerges says whether finish moves source.main for a delivery: one the operator approved through an ask, whose merge the
|
||||
// gate hands to the forge bridge.
|
||||
func gateMerges(d *Delivery) bool {
|
||||
return d.Ready != nil && d.Ready.Approved != nil && d.Ready.AskID != "" && len(d.Ready.Heads) > 0
|
||||
}
|
||||
|
||||
// leftOutOfForgeSource is whether settleForgeMerges never writes a merge to source.main: one the gate handed on an Approve
|
||||
// (finish writes it), and one learnt after the fact, long past or with no time the forge said. One learnt late within
|
||||
// replayLate of its merge, as a gate before the fix took mesh-delivery#51 (2026-10-25, its group's finish left it out and
|
||||
// source.main kept the trunk before it), is weighed like any merge heard from the forge at a catch-up, and never moves
|
||||
// source.main back (olderHead).
|
||||
func (d *Delivery) leftOutOfForgeSource() bool {
|
||||
if d.LearntLate {
|
||||
return d.MergedAt.IsZero() || d.Since.Sub(d.MergedAt) >= replayLate
|
||||
}
|
||||
return gateMerges(d)
|
||||
}
|
||||
|
||||
// settleForgeMerges moves source.main for each merge into the trunk heard only from the forge (issue 888): a pull request
|
||||
// merged at the forge directly, not handed by the gate on an Approve. Every other delivery's trunk moved with it (trunkMoved),
|
||||
// and the checker judges on the trunk source.main names, so source.main moves too. A merge replayed as history, or older than
|
||||
// a merge of its repository already heard, is never written: source.main never moves backwards. Under the lock; the writing
|
||||
// happens outside it.
|
||||
func (h *Holder) settleForgeMerges() []func() {
|
||||
var out []func()
|
||||
again := map[*Delivery]bool{}
|
||||
if h.catchUp {
|
||||
h.catchUp = false
|
||||
again = h.newestInSource()
|
||||
}
|
||||
for _, d := range h.deliveries {
|
||||
if d.MergedAs == "" || (d.InSource && !again[d]) || d.leftOutOfForgeSource() {
|
||||
continue
|
||||
}
|
||||
if d.Base != MergeBranch || d.replayed() || h.newerMergeOf(d) {
|
||||
d.InSource = true // not the trunk, or not its head: nothing to write
|
||||
h.keep(d)
|
||||
continue
|
||||
}
|
||||
id, repository, base, commit, at, group := d.ID, d.Repository, d.Base, d.MergedAs, mergedWhen(d), firstOf(d.Branch, d.Repository)
|
||||
out = append(out, func() {
|
||||
err := h.Source.Move(func(prev *Source) (Source, bool) {
|
||||
next := Source{Group: group, Heads: map[string]string{repository: commit}}
|
||||
if prev != nil {
|
||||
held := prev.Heads[repository]
|
||||
if sameCommit(held, commit) {
|
||||
return next, false // there already: the bridge's word and the forge's are one merge
|
||||
}
|
||||
if held != "" && !h.olderHead(repository, base, held, at) {
|
||||
return next, false // source.main holds a newer merge, or one the gate cannot date: never moved back
|
||||
}
|
||||
for r, head := range prev.Heads {
|
||||
if r != repository {
|
||||
next.Heads[r] = head
|
||||
}
|
||||
}
|
||||
}
|
||||
return next, true
|
||||
})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s merged at the forge, and its place in the source cannot be written yet: %v; asked again in %s", id, err, askAgainAfter)
|
||||
h.again("source.main, moved for a merge heard from the forge")
|
||||
return
|
||||
}
|
||||
if m := h.deliveries[id]; m != nil {
|
||||
m.InSource = true
|
||||
h.keep(m)
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s merged at the forge: source.main holds %s at %s", id, repository, shortOf(commit, 12))
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordForgeMade opens a forge-made delivery record for every merge into the trunk the forge made with no record's
|
||||
// merge-pull (design 47 *What the gate owes the forge*): one the gate did not say the merge of, heard as it happened. It
|
||||
// holds the pull request at its head, has no Approve, and is said as the delivery's transition with why forge-made, once.
|
||||
// Under the lock; the record is opened outside it.
|
||||
func (h *Holder) recordForgeMade() []func() {
|
||||
if h.Records == nil {
|
||||
return nil
|
||||
}
|
||||
var out []func()
|
||||
for _, d := range h.deliveries {
|
||||
if d.MergedAs == "" || d.ForgeRecorded || d.LearntLate || gateMerges(d) || d.Number == 0 || d.Base != MergeBranch {
|
||||
continue
|
||||
}
|
||||
id, milestone, heads := d.ID, milestoneOf(d), map[string]string{mdc.ApprovedKey(d.Repository, d.Number): d.Commit}
|
||||
out = append(out, func() {
|
||||
r, _, err := h.Records.Open(milestone, heads, true)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
m := h.deliveries[id]
|
||||
if err != nil || m == nil || r == nil {
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s merged at the forge with no record's merge-pull, and its forge-made record cannot be opened yet: %v; asked again in %s", id, err, askAgainAfter)
|
||||
h.again("a forge-made record, opened for a merge the forge made")
|
||||
}
|
||||
return
|
||||
}
|
||||
now := h.Now()
|
||||
body := transitionEvent(m, Transition{At: now, From: m.State, To: m.State, Event: "forge-made", Why: "forge-made"}, time.Time{})
|
||||
body["record"] = r.Instance
|
||||
m.Owed = append(m.Owed, Effect{Kind: EffectEmit, Event: "transition", Line: string(mustJSON(body)), Since: now})
|
||||
m.ForgeRecorded = true
|
||||
h.keep(m)
|
||||
h.Logf("[mesh-delivery] %s merged at the forge with no record's merge-pull: its forge-made record is %s", id, r.Instance)
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trunkHead is a full commit id as the forge names a branch's head.
|
||||
var trunkHead = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
// seedTrunks gives source.main the trunk head of a repository it names none of (issue 1422): one never merged through the
|
||||
// gate, whose open pull requests the checker could otherwise never judge. The head is the base_sha of the latest announcement of
|
||||
// any open pull request into main (the seat git's pull.updated; ADR 0323; lastAnnounced), the trunk's freshest head the gate heard. It is
|
||||
// added in one compare-and-set write that never replaces a head source.main holds, so a merge always wins; the first merge
|
||||
// through the gate moves it as any other. source.main is per repository and names main's head only (ADR 0484 §11, MergeBranch):
|
||||
// a pull request into another base seeds nothing. A repository is weighed once per start after it was found or seeded.
|
||||
// Under the lock; the writing happens outside it.
|
||||
func (h *Holder) seedTrunks() []func() {
|
||||
newest := map[string]*Delivery{}
|
||||
for _, d := range h.deliveries {
|
||||
if h.seeded[d.Repository] || d.Base != MergeBranch || !trunkHead.MatchString(d.BaseSHA) || d.State.Final() || d.MergedAs != "" {
|
||||
continue
|
||||
}
|
||||
if o := newest[d.Repository]; o == nil || lastAnnounced(d).After(lastAnnounced(o)) {
|
||||
newest[d.Repository] = d
|
||||
}
|
||||
}
|
||||
var out []func()
|
||||
for repository, d := range newest {
|
||||
id, head, group := d.ID, d.BaseSHA, firstOf(d.Branch, d.Repository)
|
||||
out = append(out, func() {
|
||||
seeded := false
|
||||
err := h.Source.Move(func(prev *Source) (Source, bool) {
|
||||
seeded = false
|
||||
next := Source{Group: group, Heads: map[string]string{}}
|
||||
if prev != nil {
|
||||
if prev.Heads[repository] != "" {
|
||||
return *prev, false // named already: never replaced
|
||||
}
|
||||
next.Group = prev.Group
|
||||
for r, held := range prev.Heads {
|
||||
next.Heads[r] = held
|
||||
}
|
||||
}
|
||||
next.Heads[repository] = head
|
||||
seeded = true
|
||||
return next, true
|
||||
})
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s: source.main names no trunk head of %s, and it cannot be seeded yet: %v; asked again in %s", id, repository, err, askAgainAfter)
|
||||
h.again("source.main, seeded with the trunk head of a repository it names none of")
|
||||
return
|
||||
}
|
||||
if h.seeded == nil {
|
||||
h.seeded = map[string]bool{}
|
||||
}
|
||||
h.seeded[repository] = true
|
||||
if seeded {
|
||||
h.Logf("[mesh-delivery] source.main named no trunk head of %s: seeded with %s, main's head as the forge announced %s with, so its pull requests are judged (issue 1422)", repository, shortOf(head, 12), id)
|
||||
}
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lastAnnounced is when the forge last announced a delivery's head with its base head: BaseSHAAt, or when the gate made it
|
||||
// for one kept before that was written.
|
||||
func lastAnnounced(d *Delivery) time.Time {
|
||||
if !d.BaseSHAAt.IsZero() {
|
||||
return d.BaseSHAAt
|
||||
}
|
||||
return d.Created
|
||||
}
|
||||
|
||||
// replayLate is how long after its merge a merge heard only from the forge's word is a replay: the forge announces a merge
|
||||
// within seconds, so one heard this late was replayed from the bus's history, and the source already holds what came after.
|
||||
const replayLate = 15 * time.Minute
|
||||
|
||||
// replayed is whether the merge was heard from the forge's word replayLate or more after it happened (novox/hq issue 313,
|
||||
// kept for source.main's sake after the history state went, ADR 0539 §17): never written to source.main.
|
||||
func (d *Delivery) replayed() bool {
|
||||
return d.FromForgesWord() && !d.MergedAt.IsZero() && d.Created.Sub(d.MergedAt) >= replayLate
|
||||
}
|
||||
|
||||
// mergeOf is a delivery's merge into its trunk as the gate knows it: the forge's word (MergedAs), or else the bridge's
|
||||
// (Ready.MergeCommit, Ready.Merged), which comes first for a merge the gate handed. Empty when it is not merged.
|
||||
func mergeOf(d *Delivery) (string, time.Time) {
|
||||
if d.MergedAs != "" {
|
||||
return d.MergedAs, mergedWhen(d)
|
||||
}
|
||||
if d.Ready != nil && d.Ready.Merged != nil && d.Ready.MergeCommit != "" {
|
||||
return d.Ready.MergeCommit, *d.Ready.Merged
|
||||
}
|
||||
return "", time.Time{}
|
||||
}
|
||||
|
||||
// newerMergeOf says whether a merge into the same trunk of d's repository was heard that landed after d's.
|
||||
func (h *Holder) newerMergeOf(d *Delivery) bool {
|
||||
for _, o := range h.deliveries {
|
||||
if commit, at := mergeOf(o); o != d && o.Repository == d.Repository && o.Base == d.Base && commit != "" &&
|
||||
!sameCommit(commit, d.MergedAs) && at.After(mergedWhen(d)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// olderHead says whether the head source.main holds for a repository is a merge the gate knows landed before at, so a merge
|
||||
// at that time may take its place. A head it does not know (written before its records, or pruned with its delivery) is
|
||||
// older only when at is within the time final records are kept: a pruned merge ended before then (issue 888, review).
|
||||
// Takes the lock.
|
||||
func (h *Holder) olderHead(repository, base, head string, at time.Time) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, o := range h.deliveries {
|
||||
if commit, when := mergeOf(o); o.Repository == repository && o.Base == base && commit != "" && sameCommit(commit, head) {
|
||||
return !when.After(at)
|
||||
}
|
||||
}
|
||||
return at.After(h.Now().Add(-keptFinal))
|
||||
}
|
||||
|
||||
// mergedWhen is when a merged delivery's merge landed: its merge time (the forge's), else when the gate made the delivery. A
|
||||
// zero time made every other merge of the repository look newer, so such a merge never moved source.main (issue 1726).
|
||||
func mergedWhen(d *Delivery) time.Time {
|
||||
if !d.MergedAt.IsZero() {
|
||||
return d.MergedAt
|
||||
}
|
||||
return d.Created
|
||||
}
|
||||
|
||||
// SourceCatchUp asks the next look to weigh again, once, the newest merge into the trunk of each repository though it is
|
||||
// marked in the source (issue 1726, mesh-catalog #474, kept here because no seat holder arrives in release 6 to run it): what
|
||||
// the gate missed while it was not running, or marked without writing, reaches source.main. The write never moves a repository
|
||||
// back (olderHead), and one already there is not written again.
|
||||
func (h *Holder) SourceCatchUp() {
|
||||
h.mu.Lock()
|
||||
h.catchUp = true
|
||||
h.mu.Unlock()
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
// newestInSource is, for each repository, its newest merge into the trunk heard from the forge, marked in the source. Under the lock.
|
||||
func (h *Holder) newestInSource() map[*Delivery]bool {
|
||||
newest := map[string]*Delivery{}
|
||||
for _, d := range h.deliveries {
|
||||
if d.MergedAs == "" || !d.InSource || d.leftOutOfForgeSource() || d.Base != MergeBranch || d.replayed() {
|
||||
continue
|
||||
}
|
||||
if o := newest[d.Repository]; o == nil || mergedWhen(d).After(mergedWhen(o)) {
|
||||
newest[d.Repository] = d
|
||||
}
|
||||
}
|
||||
out := map[*Delivery]bool{}
|
||||
for _, d := range newest {
|
||||
if !h.newerMergeOf(d) {
|
||||
out[d] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,262 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What mesh-delivery owns (novox/hq ADR 0239): the delivery — one commit in one repository — and the
|
||||
// delivery group above it. Each is kept whole, one key of the module's own state per delivery and per
|
||||
// group, written only by this holder.
|
||||
|
||||
// Delivery is one commit in one repository, from its pull request's head to every machine.
|
||||
type Delivery struct {
|
||||
// ID is owner/repository@<twelve characters of its commit>.
|
||||
ID string `json:"id"`
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
// The pull request, when there is one: its number, title, base, head branch and page.
|
||||
Number int `json:"number,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Base string `json:"base,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
CloneURL string `json:"clone_url,omitempty"`
|
||||
// The diffset, as the forge said it: what the planner maps onto modules.
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
PathsTruncated bool `json:"paths_truncated,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
ModuleDirs []string `json:"module_dirs,omitempty"`
|
||||
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
||||
// Comments is how many comments this owner owed on the pull request at this head, which numbers the next.
|
||||
Comments int `json:"comments,omitempty"`
|
||||
// Issues are the issues linked to its pull request in the issue tracker (novox/hq ADR 0493 §4, ADR 0659 §4), shown to the
|
||||
// operator who is asked to approve its merge; IssuesRead is the head they were read at, IssuesUnread why the issue
|
||||
// tracker could not say them. Words only: nothing waits on them.
|
||||
Issues []int `json:"issues,omitempty"`
|
||||
IssuesRead string `json:"issues_read,omitempty"`
|
||||
IssuesUnread string `json:"issues_unread,omitempty"`
|
||||
// issuesReading is a read of them in flight, so a look meanwhile does not ask it again; not kept.
|
||||
issuesReading string
|
||||
// Group is the delivery group it is in: its head branch name. Empty for a lone delivery.
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
State State `json:"state"`
|
||||
Since time.Time `json:"since"`
|
||||
Created time.Time `json:"created"`
|
||||
|
||||
// Check is its own check's verdict.
|
||||
Check *Verdict `json:"check,omitempty"`
|
||||
// Required are the statuses its base requires, as the forge last said them (the seat git's open-pulls): mesh/check makes it
|
||||
// the checker's (issue 1302). Empty until the forge said them.
|
||||
Required []string `json:"required,omitempty"`
|
||||
// CheckState is its head's mesh/check as the forge last said it (pending, success, failure, error): a pending one
|
||||
// after a pass is the checker judging it again, and no Approve is asked meanwhile (issue 1302).
|
||||
CheckState string `json:"check_state,omitempty"`
|
||||
// CheckAsk is this owner's own ask of its check, when the forge's announcement did not bring one: a head
|
||||
// adopted while it already waited, or one whose announcement the controller missed.
|
||||
CheckAsk *CheckAsk `json:"check_ask,omitempty"`
|
||||
// TrunkNow is its trunk's head as this owner last heard it move while it was open: the commit the last merge
|
||||
// into its trunk landed as (novox/hq ADR 0323 §7). A verdict that names another trunk commit is out of date.
|
||||
// Empty until a merge into its trunk is heard after it was made.
|
||||
TrunkNow string `json:"trunk_now,omitempty"`
|
||||
// BaseSHA is the base branch's head the forge last announced its head with (ADR 0323): sent with a check this
|
||||
// owner asks, for a verdict no build judges to name its trunk commit.
|
||||
BaseSHA string `json:"base_sha,omitempty"`
|
||||
// BaseSHAAt is when the forge last announced its head with BaseSHA: the freshest of a repository's announcements gives
|
||||
// source.main its trunk head when it names none (seedTrunks, issue 1422). Zero for one kept before it was written.
|
||||
BaseSHAAt time.Time `json:"base_sha_at,omitzero"`
|
||||
// AskedAgain is when its head was announced again while its verdict was out of date — the merge tool's ask of
|
||||
// its check against the trunk's head (ADR 0323 §5) — and no verdict has come since: its one-hour bound runs from
|
||||
// then. Nil while nothing is asked, and cleared by a verdict or another move of its trunk.
|
||||
AskedAgain *time.Time `json:"asked_again,omitempty"`
|
||||
|
||||
// The facts the table's guards read. NewerHead is the head that superseded it; ClosedUnmerged the pull
|
||||
// request closed; MergedAs the commit it landed on the trunk as; HeldWhy what holds it for a person.
|
||||
NewerHead string `json:"newer_head,omitempty"`
|
||||
ClosedUnmerged bool `json:"closed_unmerged,omitempty"`
|
||||
// Others are the other open pull requests announced at this head while this one was live (issue 1114), as they were
|
||||
// announced: the record is theirs to take over when this one's pull request closes unmerged.
|
||||
Others []PullEvent `json:"others,omitempty"`
|
||||
MergedAs string `json:"merged_as,omitempty"`
|
||||
MergedAt time.Time `json:"merged_at"`
|
||||
// InSource says the merge was looked at for source.main: written there, or left as older than the trunk's head (issue 888).
|
||||
// A merge the gate handed to the bridge on an Approve is moved there by finish instead (merges.go).
|
||||
InSource bool `json:"in_source,omitempty"`
|
||||
// ForgeRecorded is a merge the forge made with no record's merge-pull whose forge-made record is opened and said
|
||||
// (merges.go recordForgeMade).
|
||||
ForgeRecorded bool `json:"forge_recorded,omitempty"`
|
||||
// LearntLate says its merge was learnt after the fact, from its absence among the forge's open pull requests
|
||||
// (settleGone, issue 1275): it moved no trunk here, and it never moves source.main, not even at a catch-up.
|
||||
LearntLate bool `json:"learnt_late,omitempty"`
|
||||
HeldWhy string `json:"held_why,omitempty"`
|
||||
// FromForge is a delivery made from the forge's word at its merge: its head was never heard by this owner.
|
||||
FromForge bool `json:"from_forge,omitempty"`
|
||||
|
||||
// Ready is what this owner did with the operator's Approve of its merge into main, and with the merge (novox/hq ADR
|
||||
// 0421, ready.go). The json key is the one the ready mark's record had, so rows written before still read.
|
||||
Ready *ReadyRun `json:"ready,omitempty"`
|
||||
// Author is who opened its pull request, as the forge's pull request record says (the seat git's events): named with
|
||||
// its branch when a new head is needed (novox/hq ADR 0505 §5, ADR 0659 §4).
|
||||
Author string `json:"author,omitempty"`
|
||||
// FreshWanted is whether a passing verdict that arrived out of date is to have its check asked against the
|
||||
// trunk's head, before the operator is asked (novox/hq ADR 0421 §3, issue 1022). Cleared when it is asked.
|
||||
FreshWanted bool `json:"fresh_wanted,omitempty"`
|
||||
|
||||
Transitions []Transition `json:"transitions,omitempty"`
|
||||
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
|
||||
// delivery is kept, retried until done.
|
||||
Owed []Effect `json:"owed,omitempty"`
|
||||
}
|
||||
|
||||
// Transition is one move of the table, kept.
|
||||
type Transition struct {
|
||||
At time.Time `json:"at"`
|
||||
From State `json:"from"`
|
||||
To State `json:"to"`
|
||||
Event Event `json:"event"`
|
||||
Why string `json:"why"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Verdict is a check's verdict: the gate's and the repository's own. ID is the build seat's ask that ran
|
||||
// it, On the machine it ran on, At when this owner heard it; Report is the check's own account, as bounded
|
||||
// as the controller says it. All of it is what `checks` answers, whole, beside the forge's short statuses.
|
||||
type Verdict struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
On string `json:"on,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Gate string `json:"gate"`
|
||||
Summary string `json:"summary"`
|
||||
Modules []string `json:"modules,omitempty"`
|
||||
Dependents []string `json:"dependents,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
RepoSaid string `json:"repo_summary,omitempty"`
|
||||
Report string `json:"report,omitempty"`
|
||||
// Head and Trunk are what it judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, twelve
|
||||
// characters each. Trunk is empty for a verdict of a build seat that judged the head alone. Beside are the
|
||||
// commits of the other repositories the check read, by directory; Took how long it ran.
|
||||
Head string `json:"head,omitempty"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
Beside map[string]string `json:"beside,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
// Status is the forge status it was read from when it is not the controller's verdict: mesh/check, the checker's
|
||||
// (novox/hq ADR 0447, issue 1302). Empty for the controller's `checked`.
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// Judged is what a verdict judged, in words: "head 1a2b3c4d5e6f merged into trunk 7a8b9c0d1e2f" (ADR 0323 §3);
|
||||
// empty for a verdict that names no trunk commit.
|
||||
func (v *Verdict) Judged() string {
|
||||
if v == nil || v.Trunk == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("head %s merged into trunk %s", shortOf(v.Head, 12), shortOf(v.Trunk, 12))
|
||||
}
|
||||
|
||||
// outOfDate is why a delivery's verdict no longer counts: it names a trunk commit that is not its trunk's head
|
||||
// as this owner heard it (novox/hq ADR 0323 §7). Empty when it counts — or names no trunk commit, as a verdict
|
||||
// of a build seat from before the rule does, which counts as it did.
|
||||
// approvedAtHead is whether the operator's Approve covers this delivery at its head: once it does, the merge judges that
|
||||
// Approve alone, not the check (novox/hq ADR 0661 §3, §4).
|
||||
func (d *Delivery) approvedAtHead() bool {
|
||||
r := d.Ready
|
||||
return r != nil && r.Approved != nil && !r.declined() && r.Heads[d.ID] == d.Commit
|
||||
}
|
||||
|
||||
func (d *Delivery) outOfDate() string {
|
||||
if d.Check == nil || d.Check.Trunk == "" || d.TrunkNow == "" || sameCommit(d.Check.Trunk, d.TrunkNow) {
|
||||
return ""
|
||||
}
|
||||
return outOfDateWords(d.Check.Trunk, d.TrunkNow)
|
||||
}
|
||||
|
||||
// outOfDateWords is a verdict out of date, as the forge's holder says it on the pull request (ADR 0323 §4).
|
||||
func outOfDateWords(judged, now string) string {
|
||||
return fmt.Sprintf("out of date: judged against trunk %s, the trunk is now %s; judged again on a recheck or when "+
|
||||
"it is merged", shortOf(judged, 12), shortOf(now, 12))
|
||||
}
|
||||
|
||||
// CheckAsk is a head's own check, asked of the controller by this owner (or re-asked by healer H2).
|
||||
type CheckAsk struct {
|
||||
// ID is the ask's id as the controller answered it.
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
// Reasked is how many times H2 asked it again: once at most, then the operator's.
|
||||
Reasked int `json:"reasked,omitempty"`
|
||||
}
|
||||
|
||||
// GateComposed is the gate layer of a group member's own verdict when the group's composed check judges it instead
|
||||
// (novox/hq ADR 0403 §1): no gate was run for the member alone.
|
||||
const GateComposed = "composed"
|
||||
|
||||
// Passes is whether the verdict lets the delivery be ready: the gate passed or warned, and the
|
||||
// repository's own check did not fail or error.
|
||||
func (v *Verdict) Passes() bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
// A group member's own gate is the group's composed check's (novox/hq ADR 0403 §1): the controller says the layer
|
||||
// "composed", and the member counts as passed here once its repository's check did not fail, as the composed
|
||||
// check, asked after every member is ready, then judges them together.
|
||||
gate := v.Gate == "pass" || v.Gate == "warning" || v.Gate == GateComposed
|
||||
repo := v.Repo == "" || v.Repo == "pass" || v.Repo == "warning"
|
||||
return gate && repo
|
||||
}
|
||||
|
||||
// Effect is one thing owed outside the state: an event said, a line appended to a commit's note, the view
|
||||
// on the pull request refreshed, a status set.
|
||||
type Effect struct {
|
||||
Kind string `json:"kind"`
|
||||
Event string `json:"event,omitempty"` // an emit's module event, when not transition or group (issue 1025)
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Line string `json:"line,omitempty"`
|
||||
Context string `json:"context,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Tries int `json:"tries,omitempty"`
|
||||
Last string `json:"last,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
// Next is when a failed effect is tried again: later after every failure, up to retryCap.
|
||||
Next *time.Time `json:"next,omitempty"`
|
||||
// ID and Said are the act's id and when it was first said as the event owed: it stays until its owed.ended is heard.
|
||||
ID string `json:"id,omitempty"`
|
||||
Said *time.Time `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// The kinds of effect.
|
||||
const (
|
||||
EffectEmit = "emit"
|
||||
EffectNote = "note"
|
||||
EffectView = "view"
|
||||
EffectStatus = "status"
|
||||
// EffectComment is a comment on the pull request; its Context is the sequence number of the comment on the head, which
|
||||
// keeps two comments on one head two acts (meshdeliveryclient OwedCommentAct).
|
||||
EffectComment = "comment"
|
||||
)
|
||||
|
||||
// FromForgesWord is whether the delivery was made from the forge's word at its merge: said by FromForge, or —
|
||||
// for one made before FromForge was kept — by its first transition.
|
||||
func (d *Delivery) FromForgesWord() bool {
|
||||
if d.FromForge {
|
||||
return true
|
||||
}
|
||||
return len(d.Transitions) > 0 && d.Transitions[0].Event == EvAnnounced &&
|
||||
strings.Contains(d.Transitions[0].Why, "so it is made from the forge's word")
|
||||
}
|
||||
|
||||
func (d *Delivery) shownOnItsPullRequest() bool {
|
||||
return d.Number > 0
|
||||
}
|
||||
|
||||
// Owner and Repo split the repository.
|
||||
func (d *Delivery) Owner() string { o, _, _ := strings.Cut(d.Repository, "/"); return o }
|
||||
func (d *Delivery) Repo() string { _, r, _ := strings.Cut(d.Repository, "/"); return r }
|
||||
|
||||
// IDOf is a delivery's id: the repository and twelve characters of its commit.
|
||||
// IDOf is a pull request's id as a record names its members: the repository and twelve characters of its commit.
|
||||
func IDOf(repository, commit string) string {
|
||||
return strings.ToLower(repository) + "@" + shortOf(commit, 12)
|
||||
}
|
||||
@@ -280,108 +28,14 @@ func sameCommit(a, b string) bool {
|
||||
return len(a) >= 7 && strings.HasPrefix(strings.ToLower(b), strings.ToLower(a))
|
||||
}
|
||||
|
||||
// authorOf is a delivery's author as the operator reads it: "the author of <branch>", and who opened its pull request as
|
||||
// the forge's record says (the router refuses an @ in its words).
|
||||
func authorOf(d *Delivery) string {
|
||||
who := "the author of " + d.Branch
|
||||
if d.Branch == "" {
|
||||
who = "its author"
|
||||
}
|
||||
if d.Author != "" {
|
||||
who += " (" + strings.Replace(d.Author, "@", " on ", 1) + ")"
|
||||
}
|
||||
return who
|
||||
}
|
||||
|
||||
// Group is a delivery group: two or more deliveries sharing a head branch name, one level only.
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Members []string `json:"members"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
// Check is its composed check: asked of the controller for these member commits, and its verdict.
|
||||
Check *GroupCheck `json:"check,omitempty"`
|
||||
// SaidGate is, by member id, why the member's mesh/merge-gate was last said pending (a group the controller judges;
|
||||
// a group on mesh/check is owed none, issue 1302): written again only when it
|
||||
// changes (novox/hq ADR 0403 §1, §6, issue 978). Cleared once a composed verdict stands at the heads, which the
|
||||
// forge module then sets as every member's gate.
|
||||
SaidGate map[string]string `json:"said_gate,omitempty"`
|
||||
// Closed is set once a member reached the trunk: a group delivering takes no new member.
|
||||
Closed bool `json:"closed,omitempty"`
|
||||
// Said is the derived state last said, so a change of it is said once.
|
||||
Said string `json:"said,omitempty"`
|
||||
// Hold is why the group's merge or its Approve's ask is held back by a member's required status that is not a
|
||||
// success (issue 1275); nil when nothing is.
|
||||
Hold *GroupHold `json:"hold,omitempty"`
|
||||
// SaidStatus is the mesh/delivery-group status last written for it: a change of the status a state takes is
|
||||
// written too, though the state did not change (novox/hq issue 902).
|
||||
SaidStatus string `json:"said_status,omitempty"`
|
||||
// SaidWhy and SaidMembers are the why and the member ids (each names its head) last said: a member joining, leaving or
|
||||
// pushing, or a count in the why moving, is said too, though the state did not change (2026-10-30: a group of four
|
||||
// said "3 of 3 member(s) ready" for ten minutes, and the member that joined had no mesh/delivery-group at all).
|
||||
SaidWhy string `json:"said_why,omitempty"`
|
||||
SaidMembers string `json:"said_members,omitempty"`
|
||||
Owed []Effect `json:"owed,omitempty"`
|
||||
}
|
||||
|
||||
// GroupHold is a group held back from its Approve or its merge because a member's required status is not a success.
|
||||
type GroupHold struct {
|
||||
// Kind is "unasked" (the Approve is not asked) or "blocked" (the approved merge is not started: nothing is merged).
|
||||
Kind string `json:"kind"`
|
||||
Member string `json:"member"`
|
||||
Says string `json:"says"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// GroupCheck is a group's composed check.
|
||||
type GroupCheck struct {
|
||||
Asked string `json:"asked"`
|
||||
At time.Time `json:"at"`
|
||||
Commits map[string]string `json:"commits"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
// Trunks are the trunk commit each member's head was merged into, by member id (novox/hq ADR 0323 §6).
|
||||
Trunks map[string]string `json:"trunks,omitempty"`
|
||||
// OutOfDate is why its verdict no longer counts: a merge that is not one of the group's members moved a
|
||||
// member's trunk, at OutOfDateAt. It counts as outstanding (ADR 0239 §18), and runs again once a member is
|
||||
// judged again after it.
|
||||
OutOfDate string `json:"out_of_date,omitempty"`
|
||||
OutOfDateAt time.Time `json:"out_of_date_at"`
|
||||
}
|
||||
|
||||
// Commits is each member's commit, by id.
|
||||
func commitsOf(ds []*Delivery) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, d := range ds {
|
||||
out[d.ID] = d.Commit
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedIDs are the members' ids (repository@head), sorted.
|
||||
func sortedIDs(ds []*Delivery) []string {
|
||||
out := make([]string, 0, len(ds))
|
||||
for _, d := range ds {
|
||||
out = append(out, d.ID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// sameMembers is whether two member→commit maps are one.
|
||||
func sameMembers(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if b[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
raw, _ := json.Marshal(v)
|
||||
return raw
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n-1] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
|
||||
|
||||
checker "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// **One required status** (novox/hq ADR 0447, issue 1302; ADR 0503 §3, §5).
|
||||
//
|
||||
// Main requires `mesh/check` everywhere but hq: the checker's verdict on a pull request's head merged onto its trunk's
|
||||
// head, written by its forge bridge. On such a base this owner reads that one status, and nothing of the controller:
|
||||
//
|
||||
// - the verdict of a head is its mesh/check, heard as the forge's status event (its words name the head and the trunk
|
||||
// it judged: "pass <head12> on <trunk12> | …") or read from the forge's open pull requests; it is kept as the
|
||||
// delivery's Check, marked as mesh/check's, and moves the delivery as the controller's verdict did;
|
||||
// - the Approve is asked only when the checker's verdict for exactly the heads the ask covers passed (its verdicts
|
||||
// bucket, keyed by the heads' hash, ADR 0447 §2): a member's pass from a check that judged it alone, before a sibling
|
||||
// joined, is not the group's;
|
||||
// - a group's verdict is that one verdict (no composed check is asked, and no member is owed a mesh/merge-gate); a
|
||||
// member's mesh/check that is no longer a success withdraws the group's ask (ADR 0441 §1, withdrawForStatus);
|
||||
// - no check is asked of the controller: the checker checks every head on every move, and the trunk's move is its to
|
||||
// say on mesh/check (ADR 0447 §4). The controller is asked only for a base that requires mesh/repo-check without
|
||||
// mesh/check (hq), and only what it was asked before (the lead's decision for issue 1302 in R4e).
|
||||
//
|
||||
// A base whose required statuses are not known yet is read as before: the forge's open pull requests say them at the next
|
||||
// look, and a mesh/check heard on the head says it too.
|
||||
|
||||
// ctxCheck is the one status main requires (ADR 0447 §1).
|
||||
const ctxCheck = "mesh/check"
|
||||
|
||||
// checkerVerdicts is the checker's verdicts bucket as this module reads it (its manifest's reads).
|
||||
const checkerVerdicts = "mesh-delivery-checker.verdicts"
|
||||
|
||||
// The states of a checker's verdict, as the checker's client names them.
|
||||
const (
|
||||
checkerRunning = checker.VerdictRunning
|
||||
checkerPass = checker.VerdictPass
|
||||
)
|
||||
|
||||
// checkerVerdict is a verdict of the checker as its verdicts bucket keeps it (the seat's verdicts value, read here by
|
||||
// the fields the gate needs): the group, the hash of the heads it judged, each repository's trunk head and its state.
|
||||
type checkerVerdict struct {
|
||||
Group string `json:"group"`
|
||||
Heads string `json:"heads"`
|
||||
Trunk map[string]string `json:"trunk,omitempty"`
|
||||
State string `json:"state"`
|
||||
Failed struct {
|
||||
Test string `json:"test,omitempty"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
} `json:"failed,omitzero"`
|
||||
// Composition is what the check of the machines' composition made of it (the verdict's composition): its state
|
||||
// not-run says the composition was not judged.
|
||||
Composition *struct {
|
||||
State string `json:"state"`
|
||||
Words string `json:"words,omitempty"`
|
||||
} `json:"composition,omitempty"`
|
||||
}
|
||||
|
||||
// CheckerVerdictChanged takes a key of the checker's verdicts bucket (`<group>.<heads hash>`) put or deleted (an empty
|
||||
// value): the view of the checker's verdicts the Approve and a group's verdict are read from. A value that is no verdict
|
||||
// is said and dropped.
|
||||
func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.checker == nil {
|
||||
h.checker = map[string]checkerVerdict{}
|
||||
}
|
||||
hash := key
|
||||
if i := strings.LastIndex(key, "."); i >= 0 {
|
||||
hash = key[i+1:]
|
||||
}
|
||||
if h.runningSince == nil {
|
||||
h.runningSince = map[string]time.Time{}
|
||||
}
|
||||
if len(value) == 0 || string(value) == "null" {
|
||||
delete(h.checker, hash)
|
||||
delete(h.runningSince, hash)
|
||||
h.Poke()
|
||||
return
|
||||
}
|
||||
var v checkerVerdict
|
||||
if err := json.Unmarshal(value, &v); err != nil || v.State == "" {
|
||||
h.Logf("[mesh-delivery] the checker's verdict %s cannot be read (%v); it is not taken", key, err)
|
||||
return
|
||||
}
|
||||
if v.Heads == "" {
|
||||
v.Heads = hash
|
||||
}
|
||||
if prev, ok := h.checker[v.Heads]; v.State == checkerRunning && (!ok || prev.State != checkerRunning) {
|
||||
h.runningSince[v.Heads] = h.Now() // a check starts: its words say since when
|
||||
} else if v.State != checkerRunning {
|
||||
delete(h.runningSince, v.Heads)
|
||||
}
|
||||
h.checker[v.Heads] = v
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
// checkWords are the head and the trunk mesh/check's line names: "<head12> on <trunk12>" (ADR 0447 §3).
|
||||
var checkWords = regexp.MustCompile(`\b([0-9a-f]{12}) on ([0-9a-f]{12})\b`)
|
||||
|
||||
// checkLine is the head and the trunk commit a mesh/check line names, twelve characters each; empty when it names none.
|
||||
func checkLine(desc string) (head, trunk string) {
|
||||
m := checkWords.FindStringSubmatch(strings.ToLower(desc))
|
||||
if m == nil {
|
||||
return "", ""
|
||||
}
|
||||
return m[1], m[2]
|
||||
}
|
||||
|
||||
// verdictOfCheck is a delivery's verdict from its head's mesh/check: its state as a verdict, its line as the summary, and
|
||||
// the head and trunk it names.
|
||||
func verdictOfCheck(state, desc string, now time.Time) *Verdict {
|
||||
head, trunk := checkLine(desc)
|
||||
return &Verdict{At: now, Gate: verdictOfState(state), Summary: firstOf(desc, "mesh/check "+state), Head: head, Trunk: trunk,
|
||||
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) {
|
||||
if d == nil || required == nil || slices.Equal(d.Required, required) {
|
||||
return
|
||||
}
|
||||
d.Required = slices.Clone(required)
|
||||
h.keep(d)
|
||||
}
|
||||
|
||||
// takeCheckStatus takes a head's mesh/check as its delivery's verdict, as the controller's `checked` was taken: a
|
||||
// proposed delivery moves on it, a rejected one judged again against another trunk or now passing is proposed again
|
||||
// first. A pending mesh/check is no verdict and changes nothing here (its ask is withdrawn by withdrawForStatus). The same
|
||||
// verdict heard twice changes nothing. Under the lock.
|
||||
func (h *Holder) takeCheckStatus(repository, sha, state, desc string) {
|
||||
d := h.deliveries[IDOf(strings.ToLower(repository), sha)]
|
||||
if d == nil || state == "" {
|
||||
return // its announcement makes it; the forge's open pull requests bring the verdict then
|
||||
}
|
||||
if d.CheckState != state {
|
||||
d.CheckState = state
|
||||
h.keep(d)
|
||||
}
|
||||
if state == "pending" {
|
||||
return
|
||||
}
|
||||
if !slices.Contains(d.Required, ctxCheck) {
|
||||
d.Required = append(d.Required, ctxCheck)
|
||||
}
|
||||
now := h.Now()
|
||||
v := verdictOfCheck(state, desc, now)
|
||||
if c := d.Check; c != nil && c.Status == ctxCheck && c.Gate == v.Gate && c.Trunk == v.Trunk && c.Summary == v.Summary {
|
||||
return
|
||||
}
|
||||
if d.State == Rejected && d.Check != nil && (v.Passes() || v.Trunk != "" && !sameCommit(d.Check.Trunk, v.Trunk)) {
|
||||
t, err := Apply(d, EvRecheck, true, Facts{Now: now, By: byOwner, Why: fmt.Sprintf("its mesh/check judged it again: %s",
|
||||
oneLine(clip(desc, 120)))})
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
return
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.approvedAtHead() && (d.State == Ready || d.State == Merged) {
|
||||
// The merge judges the operator's Approve alone (novox/hq ADR 0661 §3): a verdict after it, a carry's, is shown and
|
||||
// moves nothing; a failure is said (failedAfterApprove).
|
||||
d.Check = v
|
||||
h.Logf("[mesh-delivery] %s: its mesh/check is %s — %s (after the operator's Approve)", d.ID, state, oneLine(clip(desc, 120)))
|
||||
h.failedAfterApprove(d, v)
|
||||
h.keep(d)
|
||||
return
|
||||
}
|
||||
if d.State != Proposed {
|
||||
h.keep(d)
|
||||
return // past its verdict: the merge reads the forge's status itself
|
||||
}
|
||||
d.Check, d.AskedAgain, d.CheckAsk = v, nil, nil
|
||||
h.causeChanged(d)
|
||||
if why := d.outOfDate(); why != "" {
|
||||
h.Logf("[mesh-delivery] %s: its mesh/check is %s; the checker checks it again", d.ID, why)
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s: its mesh/check is %s — %s", d.ID, state, oneLine(clip(desc, 120)))
|
||||
h.settleAndKeep(d)
|
||||
}
|
||||
|
||||
// headsOf are the heads the checker judges together for an ask: the members the ask covers.
|
||||
func headsOf(members []*Delivery) []mdc.Head {
|
||||
out := make([]mdc.Head, 0, len(members))
|
||||
for _, m := range members {
|
||||
out = append(out, mdc.Head{Repository: strings.ToLower(m.Repository), Number: m.Number, Head: strings.ToLower(m.Commit)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkerAt is the checker's verdict for exactly these members' heads, and their hash.
|
||||
func (h *Holder) checkerAt(members []*Delivery) (checkerVerdict, string, bool) {
|
||||
hash, err := mdc.HeadsHash(headsOf(members))
|
||||
if err != nil {
|
||||
return checkerVerdict{}, "", false
|
||||
}
|
||||
v, ok := h.checker[hash]
|
||||
return v, hash, ok
|
||||
}
|
||||
|
||||
// checkerWaits is why the Approve of a delivery on mesh/check is not asked yet: the checker's verdict for the heads the ask
|
||||
// covers is not held, or did not pass. Empty when it passed, for a delivery not judged by mesh/check, and once an ask
|
||||
// covers the head (the merge then reads the forge's statuses itself). Under the lock.
|
||||
func (h *Holder) checkerWaits(d *Delivery) string {
|
||||
if !d.judgedByCheck() || h.covering(d) != nil {
|
||||
return ""
|
||||
}
|
||||
members := h.askMembers(d)
|
||||
for _, m := range members {
|
||||
if m.CheckState != "" && m.CheckState != "success" {
|
||||
return fmt.Sprintf("the mesh/check of %s is %s at its head %s", m.ID, m.CheckState, shortOf(m.Commit, 12))
|
||||
}
|
||||
}
|
||||
v, hash, ok := h.checkerAt(members)
|
||||
what := "its head"
|
||||
if len(members) > 1 {
|
||||
what = fmt.Sprintf("its group's %d heads", len(members))
|
||||
}
|
||||
switch {
|
||||
case !ok:
|
||||
return fmt.Sprintf("the checker's verdict at %s (heads %s) is not held yet", what, hash)
|
||||
case v.State == checkerRunning:
|
||||
return fmt.Sprintf("the checker's verdict at %s (heads %s) is running", what, hash)
|
||||
case v.State != checkerPass:
|
||||
return fmt.Sprintf("the checker's verdict at %s (heads %s) is %s", what, hash, v.State)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// oneStatusGroup is whether a group is judged by mesh/check: one of its members is.
|
||||
func oneStatusGroup(members []*Delivery) bool {
|
||||
return slices.ContainsFunc(members, (*Delivery).judgedByCheck)
|
||||
}
|
||||
|
||||
// composeFromChecker makes a group's verdict on mesh/check from the checker's one verdict at its members' heads (ADR 0447
|
||||
// §2): no composed check is asked. It is kept in the group's check as a composed verdict was, so the group's state, its
|
||||
// Approve and its merges read it unchanged: running or not held is no verdict yet, a pass or a failure is the group's, and
|
||||
// a member whose trunk moved past what it judged puts it out of date. Under the lock.
|
||||
func (h *Holder) composeFromChecker(g *Group, members []*Delivery, now time.Time) {
|
||||
live := make([]*Delivery, 0, len(members))
|
||||
for _, d := range members {
|
||||
if d.MergedAs == "" && !d.State.Final() {
|
||||
live = append(live, d)
|
||||
}
|
||||
}
|
||||
if len(live) == 0 || (g.Closed && g.Check != nil && g.Check.Verdict != "") {
|
||||
return // every member on the trunk, or a closed group: the verdict its Approve was asked on stands
|
||||
}
|
||||
v, hash, ok := h.checkerAt(live)
|
||||
c := &GroupCheck{Asked: "mesh/check " + hash, At: now, Commits: commitsOf(members)}
|
||||
if g.Check != nil && g.Check.Asked == c.Asked && sameMembers(g.Check.Commits, c.Commits) {
|
||||
c.At = g.Check.At
|
||||
}
|
||||
if ok && v.State != checkerRunning {
|
||||
c.Verdict = v.State
|
||||
c.Summary = "the checker's verdict at the members' heads (" + hash + ")"
|
||||
if w := firstOf(v.Failed.Test, v.Failed.Machine, v.Failed.Words); w != "" {
|
||||
c.Summary += ": " + w
|
||||
}
|
||||
c.Trunks = map[string]string{}
|
||||
ids := make([]string, 0, len(live))
|
||||
for _, d := range live {
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
judged := v.Trunk[strings.ToLower(d.Repository)]
|
||||
if judged == "" {
|
||||
continue
|
||||
}
|
||||
c.Trunks[d.ID] = judged
|
||||
if c.OutOfDate == "" && d.TrunkNow != "" && !sameCommit(judged, d.TrunkNow) {
|
||||
c.OutOfDate = fmt.Sprintf("the checker's verdict is out of date: %s was judged on trunk %s, the trunk is now %s",
|
||||
d.ID, shortOf(judged, 12), shortOf(d.TrunkNow, 12))
|
||||
c.OutOfDateAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.Check != nil && g.Check.Asked == c.Asked && g.Check.Verdict == c.Verdict && g.Check.OutOfDate == c.OutOfDate &&
|
||||
sameMembers(g.Check.Commits, c.Commits) {
|
||||
return
|
||||
}
|
||||
g.Check, g.Updated = c, now
|
||||
h.keepGroup(g)
|
||||
if c.Verdict != "" {
|
||||
for _, d := range members {
|
||||
h.causeChanged(d) // a composed check that differs from the last: what a refusal waited on changed (issue 1785)
|
||||
}
|
||||
h.Logf("[mesh-delivery] group %s: %s — %s", g.ID, c.Verdict, c.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// checkerRefusal is why a delivery on mesh/check is asked nothing of the controller: the checker checks it.
|
||||
func checkerRefusal(id string) error {
|
||||
return fmt.Errorf("%s is judged by mesh/check, the checker's: the checker checks its head again on every move of the head, "+
|
||||
"its trunk or its group, and nothing is asked of it here (novox/hq ADR 0447). Read where its check stands with the "+
|
||||
"checker's checker_pull tool", id)
|
||||
}
|
||||
|
||||
// The states of the check at an ask's heads, as the ask says them (issue 1733).
|
||||
const (
|
||||
checkSaidPass = "pass"
|
||||
checkSaidRunning = "running"
|
||||
checkSaidNone = "none"
|
||||
checkSaidFail = "fail"
|
||||
)
|
||||
|
||||
// checkSaid is the state of the check at the heads an ask covers, and its words for the ask: what the checker's verdict at
|
||||
// exactly those heads says, never what the gate would like it to say (issue 1733). Key is one of the checkSaid states.
|
||||
type checkSaid struct {
|
||||
Key string
|
||||
Words string
|
||||
}
|
||||
|
||||
// passed is whether the check at the heads passed.
|
||||
func (c checkSaid) passed() bool { return c.Key == checkSaidPass }
|
||||
|
||||
// checkAt reads the check at these members' heads: the checker's verdict for exactly those heads (its verdicts bucket,
|
||||
// keyed by their hash), and, while none is held, the forge's mesh/check at a member's head (a failure or a pending one
|
||||
// there). "passed its check" is said only for a passing verdict at these heads. Under the lock.
|
||||
func (h *Holder) checkAt(members []*Delivery) checkSaid {
|
||||
v, hash, ok := h.checkerAt(members)
|
||||
if ok {
|
||||
switch v.State {
|
||||
case checkerPass:
|
||||
return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v)}
|
||||
case checkerRunning:
|
||||
words := "its check has no verdict yet: it is running"
|
||||
if since, held := h.runningSince[hash]; held && !since.IsZero() {
|
||||
words += " (since " + clockSaid(since) + ")"
|
||||
}
|
||||
return checkSaid{Key: checkSaidRunning, Words: words}
|
||||
default:
|
||||
why := firstOf(oneLine(v.Failed.Test), oneLine(v.Failed.Words), v.State)
|
||||
if v.Failed.Test != "" && v.Failed.Words != "" {
|
||||
why = oneLine(v.Failed.Test) + ": " + oneLine(v.Failed.Words)
|
||||
}
|
||||
return checkSaid{Key: checkSaidFail, Words: "its check failed: " + clip(why, 120)}
|
||||
}
|
||||
}
|
||||
for _, m := range members {
|
||||
switch m.CheckState {
|
||||
case "failure", "error":
|
||||
why := "mesh/check is " + m.CheckState + " at " + shortOf(m.Commit, 12)
|
||||
if m.Check != nil && m.Check.Summary != "" {
|
||||
why = oneLine(m.Check.Summary)
|
||||
}
|
||||
return checkSaid{Key: checkSaidFail, Words: "its check failed: " + clip(why, 120)}
|
||||
}
|
||||
}
|
||||
succeeded := 0
|
||||
for _, m := range members {
|
||||
switch m.CheckState {
|
||||
case "pending":
|
||||
return checkSaid{Key: checkSaidRunning, Words: "its check has no verdict yet: it is running"}
|
||||
case "success":
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case succeeded > 0 && len(members) == 1:
|
||||
return checkSaid{Key: checkSaidPass, Words: "passed its check"} // its head's mesh/check is the checker's verdict at it
|
||||
case succeeded == len(members) && succeeded > 0:
|
||||
return checkSaid{Key: checkSaidNone, Words: "its members' own checks passed; the check of their heads together has not answered"}
|
||||
}
|
||||
return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"}
|
||||
}
|
||||
|
||||
// notComposed is what a passing verdict adds when its composition was not judged (checker.CompositionNotRun).
|
||||
// Empty otherwise.
|
||||
func notComposed(v checkerVerdict) string {
|
||||
if v.Composition == nil || v.Composition.State != checker.CompositionNotRun {
|
||||
return ""
|
||||
}
|
||||
why := firstOf(oneLine(v.Composition.Words), "it was not run")
|
||||
return ", but its composition was not judged (" + why + ")"
|
||||
}
|
||||
|
||||
// operatorZone is the zone the operator reads times in, as the messenger says them (its time-zone setting).
|
||||
var operatorZone = func() *time.Location {
|
||||
if loc, err := time.LoadLocation("Europe/Brussels"); err == nil {
|
||||
return loc
|
||||
}
|
||||
return time.UTC
|
||||
}()
|
||||
|
||||
// clockSaid is a time of day as the operator reads it: in their zone, named ("00:35 CET").
|
||||
func clockSaid(t time.Time) string { return t.In(operatorZone).Format("15:04 MST") }
|
||||
@@ -1,79 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// **Issue 1302's acceptance over a real bus** (ADR 0447, 1302-S10): a pull request into a main that requires mesh/check goes
|
||||
// ready on its mesh/check alone — the forge's status event and the checker's verdict in its verdicts bucket — has its Approve
|
||||
// asked, and on the Approve its merge is handed to the bridge; nothing is asked of the controller (no delivery-check, no
|
||||
// forget-checks) and no controller verdict is heard at all.
|
||||
func TestADeliveryOnMeshCheckGoesReadyAskedAndMergedOverARealBus(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.untilListening()
|
||||
|
||||
const head = "2222222222222222222222222222222222222222"
|
||||
const trunk = "3333333333333333333333333333333333333333"
|
||||
rt.mu.Lock()
|
||||
rt.openPulls = []OpenPull{{Owner: "novox", Repo: "app", Number: 8, Base: "main", HeadSHA: head, Required: []string{ctxCheck},
|
||||
Statuses: []string{}, Checks: map[string]HeadCheck{}}}
|
||||
rt.mu.Unlock()
|
||||
announced := pr("novox/app", 8, head, "fix/y", "src/b.go")
|
||||
b.publish(t, "mesh.seat.git.event.pull-updated", seatUpdated(announced))
|
||||
|
||||
// The checker's verdict at the head, then the forge's word that its bridge set mesh/check.
|
||||
hash, err := mdc.HeadsHash([]mdc.Head{{Repository: "novox/app", Number: 8, Head: head}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verdict, _ := json.Marshal(map[string]any{"v": 1, "group": "fix/y", "heads": hash, "trunk": map[string]string{"novox/app": trunk},
|
||||
"state": "pass"})
|
||||
if _, err := b.kvs[checkerVerdicts].Put(ctx, "fix/y."+hash, verdict); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := "pass " + head[:12] + " on " + trunk[:12] + " | unit 3 run, 0 kept"
|
||||
rt.mu.Lock()
|
||||
rt.openPulls[0].Statuses = []string{ctxCheck}
|
||||
rt.openPulls[0].Checks = map[string]HeadCheck{ctxCheck: {State: "success", Description: line}}
|
||||
rt.mu.Unlock()
|
||||
set := b.publish(t, "mesh.seat.git.event.forge-status", gitseat.ForgeStatusEvent{Repository: "novox/app", Commit: head,
|
||||
Context: ctxCheck, State: "success", Description: line})
|
||||
|
||||
var key string
|
||||
rt.until("the ask to be written on mesh/check alone", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
|
||||
t.Logf("the ask was written %s after mesh/check was set", time.Since(set))
|
||||
b.answer(t, key, OptionApprove)
|
||||
var handed Merge
|
||||
rt.until("the merge to be handed to the bridge", 10*time.Second, func() bool { handed = mergeAt(b, "fix/y"); return len(handed.Pulls) > 0 })
|
||||
if len(handed.Pulls) != 1 || handed.Pulls[0].Head.Head != head || handed.Pulls[0].State != mdc.MergeMerging {
|
||||
t.Fatalf("the merge handed is %+v", handed)
|
||||
}
|
||||
rt.noControllerAsk()
|
||||
// No mesh/merge-gate is owed on the head.
|
||||
keys, _ := b.kvs["owed"].Keys(ctx)
|
||||
for _, k := range keys {
|
||||
if strings.Contains(k, "merge-gate") {
|
||||
t.Fatalf("a mesh/merge-gate was owed: %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// The tests of issue 1302 (ADR 0447 §2, §4; ADR 0441 §1; ADR 0421 §1, §3; lead decisions L6 and the gate's part of
|
||||
// 1318): on a base that requires mesh/check the gate reads that one status. A delivery is ready, its Approve asked and its
|
||||
// merge handed on when mesh/check passed at its head and the checker's verdict covers every member's head as they are; no
|
||||
// check is asked of the controller, no composed check is asked, and no mesh/merge-gate is owed.
|
||||
|
||||
const (
|
||||
oneRepo, oneRepo2 = "novox/mesh-sdk", "novox/mesh-delivery"
|
||||
oneHead, oneHead2 = "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6"
|
||||
oneTrunk = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||
oneTrunk2 = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"
|
||||
)
|
||||
|
||||
// requiresCheck makes the forge say each pull request's base requires mesh/check alone, as gitea_open_pulls answers.
|
||||
func requiresCheck(w *world, pulls ...PullEvent) {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
w.forge.open = nil
|
||||
for _, p := range pulls {
|
||||
w.forge.open = append(w.forge.open, OpenPull{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: "main",
|
||||
HeadSHA: p.HeadSHA, Required: []string{ctxCheck}, Statuses: []string{}, Checks: map[string]HeadCheck{}})
|
||||
}
|
||||
}
|
||||
|
||||
// checkStatus is the forge's word that mesh/check was set on a head, with its line as the checker writes it.
|
||||
func checkStatus(repository, head, state, trunk string) ForgeStatusEvent {
|
||||
owner, repo, _ := strings.Cut(repository, "/")
|
||||
words := map[string]string{"success": "pass", "pending": "checking", "failure": "failed: a test"}[state]
|
||||
return ForgeStatusEvent{Owner: owner, Repo: repo, SHA: head, Context: ctxCheck, State: state,
|
||||
Description: words + " " + head[:12] + " on " + trunk[:12] + " | unit 4 run, 0 kept"}
|
||||
}
|
||||
|
||||
// putCheckerVerdict puts the checker's verdict for these heads in the gate's view of the checker's verdicts bucket.
|
||||
func putCheckerVerdict(w *world, state string, heads ...mdc.Head) {
|
||||
w.t.Helper()
|
||||
hash, err := mdc.HeadsHash(heads)
|
||||
if err != nil {
|
||||
w.t.Fatal(err)
|
||||
}
|
||||
trunk := map[string]string{}
|
||||
for _, h := range heads {
|
||||
trunk[h.Repository] = oneTrunk
|
||||
}
|
||||
value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "trunk": trunk, "state": state})
|
||||
w.h.CheckerVerdictChanged("g."+hash, value)
|
||||
}
|
||||
|
||||
func headOf(repository string, number int, head string) mdc.Head {
|
||||
return mdc.Head{Repository: repository, Number: number, Head: head}
|
||||
}
|
||||
|
||||
func mergeGateOwed(w *world) []string {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
var out []string
|
||||
for k := range w.forge.statuses {
|
||||
if strings.HasSuffix(k, " mesh/merge-gate") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1302 acceptance: the gate's table moves a lone delivery to ready on mesh/check alone, asks its Approve and merges it,
|
||||
// with no check asked of the controller and no mesh/merge-gate written.
|
||||
func TestALoneDeliveryGoesReadyAskedAndMergedOnMeshCheckAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr(oneRepo, 1, oneHead, "feat/one")
|
||||
requiresCheck(w, p)
|
||||
w.h.PullUpdated(p)
|
||||
w.settleAll()
|
||||
id := IDOf(oneRepo, oneHead)
|
||||
if s := w.state(id); s != Proposed {
|
||||
t.Fatalf("before mesh/check it is %s, want proposed", s)
|
||||
}
|
||||
putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead))
|
||||
w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk))
|
||||
w.settleAll()
|
||||
if s := w.state(id); s == Proposed || s == Rejected {
|
||||
t.Fatalf("after mesh/check passed it is %s (%s), want ready or on", s, w.logs())
|
||||
}
|
||||
if d := w.delivery(id); d.Check == nil || d.Check.Status != ctxCheck || d.Check.Trunk != oneTrunk[:12] || d.Check.Head != oneHead[:12] {
|
||||
t.Fatalf("the verdict is not mesh/check's: %+v", d.Check)
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("its Approve was asked %d times, want once", w.op.count())
|
||||
}
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-sdk#1") {
|
||||
t.Fatalf("merges %v, want mesh-sdk#1", w.forge.merges)
|
||||
}
|
||||
if got := mergeGateOwed(w); len(got) != 0 {
|
||||
t.Fatalf("mesh/merge-gate was written: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1302 (lead: the verdict read must be the group's): mesh/check passing on a head is not enough while the checker's
|
||||
// verdict for exactly the delivery's heads is not held: the Approve waits for it, and says so.
|
||||
func TestTheApproveWaitsForTheCheckersVerdictAtTheDeliverysHeads(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr(oneRepo, 1, oneHead, "feat/one")
|
||||
requiresCheck(w, p)
|
||||
w.h.PullUpdated(p)
|
||||
w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk))
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the Approve was asked with no checker verdict at the head")
|
||||
}
|
||||
if d := w.delivery(IDOf(oneRepo, oneHead)); d.Ready == nil || !strings.Contains(d.Ready.Waits, "checker") {
|
||||
t.Fatalf("it does not say it waits for the checker's verdict: %+v", d.Ready)
|
||||
}
|
||||
putCheckerVerdict(w, "running", headOf(oneRepo, 1, oneHead))
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the Approve was asked on a running verdict")
|
||||
}
|
||||
putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead))
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("the Approve was asked %d times after the checker's pass, want once", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// 1302 acceptance (ADR 0447 §2, ADR 0441 §1): a group on mesh/check is asked once when every member's mesh/check passed
|
||||
// and the checker's verdict names every member's head; a member's mesh/check that changes withdraws the ask. No composed
|
||||
// check is asked of the controller and no member is owed a mesh/merge-gate.
|
||||
func TestAGroupOnMeshCheckIsAskedOnTheCheckersOneVerdictAndWithdrawnWhenAMemberChanges(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
a, b := pr(oneRepo, 1, oneHead, "feat/two"), pr(oneRepo2, 2, oneHead2, "feat/two")
|
||||
requiresCheck(w, a, b)
|
||||
w.h.PullUpdated(a)
|
||||
w.h.PullUpdated(b)
|
||||
w.h.wantForge() // as the event's handler does for a head not seen before (main.go)
|
||||
w.settleAll()
|
||||
// One member's verdict alone, of a check that judged it by itself: not the group's.
|
||||
putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead))
|
||||
w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk))
|
||||
w.h.CommitStatus(checkStatus(oneRepo2, oneHead2, "success", oneTrunk))
|
||||
w.settleAll()
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("the group was asked on a verdict that does not name every member's head")
|
||||
}
|
||||
putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead), headOf(oneRepo2, 2, oneHead2))
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || len(w.op.open()) != 1 {
|
||||
t.Fatalf("the group was asked %d times (open %v), want once", w.op.count(), w.op.open())
|
||||
}
|
||||
if got := mergeGateOwed(w); len(got) != 0 {
|
||||
t.Fatalf("mesh/merge-gate was owed: %v", got)
|
||||
}
|
||||
w.h.CommitStatus(checkStatus(oneRepo2, oneHead2, "pending", oneTrunk2))
|
||||
w.settleAll()
|
||||
if len(w.op.open()) != 0 {
|
||||
t.Fatalf("the group's ask stands though a member's mesh/check changed: %v", w.op.open())
|
||||
}
|
||||
}
|
||||
|
||||
// 1302: a failing mesh/check rejects the delivery; nothing is asked.
|
||||
func TestAFailingMeshCheckRejects(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr(oneRepo, 1, oneHead, "feat/one")
|
||||
requiresCheck(w, p)
|
||||
w.h.PullUpdated(p)
|
||||
w.h.CommitStatus(checkStatus(oneRepo, oneHead, "failure", oneTrunk))
|
||||
w.settleAll()
|
||||
if s := w.state(IDOf(oneRepo, oneHead)); s != Rejected {
|
||||
t.Fatalf("it is %s, want rejected", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1318 (the gate's part): a pull request closed unmerged asks the controller to forget nothing: forget-checks is not called.
|
||||
func TestAClosedPullRequestAsksNoForgetChecks(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr(oneRepo, 1, oneHead, "feat/one")
|
||||
w.h.PullUpdated(p)
|
||||
w.h.PullClosed(p)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// fakeOperator is the gate's bucket asks and the teller behind it: it keeps the asks written, and answers as the test says
|
||||
// (the teller's answer under the ask's key). In auto mode it approves every ask the moment it is read (the tests of other
|
||||
// behaviour, which are not about the Approve). An ask is known here by its key (meshdeliveryclient MergeAskKey), as the
|
||||
// delivery keeps it.
|
||||
type fakeOperator struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
auto bool
|
||||
asked []oc.Ask
|
||||
byKey map[string]Ask
|
||||
order []string
|
||||
seen map[string]bool
|
||||
cancelled []string
|
||||
state map[string]string
|
||||
answers map[string]*Answer
|
||||
refuse error
|
||||
}
|
||||
|
||||
func newFakeOperator(now func() time.Time) *fakeOperator {
|
||||
return &fakeOperator{now: now, byKey: map[string]Ask{}, seen: map[string]bool{}, state: map[string]string{}, answers: map[string]*Answer{}}
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Ask(key string, a Ask) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.refuse != nil {
|
||||
return o.refuse
|
||||
}
|
||||
ask, err := askOf(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := occ.Check(ask, o.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := EncodeAsk(a); err != nil {
|
||||
return err
|
||||
}
|
||||
if o.state[key] == "open" && SameAsk(o.byKey[key], a) {
|
||||
return nil // the same ask is there, as kvOperator leaves it
|
||||
}
|
||||
o.asked = append(o.asked, ask)
|
||||
o.byKey[key] = a
|
||||
if _, known := o.seen[key]; !known {
|
||||
o.order = append(o.order, key)
|
||||
o.seen[key] = true
|
||||
}
|
||||
o.state[key] = "open"
|
||||
delete(o.answers, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Cancel(key string) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.cancelled = append(o.cancelled, key)
|
||||
o.state[key] = "cancelled"
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *fakeOperator) Where(key string) (*Answer, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.auto && o.state[key] == "open" {
|
||||
o.answerLocked(key, OptionApprove)
|
||||
}
|
||||
return o.answers[key], nil
|
||||
}
|
||||
|
||||
// ask is the router's ask at a key, as the gate composed it.
|
||||
func (o *fakeOperator) ask(key string) oc.Ask {
|
||||
if a, ok := o.byKey[key]; ok {
|
||||
ask, err := askOf(a)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ask
|
||||
}
|
||||
panic("no ask " + key)
|
||||
}
|
||||
|
||||
func (o *fakeOperator) answerLocked(key, option string) {
|
||||
a := o.ask(key)
|
||||
opt, _ := occ.OptionOf(a, option)
|
||||
o.state[key] = "answered"
|
||||
o.answers[key] = &Answer{Option: option, Warrant: fmt.Sprintf("%s@%s", a.ID, o.now().UTC().Format(time.RFC3339)),
|
||||
Words: "the operator, via telegram, chose " + opt.Label}
|
||||
}
|
||||
|
||||
// answer is the operator's choice, proven to the teller by the router and written by it.
|
||||
func (o *fakeOperator) answer(key, option string) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
o.answerLocked(key, option)
|
||||
}
|
||||
|
||||
// open is the keys of the asks waiting for an answer, in the order asked.
|
||||
func (o *fakeOperator) open() []string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
var out []string
|
||||
for _, k := range o.order {
|
||||
if o.state[k] == "open" {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (o *fakeOperator) count() int { o.mu.Lock(); defer o.mu.Unlock(); return len(o.asked) }
|
||||
@@ -2,10 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
@@ -13,16 +11,12 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// The gate writes nothing to the forge: each act a transition owes it — a status, the view on the pull request, a line of
|
||||
// the commit's note, a comment — is said as the event owed of the seat mesh-delivery, on
|
||||
// The gate writes nothing to the forge: each act it owes it — the delivery's view on the pull request, a comment, a line of
|
||||
// the commit's note — is said as the event owed of the seat mesh-delivery, on
|
||||
// `mesh.seat.mesh-delivery.event.owed.<repository>.<number>`, under one id, which the forge bridge performs through the
|
||||
// seat git and answers as owed.ended (design 47 *What the gate owes the forge*). The act stays on its delivery's or group's
|
||||
// state, already on the bus, until its owed.ended is heard; at the gate's start every act still unanswered is said again
|
||||
// under the same id, and the forge holds it once. The number is 0 for a commit no pull request names; a comment's act
|
||||
// carries a sequence number, so two comments on one head are two acts.
|
||||
|
||||
// noPage is the target of a status whose delivery has no page to point at.
|
||||
const noPage = "none"
|
||||
// seat git and answers as owed.ended (design 47 *What the gate owes the forge*, §167). The act is kept in its delivery record's
|
||||
// acts, with when it was said, before it is said, and stays there until its owed.ended is heard (A-35). A comment's act carries a
|
||||
// sequence number, so two comments on one head are two acts.
|
||||
|
||||
// notesRef is the notes ref the commit's note is kept under.
|
||||
const notesRef = "mesh-plan"
|
||||
@@ -40,40 +34,33 @@ func (s seatOwed) Say(o seat.Owed) error {
|
||||
}
|
||||
|
||||
// actID is the id of an act owed: its key, when it was first owed and a digest of what it says, so it is the same each time
|
||||
// it is said again and two acts of one key (a status set twice, two lines of the note in one moment) are two ids. It holds
|
||||
// an @, which a switched act's id never holds.
|
||||
// it is said again and two acts of one key are two ids. It holds an @, which a switched act's id never holds.
|
||||
func actID(key string, since time.Time, says string) string {
|
||||
d := fnv.New32a()
|
||||
_, _ = d.Write([]byte(says))
|
||||
return key + "@" + strconv.FormatInt(since.UnixNano(), 10) + "." + strconv.FormatUint(uint64(d.Sum32()), 16)
|
||||
}
|
||||
|
||||
// actSays is what an effect says, as its id's digest reads it.
|
||||
func actSays(e Effect) string { return e.Kind + "\n" + e.Context + "\n" + e.State + "\n" + e.Line }
|
||||
|
||||
// owe says one owed act under its id. An act that has no key (a name the key refuses, a status with no page to point at)
|
||||
// is said as refused and dropped (an empty id), since saying it again would fail the same.
|
||||
func (h *Holder) owe(repository string, number int, head, act string, o Owed, since time.Time, says, want string) (id string, err error) {
|
||||
if h.Owed == nil {
|
||||
return "", errors.New("no seat to say owed on")
|
||||
// owe keeps an act in the record, with when it was said, and then says it. An act the record holds already is said again under
|
||||
// its id and its first said time, never kept twice.
|
||||
func (h *Holder) owe(r *Record, o seat.Owed) error {
|
||||
if h.Owed == nil || h.Records == nil {
|
||||
return errors.New("no seat to say owed on")
|
||||
}
|
||||
key, err := mdc.OwedKey(repository, number, head, act)
|
||||
if err == nil {
|
||||
o.Act = actKind(act)
|
||||
_, err = mdc.EncodeOwed(o)
|
||||
now := h.Now().UTC()
|
||||
if had, ok := r.Acts[o.ID]; ok && had.Owed.Said != nil {
|
||||
o.Said = had.Owed.Said
|
||||
} else {
|
||||
o.Said = &now
|
||||
}
|
||||
if err != nil {
|
||||
h.Refuse(fmt.Errorf("the act %q on %s#%d at %s cannot be owed and is dropped: %v", act, repository, number, shortOf(head, 12), err))
|
||||
return "", nil
|
||||
if err := h.Records.Act(r, RecordAct{ID: o.ID, Act: o.Act, Repository: o.Repository, Number: int(o.Number), Head: o.Head,
|
||||
At: *o.Said, Owed: o}); err != nil {
|
||||
return err
|
||||
}
|
||||
if id = actID(key, since, says); want != "" {
|
||||
id = want
|
||||
}
|
||||
said := since.UTC()
|
||||
return id, h.Owed.Say(seatOwedOf(o, id, repository, number, head, &said))
|
||||
return h.Owed.Say(o)
|
||||
}
|
||||
|
||||
// seatOwedOf is an act in the gate's working form as the seat's event owed says it.
|
||||
// seatOwedOf is an act in the old form (the state owed's value) as the seat's event owed says it.
|
||||
func seatOwedOf(o Owed, id, repository string, number int, head string, said *time.Time) seat.Owed {
|
||||
out := seat.Owed{ID: id, Repository: repository, Number: int64(number), Head: head, Said: said, Text: o.Text}
|
||||
switch o.Act {
|
||||
@@ -89,73 +76,3 @@ func seatOwedOf(o Owed, id, repository string, number int, head string, said *ti
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// owedSaid is an effect said as owed, as do says it: the act its id names, with its repository, number and head (for a
|
||||
// group's status, the member's its Last names). False for an effect that is not said as owed, or has no id yet.
|
||||
func owedSaid(e Effect, repository string, number int, head, body, url string, group bool) (seat.Owed, bool) {
|
||||
if e.ID == "" || e.Kind == EffectEmit || e.Kind == EffectView && number == 0 {
|
||||
return seat.Owed{}, false
|
||||
}
|
||||
page := url
|
||||
if group && e.Kind == EffectStatus {
|
||||
repository, number, page = memberOfLast(e.Last)
|
||||
head = e.Commit
|
||||
}
|
||||
act, value, num, hd := owedOf(e, repository, number, head, body, page)
|
||||
if act == "" {
|
||||
return seat.Owed{}, false
|
||||
}
|
||||
value.Act = actKind(act)
|
||||
return seatOwedOf(value, e.ID, repository, num, hd, e.Said), true
|
||||
}
|
||||
|
||||
// actKind is the kind of an owed act: `status=<context>` is a status, `comment.<seq>` a comment.
|
||||
func actKind(act string) string {
|
||||
kind, _, _ := mdc.ParseOwedAct(act)
|
||||
return kind
|
||||
}
|
||||
|
||||
// statusAct is the act of a status of a context.
|
||||
func statusAct(context string) string { return mdc.OwedStatus + "=" + context }
|
||||
|
||||
// commentAct is the act of the seq-th comment on a head.
|
||||
func commentAct(seq int) string {
|
||||
act, _ := mdc.OwedCommentAct(seq)
|
||||
return act
|
||||
}
|
||||
|
||||
// owedOf is an effect as the act its key names and its value: the repository and number are the delivery's (or, for a
|
||||
// group's status, the member's the effect names in Last), the body the view's.
|
||||
func owedOf(e Effect, repository string, number int, head, body, page string) (act string, value Owed, num int, hd string) {
|
||||
num, hd = number, head
|
||||
switch e.Kind {
|
||||
case EffectNote:
|
||||
hd = e.Commit
|
||||
if e.Commit != head {
|
||||
num = 0 // the merge commit is named by no pull request
|
||||
}
|
||||
return mdc.OwedNote, Owed{Text: e.Line, Ref: notesRef}, num, hd
|
||||
case EffectView:
|
||||
return mdc.OwedView, Owed{Text: body}, num, hd
|
||||
case EffectStatus:
|
||||
// The owed status must name where it points; a delivery with no pull request page points at none, which the forge's
|
||||
// tool leaves out (it links only an http or https address).
|
||||
if page == "" {
|
||||
page = noPage
|
||||
}
|
||||
return statusAct(e.Context), Owed{Context: e.Context, State: e.State, Text: e.Line, Target: page}, num, e.Commit
|
||||
case EffectComment:
|
||||
seq, _ := strconv.Atoi(e.Context)
|
||||
return commentAct(seq), Owed{Text: e.Line}, num, hd
|
||||
}
|
||||
return "", Owed{}, 0, ""
|
||||
}
|
||||
|
||||
// memberOfLast reads the repository, pull request number and page a group's status effect names in Last
|
||||
// (`owner/repo#N page`).
|
||||
func memberOfLast(last string) (repository string, number int, page string) {
|
||||
where, page, _ := strings.Cut(last, " ")
|
||||
repository, n, _ := strings.Cut(where, "#")
|
||||
number, _ = strconv.Atoi(n)
|
||||
return repository, number, page
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// W11.17 (design 47 *What the gate owes the forge*): every act owed is said as the seat's event owed under one id and stays
|
||||
// on its delivery's state until its owed.ended is heard; at the owner's start every unanswered act is said again under the
|
||||
// same id; an outcome is written into the record holding the pull request before the act is answered, once; an outcome
|
||||
// other than performed is said; the checker's outcomes are not the gate's.
|
||||
func TestAnActOwedStaysUntilItsOutcomeIsRecorded(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.owed.silent = true
|
||||
l := newLedgerFake(t)
|
||||
id := IDOf("novox/app", head)
|
||||
w.h.Records = &Records{Ledger: l, Now: w.h.Now}
|
||||
if _, _, err := w.h.Records.Open("feat/x", map[string]string{id: head}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
held := map[string]bool{}
|
||||
for _, e := range w.delivery(id).Owed {
|
||||
if e.Said != nil && e.ID != "" {
|
||||
held[e.ID] = true
|
||||
}
|
||||
}
|
||||
if len(held) < 3 {
|
||||
t.Fatalf("the acts said and held on the delivery: %v; its owed %+v", held, w.delivery(id).Owed)
|
||||
}
|
||||
saidOnce := map[string]int{}
|
||||
for _, o := range w.owed.said {
|
||||
saidOnce[o.ID]++
|
||||
if !held[o.ID] && strings.Contains(o.ID, ".view@") {
|
||||
t.Errorf("the view %s was said but is not held", o.ID)
|
||||
}
|
||||
}
|
||||
for heldID := range held {
|
||||
if saidOnce[heldID] != 1 {
|
||||
t.Errorf("%s was said %d times before any answer, want once", heldID, saidOnce[heldID])
|
||||
}
|
||||
}
|
||||
|
||||
// Unanswered, it is stalled only past 15 minutes, naming the oldest act.
|
||||
if lines := stalledOf(w, id); len(lines) != 0 {
|
||||
t.Fatalf("stalled before its bound: %+v", lines)
|
||||
}
|
||||
w.later(16 * time.Minute)
|
||||
if lines := stalledOf(w, id); len(lines) != 1 || !strings.Contains(lines[0].Says, "has no outcome from the forge bridge") ||
|
||||
lines[0].Bound != "15m0s" {
|
||||
t.Fatalf("an act unanswered past 15 minutes is not stalled: %+v", lines)
|
||||
}
|
||||
|
||||
// The owner starts again over the same state: every unanswered act is said again, under the same id.
|
||||
w2 := newWorldOver(t, w.store, w.forge, w.now)
|
||||
w2.owed.silent = true
|
||||
w2.h.Records = &Records{Ledger: l, Now: w2.h.Now}
|
||||
w2.h.Flush()
|
||||
again := map[string]bool{}
|
||||
for _, o := range w2.owed.said {
|
||||
again[o.ID] = true
|
||||
}
|
||||
for heldID := range held {
|
||||
if !again[heldID] {
|
||||
t.Errorf("%s was not said again at the start; said %v", heldID, again)
|
||||
}
|
||||
}
|
||||
w2.h.Flush()
|
||||
if len(w2.owed.said) != len(again) {
|
||||
t.Errorf("a second flush said %d acts again, want none", len(w2.owed.said)-len(again))
|
||||
}
|
||||
|
||||
var one string
|
||||
for heldID := range held {
|
||||
one = heldID
|
||||
break
|
||||
}
|
||||
// The checker's outcome of an id is not the gate's: nothing is answered.
|
||||
checkers, _ := forgemsg.EncodeOwedEnded(forgemsg.OwedEnded{Of: "mesh-delivery-checker", ID: one, Outcome: forgemsg.OwedPerformed, At: w.now})
|
||||
if err := w2.h.OwedEnded(checkers); err != nil || !owedHolds(w2, id, one) {
|
||||
t.Fatalf("the checker's outcome answered the gate's act: %v", err)
|
||||
}
|
||||
// The ledger away: the act is not answered, and the event is heard again.
|
||||
l.failOn = OpAct
|
||||
if err := w2.h.OwedEnded(owedEndedBody(one, forgemsg.OwedRefused, "the branch is protected")); err == nil || !owedHolds(w2, id, one) {
|
||||
t.Fatalf("an outcome the record could not take answered the act: %v", err)
|
||||
}
|
||||
l.failOn = ""
|
||||
if err := w2.h.OwedEnded(owedEndedBody(one, forgemsg.OwedRefused, "the branch is protected")); err != nil || owedHolds(w2, id, one) {
|
||||
t.Fatalf("the refused outcome did not answer the act: %v", err)
|
||||
}
|
||||
records, _ := w2.h.Records.Current()
|
||||
if a := records[0].Acts[one]; a.Outcome != forgemsg.OwedRefused || a.Words != "the branch is protected" || a.Head != head || a.Number != 7 {
|
||||
t.Fatalf("the record holds the act as %+v", a)
|
||||
}
|
||||
if !strings.Contains(w2.logs(), "was refused: the branch is protected") {
|
||||
t.Errorf("a refused act was not said: %s", w2.logs())
|
||||
}
|
||||
seq := records[0].Sequence
|
||||
if err := w2.h.OwedEnded(owedEndedBody(one, forgemsg.OwedRefused, "the branch is protected")); err != nil || records[0].Sequence != seq {
|
||||
t.Errorf("an outcome heard twice changed the record (%d, was %d): %v", records[0].Sequence, seq, err)
|
||||
}
|
||||
}
|
||||
|
||||
// stalledOf are the stalled lines naming a delivery that say an act has no outcome.
|
||||
func stalledOf(w *world, id string) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.ID == id && strings.Contains(l.Says, "owed to the forge") {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// owedHolds is whether the delivery still holds the act unanswered.
|
||||
func owedHolds(w *world, id, act string) bool {
|
||||
for _, e := range w.delivery(id).Owed {
|
||||
if e.ID == act {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
@@ -13,10 +12,10 @@ import (
|
||||
// the forge*): the outcome of one id, the gate's own or the checker's. Its owner is the one the manifest consumes it from.
|
||||
var owedEndedEvent = declared(forgemsg.OwedEndedEvent)
|
||||
|
||||
// OwedEnded takes the forge bridge's outcome of one act this owner said as owed. The outcome is written into the delivery
|
||||
// record that holds the pull request at that head, and only then is the act answered: taken off its delivery's or group's
|
||||
// state. An outcome heard twice, or of an id this owner holds no longer, changes nothing. An outcome other than performed
|
||||
// is said. An error leaves the event to be heard again: the record could not take it.
|
||||
// OwedEnded takes the forge bridge's outcome of one act this owner said as owed, and writes it into the delivery record that
|
||||
// holds the act (A-35): an act is answered only once its outcome is recorded. An outcome heard twice, or of an id no record
|
||||
// holds, changes nothing. An outcome other than performed is said. An error leaves the event to be heard again: the record
|
||||
// could not take it.
|
||||
func (h *Holder) OwedEnded(body []byte) error {
|
||||
e, err := forgemsg.DecodeOwedEnded(body)
|
||||
if err != nil {
|
||||
@@ -30,104 +29,31 @@ func (h *Holder) OwedEnded(body []byte) error {
|
||||
h.Refuse(fmt.Errorf("the forge bridge could not read an act this owner said: %s", e.Words))
|
||||
return nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
held, ok := h.heldAct(e.ID)
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if e.Outcome != forgemsg.OwedPerformed {
|
||||
h.Refuse(fmt.Errorf("%s: the %s owed on %s#%d at %s was %s: %s", held.owner, held.effect.Kind, held.repository,
|
||||
held.number, shortOf(held.head, 12), e.Outcome, e.Words))
|
||||
}
|
||||
if err := h.recordAct(held, e); err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.answer(e.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// heldAct is an act said and not yet answered, with where it is held. Under the lock.
|
||||
type heldAct struct {
|
||||
owner string // the delivery's or the group's id
|
||||
effect Effect
|
||||
repository, head string
|
||||
number int
|
||||
}
|
||||
|
||||
func (h *Holder) heldAct(id string) (heldAct, bool) {
|
||||
for did, d := range h.deliveries {
|
||||
for _, o := range d.Owed {
|
||||
if o.ID == id {
|
||||
head := d.Commit
|
||||
if o.Commit != "" {
|
||||
head = o.Commit
|
||||
}
|
||||
return heldAct{owner: did, effect: o, repository: d.Repository, head: head, number: d.Number}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for gid, g := range h.groups {
|
||||
for _, o := range g.Owed {
|
||||
if o.ID == id {
|
||||
repository, number, _ := memberOfLast(o.Last)
|
||||
return heldAct{owner: gid, effect: o, repository: repository, head: o.Commit, number: number}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return heldAct{}, false
|
||||
}
|
||||
|
||||
// answer takes an answered act off its delivery's or group's state. Under the lock.
|
||||
func (h *Holder) answer(id string) {
|
||||
drop := func(list []Effect) ([]Effect, bool) {
|
||||
for i, o := range list {
|
||||
if o.ID == id {
|
||||
return append(list[:i:i], list[i+1:]...), true
|
||||
}
|
||||
}
|
||||
return list, false
|
||||
}
|
||||
for _, d := range h.deliveries {
|
||||
var dropped bool
|
||||
if d.Owed, dropped = drop(d.Owed); dropped {
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
for _, g := range h.groups {
|
||||
var dropped bool
|
||||
if g.Owed, dropped = drop(g.Owed); dropped {
|
||||
h.keepGroup(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordAct writes an act's outcome into the delivery record that holds its pull request at its head; none holds it
|
||||
// before the Approve's merge is handed (switch.go), and then the outcome is on its delivery's transitions alone.
|
||||
func (h *Holder) recordAct(a heldAct, e forgemsg.OwedEnded) error {
|
||||
if h.Records == nil {
|
||||
return nil
|
||||
}
|
||||
current, err := h.Records.Current()
|
||||
kept, err := h.Records.Kept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := strings.ToLower(a.repository) + "@"
|
||||
for _, r := range current {
|
||||
for member, head := range r.Heads {
|
||||
if head == a.head && strings.HasPrefix(strings.ToLower(member), prefix) {
|
||||
at := e.At.UTC()
|
||||
return h.Records.Act(r, RecordAct{ID: e.ID, Act: a.effect.Kind, Repository: a.repository, Number: a.number,
|
||||
Head: a.head, Outcome: e.Outcome, Words: e.Words, At: at})
|
||||
}
|
||||
for _, r := range kept {
|
||||
held, ok := r.Acts[e.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if e.Outcome != forgemsg.OwedPerformed {
|
||||
h.Refuse(fmt.Errorf("%s: the %s owed on %s#%d at %s was %s: %s", r.Instance, held.Act, held.Repository, held.Number,
|
||||
shortOf(held.Head, 12), e.Outcome, e.Words))
|
||||
}
|
||||
held.Outcome, held.Words, held.At = e.Outcome, e.Words, e.At.UTC()
|
||||
return h.Records.Act(r, held)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordAct is one act the gate owed the forge for a pull request the record holds, with the forge bridge's outcome.
|
||||
// RecordAct is one act the gate owes the forge for a pull request a record holds: said, with when (At, while Outcome is empty)
|
||||
// and as the seat's event says it (Owed, which saying it again at the gate's start repeats), then ended with the forge
|
||||
// bridge's outcome and when (A-35, design 47 §165).
|
||||
type RecordAct struct {
|
||||
ID string `json:"id"`
|
||||
Act string `json:"act"`
|
||||
@@ -137,9 +63,10 @@ type RecordAct struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Words string `json:"words,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Owed seat.Owed `json:"owed"`
|
||||
}
|
||||
|
||||
// Act writes one act's outcome into the record, unless it holds that outcome already.
|
||||
// Act writes one act into the record: said (no outcome), or ended; nothing when the record holds it so already.
|
||||
func (rs *Records) Act(r *Record, a RecordAct) error {
|
||||
if had, ok := r.Acts[a.ID]; ok && had.Outcome == a.Outcome {
|
||||
return nil
|
||||
@@ -158,44 +85,3 @@ func (rs *Records) Act(r *Record, a RecordAct) error {
|
||||
// owedBound is how long an act said may wait for its owed.ended before it is stalled: many times a forge call's time, and
|
||||
// short beside the check's hour (design 47 *What the gate owes the forge*).
|
||||
const owedBound = 15 * time.Minute
|
||||
|
||||
// owedLines are the stalled lines of the acts said and unanswered past owedBound: one per delivery or group, naming its
|
||||
// oldest such act and how many more wait. Under the lock.
|
||||
func (h *Holder) owedLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
line := func(id string, state State, number int, owed []Effect) {
|
||||
var oldest *Effect
|
||||
more := 0
|
||||
for i, e := range owed {
|
||||
if e.Said == nil || now.Sub(*e.Said) < owedBound {
|
||||
continue
|
||||
}
|
||||
if oldest == nil || e.Said.Before(*oldest.Said) {
|
||||
if oldest != nil {
|
||||
more++
|
||||
}
|
||||
oldest = &owed[i]
|
||||
} else {
|
||||
more++
|
||||
}
|
||||
}
|
||||
if oldest == nil {
|
||||
return
|
||||
}
|
||||
says := fmt.Sprintf("the %s owed to the forge, said at %s as %s, has no outcome from the forge bridge", oldest.Kind,
|
||||
oldest.Said.UTC().Format(time.RFC3339), oldest.ID)
|
||||
if more > 0 {
|
||||
says += fmt.Sprintf(", nor have %d more", more)
|
||||
}
|
||||
out = append(out, StalledLine{ID: id, Number: number, State: state, For: now.Sub(*oldest.Said).Round(time.Second).String(),
|
||||
Bound: owedBound.String(), H2: "none: the forge bridge's bridge_owed reads what it performed — the operator's",
|
||||
Says: says + "; it is said again at the gate's start"})
|
||||
}
|
||||
for id, d := range h.deliveries {
|
||||
line(id, d.State, d.Number, d.Owed)
|
||||
}
|
||||
for id, g := range h.groups {
|
||||
line(id, "", 0, g.Owed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/trunk"
|
||||
)
|
||||
|
||||
// What the gate reads of the teller and of the delivery view it reads through their owners' events and tools, never their
|
||||
// state (ADR 0596 §2-§4): the teller says `answered` after each answer it writes and answers its answers through
|
||||
// `teller_answers`; the view says `reached` after each reach it writes and answers its reach through `view_reach`. An event
|
||||
// asks a look (the answers) or takes the reach it carries; the start reads what was said while the gate was away, once.
|
||||
|
||||
// The tools the gate asks (the manifest invokes them).
|
||||
const (
|
||||
tellerAnswersTool = tellerModule + ".teller_answers"
|
||||
viewReachTool = viewModule + ".view_reach"
|
||||
)
|
||||
|
||||
// The events the gate hears: `<owner>.<event>`, as the manifest consumes them.
|
||||
var (
|
||||
answeredEvent = declared("answered")
|
||||
reachedEvent = declared("reached")
|
||||
)
|
||||
|
||||
// answersOf reads the teller's answer to an ask through its tool: nil when the teller kept none.
|
||||
func answersOf(ask asking, key string) (*Answer, error) {
|
||||
raw, err := ask(tellerAnswersTool, map[string]any{"key": key})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the teller refused teller_answers: %s", lastLine(output))
|
||||
}
|
||||
var r struct {
|
||||
Found bool `json:"found"`
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &r); err != nil {
|
||||
return nil, fmt.Errorf("the teller's answer to %s is not readable: %s", key, clip(string(answer), 200))
|
||||
}
|
||||
if !r.Found {
|
||||
return nil, nil
|
||||
}
|
||||
a, err := mdc.DecodeAnswer(r.Answer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the answer %s is not readable: %w", key, err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// reachedBody is the body of the view's event reached: the key of the reach and the reach as the view wrote it.
|
||||
type reachedBody struct {
|
||||
Key string `json:"key"`
|
||||
Reach json.RawMessage `json:"reach"`
|
||||
}
|
||||
|
||||
// ReachHeard takes the view's event reached.
|
||||
func (h *Holder) ReachHeard(body []byte) error {
|
||||
var e reachedBody
|
||||
if err := json.Unmarshal(body, &e); err != nil || e.Key == "" {
|
||||
h.Refuse(fmt.Errorf("the delivery view's reach event is not readable: %v", err))
|
||||
return nil
|
||||
}
|
||||
h.ReachChanged(e.Key, e.Reach, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReachRead takes every reach the view's tool answers: what was said while the gate was away.
|
||||
func (h *Holder) ReachRead(ask asking) error {
|
||||
raw, err := ask(viewReachTool, map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
return fmt.Errorf("the delivery view refused view_reach: %s", lastLine(output))
|
||||
}
|
||||
var all struct {
|
||||
Reach []json.RawMessage `json:"reach"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &all); err != nil {
|
||||
return fmt.Errorf("the delivery view's reach is not readable: %s", clip(string(answer), 200))
|
||||
}
|
||||
for _, r := range all.Reach {
|
||||
v, err := viewstate.DecodeReach(r)
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] a reach the delivery view answers is not readable, so its merges read as not known: %v", err)
|
||||
continue
|
||||
}
|
||||
key, err := trunk.RepositoryKey(v.Repository)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
_, heard := h.reach[key]
|
||||
h.mu.Unlock()
|
||||
if !heard { // a reach the event said is as new as this read: it is kept
|
||||
h.ReachChanged(key, r, false)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// ADR 0596 §2-§4: the gate reads the teller's answer through the teller's tool, found or not, never the bucket.
|
||||
func TestTheTellersAnswerIsReadThroughItsTool(t *testing.T) {
|
||||
answer, err := mdc.EncodeAnswer(mdc.Answer{Option: "approve", Warrant: "w1", Words: "the operator chose Approve"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var asked []string
|
||||
ask := func(key string, body any) (json.RawMessage, error) {
|
||||
asked = append(asked, fmt.Sprint(key, body))
|
||||
if body.(map[string]any)["key"] == "k1" {
|
||||
return json.Marshal(map[string]any{"key": "k1", "found": true, "answer": json.RawMessage(answer)})
|
||||
}
|
||||
return json.RawMessage(`{"key":"k2","found":false}`), nil
|
||||
}
|
||||
a, err := answersOf(ask, "k1")
|
||||
if err != nil || a == nil || a.Option != "approve" || a.Warrant != "w1" {
|
||||
t.Fatalf("the answer = %+v, %v", a, err)
|
||||
}
|
||||
if a, err = answersOf(ask, "k2"); err != nil || a != nil {
|
||||
t.Errorf("no answer = %+v, %v", a, err)
|
||||
}
|
||||
if len(asked) != 2 || !strings.HasPrefix(asked[0], "mesh-delivery-teller.teller_answers") {
|
||||
t.Errorf("asked %v", asked)
|
||||
}
|
||||
if _, err = answersOf(func(string, any) (json.RawMessage, error) { return nil, fmt.Errorf("no tool runner") }, "k"); err == nil {
|
||||
t.Errorf("a tool that cannot be asked was read as no answer")
|
||||
}
|
||||
}
|
||||
|
||||
// The events answered and reached are heard: the first asks a look, the second carries the reach, which a read of the view's
|
||||
// tool at start fills in only where no event said one.
|
||||
func TestTheViewsReachIsHeardAndReadAtStart(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
reach := func(head string) json.RawMessage {
|
||||
raw, err := viewstate.EncodeReach(viewstate.Reach{Repository: "novox/web", Reached: head, ReachedSrc: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
body, _ := json.Marshal(reachedBody{Key: "novox/web", Reach: reach(strings.Repeat("a", 40))})
|
||||
if err := take(w.h, stdio.Envelope{Key: reachedEvent, Body: body}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := w.h.reach["novox/web"].Reached; got != strings.Repeat("a", 40) {
|
||||
t.Fatalf("the reach heard is %q", got)
|
||||
}
|
||||
read := func(string, any) (json.RawMessage, error) {
|
||||
return json.Marshal(map[string]any{"reach": []json.RawMessage{reach(strings.Repeat("b", 40)), func() json.RawMessage {
|
||||
raw, _ := viewstate.EncodeReach(viewstate.Reach{Repository: "novox/api", Reached: strings.Repeat("c", 40), ReachedSrc: 4})
|
||||
return raw
|
||||
}()}})
|
||||
}
|
||||
if err := w.h.ReachRead(read); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := w.h.reach["novox/web"].Reached; got != strings.Repeat("a", 40) {
|
||||
t.Errorf("the read at start overwrote the reach the event said: %q", got)
|
||||
}
|
||||
if got := w.h.reach["novox/api"].Reached; got != strings.Repeat("c", 40) {
|
||||
t.Errorf("the reach the event never said is read at start: %q", got)
|
||||
}
|
||||
if err := take(w.h, stdio.Envelope{Key: reachedEvent, Body: []byte(`{"reach":`)}); err != nil {
|
||||
t.Errorf("a reach event that cannot be read is said, never left to be heard again: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,97 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// What mesh-delivery asks of others, and where it keeps what it owns. **It never sends to a machine, writes
|
||||
// a declaration, a grant or a bus object, and never registers a build** (novox/hq ADR 0239 decision 6): it
|
||||
// asks the controller, through the controller's seat, and the forge, through the seat `git`.
|
||||
|
||||
// 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
|
||||
// it merges into would let it merge. Reads only.
|
||||
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
|
||||
// Contains is whether a commit is contained in another — an ancestor of it, or it. Reads only.
|
||||
Contains(owner, repo, commit, in string) (bool, error)
|
||||
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
|
||||
// 347). Reads only.
|
||||
OpenPulls() ([]OpenPull, error)
|
||||
}
|
||||
|
||||
// ForgeChecks is what the forge says of a commit's checks (the seat git's pull and statuses, and the merge state).
|
||||
type ForgeChecks struct {
|
||||
Commit string `json:"commit"`
|
||||
Pull *ForgePull `json:"pull,omitempty"`
|
||||
Combined string `json:"combined,omitempty"`
|
||||
Statuses []ForgeStatus `json:"statuses"`
|
||||
Merge ForgeMerge `json:"merge"`
|
||||
}
|
||||
|
||||
// ForgePull is a pull request as the forge says it.
|
||||
type ForgePull struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Merged bool `json:"merged,omitempty"`
|
||||
Base string `json:"base,omitempty"`
|
||||
Head string `json:"head,omitempty"`
|
||||
HeadSHA string `json:"head_sha,omitempty"`
|
||||
MergeCommit string `json:"merge_commit_sha,omitempty"`
|
||||
// MergedAt is when it merged, as the forge says it (RFC 3339); empty when not merged or not said.
|
||||
MergedAt string `json:"merged_at,omitempty"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
// Mergeable is the forge's own word on whether it merges into its base now (its mergeable): false while it is in
|
||||
// conflict with its base, or while the forge works that out after the base moved; nil when the forge did not say.
|
||||
Mergeable *bool `json:"mergeable,omitempty"`
|
||||
}
|
||||
|
||||
// ForgeStatus is one status of a commit, the newest of its context.
|
||||
type ForgeStatus struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description"`
|
||||
TargetURL string `json:"target_url,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Creator string `json:"creator,omitempty"`
|
||||
}
|
||||
|
||||
// ForgeMerge is whether the branch's protection lets the commit merge: every required status a success.
|
||||
// Mergeable is nil when the protection could not be read.
|
||||
type ForgeMerge struct {
|
||||
Branch string `json:"branch"`
|
||||
Protected bool `json:"protected"`
|
||||
Required []string `json:"required"`
|
||||
Blocking []struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
} `json:"blocking"`
|
||||
Mergeable *bool `json:"mergeable"`
|
||||
Says string `json:"says"`
|
||||
// Writes is whether the forge's merging account may write the repository at all (issue 1945): false, its statuses and
|
||||
// its merge are refused, and CannotWrite says so with the tool that grants it. Nil when the forge holder did not say.
|
||||
Writes *bool `json:"writes,omitempty"`
|
||||
CannotWrite string `json:"cannot_write,omitempty"`
|
||||
}
|
||||
|
||||
// Store keeps deliveries and groups: one key each, in the module's own state (ADR 0201).
|
||||
type Store interface {
|
||||
PutDelivery(d *Delivery) error
|
||||
DeleteDelivery(id string) error
|
||||
Deliveries() ([]*Delivery, error)
|
||||
PutGroup(g *Group) error
|
||||
DeleteGroup(id string) error
|
||||
Groups() ([]*Group, error)
|
||||
}
|
||||
|
||||
// --- over the runtime -----------------------------------------------------------------------------------
|
||||
|
||||
// asking is how a tool is asked: stdio.Ask, or a test's.
|
||||
@@ -137,202 +49,3 @@ func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimSpace(s), "\n")
|
||||
return strings.TrimSpace(lines[len(lines)-1])
|
||||
}
|
||||
|
||||
// seatForge reads the forge through the seat `git` (design 47 *The seat git*): every read a verb of the seat, its answer
|
||||
// typed in the seat's contract, a failed read an error and never "none". The forge module is never named.
|
||||
type seatForge struct{ ask asking }
|
||||
|
||||
// gitAsk calls a verb of the seat `git` and reads its typed answer; an answer carrying `error` is a failed read.
|
||||
func gitAsk[A any](f seatForge, verb string, in any, errOf func(*A) string) (*A, error) {
|
||||
raw, err := f.ask("seat:"+gitseat.Seat+"."+verb, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the seat git refused %s: %s", verb, lastLine(output))
|
||||
}
|
||||
var a A
|
||||
if err := json.Unmarshal(answer, &a); err != nil {
|
||||
return nil, fmt.Errorf("the seat git's %s is not readable: %s", verb, clip(string(answer), 200))
|
||||
}
|
||||
if why := errOf(&a); why != "" {
|
||||
return nil, fmt.Errorf("the seat git could not read %s: %s", verb, why)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// Statuses reads a pull request by its number through `pull` (its head when no commit is named) and the commit's statuses
|
||||
// through `statuses`. What the forge would merge now (the base's protection and its required contexts, the forge's
|
||||
// conflict word, whether the merging account writes) is the pull answer's `merging` (mergeState); a commit no pull
|
||||
// request names has none.
|
||||
func (f seatForge) Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error) {
|
||||
repository := owner + "/" + repo
|
||||
c := &ForgeChecks{Commit: commit}
|
||||
var merging *gitseat.Merging
|
||||
if number > 0 {
|
||||
a, err := gitAsk(f, gitseat.VerbPull, gitseat.PullInput{Repository: repository, Number: int64(number)},
|
||||
func(a *gitseat.PullAnswer) string { return a.Error })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Pull == nil {
|
||||
return nil, fmt.Errorf("the seat git's pull named no pull request for %s#%d", repository, number)
|
||||
}
|
||||
c.Pull, merging = forgePullOf(*a.Pull), a.Merging
|
||||
if c.Commit == "" {
|
||||
c.Commit = a.Pull.Head
|
||||
}
|
||||
if base == "" {
|
||||
base = a.Pull.Base
|
||||
}
|
||||
}
|
||||
if c.Commit == "" {
|
||||
return nil, fmt.Errorf("the seat git named no head of %s#%d", repository, number)
|
||||
}
|
||||
s, err := gitAsk(f, gitseat.VerbStatuses, gitseat.StatusesInput{Repository: repository, Commit: c.Commit},
|
||||
func(a *gitseat.StatusesAnswer) string { return a.Error })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Combined = s.Combined
|
||||
for _, st := range s.Statuses {
|
||||
c.Statuses = append(c.Statuses, forgeStatusOf(st))
|
||||
}
|
||||
c.Merge = mergeState(repository, base, merging, c.Statuses)
|
||||
if c.Pull != nil && merging != nil {
|
||||
mergeable := merging.Mergeable
|
||||
c.Pull.Mergeable = &mergeable
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// mergeState is what the forge would merge now, from the seat git's `merging` (issues 1275, 1945; ADR 0441 §1): the base's
|
||||
// protection and its required contexts, each required context not a success at the commit blocking, and whether the
|
||||
// merging account writes. Without `merging` (a commit no pull request names, or a pull request merged or closed) whether
|
||||
// it merges is not known.
|
||||
func mergeState(repository, base string, m *gitseat.Merging, statuses []ForgeStatus) ForgeMerge {
|
||||
out := ForgeMerge{Branch: base, Required: []string{}}
|
||||
if m == nil {
|
||||
out.Says = "the seat git says what merges only for an open pull request"
|
||||
return out
|
||||
}
|
||||
out.Protected, out.Required = m.Protected, append([]string{}, m.Required...)
|
||||
newest := map[string]string{}
|
||||
for _, s := range statuses {
|
||||
if _, seen := newest[s.Context]; !seen {
|
||||
newest[s.Context] = s.State
|
||||
}
|
||||
}
|
||||
for _, context := range out.Required {
|
||||
if state := newest[context]; state != "success" {
|
||||
if state == "" {
|
||||
state = "missing"
|
||||
}
|
||||
out.Blocking = append(out.Blocking, struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
}{context, state})
|
||||
}
|
||||
}
|
||||
mergeable := len(out.Blocking) == 0
|
||||
out.Mergeable = &mergeable
|
||||
switch {
|
||||
case len(out.Required) == 0:
|
||||
out.Says = base + " requires no status"
|
||||
default:
|
||||
out.Says = base + " requires " + strings.Join(out.Required, ", ")
|
||||
}
|
||||
writes := m.Writes
|
||||
out.Writes = &writes
|
||||
if !writes {
|
||||
out.CannotWrite = "the forge's merging account may not write " + repository + ": its statuses and its merge are refused"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// OpenPulls reads every open pull request through `open-pulls`.
|
||||
func (f seatForge) OpenPulls() ([]OpenPull, error) {
|
||||
a, err := gitAsk(f, gitseat.VerbOpenPulls, gitseat.OpenPullsInput{}, func(a *gitseat.OpenPullsAnswer) string { return a.Error })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]OpenPull, 0, len(a.Pulls))
|
||||
for _, p := range a.Pulls {
|
||||
out = append(out, openPullOf(p))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Contains reads through `contains` whether in contains commit.
|
||||
func (f seatForge) Contains(owner, repo, commit, in string) (bool, error) {
|
||||
a, err := gitAsk(f, gitseat.VerbContains, gitseat.ContainsInput{Repository: owner + "/" + repo, Commit: commit, In: in},
|
||||
func(a *gitseat.ContainsAnswer) string { return a.Error })
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return a.Contains, nil
|
||||
}
|
||||
|
||||
// carriedKey is a key an earlier owner's carry-over wrote into the bucket `deliveries`: no delivery, never read as one.
|
||||
const carriedKey = "carried"
|
||||
|
||||
// kvStore is the module's own state: `deliveries` and `groups`, one key each.
|
||||
type kvStore struct{}
|
||||
|
||||
// kvKey is an id as a bucket key: only the characters a key may carry.
|
||||
func kvKey(id string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '=':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteString("_" + fmt.Sprintf("%02x", r) + "_")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (kvStore) PutDelivery(d *Delivery) error {
|
||||
_, err := stdio.State("deliveries").Put(kvKey(d.ID), d)
|
||||
return err
|
||||
}
|
||||
func (kvStore) DeleteDelivery(id string) error { return stdio.State("deliveries").Delete(kvKey(id)) }
|
||||
func (kvStore) PutGroup(g *Group) error {
|
||||
_, err := stdio.State("groups").Put(kvKey(g.ID), g)
|
||||
return err
|
||||
}
|
||||
func (kvStore) DeleteGroup(id string) error { return stdio.State("groups").Delete(kvKey(id)) }
|
||||
|
||||
// readAll reads every key of a state but the ones it is told to leave (the gate's mark `carried` is no delivery).
|
||||
func readAll[T any](state string, leave ...string) ([]*T, error) {
|
||||
keys, err := stdio.State(state).Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var out []*T
|
||||
for _, k := range keys {
|
||||
if slices.Contains(leave, k) {
|
||||
continue
|
||||
}
|
||||
e, err := stdio.State(state).Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
var v T
|
||||
if err := json.Unmarshal(e.Value, &v); err != nil {
|
||||
logf("[mesh-delivery] %s %s cannot be read (%v); left as it is", state, k, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, &v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (kvStore) Deliveries() ([]*Delivery, error) { return readAll[Delivery]("deliveries", carriedKey) }
|
||||
func (kvStore) Groups() ([]*Group, error) { return readAll[Group]("groups") }
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The pull request's key (novox/hq ADR 0406 §4, issue 975): mesh-delivery keeps one key per pull request on the bus,
|
||||
// holding its head, the trunk commit its current check is asked against and its verdict, written by compare-and-set.
|
||||
// It writes a new head when the forge says one (`pull.updated`, which carries the head and the trunk commit it was
|
||||
// announced against), and a new trunk commit only when the check is asked again against one — the merge tool's ask is
|
||||
// that same announcement. A merge of a group's member, and a trunk move that leaves an inactive pull request unasked,
|
||||
// are announced as nothing (the forge announces `pull.updated` only for a head and for the merge tool's ask), so
|
||||
// they write nothing. A verdict for that head and trunk commit writes the verdict. The build seat reads the key.
|
||||
|
||||
// PullKey is what the bus keeps of one pull request.
|
||||
type PullKey struct {
|
||||
Head string `json:"head"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
}
|
||||
|
||||
// PullKeys keeps the pull requests' keys: Change reads the key, applies the change to what it read and writes the result
|
||||
// by compare-and-set when the change says to write; a write another writer won reads the key again and applies again.
|
||||
type PullKeys interface {
|
||||
Change(key string, apply func(PullKey) (PullKey, bool)) error
|
||||
}
|
||||
|
||||
// pullKeyName is a pull request's key: owner.repo.number, with any dot in a name made an underscore.
|
||||
func pullKeyName(owner, repo string, number int) string {
|
||||
clean := func(s string) string { return strings.NewReplacer(".", "_", "/", "_").Replace(s) }
|
||||
return fmt.Sprintf("%s.%s.%d", clean(owner), clean(repo), number)
|
||||
}
|
||||
|
||||
// KeepPull writes the key of a pull request whose head was announced, when the head or the trunk commit it was
|
||||
// announced against is not what the key holds; a new head or trunk clears the verdict.
|
||||
func (h *Holder) KeepPull(p PullEvent) {
|
||||
if h.Pulls == nil || p.Number == 0 || p.HeadSHA == "" {
|
||||
return
|
||||
}
|
||||
err := h.Pulls.Change(pullKeyName(p.Owner, p.Repo, p.Number), func(k PullKey) (PullKey, bool) {
|
||||
if sameCommit(k.Head, p.HeadSHA) && k.Head != "" && (p.BaseSHA == "" || sameCommit(k.Trunk, p.BaseSHA)) {
|
||||
return k, false
|
||||
}
|
||||
return PullKey{Head: p.HeadSHA, Trunk: p.BaseSHA}, true
|
||||
})
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the key of %s/%s#%d could not be written: %v", p.Owner, p.Repo, p.Number, err)
|
||||
h.again("a pull request's key that could not be written")
|
||||
return
|
||||
}
|
||||
// The head's mesh/check may have been heard before this key held the head: the two events are taken on goroutines of
|
||||
// their own, and KeepCheckVerdict then found no head to write a verdict to. The verdict the delivery holds for this head
|
||||
// and trunk is written now; read after the write above landed, it cannot miss one taken before that write.
|
||||
verdict := h.checkVerdictOf(p.repository(), p.HeadSHA, p.BaseSHA)
|
||||
if verdict == "" {
|
||||
return
|
||||
}
|
||||
err = h.Pulls.Change(pullKeyName(p.Owner, p.Repo, p.Number), func(k PullKey) (PullKey, bool) {
|
||||
if !sameCommit(k.Head, p.HeadSHA) || !sameCommit(k.Trunk, p.BaseSHA) || k.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", p.Owner, p.Repo, p.Number, err)
|
||||
h.again("a pull request's key that could not be written")
|
||||
}
|
||||
}
|
||||
|
||||
// checkVerdictOf is the verdict of the mesh/check the delivery of a head holds, when it judged this head on this trunk
|
||||
// commit; empty otherwise. Outside the lock.
|
||||
func (h *Holder) checkVerdictOf(repository, head, trunk string) string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
d := h.deliveries[IDOf(strings.ToLower(repository), head)]
|
||||
if d == nil || d.Check == nil || d.Check.Status != ctxCheck || !sameCommit(d.Check.Head, head) || !sameCommit(d.Check.Trunk, trunk) {
|
||||
return ""
|
||||
}
|
||||
return d.Check.Gate
|
||||
}
|
||||
|
||||
// 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 := 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(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", e.Owner, e.Repo, number, err)
|
||||
h.again("a pull request's key that could not be written")
|
||||
}
|
||||
}
|
||||
|
||||
// kvPulls keeps the keys in the module's own state `pulls`.
|
||||
type kvPulls struct{}
|
||||
|
||||
// changeAttempts is how often a lost compare-and-set is read and applied again before it is said.
|
||||
const changeAttempts = 10
|
||||
|
||||
func (kvPulls) Change(key string, apply func(PullKey) (PullKey, bool)) error {
|
||||
state := stdio.State("pulls")
|
||||
for range changeAttempts {
|
||||
e, err := state.Get(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var k PullKey
|
||||
if e != nil {
|
||||
if err := json.Unmarshal(e.Value, &k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
next, write := apply(k)
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
if e == nil {
|
||||
_, err = state.Create(key, next)
|
||||
} else {
|
||||
_, err = state.Update(key, next, e.Revision)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, stdio.ErrChanged) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s was written by another writer first %d times: %w", key, changeAttempts, stdio.ErrChanged)
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **The pull request's key** (novox/hq ADR 0406 §4, issue 975): one key per pull request on the bus, holding its head,
|
||||
// the trunk commit its current check is asked against and its verdict, written by compare-and-set. A head the forge
|
||||
// announces writes head and trunk; a verdict for that head and trunk writes the verdict; nothing else writes.
|
||||
|
||||
type fakePulls struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]PullKey
|
||||
writes int
|
||||
// lose makes the first n writes lose to another writer, which sets rival.
|
||||
lose int
|
||||
rival PullKey
|
||||
}
|
||||
|
||||
func newFakePulls() *fakePulls { return &fakePulls{keys: map[string]PullKey{}} }
|
||||
|
||||
func (f *fakePulls) Change(key string, apply func(PullKey) (PullKey, bool)) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for tries := 0; tries < 5; tries++ {
|
||||
next, write := apply(f.keys[key])
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
if f.lose > 0 { // another writer was first: read again and apply again
|
||||
f.lose--
|
||||
f.keys[key] = f.rival
|
||||
continue
|
||||
}
|
||||
f.keys[key] = next
|
||||
f.writes++
|
||||
return nil
|
||||
}
|
||||
return errors.New("lost the race too often")
|
||||
}
|
||||
|
||||
func (f *fakePulls) get(key string) PullKey {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.keys[key]
|
||||
}
|
||||
|
||||
const appKey = "novox.app.7"
|
||||
|
||||
func announcedAt(head, trunk string) PullEvent {
|
||||
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
|
||||
p.BaseSHA = trunk
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAnAnnouncedHeadWritesTheKeyAndTheSameAnnouncementAgainWritesNothing(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
p := announcedAt(readyHead, "t1t1t1t1t1t1")
|
||||
w.h.PullUpdated(p)
|
||||
w.h.KeepPull(p)
|
||||
if got := pulls.get(appKey); got.Head != readyHead || got.Trunk != "t1t1t1t1t1t1" || got.Verdict != "" {
|
||||
t.Fatalf("the key holds %+v", got)
|
||||
}
|
||||
w.h.KeepPull(p)
|
||||
if pulls.writes != 1 {
|
||||
t.Fatalf("the same announcement wrote %d times", pulls.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
w.h.KeepPull(announcedAt(readyHead, "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)
|
||||
}
|
||||
w.h.KeepPull(announcedAt(pushed, "t2t2t2t2t2t2"))
|
||||
if got := pulls.get(appKey); got.Head != pushed {
|
||||
t.Fatalf("a new head left %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
pulls.lose, pulls.rival = 1, PullKey{Head: readyHead, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
|
||||
w.h.Pulls = pulls
|
||||
// The rival writer already holds this very head and trunk with a verdict: the change finds nothing to write.
|
||||
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
|
||||
if got := pulls.get(appKey); got.Verdict != "pass" || pulls.writes != 0 {
|
||||
t.Fatalf("after the lost race the key holds %+v after %d writes", got, pulls.writes)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
w := newWorld(t)
|
||||
a, b := "aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb"
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/app", 8, a, "feat/y", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/app", 9, b, "feat/z", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "m8m8m8m8m8m8"))
|
||||
w.h.PullMerged(merged("novox/app", 8, a, "m8m8m8m8m8m8"))
|
||||
w.h.PullMerged(merged("novox/app", 9, b, "m9m9m9m9m9m9"))
|
||||
id := IDOf("novox/app", readyHead)
|
||||
before := w.delivery(id)
|
||||
w.h.PullMerged(merged("novox/app", 8, a, "m8m8m8m8m8m8")) // announced again, past the window
|
||||
after := w.delivery(id)
|
||||
if after.TrunkNow != before.TrunkNow || after.TrunkNow != "m9m9m9m9m9m9" {
|
||||
t.Fatalf("the trunk moved from %q to %q on a merge heard again; wanted it to stay m9m9m9m9m9m9", before.TrunkNow, after.TrunkNow)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's mesh/check event handled wholly between the announcement's PullUpdated and its KeepPull (the two events are
|
||||
// taken on goroutines of their own): the verdict must still reach the pull request's key.
|
||||
func TestAVerdictHeardBeforeTheKeyIsWrittenReachesTheKey(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
pulls := newFakePulls()
|
||||
w.h.Pulls = pulls
|
||||
p := announcedAt(readyHead, "a1a1a1a1a1a1")
|
||||
w.h.PullUpdated(p)
|
||||
s := ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: readyHead, Context: ctxCheck, State: "success",
|
||||
Description: "pass " + readyHead[:12] + " on a1a1a1a1a1a1 | unit 3 run"}
|
||||
w.h.CommitStatus(s)
|
||||
w.h.KeepCheckVerdict(s)
|
||||
w.h.KeepPull(p)
|
||||
if got := pulls.get(appKey); got.Head != readyHead || got.Verdict != "pass" {
|
||||
t.Fatalf("the key holds %+v, want the head with the verdict pass", got)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/trunk"
|
||||
)
|
||||
|
||||
// Whether a merge is on the machines (novox/hq ADR 0556 clause 4). The gate records only the merge (ADR 0539 §17, ADR 0429
|
||||
// §1): it reads the delivery view's reach of each repository, which names the repository's reached head (the newest merge every
|
||||
// node runs a build containing), and derives when asked, for a merged delivery, whether its merge is at or before that head.
|
||||
// The order of two merges into one trunk is the gate's own: the moment each was merged, the trunk being a line of merges. A
|
||||
// reached head the gate holds no merge of is said, never asked of the forge.
|
||||
|
||||
// ReachChanged takes one change of the view's reach.
|
||||
func (h *Holder) ReachChanged(key string, value []byte, deleted bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if deleted {
|
||||
delete(h.reach, key)
|
||||
return
|
||||
}
|
||||
r, err := viewstate.DecodeReach(value)
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the view's reach %s is not readable, so its merges read as not known: %v", key, err)
|
||||
delete(h.reach, key)
|
||||
return
|
||||
}
|
||||
h.reach[key] = r
|
||||
}
|
||||
|
||||
// The words a merged delivery's machines are said in.
|
||||
const (
|
||||
onMachines = "on the machines"
|
||||
notYetOn = "not yet on the machines: "
|
||||
notKnownWhere = "not known: "
|
||||
)
|
||||
|
||||
// machines says whether a merged delivery's merge is on the machines, derived from the view's reach; empty for a delivery
|
||||
// that is not merged. Under the lock.
|
||||
func (h *Holder) machines(d *Delivery) string {
|
||||
if d.State != Merged || d.MergedAs == "" {
|
||||
return ""
|
||||
}
|
||||
key, err := trunk.RepositoryKey(d.Repository)
|
||||
if err != nil {
|
||||
return notKnownWhere + err.Error()
|
||||
}
|
||||
r, ok := h.reach[key]
|
||||
switch {
|
||||
case !ok:
|
||||
return notKnownWhere + "the delivery view says no reach of " + d.Repository
|
||||
}
|
||||
// A merge that changed a module a node wants and runs nothing countable of is not yet there, whatever the reached head
|
||||
// (the module holds no head back, ADR 0556 clause 2).
|
||||
for _, mr := range r.Modules {
|
||||
if mr.Nodes == 0 || mr.Floor != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range mr.Changes {
|
||||
if sameCommit(d.MergedAs, c.Commit) {
|
||||
return notYetOn + mr.Waiting
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case r.Reached == "":
|
||||
return notYetOn + holdsSaid(r)
|
||||
case sameCommit(d.MergedAs, r.Reached):
|
||||
return onMachines
|
||||
}
|
||||
head := h.mergeOf(d.Repository, r.Reached)
|
||||
switch {
|
||||
case head == nil:
|
||||
return notKnownWhere + fmt.Sprintf("the reached head %s of %s is no merge this gate holds", shortOf(r.Reached, 12), d.Repository)
|
||||
case !d.MergedAt.After(head.MergedAt):
|
||||
return onMachines
|
||||
}
|
||||
return notYetOn + holdsSaid(r)
|
||||
}
|
||||
|
||||
// holdsSaid is what holds a repository's next head, in words.
|
||||
func holdsSaid(r viewstate.Reach) string {
|
||||
if len(r.Holds) == 0 {
|
||||
return "the delivery view names nothing that holds it yet"
|
||||
}
|
||||
return strings.Join(r.Holds, "; ")
|
||||
}
|
||||
|
||||
// mergeOf is the delivery of a repository that merged as the commit; nil when none does. Under the lock.
|
||||
func (h *Holder) mergeOf(repository, commit string) *Delivery {
|
||||
for _, d := range h.deliveries {
|
||||
if d.MergedAs != "" && strings.EqualFold(d.Repository, repository) && sameCommit(d.MergedAs, commit) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/trunk"
|
||||
)
|
||||
|
||||
// 1280 (novox/hq ADR 0556 clause 4): `deliveries` and `show` say for a merged delivery whether it is on the machines, derived
|
||||
// when asked from the view's reach and the gate's own order of merges: at or before the reached head it is, after it not yet
|
||||
// and why; a repository the view has no reach of, or a reached head the gate holds no merge of, is not known. Nothing records it.
|
||||
func TestAMergedDeliverySaysWhetherItIsOnTheMachines(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
at := w.now.Add(-3 * time.Hour)
|
||||
merge := func(commit string, n int) *Delivery {
|
||||
d := &Delivery{ID: "novox/web@" + commit[:12], Repository: "novox/web", Commit: commit[:12], Number: n, State: Merged,
|
||||
Since: at, MergedAs: strings.Repeat(commit[:1], 40), MergedAt: at.Add(time.Duration(n) * time.Minute)}
|
||||
w.h.mu.Lock()
|
||||
w.h.keep(d)
|
||||
w.h.mu.Unlock()
|
||||
return d
|
||||
}
|
||||
first, second, third := merge("111111111111", 1), merge("222222222222", 2), merge("333333333333", 3)
|
||||
reach := func(r viewstate.Reach) {
|
||||
key, _ := trunk.RepositoryKey(r.Repository)
|
||||
raw, err := viewstate.EncodeReach(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.ReachChanged(key, raw, false)
|
||||
}
|
||||
machines := func(d *Delivery) string { w.h.mu.Lock(); defer w.h.mu.Unlock(); return w.h.machines(d) }
|
||||
if got := machines(first); !strings.HasPrefix(got, notKnownWhere) {
|
||||
t.Errorf("with no reach: %q", got)
|
||||
}
|
||||
reach(viewstate.Reach{Repository: "novox/web", Reached: second.MergedAs, ReachedSrc: 7, Holds: []string{"web on g14 runs the build of 2222, not yet that of 3333"}})
|
||||
for d, want := range map[*Delivery]string{first: onMachines, second: onMachines, third: notYetOn + "web on g14"} {
|
||||
if got := machines(d); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("%s: %q, want %q", d.ID, got, want)
|
||||
}
|
||||
}
|
||||
reach(viewstate.Reach{Repository: "novox/web", Reached: strings.Repeat("9", 40), ReachedSrc: 8})
|
||||
if got := machines(first); !strings.Contains(got, "no merge this gate holds") {
|
||||
t.Errorf("a reached head the gate holds no merge of: %q", got)
|
||||
}
|
||||
reach(viewstate.Reach{Repository: "novox/web", Holds: []string{"web on ace runs nothing yet"}})
|
||||
if got := machines(first); got != notYetOn+"web on ace runs nothing yet" {
|
||||
t.Errorf("no reached head: %q", got)
|
||||
}
|
||||
// A module a node wants and runs nothing countable of holds no head back, and a merge that changed it is not yet there.
|
||||
reach(viewstate.Reach{Repository: "novox/web", Reached: third.MergedAs, ReachedSrc: 9, Modules: map[string]viewstate.ModuleReach{
|
||||
"web": {FP: "src2:x", Nodes: 1, Waiting: "web on g14 runs nothing yet", Changes: []viewstate.Change{{Commit: second.MergedAs, FP: "src2:x"}}}}})
|
||||
for d, want := range map[*Delivery]string{first: onMachines, second: notYetOn + "web on g14 runs nothing yet", third: onMachines} {
|
||||
if got := machines(d); got != want {
|
||||
t.Errorf("with a module running nowhere countable, %s: %q, want %q", d.ID, got, want)
|
||||
}
|
||||
}
|
||||
// The verbs say it; a delivery not merged says nothing of the machines.
|
||||
reach(viewstate.Reach{Repository: "novox/web", Reached: third.MergedAs, ReachedSrc: 9})
|
||||
lines := w.h.Deliveries("", "novox/web", "", true)
|
||||
if len(lines) != 3 || lines[0].Machines != onMachines {
|
||||
t.Errorf("deliveries = %+v", lines)
|
||||
}
|
||||
shown, err := w.h.Show(third.ID)
|
||||
if err != nil || shown.(map[string]any)["machines"] != onMachines {
|
||||
t.Errorf("show = %v, %v", shown, err)
|
||||
}
|
||||
if got := machines(&Delivery{State: Held, MergedAs: "x", Repository: "novox/web"}); got != "" {
|
||||
t.Errorf("a held delivery says %q of the machines", got)
|
||||
}
|
||||
}
|
||||
@@ -55,26 +55,14 @@ func (h *Holder) Work() {
|
||||
}
|
||||
}
|
||||
|
||||
// React takes every step the facts allow, after an event that can change what they are: the forge's open pull requests
|
||||
// read when a check may have answered, the steps that need another's word (look), what is owed (Flush), and then
|
||||
// the conditions of what stalled (conditions.go), and then the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
|
||||
// React takes every step the facts allow, after an event that can change what they are: the conditions of what stalled
|
||||
// (conditions.go), and then the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
|
||||
func (h *Holder) React() {
|
||||
h.reactMu.Lock()
|
||||
defer h.reactMu.Unlock()
|
||||
h.wanted.mu.Lock()
|
||||
h.wanted.at, h.wanted.step = time.Time{}, ""
|
||||
h.wanted.mu.Unlock()
|
||||
if h.uncheckedDue() {
|
||||
h.wantForge()
|
||||
}
|
||||
if h.takeForgeWanted() {
|
||||
if err := h.SeeOpenPulls(); err != nil {
|
||||
h.wantForge()
|
||||
h.again("the forge's open pull requests, read when a check may have changed")
|
||||
}
|
||||
}
|
||||
h.look()
|
||||
h.Flush()
|
||||
h.sayConditions()
|
||||
h.arm()
|
||||
}
|
||||
|
||||
@@ -1,677 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// The Approve is asked as soon as the checks pass (novox/hq ADR 0421, issue 1022).
|
||||
//
|
||||
// Before, a reviewer marked a pull request ready (a label and a comment) and only then was the operator's Approve asked
|
||||
// (issue 722, ADR 0364). The review now finishes before the push (playbook 07) and its record, a line in the pull
|
||||
// request's description, is given when the pull request is opened, so the mark repeated what the push already meant and
|
||||
// added a step and a wait. This owner asks the operator's Approve itself, within the event that makes it possible: the
|
||||
// verdict that passes, the commit status that sets the checks. Then:
|
||||
//
|
||||
// - the Approve is the only act: nothing is merged before it, and nothing else is needed after it (no label, comment
|
||||
// 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 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
|
||||
// gate says to the operator as a condition (conditions.go). So is a merge the tool refused for an hour.
|
||||
//
|
||||
// No part reads a pull request's description to gate anything (novox/hq ADR 0493 §6, ADR 0659). What this owner does is kept on the
|
||||
// delivery (Ready), said in its log, and the merge's transitions name the Approve as their warrant.
|
||||
|
||||
// readyRefusedFor is how long the merge tool may refuse an approved head before it is said as stalled: none (issue 1115) —
|
||||
// a refusal is said at once, and the merge is asked again at each event that can change it, with no timer. A refusal the
|
||||
// gate is already answering (its trunk moved and its check was asked again) is said only once that check answered and
|
||||
// the merge is still refused, or twice the repository's last check time passed (novox/hq ADR 0505 §4, `answering`).
|
||||
const readyRefusedFor time.Duration = 0
|
||||
|
||||
// groupRefusedFor is how long the merge tool may refuse a closed group's member before it is said as stalled: none (issue 1115).
|
||||
const groupRefusedFor time.Duration = 0
|
||||
|
||||
// waitsForHead is what a delivery waits for when its verdict was heard and its pull request's head was not (issue 1726).
|
||||
const waitsForHead = "its pull request's announcement: its base, branch and title are not heard, so what it merges into is not known"
|
||||
|
||||
// headHeardBound is how long a delivery may wait for its pull request's announcement before it is said as stalled.
|
||||
const headHeardBound = 10 * time.Minute
|
||||
|
||||
// groupClosed is whether a group is closed: a member reached the trunk. Under the lock.
|
||||
func (h *Holder) groupClosed(id string) bool {
|
||||
g := h.groups[id]
|
||||
return g != nil && g.Closed
|
||||
}
|
||||
|
||||
// ReadyRun is what this owner did with a delivery's Approve and merge: kept on the delivery and shown whole by `show`.
|
||||
type ReadyRun struct {
|
||||
// Head is the head the run is for.
|
||||
Head string `json:"head"`
|
||||
// Seen is when this owner first made the run: the checks passed.
|
||||
Seen time.Time `json:"seen"`
|
||||
// Waits is what the merge waits on, when it waits: a verdict, the group's composed check or the operator's Approve.
|
||||
Waits string `json:"waits,omitempty"`
|
||||
// Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered.
|
||||
Tries int `json:"tries,omitempty"`
|
||||
LastTry time.Time `json:"last_try"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
// merging is whether the member is handed to the forge bridge and not merged or refused yet: not handed twice
|
||||
// meanwhile. Not kept.
|
||||
merging bool
|
||||
// refusedNoted is whether the refusal was written into the group's merge key. Not kept.
|
||||
refusedNoted bool
|
||||
// AskedKey and Asking are the ask key last asked for and how many times it was asked at those heads; they stay when the ask
|
||||
// is withdrawn, so the next asking is the next number (mdc.AskingID). Written is whether that ask stands in the gate's asks.
|
||||
AskedKey string `json:"asked_key,omitempty"`
|
||||
Asking int `json:"asking,omitempty"`
|
||||
Written bool `json:"written,omitempty"`
|
||||
// MergeCommit is the commit the forge bridge reported the merge as, and SourceMoved whether source.main holds it
|
||||
// (merges.go).
|
||||
MergeCommit string `json:"merge_commit,omitempty"`
|
||||
SourceMoved bool `json:"source_moved,omitempty"`
|
||||
// Urgent is set once a refusal of this head was one that is urgent at once: its severity never steps down to a
|
||||
// warning after (issue 1275, AC6).
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
// Repeats is how many times in a row the merge tool refused with the same words while the trunk stayed where RepeatTrunk
|
||||
// says: past maxRepeatedRefusals the merge is asked no more by a verdict, a status or a composed check, and the refusal
|
||||
// is urgent (novox/hq issue 1106). A trunk's move, a new head or the operator's Approve starts the count again.
|
||||
Repeats int `json:"repeats,omitempty"`
|
||||
RepeatTrunk string `json:"repeat_trunk,omitempty"`
|
||||
// RefusedSince is when the merge tool began refusing it without a break.
|
||||
RefusedSince *time.Time `json:"refused_since,omitempty"`
|
||||
// FirstRefusedOn and LastRefusedOn are when the verdict the merge was asked on was heard, at the first refusal of
|
||||
// the run of refusals and at the last: a later one than the first is a fresh check answered and refused again
|
||||
// (novox/hq ADR 0505 §4).
|
||||
FirstRefusedOn time.Time `json:"first_refused_on,omitzero"`
|
||||
LastRefusedOn time.Time `json:"last_refused_on,omitzero"`
|
||||
// Merged is when the merge tool merged it.
|
||||
Merged *time.Time `json:"merged,omitempty"`
|
||||
// Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request.
|
||||
Failed string `json:"failed,omitempty"`
|
||||
// OtherBase is the branch its pull request merges into when that is not main: never asked, never merged here, said
|
||||
// once on the pull request (novox/hq ADR 0398 §4, issue 948).
|
||||
OtherBase string `json:"other_base,omitempty"`
|
||||
|
||||
// The operator's Approve of the merge into main (novox/hq ADR 0398 §3, issue 948, approval.go). AskID is the ask
|
||||
// waiting for an answer, Ask the ask as made (a warrant is held to it), Heads the heads it covers by delivery id (a
|
||||
// group's members together), AskedAt when and Asks how many were made for this head.
|
||||
AskID string `json:"ask_id,omitempty"`
|
||||
Ask *oc.Ask `json:"ask,omitempty"`
|
||||
Heads map[string]string `json:"heads,omitempty"`
|
||||
AskedAt time.Time `json:"asked_at"`
|
||||
Asks int `json:"asks,omitempty"`
|
||||
// AskCheck is the state of the check at the heads the pending ask names, as its words said it (checkAt: pass, running,
|
||||
// none or fail; issue 1733): while the check gates no merge, an ask whose check reached a verdict since is asked again
|
||||
// in new words, so it never keeps a claim the check no longer holds.
|
||||
AskCheck string `json:"ask_check,omitempty"`
|
||||
// Approved and Declined are when the operator chose, Warrant what the router's warrant says of who and how; Refused
|
||||
// why an answer was not taken (it did not hold to the ask). AnsweredAsk is the ask the answer answers: an answer stands
|
||||
// only for its own ask, so a Decline of an earlier ask never stands beside the Approve of a later one (declined).
|
||||
Approved *time.Time `json:"approved,omitempty"`
|
||||
Declined *time.Time `json:"declined,omitempty"`
|
||||
AnsweredAsk string `json:"answered_ask,omitempty"`
|
||||
Warrant string `json:"warrant,omitempty"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
|
||||
// FailedAfter is a failure heard after the operator's Approve covered the head, a carry's (novox/hq ADR 0661 §5): said
|
||||
// once on the pull request and as a stalled line naming the merge it follows, never a refusal of the merge; cleared by a
|
||||
// later pass. FailedAfterSince is when it was heard.
|
||||
FailedAfter string `json:"failed_after,omitempty"`
|
||||
FailedAfterSince *time.Time `json:"failed_after_since,omitempty"`
|
||||
|
||||
// CannotWrite is why the operator is not asked: the forge's merging account cannot write a repository the ask covers
|
||||
// (writes.go, issue 1945); CannotWriteSince since when. Said at once as a condition.
|
||||
CannotWrite string `json:"cannot_write,omitempty"`
|
||||
// HeldFull is since when its merge waits for room in the delivery records' ledger, which refused its record as full
|
||||
// (recordfill.go): handed once the ledger is below 80 % of its cap again.
|
||||
HeldFull *time.Time `json:"held_full,omitempty"`
|
||||
CannotWriteSince *time.Time `json:"cannot_write_since,omitempty"`
|
||||
|
||||
// NotHanded is why an approved merge that waits for nothing is not handed to the forge bridge (a member of its group that
|
||||
// cannot be handed, and why), NotHandedSince since when: said once in the log and as a stalled line, never silent.
|
||||
NotHanded string `json:"not_handed,omitempty"`
|
||||
NotHandedSince *time.Time `json:"not_handed_since,omitempty"`
|
||||
}
|
||||
|
||||
// declined is whether the operator's Decline stands for this run: it answered the ask the run holds now, or the run holds
|
||||
// no ask any more. A Decline of an earlier ask, the run asked again since at other heads (a sibling's new head), does not
|
||||
// stand (2026-10-29: hq#495 declined under one ask and approved under the next was never handed). A run kept before answers
|
||||
// named their ask stands declined unless an Approve came after the Decline.
|
||||
func (r *ReadyRun) declined() bool {
|
||||
if r == nil || r.Declined == nil {
|
||||
return false
|
||||
}
|
||||
if r.AnsweredAsk != "" {
|
||||
return r.AskID == "" || r.AskID == r.AnsweredAsk
|
||||
}
|
||||
return r.Approved == nil || r.Declined.After(*r.Approved)
|
||||
}
|
||||
|
||||
// MergeBranch is the one branch this owner merges into (novox/hq ADR 0398 §4): a merge into any other is its author's.
|
||||
const MergeBranch = "main"
|
||||
|
||||
// mergedBy is who a merge this owner made on an Approve is said to be by, in its transitions.
|
||||
func (r *ReadyRun) mergedBy() string {
|
||||
if r == nil || r.Merged == nil {
|
||||
return ""
|
||||
}
|
||||
by := byOwner + ", on the operator's Approve at " + shortOf(r.Head, 12)
|
||||
if r.Warrant != "" {
|
||||
by += "; the merge into main approved: " + r.Warrant
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// pullKey is a pull request as a key: owner/repo#number.
|
||||
func pullKey(p OpenPull) string {
|
||||
return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number)
|
||||
}
|
||||
|
||||
// runOf is the delivery's run, made when it has none.
|
||||
func (h *Holder) runOf(d *Delivery, now time.Time) *ReadyRun {
|
||||
if d.Ready == nil {
|
||||
d.Ready = &ReadyRun{Head: d.Commit, Seen: now}
|
||||
h.keep(d)
|
||||
}
|
||||
return d.Ready
|
||||
}
|
||||
|
||||
// readyAsks works out, under the lock, what each delivery whose checks passed needs: the operator asked, a merge asked,
|
||||
// a failure said, an out of date verdict's check asked again. The asking happens outside the lock.
|
||||
func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
var asks []func()
|
||||
h.endStaleAsks()
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
d := h.deliveries[id]
|
||||
if d.Number == 0 || d.State.Final() || d.MergedAs != "" {
|
||||
continue // not of a pull request, or past its merge
|
||||
}
|
||||
if d.Ready == nil && d.State == Ready {
|
||||
h.runOf(d, now)
|
||||
h.Poke() // its check, if none is asked, is worth asking now (askCheck)
|
||||
h.Logf("[mesh-delivery] %s is ready (%s); the operator's Approve of its merge into main is asked once it may merge",
|
||||
d.ID, h.checkAt([]*Delivery{d}).Words)
|
||||
}
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || r.declined() {
|
||||
continue
|
||||
}
|
||||
if d.State == Rejected {
|
||||
if r.Failed == "" {
|
||||
r.Failed = "its check failed"
|
||||
if d.Check != nil {
|
||||
r.Failed += ": " + oneLine(d.Check.Summary)
|
||||
if j := d.Check.Judged(); j != "" {
|
||||
r.Failed += " (" + j + ")"
|
||||
}
|
||||
}
|
||||
r.Waits = ""
|
||||
h.Logf("[mesh-delivery] %s passed its checks and is not merged: %s", d.ID, r.Failed)
|
||||
h.keep(d)
|
||||
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request's checks passed at "+
|
||||
"%s, and now %s. A new head, or a recheck that passes, asks the operator again.", d.Commit, r.Failed)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if d.Base == "" {
|
||||
// A delivery made from its verdict before its head was heard (issue 1726): its base, branch and title are
|
||||
// unknown, so what it merges into is too. It is neither asked nor passed over in silence: it waits for its
|
||||
// pull request's announcement, which fills it (pullUpdated), and past headHeardBound it is stalled.
|
||||
if r.Waits != waitsForHead {
|
||||
r.Waits = waitsForHead
|
||||
h.Logf("[mesh-delivery] %s waits: %s", d.ID, waitsForHead)
|
||||
h.keep(d)
|
||||
}
|
||||
h.lookAt(r.Seen.Add(headHeardBound), "a delivery whose pull request was never announced")
|
||||
continue
|
||||
}
|
||||
if d.Base != MergeBranch {
|
||||
if r.OtherBase != d.Base {
|
||||
r.OtherBase = d.Base
|
||||
check := h.checkAt([]*Delivery{d}).Words // ready need not mean passed: the check may gate no merge (issue 1733)
|
||||
h.Logf("[mesh-delivery] %s (%s) is not asked: it merges into %s, not %s", d.ID, check, d.Base, MergeBranch)
|
||||
h.keep(d)
|
||||
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is at %s (%s), "+
|
||||
"and it merges into %s. Only a merge into %s is this owner's; a merge into another branch is its "+
|
||||
"author's (novox/hq ADR 0398 §4).", d.Commit, check, d.Base, MergeBranch)))
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.OtherBase = ""
|
||||
r.Failed = ""
|
||||
waits := h.readyWaits(d)
|
||||
if waits != r.Waits {
|
||||
r.Waits = waits
|
||||
if waits != "" {
|
||||
h.Logf("[mesh-delivery] %s waits: %s", d.ID, waits)
|
||||
}
|
||||
h.keep(d)
|
||||
}
|
||||
var approvalAsk func()
|
||||
if waits == "" {
|
||||
waits, approvalAsk = h.approval(d, now)
|
||||
}
|
||||
if waits == "" {
|
||||
waits = h.passWaits(d) // no merge lands in a repository between another approved group's members (issue 1275)
|
||||
}
|
||||
if waits != r.Waits {
|
||||
r.Waits = waits
|
||||
h.keep(d)
|
||||
}
|
||||
if approvalAsk != nil {
|
||||
asks = append(asks, approvalAsk)
|
||||
}
|
||||
if waits != "" || r.merging {
|
||||
h.noteNotHanded(d, "", now) // what it waits for is said by its waits, or it is handed
|
||||
continue
|
||||
}
|
||||
if !r.LastTry.IsZero() {
|
||||
h.noteNotHanded(d, "", now)
|
||||
continue // refused: asked again at the next event that decides whether it may merge (mergeAgainNow), with no timer
|
||||
}
|
||||
// A group is merged one member at a time, handed to the forge bridge, and the pass stops at the first refusal (ADR
|
||||
// 0441 §3, §5); a lone pull request is a group of one.
|
||||
g := h.groups[d.Group]
|
||||
if d.Group == "" {
|
||||
g = nil
|
||||
}
|
||||
if a := h.handTurn(g, d, now); a != nil {
|
||||
h.noteNotHanded(d, "", now)
|
||||
asks = append(asks, a)
|
||||
} else {
|
||||
h.noteNotHanded(d, h.notHanded(g, d), now) // a merge never handed says why (2026-10-29: silent for an hour)
|
||||
}
|
||||
}
|
||||
asks = append(asks, h.settleMerges()...)
|
||||
asks = append(asks, h.settleForgeMerges()...)
|
||||
asks = append(asks, h.recordForgeMade()...)
|
||||
asks = append(asks, h.seedTrunks()...)
|
||||
asks = append(asks, h.withdrawnKeys()...)
|
||||
asks = append(h.cancels, asks...)
|
||||
h.cancels = nil
|
||||
return asks
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if d != nil && d.Ready != nil && d.Ready.Merged == nil {
|
||||
if d.Ready.repeatedRefusal(d) {
|
||||
return // the same refusal again and again with the trunk where it was: asking more changes nothing (issue 1106)
|
||||
}
|
||||
d.Ready.LastTry = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// causeChanged takes a verdict, a composed check or a status of the same heads that decides whether the merge may go: what the
|
||||
// refusal waited on changed, so the count of the same words starts again and the merge is asked at once (issue 1785); only a
|
||||
// refusal that truly repeats, with nothing new behind it, stops the asking (issue 1106). Under the lock.
|
||||
func (h *Holder) causeChanged(d *Delivery) {
|
||||
if d != nil && d.Ready != nil && d.Ready.Merged == nil {
|
||||
d.Ready.Repeats = 0
|
||||
}
|
||||
h.mergeAgainNow(d)
|
||||
}
|
||||
|
||||
// maxRepeatedRefusals is how many refusals in a row, with the same words and the trunk not moved, end the asking again: a
|
||||
// refusal that repeats with no trunk move is not a trunk's move, and the check asked after each only brings the same words.
|
||||
const maxRepeatedRefusals = 3
|
||||
|
||||
// repeatedRefusal is whether the merge was refused maxRepeatedRefusals times with these words and the trunk where it is
|
||||
// now: it is then asked no more until something changes (a trunk's move, a new head, the operator's Approve).
|
||||
func (r *ReadyRun) repeatedRefusal(d *Delivery) bool {
|
||||
return r.Repeats >= maxRepeatedRefusals && r.RepeatTrunk == d.TrunkNow
|
||||
}
|
||||
|
||||
// refusalKey is a refusal's first sentence, up to the trunk it names ("... and main is now <t>"): what follows says how long the
|
||||
// last check took and the like, which differs each time and is no change of cause.
|
||||
func refusalKey(answer string) string {
|
||||
if i := strings.Index(answer, " is now "); i >= 0 {
|
||||
if j := strings.Index(answer[i:], ". "); j >= 0 {
|
||||
return answer[:i+j]
|
||||
}
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
// noteRefusal counts a refusal: the same words with the trunk where it was count on, anything else starts the count at one.
|
||||
func (r *ReadyRun) noteRefusal(d *Delivery, answer string) {
|
||||
if refusalKey(answer) == refusalKey(r.Answer) && r.RepeatTrunk == d.TrunkNow && r.Repeats > 0 {
|
||||
r.Repeats++
|
||||
return
|
||||
}
|
||||
r.Repeats, r.RepeatTrunk = 1, d.TrunkNow
|
||||
}
|
||||
|
||||
// readyWaits is what a delivery's merge waits on; empty when the Approve may be asked and the merge asked. Once the
|
||||
// operator's Approve covers its heads the merge judges that Approve alone (approvedWaits); until then the check gates the
|
||||
// ask (askWaits), the one place a check gates anything here (novox/hq ADR 0661 §3, §4). Under the lock.
|
||||
func (h *Holder) readyWaits(d *Delivery) string {
|
||||
if c := h.covering(d); c != nil && c.Approved != nil {
|
||||
return h.approvedWaits(d)
|
||||
}
|
||||
return h.askWaits(d)
|
||||
}
|
||||
|
||||
// askWaits is why the operator's Approve is not asked yet: the check gates the ask (novox/hq ADR 0661 §3, ADR 0421 §1). A
|
||||
// ready delivery is asked once the checker's verdict at exactly the heads the ask covers passed; a proposed one whose
|
||||
// verdict went out of date while its ask is pending waits for its check against the trunk's head. A group is asked only
|
||||
// once its composed check passed at its members' heads (ADR 0661 §4).
|
||||
func (h *Holder) askWaits(d *Delivery) string {
|
||||
if d.judgedByCheck() && d.State == Proposed && d.outOfDate() != "" && h.covering(d) == nil {
|
||||
return "its mesh/check is " + d.outOfDate() + ": the checker checks it again against the trunk's head"
|
||||
}
|
||||
switch {
|
||||
case d.State == Ready:
|
||||
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain != nil:
|
||||
return "its check against the trunk's head is asked and has not answered"
|
||||
case d.State == Proposed && d.outOfDate() != "":
|
||||
return "its verdict is out of date: its check against the trunk's head is asked before the operator is"
|
||||
case d.State == Proposed && d.AskedAgain != nil:
|
||||
return "its check against the trunk's head is asked and has not answered"
|
||||
default:
|
||||
return "its check has not passed yet (" + string(d.State) + ")"
|
||||
}
|
||||
// On mesh/check, the checker's verdict at exactly the heads the ask covers (issue 1302, ADR 0447 §2).
|
||||
if w := h.checkerWaits(d); w != "" {
|
||||
return w
|
||||
}
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return h.groupTurnToMerge(d)
|
||||
}
|
||||
|
||||
// approvedWaits is what a merge the operator's Approve covers waits on: nothing of the check (novox/hq ADR 0661 §3, §4).
|
||||
// No verdict, carry, composed check or trunk move is waited on, and no status is read again; a group member waits only
|
||||
// for every member to be ready, at the heads the Approve covers, so the group merges in one pass. A proposed delivery
|
||||
// the Approve covers (a trunk moved before the Approve took its run) is ready on it. Under the lock.
|
||||
func (h *Holder) approvedWaits(d *Delivery) string {
|
||||
if c := h.covering(d); d.State == Proposed && d.Ready != nil && c != nil && c.Approved != nil {
|
||||
now := h.Now()
|
||||
h.approval(d, now) // the covering Approve taken onto its own run, so the table reads it
|
||||
h.moved(d, settle(d, h.factsAt(now)))
|
||||
}
|
||||
if d.State != Ready {
|
||||
return "it is " + string(d.State) + ", not ready yet"
|
||||
}
|
||||
if g := h.groups[d.Group]; d.Group != "" && g != nil {
|
||||
for _, m := range h.membersOf(g) {
|
||||
if m.State != Ready && m.State != Merged {
|
||||
return "its group " + g.ID + " waits for " + m.ID + ", which is " + string(m.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// waitsCheckBeforeAsking is how the wait for the check's verdict begins, as `show` and the delivery's wait say it.
|
||||
const waitsCheckBeforeAsking = "waits its check before asking the operator's Approve: "
|
||||
|
||||
// verdictWaits is why an Approve is not asked yet while the check gates no merge: the check at the heads the ask would cover
|
||||
// has no verdict (the operator, 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request").
|
||||
// A pass or a failure is asked (a failure in its words: the operator may still merge it), and nothing else: no timer stands
|
||||
// in for the verdict, because the checker turns a check it lost into a failure it says aloud (issue 1733, the operator,
|
||||
// 2026-10-28: "one hour is absurd"). For a group the check is the one of all its heads together. Under the lock.
|
||||
func (h *Holder) verdictWaits(d *Delivery) string {
|
||||
c := h.checkAt(h.askMembers(d))
|
||||
if c.Key == checkSaidPass || c.Key == checkSaidFail {
|
||||
return ""
|
||||
}
|
||||
return waitsCheckBeforeAsking + c.Words
|
||||
}
|
||||
|
||||
// groupTurnToMerge is why a group member may not merge yet; empty when it may: the group's composed check passed for
|
||||
// its members' heads as they are and is not out of date. No member waits for another's merge or delivery (novox/hq
|
||||
// ADR 0418 §4): once the composed check passed and the Approve covers the heads, every member is merged in one pass.
|
||||
// A group part way through that pass is merging, and its next member's turn is then (issue 1275).
|
||||
func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
||||
g := h.groups[d.Group]
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
members := h.membersOf(g)
|
||||
if state, why := GroupState(g, members); !groupMayMerge(state) {
|
||||
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
|
||||
}
|
||||
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
|
||||
!sameMembers(c.Commits, commitsOf(members)) {
|
||||
return "its group " + g.ID + "'s composed check has not passed for its members' heads"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// groupMayMerge is whether a group in this state hands its next member to the forge bridge: one ready, and one merging (a
|
||||
// member merged, the pass goes on). Every other word holds the pass.
|
||||
func groupMayMerge(state string) bool {
|
||||
switch state {
|
||||
case GroupReady, GroupMerging:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sayOn owes a comment on a delivery's pull request: an owed act numbered among the comments on its head, written as a key
|
||||
// with the delivery's other effects (owed.go). Under the lock; what it returns has nothing left to do.
|
||||
func (h *Holder) sayOn(d *Delivery, body string) func() {
|
||||
d.Comments++
|
||||
d.Owed = append(d.Owed, Effect{Kind: EffectComment, Commit: d.Commit, Line: body, Context: strconv.Itoa(d.Comments), Since: h.Now()})
|
||||
h.keep(d)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// failedAfterApprove says a failure heard after the operator's Approve covered the head: once on the pull request, and
|
||||
// as the stalled line readyLines lists; the merge is not refused for it, and the operator decides the remedy, a fix or a
|
||||
// revert (novox/hq ADR 0661 §5). A later pass clears it. Under the lock.
|
||||
func (h *Holder) failedAfterApprove(d *Delivery, v *Verdict) {
|
||||
r := d.Ready
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if v.Passes() {
|
||||
r.FailedAfter, r.FailedAfterSince = "", nil
|
||||
return
|
||||
}
|
||||
if r.FailedAfter != "" {
|
||||
return
|
||||
}
|
||||
now := h.Now()
|
||||
r.FailedAfter, r.FailedAfterSince = "its check failed after the operator's Approve: "+oneLine(clip(v.Summary, 120)), &now
|
||||
h.Logf("[mesh-delivery] %s (%s): %s", d.ID, h.mergeFollowed(d), r.FailedAfter)
|
||||
h.sayOn(d, fmt.Sprintf("mesh-delivery: **failed after the Approve.** This pull request (%s) %s. The merge is not "+
|
||||
"refused for it: the operator decides the remedy, a fix or a revert.", h.mergeFollowed(d), r.FailedAfter))
|
||||
}
|
||||
|
||||
// mergeFollowed names the merge a failure after the Approve follows: the trunk commit it merged as, or its approved head
|
||||
// not merged yet.
|
||||
func (h *Holder) mergeFollowed(d *Delivery) string {
|
||||
if d.MergedAs != "" {
|
||||
return "merged as " + shortOf(d.MergedAs, 12) + " at its head " + shortOf(d.Commit, 12)
|
||||
}
|
||||
return "approved at its head " + shortOf(d.Commit, 12) + ", not merged yet"
|
||||
}
|
||||
|
||||
// readyLines are the approved heads that will not merge: a check that failed at once, a merge refused past its bound;
|
||||
// as stalled lines, which the gate says as conditions (conditions.go). Under the lock.
|
||||
func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, d := range h.deliveries {
|
||||
r := d.Ready
|
||||
if r != nil && r.FailedAfter != "" && r.FailedAfterSince != nil && d.State != Closed {
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.FailedAfterSince).Round(time.Second).String(), Bound: "0s",
|
||||
H2: "none: the operator decides the remedy, a fix or a revert; a later pass clears it",
|
||||
Says: fmt.Sprintf("%s#%d (%s): %s", d.Repository, d.Number, h.mergeFollowed(d), r.FailedAfter)})
|
||||
continue
|
||||
}
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case d.Base == "" && d.Number != 0 && now.Sub(r.Seen) >= headHeardBound:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
|
||||
Bound: headHeardBound.String(), H2: "none: a later announcement of the pull request's head (the merge tool announces it again, the forge's announcer restarts, or a new push) or an edit of its description; a recheck does not fill it — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d at %s (%s) waits for %s", d.Repository, d.Number, shortOf(r.Head, 12),
|
||||
h.checkAt([]*Delivery{d}).Words, waitsForHead)})
|
||||
case r.CannotWrite != "" && r.Approved == nil && r.CannotWriteSince != nil:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.CannotWriteSince).Round(time.Second).String(), Bound: "0s",
|
||||
H2: "none: the forge account given write on the repository (gitea_grant_write; the gitea module grants it itself once a module of it is registered), which asks the operator then",
|
||||
Says: fmt.Sprintf("%s#%d at %s (%s): the operator is not asked for its Approve: %s",
|
||||
d.Repository, d.Number, shortOf(r.Head, 12), h.checkAt([]*Delivery{d}).Words, r.CannotWrite)})
|
||||
case r.Failed != "" && d.State == Rejected:
|
||||
// Its cure is its author's new head: listed here at once (`show`), told on the pull request at once, and the
|
||||
// operator's only once no new head came within the author's bound (novox/hq ADR 0505 §5).
|
||||
if now.Sub(d.Since) < h.authorBound() {
|
||||
continue
|
||||
}
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(d.Since).Round(time.Second).String(),
|
||||
Bound: h.authorBound().String(), H2: "none: a new head from its author, or a recheck that passes",
|
||||
Needs: NeedsNewHead, Author: authorOf(d), Branch: d.Branch,
|
||||
Says: fmt.Sprintf("%s#%d passed its checks at %s and is not merged: %s. It needs a new head from %s, "+
|
||||
"and none came for %s", d.Repository, d.Number, shortOf(r.Head, 12), r.Failed, authorOf(d),
|
||||
now.Sub(d.Since).Round(time.Minute))})
|
||||
case r.Refused != "":
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
|
||||
Bound: "0s", H2: "none: a new head, which asks the operator again — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d at %s is not merged: the operator's answer %s", d.Repository,
|
||||
d.Number, shortOf(r.Head, 12), r.Refused)})
|
||||
case r.NotHanded != "" && r.NotHandedSince != nil && r.Approved != nil:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.NotHandedSince).Round(time.Second).String(), Bound: "0s",
|
||||
H2: "none: the member named is approved at its head and ready, or its group is asked anew by a new head — the operator's",
|
||||
Says: fmt.Sprintf("%s#%d at %s is approved and %s", d.Repository, d.Number, shortOf(r.Head, 12), r.NotHanded)})
|
||||
case h.answering(d, now):
|
||||
// The trunk moved and its check was asked again: the gate is answering it, so it is no stall yet (ADR 0505
|
||||
// §4). `show` lists the refusal meanwhile.
|
||||
case r.RefusedSince != nil && d.Group != "" && h.groupClosed(d.Group) && now.Sub(*r.RefusedSince) >= groupRefusedFor:
|
||||
// A closed group's member (novox/hq ADR 0418 §3, issue 1082): its sibling merged, and its own merge is refused
|
||||
// again after each composed check. Said after the merge window, with the group and the tool's last answer.
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: groupRefusedFor.String(),
|
||||
H2: "none: the group's composed check is asked again after each refusal, and the merge each minute — the operator's",
|
||||
Severity: refusalSeverity(r, now),
|
||||
Says: fmt.Sprintf("group %s is half merged: %s#%d is approved at %s and the merge tool has refused it since %s: %s. "+
|
||||
"Needed: the refusal's cause is met (the merge is asked again at each verdict, status or Approve), or the operator decides.",
|
||||
d.Group, d.Repository, d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
||||
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
|
||||
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
||||
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
|
||||
H2: "none: the merge is asked again on a verdict, an Approve, a group's composed check or a merge check's " +
|
||||
"status turning success — the operator's",
|
||||
Says: fmt.Sprintf("%s%s#%d is approved at %s, and the merge tool has refused it since %s: %s%s", heldInGroup(d),
|
||||
d.Repository, d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer, neededOf(d)),
|
||||
Severity: severityOf(d, now)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergedEvent is the module event `merged` (issue 1025): the pull request merged, its head and group, and the ask,
|
||||
// option and heads the operator's Approve was bound to (the warrant the controller reads back from the router). A merge
|
||||
// without an Approve names no ask and no option: no warrant is invented. Under the lock, before anything clears them.
|
||||
func mergedEvent(d *Delivery, head string) map[string]any {
|
||||
r := d.Ready
|
||||
heads := map[string]string{}
|
||||
for k, v := range r.Heads {
|
||||
heads[k] = v
|
||||
}
|
||||
ev := map[string]any{"owner": d.Owner(), "repo": d.Repo(), "number": d.Number, "head_sha": head, "group": d.Group,
|
||||
"ask_id": "", "option": "", "heads": heads}
|
||||
if r.Approved != nil {
|
||||
ev["ask_id"], ev["option"] = r.AskID, OptionApprove
|
||||
if _, _, _, err := mdc.ParseAskKey(r.AskID); err == nil {
|
||||
// The ask of the gate: the router knows it by the id its key gives, and the teller asked it (ADR 0437 §6).
|
||||
ev["ask_id"], ev["asker"] = mdc.AskingID(r.AskID, r.Asking), tellerModule
|
||||
} // an Approve carried over from today's module keeps its router id, and the asker the controller takes by default
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// heldInGroup is the words that put a refused member in its group's pass: none for a lone delivery.
|
||||
func heldInGroup(d *Delivery) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return "group " + d.Group + " is held, and no member after this one is merged: "
|
||||
}
|
||||
|
||||
// neededOf is what a refused member needs, for a member of a group (issue 1275).
|
||||
func neededOf(d *Delivery) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return ". Needed: the refusal's cause is met (the merge is asked again at each verdict, status or Approve), or the operator decides."
|
||||
}
|
||||
|
||||
// answering is whether an approved head the merge tool refused is one the gate is already answering, and so not said as
|
||||
// stalled yet (novox/hq ADR 0505 §4): refused only because its trunk moved, its check asked again, and neither has that
|
||||
// check answered with the merge refused again after it, nor has twice the repository's last check time (at least the
|
||||
// floor) passed since the refusals began. Any other refusal of this head, and a refusal repeated past its limit, is said
|
||||
// at once (r.Urgent). Under the lock.
|
||||
func (h *Holder) answering(d *Delivery, now time.Time) bool {
|
||||
r := d.Ready
|
||||
if r == nil || r.RefusedSince == nil || r.Urgent || !outOfDateRefusal(r.Answer) {
|
||||
return false
|
||||
}
|
||||
if r.LastRefusedOn.After(r.FirstRefusedOn) {
|
||||
return false // its fresh check answered, and the merge was refused again on it
|
||||
}
|
||||
return now.Sub(*r.RefusedSince) < h.answeredRefusalBound(d.Repository)
|
||||
}
|
||||
|
||||
// outOfDateFor is how long a refusal for an out of date verdict is a warning before it is urgent (issue 1275, AC6).
|
||||
const outOfDateFor = 10 * time.Minute
|
||||
|
||||
// outOfDateRefusal is whether the merge tool refused because the member's verdict is judged against an earlier trunk: the
|
||||
// forge's "its verdict was judged against trunk <t>, and main is now <t>. The check of head <h> merged into trunk <t> was
|
||||
// asked" (gitea merge.ts), whose fresh check is already running.
|
||||
//
|
||||
// Words that also name another cause (a status not a success, missing or failed) are not this: the other cause is urgent
|
||||
// at once (ADR 0442, issue 1275 AC6).
|
||||
func outOfDateRefusal(answer string) bool {
|
||||
for _, other := range []string{"not a success", "(missing)", "(failure)", "(error)", "could not be asked"} {
|
||||
if strings.Contains(answer, other) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// gitea merge.ts: "Its check is already asked and not yet answered" (on its way), or "The check of head <h> merged into
|
||||
// trunk <t> was asked" (asked now). Its other refusals, among them "its check could not be asked", are not this.
|
||||
return strings.Contains(answer, "its verdict ") && strings.Contains(answer, " is now ") &&
|
||||
(strings.Contains(answer, "Its check is already asked and not yet answered") ||
|
||||
strings.Contains(answer, "merged into trunk") && strings.Contains(answer, "was asked"))
|
||||
}
|
||||
|
||||
// refusalSeverity is "warning" while a member's refusal for an out of date verdict is younger than ten minutes, as its
|
||||
// fresh check runs, and "urgent" for it after that and for every other refusal at once.
|
||||
func refusalSeverity(r *ReadyRun, now time.Time) string {
|
||||
if !r.Urgent && r.RefusedSince != nil && outOfDateRefusal(r.Answer) && now.Sub(*r.RefusedSince) < outOfDateFor {
|
||||
return "warning"
|
||||
}
|
||||
return "urgent"
|
||||
}
|
||||
|
||||
// severityOf is the severity of a refused member of a group, empty for a lone delivery.
|
||||
func severityOf(d *Delivery, now time.Time) string {
|
||||
if d.Group == "" {
|
||||
return ""
|
||||
}
|
||||
return refusalSeverity(d.Ready, now)
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
)
|
||||
|
||||
// **A pull request whose checks passed and whose merge the operator approved is merged by this owner** (novox/hq issues
|
||||
// 722 and 1022): before, a reviewed pull request with green checks waited until a session called the merge tool, and
|
||||
// again after every check the tool asked because the trunk had moved.
|
||||
|
||||
const (
|
||||
readyHead = "1111111111111111111111111111111111111111"
|
||||
pushed = "2222222222222222222222222222222222222222"
|
||||
)
|
||||
|
||||
// openPull is an open pull request into main as gitea_open_pulls reads it.
|
||||
func openPull(repo string, number int, head string) OpenPull {
|
||||
owner, name, _ := strings.Cut(repo, "/")
|
||||
return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head}
|
||||
}
|
||||
|
||||
func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent {
|
||||
c := verdict(repo, number, sha, gate)
|
||||
c.Trunk = trunk
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
id := IDOf("novox/app", readyHead)
|
||||
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()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a pull request nobody approved was merged: %v", w.forge.merges)
|
||||
}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
|
||||
t.Fatalf("an approved pull request was merged %v; wanted once, at the approved head", w.forge.merges)
|
||||
}
|
||||
d := w.delivery(id)
|
||||
if d.Ready == nil || d.Ready.Merged == nil || d.Ready.Tries != 1 {
|
||||
t.Fatalf("what was done with the Approve is not kept: %+v", d.Ready)
|
||||
}
|
||||
// Its merge's transition names the Approve as its warrant.
|
||||
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||
w.settleAll()
|
||||
d = w.delivery(id)
|
||||
var last Transition
|
||||
for _, tr := range d.Transitions {
|
||||
if tr.Event == EvMerged {
|
||||
last = tr
|
||||
}
|
||||
}
|
||||
if !strings.Contains(last.By, "the operator's Approve at "+readyHead[:12]) {
|
||||
t.Fatalf("the merge's transition does not name the Approve: %+v", last)
|
||||
}
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("merged again: %v", w.forge.merges)
|
||||
}
|
||||
|
||||
// 1733: the Approve's ask says the check's state at the heads it names, read from the checker's verdict there: "passed
|
||||
// its check" only when it passed. While the check gates no merge, the ask waits for the check's verdict (the operator,
|
||||
// 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request"): a pass or a failure is asked,
|
||||
// the failure in its words; with no verdict nothing is asked and the delivery says it waits its check, until the verdict
|
||||
// comes, however long (issue 1733: no timer stands in for it). The verdict is the
|
||||
// checker's verdicts bucket value as captured on the bus (mesh-delivery-checker, `<group>.<heads hash>`).
|
||||
for _, row := range []struct {
|
||||
name, state, failed, then string
|
||||
gated bool
|
||||
after time.Duration
|
||||
asks, open int
|
||||
want, not []string
|
||||
head string // the ask's headline starts with it
|
||||
}{
|
||||
{name: "gated, passed", gated: true, state: "pass", asks: 1, head: "Merge into main: app#7 Fix it",
|
||||
want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"},
|
||||
not: []string{"before its check ends", "no verdict yet"}},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
v := newWorld(t)
|
||||
v.op.auto = false
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Title = "Fix it"
|
||||
v.h.PullUpdated(p)
|
||||
head := headOf("novox/app", 7, readyHead)
|
||||
if row.gated {
|
||||
v.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
}
|
||||
v.settleAll()
|
||||
if !row.gated && row.state != "" {
|
||||
checkerSaysWith(v, "g", row.state, row.failed, nil, head)
|
||||
v.settleAll()
|
||||
}
|
||||
if row.after > 0 {
|
||||
v.later(row.after)
|
||||
v.settleAll()
|
||||
}
|
||||
if row.then != "" {
|
||||
checkerSays(v, "g", row.then, row.failed, head)
|
||||
v.settleAll()
|
||||
}
|
||||
open := v.op.open()
|
||||
wantOpen := min(row.asks, 1)
|
||||
if row.open < 0 {
|
||||
wantOpen = 0
|
||||
}
|
||||
if v.op.count() != row.asks || len(open) != wantOpen {
|
||||
t.Fatalf("open asks %v, asked %d times; wanted asked %d times, %d open", open, v.op.count(), row.asks, wantOpen)
|
||||
}
|
||||
if wantOpen == 0 {
|
||||
d := v.delivery(IDOf("novox/app", readyHead))
|
||||
for _, w := range row.want {
|
||||
if d.Ready == nil || !strings.Contains(d.Ready.Waits, w) {
|
||||
t.Fatalf("the delivery's wait does not say %q: %+v", w, d.Ready)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if h := v.op.ask(open[0]).Headline; row.head != "" && !strings.HasPrefix(h, row.head) {
|
||||
t.Fatalf("the headline is %q; wanted it to start %q", h, row.head)
|
||||
}
|
||||
e := v.op.ask(open[0]).Explanation
|
||||
for _, w := range row.want {
|
||||
if !strings.Contains(e, w) {
|
||||
t.Fatalf("the ask does not say %q:\n%s", w, e)
|
||||
}
|
||||
}
|
||||
for _, w := range row.not {
|
||||
if strings.Contains(e, w) {
|
||||
t.Fatalf("the ask says %q:\n%s", w, e)
|
||||
}
|
||||
}
|
||||
if err := occ.Glance(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC3: a refused merge is not asked again by any timer; only an event (here, mergeAgainNow called directly, as a
|
||||
// verdict, an Approve or a composed check does) asks it again.
|
||||
func TestAMergeRefusedIsSaidAtOnceAndAskedAgainOnlyOnAnEvent(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check is pending")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "refused it since") {
|
||||
t.Fatalf("a merge refused is not said at once: %+v", lines)
|
||||
}
|
||||
w.later(time.Hour)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked again with no event: %v", w.forge.merges)
|
||||
}
|
||||
again := w.delivery(IDOf("novox/app", readyHead))
|
||||
w.h.mu.Lock()
|
||||
w.h.mergeAgainNow(again) // what a verdict, an Approve or a composed check does
|
||||
w.h.mu.Unlock()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("not asked again on an event: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC1: a refused merge, approved and ready, is asked again at once when the forge's forge.status says one of
|
||||
// the merge checks (mesh/merge-gate, mesh/repo-check) is success for the commit the merge was refused at — the
|
||||
// delivery's approved head — and merges once the tool accepts (novox/hq mesh-catalog#366: the verdict reached this
|
||||
// owner before the forge wrote the statuses, the merge asked then was refused on a pending check, and nothing asked
|
||||
// it again when the statuses turned success).
|
||||
func TestAMergeRefusedIsAskedAgainWhenItsCheckTurnsSuccessOnTheForge(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("not asked once, refused: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "app", SHA: readyHead, Context: "mesh/repo-check", State: "success"})
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("not asked again when its check turned success: %v", w.forge.merges)
|
||||
}
|
||||
if w.delivery(IDOf("novox/app", readyHead)).Ready.Merged == nil {
|
||||
t.Fatalf("it did not merge once the tool accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC2: a status of another context, of another commit, or a state other than success changes nothing for a
|
||||
// refused merge: it is not asked again.
|
||||
func TestAMergeRefusedIsNotAskedAgainOnAStatusThatDoesNotMatch(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("not asked once, refused: %v", w.forge.merges)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
for _, e := range []ForgeStatusEvent{
|
||||
{Owner: "novox", Repo: "app", SHA: readyHead, Context: "some/other-check", State: "success"}, // another context
|
||||
{Owner: "novox", Repo: "app", SHA: pushed, Context: "mesh/repo-check", State: "success"}, // another commit
|
||||
{Owner: "novox", Repo: "app", SHA: readyHead, Context: "mesh/repo-check", State: "pending"}, // another state
|
||||
} {
|
||||
w.h.CommitStatus(e)
|
||||
w.settleAll()
|
||||
}
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked again on a status that should change nothing: %v", w.forge.merges)
|
||||
}
|
||||
if w.delivery(IDOf("novox/app", readyHead)).Ready.Merged != nil {
|
||||
t.Fatalf("merged on a status that should change nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC4: the stalled line for a refused approved head names the events that ask the merge again, and no longer
|
||||
// says "each minute" (removed by issue 1115; false since then — there is no timer).
|
||||
func TestTheStalledLineForARefusedApprovedHeadNamesTheEventsThatAskTheMergeAgain(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check (pending)")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("wanted one stalled line, got %+v", lines)
|
||||
}
|
||||
h2 := lines[0].H2
|
||||
if strings.Contains(h2, "each minute") {
|
||||
t.Fatalf("the stalled line still says 'each minute', false since issue 1115: %q", h2)
|
||||
}
|
||||
for _, want := range []string{"verdict", "Approve", "composed check", "status"} {
|
||||
if !strings.Contains(h2, want) {
|
||||
t.Fatalf("the stalled line does not name %q as asking the merge again: %q", want, h2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
|
||||
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
|
||||
w.settleAll()
|
||||
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 {
|
||||
t.Fatalf("composed, the group is asked once and nothing merges before the Approve: asks %d, merges %v", w.op.count(), w.forge.merges)
|
||||
}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 {
|
||||
t.Fatalf("both members are merged in the same pass on the one Approve: %v", w.forge.merges)
|
||||
}
|
||||
sort.Strings(w.forge.merges)
|
||||
if w.forge.merges[0] != "novox/mesh-catalog#2@"+catHead || w.forge.merges[1] != "novox/mesh-controller#1@"+ctlHead {
|
||||
t.Fatalf("merged %v, wanted both, each at its approved head", w.forge.merges)
|
||||
}
|
||||
for _, got := range w.forge.approvedHeads {
|
||||
if len(got) != 2 || got[0] != "novox/mesh-catalog@"+catHead || got[1] != "novox/mesh-controller@"+ctlHead {
|
||||
t.Fatalf("a member's merge was handed the approved heads %v", got)
|
||||
}
|
||||
}
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a second Approve was asked: %d", w.op.count())
|
||||
}
|
||||
}
|
||||
|
||||
// **A merge into another branch than main is its author's** (novox/hq ADR 0398 §4, issue 948): a green pull request
|
||||
// whose base is not main is never asked or merged by this owner; it says so once on the pull request.
|
||||
func TestAGreenPullRequestIntoAnotherBranchIsNotAskedOrMergedAndSaidOnce(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Base = "release/x"
|
||||
w.h.PullUpdated(p)
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
open := openPull("novox/app", 7, readyHead)
|
||||
open.Base = "release/x"
|
||||
w.forge.open = []OpenPull{open}
|
||||
for range 3 {
|
||||
w.h.SeeOpenPulls()
|
||||
w.settleAll()
|
||||
w.later(2 * time.Minute)
|
||||
}
|
||||
if len(w.forge.merges) != 0 || w.op.count() != 0 {
|
||||
t.Fatalf("a pull request into release/x was merged %v or asked %d", w.forge.merges, w.op.count())
|
||||
}
|
||||
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "another branch") ||
|
||||
!strings.Contains(w.forge.comments[0], "0398") {
|
||||
t.Fatalf("not said exactly once on the pull request: %v", w.forge.comments)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
@@ -546,122 +545,6 @@ func TestTheRecordsOutcomesArriveThroughTheGatesConsumer(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// W11.3 (design 47 *The switch carries today's state in, once*): at the gate's start every group in flight becomes a
|
||||
// record at its heads; a second switch (a restart, or milestone G delivered again after a revert) opens none and asks
|
||||
// nothing; and from then on every merge the gate hands is in its record, whose merge commits the record's outcomes
|
||||
// are found by.
|
||||
func TestTheSwitchCarriesTheGroupsInFlightIntoRecordsOnce(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
l := newLedgerFake(t)
|
||||
w.h.Records = &Records{Ledger: l, Now: w.h.Now}
|
||||
w.h.Merges = recordedMerges{MergeStore: w.merges, records: w.h.Records, members: w.h.groupMembers}
|
||||
// W11.17d: what the old state owed is said as switched acts: every key of owed (G §169).
|
||||
statusKey, _ := mdc.OwedKey("novox/app", 7, readyHead, statusAct("mesh/delivery"))
|
||||
w.old.put(mdc.SwitchedOwed, statusKey, []byte(`{"act":"status","context":"mesh/delivery","state":"success","text":"ready","target":"none"}`), 4)
|
||||
switched := func() []meshdelivery.Owed {
|
||||
var out []meshdelivery.Owed
|
||||
for _, o := range w.owed.said {
|
||||
if _, _, _, ok := mdc.ParseSwitchedActID(o.ID); ok {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
opened, found, err := w.h.Switch()
|
||||
if err != nil || opened != 1 || found != 0 {
|
||||
t.Fatalf("the first switch: %d opened, %d found, %v", opened, found, err)
|
||||
}
|
||||
carried := switched()
|
||||
wantStatus, _ := mdc.SwitchedActID(mdc.SwitchedOwed, statusKey, 4)
|
||||
if len(carried) != 1 || carried[0].ID != wantStatus || carried[0].Act != meshdelivery.OwedActStatus || carried[0].Context != "mesh/delivery" ||
|
||||
carried[0].Commit != readyHead {
|
||||
t.Fatalf("the switch carried %+v", carried)
|
||||
}
|
||||
asked := w.op.count()
|
||||
opened, found, err = w.h.Switch()
|
||||
if err != nil || opened != 0 || found != 1 || w.op.count() != asked {
|
||||
t.Fatalf("the switch again: %d opened, %d found, %v, %d asks (was %d)", opened, found, err, w.op.count(), asked)
|
||||
}
|
||||
if again := switched(); len(again) != 2 || again[1].ID != wantStatus {
|
||||
t.Fatalf("the switch again said %+v, wanted the same act under the same id", again)
|
||||
}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
records, err := w.h.Records.Current()
|
||||
if err != nil || len(records) != 1 {
|
||||
t.Fatalf("records %+v %v: the handed merge opened another", records, err)
|
||||
}
|
||||
r := records[0]
|
||||
if r.Milestone != "feat/g" || r.Group == nil || r.Group.Ask == "" || len(r.Group.Pulls) != 2 || len(r.Group.Members) != 2 {
|
||||
t.Fatalf("the record's group state %+v", r.Group)
|
||||
}
|
||||
for _, p := range r.Group.Pulls {
|
||||
if p.State != string(mdc.MergeMerged) || p.Commit == "" {
|
||||
t.Fatalf("a member's merge is not in the record: %+v", p)
|
||||
}
|
||||
}
|
||||
c := &StepConsumer{Records: w.h.Records, Families: stepFamilies, Now: w.h.Now}
|
||||
body := `{"v":1,"kind":"taken","key":"m.00000000000000000001","what":"revision","merges":[{"repository":"` + gCat +
|
||||
`","number":2,"merge":"` + r.Group.Pulls[1].Commit + `"}],"since":"2026-11-09T20:00:00Z"}`
|
||||
if err := c.Hear(stdio.Envelope{Key: "mesh-delivery-indexer.take-in.taken", Body: json.RawMessage(body)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back, _ := w.h.Records.Read(r.Instance); back.Outcomes[OutcomeKey("", "", "", "take-in")].Outcome != OutcomeTakenIn {
|
||||
t.Fatalf("the take-in of the group's merge is not its record's outcome: %+v", back.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
// W11.9 (design 47 *The delivery record*): the gate says heads-replaced when a candidate's heads were replaced or its pull
|
||||
// request closed, keyed as the candidate's revisions are (the heads' hash), so the indexer and the assigner drop its graphs.
|
||||
func TestACandidatesHeadsReplacedOrClosedAreSaid(t *testing.T) {
|
||||
a, b, c := strings.Repeat("a", 40), strings.Repeat("b", 40), strings.Repeat("c", 40)
|
||||
setup := func(t *testing.T) (*world, *[]headsReplaced) {
|
||||
w := newWorld(t)
|
||||
var said []headsReplaced
|
||||
w.h.SaySeat = func(subject string, body any, id string) error {
|
||||
if subject != meshdelivery.SubjectHeadsReplaced {
|
||||
t.Errorf("said on %s", subject)
|
||||
}
|
||||
e := body.(headsReplaced)
|
||||
if id != "heads-replaced."+e.Group+"."+e.Heads {
|
||||
t.Errorf("id %s for %+v", id, e)
|
||||
}
|
||||
said = append(said, e)
|
||||
return nil
|
||||
}
|
||||
w.h.PullUpdated(pr("novox/app", 7, a, "feat/x", "src/a.go"))
|
||||
w.h.PullUpdated(pr("novox/lib", 3, b, "feat/x", "src/b.go"))
|
||||
w.settleAll()
|
||||
return w, &said
|
||||
}
|
||||
hashOf := func(heads ...mdc.Head) string {
|
||||
h, err := mdc.HeadsHash(heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
group := hashOf(mdc.Head{Repository: "novox/app", Number: 7, Head: a}, mdc.Head{Repository: "novox/lib", Number: 3, Head: b})
|
||||
t.Run("a member's new head replaces the group's candidate", func(t *testing.T) {
|
||||
w, said := setup(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, c, "feat/x", "src/a.go"))
|
||||
w.settleAll()
|
||||
if len(*said) != 1 || (*said)[0].Group != "feat/x" || (*said)[0].Heads != group || (*said)[0].Head != c ||
|
||||
(*said)[0].Closed || (*said)[0].Repository != "novox/app" || (*said)[0].Number != 7 {
|
||||
t.Fatalf("said %+v (the candidate %s)", *said, group)
|
||||
}
|
||||
})
|
||||
t.Run("a member's pull request closed replaces the group's candidate", func(t *testing.T) {
|
||||
w, said := setup(t)
|
||||
w.h.PullClosed(pr("novox/lib", 3, b, "feat/x"))
|
||||
w.settleAll()
|
||||
if len(*said) != 1 || (*said)[0].Heads != group || !(*said)[0].Closed || (*said)[0].Head != "" || (*said)[0].Number != 3 {
|
||||
t.Fatalf("said %+v (the candidate %s)", *said, group)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// handActsFake is the hand-act log: what it took, or a refusal.
|
||||
type handActsFake struct {
|
||||
took []meshhandactsclient.Request
|
||||
@@ -921,52 +804,6 @@ func TestTheRecordsLedgerSaysFillingFullAndRoom(t *testing.T) {
|
||||
t.Fatalf("gate_records says the unread fill as %v", line["fill"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a merge refused for a full ledger is held, then handed with room once below 80 %", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
l := newLedgerFake(t)
|
||||
var events []said
|
||||
w.h.Records = &Records{Ledger: l, Now: w.h.Now, Say: saying(&events)}
|
||||
w.h.Merges = recordedMerges{MergeStore: w.merges, records: w.h.Records, members: w.h.groupMembers}
|
||||
l.fill = filled(capBytes)
|
||||
allGreen(w)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a full ledger let %v merge", w.forge.merges)
|
||||
}
|
||||
w.h.mu.Lock()
|
||||
since := w.h.heldFull()
|
||||
w.h.mu.Unlock()
|
||||
if since == nil || len(events) == 0 || events[0].subject != meshdelivery.SubjectFull {
|
||||
t.Fatalf("the merge is not held for room (%v), said %+v", since, events)
|
||||
}
|
||||
// Room for a record but past 80 %: still held.
|
||||
l.fill = filled(capBytes * 85 / 100)
|
||||
w.later(askAgainAfter + time.Second)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 0 {
|
||||
t.Fatalf("a ledger past 80 %% let %v merge after a full", w.forge.merges)
|
||||
}
|
||||
// Below 80 %: room said once, the held mark cleared, the group merged.
|
||||
l.fill = filled(capBytes / 2)
|
||||
w.later(askAgainAfter + time.Second)
|
||||
w.settleAll()
|
||||
w.settleAll()
|
||||
rooms := 0
|
||||
for _, e := range events {
|
||||
if e.subject == meshdelivery.SubjectRoom {
|
||||
rooms++
|
||||
}
|
||||
}
|
||||
w.h.mu.Lock()
|
||||
since = w.h.heldFull()
|
||||
w.h.mu.Unlock()
|
||||
if rooms != 1 || since != nil || len(w.forge.merges) == 0 {
|
||||
t.Fatalf("room said %d times, held since %v, merges %v", rooms, since, w.forge.merges)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ownersFake answers the owners' reads: the fingerprint the assignment graph names, and the fingerprint and phase of the have.
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
)
|
||||
|
||||
@@ -253,41 +252,3 @@ func (rs *Records) sayEnded(r *Record) error {
|
||||
return rs.say(meshdelivery.SubjectRecordEnded, recordEnded{Record: r.Instance, Milestone: r.Milestone,
|
||||
ForgeMade: r.ForgeMade, Away: r.Ended.Away, At: r.Ended.At}, "record-ended."+r.Instance)
|
||||
}
|
||||
|
||||
// headsReplaced is the seat's `heads-replaced` body: a candidate's heads replaced or its pull request closed.
|
||||
type headsReplaced struct {
|
||||
Group string `json:"group"`
|
||||
Heads string `json:"heads"`
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
Head string `json:"head,omitempty"`
|
||||
Closed bool `json:"closed,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// sayHeadsReplaced says that the candidate of these members' heads means nothing any more, since one member's head moved
|
||||
// to head or (closed) its pull request closed; the heads are keyed as the candidate's revisions are (HeadsHash). Under the
|
||||
// lock; the say itself is the work's, outside it.
|
||||
func (h *Holder) sayHeadsReplaced(group string, members []*Delivery, moved *Delivery, head string, closed bool) {
|
||||
if h.SaySeat == nil || len(members) == 0 {
|
||||
return
|
||||
}
|
||||
hash, err := mdc.HeadsHash(headsOf(members))
|
||||
if err != nil {
|
||||
h.refuse(fmt.Errorf("the candidate of %s cannot be said replaced: %v", group, err))
|
||||
return
|
||||
}
|
||||
e := headsReplaced{Group: group, Heads: hash, Repository: strings.ToLower(moved.Repository), Number: moved.Number,
|
||||
Closed: closed, At: h.Now().UTC()}
|
||||
if !closed {
|
||||
e.Head = strings.ToLower(head)
|
||||
}
|
||||
say := h.SaySeat
|
||||
id := "heads-replaced." + group + "." + hash
|
||||
h.cancels = append(h.cancels, func() {
|
||||
if err := say(meshdelivery.SubjectHeadsReplaced, e, id); err != nil {
|
||||
h.Logf("[mesh-delivery] the candidate %s of %s is not said replaced: %v", hash, group, err)
|
||||
}
|
||||
})
|
||||
h.Poke()
|
||||
}
|
||||
|
||||
@@ -144,58 +144,3 @@ func (rs *Records) fillLine() map[string]any {
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// heldFull is the earliest time a merge was held for a full ledger, of every delivery that waits for room. Under the
|
||||
// lock.
|
||||
func (h *Holder) heldFull() *time.Time {
|
||||
var since *time.Time
|
||||
for _, d := range h.deliveries {
|
||||
if r := d.Ready; r != nil && r.HeldFull != nil && (since == nil || r.HeldFull.Before(*since)) {
|
||||
since = r.HeldFull
|
||||
}
|
||||
}
|
||||
return since
|
||||
}
|
||||
|
||||
// roomFor clears every delivery's wait for room. Under the lock.
|
||||
func (h *Holder) roomFor() {
|
||||
for _, d := range h.deliveries {
|
||||
if r := d.Ready; r != nil && r.HeldFull != nil {
|
||||
r.HeldFull = nil
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// holdFull keeps that target's merge waits for room. Under the lock.
|
||||
func (h *Holder) holdFull(target string, now time.Time) {
|
||||
if d := h.deliveries[target]; d != nil && d.Ready != nil && d.Ready.HeldFull == nil {
|
||||
at := now
|
||||
d.Ready.HeldFull = &at
|
||||
h.keep(d)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRoom is whether a merge must wait because the records' ledger was full: while any merge waits for room, none
|
||||
// is handed until RoomAgain gives it.
|
||||
func (h *Holder) waitForRoom(target string) bool {
|
||||
if h.Records == nil {
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
since := h.heldFull()
|
||||
h.mu.Unlock()
|
||||
if since == nil {
|
||||
return false
|
||||
}
|
||||
ok, err := h.Records.RoomAgain(*since)
|
||||
if !ok {
|
||||
h.giveBack(target, err)
|
||||
return true
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.roomFor()
|
||||
h.mu.Unlock()
|
||||
h.Logf("[mesh-delivery] the records' ledger has room again: the merges held for it are handed")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -208,3 +209,11 @@ func recordTools(h *Holder, acts handActs) []stdio.Tool {
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// timeText is a time as the gate keeps it, RFC 3339; empty when not said.
|
||||
func timeText(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
// The gate's settings (novox/hq ADR 0505 §4, §5), merged by the mesh into one JSON file the env names. Each has a
|
||||
// The gate's setting (design 47 *The delivery record*), merged by the mesh into one JSON file the env names. Each has a
|
||||
// default here, so a gate started without the file, or with a value it cannot read, keeps the decided bound and says
|
||||
// why once.
|
||||
|
||||
@@ -12,25 +12,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRefusalFloor is the least a refusal the gate is already answering (the trunk moved and its check was
|
||||
// asked again) waits before it is said as stalled, when the setting refusal-stall-floor-minutes gives none.
|
||||
DefaultRefusalFloor = 10 * time.Minute
|
||||
// DefaultAuthorBound is how long a delivery whose cure is its author's new head waits for one before the operator
|
||||
// hears of it, when the setting author-new-head-minutes gives none (ADR 0505 §5).
|
||||
DefaultAuthorBound = 2 * time.Hour
|
||||
)
|
||||
|
||||
// Settings are the operator's values for this module.
|
||||
type Settings struct {
|
||||
RefusalFloorMinutes *int `json:"refusal-stall-floor-minutes"`
|
||||
AuthorBoundMinutes *int `json:"author-new-head-minutes"`
|
||||
// RecordRetentionDays is how many days a delivery record is kept from when it opened (design 47 *The delivery
|
||||
// record*); nil keeps DefaultRecordRetention.
|
||||
RecordRetentionDays *int `json:"record-retention"`
|
||||
}
|
||||
|
||||
// readSettings reads the settings file; a value that is not a whole number of minutes, one or more, is refused.
|
||||
// readSettings reads the settings file; a value that is not a whole number of days, from 1 to 3650, is refused.
|
||||
func readSettings(path string) (Settings, error) {
|
||||
var s Settings
|
||||
if path == "" {
|
||||
@@ -44,20 +33,6 @@ func readSettings(path string) (Settings, error) {
|
||||
if err := json.Unmarshal(raw, &loose); err != nil {
|
||||
return s, fmt.Errorf("the settings file is not JSON: %v", err)
|
||||
}
|
||||
for name, into := range map[string]**int{"refusal-stall-floor-minutes": &s.RefusalFloorMinutes,
|
||||
"author-new-head-minutes": &s.AuthorBoundMinutes} {
|
||||
switch v := loose[name].(type) {
|
||||
case nil:
|
||||
case float64:
|
||||
n := int(v)
|
||||
if v < 1 || v != float64(n) {
|
||||
return Settings{}, fmt.Errorf("%s is not a whole number of minutes, one or more", name)
|
||||
}
|
||||
*into = &n
|
||||
default:
|
||||
return Settings{}, fmt.Errorf("%s is not a number", name)
|
||||
}
|
||||
}
|
||||
switch v := loose["record-retention"].(type) {
|
||||
case nil:
|
||||
case float64:
|
||||
@@ -72,28 +47,6 @@ func readSettings(path string) (Settings, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// factsAt are the facts a settle reads at now: the moment. No setting switches whether a check gates a merge: the check
|
||||
// gates the ask, and the merge judges the operator's Approve alone (novox/hq ADR 0661 §2, §3).
|
||||
func (h *Holder) factsAt(now time.Time) Facts {
|
||||
return Facts{Now: now}
|
||||
}
|
||||
|
||||
// minutesOr is a setting's minutes, or the default when it gives none.
|
||||
func minutesOr(n *int, def time.Duration) time.Duration {
|
||||
if n == nil {
|
||||
return def
|
||||
}
|
||||
return time.Duration(*n) * time.Minute
|
||||
}
|
||||
|
||||
// refusalFloor is the least an answered refusal waits before it is said as stalled (ADR 0505 §4).
|
||||
func (h *Holder) refusalFloor() time.Duration {
|
||||
if h.Settings == nil {
|
||||
return DefaultRefusalFloor
|
||||
}
|
||||
return minutesOr(h.Settings().RefusalFloorMinutes, DefaultRefusalFloor)
|
||||
}
|
||||
|
||||
// recordRetention is how long a delivery record is kept from when it opened.
|
||||
func (h *Holder) recordRetention() time.Duration {
|
||||
if h.Settings == nil {
|
||||
@@ -104,11 +57,3 @@ func (h *Holder) recordRetention() time.Duration {
|
||||
}
|
||||
return DefaultRecordRetention
|
||||
}
|
||||
|
||||
// authorBound is how long a delivery that needs its author's new head waits before the operator hears of it (§5).
|
||||
func (h *Holder) authorBound() time.Duration {
|
||||
if h.Settings == nil {
|
||||
return DefaultAuthorBound
|
||||
}
|
||||
return minutesOr(h.Settings().AuthorBoundMinutes, DefaultAuthorBound)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,49 @@
|
||||
package main
|
||||
|
||||
// The switch to the delivery record (design 47 *The delivery record*: "The switch carries today's state in, once"): at
|
||||
// the new gate's start, every group in flight and its merge as `merges` holds it become records, keyed by the
|
||||
// milestone and the heads, and from then on every merge the gate hands is written into its record. It is safe to run
|
||||
// twice: a group in flight whose record is there already leaves it as it is (Records.Open), and a group state the
|
||||
// record holds already is not written again (Records.SetGroup), so delivering milestone G again after a revert makes no
|
||||
// second record and asks nothing again.
|
||||
// the new gate's start, every group in flight and its merge as the old sections hold them become records, keyed by the
|
||||
// milestone and the heads. It is safe to run twice: a group in flight whose record is there already leaves it as it is
|
||||
// (Records.Open), and a group state the record holds already is not written again (Records.SetGroup), so delivering milestone
|
||||
// G again after a revert makes no second record and asks nothing again. The old sections are read, never written (§613).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// milestoneOf is the milestone a delivery's record is kept under: the name its group's Approve is asked and its merge is
|
||||
// handed by (askGroup), the one name the gate gives a group's delivery today.
|
||||
func milestoneOf(d *Delivery) string { return askGroup(d) }
|
||||
// oldDelivery is a delivery as the old section `deliveries` kept it, as far as the switch reads it.
|
||||
type oldDelivery struct {
|
||||
ID string `json:"id"`
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Number int `json:"number"`
|
||||
Branch string `json:"branch"`
|
||||
Group string `json:"group"`
|
||||
State string `json:"state"`
|
||||
InSource bool `json:"in_source"`
|
||||
NewerHead string `json:"newer_head"`
|
||||
ClosedUnmerged bool `json:"closed_unmerged"`
|
||||
}
|
||||
|
||||
// recordGroupOf is a merge, as `merges` holds it, as the record's group state.
|
||||
// milestone is the name a delivery's record is kept under: its group's, a lone pull request's branch, its id.
|
||||
func (d oldDelivery) milestone() string {
|
||||
switch {
|
||||
case d.Group != "":
|
||||
return d.Group
|
||||
case d.Branch != "":
|
||||
return d.Branch
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// recordGroupOf is a merge, as the old section merges holds it, as the record's group state.
|
||||
func recordGroupOf(m Merge, members []string) RecordGroup {
|
||||
g := RecordGroup{Group: m.Group, Members: members, Ask: m.Ask, Approved: m.Approved}
|
||||
for _, p := range m.Pulls {
|
||||
@@ -30,33 +53,6 @@ func recordGroupOf(m Merge, members []string) RecordGroup {
|
||||
return g
|
||||
}
|
||||
|
||||
// recordedMerges is the gate's merges with every merge it writes also written into its delivery record: the record holds
|
||||
// the group's state, which `merges` holds today.
|
||||
type recordedMerges struct {
|
||||
MergeStore
|
||||
records *Records
|
||||
members func(group string) []string
|
||||
}
|
||||
|
||||
// Put writes the group's merge into its delivery record first, so a ledger with no room refuses it before the merge is
|
||||
// handed to the forge bridge (G §181), then into the state merges the bridge follows.
|
||||
func (m recordedMerges) Put(group string, v Merge) error {
|
||||
if m.records != nil && len(v.Approved) > 0 {
|
||||
r, _, err := m.records.Open(group, v.Approved, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the merge of %s is handed but not in its delivery record: %w", group, err)
|
||||
}
|
||||
var members []string
|
||||
if m.members != nil {
|
||||
members = m.members(group)
|
||||
}
|
||||
if err := m.records.SetGroup(r, recordGroupOf(v, members)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return m.MergeStore.Put(group, v)
|
||||
}
|
||||
|
||||
// switchGroup is one group in flight as the switch finds it: its milestone, its heads and its members.
|
||||
type switchGroup struct {
|
||||
milestone string
|
||||
@@ -64,15 +60,31 @@ type switchGroup struct {
|
||||
members []string
|
||||
}
|
||||
|
||||
// inFlight are the groups in flight, under the lock: every milestone with a delivery not final, its live members' heads
|
||||
// as `<repository>@<number>` to the full commit.
|
||||
func (h *Holder) inFlight() []switchGroup {
|
||||
// inFlight are the groups in flight in the old section deliveries: every milestone with a delivery not final, its live
|
||||
// members' heads as `<repository>@<number>` to the full commit.
|
||||
func (h *Holder) inFlight() ([]switchGroup, error) {
|
||||
if h.Old == nil {
|
||||
return nil, nil
|
||||
}
|
||||
keys, err := h.Old.Keys("deliveries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the old section deliveries cannot be read for the switch: %w", err)
|
||||
}
|
||||
by := map[string]*switchGroup{}
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() && !(d.State == Merged && !d.InSource) || d.Number == 0 || d.NewerHead != "" || d.ClosedUnmerged {
|
||||
for _, key := range keys {
|
||||
raw, _, err := h.Old.Get("deliveries", key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the old delivery %s cannot be read for the switch: %w", key, err)
|
||||
}
|
||||
var d oldDelivery
|
||||
if raw == nil || json.Unmarshal(raw, &d) != nil || d.ID == "" {
|
||||
continue // a mark an earlier owner kept, or no delivery
|
||||
}
|
||||
final := d.State == "merged" || d.State == "held" || d.State == "closed"
|
||||
if final && !(d.State == "merged" && !d.InSource) || d.Number == 0 || d.NewerHead != "" || d.ClosedUnmerged {
|
||||
continue
|
||||
}
|
||||
name := milestoneOf(d)
|
||||
name := d.milestone()
|
||||
g := by[name]
|
||||
if g == nil {
|
||||
g = &switchGroup{milestone: name, heads: map[string]string{}}
|
||||
@@ -87,7 +99,7 @@ func (h *Holder) inFlight() []switchGroup {
|
||||
out = append(out, *g)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].milestone < out[j].milestone })
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Switch carries today's state into the records, once and safe to run again: each group in flight becomes a record at
|
||||
@@ -97,9 +109,10 @@ func (h *Holder) Switch() (opened, found int, err error) {
|
||||
if h.Records == nil {
|
||||
return 0, 0, nil
|
||||
}
|
||||
h.mu.Lock()
|
||||
groups := h.inFlight()
|
||||
h.mu.Unlock()
|
||||
groups, err := h.inFlight()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
for _, g := range groups {
|
||||
var merge *Merge
|
||||
if h.Merges != nil {
|
||||
@@ -129,7 +142,7 @@ func (h *Holder) Switch() (opened, found int, err error) {
|
||||
if err := h.carryOwed(); err != nil {
|
||||
return opened, found, err
|
||||
}
|
||||
return opened, found, nil
|
||||
return opened, found, h.carryApprovals()
|
||||
}
|
||||
|
||||
// oldMerge is a group's merge as the old state merges holds it, with its key and revision; nil when it holds none.
|
||||
@@ -180,23 +193,87 @@ func (h *Holder) carryOwed() error {
|
||||
h.Refuse(fmt.Errorf("the owed act %s is not carried by the switch: %v", key, errors.Join(kerr, derr, ierr)))
|
||||
continue
|
||||
}
|
||||
if err := h.Owed.Say(seatOwedOf(o, id, repository, number, head, &now)); err != nil {
|
||||
said := seatOwedOf(o, id, repository, number, head, &now)
|
||||
if r := h.recordHolding(repository, head); r != nil {
|
||||
err = h.owe(r, said)
|
||||
} else {
|
||||
err = h.Owed.Say(said)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("the owed act %s cannot be said for the switch: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupMembers are the members' ids of the milestone a merge is handed under, under the lock.
|
||||
func (h *Holder) groupMembers(name string) []string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var out []string
|
||||
for _, d := range h.deliveries {
|
||||
if milestoneOf(d) == name && d.NewerHead == "" && !d.ClosedUnmerged {
|
||||
out = append(out, d.ID)
|
||||
// recordHolding is the open record that holds a pull request of the repository at the head, nil when none does.
|
||||
func (h *Holder) recordHolding(repository, head string) *Record {
|
||||
if h.Records == nil {
|
||||
return nil
|
||||
}
|
||||
current, err := h.Records.Current()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, r := range current {
|
||||
for _, m := range membersOf(r) {
|
||||
if strings.EqualFold(m.Repository, repository) && sameCommit(m.Head, head) {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
return nil
|
||||
}
|
||||
|
||||
// approver is who an Approve carried across the switch was given by (design 47 §542).
|
||||
const approver = "the operator"
|
||||
|
||||
// carryApprovals says, for each key of the old section merges the old bridge had not performed, `approval-carried` of each
|
||||
// pull request it had not merged, once (design 47 §542, §592): the runner takes it as that pull request's `approval` step.
|
||||
// The keys stay, read no more. Said at every switch, it is the same event under the same id each time, which the bus keeps once.
|
||||
func (h *Holder) carryApprovals() error {
|
||||
if h.Old == nil || h.SaySeat == nil {
|
||||
return nil
|
||||
}
|
||||
names, err := h.Old.Keys(mdc.SwitchedMerges)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the old section merges cannot be read for the switch: %w", err)
|
||||
}
|
||||
now := h.Now().UTC()
|
||||
for _, key := range names {
|
||||
raw, _, err := h.Old.Get(mdc.SwitchedMerges, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the old merge %s cannot be read for the switch: %w", key, err)
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
m, err := DecodeMerge(raw)
|
||||
if err != nil {
|
||||
h.Refuse(fmt.Errorf("the old merge %s is not carried by the switch: %v", key, err))
|
||||
continue
|
||||
}
|
||||
for approved, head := range m.Approved {
|
||||
repository, number, ok := parseApprovedKey(approved)
|
||||
if !ok || mergedIn(m, repository, number) {
|
||||
continue
|
||||
}
|
||||
body := seat.ApprovalCarriedEvent{Approver: approver, At: now, Commit: head, Pull: int64(number), Repository: repository}
|
||||
subject := seat.SubjectApprovalCarried(keys.EscapeName(repository), fmt.Sprint(number))
|
||||
if err := h.SaySeat(subject, body, fmt.Sprintf("approval-carried.%s.%d", repository, number)); err != nil {
|
||||
return fmt.Errorf("the approval of %s#%d cannot be said carried: %w", repository, number, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergedIn is whether a merge's pull request of that repository and number was merged.
|
||||
func mergedIn(m Merge, repository string, number int) bool {
|
||||
for _, p := range m.Pulls {
|
||||
if strings.EqualFold(p.Repository, repository) && p.Number == number && p.State == mdc.MergeMerged {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The state table (novox/hq ADR 0239 decision 2, to-be 47, cut by ADR 0539 §17): every transition a delivery may take,
|
||||
// each with its guard, compiled once. The gate keeps the merge's states only: proposed, ready, rejected, merged, held
|
||||
// (a merge unchecked) and closed. It asks the Approve and merges once; nothing after the merge is derived here, and a
|
||||
// source revision's delivered state is the view's, from source.main, the wants and the haves. The code moves a delivery
|
||||
// only through Apply, which finds the row for the delivery's state and the event, runs its guard and refuses anything
|
||||
// else, naming what was asked. A test walks the table: every row it holds, and every pair it does not.
|
||||
|
||||
// State is where a delivery is.
|
||||
type State string
|
||||
|
||||
// The states, in the order a delivery passes through them. None is a delivery not yet made: the rows from it are how
|
||||
// one comes to exist.
|
||||
const (
|
||||
None State = ""
|
||||
Proposed State = "proposed"
|
||||
Ready State = "ready"
|
||||
Rejected State = "rejected"
|
||||
// Merged is a change on the trunk: the gate's last word on it.
|
||||
Merged State = "merged"
|
||||
// Held is a merge that reached the trunk without a passing check: only a person decides what follows.
|
||||
Held State = "held"
|
||||
// Closed is a delivery that ended before the merge: its pull request closed unmerged, or a newer head took its place.
|
||||
Closed State = "closed"
|
||||
)
|
||||
|
||||
// AllStates are every state a delivery can be in, for the table's own test and its verb.
|
||||
var AllStates = []State{Proposed, Ready, Rejected, Merged, Held, Closed}
|
||||
|
||||
// Final is whether nothing follows a state.
|
||||
func (s State) Final() bool { return s == Merged || s == Held || s == Closed }
|
||||
|
||||
// legacyState reads a state an earlier owner kept as one of today's: a change that reached the trunk is merged (held
|
||||
// only when it was held for a merge without a passing check), one that ended before it closed, and a verdict taken but
|
||||
// not yet acted on is proposed again.
|
||||
func legacyState(d *Delivery) State {
|
||||
switch d.State {
|
||||
case Proposed, Ready, Rejected, Merged, Closed, None:
|
||||
return d.State
|
||||
case "checked":
|
||||
return Proposed
|
||||
case Held:
|
||||
if strings.HasPrefix(d.HeldWhy, "merged without a passing check") {
|
||||
return Held
|
||||
}
|
||||
return Merged
|
||||
case "published", "delivering", "delivered", "covered", "retired", "failed":
|
||||
return Merged
|
||||
case "superseded", "stopped":
|
||||
return Closed
|
||||
}
|
||||
return d.State
|
||||
}
|
||||
|
||||
// Event is what moves a delivery.
|
||||
type Event string
|
||||
|
||||
// The events. Observed ones are taken by settle whenever their guard holds; the others are acts — a person's, or healer
|
||||
// H2's — taken only when asked.
|
||||
const (
|
||||
EvAnnounced Event = "announced" // the forge announced a pull request's head
|
||||
EvAccepted Event = "accepted" // its verdict passes
|
||||
EvRefused Event = "refused" // its verdict fails, or the check could not run
|
||||
EvRecheck Event = "recheck" // a person asked for it again, or its group changed
|
||||
EvReask Event = "re-ask" // healer H2 asked a check asked and not answered once more
|
||||
EvLost Event = "check lost" // the build queue holds no check of its head and it was asked: asked again (issue 1274)
|
||||
EvNewHead Event = "new-head" // a newer head of the same pull request
|
||||
EvClosed Event = "closed" // the pull request closed unmerged
|
||||
EvMerged Event = "merged" // it reached the trunk
|
||||
EvMergedUnchecked Event = "merged-unchecked" // it reached the trunk without a passing check
|
||||
EvTrunkMoved Event = "trunk moved" // another merge moved its trunk, and its verdict names the trunk commit before (novox/hq ADR 0323)
|
||||
)
|
||||
|
||||
// HeldUnchecked is the why of a delivery held because its merge reached the trunk without a passing check: the one hold a
|
||||
// person may settle (novox/hq ADR 0556 clause 5).
|
||||
const HeldUnchecked = "merged without a passing check: only a person decides what follows"
|
||||
|
||||
// heldUnchecked is whether a held delivery is held for a merge without a passing check, and reached the trunk.
|
||||
func heldUnchecked(d *Delivery) bool {
|
||||
return d.State == Held && d.MergedAs != "" && strings.HasPrefix(d.HeldWhy, "merged without a passing check")
|
||||
}
|
||||
|
||||
// Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act.
|
||||
type Facts struct {
|
||||
Now time.Time
|
||||
By string
|
||||
Why string
|
||||
}
|
||||
|
||||
// Row is one transition the table holds.
|
||||
type Row struct {
|
||||
From []State
|
||||
Event Event
|
||||
To State
|
||||
// Guard says, in words, what must be true; Holds checks it, answering why not.
|
||||
Guard string
|
||||
Holds func(d *Delivery, f Facts) error
|
||||
// Act says the row is taken only when asked — a person's or H2's — never by settle.
|
||||
Act bool
|
||||
// Says is the transition's why when nobody gave one: the fact that moved it, rather than the guard.
|
||||
Says func(d *Delivery) string
|
||||
}
|
||||
|
||||
func unless(cond bool, why string) error {
|
||||
if cond {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", why)
|
||||
}
|
||||
|
||||
var notFinal = []State{Proposed, Ready, Rejected}
|
||||
|
||||
// Table is every transition there is. The order within one state is the order settle tries them.
|
||||
var Table = []Row{
|
||||
{From: []State{None}, Event: EvAnnounced, To: Proposed,
|
||||
Guard: "a pull request's head the forge announced",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
return unless(d.Number > 0 && d.Commit != "", "no pull request's head")
|
||||
}},
|
||||
{From: notFinal, Event: EvNewHead, To: Closed,
|
||||
Guard: "a newer head of the same pull request",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(d.NewerHead != "", "no newer head") }},
|
||||
{From: notFinal, Event: EvClosed, To: Closed,
|
||||
Guard: "the pull request closed unmerged",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(d.ClosedUnmerged, "the pull request is open") }},
|
||||
{From: []State{Ready}, Event: EvMerged, To: Merged,
|
||||
Guard: "on the trunk its modules follow: merged there",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
return unless(d.MergedAs != "", "not on the trunk yet")
|
||||
}},
|
||||
// **A verdict counts only for the trunk's head** (novox/hq ADR 0323 §7): one naming a trunk commit that is no
|
||||
// longer the trunk's head is kept and shown, and does not count, so the delivery stays proposed and the rows
|
||||
// cannot loop with `trunk moved`; a recheck or the merge tool's ask judges it again.
|
||||
{From: []State{Proposed}, Event: EvAccepted, To: Ready,
|
||||
Guard: "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and " +
|
||||
"the repository's own check did not fail; or the operator's Approve covers this head (the merge judges it alone)",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
if d.approvedAtHead() {
|
||||
return nil // novox/hq ADR 0661 §3: an Approve given stands across a trunk move, and the merge judges it alone
|
||||
}
|
||||
if d.Check == nil {
|
||||
return errors.New("no verdict")
|
||||
}
|
||||
if w := d.outOfDate(); w != "" {
|
||||
return errors.New("its verdict is " + w)
|
||||
}
|
||||
return unless(d.Check.Passes(), "the verdict does not pass")
|
||||
}},
|
||||
{From: []State{Proposed}, Event: EvRefused, To: Rejected,
|
||||
Guard: "a verdict names this commit and the trunk's head as its trunk commit, and the gate failed or could not run, " +
|
||||
"or the repository's own check failed; never once the operator's Approve covers this head",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
if d.approvedAtHead() {
|
||||
// novox/hq ADR 0661 §5: a failure after the Approve is said, and does not undo it.
|
||||
return errors.New("the operator's Approve covers this head: a failure is said, the merge is not refused")
|
||||
}
|
||||
if d.Check == nil {
|
||||
return errors.New("no verdict")
|
||||
}
|
||||
if w := d.outOfDate(); w != "" {
|
||||
return errors.New("its verdict is " + w)
|
||||
}
|
||||
return unless(!d.Check.Passes(), "the verdict passes")
|
||||
}},
|
||||
// **A ready delivery whose verdict went out of date goes back to proposed** (novox/hq ADR 0323 §7): another
|
||||
// merge moved its trunk. A rejected one stays rejected, its status on the forge saying the verdict is out of date.
|
||||
{From: []State{Ready}, Event: EvTrunkMoved, To: Proposed,
|
||||
Guard: "its verdict names a trunk commit that is not its trunk's head, and no Approve covers this head yet",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
if d.approvedAtHead() {
|
||||
// novox/hq ADR 0661 §3: an approved merge waits for no carry; its carry runs after the merge.
|
||||
return errors.New("the operator's Approve covers this head: it merges onto the new trunk, its carry after")
|
||||
}
|
||||
return unless(d.MergedAs == "" && d.outOfDate() != "", "not merged, and its verdict is not out of date")
|
||||
},
|
||||
Says: func(d *Delivery) string { return d.outOfDate() }},
|
||||
{From: []State{Proposed, Rejected}, Event: EvMergedUnchecked, To: Held,
|
||||
Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides what follows",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }},
|
||||
{From: []State{Rejected, Ready}, Event: EvRecheck, To: Proposed, Act: true,
|
||||
Guard: "a person asked, with why, or its group changed",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(f.Why != "", "a recheck says why") }},
|
||||
{From: []State{Proposed}, Event: EvReask, To: Proposed, Act: true,
|
||||
Guard: "its check was asked and no verdict came within its bound, and it was not re-asked before: healer H2 " +
|
||||
"asks it once more, with why — after that it is the operator's",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
switch {
|
||||
case f.Why == "" || f.By == "":
|
||||
return errors.New("a re-ask says who and why")
|
||||
case d.Check != nil:
|
||||
return errors.New("its verdict is known")
|
||||
case d.CheckAsk == nil:
|
||||
return errors.New("its check was never asked: a look asks it, not H2")
|
||||
case d.CheckAsk.Reasked > 0:
|
||||
return errors.New("its check was re-asked once already: the operator's")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
|
||||
// **A check the build queue lost is asked again** (novox/hq issue 1274): a proposed delivery waits for a check, and
|
||||
// the queue, which the table cannot read, holds none of its head. The act (the sweep's or a person's recheck) reads
|
||||
// the queue first and says why; the table keeps it to a delivery that waits for a verdict.
|
||||
{From: []State{Proposed}, Event: EvLost, To: Proposed, Act: true,
|
||||
Guard: "it waits for a check — no verdict yet, or one out of date — nothing of its head is waiting or in flight in " +
|
||||
"the build queue, and who asks says why",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
switch {
|
||||
case f.Why == "" || f.By == "":
|
||||
return errors.New("a lost check's ask says who and why")
|
||||
case d.MergedAs != "":
|
||||
return errors.New("it is merged")
|
||||
case d.Check != nil && d.outOfDate() == "":
|
||||
return errors.New("its verdict is known")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
// rowFor is the row a state and event name; nil when the table holds none.
|
||||
func rowFor(from State, ev Event, act bool) *Row {
|
||||
for i := range Table {
|
||||
r := &Table[i]
|
||||
if r.Event != ev || r.Act != act {
|
||||
continue
|
||||
}
|
||||
for _, s := range r.From {
|
||||
if s == from {
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrRefused is a transition the table does not hold, or whose guard does not.
|
||||
type ErrRefused struct {
|
||||
ID string
|
||||
From State
|
||||
Event Event
|
||||
Why string
|
||||
}
|
||||
|
||||
func (e ErrRefused) Error() string {
|
||||
from := string(e.From)
|
||||
if from == "" {
|
||||
from = "nothing"
|
||||
}
|
||||
return fmt.Sprintf("%s: %s from %s is refused — %s", e.ID, e.Event, from, e.Why)
|
||||
}
|
||||
|
||||
// Apply takes one transition, or refuses it. The delivery keeps the transition, bounded.
|
||||
func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
|
||||
r := rowFor(d.State, ev, act)
|
||||
if r == nil {
|
||||
kind := "observed"
|
||||
if act {
|
||||
kind = "asked"
|
||||
}
|
||||
return Transition{}, ErrRefused{ID: d.ID, From: d.State, Event: ev,
|
||||
Why: "the table holds no such transition (" + kind + ")"}
|
||||
}
|
||||
if err := r.Holds(d, f); err != nil {
|
||||
return Transition{}, ErrRefused{ID: d.ID, From: d.State, Event: ev, Why: "its guard does not hold: " + err.Error()}
|
||||
}
|
||||
why := f.Why
|
||||
if why == "" && r.Says != nil {
|
||||
why = r.Says(d)
|
||||
}
|
||||
if why == "" {
|
||||
why = r.Guard
|
||||
}
|
||||
by := f.By
|
||||
if by == "" && ev == EvMerged && d.MergedAs != "" {
|
||||
// A merge this owner made on the operator's Approve names it as its author (novox/hq issue 722).
|
||||
by = d.Ready.mergedBy()
|
||||
}
|
||||
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by}
|
||||
d.State, d.Since = r.To, f.Now
|
||||
d.Transitions = append(d.Transitions, t)
|
||||
if len(d.Transitions) > keptTransitions {
|
||||
d.Transitions = d.Transitions[len(d.Transitions)-keptTransitions:]
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// keptTransitions is how many transitions a delivery keeps; its note on the commit keeps every one.
|
||||
const keptTransitions = 100
|
||||
|
||||
// settle takes every observed transition whose guard holds, in the table's order, until none does — so a
|
||||
// delivery always stands where its facts put it, whatever order they arrived in.
|
||||
func settle(d *Delivery, f Facts) []Transition {
|
||||
var taken []Transition
|
||||
for range len(Table) + 1 {
|
||||
moved := false
|
||||
for i := range Table {
|
||||
r := &Table[i]
|
||||
if r.Act || !contains(r.From, d.State) {
|
||||
continue
|
||||
}
|
||||
if t, err := Apply(d, r.Event, false, f); err == nil {
|
||||
taken = append(taken, t)
|
||||
moved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !moved {
|
||||
break
|
||||
}
|
||||
}
|
||||
return taken
|
||||
}
|
||||
|
||||
func contains(states []State, s State) bool {
|
||||
for _, x := range states {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Bounds are how long a delivery may be in a state before `stalled` lists it, and what healer H2 may do
|
||||
// then — always a transition the table holds, never one of its own (novox/hq ADR 0239 decision 9). Only a proposed
|
||||
// delivery has one: its check was asked and not answered. A merge past its bound is said by the merge's own stalls.
|
||||
type Bound struct {
|
||||
For time.Duration
|
||||
// H2 is the transitions H2 may take after the bound, by event; empty when the state is the operator's.
|
||||
H2 []Event
|
||||
Says string
|
||||
}
|
||||
|
||||
// Bounds are every state's.
|
||||
var Bounds = map[State]Bound{
|
||||
Proposed: {For: time.Hour, H2: []Event{EvReask}, Says: "its check was asked and not answered: H2 asks it once " +
|
||||
"more, then the check's own watchdog (S6) and the operator speak for it"},
|
||||
}
|
||||
|
||||
// boundSince is when a delivery's bound runs from: its state's start, except for a proposed delivery whose
|
||||
// check this owner asked — its bound is "asked and not answered", so it runs from the ask. One never asked
|
||||
// runs from its start: an ask that cannot be made is still listed.
|
||||
func boundSince(d *Delivery) time.Time {
|
||||
if d.State == Proposed {
|
||||
if d.AskedAgain != nil && d.outOfDate() != "" {
|
||||
return *d.AskedAgain
|
||||
}
|
||||
if d.CheckAsk != nil {
|
||||
return d.CheckAsk.At
|
||||
}
|
||||
return d.Since
|
||||
}
|
||||
return d.Since
|
||||
}
|
||||
|
||||
// boundOf is a delivery's bound.
|
||||
func boundOf(d *Delivery) (Bound, bool) {
|
||||
if d.State == Proposed && d.outOfDate() != "" {
|
||||
// Its check is out of date and not asked: nothing is running for it, and it costs nothing (ADR 0323 §7).
|
||||
// Its bound starts when its check is asked: a recheck puts the verdict away; the merge tool's ask announces the
|
||||
// head again, and its bound runs from then.
|
||||
if d.AskedAgain == nil {
|
||||
return Bound{}, false
|
||||
}
|
||||
b := Bounds[Proposed]
|
||||
b.H2, b.Says = nil, "its verdict is "+d.outOfDate()+"; its check was asked again and not answered: the operator's"
|
||||
return b, true
|
||||
}
|
||||
b, bounded := Bounds[d.State]
|
||||
return b, bounded
|
||||
}
|
||||
|
||||
// pastBound is whether a delivery has been where it is longer than its state's bound.
|
||||
func pastBound(d *Delivery, now time.Time) bool {
|
||||
b, bounded := boundOf(d)
|
||||
return bounded && !d.State.Final() && (b.For == 0 || now.Sub(boundSince(d)) > b.For)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The table walked (novox/hq ADR 0239 decision 2, cut by ADR 0539 §17): every row it holds can be taken when its guard
|
||||
// holds and is refused when it does not; every pair it does not hold is refused, by name; and the rules the table exists
|
||||
// for are true of the table itself, not of the code around it.
|
||||
|
||||
// aDeliveryFor is a delivery whose facts make the row's guard hold.
|
||||
func aDeliveryFor(r Row) (*Delivery, Facts) {
|
||||
now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
d := &Delivery{ID: "novox/app@c0ffee000000", Repository: "novox/app", Commit: "c0ffee000000", Number: 7}
|
||||
f := Facts{Now: now}
|
||||
switch r.Event {
|
||||
case EvNewHead:
|
||||
d.NewerHead = "beef"
|
||||
case EvClosed:
|
||||
d.ClosedUnmerged = true
|
||||
case EvMerged, EvMergedUnchecked:
|
||||
d.MergedAs = "aaaa1111"
|
||||
case EvAccepted:
|
||||
d.Check = &Verdict{Gate: "pass"}
|
||||
case EvRefused:
|
||||
d.Check = &Verdict{Gate: "fail"}
|
||||
case EvRecheck, EvLost:
|
||||
f.Why, f.By = "a person's reason", "jochen"
|
||||
case EvReask:
|
||||
f.Why, f.By = "delivery stalled", byH2
|
||||
d.CheckAsk = &CheckAsk{ID: "check-1", At: now.Add(-2 * time.Hour), By: byOwner}
|
||||
case EvTrunkMoved:
|
||||
d.Check = &Verdict{Gate: "pass", Head: "c0ffee000000", Trunk: "d28ca95bbbbb"}
|
||||
d.TrunkNow = "690966dccccccccc"
|
||||
}
|
||||
return d, f
|
||||
}
|
||||
|
||||
// The merge's states only (ADR 0539 §17): proposed, ready, rejected, merged, held and closed. Nothing after the merge.
|
||||
func TestTheTableHoldsTheSixStatesOfTheMergeAndNothingAfter(t *testing.T) {
|
||||
want := map[State]bool{Proposed: true, Ready: true, Rejected: true, Merged: true, Held: true, Closed: true}
|
||||
if len(AllStates) != len(want) {
|
||||
t.Fatalf("the states are %v, wanted exactly proposed, ready, rejected, merged, held and closed", AllStates)
|
||||
}
|
||||
for _, s := range AllStates {
|
||||
if !want[s] {
|
||||
t.Errorf("%q is a state of the table: ADR 0539 §17 keeps six", s)
|
||||
}
|
||||
}
|
||||
gone := []Event{"appeared", "adopted", "checked", "go", "hold", "release", "done", "failed", "superseded", "stop",
|
||||
"history", "retire", "covered", "lifted", "rewalk", "backup failed"}
|
||||
for _, r := range Table {
|
||||
for _, g := range gone {
|
||||
if r.Event == g {
|
||||
t.Errorf("the table holds the event %q: it belongs to a delivery state that is gone", g)
|
||||
}
|
||||
}
|
||||
for _, s := range append([]State{r.To}, r.From...) {
|
||||
if s != None && !want[s] {
|
||||
t.Errorf("the row %v —%s→ %s names the state %q", r.From, r.Event, r.To, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range []State{"checked", "published", "delivering", "delivered", "failed", "superseded", "stopped",
|
||||
"retired", "covered"} {
|
||||
if rowFor(s, EvMerged, false) != nil || contains(AllStates, s) {
|
||||
t.Errorf("%q is still a state", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryRowOfTheTableIsTakenWhenItsGuardHoldsAndRefusedWhenNot(t *testing.T) {
|
||||
for _, r := range Table {
|
||||
for _, from := range r.From {
|
||||
d, f := aDeliveryFor(r)
|
||||
d.State = from
|
||||
got, err := Apply(d, r.Event, r.Act, f)
|
||||
if err != nil {
|
||||
t.Errorf("%s —%s→ %s was refused with its guard holding: %v", orNothing(from), r.Event, r.To, err)
|
||||
continue
|
||||
}
|
||||
if d.State != r.To || got.From != from || got.To != r.To || len(d.Transitions) != 1 {
|
||||
t.Errorf("%s —%s→ left it %s, transition %+v", orNothing(from), r.Event, d.State, got)
|
||||
}
|
||||
// The guard not holding: a delivery with no facts.
|
||||
bare := &Delivery{ID: "novox/app@bare", State: from, Check: &Verdict{Gate: "pass"}}
|
||||
if r.Event == EvAccepted {
|
||||
bare.Check = &Verdict{Gate: "fail"}
|
||||
}
|
||||
if r.Event == EvRefused {
|
||||
bare.Check = &Verdict{Gate: "pass"}
|
||||
}
|
||||
if _, err := Apply(bare, r.Event, r.Act, Facts{Now: f.Now}); err == nil {
|
||||
t.Errorf("%s —%s→ %s was taken with no fact for its guard", orNothing(from), r.Event, r.To)
|
||||
} else if bare.State != from || len(bare.Transitions) != 0 {
|
||||
t.Errorf("a refused transition moved the delivery to %s", bare.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) {
|
||||
events := []Event{EvAnnounced, EvAccepted, EvRefused, EvRecheck, EvReask, EvLost, EvNewHead, EvClosed, EvMerged,
|
||||
EvMergedUnchecked, EvTrunkMoved}
|
||||
for _, from := range append([]State{None}, AllStates...) {
|
||||
for _, ev := range events {
|
||||
for _, act := range []bool{false, true} {
|
||||
if rowFor(from, ev, act) != nil {
|
||||
continue
|
||||
}
|
||||
d := &Delivery{ID: "novox/app@c0ffee", State: from, MergedAs: "x", NewerHead: "y", Check: &Verdict{Gate: "pass"},
|
||||
HeldWhy: "h", ClosedUnmerged: true, Number: 1, Commit: "c"}
|
||||
_, err := Apply(d, ev, act, Facts{Now: time.Now(), Why: "w", By: "b"})
|
||||
var refused ErrRefused
|
||||
if !errors.As(err, &refused) || refused.Event != ev || refused.From != from {
|
||||
t.Errorf("%s —%s (act %v)→ was not refused by name: %v", orNothing(from), ev, act, err)
|
||||
}
|
||||
if d.State != from {
|
||||
t.Errorf("a pair the table does not hold moved %s to %s", orNothing(from), d.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rules the table is for, read off the table.
|
||||
func TestTheTableKeepsItsRules(t *testing.T) {
|
||||
for _, r := range Table {
|
||||
for _, from := range r.From {
|
||||
// Nothing else leaves a final state.
|
||||
if from.Final() {
|
||||
t.Errorf("%s is final and the table leaves it by %s", from, r.Event)
|
||||
}
|
||||
}
|
||||
// Only a ready delivery is merged, and nothing but its merge, or a merge without a passing check, puts one on the trunk.
|
||||
if r.To == Merged && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) {
|
||||
t.Errorf("%v —%s→ merged: only a ready delivery's merge merges", r.From, r.Event)
|
||||
}
|
||||
if r.To == Held && r.Event != EvMergedUnchecked {
|
||||
t.Errorf("%v —%s→ held: only a merge without a passing check holds", r.From, r.Event)
|
||||
}
|
||||
}
|
||||
// Every H2 transition is a row the table holds from that state.
|
||||
for state, b := range Bounds {
|
||||
for _, ev := range b.H2 {
|
||||
if rowFor(state, ev, false) == nil && rowFor(state, ev, true) == nil {
|
||||
t.Errorf("H2 may take %s from %s, which the table does not hold", ev, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
// H2 re-asks a check once: a second re-ask is refused by the table itself.
|
||||
d0 := &Delivery{ID: "novox/app@c0", State: Proposed, Number: 1, CheckAsk: &CheckAsk{ID: "check-1", Reasked: 1}}
|
||||
if _, err := Apply(d0, EvReask, true, Facts{Now: time.Now(), By: byH2, Why: "stalled"}); err == nil {
|
||||
t.Fatal("a check re-asked once was re-asked again")
|
||||
}
|
||||
// Once the operator's Approve covers the head (novox/hq ADR 0661 §3, §5): a proposed delivery is accepted with no
|
||||
// verdict or a failed one, none is refused, and a trunk's move puts no ready one back to proposed.
|
||||
at := time.Now()
|
||||
approved := func(d *Delivery) *Delivery {
|
||||
d.Ready = &ReadyRun{Approved: &at, Heads: map[string]string{d.ID: d.Commit}}
|
||||
return d
|
||||
}
|
||||
f := Facts{Now: at}
|
||||
for _, check := range []*Verdict{nil, {Gate: "fail"}} {
|
||||
p := approved(&Delivery{ID: "novox/app@c0", Commit: "c0", State: Proposed, Number: 1, Check: check})
|
||||
if _, err := Apply(p, EvRefused, false, f); err == nil {
|
||||
t.Fatalf("approved, a verdict %+v refused the delivery", check)
|
||||
}
|
||||
if _, err := Apply(p, EvAccepted, false, f); err != nil || p.State != Ready {
|
||||
t.Fatalf("approved, a delivery with the verdict %+v is %s: %v", check, p.State, err)
|
||||
}
|
||||
unapproved := &Delivery{ID: "novox/app@c0", Commit: "c0", State: Proposed, Number: 1, Check: check}
|
||||
if _, err := Apply(unapproved, EvAccepted, false, f); err == nil {
|
||||
t.Fatalf("with no Approve, a delivery with the verdict %+v was accepted: the check gates the ask", check)
|
||||
}
|
||||
}
|
||||
stale := approved(&Delivery{ID: "novox/app@c0", Commit: "c0", State: Ready, Check: &Verdict{Gate: "pass", Trunk: "d28ca95bbbbb"}, TrunkNow: "690966dccccc"})
|
||||
if _, err := Apply(stale, EvTrunkMoved, false, f); err == nil {
|
||||
t.Fatal("approved, a trunk's move put a ready delivery back to proposed")
|
||||
}
|
||||
stale.Ready = nil
|
||||
if _, err := Apply(stale, EvTrunkMoved, false, f); err != nil || stale.State != Proposed {
|
||||
t.Fatalf("with no Approve, a trunk's move left a ready delivery %s: %v", stale.State, err)
|
||||
}
|
||||
// A ready delivery off the trunk is refused its merge, whatever else is true of it.
|
||||
d := &Delivery{ID: "novox/app@c0", State: Ready}
|
||||
if _, err := Apply(d, EvMerged, false, Facts{Now: time.Now()}); err == nil {
|
||||
t.Fatal("a commit not on the trunk was merged")
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery an earlier owner kept in a state that is gone reads as the merge's state it came to (ADR 0539 §17).
|
||||
func TestAStateAnEarlierOwnerKeptReadsAsTheMergeStateItCameTo(t *testing.T) {
|
||||
for kept, want := range map[State]State{
|
||||
"checked": Proposed, "published": Merged, "delivering": Merged, "delivered": Merged, "covered": Merged,
|
||||
"retired": Merged, "failed": Merged, "superseded": Closed, "stopped": Closed,
|
||||
Proposed: Proposed, Ready: Ready, Rejected: Rejected, Merged: Merged, Closed: Closed,
|
||||
} {
|
||||
if got := legacyState(&Delivery{State: kept}); got != want {
|
||||
t.Errorf("kept as %q, read as %q, wanted %q", kept, got, want)
|
||||
}
|
||||
}
|
||||
if got := legacyState(&Delivery{State: Held, HeldWhy: "merged without a passing check: only a person decides"}); got != Held {
|
||||
t.Errorf("a merge without a passing check was read as %q", got)
|
||||
}
|
||||
if got := legacyState(&Delivery{State: Held, HeldWhy: "its group's composed check did not pass"}); got != Merged {
|
||||
t.Errorf("a delivery held for a walk's sake was read as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// settle stands a delivery where its facts put it, whatever order they came in.
|
||||
func TestSettleIsTheOrderOfTheFactsNotOfTheirArrival(t *testing.T) {
|
||||
now := time.Now()
|
||||
d := &Delivery{ID: "novox/app@c0", Number: 1, Commit: "c0"}
|
||||
if _, err := Apply(d, EvAnnounced, false, Facts{Now: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The verdict and the merge, both known before anything settled.
|
||||
d.Check = &Verdict{Gate: "pass"}
|
||||
d.MergedAs = "m1"
|
||||
ts := settle(d, Facts{Now: now})
|
||||
var path []State
|
||||
for _, t := range ts {
|
||||
path = append(path, t.To)
|
||||
}
|
||||
want := []State{Ready, Merged}
|
||||
if len(path) != len(want) {
|
||||
t.Fatalf("settled through %v, wanted %v", path, want)
|
||||
}
|
||||
for i := range want {
|
||||
if path[i] != want[i] {
|
||||
t.Fatalf("settled through %v, wanted %v", path, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A step that waits on the clock says when it is looked at again** (novox/hq ADR 0406 §1, issue 975): there is no
|
||||
// tick to find it, so each grace, bound and retry is registered (lookAt) when a look meets it, and the one timer fires
|
||||
// a look for the earliest. A look that meets nothing waiting registers nothing.
|
||||
|
||||
func (w *world) wantedAt() (time.Time, string) {
|
||||
w.h.wanted.mu.Lock()
|
||||
defer w.h.wanted.mu.Unlock()
|
||||
return w.h.wanted.at, w.h.wanted.step
|
||||
}
|
||||
|
||||
func TestALookThatMeetsNothingWaitingOnTheClockWantsNoOtherLook(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.React()
|
||||
if at, step := w.wantedAt(); !at.IsZero() {
|
||||
t.Fatalf("a look with nothing waiting on the clock wants another at %s for %q", at, step)
|
||||
}
|
||||
}
|
||||
|
||||
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.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()
|
||||
if d := w.delivery(id); d.Check == nil || !d.Check.Passes() || d.State != Ready {
|
||||
t.Fatalf("the standing verdict was not adopted: %+v (%s)", d.Check, d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC3: no timer asks a refused merge again; only an event does (here, mergeAgainNow called directly).
|
||||
func TestAMergeTheForgeRefusedIsAskedAgainOnlyOnAnEventWithNoTimer(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: not mergeable yet")
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("merges %v", w.forge.merges)
|
||||
}
|
||||
if at, step := w.wantedAt(); !at.IsZero() {
|
||||
t.Fatalf("the refused merge wants a timer at %s for %q; it waits for the next event", at, step)
|
||||
}
|
||||
w.forge.refuse = nil
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("asked again at a look with no event: %v", w.forge.merges)
|
||||
}
|
||||
again := w.delivery(IDOf("novox/app", readyHead))
|
||||
w.h.mu.Lock()
|
||||
w.h.mergeAgainNow(again)
|
||||
w.h.mu.Unlock()
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 2 || w.delivery(IDOf("novox/app", readyHead)).Ready.Merged == nil {
|
||||
t.Fatalf("the refused merge was not asked again at the timer: %v", w.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEffectTheBusCouldNotTakeIsTriedAgainAtItsTimeByTheTimerAlone(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.owed.down = true
|
||||
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
w.h.React()
|
||||
at, step := w.wantedAt()
|
||||
if at.IsZero() || !strings.Contains(step, "owed") || at.After(w.now.Add(retryFirst)) {
|
||||
t.Fatalf("the look wanted at %s for %q; wanted within %s for the owed effect", at, step, retryFirst)
|
||||
}
|
||||
w.owed.down = false
|
||||
w.later(retryFirst + time.Second)
|
||||
w.h.React()
|
||||
if len(w.forge.notes) == 0 {
|
||||
t.Fatal("the owed effect was not tried again at its time")
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A pull request the mesh was never asked to check (novox/hq issue 347).
|
||||
//
|
||||
// The forge's announcer is the only way the mesh hears of a pull request's head, and on 2026-10-09 it missed one: a
|
||||
// pull request opened after its push moved nothing the announcer read, and waited unchecked, unable to merge, with
|
||||
// nothing saying so. This owner asks the forge itself for every open pull request when it starts and when an event says
|
||||
// what the forge shows may have changed — a merge check's status, a new head (events.go), not a poll
|
||||
// (novox/hq ADR 0406 §1, issue 975) — and once more at a head's bound, and one whose
|
||||
// base requires the merge check — mesh/merge-gate or mesh/repo-check — with neither on its head for UncheckedAfter
|
||||
// since this owner first saw it so is said as a delivery held: a `stalled` line in the state Unannounced, which the
|
||||
// gate says as a condition (conditions.go) like any other delivery past its bound. The delivery domain's own
|
||||
// word, so no second condition and no second judge: the id is the one its delivery will have once announced.
|
||||
//
|
||||
// A head whose check started and never answered is said the same way (novox/hq issue 438). Counting any merge-check
|
||||
// status as checked let a head that got only a pending mesh/merge-gate, or never got mesh/repo-check, wait unable to
|
||||
// merge with nothing saying so. So a head is checked only when every merge check its base requires has answered —
|
||||
// success, failure, error or warning, a failure being a verdict the pull request shows itself — and one with a
|
||||
// required check pending or never set is said once that has lasted the delivery table's bound for a check asked and
|
||||
// not answered (Bounds[Proposed]): an announced head would be said after that bound too, and it is longer than the
|
||||
// controller lets one check run (its checkTimeout, 45 minutes), so a slow check or a status read a moment behind
|
||||
// (issue 423) is never said. Such a head was announced — the controller set its status — so its line is not in the
|
||||
// state Unannounced but Unanswered, with words of its own.
|
||||
//
|
||||
// A head whose delivery is held in ready or rejected is said by the same rule (novox/hq issue 441). Those states have
|
||||
// no bound of their own — the verdict is known here — but the forge's status is what lets the pull request merge or
|
||||
// shows why not, and a status write that lagged or was lost (issue 423), or a check asked again after the verdict,
|
||||
// leaves a required check pending or never set there: the pull request waits on it, and nothing here asks again. So
|
||||
// such a head is kept here like one without a delivery, and said on the same bound, as Unanswered — it was announced,
|
||||
// so never Unannounced, even with no merge check on its head at all. A delivery in any other state is not: proposed
|
||||
// and checked wait under bounds of their own, and the rest are past the merge, their pull request no longer open.
|
||||
|
||||
// Unannounced is a pull request's head the forge never announced: not a state of the table — no delivery exists —
|
||||
// only what a stalled line says.
|
||||
const Unannounced State = "unannounced"
|
||||
|
||||
// Unanswered is a pull request's head whose merge check started and never answered, and no delivery of it is held
|
||||
// here (novox/hq issue 438): not a state of the table either, only what a stalled line says. The word is the table's
|
||||
// own for the proposed state's bound: its check was asked and not answered.
|
||||
const Unanswered State = "unanswered"
|
||||
|
||||
// outOfDatePrefix begins the words the forge's holder puts a merge check back to pending with when its trunk moved
|
||||
// (novox/hq ADR 0323 §4): the gitea module's outOfDateWords.
|
||||
const outOfDatePrefix = "out of date:"
|
||||
|
||||
// UncheckedAfter is how long a head may go without a merge check before it is said: the announcer passes every half
|
||||
// minute and the check is asked at once, so ten minutes is a fault, never a queue.
|
||||
const UncheckedAfter = 10 * time.Minute
|
||||
|
||||
// mergeChecks are the statuses that are the mesh's check: mesh/check, the one main requires (novox/hq ADR 0447, issue
|
||||
// 1302), and the two it replaced, which hq's base (mesh/repo-check, ADR 0503 §5) and a reverted one still require. A base
|
||||
// requiring any is one the mesh is meant to check; only those a base requires are waited on.
|
||||
var mergeChecks = []string{ctxCheck, "mesh/merge-gate", "mesh/repo-check"}
|
||||
|
||||
// OpenPull is an open pull request as the forge says it (the seat git's open-pulls): its head, its base,
|
||||
// the contexts the base's protection requires, the contexts its head has a status of, in any state, and each of
|
||||
// those statuses with its state.
|
||||
type OpenPull struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
Base string `json:"base"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
Required []string `json:"required"`
|
||||
Statuses []string `json:"statuses"`
|
||||
// Checks are the head's statuses by context, the newest of each (novox/hq issue 438). A forge module older
|
||||
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
|
||||
// from an answered one.
|
||||
Checks map[string]HeadCheck `json:"checks,omitempty"`
|
||||
}
|
||||
|
||||
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
|
||||
type HeadCheck struct {
|
||||
State string `json:"state"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// unchecked is a head seen without an answered merge check, what it lacks, and when this owner first saw it so —
|
||||
// its own time, since the forge's update time moves on every comment.
|
||||
type unchecked struct {
|
||||
pull OpenPull
|
||||
waiting []string
|
||||
// pending is when each required check that is pending was set so, as the forge says it: what tells a check
|
||||
// asked again from one still waiting on the same ask.
|
||||
pending map[string]string
|
||||
first time.Time
|
||||
// read is when the forge was read that showed it so.
|
||||
read time.Time
|
||||
// held is the state of the delivery of this head, ready or rejected, when one is held here (novox/hq issue
|
||||
// 441); empty for a head with no delivery.
|
||||
held State
|
||||
}
|
||||
|
||||
// saidByTheForge is whether a delivery's head is said here when its required check has not answered on the forge:
|
||||
// one in ready or rejected, whose state has no bound of its own (novox/hq issue 441). No delivery at all is too.
|
||||
func saidByTheForge(d *Delivery) bool {
|
||||
return d == nil || d.State == Ready || d.State == Rejected
|
||||
}
|
||||
|
||||
// unanswered are the merge checks the base requires that the head has no verdict of, each as it reads — "never
|
||||
// set", or pending and since when — when each pending one was set so, and whether the head has any merge check at
|
||||
// all: its check started. A head on a base that requires none, or without a head, has nothing unanswered.
|
||||
func (p OpenPull) unanswered() (waiting []string, pending map[string]string, started bool) {
|
||||
if p.HeadSHA == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
pending = map[string]string{}
|
||||
started = slices.ContainsFunc(mergeChecks, func(c string) bool {
|
||||
return slices.Contains(p.Statuses, c) || p.Checks[c].State != ""
|
||||
})
|
||||
for _, c := range mergeChecks {
|
||||
if !slices.Contains(p.Required, c) {
|
||||
continue
|
||||
}
|
||||
got, ok := p.Checks[c]
|
||||
switch {
|
||||
case p.Checks == nil && started:
|
||||
// A forge that says no states: a head with any merge check counts as checked, as before issue 438,
|
||||
// since nothing here says whether the one it has answered.
|
||||
case !ok || got.State == "":
|
||||
waiting = append(waiting, c+" never set")
|
||||
case got.State == "pending" && strings.HasPrefix(got.Description, outOfDatePrefix):
|
||||
// Out of date because its trunk moved, and not asked (novox/hq ADR 0323 §4, §7): nothing runs for it and
|
||||
// it costs nothing, so it is not a check asked and not answered. Asked again, it is pending anew.
|
||||
case got.State == "pending":
|
||||
pending[c] = utcOf(got.UpdatedAt)
|
||||
waiting = append(waiting, c+" pending since "+orUnknown(pending[c]))
|
||||
}
|
||||
}
|
||||
return waiting, pending, started
|
||||
}
|
||||
|
||||
// utcOf is a forge time in UTC, as this module's other lines give their times; one it cannot read stays as given.
|
||||
func utcOf(at string) string {
|
||||
t, err := time.Parse(time.RFC3339, at)
|
||||
if err != nil {
|
||||
return at
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func orUnknown(at string) string {
|
||||
if at == "" {
|
||||
return "a time the forge did not say"
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// askedAgain is whether a required check is pending on a newer ask than when this owner last saw the head: set
|
||||
// pending for the first time, set pending anew, or gone back to pending after it answered. A check that answers
|
||||
// never is: the head waits on what is left from the same first sight (novox/hq issue 438).
|
||||
func askedAgain(was, now map[string]string) bool {
|
||||
for c, at := range now {
|
||||
if before, ok := was[c]; !ok || before != at {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SeeOpenPulls asks the forge for every open pull request and keeps the heads with a required merge check not
|
||||
// answered. A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
|
||||
func (h *Holder) SeeOpenPulls() error {
|
||||
open, err := h.Forge.OpenPulls()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the forge's open pull requests cannot be read: %v", err)
|
||||
return err
|
||||
}
|
||||
for _, gone := range h.seeOpen(open) {
|
||||
h.settleGone(gone)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openPullsMost is the most open pull requests the seat git's open-pulls answers: it reads 20 pages of 50 and says
|
||||
// nothing of the rest (mesh-catalog modules/gitea client.ts searchOpenPulls). A list that long may be cut, so what is
|
||||
// missing from it is not taken as closed.
|
||||
const openPullsMost = 20 * 50
|
||||
|
||||
// seeOpen keeps what the forge's open pull requests say, and answers the deliveries whose pull request is not among them:
|
||||
// open here, closed on the forge with no event heard (a close or merge while the gate was away, or an event lost). Only
|
||||
// from a complete list. Takes the lock.
|
||||
func (h *Holder) seeOpen(open []OpenPull) []*Delivery {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.forgeRead = true
|
||||
now := h.Now()
|
||||
seen := map[string]unchecked{}
|
||||
for _, p := range open {
|
||||
if slices.Contains(p.Required, ctxCheck) {
|
||||
// What the forge says of a head on a mesh/check base is its verdict (issue 1302): kept, and taken.
|
||||
if d := h.deliveries[IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)]; d != nil {
|
||||
h.noteRequired(d, p.Required)
|
||||
if c := p.Checks[ctxCheck]; c.State != "" {
|
||||
h.takeCheckStatus(p.Owner+"/"+p.Repo, p.HeadSHA, c.State, c.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
waiting, pending, _ := p.unanswered()
|
||||
if len(waiting) == 0 {
|
||||
continue
|
||||
}
|
||||
id := IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)
|
||||
d := h.deliveries[id]
|
||||
if !saidByTheForge(d) {
|
||||
continue // a delivery with its own bound, or past its merge
|
||||
}
|
||||
var held State
|
||||
if d != nil {
|
||||
held = d.State
|
||||
}
|
||||
// The clock runs from when this owner first saw the head waiting, and starts again only on a newer ask: a
|
||||
// check that started, or was asked again — its pending status set anew — is given its whole bound from then.
|
||||
// A check that answers does not start it again, so a head is said on the bound from its first sight.
|
||||
u := unchecked{pull: p, waiting: waiting, pending: pending, first: now, read: now, held: held}
|
||||
if was, ok := h.unchecked[id]; ok && !askedAgain(was.pending, pending) {
|
||||
u.first = was.first
|
||||
} else {
|
||||
h.Logf("[mesh-delivery] %s/%s#%d's head %s %s; said if so in %s", p.Owner, p.Repo, p.Number,
|
||||
short(p.HeadSHA), u.what(), u.bound())
|
||||
}
|
||||
seen[id] = u
|
||||
}
|
||||
h.unchecked = seen
|
||||
if len(open) >= openPullsMost {
|
||||
return nil
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, p := range open {
|
||||
listed[strings.ToLower(p.Owner+"/"+p.Repo)+"#"+strconv.Itoa(p.Number)] = true
|
||||
}
|
||||
var gone []*Delivery
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() || d.MergedAs != "" || d.Number <= 0 || listed[strings.ToLower(d.Repository)+"#"+strconv.Itoa(d.Number)] {
|
||||
continue
|
||||
}
|
||||
gone = append(gone, d)
|
||||
}
|
||||
sort.Slice(gone, func(i, j int) bool { return gone[i].ID < gone[j].ID })
|
||||
return gone
|
||||
}
|
||||
|
||||
// settleGone reads from the forge a pull request the open list does not hold, and takes its merge or its close as the
|
||||
// forge's event would have said it (issue 1275, review round 1: a pull request closed on 2026-10-17 was still ready here
|
||||
// eight days later). A merge learnt this way is past: its delivery is merged with the forge's time, moves no other
|
||||
// delivery's trunk, and never moves source.main (pullMerged, late); the trunk's merges heard as they happened move
|
||||
// those. A forge that cannot say changes nothing. Outside the lock.
|
||||
func (h *Holder) settleGone(d *Delivery) {
|
||||
h.mu.Lock()
|
||||
id, owner, repo, number, head, base, branch := d.ID, d.Owner(), d.Repo(), d.Number, d.Commit, d.Base, d.Branch
|
||||
h.mu.Unlock()
|
||||
// Read by its number alone: the forge reads the pull request's own head, which it keeps after the branch is gone.
|
||||
fc, err := h.Forge.Statuses(owner, repo, number, "", base)
|
||||
if err != nil || fc == nil || fc.Pull == nil {
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests, and the forge cannot say what became of it: %v", id, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
p := fc.Pull
|
||||
if p.State != "closed" || p.Number != number {
|
||||
return // open again, a state the forge did not say, or not this pull request: the next reading says
|
||||
}
|
||||
ev := PullEvent{Owner: owner, Repo: repo, Number: number, Base: base, Head: branch, HeadSHA: head, HTMLURL: p.HTMLURL}
|
||||
if p.Merged && p.MergeCommit != "" {
|
||||
ev.MergeCommit, ev.Merged, ev.MergedAt = p.MergeCommit, true, p.MergedAt
|
||||
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests: the forge says it merged as %s at %s", id,
|
||||
shortOf(p.MergeCommit, 12), orUnknown(p.MergedAt))
|
||||
h.pullMerged(ev, true)
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests: the forge says it was closed unmerged", id)
|
||||
h.PullClosed(ev)
|
||||
}
|
||||
|
||||
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
|
||||
// of it is held here, which the forge announced and the controller checked (novox/hq issue 441).
|
||||
func (u unchecked) started() bool {
|
||||
_, _, started := u.pull.unanswered()
|
||||
return started || u.held != ""
|
||||
}
|
||||
|
||||
// bound is how long the head may wait as it does: UncheckedAfter for a check never asked, the table's bound for a
|
||||
// check asked and not answered for one that started (novox/hq issue 438).
|
||||
func (u unchecked) bound() time.Duration {
|
||||
if u.started() {
|
||||
return Bounds[Proposed].For
|
||||
}
|
||||
return UncheckedAfter
|
||||
}
|
||||
|
||||
// what is what the head lacks, in words.
|
||||
func (u unchecked) what() string {
|
||||
if u.started() {
|
||||
return "has a merge check that has not answered: " + strings.Join(u.waiting, ", ")
|
||||
}
|
||||
return "has no merge check"
|
||||
}
|
||||
|
||||
// uncheckedLines are the heads unchecked past their bound, as stalled lines; under the lock.
|
||||
func (h *Holder) uncheckedLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for id, u := range h.unchecked {
|
||||
d := h.deliveries[id]
|
||||
if !saidByTheForge(d) || (d != nil && d.State != u.held) || now.Sub(u.first) < u.bound() {
|
||||
continue // moved on since the forge was last read: the next reading says it anew
|
||||
}
|
||||
h2 := "none: the forge never announced it, so there is no delivery to close — the operator's"
|
||||
says := fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head has had no merge "+
|
||||
"check: the mesh was never asked to check it. A new commit on its branch announces it",
|
||||
u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base)
|
||||
state, waiting := Unannounced, []string(nil)
|
||||
if u.started() {
|
||||
state, waiting = Unanswered, u.waiting
|
||||
says = fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head's check started "+
|
||||
"and never answered: %s. No delivery of it is held here, so nothing asks it again. A new commit on "+
|
||||
"its branch announces it and asks its check", u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base,
|
||||
strings.Join(u.waiting, ", "))
|
||||
h2 = "none: no delivery of it is held here, so there is none to close — the operator's"
|
||||
}
|
||||
if u.held != "" {
|
||||
// A delivery is held, its verdict known, and the forge shows another (novox/hq issue 441).
|
||||
says = fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its delivery is %s here, "+
|
||||
"but on the forge its check has not answered: %s. The pull request waits on that status and nothing "+
|
||||
"asks it again. A recheck of the delivery asks its check again, as does a new commit on its branch",
|
||||
u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base, u.held, strings.Join(u.waiting, ", "))
|
||||
h2 = "none: its delivery is " + string(u.held) + ", a state the operator moves — the operator's"
|
||||
}
|
||||
out = append(out, StalledLine{ID: id, Number: u.pull.Number, State: state, For: now.Sub(u.first).Round(time.Second).String(),
|
||||
Bound: u.bound().String(), Held: u.held,
|
||||
H2: h2, Says: says, Waiting: waiting})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func short(sha string) string {
|
||||
if len(sha) > 12 {
|
||||
return sha[:12]
|
||||
}
|
||||
return sha
|
||||
}
|
||||
|
||||
// uncheckedDue is whether a head seen waiting has reached the bound it is said at without the forge being read since: it
|
||||
// is read once more then, to say it only if it still waits. A head not yet at its bound is looked at again when it is.
|
||||
// Where the poll used to find a status that never came, an event does not exist to find it; this read is bounded to one
|
||||
// per head and its bound. Called by React.
|
||||
func (h *Holder) uncheckedDue() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now, due := h.Now(), false
|
||||
for _, u := range h.unchecked {
|
||||
at := u.first.Add(u.bound())
|
||||
switch {
|
||||
case now.Before(at):
|
||||
h.lookAt(at, "a head whose merge check has not answered, read from the forge once more at its bound")
|
||||
case u.read.Before(at):
|
||||
due = true
|
||||
}
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
// forgetUnchecked drops the heads seen waiting of a pull request that closed or merged.
|
||||
func (h *Holder) forgetUnchecked(owner, repo string, number int) {
|
||||
for id, u := range h.unchecked {
|
||||
if strings.EqualFold(u.pull.Owner, owner) && strings.EqualFold(u.pull.Repo, repo) && u.pull.Number == number {
|
||||
delete(h.unchecked, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A head nobody checks is found from events, not from a poll** (novox/hq ADR 0406 §1, issue 975, issues 347 and 722):
|
||||
// the forge's open pull requests are read when this owner starts and when an event says something they show may have
|
||||
// changed — a merge check's status, a new head, a ready mark — and once more at the bound a head's check is said.
|
||||
|
||||
func statusEvent(sha, context, state string) gitseat.ForgeStatusEvent {
|
||||
return gitseat.ForgeStatusEvent{Repository: "novox/hq", Commit: sha, Context: context, State: state}
|
||||
}
|
||||
|
||||
func TestAMergeCheckStatusReadsTheOpenPullRequestsAndAnotherContextDoesNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
before := w.forge.openCalls
|
||||
hear(t, w, gitEvent(gitseat.EventForgeStatus), statusEvent("cccc", "mesh/delivery", "success"))
|
||||
if w.forge.openCalls != before {
|
||||
t.Fatalf("a status of a context that is no merge check read the forge %d times", w.forge.openCalls-before)
|
||||
}
|
||||
hear(t, w, gitEvent(gitseat.EventForgeStatus), statusEvent("cccc", "mesh/merge-gate", "pending"))
|
||||
if w.forge.openCalls != before+1 {
|
||||
t.Fatalf("a merge check's status read the forge %d times, wanted once", w.forge.openCalls-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeadWhoseCheckNeverAnsweredIsSaidFromTheEventsAndOneReadAtTheBound(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 1, Base: "main", HeadSHA: "cccc",
|
||||
Required: []string{"mesh/merge-gate"}, Statuses: []string{"mesh/merge-gate"},
|
||||
Checks: map[string]HeadCheck{"mesh/merge-gate": {State: "pending", UpdatedAt: "2026-10-06T12:00:00Z"}}}}
|
||||
hear(t, w, gitEvent(gitseat.EventForgeStatus), statusEvent("cccc", "mesh/merge-gate", "pending"))
|
||||
if len(w.h.Stalled()) != 0 {
|
||||
t.Fatal("a head just seen waiting is said already")
|
||||
}
|
||||
reads := w.forge.openCalls
|
||||
// Minutes pass with no event: nothing polls. At the bound, one read confirms it, and it is said.
|
||||
for range 3 {
|
||||
w.later(5 * time.Minute)
|
||||
w.h.React()
|
||||
}
|
||||
w.later(Bounds[Proposed].For)
|
||||
w.h.React()
|
||||
w.h.React()
|
||||
if got := w.forge.openCalls - reads; got != 1 {
|
||||
t.Fatalf("the forge's open pull requests were read %d times between the status and the bound's end; wanted once, at the bound", got)
|
||||
}
|
||||
if lines := w.h.Stalled(); len(lines) != 1 || lines[0].State != Unanswered {
|
||||
t.Fatalf("the head whose check never answered is not said: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewlyAnnouncedHeadReadsTheForgeAndAKnownOneDoesNot(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
before := w.forge.openCalls
|
||||
hear(t, w, gitEvent(gitseat.EventPullUpdated), seatUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")))
|
||||
if w.forge.openCalls != before+2 { // the unchecked heads, and the head's standing verdict before its check is asked (issue 1115)
|
||||
t.Fatalf("a new head read the forge %d times, wanted twice", w.forge.openCalls-before)
|
||||
}
|
||||
hear(t, w, gitEvent(gitseat.EventPullUpdated), seatUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")))
|
||||
if w.forge.openCalls != before+3 { // the look of a delivery awaiting its verdict reads its standing one
|
||||
t.Fatalf("the same head announced again read the forge again (%d reads)", w.forge.openCalls-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingReadsTheForgeWithoutAnEvent(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.open = []OpenPull{openPull("novox/app", 7, readyHead)}
|
||||
before := w.forge.openCalls
|
||||
for range 6 {
|
||||
w.later(time.Minute)
|
||||
w.settleAll()
|
||||
}
|
||||
if w.forge.openCalls != before {
|
||||
t.Fatalf("the forge's open pull requests were read %d times with no event", w.forge.openCalls-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClosedOrMergedPullRequestIsNoLongerSaidUnchecked(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 1, Base: "main", HeadSHA: "cccc",
|
||||
Required: []string{"mesh/merge-gate"}}}
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(11 * time.Minute)
|
||||
if len(w.h.Stalled()) != 1 {
|
||||
t.Fatal("not said")
|
||||
}
|
||||
closed := pr("novox/hq", 1, "cccc", "feat/x")
|
||||
hear(t, w, gitEvent(gitseat.EventPullClosed), seatClosed(closed))
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a closed pull request is still said: %+v", lines)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A pull request the mesh was never asked to check is a delivery held, and said** (novox/hq issue 347): one open
|
||||
// on a base that requires the merge check — mesh/merge-gate or mesh/repo-check, as hq requires only the second —
|
||||
// whose head has had neither for ten minutes since this owner first saw it so, is a stalled line: the controller
|
||||
// raises it as `delivery.<id>.stalled` (D14), the delivery domain's own word for a delivery held past its bound.
|
||||
func TestAPullRequestNobodyCheckedIsAStalledDelivery(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
head := "bfe82315f42c8f4509d0598c32569a202d5eada6"
|
||||
w.forge.open = []OpenPull{
|
||||
{Owner: "novox", Repo: "hq", Number: 243, Base: "main", HeadSHA: head, Required: []string{"mesh/repo-check"}},
|
||||
{Owner: "novox", Repo: "sdk", Number: 14, Base: "main", HeadSHA: "aaaa", Required: []string{"mesh/merge-gate", "mesh/repo-check"},
|
||||
Statuses: []string{"mesh/merge-gate"}},
|
||||
{Owner: "novox", Repo: "free", Number: 1, Base: "main", HeadSHA: "bbbb", Required: nil},
|
||||
}
|
||||
w.h.SeeOpenPulls()
|
||||
if lines := w.h.Stalled(); len(lines) != 0 {
|
||||
t.Fatalf("a head just seen is stalled already: %+v", lines)
|
||||
}
|
||||
w.later(6 * time.Minute)
|
||||
w.h.SeeOpenPulls() // seen again: its own first-seen time stands, whatever the forge's update time says
|
||||
w.later(5 * time.Minute)
|
||||
lines := w.h.Stalled()
|
||||
if len(lines) != 1 || lines[0].ID != IDOf("novox/hq", head) || lines[0].State != Unannounced ||
|
||||
!strings.Contains(lines[0].Says, "no merge check") || !strings.HasPrefix(lines[0].H2, "none") {
|
||||
t.Fatalf("an unchecked pull request on a base requiring only mesh/repo-check reads %+v", lines)
|
||||
}
|
||||
if lines[0].Number != 243 {
|
||||
t.Fatalf("the line does not name the pull request's number: %+v", lines[0])
|
||||
}
|
||||
if lines[0].For != "11m0s" {
|
||||
t.Fatalf("its time is not counted from when this owner first saw it: %s", lines[0].For)
|
||||
}
|
||||
|
||||
// Announced at last: a delivery of its own, with its own bound — no longer this line.
|
||||
w.h.PullUpdated(pr("novox/hq", 243, head, "decision/x"))
|
||||
w.h.SeeOpenPulls()
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.State == Unannounced {
|
||||
t.Fatalf("an announced pull request is still said unannounced: %+v", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One that gets a check, or closes, is dropped; one the forge cannot be asked about is not guessed.
|
||||
func TestAnUncheckedPullRequestThatGetsACheckOrClosesIsDropped(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 1, Base: "main", HeadSHA: "cccc",
|
||||
Required: []string{"mesh/merge-gate"}}}
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(11 * time.Minute)
|
||||
if len(w.h.Stalled()) != 1 {
|
||||
t.Fatal("not said")
|
||||
}
|
||||
w.forge.open[0].Statuses = []string{"mesh/merge-gate"}
|
||||
w.h.SeeOpenPulls()
|
||||
if len(w.h.Stalled()) != 0 {
|
||||
t.Fatal("a pull request with a check is still said")
|
||||
}
|
||||
w.forge.open[0].Statuses = nil
|
||||
w.h.SeeOpenPulls()
|
||||
w.later(11 * time.Minute)
|
||||
w.forge.openDown = true
|
||||
w.h.SeeOpenPulls() // the forge away: what was seen stands, nothing new is guessed
|
||||
if len(w.h.Stalled()) != 1 {
|
||||
t.Fatal("a forge that could not be asked dropped or invented a line")
|
||||
}
|
||||
w.forge.openDown = false
|
||||
w.forge.open = nil
|
||||
w.h.SeeOpenPulls()
|
||||
if len(w.h.Stalled()) != 0 {
|
||||
t.Fatal("a closed pull request is still said")
|
||||
}
|
||||
}
|
||||
@@ -9,289 +9,301 @@ import (
|
||||
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
)
|
||||
|
||||
// The seat's verbs (novox/hq ADR 0239, the mesh-delivery seat in the controller's set): six that read, and
|
||||
// the acts — a person's recheck, and healer H2's close, each only by a transition the table
|
||||
// holds.
|
||||
// The seat's verbs that read (novox/hq ADR 0239; design 47 G §431, §172, A-24): from G's delivery the gate keeps no merge
|
||||
// states, so `deliveries`, `show` and `groups` answer the delivery records as the gate keeps them (G-3 *The delivery
|
||||
// record*), filtered by their arguments, with no line form of their own, and `stalled` answers the records whose open wait is
|
||||
// past its bound, with that wait, and the acts owed the forge past theirs.
|
||||
|
||||
// Line is one delivery as `deliveries` lists it.
|
||||
type Line struct {
|
||||
ID string `json:"id"`
|
||||
State State `json:"state"`
|
||||
Since string `json:"since"`
|
||||
For string `json:"for"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Waits string `json:"waits,omitempty"`
|
||||
Owed int `json:"owed,omitempty"`
|
||||
Stalled bool `json:"stalled,omitempty"`
|
||||
// Machines says, for a merged delivery, whether its merge is on the machines, derived from the delivery view's reach
|
||||
// (reached.go, novox/hq ADR 0556 clause 4); recorded nowhere.
|
||||
Machines string `json:"machines,omitempty"`
|
||||
// The two states of a record, as `deliveries` filters them.
|
||||
const (
|
||||
stateOpen = "open"
|
||||
stateEnded = "ended"
|
||||
)
|
||||
|
||||
// listedFor is how long an ended record is listed by `deliveries` without `all`: the contract's thirty days.
|
||||
const listedFor = 30 * 24 * time.Hour
|
||||
|
||||
// recordOpenBound is how long a record may stay open after it opened before `stalled` lists it (design 47 §148: a delivery of
|
||||
// the whole mesh takes under an hour, so a day means something stands still).
|
||||
const recordOpenBound = 24 * time.Hour
|
||||
|
||||
func stateOf(r *Record) string {
|
||||
if r.Ended != nil {
|
||||
return stateEnded
|
||||
}
|
||||
return stateOpen
|
||||
}
|
||||
|
||||
// waitsFor is what a delivery waits for, in words.
|
||||
func (h *Holder) waitsFor(d *Delivery) string {
|
||||
if r := d.Ready; r != nil && r.Merged == nil && strings.HasPrefix(r.Waits, "approved;") {
|
||||
// Approved and waiting for another delivery's check, or for its own verdict's carry (ADR 0548 §2, §8, §9).
|
||||
return r.Waits
|
||||
// groupOf is the id of the group a record is: its group's id, its milestone otherwise.
|
||||
func groupOf(r *Record) string {
|
||||
if r.Group != nil && r.Group.Group != "" {
|
||||
return r.Group.Group
|
||||
}
|
||||
switch d.State {
|
||||
case Proposed:
|
||||
return "its check"
|
||||
case Ready:
|
||||
if d.Base == "" && d.Number != 0 {
|
||||
return waitsForHead
|
||||
return r.Milestone
|
||||
}
|
||||
|
||||
// holdsRepository is whether one of the record's pull requests is in the repository (owner/name, any case).
|
||||
func holdsRepository(r *Record, repository string) bool {
|
||||
for _, m := range membersOf(r) {
|
||||
if strings.EqualFold(m.Repository, repository) {
|
||||
return true
|
||||
}
|
||||
return "its merge"
|
||||
case Rejected:
|
||||
return "a new head, or a recheck"
|
||||
case Held:
|
||||
return "a person: " + d.HeldWhy
|
||||
}
|
||||
return ""
|
||||
return false
|
||||
}
|
||||
|
||||
// Deliveries is the `deliveries` verb.
|
||||
func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
// membersOf are a record's pull requests: the group's pulls as kept, or else the members its heads name (`<repository>@<number>`
|
||||
// to the commit, as an Approve names them).
|
||||
func membersOf(r *Record) []seat.Member {
|
||||
open := r.Ended == nil
|
||||
var out []seat.Member
|
||||
if r.Group != nil && len(r.Group.Pulls) > 0 {
|
||||
for _, p := range r.Group.Pulls {
|
||||
out = append(out, seat.Member{ID: IDOf(p.Repository, firstOf(p.Commit, p.Head)), Repository: p.Repository, State: p.State,
|
||||
Number: int64(p.Number), Head: p.Head, Open: open && p.State != "merged"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
ids := make([]string, 0, len(r.Heads))
|
||||
for id := range r.Heads {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
repository, number, ok := parseApprovedKey(id)
|
||||
if !ok {
|
||||
repository = id
|
||||
}
|
||||
out = append(out, seat.Member{ID: IDOf(repository, r.Heads[id]), Repository: repository, Number: int64(number), Head: r.Heads[id],
|
||||
State: stateOf(r), Open: open})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseApprovedKey is a heads key `<repository>@<number>` as its repository and number.
|
||||
func parseApprovedKey(key string) (string, int, bool) {
|
||||
i := strings.LastIndex(key, "@")
|
||||
if i < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(key[i+1:], "%d", &n); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return key[:i], n, true
|
||||
}
|
||||
|
||||
func firstOf(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Deliveries is the `deliveries` verb: every record kept as it is kept, the open ones first, then the newest; the ended ones
|
||||
// of the last thirty days (all: every one kept), narrowed by state (open or ended), repository or group.
|
||||
func (h *Holder) Deliveries(state, repository, group string, all bool) (map[string]any, error) {
|
||||
kept, err := h.keptRecords()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := h.Now()
|
||||
var out []Line
|
||||
for _, d := range h.deliveries {
|
||||
if state != "" && string(d.State) != state || repository != "" && !strings.EqualFold(d.Repository, repository) ||
|
||||
group != "" && d.Group != group {
|
||||
continue
|
||||
}
|
||||
if d.State.Final() && !all && now.Sub(d.Since) > 24*time.Hour {
|
||||
continue
|
||||
}
|
||||
l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(),
|
||||
Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed), Machines: h.machines(d)}
|
||||
l.Stalled = pastBound(d, now)
|
||||
out = append(out, l)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].State.Final() != out[j].State.Final() {
|
||||
return !out[i].State.Final()
|
||||
}
|
||||
return out[i].Since > out[j].Since
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Show is the `show` verb: a delivery whole, or a group with its members.
|
||||
func (h *Holder) Show(id string) (any, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
out := map[string]any{"delivery": d, "waits": h.waitsFor(d)}
|
||||
if m := h.machines(d); m != "" {
|
||||
out["machines"] = m
|
||||
}
|
||||
if g := h.groups[d.Group]; g != nil {
|
||||
state, why := GroupState(g, h.membersOf(g))
|
||||
out["group"] = map[string]any{"group": g, "state": state, "why": why}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if g := h.groups[id]; g != nil {
|
||||
members := h.membersOf(g)
|
||||
state, why := GroupState(g, members)
|
||||
return map[string]any{"group": g, "state": state, "why": why, "members": members}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id)
|
||||
}
|
||||
|
||||
// Groups is the seat's verb groups: every delivery group not ended (all: those that ended too), or with only that group
|
||||
// alone, ended or not, and none for a group it does not know. Each with its members in merge order (membersOf), its state
|
||||
// and check, and the acts the gate said for it and its members: each unanswered act as it was said (the forge bridge's to
|
||||
// perform), and each act a delivery record holds with the outcome recorded (owedended.go). Outside the lock: the records
|
||||
// are read first.
|
||||
func (h *Holder) Groups(all bool, only string) seat.GroupsAnswer {
|
||||
var kept []*Record
|
||||
if h.Records != nil {
|
||||
var err error
|
||||
if kept, err = h.Records.Kept(); err != nil {
|
||||
return seat.GroupsAnswer{Error: "the delivery records cannot be read: " + err.Error()}
|
||||
}
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := seat.GroupsAnswer{Groups: []seat.Group{}}
|
||||
for _, g := range h.groups {
|
||||
if only != "" && g.ID != only {
|
||||
continue
|
||||
}
|
||||
members := h.membersOf(g)
|
||||
if len(members) == 0 {
|
||||
continue
|
||||
}
|
||||
state, why := GroupState(g, members)
|
||||
if only == "" && !all && (state == GroupMerged || state == GroupClosed) {
|
||||
continue
|
||||
}
|
||||
l := seat.Group{ID: g.ID, State: state, Why: why, Members: []seat.Member{}, Acts: []seat.Act{}}
|
||||
heads := map[string]bool{}
|
||||
for _, d := range members {
|
||||
l.Members = append(l.Members, seat.Member{ID: d.ID, Repository: d.Repository, State: string(d.State),
|
||||
Number: int64(d.Number), Head: d.Commit, Base: d.Base,
|
||||
Open: !d.State.Final() && d.MergedAs == "" && !d.ClosedUnmerged})
|
||||
heads[strings.ToLower(d.Repository)+"@"+d.Commit] = true
|
||||
for _, e := range d.Owed {
|
||||
if o, ok := owedSaid(e, d.Repository, d.Number, d.Commit, ViewBody(d, g), d.HTMLURL, false); ok {
|
||||
l.Acts = append(l.Acts, seat.Act{Owed: o})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, e := range g.Owed {
|
||||
if o, ok := owedSaid(e, "", 0, "", "", "", true); ok {
|
||||
l.Acts = append(l.Acts, seat.Act{Owed: o})
|
||||
}
|
||||
}
|
||||
l.Acts = append(l.Acts, recordedActs(kept, heads)...)
|
||||
if g.Check != nil {
|
||||
l.Check = g.Check.Verdict
|
||||
if l.Check == "" {
|
||||
l.Check = "asked as " + g.Check.Asked
|
||||
}
|
||||
}
|
||||
out.Groups = append(out.Groups, l)
|
||||
}
|
||||
sort.Slice(out.Groups, func(i, j int) bool { return out.Groups[i].ID < out.Groups[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// recordedActs are the acts the kept records hold for pull requests at these heads (`<repository>@<head>`, lower case),
|
||||
// each with its outcome, ordered by id.
|
||||
func recordedActs(kept []*Record, heads map[string]bool) []seat.Act {
|
||||
var out []seat.Act
|
||||
var out []recordView
|
||||
for _, r := range kept {
|
||||
for _, a := range r.Acts {
|
||||
if !heads[strings.ToLower(a.Repository)+"@"+a.Head] {
|
||||
continue
|
||||
}
|
||||
at := a.At
|
||||
out = append(out, seat.Act{Outcome: a.Outcome, Words: a.Words, At: &at, Owed: seat.Owed{ID: a.ID,
|
||||
Act: a.Act, Repository: a.Repository, Number: int64(a.Number), Head: a.Head}})
|
||||
if state != "" && state != stateOf(r) || repository != "" && !holdsRepository(r, repository) || group != "" && groupOf(r) != group {
|
||||
continue
|
||||
}
|
||||
if r.Ended != nil && !all && now.Sub(r.Ended.At) > listedFor {
|
||||
continue
|
||||
}
|
||||
out = append(out, viewOf(r))
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if (out[i].Record.Ended == nil) != (out[j].Record.Ended == nil) {
|
||||
return out[i].Record.Ended == nil
|
||||
}
|
||||
return out[i].Record.Opened.After(out[j].Record.Opened)
|
||||
})
|
||||
if out == nil {
|
||||
out = []recordView{}
|
||||
}
|
||||
return map[string]any{"deliveries": out}, nil
|
||||
}
|
||||
|
||||
func (h *Holder) keptRecords() ([]*Record, error) {
|
||||
if h.Records == nil {
|
||||
return nil, fmt.Errorf("this gate keeps no delivery records")
|
||||
}
|
||||
kept, err := h.Records.Kept()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the delivery records cannot be read: %w", err)
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// Show is the `show` verb: the record an id names whole, as it is kept: its instance, its milestone or group, or one of its
|
||||
// pull requests' ids (`<repository>@<twelve characters of the commit>`).
|
||||
func (h *Holder) Show(id string) (any, error) {
|
||||
kept, err := h.keptRecords()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []recordView
|
||||
for _, r := range kept {
|
||||
hit := r.Instance == id || r.Milestone == id || groupOf(r) == id
|
||||
for _, m := range membersOf(r) {
|
||||
hit = hit || strings.EqualFold(m.ID, id)
|
||||
}
|
||||
if hit {
|
||||
out = append(out, viewOf(r))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no delivery record or group %q: `deliveries` lists them", id)
|
||||
}
|
||||
return map[string]any{"records": out}, nil
|
||||
}
|
||||
|
||||
// Groups is the seat's verb groups: every record's group not ended (all: those that ended too), or with only that group
|
||||
// alone, ended or not, and none for a group it does not know. Each with its members, its state, why, and the acts the gate said
|
||||
// for it with the outcome recorded in the record (owedended.go).
|
||||
func (h *Holder) Groups(all bool, only string) seat.GroupsAnswer {
|
||||
kept, err := h.keptRecords()
|
||||
if err != nil {
|
||||
return seat.GroupsAnswer{Error: err.Error()}
|
||||
}
|
||||
out := seat.GroupsAnswer{Groups: []seat.Group{}}
|
||||
for _, r := range kept {
|
||||
if only != "" && groupOf(r) != only || only == "" && !all && r.Ended != nil {
|
||||
continue
|
||||
}
|
||||
g := seat.Group{ID: groupOf(r), State: stateOf(r), Members: membersOf(r), Acts: recordedActs(r)}
|
||||
if r.Ended != nil {
|
||||
g.Why = r.Ended.Why
|
||||
} else {
|
||||
g.Why = allRun(r)
|
||||
}
|
||||
out.Groups = append(out.Groups, g)
|
||||
}
|
||||
sort.SliceStable(out.Groups, func(i, j int) bool { return out.Groups[i].ID < out.Groups[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// recordedActs are the acts a record holds, each with its outcome when the forge bridge said one, ordered by id.
|
||||
func recordedActs(r *Record) []seat.Act {
|
||||
out := []seat.Act{}
|
||||
for _, a := range r.Acts {
|
||||
o := a.Owed
|
||||
if o.ID == "" {
|
||||
o = seat.Owed{ID: a.ID, Act: a.Act, Repository: a.Repository, Number: int64(a.Number), Head: a.Head}
|
||||
}
|
||||
act := seat.Act{Outcome: a.Outcome, Words: a.Words, Owed: o}
|
||||
if a.Outcome != "" {
|
||||
at := a.At
|
||||
act.At = &at
|
||||
}
|
||||
out = append(out, act)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Owed.ID < out[j].Owed.ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// StalledLine is one delivery past its state's bound.
|
||||
// StalledLine is one record stalled: its open wait past its bound, or an act owed the forge unanswered past its.
|
||||
type StalledLine struct {
|
||||
// ID is the record's instance.
|
||||
ID string `json:"id"`
|
||||
// Number is the pull request's, for a head the forge never announced: the condition names it (novox/hq issue 347).
|
||||
Number int `json:"number,omitempty"`
|
||||
State State `json:"state"`
|
||||
// Waiting are the merge checks a line of the state Unanswered waits on, each as it reads — "mesh/check
|
||||
// never set", "mesh/check pending since <UTC time>" — so the controller's words name them without reading
|
||||
// Says (novox/hq issue 438).
|
||||
Waiting []string `json:"waiting,omitempty"`
|
||||
// Checks are the same merge checks as data — context, state, since when — so the controller says their times in
|
||||
// the operator's own zone, which it cannot do inside a finished sentence (novox/hq issue 443). Waiting stays
|
||||
// beside them for a controller that does not read them yet.
|
||||
Checks []WaitingCheck `json:"checks,omitempty"`
|
||||
// Held is the state of the delivery a line of the state Unanswered is about, ready or rejected, when one is held
|
||||
// here (novox/hq issues 441, 451): its check answered once, so the controller advises a recheck of it rather than
|
||||
// the build queue, reading the state here and never from Says. Empty for a head with no delivery.
|
||||
Held State `json:"held,omitempty"`
|
||||
// Wait is the open wait: what the record waits for, or the act unanswered.
|
||||
Wait string `json:"wait"`
|
||||
For string `json:"for"`
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
// Severity is "urgent" for a group that is held or half merged (issue 1275); empty otherwise.
|
||||
// Severity is empty: nothing a record waits on is urgent.
|
||||
Severity string `json:"severity,omitempty"`
|
||||
// Needs is NeedsNewHead for a delivery whose cure is its author's new head (novox/hq ADR 0505 §5), with Author
|
||||
// naming that author as the operator reads it and Branch its pull request's branch; empty otherwise.
|
||||
Needs string `json:"needs,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
}
|
||||
|
||||
// NeedsNewHead is what a stalled line needs when its cure is its author's new head (ADR 0505 §5).
|
||||
const NeedsNewHead = "new-head"
|
||||
|
||||
// Stalled is the `stalled` verb: what the gate says to the operator as conditions (conditions.go).
|
||||
// Stalled is the `stalled` verb: the records whose open wait is past its bound, with that wait (§148, A-24), and the acts owed
|
||||
// the forge unanswered past their fifteen minutes (§165), which the condition keeper raises and the gate says (conditions.go).
|
||||
func (h *Holder) Stalled() []StalledLine {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := []StalledLine{}
|
||||
kept, err := h.keptRecords()
|
||||
if err != nil {
|
||||
h.Refuse(err)
|
||||
return out
|
||||
}
|
||||
now := h.Now()
|
||||
var out []StalledLine
|
||||
for _, d := range h.deliveries {
|
||||
if !pastBound(d, now) {
|
||||
continue
|
||||
}
|
||||
b, _ := boundOf(d)
|
||||
h2 := "none: the state is the operator's"
|
||||
switch {
|
||||
case d.State == Proposed && d.AskedAgain != nil && d.outOfDate() != "":
|
||||
h2 = "none: its verdict is out of date and its check, asked again, has not answered — the operator's"
|
||||
case d.State == Proposed && d.CheckAsk == nil:
|
||||
h2 = "none: its check could not be asked yet — the checker has not answered it"
|
||||
case d.State == Proposed && d.CheckAsk.Reasked > 0:
|
||||
h2 = "none: its check was re-asked once already and still not answered — the operator's"
|
||||
case d.State == Proposed:
|
||||
h2 = "close, by re-ask: its check asked once more"
|
||||
case len(b.H2) > 0:
|
||||
var evs []string
|
||||
for _, e := range b.H2 {
|
||||
evs = append(evs, string(e))
|
||||
for _, r := range kept {
|
||||
if r.Ended == nil && now.Sub(r.Opened) > recordOpenBound {
|
||||
wait := allRun(r)
|
||||
if wait == "" {
|
||||
wait = "its end, which every present node running its entries brings"
|
||||
}
|
||||
h2 = "close, by " + strings.Join(evs, " or ")
|
||||
out = append(out, StalledLine{ID: r.Instance, Wait: wait, For: now.Sub(r.Opened).Round(time.Second).String(),
|
||||
Bound: recordOpenBound.String(), H2: "none: what the record waits on is its owner's — `gate_record_end` ends it by hand",
|
||||
Says: fmt.Sprintf("the delivery record %s has been open since %s: %s", r.Instance, r.Opened.UTC().Format(time.RFC3339), wait)})
|
||||
}
|
||||
out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(boundSince(d)).Round(time.Second).String(),
|
||||
Bound: b.For.String(), H2: h2, Says: b.Says})
|
||||
out = append(out, owedLines(r, now)...)
|
||||
}
|
||||
out = append(out, h.withChecks(h.uncheckedLines(now))...)
|
||||
out = append(out, h.readyLines(now)...)
|
||||
out = append(out, h.groupLines(now)...)
|
||||
out = append(out, h.owedLines(now)...) // an act owed the forge with no outcome past its bound (W11.17)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// owedLines is the stalled line of a record's acts said and unanswered past owedBound: its oldest such act and how many more wait.
|
||||
func owedLines(r *Record, now time.Time) []StalledLine {
|
||||
var oldest *RecordAct
|
||||
more := 0
|
||||
for id := range r.Acts {
|
||||
a := r.Acts[id]
|
||||
if a.Outcome != "" || now.Sub(a.At) < owedBound {
|
||||
continue
|
||||
}
|
||||
if oldest == nil {
|
||||
oldest = &a
|
||||
} else {
|
||||
more++
|
||||
if a.At.Before(oldest.At) {
|
||||
oldest = &a
|
||||
}
|
||||
}
|
||||
}
|
||||
if oldest == nil {
|
||||
return nil
|
||||
}
|
||||
says := fmt.Sprintf("the %s owed to the forge, said at %s as %s, has no outcome from the forge bridge", oldest.Act,
|
||||
oldest.At.UTC().Format(time.RFC3339), oldest.ID)
|
||||
if more > 0 {
|
||||
says += fmt.Sprintf(", nor have %d more", more)
|
||||
}
|
||||
return []StalledLine{{ID: r.Instance, Wait: oldest.ID, For: now.Sub(oldest.At).Round(time.Second).String(), Bound: owedBound.String(),
|
||||
H2: "none: the forge bridge's bridge_owed reads what it performed — the operator's", Says: says + "; it is said again at the gate's start"}}
|
||||
}
|
||||
|
||||
// Status is the module's own `delivery_status`.
|
||||
func (h *Holder) Status(listening string) map[string]any {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
count := map[State]int{}
|
||||
records := map[string]int{stateOpen: 0, stateEnded: 0}
|
||||
owed := 0
|
||||
for _, d := range h.deliveries {
|
||||
count[d.State]++
|
||||
owed += len(d.Owed)
|
||||
if h.Records != nil {
|
||||
if kept, err := h.Records.Kept(); err == nil {
|
||||
for _, r := range kept {
|
||||
records[stateOf(r)]++
|
||||
for _, a := range r.Acts {
|
||||
if a.Outcome == "" {
|
||||
owed++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, g := range h.groups {
|
||||
owed += len(g.Owed)
|
||||
}
|
||||
byState := map[string]int{}
|
||||
for s, n := range count {
|
||||
byState[string(s)] = n
|
||||
}
|
||||
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
|
||||
"events": listening, "started": h.started.Format(time.RFC3339)}
|
||||
// The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again,
|
||||
// or given up, and when the last was tried. A count since the start, said with that start: two readings in
|
||||
// one run give a window's tries.
|
||||
out := map[string]any{"records": records, "owed": owed, "events": listening, "started": h.started.Format(time.RFC3339)}
|
||||
// The owed effects tried since this owner started (novox/hq issue 407): a count since the start, said with that start.
|
||||
tried := map[string]any{"since": h.started.Format(time.RFC3339), "done": h.tried.done, "failed": h.tried.failed,
|
||||
"given-up": h.tried.givenUp, "tried": h.tried.done + h.tried.failed + h.tried.givenUp}
|
||||
if !h.tried.last.IsZero() {
|
||||
tried["last"] = h.tried.last.Format(time.RFC3339)
|
||||
}
|
||||
out["owed-tried"] = tried
|
||||
// Each held delivery by name, with why and the verb that releases it (novox/hq issue 529): a count alone
|
||||
// pointed a reader at nothing.
|
||||
var held []string
|
||||
for _, d := range h.deliveries {
|
||||
if d.State == Held {
|
||||
held = append(held, HeldLine(d))
|
||||
}
|
||||
}
|
||||
if len(held) > 0 {
|
||||
sort.Strings(held)
|
||||
out["held"] = held
|
||||
}
|
||||
if len(h.refusals) > 0 {
|
||||
from := 0
|
||||
if len(h.refusals) > 10 {
|
||||
@@ -301,10 +313,3 @@ func (h *Holder) Status(listening string) map[string]any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeldLine says a held delivery in one line: which, since when and why. A merge without a passing check is a person's to
|
||||
// read; the gate moves nothing after the merge.
|
||||
func HeldLine(d *Delivery) string {
|
||||
line := fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), d.HeldWhy)
|
||||
return line
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The merge checks an unanswered line waits on, as data (novox/hq issue 443). The line's Waiting says them as finished
|
||||
// words, with a pending check's time in UTC; the operator reads the controller's words, which give every time in the
|
||||
// operator's own zone, and the controller cannot say a time again inside a sentence it did not write. So each check
|
||||
// is given too as its context, its state and since when, and the controller words it.
|
||||
|
||||
// WaitingCheck is one merge check a stalled line of the state Unanswered waits on.
|
||||
type WaitingCheck struct {
|
||||
Context string `json:"context"`
|
||||
// State is CheckPending or CheckNeverSet.
|
||||
State string `json:"state"`
|
||||
// Since is when a pending check was set so, in RFC 3339 and UTC; empty when the forge did not say, or said what
|
||||
// cannot be read — never a time made up.
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
CheckPending = "pending"
|
||||
CheckNeverSet = "never-set"
|
||||
)
|
||||
|
||||
// waitingChecks are the checks unanswered names, in its order and by its rule, as data.
|
||||
func (p OpenPull) waitingChecks() []WaitingCheck {
|
||||
if p.HeadSHA == "" {
|
||||
return nil
|
||||
}
|
||||
started := slices.ContainsFunc(mergeChecks, func(c string) bool {
|
||||
return slices.Contains(p.Statuses, c) || p.Checks[c].State != ""
|
||||
})
|
||||
var out []WaitingCheck
|
||||
for _, c := range mergeChecks {
|
||||
if !slices.Contains(p.Required, c) {
|
||||
continue
|
||||
}
|
||||
got, ok := p.Checks[c]
|
||||
switch {
|
||||
case p.Checks == nil && started:
|
||||
// Read as checked, as unanswered does: nothing says whether the check it has answered.
|
||||
case !ok || got.State == "":
|
||||
out = append(out, WaitingCheck{Context: c, State: CheckNeverSet})
|
||||
case got.State == "pending":
|
||||
w := WaitingCheck{Context: c, State: CheckPending}
|
||||
if t, err := time.Parse(time.RFC3339, got.UpdatedAt); err == nil {
|
||||
w.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withChecks gives each unanswered line the checks it waits on as data, from the head it was said of; under the lock.
|
||||
// It reads every line of the state, whichever rule said it, so a line said for another reason carries them too.
|
||||
func (h *Holder) withChecks(lines []StalledLine) []StalledLine {
|
||||
for i, l := range lines {
|
||||
if u, ok := h.unchecked[l.ID]; ok && l.State == Unanswered && len(l.Waiting) > 0 {
|
||||
lines[i].Checks = u.pull.waitingChecks()
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
)
|
||||
|
||||
// **The operator is asked only for a merge the forge account can carry out** (novox/hq issue 1945). car-hunter #2, a
|
||||
// repository of a user, was approved, and its first act after the Approve, the commit status, was refused 403: the mesh's
|
||||
// merging account (the gitea module's tools, mesh-tools) only read that repository. Before the gate asks the operator's
|
||||
// Approve it reads, with the forge's merge state (seatForge.mergeState), whether that account writes each repository the
|
||||
// ask covers (merge.writes), as the ask is about to be written. Where it does not, nothing is asked: the delivery waits,
|
||||
// saying "the forge account cannot write <repo>: gitea_grant_write", and the gate raises it as a condition at once.
|
||||
//
|
||||
// A repository not written is kept so, and the ask is not composed again, until the seat git's event
|
||||
// repo.writable (its grant, read back from the forge) or, as a safety net, writeReadLasts after the read. A forge
|
||||
// that does not say merge.writes (an older gitea module) is taken as writing: the merge says its own refusal, as before.
|
||||
|
||||
// writeReadLasts is how long a read of whether the forge account writes a repository holds.
|
||||
const writeReadLasts = 10 * time.Minute
|
||||
|
||||
// writableEvent is the seat git's event of an account given write on a repository.
|
||||
var writableEvent = gitEvent(gitseat.EventRepoWritable)
|
||||
|
||||
// writeRead is what the forge last said of the merging account on one repository it cannot write.
|
||||
type writeRead struct {
|
||||
says string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// cannotWriteSays is what a delivery waits on when the forge account cannot write its repository.
|
||||
func cannotWriteSays(repository string) string {
|
||||
return "the forge account cannot write " + repository + ": gitea_grant_write"
|
||||
}
|
||||
|
||||
// writeWaits is what an ask about to be made waits on: a repository it covers that the forge account was read, within
|
||||
// writeReadLasts, not to write. Under the lock.
|
||||
func (h *Holder) writeWaits(d *Delivery, members []*Delivery, now time.Time) string {
|
||||
r := d.Ready
|
||||
for _, m := range members {
|
||||
repo := strings.ToLower(m.Repository)
|
||||
w := h.writeReads[repo]
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
if !now.Before(w.at.Add(writeReadLasts)) {
|
||||
delete(h.writeReads, repo) // read again at the ask
|
||||
continue
|
||||
}
|
||||
if r.CannotWrite != w.says {
|
||||
r.CannotWrite, r.CannotWriteSince = w.says, &now
|
||||
h.keep(d)
|
||||
}
|
||||
h.lookAt(w.at.Add(writeReadLasts), "a repository the forge account could not write, read again")
|
||||
return w.says
|
||||
}
|
||||
if r.CannotWrite != "" {
|
||||
r.CannotWrite, r.CannotWriteSince = "", nil
|
||||
h.keep(d)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cannotWrite reads, at the ask, whether the forge account writes each repository the ask covers, and answers what the
|
||||
// first one it cannot write says; a read that fails says nothing (the merge says its own refusal, as before). Outside the
|
||||
// lock; a repository not written is kept, under the lock.
|
||||
func (h *Holder) cannotWrite(reads []memberRead) string {
|
||||
for _, m := range reads {
|
||||
fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base)
|
||||
if err != nil || fc == nil || fc.Merge.Writes == nil || *fc.Merge.Writes {
|
||||
continue
|
||||
}
|
||||
repo := strings.ToLower(m.owner + "/" + m.repo)
|
||||
says := fc.Merge.CannotWrite
|
||||
if says == "" {
|
||||
says = cannotWriteSays(repo)
|
||||
}
|
||||
h.mu.Lock()
|
||||
if h.writeReads == nil {
|
||||
h.writeReads = map[string]*writeRead{}
|
||||
}
|
||||
h.writeReads[repo] = &writeRead{says: says, at: h.Now()}
|
||||
h.mu.Unlock()
|
||||
return says
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WriteGranted takes the seat git's repo.writable: the repository's read is dropped, so the next look reads it
|
||||
// again, and an approved merge of it that the forge refused is asked again, its cause gone.
|
||||
func (h *Holder) WriteGranted(body json.RawMessage) error {
|
||||
var e gitseat.RepoWritableEvent
|
||||
if err := json.Unmarshal(body, &e); err != nil || !strings.Contains(strings.Trim(e.Repository, "/"), "/") {
|
||||
return fmt.Errorf("%s names no repository: %s", writableEvent, string(body))
|
||||
}
|
||||
repo := strings.ToLower(e.Repository)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.writeReads, repo)
|
||||
for _, d := range h.deliveries {
|
||||
if strings.ToLower(d.Repository) != repo || d.Ready == nil || d.Ready.Approved == nil || d.Ready.Merged != nil {
|
||||
continue
|
||||
}
|
||||
d.Ready.Repeats = 0
|
||||
h.mergeAgainNow(d)
|
||||
h.keep(d)
|
||||
}
|
||||
h.Logf("[mesh-delivery] the forge account was given write on %s: it is read again before an ask, and an approved merge of it is asked again", repo)
|
||||
return nil
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
|
||||
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1945: car-hunter #2 was asked, approved, and its commit status refused 403, since the forge's merging account only
|
||||
// read the repository. 1945/AC3: a pull request whose checks passed, in a repository the merging account cannot write, is
|
||||
// not asked; it waits saying so, loud as a condition at once. 1945/AC4: the gitea module's gitea.repo.writable asks it then.
|
||||
func TestAnUnwritableRepositoryIsNotAskedAndSaysSoUntilTheWriteIsGranted(t *testing.T) {
|
||||
w := announced(t)
|
||||
conds := hearConditions(w)
|
||||
w.forge.unwritable = map[string]bool{"novox/app": true}
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.h.React()
|
||||
if w.op.count() != 0 || len(w.op.open()) != 0 {
|
||||
t.Fatalf("the operator was asked %d times for a merge the forge account cannot carry out", w.op.count())
|
||||
}
|
||||
d := w.h.deliveries[IDOf("novox/app", readyHead)]
|
||||
want := "the forge account cannot write novox/app: gitea_grant_write"
|
||||
if d.Ready.Waits != want || d.Ready.CannotWrite != want {
|
||||
t.Fatalf("waits %q, cannot_write %q; wanted %q", d.Ready.Waits, d.Ready.CannotWrite, want)
|
||||
}
|
||||
if c := conds.last(d.ID); c == nil || c.State != "raised" || !strings.Contains(c.Summary+c.Explanation+c.Headline, "cannot write novox/app") {
|
||||
t.Fatalf("not said as a condition: %+v", c)
|
||||
}
|
||||
// Looked at again with nothing changed, the forge is not read again: the read holds until the grant or its bound.
|
||||
asked := len(w.forge.asked)
|
||||
w.h.React()
|
||||
if len(w.forge.asked) != asked || w.op.count() != 0 {
|
||||
t.Fatalf("read again %d times, asked %d", len(w.forge.asked)-asked, w.op.count())
|
||||
}
|
||||
|
||||
delete(w.forge.unwritable, "novox/app")
|
||||
hear(t, w, writableEvent, gitseat.RepoWritableEvent{Repository: "novox/app", Account: "mesh-tools"})
|
||||
w.h.React()
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("after the write was granted the operator was asked %d times, wanted once", w.op.count())
|
||||
}
|
||||
if d.Ready.CannotWrite != "" || strings.Contains(d.Ready.Waits, "cannot write") {
|
||||
t.Fatalf("still says it cannot write: %q / %q", d.Ready.CannotWrite, d.Ready.Waits)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"refusal-stall-floor-minutes": 10,
|
||||
"author-new-head-minutes": 120,
|
||||
"record-retention": 90
|
||||
}
|
||||
|
||||
@@ -17,23 +17,11 @@
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"git.pull-updated",
|
||||
"git.pull-merged",
|
||||
"git.pull-closed",
|
||||
"git.forge-pull",
|
||||
"git.forge-status",
|
||||
"git.repo-writable",
|
||||
"mesh-delivery-forge-bridge.forge.**",
|
||||
"mesh-delivery-forge-bridge.owed.ended",
|
||||
"mesh-delivery-teller.answered",
|
||||
"mesh-delivery-view.reached",
|
||||
"mesh-config.config-changed",
|
||||
"mesh-delivery-indexer.take-in.taken"
|
||||
],
|
||||
"emits": [
|
||||
"transition",
|
||||
"group",
|
||||
"merged"
|
||||
"transition"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
@@ -130,6 +118,9 @@
|
||||
},
|
||||
"recovery": {
|
||||
"type": "object"
|
||||
},
|
||||
"owed": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"operations": [
|
||||
@@ -236,7 +227,8 @@
|
||||
"head",
|
||||
"outcome",
|
||||
"words",
|
||||
"at"
|
||||
"at",
|
||||
"owed"
|
||||
],
|
||||
"into": "acts",
|
||||
"key": "id",
|
||||
@@ -311,23 +303,11 @@
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg": "0.1"
|
||||
},
|
||||
"invokes": [
|
||||
"seat:git.pull",
|
||||
"seat:git.statuses",
|
||||
"seat:git.open-pulls",
|
||||
"seat:git.contains",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:issue-tracker.list",
|
||||
"seat:mesh-wants.entry",
|
||||
"seat:node-engine.composition",
|
||||
"mesh-delivery-teller.teller_answers",
|
||||
"mesh-delivery-view.view_reach"
|
||||
"seat:node-engine.composition"
|
||||
],
|
||||
"state": [
|
||||
"pulls",
|
||||
{
|
||||
"name": "asks",
|
||||
"history": 5
|
||||
},
|
||||
{
|
||||
"name": "merges",
|
||||
"history": 10
|
||||
@@ -341,11 +321,7 @@
|
||||
"ledger-calls",
|
||||
"ledger-upkeep-calls"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-checker.verdicts"
|
||||
],
|
||||
"tools": [
|
||||
"delivery_merge_again",
|
||||
"delivery_status",
|
||||
"gate_consumer",
|
||||
"gate_record",
|
||||
@@ -354,22 +330,6 @@
|
||||
"gate_records"
|
||||
],
|
||||
"config": {
|
||||
"refusal-stall-floor-minutes": {
|
||||
"type": "number",
|
||||
"description": "minutes, at least, before a merge refused because its trunk moved, whose check the gate asked again, is said as stalled; twice the repository's last check time when that is longer (ADR 0505 \u00a74).",
|
||||
"minimum": 1,
|
||||
"maximum": 1440,
|
||||
"trusted": false,
|
||||
"default": 10
|
||||
},
|
||||
"author-new-head-minutes": {
|
||||
"type": "number",
|
||||
"description": "minutes a delivery that needs its author's new head (a conflict, a failed check, a rejected group) waits for one before the operator hears of it (ADR 0505 \u00a75).",
|
||||
"minimum": 1,
|
||||
"maximum": 10080,
|
||||
"trusted": false,
|
||||
"default": 120
|
||||
},
|
||||
"record-retention": {
|
||||
"type": "number",
|
||||
"description": "days a delivery record is kept from when it opened (design 47 *The delivery record*): ninety covers every trace asked of a past delivery so far; when a record is removed at its retention, the gate says freed for each graph revision it pinned. Nothing else is removed to make room.",
|
||||
@@ -393,8 +353,6 @@
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"takes-config": [
|
||||
"refusal-stall-floor-minutes",
|
||||
"author-new-head-minutes",
|
||||
"record-retention"
|
||||
],
|
||||
"merge": "json",
|
||||
|
||||
Reference in New Issue
Block a user