829 lines
31 KiB
Go
829 lines
31 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 h.stopLapsed(d) {
|
|
return "a later walk of its repository that contains its merge: its group stopped it for " + d.stoppedFor() +
|
|
", whose change was delivered since (novox/hq issue 747)"
|
|
}
|
|
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"`
|
|
// 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 := 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))
|
|
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"`
|
|
// 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)...)
|
|
out = append(out, h.readyLines(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
|
|
// The composed check of its group goes out of date (novox/hq ADR 0323 §6, issue 867): it was judged for these very
|
|
// heads (running or answered: a run's answer is taken by its id and must not stand for the recheck), so without this the kept verdict (a failure above all) stands and the group is never asked again. It is
|
|
// asked again once the member is judged again, when every member is ready.
|
|
if g := h.groups[d.Group]; g != nil && g.Check != nil {
|
|
now := h.Now()
|
|
g.Check.OutOfDate = fmt.Sprintf("composed check out of date: %s was rechecked (%s)", d.ID, why)
|
|
g.Check.OutOfDateAt = now
|
|
g.Updated = now
|
|
h.keepGroup(g)
|
|
}
|
|
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"
|
|
}
|