diff --git a/modules/mesh-issues/cmd/mesh-issues/claims_test.go b/modules/mesh-issues/cmd/mesh-issues/claims_test.go index 7a627b9f..40205884 100644 --- a/modules/mesh-issues/cmd/mesh-issues/claims_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/claims_test.go @@ -62,17 +62,17 @@ func TestALinkedFixDeliveredIsNotedOnceNamingItsWorker(t *testing.T) { linkPR(t, h, n, "mesh-catalog#226") active := get(t, h, n).Worker.Active merged := now.Add(-20 * time.Minute) - transition(t, h, 225, "published", "", "abc", merged) + transition(t, h, 225, "published", "", "abc1234", merged) if v, _ := h.View(n); v.Delivery != "fix merged, not delivered: mesh-catalog#225" { t.Fatalf("a linked pull request's delivery is not measured: %q", v.Delivery) } delivered := now.Add(-10 * time.Minute) - transition(t, h, 225, "delivered", "", "abc", delivered) + transition(t, h, 225, "delivered", "", "abc1234", delivered) if countEvents(h, EventReadyToVerify) != 0 { t.Fatal("said delivered while mesh-catalog#226 is not") } - transition(t, h, 226, "delivered", "", "def", delivered.Add(time.Minute)) - transition(t, h, 226, "delivered", "", "def", delivered.Add(time.Minute)) + transition(t, h, 226, "delivered", "", "def1234", delivered.Add(time.Minute)) + transition(t, h, 226, "delivered", "", "def1234", delivered.Add(time.Minute)) is := get(t, h, n) var said []string for _, nt := range is.Notes { @@ -119,7 +119,7 @@ func TestDeliveredNotResolvedIsWarnedAfterTheBoundAndClearedWhenItLeavesFixing(t t.Fatal(err) } linkPR(t, h, n, "mesh-catalog#9") - transition(t, h, 9, "delivered", "", "abc", *now) + transition(t, h, 9, "delivered", "", "abc1234", *now) *now = now.Add(59 * time.Minute) if _, err := h.Sweep(); err != nil || len(note.said) != 0 { t.Fatalf("warned under the bound: %q %v", note.said, err) @@ -169,7 +169,7 @@ func TestDeliveredNotResolvedIsWarnedAfterTheBoundAndClearedWhenItLeavesFixing(t t.Fatal(err) } linkPR(t, h2, m, "mesh-catalog#9") - transition(t, h2, 9, "delivered", "", "abc", *now2) + transition(t, h2, 9, "delivered", "", "abc1234", *now2) *now2 = now2.Add(11 * time.Minute) if _, err := h2.Sweep(); err != nil || len(n2.said) != 1 { t.Fatalf("the setting's bound was not read: %q %v", n2.said, err) @@ -188,7 +188,7 @@ func TestDeliveredNotResolvedIsRetriedAndClearedAtTheSweep(t *testing.T) { t.Fatal(err) } linkPR(t, h, n, "mesh-catalog#9") - transition(t, h, 9, "delivered", "", "abc", *now) + transition(t, h, 9, "delivered", "", "abc1234", *now) *now = now.Add(2 * time.Hour) _, _ = h.Sweep() note.err = nil @@ -355,7 +355,7 @@ func TestAPullRequestClosedUnmergedIsWithdrawn(t *testing.T) { } // A second fix linked and delivered is all the issue waits for: the withdrawn one is no fix. linkPR(t, h, n, "mesh-catalog#10") - transition(t, h, 10, "delivered", "", "abc", *now) + transition(t, h, 10, "delivered", "", "abc1234", *now) if v := mustView(t, h, n); !v.DeliveredNotResolved || v.Delivery != "delivered to every machine it should reach" { t.Fatalf("%+v %q", v.DeliveredNotResolved, v.Delivery) } @@ -378,7 +378,7 @@ func TestOnePullRequestLinkedToSeveralIssues(t *testing.T) { linkPR(t, h, n, "mesh-catalog#9") ns = append(ns, n) } - transition(t, h, 9, "delivered", "", "abc", *now) + transition(t, h, 9, "delivered", "", "abc1234", *now) if countEvents(h, EventReadyToVerify) != 2 { t.Fatalf("%d ready-to-verify", countEvents(h, EventReadyToVerify)) } diff --git a/modules/mesh-issues/cmd/mesh-issues/delivered_test.go b/modules/mesh-issues/cmd/mesh-issues/delivered_test.go new file mode 100644 index 00000000..2d6e3584 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/delivered_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// R6-C (ADR 0539 §17): the gate keeps no state after the merge, so an issue's fix is delivered when the delivery view says its +// source revision reached the machines (`mesh-delivery-view.delivered`: {revision, group, heads}), matched to the fix by the +// group it merged in or by the commit its merge landed as. The same revision twice is once. + +func mergedFix(t *testing.T, h *harness, number int, group, mergedAs string) { + t.Helper() + body := map[string]any{"repository": "novox/mesh-catalog", "number": number, "to": "merged", "merged_as": mergedAs, + "at": time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)} + if group != "" { + body["group"] = group + } + raw, _ := json.Marshal(body) + if err := h.OnTransition(stdio.Envelope{Key: "mesh-delivery-gate.transition", Body: raw}); err != nil { + t.Fatal(err) + } +} + +func viewSays(t *testing.T, h *harness, revision int, group string, heads map[string]string) { + t.Helper() + raw, _ := json.Marshal(map[string]any{"revision": revision, "group": group, "heads": heads}) + if err := h.OnDelivered(stdio.Envelope{Key: DeliveredEvent, Body: raw}); err != nil { + t.Fatal(err) + } +} + +func fixIssue(t *testing.T, h *harness, pr string) uint64 { + t.Helper() + n := h.open(t, "a fix to deliver "+pr) + fixedAtFixing(t, h, n, pr) + return n +} + +func TestAFixIsDeliveredWhenTheViewSaysTheGroupThatMovedTheSourceReachedTheMachines(t *testing.T) { + h := newHarness(t) + in := fixIssue(t, h, "mesh-catalog#20") + other := fixIssue(t, h, "mesh-catalog#21") + mergedFix(t, h, 20, "feat/r6", "aaaaaaaaaaaa1111") + mergedFix(t, h, 21, "feat/other", "bbbbbbbbbbbb2222") + if v, _ := h.View(in); v.Delivery != "fix merged, not delivered: mesh-catalog#20" { + t.Fatalf("before the view speaks: %q", v.Delivery) + } + viewSays(t, h, 7, "feat/r6", map[string]string{"novox/mesh-sdk": "cccccccccccc3333"}) + if v, _ := h.View(in); v.Delivery != "delivered to every machine it should reach" { + t.Fatalf("the group's fix after the view spoke: %q", v.Delivery) + } + if v, _ := h.View(other); v.Delivery != "fix merged, not delivered: mesh-catalog#21" { + t.Fatalf("a fix of another group was delivered by a revision that is not its own: %q", v.Delivery) + } + if n := countEvents(h, EventReadyToVerify); n != 1 { + t.Fatalf("%d ready-to-verify, wanted one", n) + } +} + +func TestAFixIsDeliveredWhenTheViewSaysTheHeadItsMergeLandedAs(t *testing.T) { + h := newHarness(t) + n := fixIssue(t, h, "mesh-catalog#22") + mergedFix(t, h, 22, "", "dddddddddddd4444eeee") + viewSays(t, h, 8, "some-other-group", map[string]string{"novox/mesh-sdk": "dddddddddddd4444eeee"}) + if v, _ := h.View(n); v.Delivery != "fix merged, not delivered: mesh-catalog#22" { + t.Fatalf("a head of another repository delivered it: %q", v.Delivery) + } + viewSays(t, h, 9, "some-other-group", map[string]string{"novox/mesh-catalog": "dddddddddddd"}) + if v, _ := h.View(n); v.Delivery != "delivered to every machine it should reach" { + t.Fatalf("the head its merge landed as: %q", v.Delivery) + } +} + +func TestTheSameRevisionTwiceIsOnceAndTheGatesOwnDeliveredIsNoWord(t *testing.T) { + h := newHarness(t) + n := fixIssue(t, h, "mesh-catalog#23") + // The gate keeps no state after the merge: a transition to delivered, from an older gate, says nothing. + raw, _ := json.Marshal(map[string]any{"repository": "novox/mesh-catalog", "number": 23, "to": "delivered", "merged_as": "ffffffffffff5555"}) + if err := h.OnTransition(stdio.Envelope{Key: "mesh-delivery-gate.transition", Body: raw}); err != nil { + t.Fatal(err) + } + if v, _ := h.View(n); v.Delivery != "nothing heard from the delivery of mesh-catalog#23" { + t.Fatalf("the gate's delivered counted: %q", v.Delivery) + } + mergedFix(t, h, 23, "feat/x", "ffffffffffff5555") + for range 2 { + viewSays(t, h, 10, "feat/x", nil) + } + if is := get(t, h, n); len(is.Delivered) != 1 || countEvents(h, EventReadyToVerify) != 1 { + t.Fatalf("delivered %v, %d ready-to-verify", is.Delivered, countEvents(h, EventReadyToVerify)) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/delivery.go b/modules/mesh-issues/cmd/mesh-issues/delivery.go index f07bf835..b23e023c 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivery.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivery.go @@ -1,8 +1,10 @@ package main -// Whether an issue's fix is delivered (novox/hq ADR 0281). The tracker hears mesh-delivery's `transition` event -// for every pull request an issue's fixed-by names and keeps, per pull request, the state the delivery last said -// — merged or not, held and why, covered by a later merge, delivered. From that it derives one line for `get` and +// Whether an issue's fix is delivered (novox/hq ADR 0281, cut by ADR 0539 §17). The tracker hears the gate's `transition` +// event for every pull request an issue's fixed-by names and keeps, per pull request, the state the gate last said — merged +// or not, held and why, closed — and the delivery view's `delivered` event for what reached the machines: the gate keeps no +// state after the merge, so a source revision's delivered state is the view's, said once per revision with the group that +// moved it and the merged heads. From that it derives one line for `get` and // `list`: a measured fact, never set by a verb. Delivered is not resolved: when every fix of an issue not yet // resolved is delivered, the issue gains a note and the event `ready-to-verify`, and an agent proves its // acceptance live before resolving it. A fix merged and not delivered past a bound is said on the issue and to @@ -41,6 +43,11 @@ type Fix struct { // no module; Opened when its pull request was first heard (novox/hq ADR 0313 §2, §4). Class string `json:"class,omitempty"` Opened *time.Time `json:"opened,omitempty"` + // Repository, Group and MergedAs are what the gate said of its merge, which the view's `delivered` event is matched to: + // the repository (owner/name), the group that moved the source, and the commit the merge landed as. + Repository string `json:"repository,omitempty"` + Group string `json:"group,omitempty"` + MergedAs string `json:"merged-as,omitempty"` } // heardFix is what the delivery said of a pull request at one moment: its state, why, whether it is merged, and @@ -54,6 +61,10 @@ type heardFix struct { Class string Opened *time.Time MergedAt *time.Time + // Repository, Group and MergedAs: see Fix. + Repository string + Group string + MergedAs string } // news says whether what was heard adds anything to what a fix keeps. @@ -64,13 +75,15 @@ func (h heardFix) news(f *Fix, kept string) bool { if h.Opened != nil && (f.Opened == nil || !f.Opened.Equal(*h.Opened)) { return true } + if h.Group != "" && f.Group != h.Group || h.MergedAs != "" && f.MergedAs != h.MergedAs || h.Repository != "" && f.Repository != h.Repository { + return true + } return h.MergedAt != nil && (f.MergedAt == nil || !f.MergedAt.Equal(*h.MergedAt)) } -// reached says whether a delivery's state says the change reached the machines (mesh-delivery's Reached). -func reached(state string) bool { - return state == "delivered" || state == "covered" || state == "retired" -} +// reached says whether a fix's state says the change reached the machines: only the view's `delivered` event says it +// (OnDelivered); the gate's states end at the merge. +func reached(state string) bool { return state == "delivered" } // Whether a pull request is merged is what the delivery says of the pull request itself — `merged_as` on a // transition, the pull request's `merged` in checks — never read from the state: `stopped` is also what a pull @@ -79,7 +92,9 @@ func reached(state string) bool { // withdrawn says whether a fix is a pull request closed without merging: no fix at all, neither awaiting delivery, // delivered, nor open. -func withdrawn(f *Fix) bool { return f != nil && f.State == "stopped" && !f.Merged } +func withdrawn(f *Fix) bool { + return f != nil && (f.State == "closed" || f.State == "stopped") && !f.Merged +} // liveFixes is an issue's fixes less those withdrawn: what its delivery, readiness to verify, the stuck sweep and a // abandoned issue are measured on. fixPullRequests still names the withdrawn ones, so a pull request reopened is heard. @@ -138,17 +153,15 @@ func DeliveryLine(is *Issue) string { return "" } var held, failed, merged, open, unheard []string - covered := false for _, pr := range prs { f := fixOf(is, pr) switch { case contains(is.Delivered, pr): - covered = covered || (f != nil && f.State == "covered") case f == nil: unheard = append(unheard, pr) case f.State == "held": held = append(held, strings.TrimSpace(pr+": "+f.Why)) - case f.State == "failed" || f.State == "stopped": + case f.State == "failed" || f.State == "stopped" || f.State == "closed": failed = append(failed, strings.TrimSpace(pr+" "+f.State+": "+f.Why)) case f.Merged: merged = append(merged, pr) @@ -167,8 +180,6 @@ func DeliveryLine(is *Issue) string { return "fix not merged: " + strings.Join(open, ", ") case len(unheard) > 0: return "nothing heard from the delivery of " + strings.Join(unheard, ", ") - case covered: - return "delivered to every machine it should reach (by a later merge that contains it)" } return "delivered to every machine it should reach" } @@ -215,6 +226,15 @@ func (t *Tracker) recordFix(pr string, h heardFix, only uint64) ([]uint64, error if h.Class != "" { f.Class = h.Class } + if h.Repository != "" { + f.Repository = h.Repository + } + if h.Group != "" { + f.Group = h.Group + } + if h.MergedAs != "" { + f.MergedAs = h.MergedAs + } if h.Opened != nil { o := h.Opened.UTC() f.Opened = &o @@ -374,17 +394,22 @@ func (t *Tracker) OnTransition(e stdio.Envelope) error { Class string `json:"class"` Opened *time.Time `json:"opened"` MergedAt *time.Time `json:"merged_at"` + Group string `json:"group"` } if err := json.Unmarshal(e.Body, &body); err != nil || body.To == "" || body.Number <= 0 { return nil } + if body.To == "delivered" { + return nil // the gate keeps no state after the merge; what reached the machines is the view's (OnDelivered) + } at := body.At.UTC() if at.IsZero() { at = t.now() } merged := body.MergedAs != "" changed, err := t.recordFix(prKey(body.Repository, body.Number), heardFix{State: body.To, Why: body.Why, Merged: merged, - At: at, Class: body.Class, Opened: nonZero(body.Opened), MergedAt: nonZero(body.MergedAt)}, 0) + At: at, Class: body.Class, Opened: nonZero(body.Opened), MergedAt: nonZero(body.MergedAt), + Repository: strings.ToLower(body.Repository), Group: body.Group, MergedAs: body.MergedAs}, 0) if err != nil { return err } @@ -396,6 +421,90 @@ func (t *Tracker) OnTransition(e stdio.Envelope) error { return nil } +// DeliveredEvent is the view's `delivered` (mesh-delivery-view, ADR 0539 §17 and design 47 R6): said once per source revision +// when every want, have and assignment is at the fingerprint and healthy. +const DeliveredEvent = "mesh-delivery-view.delivered" + +// OnDelivered is the handler of the view's `delivered` event: {revision, group, heads}. Every merged fix of a pull request the +// group moved, or whose merge is the head the revision holds for its repository, reached the machines at that revision. The same +// revision twice changes nothing (a delivered fix stays delivered), and a fix is matched by the group it merged in or by its own +// merge commit, so a revision that carries a later head of a repository does not cover an earlier merge the tracker never heard +// delivered: that waits for its own revision's event. +func (t *Tracker) OnDelivered(e stdio.Envelope) error { + var body struct { + Revision uint64 `json:"revision"` + Group string `json:"group"` + Heads map[string]string `json:"heads"` + // At is when the view judged the revision delivered, when it says so; else this tracker's moment of hearing it. + At time.Time `json:"at"` + } + if err := json.Unmarshal(e.Body, &body); err != nil || (body.Group == "" && len(body.Heads) == 0) { + return nil + } + all, err := t.allIssues() + if err != nil { + return err + } + at := body.At.UTC() + if at.IsZero() { + at = t.now() + } + var changed []uint64 + for n, is := range all { + for _, pr := range liveFixes(is) { + f := fixOf(is, pr) + if f == nil || !f.Merged || contains(is.Delivered, pr) || !deliveredBy(f, body.Group, body.Heads) { + continue + } + pr := pr + if _, err := t.change(n, Act{Op: "delivery", By: Seat}, func(i *Issue) error { + g := fixOf(i, pr) + if g == nil || contains(i.Delivered, pr) { + return nil + } + g.State, g.Why, g.Since = "delivered", fmt.Sprintf("source revision %d reached the machines", body.Revision), at + i.Delivered = append(i.Delivered, pr) + return nil + }); err != nil { + return err + } + if len(changed) == 0 || changed[len(changed)-1] != n { + changed = append(changed, n) + } + } + } + sort.Slice(changed, func(i, j int) bool { return changed[i] < changed[j] }) + for _, n := range changed { + if err := t.afterFix(n); err != nil { + return err + } + } + return nil +} + +// deliveredBy says whether a merged fix is one a delivered revision names: the group it merged in is the group that moved the +// source, or its merge commit is the head the revision holds for its repository. +func deliveredBy(f *Fix, group string, heads map[string]string) bool { + if group != "" && f.Group == group { + return true + } + for repository, head := range heads { + if f.MergedAs != "" && strings.EqualFold(repository, f.Repository) && sameCommitIn(f.MergedAs, head) { + return true + } + } + return false +} + +// sameCommitIn is whether two commits are one, however either is abbreviated (at least seven characters). +func sameCommitIn(a, b string) bool { + a, b = strings.ToLower(a), strings.ToLower(b) + if len(a) > len(b) { + a, b = b, a + } + return len(a) >= 7 && strings.HasPrefix(b, a) +} + // Delivery is a pull request's delivery as mesh-delivery's `checks` answers it; a test stubs the asking. type Delivery struct { State string diff --git a/modules/mesh-issues/cmd/mesh-issues/delivery_test.go b/modules/mesh-issues/cmd/mesh-issues/delivery_test.go index bc904440..e431d658 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivery_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivery_test.go @@ -52,6 +52,19 @@ func transition(t *testing.T, h *harness, number int, to, why, mergedAs string, // transitionOn is a delivery's transition said under an event's name. func transitionOn(t *testing.T, h *harness, event string, number int, to, why, mergedAs string, at time.Time) { t.Helper() + if to == "delivered" || to == "covered" || to == "retired" { + // The gate keeps no state after the merge (ADR 0539 §17): what reached the machines is the view's `delivered`, matched + // to the fix by the commit its merge landed as. The fix is first heard merged, as it is in the real flow. + if mergedAs == "" { + mergedAs = "abc1234" + } + transitionOn(t, h, event, number, "merged", why, mergedAs, at) + raw, _ := json.Marshal(map[string]any{"revision": 1, "heads": map[string]string{"novox/mesh-catalog": mergedAs}, "at": at}) + if err := h.OnDelivered(stdio.Envelope{Key: DeliveredEvent, Body: raw}); err != nil { + t.Fatal(err) + } + return + } raw, _ := json.Marshal(map[string]any{"repository": "novox/mesh-catalog", "number": number, "to": to, "why": why, "merged_as": mergedAs, "at": at}) if err := h.OnTransition(stdio.Envelope{Key: event, Body: raw}); err != nil { @@ -93,11 +106,11 @@ func TestAnIssueKnowsWhetherItsFixIsDelivered(t *testing.T) { if got := line(); got != "fix not merged: mesh-catalog#9" { t.Fatal(got) } - transition(t, h, 9, "published", "", "abc", at) + transition(t, h, 9, "published", "", "abc1234", at) if got := line(); got != "fix merged, not delivered: mesh-catalog#9" { t.Fatal(got) } - transition(t, h, 9, "held", "dnsmasq waits for a person's push on two machines", "abc", at) + transition(t, h, 9, "held", "dnsmasq waits for a person's push on two machines", "abc1234", at) if got := line(); got != "held: mesh-catalog#9: dnsmasq waits for a person's push on two machines" { t.Fatal(got) } @@ -106,7 +119,7 @@ func TestAnIssueKnowsWhetherItsFixIsDelivered(t *testing.T) { t.Fatalf("%+v", rows.Rows[0]) } for i := 0; i < 2; i++ { - transition(t, h, 9, "delivered", "", "abc", at) + transition(t, h, 9, "delivered", "", "abc1234", at) } if got := line(); got != "delivered to every machine it should reach" { t.Fatal(got) @@ -124,15 +137,6 @@ func TestAnIssueKnowsWhetherItsFixIsDelivered(t *testing.T) { if len(verify) != 1 || !strings.Contains(verify[0], "Delivered is not resolved") { t.Fatalf("%q", verify) } - // Covered by a later merge counts as delivered, and says so; a resolved issue is not said ready to verify. - h2 := newHarness(t) - m := h2.open(t, "x") - resolve(t, h2, m, "mesh-catalog#9") - transition(t, h2, 9, "covered", "", "abc", at) - if v, _ := h2.View(m); !strings.HasPrefix(v.Delivery, "delivered to every machine it should reach (by a later merge") || - countEvents(h2, EventReadyToVerify) != 0 { - t.Fatalf("%q", v.Delivery) - } } // A fix merged and not delivered past the bound is said once on the issue and the operator's channel, and @@ -144,7 +148,7 @@ func TestAStuckFixIsSaidOnceAndClearedWhenDelivered(t *testing.T) { note := ¬ified{} h.Notify = note.notify merged := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC) // three hours before the harness's clock - transition(t, h, 9, "held", "waits for a person", "abc", merged) + transition(t, h, 9, "held", "waits for a person", "abc1234", merged) note.err = errStuck if said, err := h.Sweep(); err != nil || said != 0 { t.Fatalf("%d %v", said, err) @@ -164,7 +168,7 @@ func TestAStuckFixIsSaidOnceAndClearedWhenDelivered(t *testing.T) { if stuck != 1 || len(note.said) != 1 || !strings.HasPrefix(note.said[0], "issue-tracker.fix-stuck.365 warning") { t.Fatalf("%d notes, said %q", stuck, note.said) } - transition(t, h, 9, "delivered", "", "abc", merged) + transition(t, h, 9, "delivered", "", "abc1234", merged) if len(note.clear) != 1 { t.Fatalf("not cleared: %q", note.clear) } @@ -174,7 +178,7 @@ func TestAStuckFixIsSaidOnceAndClearedWhenDelivered(t *testing.T) { fixedAtFixing(t, h2, m, "mesh-catalog#9") n2 := ¬ified{} h2.Notify = n2.notify - transition(t, h2, 9, "delivering", "", "abc", time.Date(2026, 10, 10, 11, 30, 0, 0, time.UTC)) + transition(t, h2, 9, "delivering", "", "abc1234", time.Date(2026, 10, 10, 11, 30, 0, 0, time.UTC)) if said, _ := h2.Sweep(); said != 0 || len(n2.said) != 0 { t.Fatal("said stuck under the bound") } @@ -218,13 +222,13 @@ func TestDeliveredStaysDeliveredAndClearsAreRetried(t *testing.T) { note := ¬ified{} h.Notify = note.notify merged := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC) - transition(t, h, 9, "held", "waits", "abc", merged) + transition(t, h, 9, "held", "waits", "abc1234", merged) if said, _ := h.Sweep(); said != 1 { t.Fatal("not said stuck") } note.err = errStuck - transition(t, h, 9, "delivered", "", "abc", merged.Add(time.Hour)) - transition(t, h, 9, "held", "an older word", "abc", merged.Add(30*time.Minute)) // out of order + transition(t, h, 9, "delivered", "", "abc1234", merged.Add(time.Hour)) + transition(t, h, 9, "held", "an older word", "abc1234", merged.Add(30*time.Minute)) // out of order if v, _ := h.View(n); v.Delivery != "delivered to every machine it should reach" { t.Fatal(v.Delivery) } @@ -243,7 +247,7 @@ func TestDeliveredStaysDeliveredAndClearsAreRetried(t *testing.T) { } n2 := ¬ified{} h2.Notify = n2.notify - transition(t, h2, 9, "held", "waits", "abc", merged) + transition(t, h2, 9, "held", "waits", "abc1234", merged) _, _ = h2.Sweep() if _, err := h2.Move(MoveAsk{Number: m, Status: StatusLocated, FixedBy: []string{"mesh-catalog#10"}}); err != nil { t.Fatal(err) @@ -284,7 +288,7 @@ func TestAResolvedIssueEndsItsStuckWarningAndIsNeverSaidStuck(t *testing.T) { fixedAtFixing(t, h, n, "mesh-catalog#9") note := ¬ified{} h.Notify = note.notify - transition(t, h, 9, "failed", "the gate failed on one machine", "abc", merged) + transition(t, h, 9, "failed", "the gate failed on one machine", "abc1234", merged) if said, err := h.Sweep(); err != nil || said != 1 || len(note.said) != 1 { t.Fatalf("not said stuck: %d %v %q", said, err, note.said) } @@ -314,7 +318,7 @@ func TestAResolvedIssueEndsItsStuckWarningAndIsNeverSaidStuck(t *testing.T) { fixedAtFixing(t, h, n, "mesh-catalog#9") note := ¬ified{err: errStuck} h.Notify = note.notify - transition(t, h, 9, "failed", "the gate failed", "abc", merged) + transition(t, h, 9, "failed", "the gate failed", "abc1234", merged) if _, err := h.change(n, Act{Op: "warned", By: Seat}, func(i *Issue) error { i.Fixes[0].StuckSaid, i.StuckOpen = true, true return nil @@ -331,7 +335,7 @@ func TestAResolvedIssueEndsItsStuckWarningAndIsNeverSaidStuck(t *testing.T) { } m := h.open(t, "y") fixedAtFixing(t, h, m, "mesh-catalog#10") - transition(t, h, 10, "failed", "the gate failed", "abc", merged) + transition(t, h, 10, "failed", "the gate failed", "abc1234", merged) if _, err := h.Move(MoveAsk{Number: m, Status: StatusResolved, FixedBy: []string{"mesh-catalog#10"}}); err != nil { t.Fatal(err) } diff --git a/modules/mesh-issues/cmd/mesh-issues/fixtime_test.go b/modules/mesh-issues/cmd/mesh-issues/fixtime_test.go index e3ff06ec..551b891a 100644 --- a/modules/mesh-issues/cmd/mesh-issues/fixtime_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/fixtime_test.go @@ -38,10 +38,17 @@ func (c *fixClock) later(d time.Duration) time.Time { c.at = c.at.Add(d); return // delivery says one transition of a pull request as mesh-delivery says it, with what a fix's time is read by. func (c *fixClock) delivery(t *testing.T, number int, to, class string, opened, mergedAt *time.Time) { t.Helper() + delivered := to == "delivered" + if delivered { + // What reached the machines is the view's `delivered` (ADR 0539 §17): the fix is heard merged, then delivered. + to = "merged" + } body := map[string]any{"repository": "novox/mesh-catalog", "number": number, "to": to, "at": c.at} - if mergedAt != nil { + if mergedAt != nil || delivered { body["merged_as"] = "m" + strings.Repeat("a", 11) - body["merged_at"] = *mergedAt + if mergedAt != nil { + body["merged_at"] = *mergedAt + } } if class != "" { body["class"] = class @@ -53,6 +60,12 @@ func (c *fixClock) delivery(t *testing.T, number int, to, class string, opened, if err := c.OnTransition(stdio.Envelope{Key: "mesh-delivery-gate.transition", Body: raw}); err != nil { t.Fatal(err) } + if delivered { + view, _ := json.Marshal(map[string]any{"revision": number, "heads": map[string]string{"novox/mesh-catalog": "m" + strings.Repeat("a", 11)}, "at": c.at}) + if err := c.OnDelivered(stdio.Envelope{Key: DeliveredEvent, Body: view}); err != nil { + t.Fatal(err) + } + } } func at(t time.Time) *time.Time { return &t } diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index 69c79e1a..ca2c33c4 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -161,6 +161,7 @@ func hearOpened(t *Tracker) { for _, event := range transitionEvents { hearings = append(hearings, hearing{event, t.OnTransition}) } + hearings = append(hearings, hearing{DeliveredEvent, t.OnDelivered}) for _, s := range hearings { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { time.Sleep(wait) diff --git a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go index d8773669..db7df92b 100644 --- a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go @@ -110,7 +110,7 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) { } // The duplicate check hears the tracker's own opens and asks the agent module on its node (ADR 0278 §1, §5); // the import and a reservation read the record through the records module (issue 424). - if !reflect.DeepEqual(m.Consumes, []string{"mesh-issues." + EventOpened, "mesh-issues." + EventMoved, "mesh-delivery-gate.transition"}) || + if !reflect.DeepEqual(m.Consumes, []string{"mesh-issues." + EventOpened, "mesh-issues." + EventMoved, "mesh-delivery-gate.transition", DeliveredEvent}) || !reflect.DeepEqual(m.Invokes, []string{"claude-code.claude_code_judge", "seat:mesh-delivery.checks", "seat:operator-channel.notify", "gitea.gitea_pull_reviews", "records.records_status", "records.records_list", "records.records_read", "mesh-knowledge.knowledge_highest"}) { t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes) diff --git a/modules/mesh-issues/cmd/mesh-issues/triage_test.go b/modules/mesh-issues/cmd/mesh-issues/triage_test.go index 3a155f55..73b64c51 100644 --- a/modules/mesh-issues/cmd/mesh-issues/triage_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/triage_test.go @@ -472,11 +472,8 @@ func TestAnIssueIsReadyOnlyWhenWhatItDependsOnIsDelivered(t *testing.T) { if asked != 1 || ready(h, w) { t.Fatalf("ready before its fix is delivered (asked %d)", asked) } - tr := stdio.Envelope{Key: "mesh-delivery-gate.transition", Body: json.RawMessage(`{"repository":"novox/mesh-catalog","number":9,"to":"delivered"}`)} for i := 0; i < 2; i++ { - if err := h.OnTransition(tr); err != nil { - t.Fatal(err) - } + deliverNine(t, h) } if !ready(h, w) || !reflect.DeepEqual(get(t, h, dep).Delivered, []string{"mesh-catalog#9"}) { t.Fatal("not ready once delivered") @@ -771,10 +768,7 @@ func TestReadyIsSaidOnceFollowsDuplicatesAndAskingFailsLoudly(t *testing.T) { if got := pullRequests(get(t, h, b)); !reflect.DeepEqual(got, []string{"mesh-catalog#9"}) { t.Fatalf("%v", got) } - tr := stdio.Envelope{Body: json.RawMessage(`{"repository":"novox/mesh-catalog","number":9,"to":"delivered"}`)} - if err := h.OnTransition(tr); err != nil { - t.Fatal(err) - } + deliverNine(t, h) readies := 0 for _, e := range h.said() { if e.Event == EventReady { @@ -824,3 +818,17 @@ func TestAVerdictNamingIssueZeroIsNone(t *testing.T) { t.Fatalf("%+v", is) } } + +// deliverNine says pull request 9 of mesh-catalog merged, then delivered by the view's event (ADR 0539 §17). +func deliverNine(t *testing.T, h *harness) { + t.Helper() + merged := stdio.Envelope{Key: "mesh-delivery-gate.transition", Body: json.RawMessage( + `{"repository":"novox/mesh-catalog","number":9,"to":"merged","merged_as":"abc1234abc"}`)} + if err := h.OnTransition(merged); err != nil { + t.Fatal(err) + } + view := stdio.Envelope{Key: DeliveredEvent, Body: json.RawMessage(`{"revision":3,"heads":{"novox/mesh-catalog":"abc1234abc"}}`)} + if err := h.OnDelivered(view); err != nil { + t.Fatal(err) + } +} diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json index 388ec54c..e144bbf0 100644 --- a/modules/mesh-issues/module.json +++ b/modules/mesh-issues/module.json @@ -57,7 +57,8 @@ "consumes": [ "mesh-issues.opened", "mesh-issues.moved", - "mesh-delivery-gate.transition" + "mesh-delivery-gate.transition", + "mesh-delivery-view.delivered" ], "invokes": [ "claude-code.claude_code_judge",