461 lines
15 KiB
Go
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
|
|
}
|