A group whose order rules disagreed was refused as "its order contradicts itself" with only the member ids: nobody could see which rules clashed or what to write to resolve it. The controller now resolves rules by precedence (hq ADR 0249) and answers, per pair, the rules it won over, and per contradiction no precedence resolves, both rules and how to declare the order. mesh-delivery keeps both, lists them in groups and the plan note, and refuses a group by those words; a cycle through more members names the pairs in it. An older controller's answer reads as before.
299 lines
8.4 KiB
Go
299 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// What a transition owes outside the state (novox/hq ADR 0239 decision 5), each by its owner: the event on
|
|
// the bus (said by this module), a line on the commit's note and the pull request's view and statuses (by
|
|
// the forge's holder, asked through its tools). Owed, never lost: kept with the delivery before anything is
|
|
// tried, and tried again until done.
|
|
|
|
// transitionEvent is what `mesh-delivery.transition` says: no secret, no address.
|
|
func transitionEvent(d *Delivery, t Transition) map[string]any {
|
|
e := map[string]any{"id": d.ID, "repository": d.Repository, "commit": d.Commit, "from": t.From, "to": t.To,
|
|
"event": t.Event, "why": t.Why, "at": t.At}
|
|
if t.By != "" {
|
|
e["by"] = t.By
|
|
}
|
|
if d.Number > 0 {
|
|
e["number"] = d.Number
|
|
}
|
|
if d.Group != "" {
|
|
e["group"] = d.Group
|
|
}
|
|
if d.MergedAs != "" {
|
|
e["merged_as"] = d.MergedAs
|
|
}
|
|
if d.Walk != nil {
|
|
e["walk"] = d.Walk.ID
|
|
}
|
|
if d.Plan != nil {
|
|
e["plan"] = d.Plan.Summary
|
|
}
|
|
return e
|
|
}
|
|
|
|
// noteLine is one transition as the commit's note keeps it.
|
|
func noteLine(d *Delivery, t Transition) string {
|
|
by := ""
|
|
if t.By != "" {
|
|
by = " by " + t.By
|
|
}
|
|
line := fmt.Sprintf("%s delivery %s: %s -> %s (%s%s): %s", t.At.UTC().Format(time.RFC3339), d.ID, orNothing(t.From),
|
|
t.To, t.Event, by, t.Why)
|
|
if t.To == Proposed && d.Plan != nil {
|
|
line += "; predicted: " + d.Plan.Summary
|
|
}
|
|
if d.Group != "" && (t.To == Proposed || t.To == Published) {
|
|
line += "; group " + d.Group
|
|
}
|
|
return oneLine(line)
|
|
}
|
|
|
|
// executedLine is what a delivery did, written on the commit it landed as when it ends.
|
|
func executedLine(d *Delivery) string {
|
|
var parts []string
|
|
for _, s := range d.Steps {
|
|
parts = append(parts, fmt.Sprintf("%s on %s %s", s.Module, s.Machine, s.State))
|
|
}
|
|
walk := ""
|
|
if d.Walk != nil {
|
|
walk = " (walk " + d.Walk.ID + ")"
|
|
}
|
|
return oneLine(fmt.Sprintf("%s delivery %s executed%s: %s", time.Now().UTC().Format(time.RFC3339), d.ID, walk,
|
|
strings.Join(parts, "; ")))
|
|
}
|
|
|
|
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
|
|
|
|
// statusOf is the commit status `mesh/delivery`: where the delivery stands, in the forge's words.
|
|
func statusOf(d *Delivery) (string, string) {
|
|
why := ""
|
|
if len(d.Transitions) > 0 {
|
|
why = d.Transitions[len(d.Transitions)-1].Why
|
|
}
|
|
switch d.State {
|
|
case Proposed, Checked:
|
|
return "pending", "checking"
|
|
case Ready:
|
|
return "success", "ready: it delivers once merged"
|
|
case Rejected:
|
|
return "failure", clip("rejected: "+why, 140)
|
|
case Published:
|
|
return "pending", "published: its walk waits for its turn"
|
|
case Held:
|
|
return "pending", clip("held for a person: "+d.HeldWhy, 140)
|
|
case Delivering:
|
|
passed := 0
|
|
for _, s := range d.Steps {
|
|
if s.State == StepPassed {
|
|
passed++
|
|
}
|
|
}
|
|
return "pending", fmt.Sprintf("delivering: %d machine step(s) passed", passed)
|
|
case Delivered:
|
|
return "success", "delivered"
|
|
case Failed:
|
|
return "failure", clip("failed: "+why, 140)
|
|
case Superseded:
|
|
return "warning", clip("superseded: "+why, 140)
|
|
case Stopped:
|
|
return "error", clip("stopped: "+why, 140)
|
|
}
|
|
return "pending", string(d.State)
|
|
}
|
|
|
|
// viewMarker is how the forge's holder finds the one view comment of a pull request.
|
|
const viewMarker = "<!-- mesh-delivery:view -->"
|
|
|
|
// ViewBody is a delivery's view on its pull request, written from the delivery whole.
|
|
func ViewBody(d *Delivery, g *Group) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339))
|
|
if d.HeldWhy != "" && d.State == Held {
|
|
fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy)
|
|
}
|
|
if d.Plan != nil {
|
|
fmt.Fprintf(&b, "**Delivery plan** — %s\n", d.Plan.Summary)
|
|
for i, tier := range d.Plan.Tiers {
|
|
fmt.Fprintf(&b, "- tier %d: %s\n", i, strings.Join(tier, ", "))
|
|
}
|
|
for _, m := range d.Plan.Machines {
|
|
var parts []string
|
|
if len(m.Receives) > 0 {
|
|
parts = append(parts, "receives "+strings.Join(m.Receives, ", "))
|
|
}
|
|
if len(m.Waits) > 0 {
|
|
parts = append(parts, "waits for a person: "+strings.Join(m.Waits, ", "))
|
|
}
|
|
fmt.Fprintf(&b, "- %s: %s\n", m.Machine, strings.Join(parts, "; "))
|
|
}
|
|
for _, s := range d.Plan.Steps {
|
|
fmt.Fprintf(&b, "- %s\n", s)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
if g != nil {
|
|
fmt.Fprintf(&b, "**Group** `%s`, in order: %s\n", g.ID, strings.Join(g.Order, " → "))
|
|
for _, p := range g.Pairs {
|
|
fmt.Fprintf(&b, "- %s\n", p.orderSaid())
|
|
}
|
|
for _, c := range g.Contradictions {
|
|
fmt.Fprintf(&b, "- **refused**: %s\n", c.Said)
|
|
}
|
|
if g.Check != nil && g.Check.Verdict != "" {
|
|
fmt.Fprintf(&b, "- composed together: %s — %s\n", g.Check.Verdict, g.Check.Summary)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
if len(d.Steps) > 0 {
|
|
b.WriteString("**Machines**\n")
|
|
for _, s := range d.Steps {
|
|
fmt.Fprintf(&b, "- %s on %s: %s", s.Module, s.Machine, s.State)
|
|
if s.Why != "" {
|
|
fmt.Fprintf(&b, " — %s", s.Why)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("**Transitions**\n")
|
|
from := 0
|
|
if len(d.Transitions) > 12 {
|
|
from = len(d.Transitions) - 12
|
|
}
|
|
for _, t := range d.Transitions[from:] {
|
|
fmt.Fprintf(&b, "- %s %s → %s (%s): %s\n", t.At.UTC().Format("2006-01-02 15:04"), orNothing(t.From), t.To, t.Event, t.Why)
|
|
}
|
|
b.WriteString("\nThe commit's note under `refs/notes/mesh-plan` keeps every transition: `git log --notes=mesh-plan`.\n")
|
|
return b.String()
|
|
}
|
|
|
|
// Flush does what is owed, kept deliveries first: a delivery not yet kept says nothing.
|
|
func (h *Holder) Flush() {
|
|
type job struct {
|
|
id string
|
|
group bool
|
|
e Effect
|
|
body string
|
|
repo [2]string
|
|
num int
|
|
url string
|
|
}
|
|
h.mu.Lock()
|
|
for id := range h.dirty {
|
|
if d := h.deliveries[id]; d != nil {
|
|
h.keep(d)
|
|
}
|
|
}
|
|
for id := range h.dirtyGroup {
|
|
if g := h.groups[id]; g != nil {
|
|
h.keepGroup(g)
|
|
}
|
|
}
|
|
var jobs []job
|
|
ids := make([]string, 0, len(h.deliveries))
|
|
for id := range h.deliveries {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
for _, id := range ids {
|
|
d := h.deliveries[id]
|
|
if h.dirty[id] {
|
|
continue
|
|
}
|
|
for _, e := range d.Owed {
|
|
j := job{id: id, e: e, repo: [2]string{d.Owner(), d.Repo()}, num: d.Number, url: d.HTMLURL}
|
|
if e.Kind == EffectView {
|
|
j.body = ViewBody(d, h.groups[d.Group])
|
|
}
|
|
jobs = append(jobs, j)
|
|
}
|
|
}
|
|
for id, g := range h.groups {
|
|
if h.dirtyGroup[id] {
|
|
continue
|
|
}
|
|
for _, e := range g.Owed {
|
|
jobs = append(jobs, job{id: id, group: true, e: e})
|
|
}
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
for _, j := range jobs {
|
|
err := h.do(j.e, j.repo, j.num, j.body, j.url, j.group)
|
|
h.mu.Lock()
|
|
owed := func(list []Effect) []Effect {
|
|
for i, o := range list {
|
|
if o.Kind == j.e.Kind && o.Commit == j.e.Commit && o.Context == j.e.Context && o.Line == j.e.Line &&
|
|
o.Since.Equal(j.e.Since) {
|
|
if err == nil {
|
|
return append(list[:i:i], list[i+1:]...)
|
|
}
|
|
list[i].Tries++
|
|
list[i].Last = err.Error()
|
|
return list
|
|
}
|
|
}
|
|
return list
|
|
}
|
|
if j.group {
|
|
if g := h.groups[j.id]; g != nil {
|
|
g.Owed = owed(g.Owed)
|
|
h.keepGroup(g)
|
|
}
|
|
} else if d := h.deliveries[j.id]; d != nil {
|
|
d.Owed = owed(d.Owed)
|
|
h.keep(d)
|
|
}
|
|
h.mu.Unlock()
|
|
if err != nil && j.e.Tries%10 == 0 {
|
|
h.Logf("[mesh-delivery] %s: %s not done yet (%v); tried again", j.id, j.e.Kind, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// do is one owed effect.
|
|
func (h *Holder) do(e Effect, repo [2]string, number int, body, url string, group bool) error {
|
|
switch e.Kind {
|
|
case EffectEmit:
|
|
var payload any
|
|
if err := json.Unmarshal([]byte(e.Line), &payload); err != nil {
|
|
return nil // nothing sayable: dropped
|
|
}
|
|
event := "transition"
|
|
if group {
|
|
event = "group"
|
|
}
|
|
return h.Emit(event, payload)
|
|
case EffectNote:
|
|
if h.Forge == nil {
|
|
return fmt.Errorf("no forge")
|
|
}
|
|
return h.Forge.Note(repo[0], repo[1], e.Commit, e.Line)
|
|
case EffectView:
|
|
if h.Forge == nil || number == 0 {
|
|
return nil
|
|
}
|
|
return h.Forge.View(repo[0], repo[1], number, body)
|
|
case EffectStatus:
|
|
if h.Forge == nil {
|
|
return fmt.Errorf("no forge")
|
|
}
|
|
if group {
|
|
// A group's status names its member's repository and page in Last.
|
|
where, page, _ := strings.Cut(e.Last, " ")
|
|
ownerRepo, _, _ := strings.Cut(where, "#")
|
|
owner, name, _ := strings.Cut(ownerRepo, "/")
|
|
return h.Forge.Status(owner, name, e.Commit, e.Context, e.State, e.Line, page)
|
|
}
|
|
return h.Forge.Status(repo[0], repo[1], e.Commit, e.Context, e.State, e.Line, url)
|
|
}
|
|
return nil
|
|
}
|