diff --git a/modules/mesh-delivery/cmd/mesh-delivery/events.go b/modules/mesh-delivery/cmd/mesh-delivery/events.go index c114e4ca..4528e972 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/events.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/events.go @@ -2,6 +2,7 @@ package main import ( "regexp" + "slices" "strconv" "strings" @@ -101,3 +102,12 @@ func (h *Holder) AskDecided(id, state string, w *asks.Warrant) { s() } } + +// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/merge-gate, mesh/repo-check) 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. +func (h *Holder) CommitStatus(e ForgeStatusEvent) { + if slices.Contains(mergeChecks, e.Context) { + h.wantForge() + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/holder.go b/modules/mesh-delivery/cmd/mesh-delivery/holder.go index e9c479ed..e5e529af 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/holder.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/holder.go @@ -320,14 +320,15 @@ var trunkLike = map[string]bool{"main": true, "master": true, "trunk": true, "": // PullUpdated takes a pull request's new head: a delivery proposed, an older head of the same pull request // superseded, and the delivery put in the group its branch name makes. -func (h *Holder) PullUpdated(p PullEvent) { +func (h *Holder) PullUpdated(p PullEvent) (isNew bool) { if p.HeadSHA == "" || p.Number == 0 { h.Refuse(errors.New("a pull request announced with no head or no number; nothing proposed")) - return + return false } h.mu.Lock() defer h.mu.Unlock() id := IDOf(p.repository(), p.HeadSHA) + isNew = h.deliveries[id] == nil now := h.Now() startedOver := "" if d := h.deliveries[id]; d != nil { @@ -364,7 +365,7 @@ func (h *Holder) PullUpdated(p PullEvent) { d, t, err := h.freshAnnouncement(p, now, startedOver) if err != nil { h.refuse(err) - return + return false } ts := []Transition{t} // An announcement heard again from the bus's history may carry its head's verdict already: the forge holds @@ -377,6 +378,7 @@ func (h *Holder) PullUpdated(p PullEvent) { if !h.takePlaceOf(replaced, d) { h.joinGroup(d) } + return isNew } // startedOverFor is why a delivery already kept at a head's id is started over for a pull request's number rather @@ -523,6 +525,7 @@ func (h *Holder) PullMerged(p PullEvent) { h.mu.Lock() defer h.mu.Unlock() delete(h.marks, strings.ToLower(p.Owner+"/"+p.Repo)+fmt.Sprintf("#%d", p.Number)) // a merged pull request keeps no mark + h.forgetUnchecked(p.Owner, p.Repo, p.Number) var d *Delivery if p.HeadSHA != "" { d = h.deliveries[IDOf(p.repository(), p.HeadSHA)] @@ -682,6 +685,7 @@ func verdictOfState(s string) string { func (h *Holder) PullClosed(p PullEvent) { h.mu.Lock() delete(h.marks, strings.ToLower(p.Owner+"/"+p.Repo)+fmt.Sprintf("#%d", p.Number)) // a closed pull request keeps no mark + h.forgetUnchecked(p.Owner, p.Repo, p.Number) for _, d := range h.deliveries { if d.Repository == p.repository() && d.Number == p.Number && !d.State.Final() && d.MergedAs == "" { d.ClosedUnmerged = true diff --git a/modules/mesh-delivery/cmd/mesh-delivery/main.go b/modules/mesh-delivery/cmd/mesh-delivery/main.go index 2fa169ad..6bfaffcb 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/main.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/main.go @@ -32,10 +32,10 @@ func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() } func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now } // The events this owner follows: the forge's pull requests, and the controller's verdicts and walks. -// The forge's label and comment events say a ready mark changed (events.go). +// The forge's label and comment events say a ready mark changed, its status events that a check answered (events.go). var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed", "gitea.forge.pull-request.labeled", "gitea.forge.pull-request.unlabeled", "gitea.forge.pull-request.commented", - ControllerSeat + ".checked", ControllerSeat + ".plan-moved"} + "gitea.forge.status", ControllerSeat + ".checked", ControllerSeat + ".plan-moved"} func main() { operator := &asker.Client{Module: Module, State: stdio.State("asked")} @@ -140,7 +140,9 @@ func take(h *Holder, e stdio.Envelope) error { } switch e.Key { case "gitea.pull.updated": - h.PullUpdated(p) + if h.PullUpdated(p) { + h.wantForge() // a head not seen before: the forge shows whether anyone checks it + } case "gitea.pull.merged": h.PullMerged(p) default: @@ -160,6 +162,13 @@ func take(h *Holder, e stdio.Envelope) error { default: h.PullCommented(p) } + case "gitea.forge.status": + var s ForgeStatusEvent + if err := json.Unmarshal(e.Body, &s); err != nil { + h.Refuse(fmt.Errorf("a commit status is not readable: %v", err)) + return nil + } + h.CommitStatus(s) case ControllerSeat + ".checked": var c CheckedEvent if err := json.Unmarshal(e.Body, &c); err != nil { diff --git a/modules/mesh-delivery/cmd/mesh-delivery/react.go b/modules/mesh-delivery/cmd/mesh-delivery/react.go index d40b8343..79da6816 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/react.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/react.go @@ -64,6 +64,9 @@ func (h *Holder) React() { 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() diff --git a/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go b/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go index 0cc1d254..7c4fcb98 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go @@ -11,7 +11,9 @@ import ( // // 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 every few minutes, and one whose +// 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, a ready mark (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 // controller raises as `delivery..stalled` (D14) like any other delivery past its bound. The delivery domain's own @@ -93,6 +95,8 @@ type unchecked struct { // 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 @@ -166,7 +170,7 @@ func askedAgain(was, now map[string]string) bool { } // 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. +// answered, and the ready marks (seeMarks). 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 { @@ -194,7 +198,7 @@ func (h *Holder) SeeOpenPulls() error { // 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, held: held} + 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 { @@ -274,3 +278,32 @@ func short(sha string) string { } 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) + } + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/unchecked_events_test.go b/modules/mesh-delivery/cmd/mesh-delivery/unchecked_events_test.go new file mode 100644 index 00000000..e052a884 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/unchecked_events_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "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) ForgeStatusEvent { + return ForgeStatusEvent{Owner: "novox", Repo: "hq", SHA: sha, Context: context, State: state} +} + +func TestAMergeCheckStatusReadsTheOpenPullRequestsAndAnotherContextDoesNot(t *testing.T) { + w := newWorld(t) + before := w.forge.openCalls + hear(t, w, "gitea.forge.status", 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, "gitea.forge.status", 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, "gitea.forge.status", 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, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + if w.forge.openCalls != before+1 { + t.Fatalf("a new head read the forge %d times, wanted once", w.forge.openCalls-before) + } + hear(t, w, "gitea.pull.updated", pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) + if w.forge.openCalls != before+1 { + 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{markedPull("novox/app", 7, readyHead, 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, "gitea.pull.closed", closed) + if lines := w.h.Stalled(); len(lines) != 0 { + t.Fatalf("a closed pull request is still said: %+v", lines) + } +} diff --git a/modules/mesh-delivery/module.json b/modules/mesh-delivery/module.json index 6ff9c526..617e5615 100644 --- a/modules/mesh-delivery/module.json +++ b/modules/mesh-delivery/module.json @@ -31,6 +31,7 @@ "gitea.forge.pull-request.labeled", "gitea.forge.pull-request.unlabeled", "gitea.forge.pull-request.commented", + "gitea.forge.status", "mesh-controller.checked", "mesh-controller.plan-moved" ],