Merge remote-tracking branch 'origin/main' into fix/403-fix-time
mesh/merge-gate head 005f802504fc merged into trunk eb5f33f2b475 — pass: builds mesh-delivery, mesh-issues → novox; no bus step; every machine composes wit…
mesh/repo-check head 005f802504fc merged into trunk eb5f33f2b475 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1791834103351744736 (novox/mesh-catalog at ac2d4dc4 into main,…
mesh/delivery-group group fix/403-fix-time merged: every member is merged
mesh/merge-gate head 005f802504fc merged into trunk eb5f33f2b475 — pass: builds mesh-delivery, mesh-issues → novox; no bus step; every machine composes wit…
mesh/repo-check head 005f802504fc merged into trunk eb5f33f2b475 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1791834103351744736 (novox/mesh-catalog at ac2d4dc4 into main,…
mesh/delivery-group group fix/403-fix-time merged: every member is merged
# Conflicts: # modules/mesh-delivery/cmd/mesh-delivery/model.go
This commit is contained in:
@@ -51,6 +51,10 @@ type ChecksAnswer struct {
|
||||
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.
|
||||
@@ -118,6 +122,7 @@ func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAn
|
||||
}
|
||||
if d.Check != nil && sameCommit(d.Commit, a.Commit) {
|
||||
a.Verdict = d.Check
|
||||
a.Judged, a.OutOfDate = d.Check.Judged(), d.outOfDate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +278,12 @@ func (a *ChecksAnswer) says() string {
|
||||
default:
|
||||
merge = "NOT mergeable — " + merge
|
||||
}
|
||||
if a.Judged != "" {
|
||||
what += " (" + a.Judged + ")"
|
||||
}
|
||||
if a.OutOfDate != "" {
|
||||
merge += "; its verdict is " + a.OutOfDate
|
||||
}
|
||||
return what + ": " + statuses + "; " + merge
|
||||
}
|
||||
|
||||
|
||||
@@ -451,7 +451,7 @@ func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
|
||||
State: Ready, Since: t0, Created: t0.Add(-time.Hour), Plan: planMoving(b, "b"), Check: &Verdict{Gate: "pass"}}
|
||||
g := &Group{ID: "feat/k", Members: []string{aID, bID}, Order: []string{aID, bID}, Closed: true,
|
||||
OrderOf: map[string]string{aID: aHead, bID: bHead},
|
||||
Check: &GroupCheck{Asked: "check-1", At: t0, Commits: c.commits}}
|
||||
Check: &GroupCheck{Asked: "check-old", At: t0, Commits: c.commits}}
|
||||
for _, d := range []*Delivery{da, db} {
|
||||
_ = store.PutDelivery(d)
|
||||
}
|
||||
@@ -463,7 +463,7 @@ func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
|
||||
if s := w.state(aID); s != Held {
|
||||
t.Fatalf("before its composed verdict it is %s", s)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: c.verdict, Summary: "composed: " + c.verdict})
|
||||
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-old", Verdict: c.verdict, Summary: "composed: " + c.verdict})
|
||||
w.settleAll()
|
||||
if s := w.state(aID); s != c.want {
|
||||
t.Fatalf("its composed check %s, and it is %s (%s), want %s", c.name, s, w.delivery(aID).HeldWhy, c.want)
|
||||
@@ -471,6 +471,10 @@ func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
|
||||
if c.want == Delivering && !containsString(ctl.delivers, "plan-a") {
|
||||
t.Fatalf("lifted, its walk was not let go: %v", ctl.delivers)
|
||||
}
|
||||
// A composed check of other heads is asked again for these heads (novox/hq ADR 0323 §6).
|
||||
if c.name == "passed for other heads" && !containsString(ctl.checks, "feat/k:"+aID+","+bID) {
|
||||
t.Fatalf("the composed check was not asked again for the heads that merged: %v", ctl.checks)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,8 @@ type PullEvent struct {
|
||||
ModuleDirs []string `json:"module_dirs,omitempty"`
|
||||
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
||||
HeadChecks map[string]string `json:"head_checks,omitempty"`
|
||||
// BaseSHA is the base branch's head when the head was announced (novox/hq ADR 0323).
|
||||
BaseSHA string `json:"base_sha,omitempty"`
|
||||
}
|
||||
|
||||
func (p PullEvent) repository() string { return strings.ToLower(p.Owner + "/" + p.Repo) }
|
||||
@@ -297,10 +299,22 @@ func (h *Holder) PullUpdated(p PullEvent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
id := IDOf(p.repository(), p.HeadSHA)
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
return // announced again: nothing new
|
||||
}
|
||||
now := h.Now()
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
// Announced again: nothing new — but for a delivery whose verdict is out of date, the announcement is its
|
||||
// check asked again against the trunk's head (the merge tool's ask, novox/hq ADR 0323 §5), and its bound
|
||||
// runs from now (§7). The base head it was announced with is kept, for a check this owner asks after it.
|
||||
if p.BaseSHA != "" && d.BaseSHA != p.BaseSHA {
|
||||
d.BaseSHA = p.BaseSHA
|
||||
h.keep(d)
|
||||
}
|
||||
if d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil {
|
||||
d.AskedAgain = &now
|
||||
h.keep(d)
|
||||
}
|
||||
return
|
||||
}
|
||||
replaced := ""
|
||||
for _, old := range h.deliveries {
|
||||
if old.Repository == p.repository() && old.Number == p.Number && !sameCommit(old.Commit, p.HeadSHA) &&
|
||||
!old.State.Final() && old.MergedAs == "" {
|
||||
@@ -308,13 +322,14 @@ func (h *Holder) PullUpdated(p PullEvent) {
|
||||
h.settleAndKeep(old)
|
||||
if old.Group != "" {
|
||||
h.leaveGroup(old)
|
||||
replaced = old.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
d := &Delivery{ID: id, Repository: p.repository(), Commit: p.HeadSHA, Number: p.Number, Title: p.Title,
|
||||
Base: p.Base, Branch: p.Head, HTMLURL: p.HTMLURL, CloneURL: p.CloneURL, Paths: p.Paths,
|
||||
PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid,
|
||||
After: AfterIn(p.Body), Created: now}
|
||||
After: AfterIn(p.Body), Created: now, BaseSHA: p.BaseSHA}
|
||||
t, err := Apply(d, EvAnnounced, false, Facts{Now: now, Why: fmt.Sprintf("%s#%d's head announced", d.Repository, d.Number)})
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
@@ -328,7 +343,45 @@ func (h *Holder) PullUpdated(p PullEvent) {
|
||||
ts = append(ts, settle(d, Facts{Now: now})...)
|
||||
}
|
||||
h.moved(d, ts)
|
||||
h.joinGroup(d)
|
||||
if !h.takePlaceOf(replaced, d) {
|
||||
h.joinGroup(d)
|
||||
}
|
||||
}
|
||||
|
||||
// takePlaceOf puts a member's new head — a rebase, a push — in its group in the place of the head it superseded,
|
||||
// when the group is closed: a member of it already reached the trunk, and a closed group takes no new member. The
|
||||
// new head has its own check, and the group's composed check runs again for the new heads rather than holding the
|
||||
// group for a person (novox/hq ADR 0323 §6, issue 406). An open group takes it as joinGroup does.
|
||||
func (h *Holder) takePlaceOf(old string, d *Delivery) bool {
|
||||
if old == "" {
|
||||
return false
|
||||
}
|
||||
for _, g := range h.groups {
|
||||
_, ordered := g.OrderOf[old]
|
||||
if !g.Closed || !ordered && !containsString(g.Order, old) {
|
||||
continue
|
||||
}
|
||||
if ordered {
|
||||
delete(g.OrderOf, old)
|
||||
g.OrderOf[d.ID] = d.Commit
|
||||
}
|
||||
if !containsString(g.Members, d.ID) {
|
||||
g.Members = append(g.Members, d.ID)
|
||||
sort.Strings(g.Members)
|
||||
}
|
||||
for i, id := range g.Order {
|
||||
if id == old {
|
||||
g.Order[i] = d.ID
|
||||
}
|
||||
}
|
||||
d.Group = g.ID
|
||||
g.Updated = h.Now()
|
||||
h.keep(d)
|
||||
h.keepGroup(g)
|
||||
h.Logf("[mesh-delivery] group %s: %s takes the place of %s, which it superseded", g.ID, d.ID, old)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decidedOnHead is the verdict the forge's statuses on a head hold, when the gate decided one: passed, warned
|
||||
@@ -469,6 +522,47 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
}
|
||||
h.moved(d, ts)
|
||||
h.closeGroupOf(d)
|
||||
h.trunkMoved(d, p.Base, p.MergeCommit, now)
|
||||
}
|
||||
|
||||
// trunkMoved takes a merge into a trunk as that trunk's move (novox/hq ADR 0323 §7): every other open delivery into
|
||||
// it keeps the trunk's new head, and a ready one whose verdict names the commit before goes back to proposed. A
|
||||
// group's composed check goes out of date when the merge is not one of its members and a member still open merges
|
||||
// into that trunk (§6); the group then reads checking. Nothing is asked: a recheck or the merge tool judges again.
|
||||
func (h *Holder) trunkMoved(merged *Delivery, base, commit string, now time.Time) {
|
||||
if base == "" || commit == "" {
|
||||
return
|
||||
}
|
||||
for _, o := range h.deliveries {
|
||||
if o == merged || o.Repository != merged.Repository || o.Base != base || o.MergedAs != "" || o.State.Final() ||
|
||||
sameCommit(o.TrunkNow, commit) {
|
||||
continue
|
||||
}
|
||||
// A check asked again against the trunk before this move judged that trunk: it is out of date too.
|
||||
o.TrunkNow, o.AskedAgain = commit, nil
|
||||
h.settleAndKeep(o)
|
||||
}
|
||||
for _, g := range h.groups {
|
||||
if g.ID == merged.Group || g.Check == nil || g.Check.Verdict == "" || g.Check.OutOfDate != "" {
|
||||
continue
|
||||
}
|
||||
for _, m := range h.membersOf(g) {
|
||||
if m.Repository != merged.Repository || m.Base != base || m.MergedAs != "" || m.State.Final() {
|
||||
continue
|
||||
}
|
||||
judged := g.Check.Trunks[m.ID]
|
||||
if judged == "" {
|
||||
judged = "?"
|
||||
}
|
||||
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was judged against trunk %s, the trunk is now %s "+
|
||||
"(%s merged)", m.ID, shortOf(judged, 12), shortOf(commit, 12), merged.ID)
|
||||
g.Check.OutOfDateAt = now
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
h.Logf("[mesh-delivery] group %s: %s", g.ID, g.Check.OutOfDate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// closeGroupOf closes a delivery's group once it reached the trunk: a delivering group takes no new member.
|
||||
@@ -540,7 +634,13 @@ type CheckedEvent struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
} `json:"members,omitempty"`
|
||||
// Trunk is the trunk commit the head was merged into and judged as, Beside the other repositories' commits the
|
||||
// check read, and Took how long it ran (novox/hq ADR 0323 §3).
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
Beside map[string]string `json:"beside,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
}
|
||||
|
||||
// Checked takes a verdict: a head's own, or a group's composed check.
|
||||
@@ -555,6 +655,18 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
return
|
||||
}
|
||||
g.Check.Verdict, g.Check.Summary = c.Verdict, c.Summary
|
||||
// Each member's head and the trunk commit it was merged into (novox/hq ADR 0323 §6).
|
||||
for _, m := range c.Members {
|
||||
for id := range g.Check.Commits {
|
||||
if d := h.deliveries[id]; d != nil && strings.EqualFold(d.Repository, m.Owner+"/"+m.Repo) &&
|
||||
sameCommit(d.Commit, m.Commit) && m.Trunk != "" {
|
||||
if g.Check.Trunks == nil {
|
||||
g.Check.Trunks = map[string]string{}
|
||||
}
|
||||
g.Check.Trunks[id] = m.Trunk
|
||||
}
|
||||
}
|
||||
}
|
||||
g.Updated = now
|
||||
h.keepGroup(g)
|
||||
h.Logf("[mesh-delivery] group %s composed: %s — %s", g.ID, c.Verdict, c.Summary)
|
||||
@@ -575,11 +687,23 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.State == Rejected && c.Trunk != "" && d.Check != nil && !sameCommit(d.Check.Trunk, c.Trunk) {
|
||||
// Judged again against another trunk commit — the merge tool's ask of a verdict out of date, or a check asked
|
||||
// elsewhere (novox/hq ADR 0323 §5, §7): taken as the recheck it is.
|
||||
t, err := Apply(d, EvRecheck, true, Facts{Now: now, By: byOwner, Why: fmt.Sprintf("judged again against trunk %s "+
|
||||
"(it was rejected against trunk %s)", shortOf(c.Trunk, 12), shortOf(d.Check.Trunk, 12))})
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
return
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.State != Proposed {
|
||||
h.Logf("[mesh-delivery] %s is %s: a verdict for it now is history (%s)", d.ID, d.State, c.Verdict)
|
||||
return
|
||||
}
|
||||
v := &Verdict{ID: c.ID, On: c.On, At: now, Gate: c.Verdict, Summary: c.Summary, Report: c.Report}
|
||||
v := &Verdict{ID: c.ID, On: c.On, At: now, Gate: c.Verdict, Summary: c.Summary, Report: c.Report,
|
||||
Head: shortOf(c.Commit, 12), Trunk: c.Trunk, Beside: c.Beside, Took: c.Took}
|
||||
if c.Gate != nil {
|
||||
v.Gate, v.Summary = c.Gate.Verdict, c.Gate.Summary
|
||||
v.Modules, v.Dependents = c.Gate.Modules, c.Gate.Dependents
|
||||
@@ -590,10 +714,14 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
if v.Gate == "" {
|
||||
v.Gate = "error"
|
||||
}
|
||||
d.Check = v
|
||||
d.Check, d.AskedAgain = v, nil
|
||||
if c.Plan != nil {
|
||||
d.Plan = c.Plan
|
||||
}
|
||||
if why := d.outOfDate(); why != "" {
|
||||
// Kept and shown, and it does not count: the delivery stays proposed (ADR 0323 §7).
|
||||
h.Logf("[mesh-delivery] %s: its verdict %s is %s", d.ID, c.ID, why)
|
||||
}
|
||||
h.settleAndKeep(d)
|
||||
}
|
||||
|
||||
@@ -1442,14 +1570,19 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
})
|
||||
return asks
|
||||
}
|
||||
// The composed check, once every member is ready and the order holds.
|
||||
// The composed check, once every member is ready and the order holds. Asked again for new heads, even once the
|
||||
// group is closed: a member rebased or pushed is a new head, and the group waits for its composed check rather
|
||||
// than being held for a person (novox/hq ADR 0323 §6, issue 406). One out of date is asked again once a member
|
||||
// was judged again after it went out of date: a recheck, or the merge tool's ask.
|
||||
allReady := true
|
||||
for _, d := range members {
|
||||
if d.State != Ready {
|
||||
// In a closed group a member on the trunk counts — merged and on its way, or already delivered (reached).
|
||||
if d.State != Ready && !(g.Closed && d.MergedAs != "" && (!d.State.Final() || d.State.Reached())) {
|
||||
allReady = false
|
||||
}
|
||||
}
|
||||
if allReady && len(g.Cycle) == 0 && !g.Closed && (g.Check == nil || !sameMembers(g.Check.Commits, commits)) {
|
||||
again := g.Check != nil && (!sameMembers(g.Check.Commits, commits) || g.Check.OutOfDate != "" && rejudgedSince(members, g.Check.OutOfDateAt))
|
||||
if allReady && len(g.Cycle) == 0 && (g.Check == nil && !g.Closed || again) {
|
||||
ms := h.inOrder(g, members)
|
||||
id := g.ID
|
||||
asks = append(asks, func() {
|
||||
@@ -1571,7 +1704,7 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
|
||||
}
|
||||
// Composed for these very heads: a verdict on another set of heads is no verdict on these.
|
||||
composed := g.Check != nil && (g.Check.Verdict == "pass" || g.Check.Verdict == "warning") &&
|
||||
sameMembers(g.Check.Commits, commitsOf(members))
|
||||
sameMembers(g.Check.Commits, commitsOf(members)) && g.Check.OutOfDate == ""
|
||||
// A member's turn registered, the members after it that the same walk carries take theirs too: one walk is let
|
||||
// go once, for all of them (ADR 0276 decision 5). A member on another walk, or not at its turn, waits.
|
||||
var asks []func()
|
||||
@@ -1657,9 +1790,11 @@ func (h *Holder) turnsOf(g *Group, members []*Delivery, waits map[string]carryWa
|
||||
}
|
||||
return asks
|
||||
case d.State == Published:
|
||||
// Asked and not answered is not "did not pass": it waits, published, for the answer (issue 316).
|
||||
if !composed && d.HeldWhy == "" && g.Check != nil && g.Check.Verdict == "" &&
|
||||
sameMembers(g.Check.Commits, commitsOf(members)) {
|
||||
// Asked and not answered is not "did not pass": it waits, published, for the answer (issue 316). Nor is a
|
||||
// composed check of other heads — a member rebased or pushed — or one out of date: it runs again for these
|
||||
// heads, and the member waits for it (novox/hq ADR 0323 §6, issue 406).
|
||||
if !composed && d.HeldWhy == "" && g.Check != nil && (g.Check.Verdict == "" || g.Check.OutOfDate != "" ||
|
||||
!sameMembers(g.Check.Commits, commitsOf(members))) {
|
||||
return asks
|
||||
}
|
||||
if !composed && d.HeldWhy == "" {
|
||||
@@ -2180,12 +2315,23 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
|
||||
return "delivered", "every member is delivered"
|
||||
case g.Closed && n > 0 && ended == n:
|
||||
return "stopped", supersededWhy + strings.Join(uncovered, ", ")
|
||||
case g.Closed && g.Check != nil && (g.Check.OutOfDate != "" || g.Check.Verdict == "" ||
|
||||
!sameMembers(g.Check.Commits, commitsOf(members))):
|
||||
// A member rebased or pushed once the group was delivering, or its composed check out of date: it is composed
|
||||
// again for these heads, and the group is checking until then, never held (novox/hq ADR 0323 §6, issue 406).
|
||||
if g.Check.OutOfDate != "" {
|
||||
return "checking", g.Check.OutOfDate
|
||||
}
|
||||
return "checking", "composed again for its members' new heads"
|
||||
case g.Closed:
|
||||
return "delivering", fmt.Sprintf("%d of %d delivered", reached, n)
|
||||
case len(g.Cycle) > 0:
|
||||
return "rejected", cycleSaid(g)
|
||||
case count[Rejected] > 0:
|
||||
return "rejected", "a member's own check failed"
|
||||
case g.Check != nil && g.Check.OutOfDate != "":
|
||||
// Out of date counts as outstanding (ADR 0239 §18, novox/hq ADR 0323 §7): checking, saying why.
|
||||
return "checking", g.Check.OutOfDate
|
||||
case g.Check != nil && g.Check.Verdict != "" && g.Check.Verdict != "pass" && g.Check.Verdict != "warning":
|
||||
return "rejected", "composed together: " + g.Check.Summary
|
||||
case count[Ready] == n && g.Check != nil && sameMembers(g.Check.Commits, commitsOf(members)) &&
|
||||
@@ -2251,3 +2397,13 @@ func cycleSaid(g *Group) string {
|
||||
}
|
||||
return why
|
||||
}
|
||||
|
||||
// rejudgedSince is whether a member was judged again after a moment: a verdict of its own heard since.
|
||||
func rejudgedSince(members []*Delivery, at time.Time) bool {
|
||||
for _, d := range members {
|
||||
if d.Check != nil && d.Check.At.After(at) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
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"}
|
||||
plan := *c.Plan
|
||||
plan.Head, plan.Trunk = sha, c.Trunk
|
||||
c.Plan = &plan
|
||||
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)
|
||||
}
|
||||
asked := len(w.ctl.checks)
|
||||
|
||||
// #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)
|
||||
}
|
||||
if len(w.ctl.checks) != asked {
|
||||
t.Fatalf("the trunk's move asked a check: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
if len(w.ctl.checks) != asked {
|
||||
t.Fatalf("an out-of-date check was asked by this owner: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
|
||||
// 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 || d.Check.ID != "check-late" {
|
||||
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)
|
||||
}
|
||||
if d.Plan == nil || d.Plan.Trunk != trunk0[:12] || d.Plan.Head != head {
|
||||
t.Fatalf("the kept delivery plan records %+v", d.Plan)
|
||||
}
|
||||
|
||||
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 delivery group's composed check follows the same rule** (ADR 0323 §6, §7): a merge that is not one of its
|
||||
// members into a member's trunk puts it out of date and the group reads checking; its own members merging in its
|
||||
// order do not; and it runs again once a member is judged again.
|
||||
func TestAGroupsComposedCheckGoesOutOfDateOnAMergeThatIsNotItsMember(t *testing.T) {
|
||||
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
|
||||
const hc, hk = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1"
|
||||
idc, idk := IDOf(ctl, hc), IDOf(cat, hk)
|
||||
start := func(t *testing.T) *world {
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
|
||||
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
|
||||
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
|
||||
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g == nil || g.Check == nil {
|
||||
t.Fatalf("no composed check was asked: %+v", g)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: hc, Trunk: trunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: hk, Trunk: trunk0[:12]}}})
|
||||
w.settleAll()
|
||||
if state, _ := GroupState(g, w.h.membersOf(g)); state != "ready" {
|
||||
t.Fatalf("the group composed green is %s", state)
|
||||
}
|
||||
if g.Check.Trunks[idc] != trunk0[:12] {
|
||||
t.Fatalf("the composed check names its members' trunks %v", g.Check.Trunks)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
t.Run("a merge that is not a member", func(t *testing.T) {
|
||||
w := start(t)
|
||||
asked := len(w.ctl.checks)
|
||||
w.h.PullMerged(merged(ctl, 5, "5555555555555555", merge1))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
state, why := GroupState(g, w.h.membersOf(g))
|
||||
if state != "checking" || g.Check.OutOfDate == "" {
|
||||
t.Fatalf("the group is %s (%s) after a merge that is not its member moved a member's trunk", state, why)
|
||||
}
|
||||
for _, head := range []string{ctl + "@" + hc, cat + "@" + hk} {
|
||||
s := w.forge.statuses[head+" "+ctxGroup]
|
||||
if !strings.HasPrefix(s, "pending ") || !strings.Contains(s, "out of date") {
|
||||
t.Fatalf("mesh/delivery-group on %s says %q, not pending with the reason", head, s)
|
||||
}
|
||||
}
|
||||
if len(w.ctl.checks) != asked {
|
||||
t.Fatalf("the trunk's move asked a check: %v", w.ctl.checks[asked:])
|
||||
}
|
||||
// The controller's member judged again against the trunk's head: the composed check runs again.
|
||||
w.later(time.Minute)
|
||||
w.h.Checked(judged(ctl, 1, hc, "pass", merge1))
|
||||
w.settleAll()
|
||||
if len(w.ctl.checks) == asked || !strings.HasPrefix(w.ctl.checks[len(w.ctl.checks)-1], "feat/g:") {
|
||||
t.Fatalf("the composed check was not asked again once a member was judged again: %v", w.ctl.checks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("its own member merging", func(t *testing.T) {
|
||||
w := start(t)
|
||||
mc := merged(ctl, 1, hc, merge1)
|
||||
mc.Head = "feat/g"
|
||||
w.h.PullMerged(mc)
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g.Check.OutOfDate != "" {
|
||||
t.Fatalf("the group's own member merging put its composed check out of date: %s", g.Check.OutOfDate)
|
||||
}
|
||||
if s := w.state(idk); s != Ready {
|
||||
t.Fatalf("the catalogue's member, in another repository, is %s", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// **After a member is rebased or pushed, the composed check runs again for the new heads** and the group reads
|
||||
// checking, not held (ADR 0323 §6, issue 406's second symptom): its first member merged, closing the group, and then
|
||||
// its second was rebased just before its own merge.
|
||||
func TestARebasedMemberIsComposedAgainNotHeld(t *testing.T) {
|
||||
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
|
||||
const hc, hk, hc2 = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1", "c2c2c2c2c2c2c2c2"
|
||||
idc, idk, idc2 := IDOf(ctl, hc), IDOf(cat, hk), IDOf(ctl, hc2)
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
|
||||
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
|
||||
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
|
||||
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if len(g.Order) != 2 || g.Order[0] != idk {
|
||||
t.Fatalf("the group's order is %v: the catalogue's member first is what this replays", g.Order)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"})
|
||||
w.settleAll()
|
||||
|
||||
// The catalogue's member merges first: the group is closed.
|
||||
mk := merged(cat, 2, hk, merge1)
|
||||
mk.Head = "feat/g"
|
||||
w.h.PullMerged(mk)
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-cat", cat, merge1, true)))
|
||||
w.settleAll()
|
||||
if !g.Closed {
|
||||
t.Fatal("the group is not closed once a member merged")
|
||||
}
|
||||
|
||||
// The controller's member is rebased just before its merge: a new head, with its own check.
|
||||
w.h.PullUpdated(pr(ctl, 1, hc2, "feat/g"))
|
||||
w.settleAll()
|
||||
if d := w.delivery(idc2); d.Group != "feat/g" {
|
||||
t.Fatalf("the rebased head did not take its place in its closed group: %q", d.Group)
|
||||
}
|
||||
if containsString(g.Members, idc) || !containsString(g.Members, idc2) || len(g.Order) != 2 || g.Order[1] != idc2 {
|
||||
t.Fatalf("the group's members are %v in the order %v", g.Members, g.Order)
|
||||
}
|
||||
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
|
||||
t.Fatalf("the group reads %s (%s), not checking", state, why)
|
||||
}
|
||||
w.h.Checked(judged(ctl, 1, hc2, "pass", trunk0))
|
||||
w.settleAll()
|
||||
asked := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if !strings.HasPrefix(asked, "feat/g:") || !strings.Contains(asked, idc2) || !strings.Contains(asked, idk) {
|
||||
t.Fatalf("the composed check was not asked again for the new heads: %v", w.ctl.checks)
|
||||
}
|
||||
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
|
||||
t.Fatalf("the group reads %s (%s), not checking", state, why)
|
||||
}
|
||||
for _, id := range []string{idk, idc2} {
|
||||
if d := w.delivery(id); d.State == Held {
|
||||
t.Fatalf("%s is held for a person: %s", id, d.HeldWhy)
|
||||
}
|
||||
}
|
||||
// Its verdict for the new heads: the rebased member merges and takes its turn.
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
mc := merged(ctl, 1, hc2, "e0e0e0e0e0e0e0e0")
|
||||
mc.Head = "feat/g"
|
||||
w.h.PullMerged(mc)
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-ctl", ctl, "e0e0e0e0e0e0e0e0", true)))
|
||||
w.settleAll()
|
||||
if d := w.delivery(idc2); d.State == Held {
|
||||
t.Fatalf("the rebased member, composed green for the new heads, is held: %s", d.HeldWhy)
|
||||
}
|
||||
}
|
||||
|
||||
// A head whose merge check the forge's holder put out of date is not a check asked and not answered: `stalled` does
|
||||
// not list it, however long it waits — rejected, or with no delivery here — until its check is asked (ADR 0323 §7).
|
||||
func TestAnOutOfDateCheckOnTheForgeIsNotSaidUnanswered(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// The same, with the first member already delivered when the second is rebased: a delivered member counts for the
|
||||
// composed check of the new heads, which is asked, and the rebased member is never held (review of ADR 0323 §6).
|
||||
func TestARebasedMemberAfterAnotherWasDeliveredIsComposedAgain(t *testing.T) {
|
||||
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
|
||||
const hc, hk, hc2 = "c1c1c1c1c1c1c1c1", "k1k1k1k1k1k1k1k1", "c2c2c2c2c2c2c2c2"
|
||||
idk, idc2 := IDOf(cat, hk), IDOf(ctl, hc2)
|
||||
w := newWorld(t)
|
||||
w.h.PullUpdated(pr(ctl, 1, hc, "feat/g"))
|
||||
w.h.PullUpdated(pr(cat, 2, hk, "feat/g"))
|
||||
w.h.Checked(judged(ctl, 1, hc, "pass", trunk0))
|
||||
w.h.Checked(judged(cat, 2, hk, "pass", trunk0))
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"})
|
||||
w.settleAll()
|
||||
|
||||
// The catalogue's member merges and its walk is done: delivered.
|
||||
mk := merged(cat, 2, hk, merge1)
|
||||
mk.Head = "feat/g"
|
||||
w.h.PullMerged(mk)
|
||||
walk := w.ctl.put(aWalk("plan-cat", cat, merge1, true))
|
||||
w.h.WalkMoved(walk)
|
||||
w.settleAll()
|
||||
walk = w.ctl.walk("plan-cat")
|
||||
walk.State = walkDone
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
w.settleAll()
|
||||
if s := w.state(idk); s != Delivered {
|
||||
t.Fatalf("the catalogue's member is %s, not delivered", s)
|
||||
}
|
||||
|
||||
// The controller's member is rebased, and judged.
|
||||
w.h.PullUpdated(pr(ctl, 1, hc2, "feat/g"))
|
||||
w.h.Checked(judged(ctl, 1, hc2, "pass", trunk0))
|
||||
w.settleAll()
|
||||
asked := w.ctl.checks[len(w.ctl.checks)-1]
|
||||
if !strings.HasPrefix(asked, "feat/g:") || !strings.Contains(asked, idc2) {
|
||||
t.Fatalf("the composed check was not asked again for the new heads: %v", w.ctl.checks)
|
||||
}
|
||||
if state, why := GroupState(g, w.h.membersOf(g)); state != "checking" {
|
||||
t.Fatalf("the group reads %s (%s), not checking", state, why)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed again"})
|
||||
mc := merged(ctl, 1, hc2, "e0e0e0e0e0e0e0e0")
|
||||
mc.Head = "feat/g"
|
||||
w.h.PullMerged(mc)
|
||||
w.h.WalkMoved(w.ctl.put(aWalk("plan-ctl", ctl, "e0e0e0e0e0e0e0e0", true)))
|
||||
w.settleAll()
|
||||
if d := w.delivery(idc2); d.State == Held {
|
||||
t.Fatalf("the rebased member is held: %s", d.HeldWhy)
|
||||
}
|
||||
}
|
||||
|
||||
// A check this owner asks — a recheck — sends the base head the forge last announced the head with, so the
|
||||
// controller's verdict for a head no build judges names its trunk commit (ADR 0323 §3, review of mesh-controller#271).
|
||||
func TestARecheckSendsTheAnnouncedBaseHead(t *testing.T) {
|
||||
const repo = "novox/mesh-tools-go"
|
||||
const head = "1a2b3c4d5e6f7a8b"
|
||||
w := newWorld(t)
|
||||
p := pr(repo, 3, head, "x")
|
||||
p.BaseSHA = trunk0
|
||||
w.h.PullUpdated(p)
|
||||
id := IDOf(repo, head)
|
||||
if m := MemberOf(w.delivery(id)); m.BaseSHA != trunk0 {
|
||||
t.Fatalf("the member sends base head %q", m.BaseSHA)
|
||||
}
|
||||
p.BaseSHA = merge1 // announced again, by the merge tool
|
||||
w.h.PullUpdated(p)
|
||||
if m := MemberOf(w.delivery(id)); m.BaseSHA != merge1 {
|
||||
t.Fatalf("announced again, the member sends base head %q", m.BaseSHA)
|
||||
}
|
||||
if raw := string(mustJSON(MemberOf(w.delivery(id)))); !strings.Contains(raw, `"base_sha":"`+merge1+`"`) {
|
||||
t.Fatalf("the member as the controller reads it: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,17 @@ type Delivery struct {
|
||||
// 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"`
|
||||
// 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.
|
||||
@@ -128,6 +139,38 @@ type Verdict struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
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).
|
||||
@@ -168,6 +211,8 @@ type DeliveryPlan struct {
|
||||
// Class is the class of the slowest-budgeted module it moves, as the planner says it: core, leaf, or none when
|
||||
// it moves no module (novox/hq ADR 0313 §2). Empty in a plan from a controller that did not say it.
|
||||
Class string `json:"class,omitempty"`
|
||||
// Trunk is the trunk commit the head was merged into when its verdict judged it (novox/hq ADR 0323 §3).
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}
|
||||
|
||||
// receiving are the machines the plan sends a module to, in the plan's order; none when the plan names no
|
||||
@@ -755,6 +800,13 @@ type GroupCheck struct {
|
||||
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.
|
||||
|
||||
@@ -31,13 +31,16 @@ type Member struct {
|
||||
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
||||
CloneURL string `json:"clone_url,omitempty"`
|
||||
After []string `json:"after,omitempty"`
|
||||
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
|
||||
// the controller's verdict names when no build judges the head.
|
||||
BaseSHA string `json:"base_sha,omitempty"`
|
||||
}
|
||||
|
||||
// MemberOf is a delivery as a member.
|
||||
func MemberOf(d *Delivery) Member {
|
||||
return Member{ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
|
||||
PathsTruncated: d.PathsTruncated, Removed: d.Removed, ModuleDirs: d.ModuleDirs, ModuleDirsSaid: d.ModuleDirsSaid,
|
||||
CloneURL: d.CloneURL, After: d.After}
|
||||
CloneURL: d.CloneURL, After: d.After, BaseSHA: d.BaseSHA}
|
||||
}
|
||||
|
||||
// Order is a group's order as the controller works it out: each pair with the rules it won over by
|
||||
|
||||
@@ -85,6 +85,7 @@ const (
|
||||
EvCovered Event = "covered" // a later merge into the same trunk delivered every module it moves
|
||||
EvLifted Event = "lifted" // what held it for a person is no longer so: its group's composed check passed, or a walk now answers its merge
|
||||
EvRewalk Event = "rewalk" // the walk that carried a later commit for it failed, or was stopped for another merge (ADR 0299): it waits for the walk that walks it again
|
||||
EvTrunkMoved Event = "trunk moved" // another merge moved its trunk, and its verdict names the trunk commit before (novox/hq ADR 0323)
|
||||
)
|
||||
|
||||
// Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act.
|
||||
@@ -167,9 +168,17 @@ var Table = []Row{
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
return unless(d.MergedAs != "" && (d.Walk != nil || d.MovesNothing()), "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: EvChecked, To: Checked,
|
||||
Guard: "the verdict names this commit",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(d.Check != nil, "no verdict") }},
|
||||
Guard: "a verdict names this commit and the trunk's head as its trunk commit",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
if d.Check == nil {
|
||||
return errors.New("no verdict")
|
||||
}
|
||||
return unless(d.outOfDate() == "", "its verdict is "+d.outOfDate())
|
||||
}},
|
||||
{From: []State{Checked}, Event: EvAccepted, To: Ready,
|
||||
Guard: "the gate passed or warned, and the repository's own check did not fail",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(d.Check.Passes(), "the verdict does not pass") }},
|
||||
@@ -199,6 +208,14 @@ var Table = []Row{
|
||||
Says: func(d *Delivery) string {
|
||||
return "covered: a later merge that contains it was delivered: " + d.CoveredBy
|
||||
}},
|
||||
// **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",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
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, Checked, Rejected}, Event: EvMergedUnchecked, To: Held,
|
||||
Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides that it goes on",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }},
|
||||
@@ -543,6 +560,9 @@ const checkGrace = 15 * time.Minute
|
||||
// runs from its start plus the grace before it is asked: 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
|
||||
}
|
||||
@@ -562,6 +582,17 @@ var rewalkBound = Bound{For: unwalked, Says: "the walk that carried a later comm
|
||||
|
||||
// boundOf is a delivery's bound: its state's, or none for a delivery held because no walk answers it.
|
||||
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
|
||||
}
|
||||
if d.State == Held && d.Unwalked && d.Walk == nil {
|
||||
return heldUnwalked, true
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ func aDeliveryFor(r Row) (*Delivery, Facts) {
|
||||
case EvRewalk:
|
||||
d.MergedAs, d.AnsweredBy = "aaaa1111", "bbbb2222"
|
||||
d.Walk = &WalkSeen{ID: "plan-2", State: walkFailed}
|
||||
case EvTrunkMoved:
|
||||
d.Check = &Verdict{Gate: "pass", Head: "c0ffee000000", Trunk: "d28ca95bbbbb"}
|
||||
d.TrunkNow = "690966dccccccccc"
|
||||
case EvLifted:
|
||||
d.MergedAs, d.Lifted = "aaaa1111", "its group's composed check passed"
|
||||
d.Walk = &WalkSeen{ID: "plan-1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}
|
||||
@@ -129,7 +132,7 @@ func TestEveryRowOfTheTableIsTakenWhenItsGuardHoldsAndRefusedWhenNot(t *testing.
|
||||
func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) {
|
||||
events := []Event{EvAnnounced, EvAppeared, EvAdopted, EvChecked, EvAccepted, EvRefused, EvRecheck, EvReask, EvNewHead,
|
||||
EvClosed, EvMerged, EvMergedUnchecked, EvGo, EvHold, EvRelease, EvDone, EvFailed, EvSuperseded, EvStop,
|
||||
EvHistory, EvRetire, EvCovered, EvLifted, EvRewalk}
|
||||
EvHistory, EvRetire, EvCovered, EvLifted, EvRewalk, EvTrunkMoved}
|
||||
for _, from := range append([]State{None}, AllStates...) {
|
||||
for _, ev := range events {
|
||||
for _, act := range []bool{false, true} {
|
||||
|
||||
@@ -44,6 +44,10 @@ const Unannounced State = "unannounced"
|
||||
// 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
|
||||
@@ -119,6 +123,9 @@ func (p OpenPull) unanswered() (waiting []string, pending map[string]string, sta
|
||||
// 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]))
|
||||
|
||||
@@ -229,6 +229,8 @@ func (h *Holder) Stalled() []StalledLine {
|
||||
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 controller does not answer delivery-check"
|
||||
case d.State == Proposed && d.CheckAsk.Reasked > 0:
|
||||
|
||||
Reference in New Issue
Block a user