Files
mesh-catalog/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go
T
jschoubben d6a069ae49
mesh/delivery merged: source.main moved to novox/mesh-catalog@1aa22efb0d78
mesh/check being carried to d6a069ae4959 on 1aa22efb0d78 | from 12c2574c9d54
mesh-delivery-gate: an answer stands only for the ask it answers, and a merge never handed says why
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.
2026-10-30 11:35:35 +01:00

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)
}