430 lines
17 KiB
Go
430 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The state table (novox/hq ADR 0239 decision 2, to-be 47, cut by ADR 0539 §17): every transition a delivery may take,
|
|
// each with its guard, compiled once. The gate keeps the merge's states only: proposed, ready, rejected, merged, held
|
|
// (a merge unchecked) and closed. It asks the Approve and merges once; nothing after the merge is derived here, and a
|
|
// source revision's delivered state is the view's, from source.main, the wants and the haves. The code moves a delivery
|
|
// only through Apply, which finds the row for the delivery's state and the event, runs its guard and refuses anything
|
|
// else, naming what was asked. A test walks the table: every row it holds, and every pair it does not.
|
|
|
|
// State is where a delivery is.
|
|
type State string
|
|
|
|
// The states, in the order a delivery passes through them. None is a delivery not yet made: the rows from it are how
|
|
// one comes to exist.
|
|
const (
|
|
None State = ""
|
|
Proposed State = "proposed"
|
|
Ready State = "ready"
|
|
Rejected State = "rejected"
|
|
// Merged is a change on the trunk: the gate's last word on it.
|
|
Merged State = "merged"
|
|
// Held is a merge that reached the trunk without a passing check: only a person decides what follows.
|
|
Held State = "held"
|
|
// Closed is a delivery that ended before the merge: its pull request closed unmerged, or a newer head took its place.
|
|
Closed State = "closed"
|
|
)
|
|
|
|
// AllStates are every state a delivery can be in, for the table's own test and its verb.
|
|
var AllStates = []State{Proposed, Ready, Rejected, Merged, Held, Closed}
|
|
|
|
// Final is whether nothing follows a state.
|
|
func (s State) Final() bool { return s == Merged || s == Held || s == Closed }
|
|
|
|
// legacyState reads a state an earlier owner kept as one of today's: a change that reached the trunk is merged (held
|
|
// only when it was held for a merge without a passing check), one that ended before it closed, and a verdict taken but
|
|
// not yet acted on is proposed again.
|
|
func legacyState(d *Delivery) State {
|
|
switch d.State {
|
|
case Proposed, Ready, Rejected, Merged, Closed, None:
|
|
return d.State
|
|
case "checked":
|
|
return Proposed
|
|
case Held:
|
|
if strings.HasPrefix(d.HeldWhy, "merged without a passing check") {
|
|
return Held
|
|
}
|
|
return Merged
|
|
case "published", "delivering", "delivered", "covered", "retired", "failed":
|
|
return Merged
|
|
case "superseded", "stopped":
|
|
return Closed
|
|
}
|
|
return d.State
|
|
}
|
|
|
|
// Event is what moves a delivery.
|
|
type Event string
|
|
|
|
// The events. Observed ones are taken by settle whenever their guard holds; the others are acts — a person's, or healer
|
|
// H2's — taken only when asked.
|
|
const (
|
|
EvAnnounced Event = "announced" // the forge announced a pull request's head
|
|
EvAccepted Event = "accepted" // its verdict passes
|
|
EvRefused Event = "refused" // its verdict fails, or the check could not run
|
|
EvRecheck Event = "recheck" // a person asked for it again, or its group changed
|
|
EvReask Event = "re-ask" // healer H2 asked a check asked and not answered once more
|
|
EvLost Event = "check lost" // the build queue holds no check of its head and it was asked: asked again (issue 1274)
|
|
EvNewHead Event = "new-head" // a newer head of the same pull request
|
|
EvClosed Event = "closed" // the pull request closed unmerged
|
|
EvMerged Event = "merged" // it reached the trunk
|
|
EvMergedUnchecked Event = "merged-unchecked" // it reached the trunk without a passing check
|
|
EvTrunkMoved Event = "trunk moved" // another merge moved its trunk, and its verdict names the trunk commit before (novox/hq ADR 0323)
|
|
EvSettled Event = "settled" // the operator settled a merge held without a passing check (novox/hq ADR 0556 clause 5)
|
|
)
|
|
|
|
// HeldUnchecked is the why of a delivery held because its merge reached the trunk without a passing check: the one hold a
|
|
// person may settle (novox/hq ADR 0556 clause 5).
|
|
const HeldUnchecked = "merged without a passing check: only a person decides what follows"
|
|
|
|
// heldUnchecked is whether a held delivery is held for a merge without a passing check, and reached the trunk.
|
|
func heldUnchecked(d *Delivery) bool {
|
|
return d.State == Held && d.MergedAs != "" && strings.HasPrefix(d.HeldWhy, "merged without a passing check")
|
|
}
|
|
|
|
// Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act.
|
|
type Facts struct {
|
|
Now time.Time
|
|
By string
|
|
Why string
|
|
// Ungated is the setting check-gates-merge false: mesh/check gates no merge, so a proposed delivery is accepted with no
|
|
// verdict, none is refused for one, and a trunk's move puts none back to proposed.
|
|
Ungated bool
|
|
}
|
|
|
|
// Row is one transition the table holds.
|
|
type Row struct {
|
|
From []State
|
|
Event Event
|
|
To State
|
|
// Guard says, in words, what must be true; Holds checks it, answering why not.
|
|
Guard string
|
|
Holds func(d *Delivery, f Facts) error
|
|
// Act says the row is taken only when asked — a person's or H2's — never by settle.
|
|
Act bool
|
|
// Says is the transition's why when nobody gave one: the fact that moved it, rather than the guard.
|
|
Says func(d *Delivery) string
|
|
}
|
|
|
|
func unless(cond bool, why string) error {
|
|
if cond {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s", why)
|
|
}
|
|
|
|
var notFinal = []State{Proposed, Ready, Rejected}
|
|
|
|
// Table is every transition there is. The order within one state is the order settle tries them.
|
|
var Table = []Row{
|
|
{From: []State{None}, Event: EvAnnounced, To: Proposed,
|
|
Guard: "a pull request's head the forge announced",
|
|
Holds: func(d *Delivery, f Facts) error {
|
|
return unless(d.Number > 0 && d.Commit != "", "no pull request's head")
|
|
}},
|
|
{From: notFinal, Event: EvNewHead, To: Closed,
|
|
Guard: "a newer head of the same pull request",
|
|
Holds: func(d *Delivery, f Facts) error { return unless(d.NewerHead != "", "no newer head") }},
|
|
{From: notFinal, Event: EvClosed, To: Closed,
|
|
Guard: "the pull request closed unmerged",
|
|
Holds: func(d *Delivery, f Facts) error { return unless(d.ClosedUnmerged, "the pull request is open") }},
|
|
{From: []State{Ready}, Event: EvMerged, To: Merged,
|
|
Guard: "on the trunk its modules follow: merged there",
|
|
Holds: func(d *Delivery, f Facts) error {
|
|
return unless(d.MergedAs != "", "not on the trunk yet")
|
|
}},
|
|
// **A verdict counts only for the trunk's head** (novox/hq ADR 0323 §7): one naming a trunk commit that is no
|
|
// longer the trunk's head is kept and shown, and does not count, so the delivery stays proposed and the rows
|
|
// cannot loop with `trunk moved`; a recheck or the merge tool's ask judges it again.
|
|
{From: []State{Proposed}, Event: EvAccepted, To: Ready,
|
|
Guard: "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and " +
|
|
"the repository's own check did not fail; or the check gates no merge (check-gates-merge false)",
|
|
Holds: func(d *Delivery, f Facts) error {
|
|
if f.Ungated {
|
|
return nil
|
|
}
|
|
if d.Check == nil {
|
|
return errors.New("no verdict")
|
|
}
|
|
if w := d.outOfDate(); w != "" {
|
|
return errors.New("its verdict is " + w)
|
|
}
|
|
return unless(d.Check.Passes(), "the verdict does not pass")
|
|
}},
|
|
{From: []State{Proposed}, Event: EvRefused, To: Rejected,
|
|
Guard: "a verdict names this commit and the trunk's head as its trunk commit, and the gate failed or could not run, " +
|
|
"or the repository's own check failed; never while the check gates no merge",
|
|
Holds: func(d *Delivery, f Facts) error {
|
|
if f.Ungated {
|
|
return errors.New("the check gates no merge (check-gates-merge false)")
|
|
}
|
|
if d.Check == nil {
|
|
return errors.New("no verdict")
|
|
}
|
|
if w := d.outOfDate(); w != "" {
|
|
return errors.New("its verdict is " + w)
|
|
}
|
|
return unless(!d.Check.Passes(), "the verdict passes")
|
|
}},
|
|
// **A ready delivery whose verdict went out of date goes back to proposed** (novox/hq ADR 0323 §7): another
|
|
// merge moved its trunk. A rejected one stays rejected, its status on the forge saying the verdict is out of date.
|
|
{From: []State{Ready}, Event: EvTrunkMoved, To: Proposed,
|
|
Guard: "its verdict names a trunk commit that is not its trunk's head, and the check gates the merge",
|
|
Holds: func(d *Delivery, f Facts) error {
|
|
if f.Ungated {
|
|
return errors.New("the check gates no merge (check-gates-merge false)")
|
|
}
|
|
return unless(d.MergedAs == "" && d.outOfDate() != "", "not merged, and its verdict is not out of date")
|
|
},
|
|
Says: func(d *Delivery) string { return d.outOfDate() }},
|
|
{From: []State{Proposed, Rejected}, Event: EvMergedUnchecked, To: Held,
|
|
Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides what follows",
|
|
Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }},
|
|
// **A person settles a merge held without a passing check** (novox/hq ADR 0556 clause 5): only on the operator's answer to
|
|
// the settle ask, with its words; a hold of another kind is not taken here. Whether it is on the machines is then derived.
|
|
{From: []State{Held}, Event: EvSettled, To: Merged, Act: true,
|
|
Guard: "held because its merge reached the trunk without a passing check, and the operator answered settle to the ask " +
|
|
"`settle` wrote, with the words of that answer",
|
|
Holds: func(d *Delivery, f Facts) error {
|
|
switch {
|
|
case !heldUnchecked(d):
|
|
return errors.New("it is not held for a merge without a passing check: " + d.HeldWhy)
|
|
case f.By == "" || f.Why == "":
|
|
return errors.New("a settle names the operator's answer and its words")
|
|
}
|
|
return nil
|
|
}},
|
|
{From: []State{Rejected, Ready}, Event: EvRecheck, To: Proposed, Act: true,
|
|
Guard: "a person asked, with why, or its group changed",
|
|
Holds: func(d *Delivery, f Facts) error { return unless(f.Why != "", "a recheck says why") }},
|
|
{From: []State{Proposed}, Event: EvReask, To: Proposed, Act: true,
|
|
Guard: "its check was asked and no verdict came within its bound, and it was not re-asked before: healer H2 " +
|
|
"asks it once more, with why — after that it is the operator's",
|
|
Holds: func(d *Delivery, f Facts) error {
|
|
switch {
|
|
case f.Why == "" || f.By == "":
|
|
return errors.New("a re-ask says who and why")
|
|
case d.Check != nil:
|
|
return errors.New("its verdict is known")
|
|
case d.CheckAsk == nil:
|
|
return errors.New("its check was never asked: a look asks it, not H2")
|
|
case d.CheckAsk.Reasked > 0:
|
|
return errors.New("its check was re-asked once already: the operator's")
|
|
}
|
|
return nil
|
|
}},
|
|
|
|
// **A check the build queue lost is asked again** (novox/hq issue 1274): a proposed delivery waits for a check, and
|
|
// the queue, which the table cannot read, holds none of its head. The act (the sweep's or a person's recheck) reads
|
|
// the queue first and says why; the table keeps it to a delivery that waits for a verdict.
|
|
{From: []State{Proposed}, Event: EvLost, To: Proposed, Act: true,
|
|
Guard: "it waits for a check — no verdict yet, or one out of date — nothing of its head is waiting or in flight in " +
|
|
"the build queue, and who asks says why",
|
|
Holds: func(d *Delivery, f Facts) error {
|
|
switch {
|
|
case f.Why == "" || f.By == "":
|
|
return errors.New("a lost check's ask says who and why")
|
|
case d.MergedAs != "":
|
|
return errors.New("it is merged")
|
|
case d.Check != nil && d.outOfDate() == "":
|
|
return errors.New("its verdict is known")
|
|
}
|
|
return nil
|
|
}},
|
|
}
|
|
|
|
// rowFor is the row a state and event name; nil when the table holds none.
|
|
func rowFor(from State, ev Event, act bool) *Row {
|
|
for i := range Table {
|
|
r := &Table[i]
|
|
if r.Event != ev || r.Act != act {
|
|
continue
|
|
}
|
|
for _, s := range r.From {
|
|
if s == from {
|
|
return r
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ErrRefused is a transition the table does not hold, or whose guard does not.
|
|
type ErrRefused struct {
|
|
ID string
|
|
From State
|
|
Event Event
|
|
Why string
|
|
}
|
|
|
|
func (e ErrRefused) Error() string {
|
|
from := string(e.From)
|
|
if from == "" {
|
|
from = "nothing"
|
|
}
|
|
return fmt.Sprintf("%s: %s from %s is refused — %s", e.ID, e.Event, from, e.Why)
|
|
}
|
|
|
|
// Apply takes one transition, or refuses it. The delivery keeps the transition, bounded.
|
|
func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
|
|
r := rowFor(d.State, ev, act)
|
|
if r == nil {
|
|
kind := "observed"
|
|
if act {
|
|
kind = "asked"
|
|
}
|
|
return Transition{}, ErrRefused{ID: d.ID, From: d.State, Event: ev,
|
|
Why: "the table holds no such transition (" + kind + ")"}
|
|
}
|
|
if err := r.Holds(d, f); err != nil {
|
|
return Transition{}, ErrRefused{ID: d.ID, From: d.State, Event: ev, Why: "its guard does not hold: " + err.Error()}
|
|
}
|
|
why := f.Why
|
|
if why == "" && r.Says != nil {
|
|
why = r.Says(d)
|
|
}
|
|
if why == "" {
|
|
why = r.Guard
|
|
}
|
|
by := f.By
|
|
if by == "" && ev == EvMerged && d.MergedAs != "" {
|
|
// A merge this owner made on the operator's Approve names it as its author (novox/hq issue 722).
|
|
by = d.Ready.mergedBy()
|
|
}
|
|
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by}
|
|
d.State, d.Since = r.To, f.Now
|
|
d.Transitions = append(d.Transitions, t)
|
|
if len(d.Transitions) > keptTransitions {
|
|
d.Transitions = d.Transitions[len(d.Transitions)-keptTransitions:]
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// keptTransitions is how many transitions a delivery keeps; its note on the commit keeps every one.
|
|
const keptTransitions = 100
|
|
|
|
// settle takes every observed transition whose guard holds, in the table's order, until none does — so a
|
|
// delivery always stands where its facts put it, whatever order they arrived in.
|
|
func settle(d *Delivery, f Facts) []Transition {
|
|
var taken []Transition
|
|
for range len(Table) + 1 {
|
|
moved := false
|
|
for i := range Table {
|
|
r := &Table[i]
|
|
if r.Act || !contains(r.From, d.State) {
|
|
continue
|
|
}
|
|
if t, err := Apply(d, r.Event, false, f); err == nil {
|
|
taken = append(taken, t)
|
|
moved = true
|
|
break
|
|
}
|
|
}
|
|
if !moved {
|
|
break
|
|
}
|
|
}
|
|
return taken
|
|
}
|
|
|
|
func contains(states []State, s State) bool {
|
|
for _, x := range states {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Bounds are how long a delivery may be in a state before `stalled` lists it, and what healer H2 may do
|
|
// then — always a transition the table holds, never one of its own (novox/hq ADR 0239 decision 9). Only a proposed
|
|
// delivery has one: its check was asked and not answered. A merge past its bound is said by the merge's own stalls.
|
|
type Bound struct {
|
|
For time.Duration
|
|
// H2 is the transitions H2 may take after the bound, by event; empty when the state is the operator's.
|
|
H2 []Event
|
|
Says string
|
|
}
|
|
|
|
// Bounds are every state's.
|
|
var Bounds = map[State]Bound{
|
|
Proposed: {For: time.Hour, H2: []Event{EvReask}, Says: "its check was asked and not answered: H2 asks it once " +
|
|
"more, then the check's own watchdog (S6) and the operator speak for it"},
|
|
}
|
|
|
|
// boundSince is when a delivery's bound runs from: its state's start, except for a proposed delivery whose
|
|
// check this owner asked — its bound is "asked and not answered", so it runs from the ask. One never asked
|
|
// runs from its start: an ask that cannot be made is still listed.
|
|
func boundSince(d *Delivery) time.Time {
|
|
if d.State == Proposed {
|
|
if d.AskedAgain != nil && d.outOfDate() != "" {
|
|
return *d.AskedAgain
|
|
}
|
|
if d.CheckAsk != nil {
|
|
return d.CheckAsk.At
|
|
}
|
|
return d.Since
|
|
}
|
|
return d.Since
|
|
}
|
|
|
|
// boundOf is a delivery's bound.
|
|
func boundOf(d *Delivery) (Bound, bool) {
|
|
if d.State == Proposed && d.outOfDate() != "" {
|
|
// Its check is out of date and not asked: nothing is running for it, and it costs nothing (ADR 0323 §7).
|
|
// Its bound starts when its check is asked: a recheck puts the verdict away; the merge tool's ask announces the
|
|
// head again, and its bound runs from then.
|
|
if d.AskedAgain == nil {
|
|
return Bound{}, false
|
|
}
|
|
b := Bounds[Proposed]
|
|
b.H2, b.Says = nil, "its verdict is "+d.outOfDate()+"; its check was asked again and not answered: the operator's"
|
|
return b, true
|
|
}
|
|
b, bounded := Bounds[d.State]
|
|
return b, bounded
|
|
}
|
|
|
|
// pastBound is whether a delivery has been where it is longer than its state's bound.
|
|
func pastBound(d *Delivery, now time.Time) bool {
|
|
b, bounded := boundOf(d)
|
|
return bounded && !d.State.Final() && (b.For == 0 || now.Sub(boundSince(d)) > b.For)
|
|
}
|
|
|
|
// TableText is the table as the `table` verb answers it.
|
|
func TableText() map[string]any {
|
|
var rows []map[string]any
|
|
for _, r := range Table {
|
|
from := make([]string, 0, len(r.From))
|
|
for _, s := range r.From {
|
|
if s == None {
|
|
from = append(from, "(none)")
|
|
continue
|
|
}
|
|
from = append(from, string(s))
|
|
}
|
|
kind := "observed"
|
|
if r.Act {
|
|
kind = "asked"
|
|
}
|
|
rows = append(rows, map[string]any{"from": strings.Join(from, ", "), "event": r.Event, "to": r.To,
|
|
"guard": r.Guard, "taken": kind})
|
|
}
|
|
bounds := map[string]any{}
|
|
for s, b := range Bounds {
|
|
var h2 []string
|
|
for _, e := range b.H2 {
|
|
h2 = append(h2, string(e))
|
|
}
|
|
bounds[string(s)] = map[string]any{"bound": b.For.String(), "h2": h2, "says": b.Says}
|
|
}
|
|
return map[string]any{"transitions": rows, "bounds": bounds}
|
|
}
|