Once main requires mesh/check, the controller publishes no verdict for it and the gate, which moved deliveries on the controller's checked, would hold every pull request proposed. A head's mesh/check is now its verdict, from the forge's status event (gitea now sends the status's words) or its open pull requests. The Approve is asked only on the checker's verdict at exactly the heads the ask covers, so a member judged alone never passes its group; a group on mesh/check asks no composed check and owes no mesh/merge-gate; a member's mesh/check that changes withdraws the ask. Nothing is asked of the controller for such a base, and recheck refuses, naming checker_pull. hq's mesh/repo-check keeps today's path.
316 lines
15 KiB
Go
316 lines
15 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 when it starts and when an event says
|
|
// what the forge shows may have changed — a merge check's status, a new head (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
|
|
// 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 check: mesh/check, the one main requires (novox/hq ADR 0447, issue
|
|
// 1302), and the two it replaced, which hq's base (mesh/repo-check, ADR 0503 §5) and a reverted one still require. A base
|
|
// requiring any is one the mesh is meant to check; only those a base requires are waited on.
|
|
var mergeChecks = []string{ctxCheck, "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
|
|
// 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
|
|
}
|
|
|
|
// 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() error {
|
|
open, err := h.Forge.OpenPulls()
|
|
if err != nil {
|
|
h.Logf("[mesh-delivery] the forge's open pull requests cannot be read: %v", err)
|
|
return err
|
|
}
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
now := h.Now()
|
|
seen := map[string]unchecked{}
|
|
for _, p := range open {
|
|
if slices.Contains(p.Required, ctxCheck) {
|
|
// What the forge says of a head on a mesh/check base is its verdict (issue 1302): kept, and taken.
|
|
if d := h.deliveries[IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)]; d != nil {
|
|
h.noteRequired(d, p.Required)
|
|
if c := p.Checks[ctxCheck]; c.State != "" {
|
|
h.takeCheckStatus(p.Owner+"/"+p.Repo, p.HeadSHA, c.State, c.Description)
|
|
}
|
|
}
|
|
}
|
|
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, read: 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
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|