issue 975: no forge poll; the open pull requests are read at start and when a merge check's status, a new head or a ready mark says they changed
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.<id>.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user