mesh/delivery-group group feat/341-a-merge-check-judges-the-merge checking: 0 of 3 member(s) ready
mesh/merge-gate pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A merge check's verdict now names the trunk commit it judged (novox/hq ADR 0323, issue 341), but the forge kept it green after another merge moved the trunk, the merge tool merged on it, and mesh-delivery kept the delivery ready. The forge's holder puts both statuses of every open pull request into a moved trunk back to pending with the reason and runs nothing; the merge tool compares the verdict's trunk commit with the trunk's head, asks the check once and never merges by itself; mesh-delivery sends a ready delivery back to proposed, counts only a verdict for the trunk's head, and composes a group again for a rebased member instead of holding it.
796 lines
29 KiB
Go
796 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"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, release and stop, 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"`
|
|
Plan string `json:"plan,omitempty"`
|
|
Walk string `json:"walk,omitempty"`
|
|
Owed int `json:"owed,omitempty"`
|
|
Stalled bool `json:"stalled,omitempty"`
|
|
}
|
|
|
|
// waitsFor is what a delivery waits for, in words.
|
|
func (h *Holder) waitsFor(d *Delivery) string {
|
|
if d.HeldBack != "" && !d.State.Final() {
|
|
return d.HeldBack
|
|
}
|
|
switch d.State {
|
|
case Proposed:
|
|
return "its check"
|
|
case Ready:
|
|
if b, batched := h.batchOf(d); batched {
|
|
return batchSaid(b, h.Now())
|
|
}
|
|
if d.MergedAs != "" {
|
|
return "a walk that answers its merge"
|
|
}
|
|
return "its merge"
|
|
case Rejected:
|
|
return "a new head, or a recheck"
|
|
case Published:
|
|
if d.waitsForItsOwnWalk() {
|
|
return "a walk of its own commit: its walk " + d.Walk.ID + ", which carried " + shortOf(d.AnsweredBy, 8) +
|
|
" for it, failed"
|
|
}
|
|
if d.Walk != nil && d.Walk.TakenOverBy != "" {
|
|
return "the walk " + d.Walk.TakenOverBy + ", which took its walk " + d.Walk.ID + " over"
|
|
}
|
|
if d.endedUnstarted() {
|
|
// Never "its walk's start": a done walk has none (novox/hq issue 474).
|
|
if _, known := d.walkMovedItsModules(); !known {
|
|
return "its plan or its walk's record, to read what its walk " + d.Walk.ID + ", ended before its word, built"
|
|
}
|
|
return "a later delivered merge to carry " + strings.Join(d.notMovedBy(), ", ") + ": its walk " + d.Walk.ID +
|
|
" ended without building it, and it is stopped " + strandedAfter.String() + " after that walk ended otherwise"
|
|
}
|
|
if d.Group != "" {
|
|
return "its turn in group " + d.Group
|
|
}
|
|
return "its walk's start"
|
|
case Held:
|
|
return "a person: " + d.HeldWhy
|
|
case Delivering:
|
|
if d.Walk != nil {
|
|
return "its walk: " + clip(d.Walk.Note, 120)
|
|
}
|
|
case Stopped:
|
|
if strandedUncovered(d) {
|
|
return "a later delivered merge to carry " + strings.Join(d.notMovedBy(), ", ") + ": it is stopped, and " +
|
|
"listed by stalled until one does (novox/hq issue 480)"
|
|
}
|
|
}
|
|
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
|
|
}
|
|
// A final delivery `stalled` still lists stays, however old: the two never disagree (novox/hq issue 480).
|
|
if d.State.Final() && !all && now.Sub(d.Since) > 24*time.Hour && !strandedUncovered(d) {
|
|
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)}
|
|
if d.Plan != nil {
|
|
l.Plan = d.Plan.Summary
|
|
}
|
|
if d.Walk != nil {
|
|
l.Walk = d.Walk.ID
|
|
}
|
|
l.Stalled = pastBound(d, now) || strandedUncovered(d)
|
|
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)}
|
|
if d.coveredStop() {
|
|
out["reads"] = d.coveredStopSaid() // stopped, and its change reached the machines (novox/hq issue 480)
|
|
}
|
|
if g := h.groups[d.Group]; g != nil {
|
|
state, why := 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 := GroupState(g, members)
|
|
return map[string]any{"group": g, "state": state, "why": why, "members": members}, 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"`
|
|
Order []string `json:"order"`
|
|
Pairs []Pair `json:"pairs,omitempty"`
|
|
Cycle []string `json:"cycle,omitempty"`
|
|
// Contradictions is each contradiction no precedence resolves, said with how to declare the order.
|
|
Contradictions []Contradiction `json:"contradictions,omitempty"`
|
|
Check string `json:"composed,omitempty"`
|
|
Members []string `json:"members"`
|
|
}
|
|
|
|
// 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 := GroupState(g, members)
|
|
if !all && (state == "delivered" || state == "failed" || state == "stopped") {
|
|
continue
|
|
}
|
|
l := GroupLine{ID: g.ID, State: state, Why: why, Order: g.Order, Pairs: g.Pairs, Cycle: g.Cycle,
|
|
Contradictions: g.Contradictions}
|
|
for _, d := range members {
|
|
l.Members = append(l.Members, fmt.Sprintf("%s (%s)", d.ID, d.State))
|
|
}
|
|
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"`
|
|
// Walk is the walk a line of the state Unheld is about (novox/hq issue 362).
|
|
Walk string `json:"walk,omitempty"`
|
|
// Waiting are the merge checks a line of the state Unanswered waits on, each as it reads — "mesh/repo-check
|
|
// never set", "mesh/merge-gate 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"`
|
|
}
|
|
|
|
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
|
|
func (h *Holder) Stalled() []StalledLine {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
now := h.Now()
|
|
var out []StalledLine
|
|
for _, d := range h.deliveries {
|
|
if strandedUncovered(d) {
|
|
// Stopped, so past no bound, and still loud (review of novox/hq issue 474): listed until a later
|
|
// delivered merge carries its modules.
|
|
out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(d.Since).Round(time.Second).String(),
|
|
Bound: "0s", H2: "none: a merge that delivers its modules ends it — the operator's", Says: d.Stranded})
|
|
continue
|
|
}
|
|
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 controller does not answer delivery-check"
|
|
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 ") + ", when the walk's record says so"
|
|
}
|
|
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.orphanLines(now)...)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
// Recheck is the `recheck` verb: a rejected or ready delivery proposed again, and its check asked again.
|
|
//
|
|
// A recheck asks of the head the forge already judged, and the forge keeps the newest status of each
|
|
// context on it: left alone, the old verdict stands, and a branch that requires it lets the pull request merge
|
|
// on it while the fresh check runs. So the merge check's statuses are put back to pending first — by the
|
|
// forge's holder, which keeps them — and only then is the check asked. A forge that cannot be told refuses the
|
|
// recheck whole: the delivery stays where it stood, and nothing is asked on a stale verdict.
|
|
func (h *Holder) Recheck(id, why, by string) (string, error) {
|
|
h.mu.Lock()
|
|
d := h.deliveries[id]
|
|
if d == nil {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("no delivery %q", id)
|
|
}
|
|
// Whether the table takes it, tried on a copy: nothing is said on the forge for a recheck it refuses.
|
|
probe := *d
|
|
probe.Transitions = append([]Transition(nil), d.Transitions...)
|
|
if _, err := Apply(&probe, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil {
|
|
h.mu.Unlock()
|
|
return "", err
|
|
}
|
|
owner, repo, commit, page := d.Owner(), d.Repo(), d.Commit, d.HTMLURL
|
|
h.mu.Unlock()
|
|
if err := h.checkingAgain(owner, repo, commit, page, why); err != nil {
|
|
return "", fmt.Errorf("%s is not rechecked: %v; its merge check must say pending before it is asked again, "+
|
|
"so nothing merges on the old verdict — ask again once the forge answers", id, err)
|
|
}
|
|
h.mu.Lock()
|
|
t, err := Apply(d, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by})
|
|
if err != nil {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("%v (its merge check says pending; the forge's next announcement asks it)", err)
|
|
}
|
|
d.Check = nil
|
|
h.moved(d, []Transition{t})
|
|
m := MemberOf(d)
|
|
h.mu.Unlock()
|
|
asked, err := h.Controller.Check("", []Member{m})
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s is proposed again and its check could not be asked yet (%v); the forge's next "+
|
|
"announcement asks it", id, err)
|
|
}
|
|
return fmt.Sprintf("%s is proposed again, its merge check pending; its check was asked (%s)", id, asked), nil
|
|
}
|
|
|
|
// checkingAgain puts a head's merge check statuses — those a branch's protection may require — back to
|
|
// pending, so the forge refuses the merge until the fresh verdict sets them. The gate always, since every
|
|
// verdict sets it; the repository's own check only when the head holds one, since a verdict that says none
|
|
// would leave a pending one standing for ever. Asked of the forge's holder, which keeps them; it takes
|
|
// pending on them and nothing else, a verdict being the controller's alone (novox/hq issue 293).
|
|
func (h *Holder) checkingAgain(owner, repo, commit, page, why string) error {
|
|
if h.Forge == nil {
|
|
return errors.New("no forge")
|
|
}
|
|
contexts := []string{ctxGate, ctxRepo}
|
|
if held, err := h.Forge.Statuses(owner, repo, 0, commit, ""); err == nil {
|
|
contexts = []string{ctxGate}
|
|
for _, s := range held.Statuses {
|
|
if s.Context == ctxRepo {
|
|
contexts = append(contexts, ctxRepo)
|
|
}
|
|
}
|
|
}
|
|
line := clip(oneLine("checking again: "+why), 140)
|
|
for _, c := range contexts {
|
|
if err := h.Forge.Status(owner, repo, commit, c, "pending", line, page); err != nil {
|
|
return fmt.Errorf("the forge did not set %s pending on %s: %w", c, shortOf(commit, 8), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Release is the `release` verb: a held delivery goes on, on a person's word.
|
|
func (h *Holder) Release(id, why, by string) (string, error) {
|
|
h.mu.Lock()
|
|
d := h.deliveries[id]
|
|
if d == nil {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("no delivery %q", id)
|
|
}
|
|
t, err := Apply(d, EvRelease, true, Facts{Now: h.Now(), Why: why, By: by})
|
|
if err != nil {
|
|
h.mu.Unlock()
|
|
return "", err
|
|
}
|
|
d.HeldWhy = ""
|
|
h.moved(d, []Transition{t})
|
|
var walk string
|
|
if d.Walk != nil && d.Walk.Waits {
|
|
walk = d.Walk.ID
|
|
}
|
|
var stopped []string
|
|
if walk != "" {
|
|
stopped, _ = h.carriesStopped(walk)
|
|
}
|
|
h.mu.Unlock()
|
|
if walk == "" {
|
|
return fmt.Sprintf("%s is delivering, on %s's word", id, by), nil
|
|
}
|
|
if len(stopped) > 0 {
|
|
// Never the word for a walk that carries a stopped change (ADR 0299): asked to leave it out first.
|
|
return "", fmt.Errorf("%s is delivering on %s's word, and its walk %s is not let go yet: it carries %s, whose "+
|
|
"delivery was stopped, and is asked to leave it out first", id, by, walk, strings.Join(stopped, ", "))
|
|
}
|
|
if err := h.Controller.Deliver(walk, "released by "+by+": "+why); err != nil {
|
|
return "", fmt.Errorf("%s is delivering on %s's word, and its walk %s could not be let go yet (%v): the next "+
|
|
"tick asks again", id, by, walk, err)
|
|
}
|
|
h.mu.Lock()
|
|
if d := h.deliveries[id]; d != nil {
|
|
d.LetGo, d.LetGoWhy = h.Now(), "released by "+by
|
|
if d.Walk != nil && d.Walk.ID == walk {
|
|
d.Walk.Waits, d.Walk.GoBy = false, byOwner
|
|
}
|
|
h.keep(d)
|
|
}
|
|
h.mu.Unlock()
|
|
return fmt.Sprintf("%s is delivering on %s's word; its walk %s is let go", id, by, walk), nil
|
|
}
|
|
|
|
// Stop is the `stop` verb with no way given: see StopWay.
|
|
func (h *Holder) Stop(id, why, by string) (string, error) { return h.StopWay(id, why, by, "") }
|
|
|
|
// The ways a person's stop of a delivery on a shared walk takes (novox/hq ADR 0299).
|
|
const (
|
|
wayAgain = "again" // the walk ended, its other merges walked again without this one
|
|
wayWalk = "walk" // the whole walk stopped, every delivery on it with it
|
|
)
|
|
|
|
// StopWay is the `stop` verb: a delivery stopped, its walk ended through the controller, and in a group every
|
|
// member after it stopped too.
|
|
//
|
|
// **A stop never takes other deliveries down quietly** (novox/hq issue 459, ADR 0299): one walk carries every merge
|
|
// of its batch. When the delivery's open walk carries anything else, the stop does nothing at first: it says what
|
|
// ending the walk would take down — each other delivery, and which of them contain this merge and end either way —
|
|
// and the two ways, given with `way`: `again` ends the walk and walks the others again without this merge and the
|
|
// merges that contain it; `walk` stops the whole walk, and every delivery on it ends stopped, naming this stop.
|
|
func (h *Holder) StopWay(id, why, by, way string) (string, error) {
|
|
if way != "" && way != wayAgain && way != wayWalk {
|
|
return "", fmt.Errorf("way %q: %q or %q", way, wayAgain, wayWalk)
|
|
}
|
|
h.mu.Lock()
|
|
d := h.deliveries[id]
|
|
if d == nil {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("no delivery %q", id)
|
|
}
|
|
// Its guard is read on a copy: the delivery is stopped only once its walk is (review of #221).
|
|
trial := *d
|
|
if _, err := Apply(&trial, EvStop, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil {
|
|
h.mu.Unlock()
|
|
return "", err
|
|
}
|
|
var walk string
|
|
if d.Walk != nil && (d.Walk.State == walkBuilding || d.Walk.State == walkRolling) {
|
|
walk = d.Walk.ID
|
|
}
|
|
merge := d.Repository + "@" + d.MergedAs
|
|
// A merge that waits in a batch not yet cut: the batch is asked to leave it out once it is stopped (ADR 0299).
|
|
var batch string
|
|
if b, batched := h.batchOf(d); batched && walk == "" {
|
|
batch = b.ID
|
|
}
|
|
var others []string
|
|
if walk != "" {
|
|
others = h.othersOn(walk, []string{merge})
|
|
}
|
|
if len(others) > 0 && way == "" {
|
|
said := h.takenDown(d, walk, others)
|
|
h.mu.Unlock()
|
|
return "", errors.New(said)
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
var did string
|
|
switch {
|
|
case walk == "":
|
|
case len(others) == 0 || way == wayWalk:
|
|
whole := why
|
|
if len(others) > 0 {
|
|
whole = fmt.Sprintf("%s was stopped, and its whole walk with it: %s", id, why)
|
|
}
|
|
if err := h.Controller.Stop(walk, whole, by); err != nil {
|
|
return "", fmt.Errorf("%s is not stopped: its walk %s could not be ended yet: %v — `plans stop %s --why …` "+
|
|
"ends it by hand", id, walk, err, walk)
|
|
}
|
|
did = fmt.Sprintf("; its walk %s is ended", walk)
|
|
if len(others) > 0 {
|
|
did = fmt.Sprintf("; its walk %s is ended whole, and every delivery on it with it: %s", walk,
|
|
strings.Join(others, ", "))
|
|
}
|
|
default:
|
|
a, err := h.Controller.StopWithout(walk, why, by, []string{merge})
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s is not stopped: its walk %s could not be ended without it: %v — `way: %s` stops "+
|
|
"the whole walk, and `plans stop %s --why …` ends it by hand", id, walk, err, wayWalk, walk)
|
|
}
|
|
did = fmt.Sprintf("; its walk %s is ended without it, and %s", walk, againWords(a))
|
|
}
|
|
|
|
h.mu.Lock()
|
|
d = h.deliveries[id]
|
|
if d == nil || d.State.Final() {
|
|
h.mu.Unlock()
|
|
return fmt.Sprintf("%s ended meanwhile%s", id, did), nil
|
|
}
|
|
t, err := Apply(d, EvStop, true, Facts{Now: h.Now(), Why: why, By: by})
|
|
if err != nil {
|
|
h.mu.Unlock()
|
|
return "", err
|
|
}
|
|
h.moved(d, []Transition{t})
|
|
var asks []func()
|
|
if g := h.groups[d.Group]; g != nil && g.Closed {
|
|
asks = h.groupTurns(g, h.membersOf(g))
|
|
} else if g != nil {
|
|
h.leaveGroup(d)
|
|
}
|
|
h.mu.Unlock()
|
|
for _, a := range asks {
|
|
a()
|
|
}
|
|
if batch != "" {
|
|
// Asked now, not at the next tick; and the batch's walk is never let go while it carries the merge.
|
|
did = fmt.Sprintf("; its merge waits in the batch %s, which was asked now to leave it out and is never let go "+
|
|
"while it carries it: %s", batch, h.stopLeaving(batch, why, by, []string{merge}))
|
|
}
|
|
return fmt.Sprintf("%s is stopped, on %s's word%s", id, by, did), nil
|
|
}
|
|
|
|
// takenDown is what ending a delivery's shared walk would take down, and the two ways: for a caller holding the
|
|
// lock (ADR 0299).
|
|
func (h *Holder) takenDown(d *Delivery, walk string, others []string) string {
|
|
var ride, contain []string
|
|
for _, o := range others {
|
|
od := h.deliveries[o]
|
|
if od != nil && containsMerge(od, d) {
|
|
contain = append(contain, o)
|
|
continue
|
|
}
|
|
ride = append(ride, o)
|
|
}
|
|
s := fmt.Sprintf("%s is not stopped: its walk %s carries other changes, and ending the walk would take them down: %s.",
|
|
d.ID, walk, orNone(strings.Join(ride, ", ")))
|
|
if len(contain) > 0 {
|
|
s += fmt.Sprintf(" These contain its merge and end with it either way: %s.", strings.Join(contain, ", "))
|
|
}
|
|
// A module is built at its branch, which holds this merge: a delivery moving a module this one moves would
|
|
// deliver it, and the controller leaves it out too (ADR 0299). Its plan is what is known here; the controller
|
|
// decides, and its answer names each.
|
|
var share []string
|
|
for _, o := range ride {
|
|
if od := h.deliveries[o]; od != nil && od.Plan != nil && d.Plan != nil {
|
|
if both := sharedModules(od.Plan.Moved, d.Plan.Moved); len(both) > 0 {
|
|
share = append(share, fmt.Sprintf("%s (moves %s)", o, strings.Join(both, ", ")))
|
|
}
|
|
}
|
|
}
|
|
if len(share) > 0 {
|
|
s += fmt.Sprintf(" With `way: %s` these are left out too, for moving a module this merge moves: %s.", wayAgain,
|
|
strings.Join(share, ", "))
|
|
}
|
|
s += fmt.Sprintf(" With `way: %s` the controller may leave out more for sharing a module, and its answer names each.",
|
|
wayAgain)
|
|
return s + fmt.Sprintf(" What the walk already sent stays where it was sent; the stop's answer names it."+
|
|
" Choose a way: `way: %s` ends the walk and walks the others again without this merge; "+
|
|
"`way: %s` stops the whole walk and every delivery on it.", wayAgain, wayWalk)
|
|
}
|
|
|
|
// sharedModules are the modules in both lists, sorted.
|
|
func sharedModules(a, b []string) []string {
|
|
var out []string
|
|
for _, m := range a {
|
|
if containsString(b, m) && !containsString(out, m) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// containsMerge is whether o is a later merge of d's repository and branch, which contains d's merge on a linear
|
|
// trunk: merged after it, or the merge the walk carries for it.
|
|
func containsMerge(o, d *Delivery) bool {
|
|
if o.ID == d.ID || !strings.EqualFold(o.Repository, d.Repository) || !strings.EqualFold(o.Base, d.Base) || o.MergedAs == "" {
|
|
return false
|
|
}
|
|
return o.MergedAt.After(d.MergedAt) || d.AnsweredBy != "" && sameCommit(d.AnsweredBy, o.MergedAs) &&
|
|
!sameCommit(d.AnsweredBy, d.MergedAs)
|
|
}
|
|
|
|
// Close is healer H2's verb: for a delivery past its bound, the walk's record is read again and the
|
|
// delivery settled; 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
|
|
}
|
|
if d.Walk == nil {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("%s has no walk on record: H2 has nothing to read", id)
|
|
}
|
|
walk, was := d.Walk.ID, d.State
|
|
h.mu.Unlock()
|
|
_, walks, err := h.Controller.Walks(walk)
|
|
if err != nil {
|
|
return "", fmt.Errorf("the walk %s cannot be read: %w", walk, err)
|
|
}
|
|
for _, w := range walks {
|
|
h.WalkMoved(w)
|
|
}
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
d = h.deliveries[id]
|
|
if d.State == was {
|
|
return "", errors.New(id + " is still " + string(was) + ": its walk's record does not say it moved — the condition is the operator's")
|
|
}
|
|
return fmt.Sprintf("%s: %s → %s, as its walk's record says (%s)", id, was, d.State, why), nil
|
|
}
|
|
|
|
// reask is H2's close of a proposed delivery past its bound: its check, asked and not answered, asked once
|
|
// more — the table's re-ask, which holds only once. The row is tried first on a copy, so an ask the controller
|
|
// does not take spends nothing. Called under the lock; it unlocks.
|
|
func (h *Holder) reask(d *Delivery, why string) (string, error) {
|
|
id := d.ID
|
|
trial := *d
|
|
trial.Transitions = nil
|
|
if _, err := Apply(&trial, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why}); err != nil {
|
|
h.mu.Unlock()
|
|
return "", err
|
|
}
|
|
m := MemberOf(d)
|
|
h.mu.Unlock()
|
|
asked, err := h.Controller.Check("", []Member{m})
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s's check could not be re-asked yet: %w", id, err)
|
|
}
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
d = h.deliveries[id]
|
|
if d == nil {
|
|
return "", fmt.Errorf("no delivery %q", id)
|
|
}
|
|
t, err := Apply(d, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why + " — re-asked as " + asked})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
d.CheckAsk.Reasked++
|
|
d.CheckAsk.ID, d.CheckAsk.At, d.CheckAsk.By, d.CheckAsk.Why = asked, h.Now(), byH2, why
|
|
h.moved(d, []Transition{t})
|
|
return fmt.Sprintf("%s: its check re-asked as %s, once (%s); if that is not answered either, the operator's", id,
|
|
asked, why), nil
|
|
}
|
|
|
|
// WhatIf is the `what-if` verb.
|
|
func (h *Holder) WhatIf(repository, base string, paths []string) (*DeliveryPlan, error) {
|
|
if base == "" {
|
|
base = "main"
|
|
}
|
|
return h.Controller.Plan(repository, base, "", paths, nil, nil)
|
|
}
|
|
|
|
// 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),
|
|
"walks-unmatched": len(h.unmatched), "events": listening, "started": h.started.Format(time.RFC3339),
|
|
"seat-on-record": h.held}
|
|
// 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 !h.reconciled.IsZero() {
|
|
out["walks-read"] = h.reconciled.Format(time.RFC3339)
|
|
}
|
|
if h.unreached != "" {
|
|
out["controller-unreached"] = h.unreached
|
|
}
|
|
if len(h.refusals) > 0 {
|
|
from := 0
|
|
if len(h.refusals) > 10 {
|
|
from = len(h.refusals) - 10
|
|
}
|
|
out["refused"] = h.refusals[from:]
|
|
}
|
|
if len(h.backlog) > 0 {
|
|
var ids []string
|
|
for _, w := range h.backlog {
|
|
ids = append(ids, w.ID+" "+w.State)
|
|
}
|
|
out["backlog-walks"] = ids
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RetiredAnswer is what `retire-history` answers.
|
|
type RetiredAnswer struct {
|
|
Retired int `json:"retired"`
|
|
ByRepository map[string]int `json:"by-repository,omitempty"`
|
|
Dry bool `json:"dry,omitempty"`
|
|
// LeftHeld are the held deliveries the rule does not take, with why — a person's still.
|
|
LeftHeld int `json:"left-held"`
|
|
Left []string `json:"left,omitempty"`
|
|
Says string `json:"says"`
|
|
}
|
|
|
|
// RetireHistory is the `retire-history` verb (novox/hq issue 313): held deliveries that are history — made from
|
|
// the forge's word for a merge on the trunk heard long after it happened, with no walk of theirs open — retired
|
|
// on a person's word, never released or delivered. One by its id, refused when the rule does not take it; or
|
|
// every held one, of one repository when named, each the rule does not take left held and named. Dry answers
|
|
// what it would do and changes nothing.
|
|
func (h *Holder) RetireHistory(id, repository, why, by string, dry bool) (*RetiredAnswer, error) {
|
|
if why == "" {
|
|
return nil, errors.New("a retire says why")
|
|
}
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
now := h.Now()
|
|
var held []*Delivery
|
|
if id != "" {
|
|
d := h.deliveries[id]
|
|
if d == nil {
|
|
return nil, fmt.Errorf("no delivery %q", id)
|
|
}
|
|
held = []*Delivery{d}
|
|
} else {
|
|
for _, d := range h.deliveries {
|
|
if d.State == Held && (repository == "" || strings.EqualFold(d.Repository, repository)) {
|
|
held = append(held, d)
|
|
}
|
|
}
|
|
sort.Slice(held, func(i, j int) bool { return held[i].ID < held[j].ID })
|
|
}
|
|
out := &RetiredAnswer{ByRepository: map[string]int{}, Dry: dry}
|
|
for _, d := range held {
|
|
// Tried on a copy first: a refused retire changes nothing, and a dry one nothing either.
|
|
probe := *d
|
|
probe.Transitions = append([]Transition(nil), d.Transitions...)
|
|
f := Facts{Now: now, By: by, Why: why}
|
|
if _, err := Apply(&probe, EvRetire, true, f); err != nil {
|
|
if id != "" {
|
|
return nil, err
|
|
}
|
|
out.LeftHeld++
|
|
if len(out.Left) < 25 {
|
|
out.Left = append(out.Left, err.Error())
|
|
}
|
|
continue
|
|
}
|
|
out.Retired++
|
|
out.ByRepository[d.Repository]++
|
|
if dry {
|
|
continue
|
|
}
|
|
t, _ := Apply(d, EvRetire, true, f)
|
|
d.History = "retired by " + by + ": " + why
|
|
d.HeldWhy = ""
|
|
// Its pull request is not told: the forge may no longer hold it, or holds another under its number.
|
|
d.Owed = withoutKinds(d.Owed, EffectView, EffectStatus)
|
|
h.moved(d, []Transition{t})
|
|
}
|
|
verb := "retired"
|
|
if dry {
|
|
verb = "would retire"
|
|
}
|
|
out.Says = fmt.Sprintf("%s %d held delivery(ies) as history; %d held left for a person", verb, out.Retired, out.LeftHeld)
|
|
h.Logf("[mesh-delivery] retire-history (%s): %s", why, out.Says)
|
|
return out, nil
|
|
}
|
|
|
|
// withoutKinds is an owed list without the effects of the kinds named.
|
|
func withoutKinds(owed []Effect, kinds ...string) []Effect {
|
|
var out []Effect
|
|
for _, e := range owed {
|
|
if !containsString(kinds, e.Kind) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// HeldLine says a held delivery in one line: which, since when, why, its walk when one waits, and the verb that
|
|
// releases it (novox/hq issue 529).
|
|
func HeldLine(d *Delivery) string {
|
|
why := d.HeldWhy
|
|
if why == "" {
|
|
why = lastWhyOf(d)
|
|
}
|
|
s := fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), why)
|
|
if d.Walk != nil && d.Walk.Waits {
|
|
s += "; its walk " + d.Walk.ID + " waits for it"
|
|
}
|
|
return s + " — a person decides: `mesh-delivery.release` (id " + d.ID + ", why) lets it go, `mesh-delivery.stop` ends it"
|
|
}
|