mesh/delivery merged: source.main moved to novox/mesh-catalog@1aa22efb0d78
mesh/check being carried to d6a069ae4959 on 1aa22efb0d78 | from 12c2574c9d54
On 2026-10-29 the group feat/774-vault-mints-l2 was approved and never handed: hq#495 kept a Decline of an earlier ask beside the Approve of the next, readyAsks skipped it and handTurn returned nil for every member, with no log line. The Approve and a new ask now clear an earlier Decline, a run's answer names its ask (answered_ask), an approved merge that waits for nothing and is not handed logs why once and is a stalled line, and delivery_turns lists no unwritten turn while the check gates no merge.
726 lines
36 KiB
Go
726 lines
36 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// The Approve is asked as soon as the checks pass (novox/hq ADR 0421, issue 1022).
|
|
//
|
|
// Before, a reviewer marked a pull request ready (a label and a comment) and only then was the operator's Approve asked
|
|
// (issue 722, ADR 0364). The review now finishes before the push (playbook 07) and its record, a line in the pull
|
|
// request's description, is given when the pull request is opened, so the mark repeated what the push already meant and
|
|
// added a step and a wait. This owner asks the operator's Approve itself, within the event that makes it possible: the
|
|
// verdict that passes, the commit status that sets the checks. Then:
|
|
//
|
|
// - the Approve is the only act: nothing is merged before it, and nothing else is needed after it (no label, comment
|
|
// or second Approve); it is asked once for a head, and once for a group's heads together;
|
|
// - it merges through the forge's merge tool (gitea_merge_pull_request), the one path every merge takes, at the head
|
|
// the Approve names, handing the tool the heads the Approve covers (ADR 0421 §9);
|
|
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
|
|
// again by the checker on its trunk's move (ADR 0447 §4), and an Approve already asked stays through a trunk move
|
|
// (ADR 0421 §3): the merge is asked again on the passing verdict, with no second Approve;
|
|
// - a group member only once the group's composed check passed for its members' heads as they are;
|
|
// - a push ends the Approve and says so once on the pull request (ADR 0421 §6); the new head is asked on its own;
|
|
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
|
|
// gate says to the operator as a condition (conditions.go). So is a merge the tool refused for an hour.
|
|
//
|
|
// The review record is read and put in the ask; it is never a gate (ADR 0421 §5). What this owner does is kept on the
|
|
// delivery (Ready), said in its log, and the merge's transitions name the Approve as their warrant.
|
|
|
|
// readyRefusedFor is how long the merge tool may refuse an approved head before it is said as stalled: none (issue 1115) —
|
|
// a refusal is said at once, and the merge is asked again at each event that can change it, with no timer. A refusal the
|
|
// gate is already answering (its trunk moved and its check was asked again) is said only once that check answered and
|
|
// the merge is still refused, or twice the repository's last check time passed (novox/hq ADR 0505 §4, `answering`).
|
|
const readyRefusedFor time.Duration = 0
|
|
|
|
// groupRefusedFor is how long the merge tool may refuse a closed group's member before it is said as stalled: none (issue 1115).
|
|
const groupRefusedFor time.Duration = 0
|
|
|
|
// waitsForHead is what a delivery waits for when its verdict was heard and its pull request's head was not (issue 1726).
|
|
const waitsForHead = "its pull request's announcement: its base, branch and title are not heard, so what it merges into is not known"
|
|
|
|
// headHeardBound is how long a delivery may wait for its pull request's announcement before it is said as stalled.
|
|
const headHeardBound = 10 * time.Minute
|
|
|
|
// groupClosed is whether a group is closed: a member reached the trunk. Under the lock.
|
|
func (h *Holder) groupClosed(id string) bool {
|
|
g := h.groups[id]
|
|
return g != nil && g.Closed
|
|
}
|
|
|
|
// ReadyRun is what this owner did with a delivery's Approve and merge: kept on the delivery and shown whole by `show`.
|
|
type ReadyRun struct {
|
|
// Head is the head the run is for.
|
|
Head string `json:"head"`
|
|
// Seen is when this owner first made the run: the checks passed.
|
|
Seen time.Time `json:"seen"`
|
|
// Waits is what the merge waits on, when it waits: a verdict, the group's composed check or the operator's Approve.
|
|
Waits string `json:"waits,omitempty"`
|
|
// Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered.
|
|
Tries int `json:"tries,omitempty"`
|
|
LastTry time.Time `json:"last_try"`
|
|
Answer string `json:"answer,omitempty"`
|
|
// merging is whether the member is handed to the forge bridge and not merged or refused yet: not handed twice
|
|
// meanwhile. Not kept.
|
|
merging bool
|
|
// refusedNoted is whether the refusal was written into the group's merge key. Not kept.
|
|
refusedNoted bool
|
|
// AskedKey and Asking are the ask key last asked for and how many times it was asked at those heads; they stay when the ask
|
|
// is withdrawn, so the next asking is the next number (sdk.AskingID). Written is whether that ask stands in the gate's asks.
|
|
AskedKey string `json:"asked_key,omitempty"`
|
|
Asking int `json:"asking,omitempty"`
|
|
Written bool `json:"written,omitempty"`
|
|
// MergeCommit is the commit the forge bridge reported the merge as, and SourceMoved whether source.main holds it
|
|
// (merges.go).
|
|
MergeCommit string `json:"merge_commit,omitempty"`
|
|
SourceMoved bool `json:"source_moved,omitempty"`
|
|
// OwnAskedAt is when its own check was asked after the merge tool refused it for a pending status of its own (issue
|
|
// 1049): once for the head this run is for.
|
|
OwnAskedAt time.Time `json:"own_asked_at,omitempty"`
|
|
// Urgent is set once a refusal of this head was one that is urgent at once: its severity never steps down to a
|
|
// warning after (issue 1275, AC6).
|
|
Urgent bool `json:"urgent,omitempty"`
|
|
// Repeats is how many times in a row the merge tool refused with the same words while the trunk stayed where RepeatTrunk
|
|
// says: past maxRepeatedRefusals the merge is asked no more by a verdict, a status or a composed check, and the refusal
|
|
// is urgent (novox/hq issue 1106). A trunk's move, a new head or the operator's Approve starts the count again.
|
|
Repeats int `json:"repeats,omitempty"`
|
|
RepeatTrunk string `json:"repeat_trunk,omitempty"`
|
|
// RefusedSince is when the merge tool began refusing it without a break.
|
|
RefusedSince *time.Time `json:"refused_since,omitempty"`
|
|
// FirstRefusedOn and LastRefusedOn are when the verdict the merge was asked on was heard, at the first refusal of
|
|
// the run of refusals and at the last: a later one than the first is a fresh check answered and refused again
|
|
// (novox/hq ADR 0505 §4).
|
|
FirstRefusedOn time.Time `json:"first_refused_on,omitzero"`
|
|
LastRefusedOn time.Time `json:"last_refused_on,omitzero"`
|
|
// Merged is when the merge tool merged it.
|
|
Merged *time.Time `json:"merged,omitempty"`
|
|
// Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request.
|
|
Failed string `json:"failed,omitempty"`
|
|
// OtherBase is the branch its pull request merges into when that is not main: never asked, never merged here, said
|
|
// once on the pull request (novox/hq ADR 0398 §4, issue 948).
|
|
OtherBase string `json:"other_base,omitempty"`
|
|
|
|
// The operator's Approve of the merge into main (novox/hq ADR 0398 §3, issue 948, approval.go). AskID is the ask
|
|
// waiting for an answer, Ask the ask as made (a warrant is held to it), Heads the heads it covers by delivery id (a
|
|
// group's members together), AskedAt when and Asks how many were made for this head.
|
|
AskID string `json:"ask_id,omitempty"`
|
|
Ask *asks.Ask `json:"ask,omitempty"`
|
|
Heads map[string]string `json:"heads,omitempty"`
|
|
AskedAt time.Time `json:"asked_at"`
|
|
Asks int `json:"asks,omitempty"`
|
|
// AskCheck is the state of the check at the heads the pending ask names, as its words said it (checkAt: pass, running,
|
|
// none or fail; issue 1733): while the check gates no merge, an ask whose check reached a verdict since is asked again
|
|
// in new words, so it never keeps a claim the check no longer holds.
|
|
AskCheck string `json:"ask_check,omitempty"`
|
|
// Approved and Declined are when the operator chose, Warrant what the router's warrant says of who and how; Refused
|
|
// why an answer was not taken (it did not hold to the ask). AnsweredAsk is the ask the answer answers: an answer stands
|
|
// only for its own ask, so a Decline of an earlier ask never stands beside the Approve of a later one (declined).
|
|
Approved *time.Time `json:"approved,omitempty"`
|
|
Declined *time.Time `json:"declined,omitempty"`
|
|
AnsweredAsk string `json:"answered_ask,omitempty"`
|
|
Warrant string `json:"warrant,omitempty"`
|
|
Refused string `json:"refused,omitempty"`
|
|
|
|
// One check per delivery (novox/hq ADR 0548 §2, §4, turns.go): HeldSince is when the approved merge began to wait for the
|
|
// check of other heads HeldFor names (its verdict key), running on a repository it goes into; HandNow the operator's word
|
|
// that handed it at once past that check.
|
|
HeldSince *time.Time `json:"held_since,omitempty"`
|
|
HeldFor string `json:"held_for,omitempty"`
|
|
HandNow string `json:"hand_now,omitempty"`
|
|
|
|
// CannotWrite is why the operator is not asked: the forge's merging account cannot write a repository the ask covers
|
|
// (writes.go, issue 1945); CannotWriteSince since when. Said at once as a condition.
|
|
CannotWrite string `json:"cannot_write,omitempty"`
|
|
CannotWriteSince *time.Time `json:"cannot_write_since,omitempty"`
|
|
|
|
// NotHanded is why an approved merge that waits for nothing is not handed to the forge bridge (a member of its group that
|
|
// cannot be handed, and why), NotHandedSince since when: said once in the log and as a stalled line, never silent.
|
|
NotHanded string `json:"not_handed,omitempty"`
|
|
NotHandedSince *time.Time `json:"not_handed_since,omitempty"`
|
|
}
|
|
|
|
// declined is whether the operator's Decline stands for this run: it answered the ask the run holds now, or the run holds
|
|
// no ask any more. A Decline of an earlier ask, the run asked again since at other heads (a sibling's new head), does not
|
|
// stand (2026-10-29: hq#495 declined under one ask and approved under the next was never handed). A run kept before answers
|
|
// named their ask stands declined unless an Approve came after the Decline.
|
|
func (r *ReadyRun) declined() bool {
|
|
if r == nil || r.Declined == nil {
|
|
return false
|
|
}
|
|
if r.AnsweredAsk != "" {
|
|
return r.AskID == "" || r.AskID == r.AnsweredAsk
|
|
}
|
|
return r.Approved == nil || r.Declined.After(*r.Approved)
|
|
}
|
|
|
|
// MergeBranch is the one branch this owner merges into (novox/hq ADR 0398 §4): a merge into any other is its author's.
|
|
const MergeBranch = "main"
|
|
|
|
// mergedBy is who a merge this owner made on an Approve is said to be by, in its transitions.
|
|
func (r *ReadyRun) mergedBy() string {
|
|
if r == nil || r.Merged == nil {
|
|
return ""
|
|
}
|
|
by := byOwner + ", on the operator's Approve at " + shortOf(r.Head, 12)
|
|
if r.Warrant != "" {
|
|
by += "; the merge into main approved: " + r.Warrant
|
|
}
|
|
return by
|
|
}
|
|
|
|
// pullKey is a pull request as a key: owner/repo#number.
|
|
func pullKey(p OpenPull) string {
|
|
return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number)
|
|
}
|
|
|
|
// runOf is the delivery's run, made when it has none.
|
|
func (h *Holder) runOf(d *Delivery, now time.Time) *ReadyRun {
|
|
if d.Ready == nil {
|
|
d.Ready = &ReadyRun{Head: d.Commit, Seen: now}
|
|
h.keep(d)
|
|
}
|
|
return d.Ready
|
|
}
|
|
|
|
// readyAsks works out, under the lock, what each delivery whose checks passed needs: the operator asked, a merge asked,
|
|
// a failure said, an out of date verdict's check asked again. The asking happens outside the lock.
|
|
func (h *Holder) readyAsks(now time.Time) []func() {
|
|
var asks []func()
|
|
h.endStaleAsks()
|
|
gates := h.checkGates()
|
|
ids := make([]string, 0, len(h.deliveries))
|
|
for id := range h.deliveries {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
for _, id := range ids {
|
|
d := h.deliveries[id]
|
|
if d.Number == 0 || d.State.Final() || d.MergedAs != "" {
|
|
continue // not of a pull request, or past its merge
|
|
}
|
|
if !gates {
|
|
h.ungate(d, now)
|
|
}
|
|
if d.Ready == nil && d.State == Ready {
|
|
h.runOf(d, now)
|
|
h.Poke() // its check, if none is asked, is worth asking now (askCheck)
|
|
h.Logf("[mesh-delivery] %s is ready (%s); the operator's Approve of its merge into main is asked once it may merge",
|
|
d.ID, h.checkAt([]*Delivery{d}).Words)
|
|
}
|
|
if a := h.sayAfterLine(d); a != nil {
|
|
asks = append(asks, a)
|
|
}
|
|
r := d.Ready
|
|
if r == nil || r.Merged != nil || r.declined() {
|
|
continue
|
|
}
|
|
if d.State == Rejected {
|
|
if r.Failed == "" {
|
|
r.Failed = "its check failed"
|
|
if d.Check != nil {
|
|
r.Failed += ": " + oneLine(d.Check.Summary)
|
|
if j := d.Check.Judged(); j != "" {
|
|
r.Failed += " (" + j + ")"
|
|
}
|
|
}
|
|
r.Waits = ""
|
|
h.Logf("[mesh-delivery] %s passed its checks and is not merged: %s", d.ID, r.Failed)
|
|
h.keep(d)
|
|
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request's checks passed at "+
|
|
"%s, and now %s. A new head, or a recheck that passes, asks the operator again.", d.Commit, r.Failed)))
|
|
}
|
|
continue
|
|
}
|
|
if d.Base == "" {
|
|
// A delivery made from its verdict before its head was heard (issue 1726): its base, branch and title are
|
|
// unknown, so what it merges into is too. It is neither asked nor passed over in silence: it waits for its
|
|
// pull request's announcement, which fills it (pullUpdated), and past headHeardBound it is stalled.
|
|
if r.Waits != waitsForHead {
|
|
r.Waits = waitsForHead
|
|
h.Logf("[mesh-delivery] %s waits: %s", d.ID, waitsForHead)
|
|
h.keep(d)
|
|
}
|
|
h.lookAt(r.Seen.Add(headHeardBound), "a delivery whose pull request was never announced")
|
|
continue
|
|
}
|
|
if d.Base != MergeBranch {
|
|
if r.OtherBase != d.Base {
|
|
r.OtherBase = d.Base
|
|
check := h.checkAt([]*Delivery{d}).Words // ready need not mean passed: the check may gate no merge (issue 1733)
|
|
h.Logf("[mesh-delivery] %s (%s) is not asked: it merges into %s, not %s", d.ID, check, d.Base, MergeBranch)
|
|
h.keep(d)
|
|
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is at %s (%s), "+
|
|
"and it merges into %s. Only a merge into %s is this owner's; a merge into another branch is its "+
|
|
"author's (novox/hq ADR 0398 §4).", d.Commit, check, d.Base, MergeBranch)))
|
|
}
|
|
continue
|
|
}
|
|
r.OtherBase = ""
|
|
r.Failed = ""
|
|
waits := h.readyWaits(d)
|
|
if waits != r.Waits {
|
|
wasHeld := d.holdsForReview()
|
|
r.Waits = waits
|
|
if (wasHeld || d.holdsForReview()) && d.shownOnItsPullRequest() {
|
|
// The status and the view say what it waits for, again whenever the words change while held, and no more once asked.
|
|
state, desc := statusOf(d)
|
|
d.owe(Effect{Kind: EffectView, Since: now})
|
|
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery", State: state, Line: desc, Since: now})
|
|
}
|
|
if waits != "" {
|
|
h.Logf("[mesh-delivery] %s waits: %s", d.ID, waits)
|
|
}
|
|
h.keep(d)
|
|
}
|
|
var approvalAsk func()
|
|
if waits == "" {
|
|
waits, approvalAsk = h.approval(d, now)
|
|
}
|
|
if waits == "" && gates {
|
|
waits = h.turnWaits(d, now) // nothing merges into a repository while a check of other heads judges it (ADR 0548 §2)
|
|
}
|
|
if waits == "" {
|
|
waits = h.passWaits(d) // no merge lands in a repository between another approved group's members (issue 1275)
|
|
}
|
|
if waits != r.Waits {
|
|
r.Waits = waits
|
|
h.keep(d)
|
|
}
|
|
if approvalAsk != nil {
|
|
asks = append(asks, approvalAsk)
|
|
}
|
|
if waits != "" || r.merging {
|
|
h.noteNotHanded(d, "", now) // what it waits for is said by its waits, or it is handed
|
|
continue
|
|
}
|
|
if !r.LastTry.IsZero() {
|
|
h.noteNotHanded(d, "", now)
|
|
continue // refused: asked again at the next event that decides whether it may merge (mergeAgainNow), with no timer
|
|
}
|
|
// A group is merged one member at a time, handed to the forge bridge, and the pass stops at the first refusal (ADR
|
|
// 0441 §3, §5); a lone pull request is a group of one.
|
|
g := h.groups[d.Group]
|
|
if d.Group == "" {
|
|
g = nil
|
|
}
|
|
if a := h.handTurn(g, d, now); a != nil {
|
|
h.noteNotHanded(d, "", now)
|
|
asks = append(asks, a)
|
|
} else {
|
|
h.noteNotHanded(d, h.notHanded(g, d), now) // a merge never handed says why (2026-10-29: silent for an hour)
|
|
}
|
|
}
|
|
asks = append(asks, h.turnWrites()...) // the merge turns, after the holds of this look (ADR 0548 §3)
|
|
asks = append(asks, h.settleMerges()...)
|
|
asks = append(asks, h.settleForgeMerges()...)
|
|
asks = append(asks, h.seedTrunks()...)
|
|
asks = append(asks, h.withdrawnKeys()...)
|
|
asks = append(h.cancels, asks...)
|
|
h.cancels = nil
|
|
return asks
|
|
}
|
|
|
|
// ungate stands a delivery where it is while mesh/check gates no merge (check-gates-merge false): a proposed one is ready
|
|
// with no verdict, and a rejected one, refused on a verdict before the setting changed, is proposed again and so ready. Its
|
|
// verdict stays on it, shown. Under the lock.
|
|
func (h *Holder) ungate(d *Delivery, now time.Time) {
|
|
f := h.factsAt(now)
|
|
if d.State == Rejected {
|
|
f.By, f.Why = byOwner, "the check gates no merge (check-gates-merge false): its verdict is shown, not waited on"
|
|
t, err := Apply(d, EvRecheck, true, f)
|
|
if err != nil {
|
|
h.refuse(err)
|
|
return
|
|
}
|
|
if d.Ready != nil {
|
|
d.Ready.Failed = ""
|
|
}
|
|
h.moved(d, []Transition{t})
|
|
f.By, f.Why = "", ""
|
|
}
|
|
if d.State == Proposed {
|
|
h.moved(d, settle(d, f))
|
|
h.closeGroupOf(d)
|
|
}
|
|
}
|
|
|
|
// sayAfterLine tells a pull request, once, that its description holds an `after: <repository>` line, which is not read
|
|
// (novox/hq ADR 0419 §2): the order of a delivery comes from the module graph alone, and no refusal asks for the line.
|
|
// Once per pull request, not per head. Under the lock; the comment is said outside it.
|
|
func (h *Holder) sayAfterLine(d *Delivery) func() {
|
|
if len(d.After) == 0 || d.AfterSaid {
|
|
return nil
|
|
}
|
|
d.AfterSaid = true
|
|
for _, o := range h.deliveries {
|
|
if o != d && o.Number == d.Number && o.Repository == d.Repository && o.AfterSaid {
|
|
h.keep(d)
|
|
return nil // said on this pull request for an earlier head
|
|
}
|
|
}
|
|
h.keep(d)
|
|
h.Logf("[mesh-delivery] %s: its description holds an after: line (%s), which is not read", d.ID, strings.Join(d.After, ", "))
|
|
return h.sayOn(d, "mesh-delivery: this description holds an `after:` line ("+strings.Join(d.After, ", ")+"). It is not read: "+
|
|
"the order of a delivery comes from the module graph alone, and nothing asks for the line (novox/hq ADR 0419).")
|
|
}
|
|
|
|
// mergeAgainNow lets an approved delivery's merge be asked at once: something that decides whether it may merge happened (a
|
|
// verdict, an Approve, a group's composed check), so the wait after a refusal does not hold it. Under the lock.
|
|
func (h *Holder) mergeAgainNow(d *Delivery) {
|
|
if d != nil && d.Ready != nil && d.Ready.Merged == nil {
|
|
if d.Ready.repeatedRefusal(d) {
|
|
return // the same refusal again and again with the trunk where it was: asking more changes nothing (issue 1106)
|
|
}
|
|
d.Ready.LastTry = time.Time{}
|
|
}
|
|
}
|
|
|
|
// maxRepeatedRefusals is how many refusals in a row, with the same words and the trunk not moved, end the asking again: a
|
|
// refusal that repeats with no trunk move is not a trunk's move, and the check asked after each only brings the same words.
|
|
const maxRepeatedRefusals = 3
|
|
|
|
// repeatedRefusal is whether the merge was refused maxRepeatedRefusals times with these words and the trunk where it is
|
|
// now: it is then asked no more until something changes (a trunk's move, a new head, the operator's Approve).
|
|
func (r *ReadyRun) repeatedRefusal(d *Delivery) bool {
|
|
return r.Repeats >= maxRepeatedRefusals && r.RepeatTrunk == d.TrunkNow
|
|
}
|
|
|
|
// refusalKey is a refusal's first sentence, up to the trunk it names ("... and main is now <t>"): what follows says how long the
|
|
// last check took and the like, which differs each time and is no change of cause.
|
|
func refusalKey(answer string) string {
|
|
if i := strings.Index(answer, " is now "); i >= 0 {
|
|
if j := strings.Index(answer[i:], ". "); j >= 0 {
|
|
return answer[:i+j]
|
|
}
|
|
}
|
|
return answer
|
|
}
|
|
|
|
// noteRefusal counts a refusal: the same words with the trunk where it was count on, anything else starts the count at one.
|
|
func (r *ReadyRun) noteRefusal(d *Delivery, answer string) {
|
|
if refusalKey(answer) == refusalKey(r.Answer) && r.RepeatTrunk == d.TrunkNow && r.Repeats > 0 {
|
|
r.Repeats++
|
|
return
|
|
}
|
|
r.Repeats, r.RepeatTrunk = 1, d.TrunkNow
|
|
}
|
|
|
|
// readyWaits is what a delivery's merge waits on; empty when the Approve may be asked and the merge asked. A ready
|
|
// delivery is asked. So is a proposed one whose verdict went out of date, when an Approve was asked for it already (it
|
|
// stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an
|
|
// Approve waits for that check first.
|
|
func (h *Holder) readyWaits(d *Delivery) string {
|
|
if !h.checkGates() {
|
|
return h.ungatedWaits(d)
|
|
}
|
|
if d.judgedByCheck() && d.State == Proposed && d.outOfDate() != "" && h.covering(d) == nil {
|
|
return "its mesh/check is " + d.outOfDate() + ": the checker checks it again against the trunk's head"
|
|
}
|
|
switch {
|
|
case d.State == Ready:
|
|
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain != nil:
|
|
return "its check against the trunk's head is asked and has not answered"
|
|
case d.State == Proposed && d.outOfDate() != "" && d.Check.Passes() && h.covering(d) != nil:
|
|
// Approved, and its trunk moved: the checker carries its verdict to the trunk's head by its package digests (ADR 0548
|
|
// §5, §8). The Approve stands and is not asked again; the merge waits for the carried verdict, not a fresh check.
|
|
return "approved; its verdict is being carried to the trunk's head " + shortOf(d.TrunkNow, 12) + " (" + d.outOfDate() + ")"
|
|
case d.State == Proposed && d.outOfDate() != "":
|
|
return "its verdict is out of date: its check against the trunk's head is asked before the operator is"
|
|
case d.State == Proposed && d.AskedAgain != nil:
|
|
return "its check against the trunk's head is asked and has not answered"
|
|
default:
|
|
return "its check has not passed yet (" + string(d.State) + ")"
|
|
}
|
|
// ADR 0496 §1, §2: the Approve is asked only for heads with a review recorded at them. An ask already raised or
|
|
// answered is not touched (covering).
|
|
if h.covering(d) == nil {
|
|
if w := h.reviewWaits(d); w != "" {
|
|
return w
|
|
}
|
|
}
|
|
// On mesh/check, the checker's verdict at exactly the heads the ask covers (issue 1302, ADR 0447 §2).
|
|
if w := h.checkerWaits(d); w != "" {
|
|
return w
|
|
}
|
|
if d.Group == "" {
|
|
return ""
|
|
}
|
|
return h.groupTurnToMerge(d)
|
|
}
|
|
|
|
// ungatedWaits is readyWaits while mesh/check gates no merge (check-gates-merge false): a ready delivery waits only for its
|
|
// review at its head, and a group member for every member to be ready; no verdict, composed check or trunk is waited on.
|
|
func (h *Holder) ungatedWaits(d *Delivery) string {
|
|
if d.State != Ready {
|
|
return "it is " + string(d.State) + ", not ready yet"
|
|
}
|
|
if h.covering(d) == nil {
|
|
if w := h.reviewWaits(d); w != "" {
|
|
return w
|
|
}
|
|
}
|
|
if g := h.groups[d.Group]; d.Group != "" && g != nil {
|
|
for _, m := range h.membersOf(g) {
|
|
if m.State != Ready && m.State != Merged {
|
|
return "its group " + g.ID + " waits for " + m.ID + ", which is " + string(m.State)
|
|
}
|
|
}
|
|
}
|
|
if h.covering(d) == nil {
|
|
return h.verdictWaits(d)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// waitsCheckBeforeAsking is how the wait for the check's verdict begins, as `show` and the delivery's wait say it.
|
|
const waitsCheckBeforeAsking = "waits its check before asking the operator's Approve: "
|
|
|
|
// verdictWaits is why an Approve is not asked yet while the check gates no merge: the check at the heads the ask would cover
|
|
// has no verdict (the operator, 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request").
|
|
// A pass or a failure is asked (a failure in its words: the operator may still merge it), and nothing else: no timer stands
|
|
// in for the verdict, because the checker turns a check it lost into a failure it says aloud (issue 1733, the operator,
|
|
// 2026-10-28: "one hour is absurd"). For a group the check is the one of all its heads together. Under the lock.
|
|
func (h *Holder) verdictWaits(d *Delivery) string {
|
|
c := h.checkAt(h.askMembers(d))
|
|
if c.Key == checkSaidPass || c.Key == checkSaidFail {
|
|
return ""
|
|
}
|
|
return waitsCheckBeforeAsking + c.Words
|
|
}
|
|
|
|
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
|
|
const waitsForReview = "waits for a review at "
|
|
|
|
// reviewWaits is why the Approve is not asked yet for want of a review (novox/hq ADR 0496, issue 1803): a delivery
|
|
// whose description holds no review line, `pending` or a line naming another commit, and for a group any member so.
|
|
// Empty when every delivery the ask covers is reviewed at its head. Under the lock.
|
|
func (h *Holder) reviewWaits(d *Delivery) string {
|
|
members := h.askMembers(d)
|
|
var waiting []string
|
|
for _, m := range members {
|
|
if _, ok := reviewWords(m); ok {
|
|
continue
|
|
}
|
|
if len(members) == 1 {
|
|
return waitsForReview + shortOf(m.Commit, 12)
|
|
}
|
|
waiting = append(waiting, "at "+shortOf(m.Commit, 12)+" ("+pullSaid(m)+")")
|
|
}
|
|
if len(waiting) == 0 {
|
|
return ""
|
|
}
|
|
return waitsForReview + strings.Join(waiting, " and ")[len("at "):]
|
|
}
|
|
|
|
// holdsForReview is whether a delivery's wait is the hold for a review.
|
|
func (d *Delivery) holdsForReview() bool {
|
|
return d.Ready != nil && strings.HasPrefix(d.Ready.Waits, waitsForReview)
|
|
}
|
|
|
|
// groupTurnToMerge is why a group member may not merge yet; empty when it may: the group's composed check passed for
|
|
// its members' heads as they are and is not out of date. No member waits for another's merge or delivery (novox/hq
|
|
// ADR 0418 §4): once the composed check passed and the Approve covers the heads, every member is merged in one pass.
|
|
// A group part way through that pass is merging, and its next member's turn is then (issue 1275).
|
|
func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
|
g := h.groups[d.Group]
|
|
if g == nil {
|
|
return ""
|
|
}
|
|
members := h.membersOf(g)
|
|
if state, why := GroupState(g, members); !groupMayMerge(state) {
|
|
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
|
|
}
|
|
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
|
|
!sameMembers(c.Commits, commitsOf(members)) {
|
|
return "its group " + g.ID + "'s composed check has not passed for its members' heads"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// groupMayMerge is whether a group in this state hands its next member to the forge bridge: one ready, and one merging (a
|
|
// member merged, the pass goes on). Every other word holds the pass.
|
|
func groupMayMerge(state string) bool {
|
|
switch state {
|
|
case GroupReady, GroupMerging:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sayOn owes a comment on a delivery's pull request: an owed act numbered among the comments on its head, written as a key
|
|
// with the delivery's other effects (owed.go). Under the lock; what it returns has nothing left to do.
|
|
func (h *Holder) sayOn(d *Delivery, body string) func() {
|
|
d.Comments++
|
|
d.Owed = append(d.Owed, Effect{Kind: EffectComment, Commit: d.Commit, Line: body, Context: strconv.Itoa(d.Comments), Since: h.Now()})
|
|
h.keep(d)
|
|
return func() {}
|
|
}
|
|
|
|
// readyLines are the approved heads that will not merge: a check that failed at once, a merge refused past its bound;
|
|
// as stalled lines, which the gate says as conditions (conditions.go). Under the lock.
|
|
func (h *Holder) readyLines(now time.Time) []StalledLine {
|
|
var out []StalledLine
|
|
for _, d := range h.deliveries {
|
|
r := d.Ready
|
|
if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() {
|
|
continue
|
|
}
|
|
switch {
|
|
case d.Base == "" && d.Number != 0 && now.Sub(r.Seen) >= headHeardBound:
|
|
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
|
|
Bound: headHeardBound.String(), H2: "none: a later announcement of the pull request's head (the merge tool announces it again, the forge's announcer restarts, or a new push) or an edit of its description; a recheck does not fill it — the operator's",
|
|
Says: fmt.Sprintf("%s#%d at %s (%s) waits for %s", d.Repository, d.Number, shortOf(r.Head, 12),
|
|
h.checkAt([]*Delivery{d}).Words, waitsForHead)})
|
|
case r.CannotWrite != "" && r.Approved == nil && r.CannotWriteSince != nil:
|
|
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
|
For: now.Sub(*r.CannotWriteSince).Round(time.Second).String(), Bound: "0s",
|
|
H2: "none: the forge account given write on the repository (gitea_grant_write; the gitea module grants it itself once a module of it is registered), which asks the operator then",
|
|
Says: fmt.Sprintf("%s#%d at %s (%s): the operator is not asked for its Approve: %s",
|
|
d.Repository, d.Number, shortOf(r.Head, 12), h.checkAt([]*Delivery{d}).Words, r.CannotWrite)})
|
|
case r.Failed != "" && d.State == Rejected:
|
|
// Its cure is its author's new head: listed here at once (`show`), told on the pull request at once, and the
|
|
// operator's only once no new head came within the author's bound (novox/hq ADR 0505 §5).
|
|
if now.Sub(d.Since) < h.authorBound() {
|
|
continue
|
|
}
|
|
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(d.Since).Round(time.Second).String(),
|
|
Bound: h.authorBound().String(), H2: "none: a new head from its author, or a recheck that passes",
|
|
Needs: NeedsNewHead, Author: authorOf(d), Branch: d.Branch,
|
|
Says: fmt.Sprintf("%s#%d passed its checks at %s and is not merged: %s. It needs a new head from %s, "+
|
|
"and none came for %s", d.Repository, d.Number, shortOf(r.Head, 12), r.Failed, authorOf(d),
|
|
now.Sub(d.Since).Round(time.Minute))})
|
|
case r.Refused != "":
|
|
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
|
|
Bound: "0s", H2: "none: a new head, which asks the operator again — the operator's",
|
|
Says: fmt.Sprintf("%s#%d at %s is not merged: the operator's answer %s", d.Repository,
|
|
d.Number, shortOf(r.Head, 12), r.Refused)})
|
|
case r.NotHanded != "" && r.NotHandedSince != nil && r.Approved != nil:
|
|
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
|
For: now.Sub(*r.NotHandedSince).Round(time.Second).String(), Bound: "0s",
|
|
H2: "none: the member named is approved at its head and ready, or its group is asked anew by a new head — the operator's",
|
|
Says: fmt.Sprintf("%s#%d at %s is approved and %s", d.Repository, d.Number, shortOf(r.Head, 12), r.NotHanded)})
|
|
case h.answering(d, now):
|
|
// The trunk moved and its check was asked again: the gate is answering it, so it is no stall yet (ADR 0505
|
|
// §4). `show` lists the refusal meanwhile.
|
|
case r.RefusedSince != nil && d.Group != "" && h.groupClosed(d.Group) && now.Sub(*r.RefusedSince) >= groupRefusedFor:
|
|
// A closed group's member (novox/hq ADR 0418 §3, issue 1082): its sibling merged, and its own merge is refused
|
|
// again after each composed check. Said after the merge window, with the group and the tool's last answer.
|
|
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
|
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: groupRefusedFor.String(),
|
|
H2: "none: the group's composed check is asked again after each refusal, and the merge each minute — the operator's",
|
|
Severity: refusalSeverity(r, now),
|
|
Says: fmt.Sprintf("group %s is half merged: %s#%d is approved at %s and the merge tool has refused it since %s: %s. "+
|
|
"Needed: the refusal's cause is met (the merge is asked again at each verdict, status or Approve), or the operator decides.",
|
|
d.Group, d.Repository, d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
|
|
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
|
|
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
|
|
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
|
|
H2: "none: the merge is asked again on a verdict, an Approve, a group's composed check or a merge check's " +
|
|
"status turning success — the operator's",
|
|
Says: fmt.Sprintf("%s%s#%d is approved at %s, and the merge tool has refused it since %s: %s%s", heldInGroup(d),
|
|
d.Repository, d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer, neededOf(d)),
|
|
Severity: severityOf(d, now)})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mergedEvent is the module event `merged` (issue 1025): the pull request merged, its head and group, and the ask,
|
|
// option and heads the operator's Approve was bound to (the warrant the controller reads back from the router). A merge
|
|
// without an Approve names no ask and no option: no warrant is invented. Under the lock, before anything clears them.
|
|
func mergedEvent(d *Delivery, head string) map[string]any {
|
|
r := d.Ready
|
|
heads := map[string]string{}
|
|
for k, v := range r.Heads {
|
|
heads[k] = v
|
|
}
|
|
ev := map[string]any{"owner": d.Owner(), "repo": d.Repo(), "number": d.Number, "head_sha": head, "group": d.Group,
|
|
"ask_id": "", "option": "", "heads": heads}
|
|
if r.Approved != nil {
|
|
ev["ask_id"], ev["option"] = r.AskID, OptionApprove
|
|
if _, _, _, err := sdk.ParseAskKey(r.AskID); err == nil {
|
|
// The ask of the gate: the router knows it by the id its key gives, and the teller asked it (ADR 0437 §6).
|
|
ev["ask_id"], ev["asker"] = sdk.AskingID(r.AskID, r.Asking), sdk.TellerModule
|
|
} // an Approve carried over from today's module keeps its router id, and the asker the controller takes by default
|
|
}
|
|
return ev
|
|
}
|
|
|
|
// heldInGroup is the words that put a refused member in its group's pass: none for a lone delivery.
|
|
func heldInGroup(d *Delivery) string {
|
|
if d.Group == "" {
|
|
return ""
|
|
}
|
|
return "group " + d.Group + " is held, and no member after this one is merged: "
|
|
}
|
|
|
|
// neededOf is what a refused member needs, for a member of a group (issue 1275).
|
|
func neededOf(d *Delivery) string {
|
|
if d.Group == "" {
|
|
return ""
|
|
}
|
|
return ". Needed: the refusal's cause is met (the merge is asked again at each verdict, status or Approve), or the operator decides."
|
|
}
|
|
|
|
// answering is whether an approved head the merge tool refused is one the gate is already answering, and so not said as
|
|
// stalled yet (novox/hq ADR 0505 §4): refused only because its trunk moved, its check asked again, and neither has that
|
|
// check answered with the merge refused again after it, nor has twice the repository's last check time (at least the
|
|
// floor) passed since the refusals began. Any other refusal of this head, and a refusal repeated past its limit, is said
|
|
// at once (r.Urgent). Under the lock.
|
|
func (h *Holder) answering(d *Delivery, now time.Time) bool {
|
|
r := d.Ready
|
|
if r == nil || r.RefusedSince == nil || r.Urgent || !outOfDateRefusal(r.Answer) {
|
|
return false
|
|
}
|
|
if r.LastRefusedOn.After(r.FirstRefusedOn) {
|
|
return false // its fresh check answered, and the merge was refused again on it
|
|
}
|
|
return now.Sub(*r.RefusedSince) < h.answeredRefusalBound(d.Repository)
|
|
}
|
|
|
|
// outOfDateFor is how long a refusal for an out of date verdict is a warning before it is urgent (issue 1275, AC6).
|
|
const outOfDateFor = 10 * time.Minute
|
|
|
|
// outOfDateRefusal is whether the merge tool refused because the member's verdict is judged against an earlier trunk: the
|
|
// forge's "its verdict was judged against trunk <t>, and main is now <t>. The check of head <h> merged into trunk <t> was
|
|
// asked" (gitea merge.ts), whose fresh check is already running.
|
|
//
|
|
// Words that also name another cause (a status not a success, missing or failed) are not this: the other cause is urgent
|
|
// at once (ADR 0442, issue 1275 AC6).
|
|
func outOfDateRefusal(answer string) bool {
|
|
for _, other := range []string{"not a success", "(missing)", "(failure)", "(error)", "could not be asked"} {
|
|
if strings.Contains(answer, other) {
|
|
return false
|
|
}
|
|
}
|
|
// gitea merge.ts: "Its check is already asked and not yet answered" (on its way), or "The check of head <h> merged into
|
|
// trunk <t> was asked" (asked now). Its other refusals, among them "its check could not be asked", are not this.
|
|
return strings.Contains(answer, "its verdict ") && strings.Contains(answer, " is now ") &&
|
|
(strings.Contains(answer, "Its check is already asked and not yet answered") ||
|
|
strings.Contains(answer, "merged into trunk") && strings.Contains(answer, "was asked"))
|
|
}
|
|
|
|
// refusalSeverity is "warning" while a member's refusal for an out of date verdict is younger than ten minutes, as its
|
|
// fresh check runs, and "urgent" for it after that and for every other refusal at once.
|
|
func refusalSeverity(r *ReadyRun, now time.Time) string {
|
|
if !r.Urgent && r.RefusedSince != nil && outOfDateRefusal(r.Answer) && now.Sub(*r.RefusedSince) < outOfDateFor {
|
|
return "warning"
|
|
}
|
|
return "urgent"
|
|
}
|
|
|
|
// severityOf is the severity of a refused member of a group, empty for a lone delivery.
|
|
func severityOf(d *Delivery, now time.Time) string {
|
|
if d.Group == "" {
|
|
return ""
|
|
}
|
|
return refusalSeverity(d.Ready, now)
|
|
}
|