341 lines
13 KiB
Go
341 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The seat's verbs (novox/hq ADR 0239, the mesh-delivery seat in the controller's set): six that read, and
|
|
// the acts — a person's recheck, and healer H2's close, each only by a transition the table
|
|
// holds.
|
|
|
|
// Line is one delivery as `deliveries` lists it.
|
|
type Line struct {
|
|
ID string `json:"id"`
|
|
State State `json:"state"`
|
|
Since string `json:"since"`
|
|
For string `json:"for"`
|
|
Group string `json:"group,omitempty"`
|
|
Number int `json:"number,omitempty"`
|
|
Waits string `json:"waits,omitempty"`
|
|
Owed int `json:"owed,omitempty"`
|
|
Stalled bool `json:"stalled,omitempty"`
|
|
// Machines says, for a merged delivery, whether its merge is on the machines, derived from the delivery view's reach
|
|
// (reached.go, novox/hq ADR 0556 clause 4); recorded nowhere.
|
|
Machines string `json:"machines,omitempty"`
|
|
}
|
|
|
|
// waitsFor is what a delivery waits for, in words.
|
|
func (h *Holder) waitsFor(d *Delivery) string {
|
|
if r := d.Ready; r != nil && r.Merged == nil && strings.HasPrefix(r.Waits, "approved;") {
|
|
// Approved and waiting for another delivery's check, or for its own verdict's carry (ADR 0548 §2, §8, §9).
|
|
return r.Waits
|
|
}
|
|
switch d.State {
|
|
case Proposed:
|
|
return "its check"
|
|
case Ready:
|
|
if d.Base == "" && d.Number != 0 {
|
|
return waitsForHead
|
|
}
|
|
return "its merge"
|
|
case Rejected:
|
|
return "a new head, or a recheck"
|
|
case Held:
|
|
return "a person: " + d.HeldWhy
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Deliveries is the `deliveries` verb.
|
|
func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
now := h.Now()
|
|
var out []Line
|
|
for _, d := range h.deliveries {
|
|
if state != "" && string(d.State) != state || repository != "" && !strings.EqualFold(d.Repository, repository) ||
|
|
group != "" && d.Group != group {
|
|
continue
|
|
}
|
|
if d.State.Final() && !all && now.Sub(d.Since) > 24*time.Hour {
|
|
continue
|
|
}
|
|
l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(),
|
|
Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed), Machines: h.machines(d)}
|
|
l.Stalled = pastBound(d, now)
|
|
out = append(out, l)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].State.Final() != out[j].State.Final() {
|
|
return !out[i].State.Final()
|
|
}
|
|
return out[i].Since > out[j].Since
|
|
})
|
|
return out
|
|
}
|
|
|
|
// Show is the `show` verb: a delivery whole, or a group with its members.
|
|
func (h *Holder) Show(id string) (any, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if d := h.deliveries[id]; d != nil {
|
|
out := map[string]any{"delivery": d, "waits": h.waitsFor(d), "check-gates-merge": h.checkGates()}
|
|
if m := h.machines(d); m != "" {
|
|
out["machines"] = m
|
|
}
|
|
if g := h.groups[d.Group]; g != nil {
|
|
state, why, _ := h.groupState(g, h.membersOf(g))
|
|
out["group"] = map[string]any{"group": g, "state": state, "why": why}
|
|
}
|
|
return out, nil
|
|
}
|
|
if g := h.groups[id]; g != nil {
|
|
members := h.membersOf(g)
|
|
state, why, _ := h.groupState(g, members)
|
|
return map[string]any{"group": g, "state": state, "why": why, "members": members, "check-gates-merge": h.checkGates()}, nil
|
|
}
|
|
return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id)
|
|
}
|
|
|
|
// GroupLine is one group as `groups` lists it.
|
|
type GroupLine struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
Why string `json:"why"`
|
|
// ComposedGate says the group's gate is the controller's composed check's: each member's own pull request check runs
|
|
// the repository's check alone, and the composed verdict sets its mesh/merge-gate (novox/hq ADR 0403 §1, issue 978).
|
|
// On mesh/check the group's check is the checker's one verdict at every member's head (ADR 0447 §2, issue 1302).
|
|
ComposedGate bool `json:"composed_gate"`
|
|
Check string `json:"composed,omitempty"`
|
|
Members []string `json:"members"`
|
|
// Details are the members again, one object each, with what a reader needs to find the pull request's
|
|
// siblings: its number, full head, base branch and whether it is open (novox/hq ADR 0382 §1). Additive:
|
|
// `members` keeps its text form for older readers.
|
|
Details []MemberLine `json:"member_details,omitempty"`
|
|
}
|
|
|
|
// MemberLine is one member of a group, as `groups` lists it beside its text line.
|
|
type MemberLine struct {
|
|
ID string `json:"id"`
|
|
Repository string `json:"repository"`
|
|
State State `json:"state"`
|
|
Number int `json:"number"`
|
|
Head string `json:"head"`
|
|
Base string `json:"base"`
|
|
Open bool `json:"open"`
|
|
}
|
|
|
|
// Groups is the `groups` verb.
|
|
func (h *Holder) Groups(all bool) []GroupLine {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
var out []GroupLine
|
|
for _, g := range h.groups {
|
|
members := h.membersOf(g)
|
|
if len(members) == 0 {
|
|
continue
|
|
}
|
|
state, why, _ := h.groupState(g, members)
|
|
if !all && (state == GroupMerged || state == GroupClosed) {
|
|
continue
|
|
}
|
|
l := GroupLine{ID: g.ID, State: state, Why: why, ComposedGate: true}
|
|
for _, d := range members {
|
|
l.Members = append(l.Members, fmt.Sprintf("%s (%s)", d.ID, d.State))
|
|
l.Details = append(l.Details, MemberLine{ID: d.ID, Repository: d.Repository, State: d.State,
|
|
Number: d.Number, Head: d.Commit, Base: d.Base,
|
|
Open: !d.State.Final() && d.MergedAs == "" && !d.ClosedUnmerged})
|
|
}
|
|
if g.Check != nil {
|
|
l.Check = g.Check.Verdict
|
|
if l.Check == "" {
|
|
l.Check = "asked as " + g.Check.Asked
|
|
}
|
|
}
|
|
out = append(out, l)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
// StalledLine is one delivery past its state's bound.
|
|
type StalledLine struct {
|
|
ID string `json:"id"`
|
|
// Number is the pull request's, for a head the forge never announced: the condition names it (novox/hq issue 347).
|
|
Number int `json:"number,omitempty"`
|
|
State State `json:"state"`
|
|
// Waiting are the merge checks a line of the state Unanswered waits on, each as it reads — "mesh/check
|
|
// never set", "mesh/check pending since <UTC time>" — so the controller's words name them without reading
|
|
// Says (novox/hq issue 438).
|
|
Waiting []string `json:"waiting,omitempty"`
|
|
// Checks are the same merge checks as data — context, state, since when — so the controller says their times in
|
|
// the operator's own zone, which it cannot do inside a finished sentence (novox/hq issue 443). Waiting stays
|
|
// beside them for a controller that does not read them yet.
|
|
Checks []WaitingCheck `json:"checks,omitempty"`
|
|
// Held is the state of the delivery a line of the state Unanswered is about, ready or rejected, when one is held
|
|
// here (novox/hq issues 441, 451): its check answered once, so the controller advises a recheck of it rather than
|
|
// the build queue, reading the state here and never from Says. Empty for a head with no delivery.
|
|
Held State `json:"held,omitempty"`
|
|
For string `json:"for"`
|
|
Bound string `json:"bound"`
|
|
H2 string `json:"h2"`
|
|
Says string `json:"says"`
|
|
// Severity is "urgent" for a group that is held or half merged (issue 1275); empty otherwise.
|
|
Severity string `json:"severity,omitempty"`
|
|
// Needs is NeedsNewHead for a delivery whose cure is its author's new head (novox/hq ADR 0505 §5), with Author
|
|
// naming that author as the operator reads it and Branch its pull request's branch; empty otherwise.
|
|
Needs string `json:"needs,omitempty"`
|
|
Author string `json:"author,omitempty"`
|
|
Branch string `json:"branch,omitempty"`
|
|
}
|
|
|
|
// NeedsNewHead is what a stalled line needs when its cure is its author's new head (ADR 0505 §5).
|
|
const NeedsNewHead = "new-head"
|
|
|
|
// Stalled is the `stalled` verb: what the gate says to the operator as conditions (conditions.go).
|
|
func (h *Holder) Stalled() []StalledLine {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
now := h.Now()
|
|
var out []StalledLine
|
|
for _, d := range h.deliveries {
|
|
if !pastBound(d, now) {
|
|
continue
|
|
}
|
|
b, _ := boundOf(d)
|
|
h2 := "none: the state is the operator's"
|
|
switch {
|
|
case d.State == Proposed && d.AskedAgain != nil && d.outOfDate() != "":
|
|
h2 = "none: its verdict is out of date and its check, asked again, has not answered — the operator's"
|
|
case d.State == Proposed && d.CheckAsk == nil:
|
|
h2 = "none: its check could not be asked yet — the checker has not answered it"
|
|
case d.State == Proposed && d.CheckAsk.Reasked > 0:
|
|
h2 = "none: its check was re-asked once already and still not answered — the operator's"
|
|
case d.State == Proposed:
|
|
h2 = "close, by re-ask: its check asked once more"
|
|
case len(b.H2) > 0:
|
|
var evs []string
|
|
for _, e := range b.H2 {
|
|
evs = append(evs, string(e))
|
|
}
|
|
h2 = "close, by " + strings.Join(evs, " or ")
|
|
}
|
|
out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(boundSince(d)).Round(time.Second).String(),
|
|
Bound: b.For.String(), H2: h2, Says: b.Says})
|
|
}
|
|
out = append(out, h.withChecks(h.uncheckedLines(now))...)
|
|
out = append(out, h.readyLines(now)...)
|
|
out = append(out, h.turnLines(now)...) // an approved merge held past its bound by another delivery's check (ADR 0548 §4)
|
|
out = append(out, h.groupLines(now)...)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
// Recheck is the `recheck` verb. The checker judges every head and checks it again on every move of its trunk (ADR 0447 §4), and
|
|
// the controller judges none (ADR 0524 §5), so there is no ask to make: a delivery that exists is refused, naming the checker
|
|
// (the lead's decision for issue 1302), and an unknown id is refused as before.
|
|
func (h *Holder) Recheck(id, why, by string) (string, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.deliveries[id] == nil {
|
|
return "", fmt.Errorf("no delivery %q", id)
|
|
}
|
|
return "", checkerRefusal(id)
|
|
}
|
|
|
|
// Close is healer H2's verb: for a proposed delivery past its bound, its check is asked once more; only a transition
|
|
// the table names for that state counts, any other answer is refused.
|
|
func (h *Holder) Close(id, why string) (string, error) {
|
|
h.mu.Lock()
|
|
d := h.deliveries[id]
|
|
if d == nil {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("no delivery %q", id)
|
|
}
|
|
b, bounded := boundOf(d)
|
|
if !bounded || len(b.H2) == 0 {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("%s is %s: H2 has no transition there — the state is the operator's", id, d.State)
|
|
}
|
|
if !pastBound(d, h.Now()) {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("%s has been %s for %s, within its bound of %s: nothing to close", id, d.State,
|
|
h.Now().Sub(boundSince(d)).Round(time.Second), b.For)
|
|
}
|
|
if d.State == Proposed {
|
|
return h.reask(d, why) // unlocks
|
|
}
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("%s is %s: H2 has no transition there", id, d.State)
|
|
}
|
|
|
|
// reask is H2's close of a proposed delivery past its bound. A head's check is the checker's, which asks nothing of anyone
|
|
// and checks again on its own (ADR 0447 §4), so there is no check to ask once more. Called under the lock; it unlocks.
|
|
func (h *Holder) reask(d *Delivery, why string) (string, error) {
|
|
h.mu.Unlock()
|
|
return "", checkerRefusal(d.ID)
|
|
}
|
|
|
|
// Status is the module's own `delivery_status`.
|
|
func (h *Holder) Status(listening string) map[string]any {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
count := map[State]int{}
|
|
owed := 0
|
|
for _, d := range h.deliveries {
|
|
count[d.State]++
|
|
owed += len(d.Owed)
|
|
}
|
|
for _, g := range h.groups {
|
|
owed += len(g.Owed)
|
|
}
|
|
byState := map[string]int{}
|
|
for s, n := range count {
|
|
byState[string(s)] = n
|
|
}
|
|
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
|
|
"events": listening, "started": h.started.Format(time.RFC3339), "check-gates-merge": h.checkGates()}
|
|
// The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again,
|
|
// or given up, and when the last was tried. A count since the start, said with that start: two readings in
|
|
// one run give a window's tries.
|
|
tried := map[string]any{"since": h.started.Format(time.RFC3339), "done": h.tried.done, "failed": h.tried.failed,
|
|
"given-up": h.tried.givenUp, "tried": h.tried.done + h.tried.failed + h.tried.givenUp}
|
|
if !h.tried.last.IsZero() {
|
|
tried["last"] = h.tried.last.Format(time.RFC3339)
|
|
}
|
|
out["owed-tried"] = tried
|
|
// Each held delivery by name, with why and the verb that releases it (novox/hq issue 529): a count alone
|
|
// pointed a reader at nothing.
|
|
var held []string
|
|
for _, d := range h.deliveries {
|
|
if d.State == Held {
|
|
held = append(held, HeldLine(d))
|
|
}
|
|
}
|
|
if len(held) > 0 {
|
|
sort.Strings(held)
|
|
out["held"] = held
|
|
}
|
|
if len(h.refusals) > 0 {
|
|
from := 0
|
|
if len(h.refusals) > 10 {
|
|
from = len(h.refusals) - 10
|
|
}
|
|
out["refused"] = h.refusals[from:]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// HeldLine says a held delivery in one line: which, since when and why. A merge without a passing check is a person's to
|
|
// read; the gate moves nothing after the merge.
|
|
func HeldLine(d *Delivery) string {
|
|
line := fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), d.HeldWhy)
|
|
if heldUnchecked(d) {
|
|
line += " (mesh-delivery.settle asks the operator to settle it, ADR 0556)"
|
|
}
|
|
return line
|
|
}
|