Files
mesh-catalog/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go
T
jschoubben 82cca650a2
mesh/delivery-group group feat/341-a-merge-check-judges-the-merge checking: 0 of 3 member(s) ready
mesh/merge-gate pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Put a verdict out of date when its trunk moves, and refuse a merge on one
A merge check's verdict now names the trunk commit it judged (novox/hq ADR
0323, issue 341), but the forge kept it green after another merge moved the
trunk, the merge tool merged on it, and mesh-delivery kept the delivery ready.
The forge's holder puts both statuses of every open pull request into a moved
trunk back to pending with the reason and runs nothing; the merge tool compares
the verdict's trunk commit with the trunk's head, asks the check once and never
merges by itself; mesh-delivery sends a ready delivery back to proposed, counts
only a verdict for the trunk's head, and composes a group again for a rebased
member instead of holding it.
2026-10-12 17:29:58 +02:00

272 lines
13 KiB
Go

package main
import (
"fmt"
"slices"
"strings"
"time"
)
// A pull request the mesh was never asked to check (novox/hq issue 347).
//
// The forge's announcer is the only way the mesh hears of a pull request's head, and on 2026-10-09 it missed one: a
// pull request opened after its push moved nothing the announcer read, and waited unchecked, unable to merge, with
// nothing saying so. This owner asks the forge itself for every open pull request every few minutes, 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
// word, so no second condition and no second judge: the id is the one its delivery will have once announced.
//
// A head whose check started and never answered is said the same way (novox/hq issue 438). Counting any merge-check
// status as checked let a head that got only a pending mesh/merge-gate, or never got mesh/repo-check, wait unable to
// merge with nothing saying so. So a head is checked only when every merge check its base requires has answered —
// success, failure, error or warning, a failure being a verdict the pull request shows itself — and one with a
// required check pending or never set is said once that has lasted the delivery table's bound for a check asked and
// not answered (Bounds[Proposed]): an announced head would be said after that bound too, and it is longer than the
// controller lets one check run (its checkTimeout, 45 minutes), so a slow check or a status read a moment behind
// (issue 423) is never said. Such a head was announced — the controller set its status — so its line is not in the
// state Unannounced but Unanswered, with words of its own.
//
// A head whose delivery is held in ready or rejected is said by the same rule (novox/hq issue 441). Those states have
// no bound of their own — the verdict is known here — but the forge's status is what lets the pull request merge or
// shows why not, and a status write that lagged or was lost (issue 423), or a check asked again after the verdict,
// leaves a required check pending or never set there: the pull request waits on it, and nothing here asks again. So
// such a head is kept here like one without a delivery, and said on the same bound, as Unanswered — it was announced,
// so never Unannounced, even with no merge check on its head at all. A delivery in any other state is not: proposed
// and checked wait under bounds of their own, and the rest are past the merge, their pull request no longer open.
// Unannounced is a pull request's head the forge never announced: not a state of the table — no delivery exists —
// only what a stalled line says.
const Unannounced State = "unannounced"
// Unanswered is a pull request's head whose merge check started and never answered, and no delivery of it is held
// here (novox/hq issue 438): not a state of the table either, only what a stalled line says. The word is the table's
// own for the proposed state's bound: its check was asked and not answered.
const Unanswered State = "unanswered"
// outOfDatePrefix begins the words the forge's holder puts a merge check back to pending with when its trunk moved
// (novox/hq ADR 0323 §4): the gitea module's outOfDateWords.
const outOfDatePrefix = "out of date:"
// UncheckedAfter is how long a head may go without a merge check before it is said: the announcer passes every half
// minute and the check is asked at once, so ten minutes is a fault, never a queue.
const UncheckedAfter = 10 * time.Minute
// mergeChecks are the statuses that are the mesh's merge check (novox/hq ADR 0237): a base requiring either is
// one the mesh is meant to check.
var mergeChecks = []string{"mesh/merge-gate", "mesh/repo-check"}
// OpenPull is an open pull request as the forge says it (the gitea module's gitea_open_pulls): its head, its base,
// the contexts the base's protection requires, the contexts its head has a status of, in any state, and each of
// those statuses with its state.
type OpenPull struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Base string `json:"base"`
HeadSHA string `json:"head_sha"`
HTMLURL string `json:"html_url,omitempty"`
Required []string `json:"required"`
Statuses []string `json:"statuses"`
// Checks are the head's statuses by context, the newest of each (novox/hq issue 438). A forge module older
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
// from an answered one.
Checks map[string]HeadCheck `json:"checks,omitempty"`
}
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
type HeadCheck struct {
State string `json:"state"`
Description string `json:"description,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// unchecked is a head seen without an answered merge check, what it lacks, and when this owner first saw it so —
// its own time, since the forge's update time moves on every comment.
type unchecked struct {
pull OpenPull
waiting []string
// pending is when each required check that is pending was set so, as the forge says it: what tells a check
// asked again from one still waiting on the same ask.
pending map[string]string
first time.Time
// held is the state of the delivery of this head, ready or rejected, when one is held here (novox/hq issue
// 441); empty for a head with no delivery.
held State
}
// saidByTheForge is whether a delivery's head is said here when its required check has not answered on the forge:
// one in ready or rejected, whose state has no bound of its own (novox/hq issue 441). No delivery at all is too.
func saidByTheForge(d *Delivery) bool {
return d == nil || d.State == Ready || d.State == Rejected
}
// unanswered are the merge checks the base requires that the head has no verdict of, each as it reads — "never
// set", or pending and since when — when each pending one was set so, and whether the head has any merge check at
// all: its check started. A head on a base that requires none, or without a head, has nothing unanswered.
func (p OpenPull) unanswered() (waiting []string, pending map[string]string, started bool) {
if p.HeadSHA == "" {
return nil, nil, false
}
pending = map[string]string{}
started = slices.ContainsFunc(mergeChecks, func(c string) bool {
return slices.Contains(p.Statuses, c) || p.Checks[c].State != ""
})
for _, c := range mergeChecks {
if !slices.Contains(p.Required, c) {
continue
}
got, ok := p.Checks[c]
switch {
case p.Checks == nil && started:
// A forge that says no states: a head with any merge check counts as checked, as before issue 438,
// since nothing here says whether the one it has answered.
case !ok || got.State == "":
waiting = append(waiting, c+" never set")
case got.State == "pending" && strings.HasPrefix(got.Description, outOfDatePrefix):
// Out of date because its trunk moved, and not asked (novox/hq ADR 0323 §4, §7): nothing runs for it and
// it costs nothing, so it is not a check asked and not answered. Asked again, it is pending anew.
case got.State == "pending":
pending[c] = utcOf(got.UpdatedAt)
waiting = append(waiting, c+" pending since "+orUnknown(pending[c]))
}
}
return waiting, pending, started
}
// utcOf is a forge time in UTC, as this module's other lines give their times; one it cannot read stays as given.
func utcOf(at string) string {
t, err := time.Parse(time.RFC3339, at)
if err != nil {
return at
}
return t.UTC().Format(time.RFC3339)
}
func orUnknown(at string) string {
if at == "" {
return "a time the forge did not say"
}
return at
}
// askedAgain is whether a required check is pending on a newer ask than when this owner last saw the head: set
// pending for the first time, set pending anew, or gone back to pending after it answered. A check that answers
// never is: the head waits on what is left from the same first sight (novox/hq issue 438).
func askedAgain(was, now map[string]string) bool {
for c, at := range now {
if before, ok := was[c]; !ok || before != at {
return true
}
}
return false
}
// SeeOpenPulls asks the forge for every open pull request and keeps the heads with a required merge check not
// answered. A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
func (h *Holder) SeeOpenPulls() {
open, err := h.Forge.OpenPulls()
if err != nil {
h.Logf("[mesh-delivery] the forge's open pull requests cannot be read: %v", err)
return
}
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
seen := map[string]unchecked{}
for _, p := range open {
waiting, pending, _ := p.unanswered()
if len(waiting) == 0 {
continue
}
id := IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)
d := h.deliveries[id]
if !saidByTheForge(d) {
continue // a delivery with its own bound, or past its merge
}
var held State
if d != nil {
held = d.State
}
// The clock runs from when this owner first saw the head waiting, and starts again only on a newer ask: a
// check that started, or was asked again — its pending status set anew — is given its whole bound from then.
// A check that answers does not start it again, so a head is said on the bound from its first sight.
u := unchecked{pull: p, waiting: waiting, pending: pending, first: now, held: held}
if was, ok := h.unchecked[id]; ok && !askedAgain(was.pending, pending) {
u.first = was.first
} else {
h.Logf("[mesh-delivery] %s/%s#%d's head %s %s; said if so in %s", p.Owner, p.Repo, p.Number,
short(p.HeadSHA), u.what(), u.bound())
}
seen[id] = u
}
h.unchecked = seen
}
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
// of it is held here, which the forge announced and the controller checked (novox/hq issue 441).
func (u unchecked) started() bool {
_, _, started := u.pull.unanswered()
return started || u.held != ""
}
// bound is how long the head may wait as it does: UncheckedAfter for a check never asked, the table's bound for a
// check asked and not answered for one that started (novox/hq issue 438).
func (u unchecked) bound() time.Duration {
if u.started() {
return Bounds[Proposed].For
}
return UncheckedAfter
}
// what is what the head lacks, in words.
func (u unchecked) what() string {
if u.started() {
return "has a merge check that has not answered: " + strings.Join(u.waiting, ", ")
}
return "has no merge check"
}
// uncheckedLines are the heads unchecked past their bound, as stalled lines; under the lock.
func (h *Holder) uncheckedLines(now time.Time) []StalledLine {
var out []StalledLine
for id, u := range h.unchecked {
d := h.deliveries[id]
if !saidByTheForge(d) || (d != nil && d.State != u.held) || now.Sub(u.first) < u.bound() {
continue // moved on since the forge was last read: the next reading says it anew
}
h2 := "none: the forge never announced it, so there is no delivery to close — the operator's"
says := fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head has had no merge "+
"check: the mesh was never asked to check it. A new commit on its branch announces it",
u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base)
state, waiting := Unannounced, []string(nil)
if u.started() {
state, waiting = Unanswered, u.waiting
says = fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head's check started "+
"and never answered: %s. No delivery of it is held here, so nothing asks it again. A new commit on "+
"its branch announces it and asks its check", u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base,
strings.Join(u.waiting, ", "))
h2 = "none: no delivery of it is held here, so there is none to close — the operator's"
}
if u.held != "" {
// A delivery is held, its verdict known, and the forge shows another (novox/hq issue 441).
says = fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its delivery is %s here, "+
"but on the forge its check has not answered: %s. The pull request waits on that status and nothing "+
"asks it again. A recheck of the delivery asks its check again, as does a new commit on its branch",
u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base, u.held, strings.Join(u.waiting, ", "))
h2 = "none: its delivery is " + string(u.held) + ", a state the operator moves — the operator's"
}
out = append(out, StalledLine{ID: id, Number: u.pull.Number, State: state, For: now.Sub(u.first).Round(time.Second).String(),
Bound: u.bound().String(), Held: u.held,
H2: h2, Says: says, Waiting: waiting})
}
return out
}
func short(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}