Files
mesh-catalog/modules/mesh-delivery/cmd/mesh-delivery/approval.go
T

461 lines
15 KiB
Go

package main
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// A merge into main waits for the operator's Approve (novox/hq ADR 0398 §3, issue 948).
//
// Where a ready mark at the head, a passing check and the group's turn used to call the merge tool at once, this
// owner now asks the operator on the router (the operator channel, as the knowledge base asks for a record): Approve
// or Decline, each option binding the act "merge these heads". It merges only after an Approve whose warrant holds for
// that ask — the same asker, the same ask's digest, an option it offered, in time, and the act that option bound.
//
// - Asked once per head. A group is asked once, for all its members' heads together, by the first member that reaches
// this point; the Approve covers every member at those heads.
// - A push after the ask cancels it (the mark is cleared with it): the new head needs a new mark and a new Approve.
// - A Decline merges nothing, is said once on the pull request, and clears the mark.
// - An ask that ended without a choice (expired, cancelled, refused by the router) is asked again when the router says so.
// - The ask, its state and the warrant's words are kept on the delivery's ReadyRun and shown by `show`.
//
// The terminal Approve (ADR 0398 §3) is issue 953; today the proving channel is the operator's phone.
// Module is this module's name: the one the bus grants its asks under.
const Module = "mesh-delivery"
// The two answers, both at the level approve.
const (
OptionApprove = "approve"
OptionDecline = "decline"
)
// AskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval.
const AskLasts = asks.ApproveLasts - 10*time.Minute
// Operator is the operator channel as this owner reaches it: asker.Client in the running module.
type Operator interface {
// Ask checks, keeps and publishes an ask under this module's name.
Ask(a asks.Ask) error
// Cancel takes an ask back.
Cancel(id string) error
// Where is an ask's state as the router keeps it, and its warrant once there is one.
Where(id string) (string, *asks.Warrant, error)
}
// headsString is the heads an act names, as an act's value: "<id>=<sha>" for each, sorted, joined by commas.
func headsString(heads map[string]string) string {
var out []string
for id, sha := range heads {
out = append(out, id+"="+sha)
}
sort.Strings(out)
return strings.Join(out, ",")
}
// mergeAct is what an answer binds: this ask, the answer, and the heads it covers.
func mergeAct(ask, option string, heads map[string]string) asks.Act {
return asks.Act{"verb": Module + ".merge", "ask": ask, "option": option, "heads": headsString(heads)}
}
var unshownPath = regexp.MustCompile(`[/\\@~$]|\d+\.\d+\.\d+|[A-Za-z0-9+/=_]{24,}|(?i)\b[a-z0-9-]+\.[a-z]{2,12}\b`)
// phone is a line the operator's phone may be shown: what the router refuses in an ask's words (a path, an address, a
// host-like name, a long unbroken word) is left out, and the line is cut to n characters.
func phone(s string, n int) string {
s = strings.Join(strings.Fields(unshownPath.ReplaceAllString(s, "…")), " ")
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
var issueNamed = regexp.MustCompile(`(?i)\bissues?\s*#?(\d+)`)
// IssuesIn reads the issues a pull request's title and body name ("issue 948"), ascending, once each.
func IssuesIn(text string) []int {
seen := map[int]bool{}
var out []int
for _, m := range issueNamed.FindAllStringSubmatch(text, -1) {
if n, err := strconv.Atoi(m[1]); err == nil && !seen[n] {
seen[n] = true
out = append(out, n)
}
}
sort.Ints(out)
return out
}
func issuesSaid(ds []*Delivery) string {
seen := map[int]bool{}
var out []string
for _, d := range ds {
for _, n := range d.Issues {
if !seen[n] {
seen[n] = true
out = append(out, "issue "+strconv.Itoa(n))
}
}
}
if len(out) == 0 {
return "none named"
}
return strings.Join(out, ", ")
}
func repoName(repository string) string {
_, name, ok := strings.Cut(repository, "/")
if !ok {
return repository
}
return name
}
func pullSaid(d *Delivery) string { return fmt.Sprintf("%s#%d", repoName(d.Repository), d.Number) }
// composeMergeAsk is the ask for a delivery, or for a group's members together (in the order they merge).
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, now time.Time) asks.Ask {
var a asks.Ask
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
a.OnExpiry = "nothing is merged; the ask is made again"
a.About = "delivery." + phone(repoName(d.Repository), 40) + "." + strconv.Itoa(d.Number)
a.About = strings.ReplaceAll(a.About, " ", "-")
var reviewers, reaches, details []string
for _, m := range members {
by, at := "a reviewer", ""
if m.Ready != nil {
if m.Ready.By != "" {
by = m.Ready.By
}
at = m.Ready.MarkedAt
}
reviewers = append(reviewers, fmt.Sprintf("%s marked %s ready at %s", by, pullSaid(m), shortOf(m.Commit, 12)))
plan := "no delivery plan yet"
if m.Plan != nil && m.Plan.Summary != "" {
plan = m.Plan.Summary
}
reaches = append(reaches, plan)
details = append(details, fmt.Sprintf("%s %q at %s: issues: %s; reviewer: %s, marked %s; reaches: %s",
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), by, at, plan))
}
if group == "" {
a.Headline = phone("Merge into main: "+pullSaid(d)+" "+d.Title, asks.HeadlineLength)
a.Explanation = strings.Join([]string{
phone(fmt.Sprintf("%s %s passed its checks and was reviewed. Approve merges it into main at %s; Decline leaves it unmerged.",
pullSaid(d), d.Title, shortOf(d.Commit, 12)), asks.SummaryLength),
phone("Issues: "+issuesSaid(members), asks.PointLength),
phone("Reviewer: "+reviewers[0], asks.PointLength),
phone("Reaches: "+reaches[0], asks.PointLength),
}, "\n")
} else {
var names []string
for _, m := range members {
names = append(names, pullSaid(m))
}
a.Headline = phone("Merge into main: group "+group+" ("+strings.Join(names, ", ")+")", asks.HeadlineLength)
a.Explanation = strings.Join([]string{
phone(fmt.Sprintf("The group %s, %d pull requests, passed its checks and was reviewed. Approve merges all of them into main in their order, at the heads named; Decline leaves them unmerged.",
group, len(members)), asks.SummaryLength),
phone("Members, in order: "+strings.Join(names, ", "), asks.PointLength),
phone("Issues: "+issuesSaid(members), asks.PointLength),
phone("Reaches: "+strings.Join(reaches, "; "), asks.PointLength),
}, "\n")
}
a.Details = phone(strings.Join(details, "\n"), 1500)
if len(details) > 0 { // keep the lines apart: phone joined them
a.Details = strings.Join(func() []string {
var out []string
for _, l := range details {
out = append(out, phone(l, 400))
}
return out
}(), "\n")
}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "the pull request is merged into main at the head named (a group: all its members, in order)"},
{OptionDecline, "Decline", "nothing is merged and the ready mark is taken off"},
} {
binds, _ := asks.ActDigest(mergeAct(id, o.id, heads))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
}
return a
}
// askMembers are the deliveries one ask covers: the group's members not yet merged, in the group's order, or the delivery.
func (h *Holder) askMembers(d *Delivery) []*Delivery {
g := h.groups[d.Group]
if d.Group == "" || g == nil {
return []*Delivery{d}
}
var out []*Delivery
seen := map[string]bool{}
for _, id := range append(append([]string(nil), g.Order...), g.Members...) {
if o := h.deliveries[id]; o != nil && !seen[id] && o.MergedAs == "" && !o.State.Final() {
seen[id] = true
out = append(out, o)
}
}
if len(out) == 0 {
return []*Delivery{d}
}
return out
}
// covering is the ready run holding an ask (pending or approved) that covers d at its head: its own, or a group
// sibling's, whose ask named d's head too.
func (h *Holder) covering(d *Delivery) *ReadyRun {
has := func(o *Delivery) *ReadyRun {
if o != nil && o.Ready != nil && (o.Ready.AskID != "" || o.Ready.Approved != nil) && o.Ready.Heads[d.ID] == d.Commit {
return o.Ready
}
return nil
}
if r := has(d); r != nil {
return r
}
if g := h.groups[d.Group]; d.Group != "" && g != nil {
for _, id := range g.Members {
if r := has(h.deliveries[id]); r != nil {
return r
}
}
}
return nil
}
func headsEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
// headsHold is whether an ask's heads are still the heads of d's group: every member now at the head asked, and every
// head asked that is no member now a member already merged.
func (h *Holder) headsHold(d *Delivery, c *ReadyRun) bool {
now := map[string]bool{}
for _, m := range h.askMembers(d) {
now[m.ID] = true
if c.Heads[m.ID] != m.Commit {
return false
}
}
for id := range c.Heads {
if !now[id] {
if o := h.deliveries[id]; o == nil || o.MergedAs == "" {
return false
}
}
}
return true
}
// endStaleAsks ends every ask whose heads are no longer its group's: a push to any member, marked or not, ends the
// approval (ADR 0398 §3). Under the lock; the cancels run with the look's asks.
func (h *Holder) endStaleAsks() {
for _, d := range h.deliveries {
r := d.Ready
if r == nil || r.Merged != nil || d.MergedAs != "" || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 {
continue
}
if !h.headsHold(d, r) {
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "the heads it covers are no longer the group's heads"))
}
}
}
// approval is what a delivery that may merge waits on for the operator: empty once its head is approved, and the ask
// to make when none is made yet. Under the lock.
func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
r := d.Ready
c := h.covering(d)
if c != nil {
if c.Approved != nil {
r.Approved, r.Warrant, r.AskID, r.Ask, r.Heads = c.Approved, c.Warrant, c.AskID, c.Ask, c.Heads
return "", nil
}
return "the operator's Approve is asked (" + c.AskID + ")", nil
}
if r.Refused != "" {
return "the operator's answer was refused here: " + r.Refused, nil
}
if h.Operator == nil {
return "the operator cannot be asked from here", nil
}
members := h.askMembers(d)
group := ""
if len(members) > 1 {
group = d.Group
}
heads := map[string]string{}
for _, m := range members {
heads[m.ID] = m.Commit
}
r.Asks++
id := fmt.Sprintf("merge-%d-%s-%d", d.Number, shortOf(d.Commit, 12), r.Asks)
ask := composeMergeAsk(id, d, group, members, heads, now)
taken := []*Delivery{}
for _, m := range members {
if m.Ready == nil {
continue
}
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks)
h.keep(m)
taken = append(taken, m)
}
h.Logf("[mesh-delivery] %s is marked ready and its checks passed: the operator is asked to Approve its merge into main (%s, ask %d)",
d.ID, id, r.Asks)
return "the operator's Approve is asked (" + id + ")", func() {
if err := h.Operator.Ask(ask); err != nil {
h.mu.Lock()
defer h.mu.Unlock()
for _, m := range taken {
if m.Ready != nil && m.Ready.AskID == id {
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads = "", nil, nil
h.keep(m)
}
}
h.again("the operator's Approve, which could not be asked")
h.Logf("[mesh-delivery] the operator could not be asked about %s (%v); asked again in %s", d.ID, err, askAgainAfter)
}
}
}
// dropAsk forgets a pending ask on every delivery it covers and takes it back from the router; under the lock.
func (h *Holder) dropAsk(id, why string) func() {
for _, d := range h.deliveries {
if d.Ready != nil && d.Ready.AskID == id {
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
if d.Ready.Merged == nil {
d.Ready.Approved, d.Ready.Warrant = nil, ""
}
h.keep(d)
}
}
h.Logf("[mesh-delivery] the operator's ask %s is cancelled: %s", id, why)
return func() {
if h.Operator == nil {
return
}
if err := h.Operator.Cancel(id); err != nil {
h.Logf("[mesh-delivery] the operator's ask %s could not be cancelled (%v)", id, err)
}
}
}
// pendingAsks are the asks waiting for an answer, by id; under the lock.
func (h *Holder) pendingAsks() []string {
seen := map[string]bool{}
var out []string
for _, d := range h.deliveries {
if r := d.Ready; r != nil && r.AskID != "" && r.Approved == nil && r.Declined == nil && !seen[r.AskID] {
seen[r.AskID] = true
out = append(out, r.AskID)
}
}
sort.Strings(out)
return out
}
// hearAsks reads the router's record of every pending ask and applies the warrant of each that closed.
func (h *Holder) hearAsks() {
if h.Operator == nil {
return
}
h.mu.Lock()
ids := h.pendingAsks()
h.mu.Unlock()
for _, id := range ids {
state, warrant, err := h.Operator.Where(id)
if err != nil {
h.Logf("[mesh-delivery] the router's record of the ask %s cannot be read: %v", id, err)
continue
}
if state == "" || state == "open" || state == "held" {
continue
}
h.mu.Lock()
said := h.decided(id, state, warrant)
h.mu.Unlock()
for _, s := range said {
s()
}
}
}
// decided applies one closed ask's warrant to the deliveries it covers; under the lock. It returns what to say on the
// pull requests.
func (h *Holder) decided(id, state string, w *asks.Warrant) []func() {
var covered []*Delivery
for _, d := range h.deliveries {
if d.Ready != nil && d.Ready.AskID == id {
covered = append(covered, d)
}
}
if len(covered) == 0 {
return nil
}
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
first, now := covered[0].Ready, h.Now()
end := func(why string) {
for _, d := range covered {
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
h.keep(d)
}
h.Logf("[mesh-delivery] the operator's ask %s ended without a choice (%s); asked again at once", id, why)
}
if w == nil || w.Outcome != asks.OutcomeChosen {
why := "the router says " + state
if w != nil {
why = "ended " + string(w.Outcome)
if w.Words != "" {
why += " (" + w.Words + ")"
}
}
end(why)
return nil
}
option, err := w.For(Module, *first.Ask)
if err == nil {
err = option.Performs(mergeAct(id, option.ID, first.Heads))
}
if err != nil {
h.Logf("[mesh-delivery] REFUSED the answer to the ask %s: %v", id, err)
for _, d := range covered {
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
d.Ready.Refused = "was answered, and the answer does not hold here: " + clip(oneLine(err.Error()), 300)
h.keep(d)
}
return nil
}
var said []func()
for _, d := range covered {
r := d.Ready
r.Warrant = w.Says()
h.mergeAgainNow(d) // the operator chose: the merge is asked at once
switch option.ID {
case OptionApprove:
r.Approved = &now
h.Logf("[mesh-delivery] %s: the operator approved its merge into main (%s)", d.ID, r.Warrant)
case OptionDecline:
r.Declined = &now
h.Logf("[mesh-delivery] %s: the operator declined its merge into main (%s)", d.ID, r.Warrant)
said = append(said, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** The merge of this pull request into main at %s was "+
"declined: %s. The ready mark is taken off; a new mark, once reviewed, asks again.", d.Commit, r.Warrant)))
}
h.keep(d)
}
return said
}