mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues, messenger → 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)
The router's order put a wontfix issue before an open one, and a stale worker on an issue waiting for a decision said both; review of #191.
346 lines
12 KiB
Go
346 lines
12 KiB
Go
package main
|
|
|
|
// Which issue tracks a condition, said in its message to the operator (novox/hq issue 400, ADR 0285). An issue names the
|
|
// conditions it tracks by their keys, through the issue tracker's link kind `condition`; the tracker's verb
|
|
// `tracking` answers, in one call, every condition key an issue links and where each of those issues stands. The
|
|
// router asks it outside the work, keeps the answer, and every message about a condition says one line from it:
|
|
//
|
|
// - "Tracked as issue 386: nobody is working on it." — or who is, since when; or that its fix is merged and on
|
|
// its way, delivered and waiting to be checked, or resolved while this still happens; or that it waits for the
|
|
// operator's answer or for a decision.
|
|
// - "No issue tracks this yet." when no issue links the condition's key.
|
|
// - "Whether an issue tracks this could not be checked." when the tracker gave no answer within TrackingKept:
|
|
// never a guess, and never silence that reads as "untracked".
|
|
//
|
|
// The line changes the verdict in two cases, and never when a message is sent (ADR 0253's rules stand):
|
|
//
|
|
// - "Nothing for you to do." becomes "Needs you: answer issue N." when an issue tracking it waits for the
|
|
// operator's answer: the operator is never told there is nothing to do while the mesh waits on them.
|
|
// - "Nothing for you to do." is left out when every issue tracking it is one nobody is working on, so the
|
|
// message does not reassure where nothing is being done; the line then says so.
|
|
//
|
|
// The issue number is the one numbered id a message may carry (ADR 0285): the operator names an issue by it.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// TrackingVerb is the issue tracker's verb the router asks which issues track which conditions.
|
|
const TrackingVerb = "seat:issue-tracker.tracking"
|
|
|
|
const (
|
|
// TrackingWithin is how long the router waits for the tracker's answer.
|
|
TrackingWithin = 5 * time.Second
|
|
// TrackingAskEvery is how often the router asks again; a failed ask is tried again after it too.
|
|
TrackingAskEvery = time.Minute
|
|
// TrackingKept is how long an answer is used: older, a message says the tracking could not be checked.
|
|
TrackingKept = 10 * time.Minute
|
|
// TrackingNamed is the most issues one message names; the rest are counted.
|
|
TrackingNamed = 3
|
|
)
|
|
|
|
// TrackedIssue is one issue that tracks a condition, as the tracker answers it.
|
|
type TrackedIssue struct {
|
|
Number uint64 `json:"number"`
|
|
Status string `json:"status"`
|
|
Needs string `json:"needs,omitempty"`
|
|
// Worker is who works on it, as who@node, while it is diagnosing or fixing; Active the last time they did
|
|
// anything on it; Stale that it is longer ago than the tracker's bound.
|
|
Worker string `json:"worker,omitempty"`
|
|
Since *time.Time `json:"since,omitempty"`
|
|
Active *time.Time `json:"active,omitempty"`
|
|
Stale bool `json:"stale,omitempty"`
|
|
// Fix is where its fix stands, as the delivery last said it: empty when no fix is named; not-merged,
|
|
// merged, held, failed, unheard or delivered.
|
|
Fix string `json:"fix,omitempty"`
|
|
}
|
|
|
|
// trackWord is the tracker's last answer, and when it was asked.
|
|
type trackWord struct {
|
|
at time.Time // when it was answered; zero when it never was
|
|
tried time.Time // when it was last asked, answered or not
|
|
conditions map[string][]TrackedIssue
|
|
err string
|
|
}
|
|
|
|
// askTracking asks the tracker which issues track which conditions, failing on no answer in time.
|
|
func (h *Holder) askTracking() (map[string][]TrackedIssue, error) {
|
|
if h.Tracking != nil {
|
|
return h.Tracking()
|
|
}
|
|
if h.Ask == nil {
|
|
return nil, errors.New("this router cannot ask the issue tracker")
|
|
}
|
|
type answer struct {
|
|
raw json.RawMessage
|
|
err error
|
|
}
|
|
done := make(chan answer, 1)
|
|
go func() {
|
|
raw, err := h.Ask(TrackingVerb, map[string]any{})
|
|
done <- answer{raw, err}
|
|
}()
|
|
var a answer
|
|
select {
|
|
case a = <-done:
|
|
case <-time.After(TrackingWithin):
|
|
return nil, fmt.Errorf("the issue tracker did not answer within %s", TrackingWithin)
|
|
}
|
|
if a.err != nil {
|
|
return nil, a.err
|
|
}
|
|
var body struct {
|
|
Conditions map[string][]TrackedIssue `json:"conditions"`
|
|
}
|
|
if err := json.Unmarshal(a.raw, &body); err != nil {
|
|
return nil, fmt.Errorf("the issue tracker's answer could not be read: %v", err)
|
|
}
|
|
if body.Conditions == nil {
|
|
body.Conditions = map[string][]TrackedIssue{}
|
|
}
|
|
return body.Conditions, nil
|
|
}
|
|
|
|
// warmTracking asks the tracker again, outside the work, when its last answer is TrackingAskEvery old; what is
|
|
// composed inside the work reads the answer kept.
|
|
func (h *Holder) warmTracking() {
|
|
if h.Tracking == nil && h.Ask == nil {
|
|
return
|
|
}
|
|
now := h.Now()
|
|
h.mu.Lock()
|
|
tried := h.track.tried
|
|
if !tried.IsZero() && now.Sub(tried) >= 0 && now.Sub(tried) < TrackingAskEvery {
|
|
h.mu.Unlock()
|
|
return
|
|
}
|
|
h.track.tried = now // marked before asking, so two callers at once ask once
|
|
h.mu.Unlock()
|
|
got, err := h.askTracking()
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.track.tried = now
|
|
if err != nil {
|
|
if h.track.err != err.Error() && h.Logf != nil {
|
|
h.Logf("[messenger] could not ask which issues track the conditions: %v", err)
|
|
}
|
|
h.track.err = err.Error()
|
|
return
|
|
}
|
|
h.track = trackWord{at: now, tried: now, conditions: got}
|
|
}
|
|
|
|
// trackedBy is the issues that track a condition key, and whether the tracker's word is recent enough to say so.
|
|
// ok false: no recent word, and the message says it could not be checked.
|
|
func (h *Holder) trackedBy(key string) ([]TrackedIssue, bool) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
now := h.Now()
|
|
if h.track.at.IsZero() || now.Sub(h.track.at) > TrackingKept || now.Sub(h.track.at) < -ClockSlack {
|
|
return nil, false
|
|
}
|
|
return h.track.conditions[key], true
|
|
}
|
|
|
|
// tracksAsked says whether this router asks the tracker at all; one that cannot (a test's) says nothing of it.
|
|
func (h *Holder) tracksAsked() bool { return h.Tracking != nil || h.Ask != nil }
|
|
|
|
// The verdicts a condition's explanation opens with (novox/hq ADR 0253).
|
|
const (
|
|
verdictNothing = "Nothing for you to do."
|
|
verdictNeeds = "Needs you:"
|
|
)
|
|
|
|
// withTracking is a condition's message with the line on the issues that track it, and its verdict changed where
|
|
// an issue waits for the operator or nobody works on any of them. Cleared and silenced messages, and one withheld
|
|
// whole, say nothing of it.
|
|
func withTracking(m Message, r *Record, what string, issues []TrackedIssue, known bool, now time.Time, loc *time.Location) Message {
|
|
if r.Origin != "condition" || what == "cleared" || what == "silenced" || m.Title == "A mesh message was withheld" {
|
|
return m
|
|
}
|
|
line, verdict := trackingWords(issues, known, now, loc)
|
|
// since is the last line, which never holds a line break; the explanation is everything before it.
|
|
explanation, since := "", m.Body
|
|
if i := strings.LastIndex(m.Body, "\n"); i >= 0 {
|
|
explanation, since = m.Body[:i], m.Body[i+1:]
|
|
}
|
|
if rest, ok := strings.CutPrefix(explanation, verdictNothing); ok {
|
|
switch verdict {
|
|
case "needs":
|
|
explanation = verdictNeeds + " " + needsAnswer(issues) + rest
|
|
case "nobody":
|
|
explanation = strings.TrimSpace(rest)
|
|
}
|
|
}
|
|
parts := []string{}
|
|
for _, p := range []string{explanation, line, since} {
|
|
if p != "" {
|
|
parts = append(parts, p)
|
|
}
|
|
}
|
|
m.Body = strings.Join(parts, "\n")
|
|
return m
|
|
}
|
|
|
|
// needsAnswer is the "Needs you:" sentence for the issues that wait for the operator's answer.
|
|
func needsAnswer(issues []TrackedIssue) string {
|
|
var nums []uint64
|
|
for _, is := range issues {
|
|
if waitsForOperator(is) {
|
|
nums = append(nums, is.Number)
|
|
}
|
|
}
|
|
sort.Slice(nums, func(i, j int) bool { return nums[i] < nums[j] })
|
|
var ns []string
|
|
for _, n := range nums {
|
|
ns = append(ns, fmt.Sprint(n))
|
|
}
|
|
if len(ns) == 1 {
|
|
return "answer issue " + ns[0] + "."
|
|
}
|
|
return "answer issues " + joinAnd(ns) + "."
|
|
}
|
|
|
|
// trackingWords is the line said of the issues that track a condition, and what it does to a "Nothing for you to
|
|
// do." verdict: "needs" (an issue waits for the operator), "nobody" (nobody works on any of them) or "".
|
|
func trackingWords(issues []TrackedIssue, known bool, now time.Time, loc *time.Location) (string, string) {
|
|
if !known {
|
|
return "Whether an issue tracks this could not be checked.", ""
|
|
}
|
|
if len(issues) == 0 {
|
|
return "No issue tracks this yet.", ""
|
|
}
|
|
sorted := append([]TrackedIssue(nil), issues...)
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
ri, rj := closed(sorted[i]), closed(sorted[j])
|
|
if ri != rj {
|
|
return !ri
|
|
}
|
|
return sorted[i].Number < sorted[j].Number
|
|
})
|
|
var sentences []string
|
|
for i, is := range sorted {
|
|
if i == TrackingNamed {
|
|
more := len(sorted) - TrackingNamed
|
|
if more == 1 {
|
|
sentences = append(sentences, "And 1 more issue.")
|
|
} else {
|
|
sentences = append(sentences, fmt.Sprintf("And %d more issues.", more))
|
|
}
|
|
break
|
|
}
|
|
lead := "Tracked as issue "
|
|
if i > 0 {
|
|
lead = "Also as issue "
|
|
}
|
|
sentences = append(sentences, lead+fmt.Sprint(is.Number)+": "+issueState(is, now, loc)+".")
|
|
}
|
|
verdict := "nobody"
|
|
for _, is := range sorted {
|
|
if waitsForOperator(is) {
|
|
verdict = "needs"
|
|
break
|
|
}
|
|
if !nobodyOn(is, now) {
|
|
verdict = ""
|
|
}
|
|
}
|
|
return strings.Join(sentences, " "), verdict
|
|
}
|
|
|
|
// waitsForOperator says whether an issue waits for the operator's answer: it needs the operator, and no fix of it
|
|
// is merged yet.
|
|
func waitsForOperator(is TrackedIssue) bool {
|
|
return is.Needs == "operator" && !closed(is) && !fixUnderway(is)
|
|
}
|
|
|
|
// closed: resolved or wontfix.
|
|
func closed(is TrackedIssue) bool { return is.Status == "resolved" || is.Status == "wontfix" }
|
|
|
|
// fixUnderway: its fix is merged, whatever the delivery did with it since.
|
|
func fixUnderway(is TrackedIssue) bool {
|
|
switch is.Fix {
|
|
case "merged", "held", "failed", "delivered":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// working: someone works on it now.
|
|
func working(is TrackedIssue) bool { return is.Worker != "" && !is.Stale }
|
|
|
|
// nobodyOn says whether nothing is being done on an issue: nobody works on it, and it is closed while this still
|
|
// happens, or no fix of it is written or merged.
|
|
func nobodyOn(is TrackedIssue, now time.Time) bool {
|
|
if working(is) {
|
|
return false
|
|
}
|
|
return closed(is) || (!fixUnderway(is) && is.Fix != "not-merged" && is.Fix != "unheard")
|
|
}
|
|
|
|
// issueState is where one issue stands, in plain words, after "Tracked as issue N: ".
|
|
func issueState(is TrackedIssue, now time.Time, loc *time.Location) string {
|
|
switch {
|
|
case is.Status == "resolved":
|
|
return "it was resolved, yet this still happens"
|
|
case is.Status == "wontfix":
|
|
return "it will not be fixed"
|
|
case is.Fix == "delivered":
|
|
return "its fix is delivered and waits to be checked"
|
|
case is.Fix == "merged":
|
|
return "its fix is merged and on its way"
|
|
case is.Fix == "held":
|
|
return "its fix is merged and held before it is delivered"
|
|
case is.Fix == "failed":
|
|
return "its fix is merged, but its delivery failed"
|
|
case working(is):
|
|
doing := "is fixing it"
|
|
if is.Status == "diagnosing" {
|
|
doing = "is looking for its cause"
|
|
}
|
|
s := workerWords(is.Worker) + " " + doing
|
|
if is.Since != nil {
|
|
s += " since " + when(*is.Since, now, loc)
|
|
}
|
|
return s
|
|
case is.Fix == "not-merged":
|
|
return "a fix is written and waits to be merged"
|
|
case is.Fix == "unheard":
|
|
return "a fix is named, and nothing is heard yet of its delivery"
|
|
case waitsForOperator(is):
|
|
return "it waits for your answer"
|
|
}
|
|
if is.Worker != "" && is.Active != nil {
|
|
return "nobody has worked on it since " + when(*is.Active, now, loc)
|
|
}
|
|
if is.Needs == "decision" {
|
|
return "nobody is working on it; it waits for a decision first"
|
|
}
|
|
return "nobody is working on it"
|
|
}
|
|
|
|
// workerWords is who@node as said: "fix-400 on g14".
|
|
func workerWords(worker string) string {
|
|
who, node, ok := strings.Cut(worker, "@")
|
|
if !ok {
|
|
return worker
|
|
}
|
|
return who + " on " + node
|
|
}
|
|
|
|
// joinAnd is "a", "a and b", "a, b and c".
|
|
func joinAnd(xs []string) string {
|
|
switch len(xs) {
|
|
case 0:
|
|
return ""
|
|
case 1:
|
|
return xs[0]
|
|
}
|
|
return strings.Join(xs[:len(xs)-1], ", ") + " and " + xs[len(xs)-1]
|
|
}
|