Merge pull request 'Say in a condition's message which issue tracks it and where it stands (issue 400)' (#191) from feat/400-alert-names-its-issue into main
This commit was merged in pull request #191.
This commit is contained in:
@@ -25,6 +25,7 @@ what it refuses.
|
||||
| `import` | optionally `dry-run`, `commit` | hq's issue folders, read through the records module, each written as an issue under its own number; see below |
|
||||
| `set` | `number`, any of `severity`, `needs`, `suggested-owner` | changes them as a person decides |
|
||||
| `groom` | optionally `pass`, `confirm` | the triage over the backlog: without arguments a dry run in the background, answering its pass id; with `pass` its progress and findings; with `confirm` and `pass` exactly that pass's findings applied, each checked again; see below |
|
||||
| `tracking` | optionally `conditions` (condition keys) | for every condition key an issue links (link kind `condition`), the issues that link it, those not closed first: status, severity, needs, worker with since, active and stale, and where the fix is (`fix`); the operator's channel says it in a condition's message (hq ADR 0285) |
|
||||
|
||||
**The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the
|
||||
folders until the import, and hq ADR 0283): a status is one of `open`,
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
const Seat = "issue-tracker"
|
||||
|
||||
// Verbs are the seat's verbs, as the manifest declares and claims them.
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set"}
|
||||
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set", "tracking"}
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
@@ -602,5 +602,26 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
}
|
||||
return t.Set(ask)
|
||||
}},
|
||||
{Name: Seat + ".tracking",
|
||||
Description: "Which issues track which conditions: for every condition key an issue links (link kind condition), " +
|
||||
"or only the keys given, the issues that link it, those not closed (resolved or wontfix) first, each with its status, " +
|
||||
"severity, what it needs, who works on it since when and whether they went quiet (stale), and where its " +
|
||||
"fix is (fix: not-merged, merged, held, failed, unheard or delivered; empty when none is named). The " +
|
||||
"operator's channel asks it to say in a condition's message which issue tracks it. Refused: a key not of a condition " +
|
||||
"key's shape.",
|
||||
Input: schema(nil, map[string]any{
|
||||
"conditions": texts("only these condition keys, such as module.mounts.shanks.unhealthy; all when not given"),
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("tracking", m, "conditions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := a.list("conditions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t.Tracking(keys)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
// Which issues track which conditions (novox/hq issue 400, ADR 0285). An issue names a condition it tracks by the
|
||||
// condition's key, through a link of kind `condition`, set by whoever opens the issue or works on it. The verb
|
||||
// `tracking` answers, in one call, every condition key an issue links and, for each, the issues that link it and
|
||||
// where each stands: its status and what it needs, who works on it and since when, and where its fix is. The
|
||||
// router of the operator's channel asks it and words the line a condition's message says; the tracker says no
|
||||
// words for the operator itself, and the controller learns nothing of issues.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MostTrackingPerCondition is the most issues the answer gives for one condition key, those not closed first.
|
||||
const MostTrackingPerCondition = 10
|
||||
|
||||
// TrackingIssue is one issue that tracks a condition, as `tracking` answers it.
|
||||
type TrackingIssue struct {
|
||||
Number uint64 `json:"number"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Needs string `json:"needs,omitempty"`
|
||||
// Worker, Since, Active and Stale are who works on it, while it is diagnosing or fixing (ADR 0284).
|
||||
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 (ADR 0281): see FixState.
|
||||
Fix string `json:"fix,omitempty"`
|
||||
}
|
||||
|
||||
// Tracked is the answer to `tracking`.
|
||||
type Tracked struct {
|
||||
// Conditions is, by condition key, the issues that link it.
|
||||
Conditions map[string][]TrackingIssue `json:"conditions"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// FixState is where an issue's fix stands, one word, from the same facts as DeliveryLine and in its order: empty
|
||||
// when fixed-by names no pull request; failed, held, merged (not delivered yet), not-merged, unheard (nothing
|
||||
// heard from the delivery), or delivered (every pull request it names).
|
||||
func FixState(is *Issue) string {
|
||||
prs := pullRequests(is)
|
||||
if len(prs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var failed, held, merged, open, unheard bool
|
||||
for _, pr := range prs {
|
||||
f := fixOf(is, pr)
|
||||
switch {
|
||||
case contains(is.Delivered, pr):
|
||||
case f == nil:
|
||||
unheard = true
|
||||
case f.State == "held":
|
||||
held = true
|
||||
case f.State == "failed" || f.State == "stopped":
|
||||
failed = true
|
||||
case f.Merged:
|
||||
merged = true
|
||||
default:
|
||||
open = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case failed:
|
||||
return "failed"
|
||||
case held:
|
||||
return "held"
|
||||
case merged:
|
||||
return "merged"
|
||||
case open:
|
||||
return "not-merged"
|
||||
case unheard:
|
||||
return "unheard"
|
||||
}
|
||||
return "delivered"
|
||||
}
|
||||
|
||||
// Tracking answers which issues track which conditions: only the keys asked, or every key an issue links.
|
||||
func (t *Tracker) Tracking(keys []string) (*Tracked, error) {
|
||||
want := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
k, err := checkTarget(LinkCondition, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want[k] = true
|
||||
}
|
||||
all, err := t.allIssues()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now, staleAfter := t.now(), t.workerStaleAfter()
|
||||
out := &Tracked{Conditions: map[string][]TrackingIssue{}}
|
||||
for _, is := range all {
|
||||
for _, l := range is.Links {
|
||||
if l.Kind != LinkCondition || (len(want) > 0 && !want[l.Target]) {
|
||||
continue
|
||||
}
|
||||
ti := TrackingIssue{Number: is.Number, Status: is.Status, Severity: is.Severity, Needs: is.Needs, Fix: FixState(is)}
|
||||
if w := is.Worker; w != nil {
|
||||
since, active := w.Since, w.Active
|
||||
ti.Worker, ti.Since, ti.Active, ti.Stale = w.By, &since, &active, w.stale(now, staleAfter)
|
||||
}
|
||||
out.Conditions[l.Target] = append(out.Conditions[l.Target], ti)
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for k, list := range out.Conditions {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
ci, cj := closedStatus(list[i].Status), closedStatus(list[j].Status)
|
||||
if ci != cj {
|
||||
return !ci
|
||||
}
|
||||
return list[i].Number < list[j].Number
|
||||
})
|
||||
if len(list) > MostTrackingPerCondition {
|
||||
list = list[:MostTrackingPerCondition]
|
||||
}
|
||||
out.Conditions[k] = list
|
||||
n++
|
||||
}
|
||||
switch {
|
||||
case n == 0 && len(want) > 0:
|
||||
out.Said = "No issue tracks " + strings.Join(sortedKeys(want), ", ") + "."
|
||||
case n == 0:
|
||||
out.Said = "No issue tracks a condition."
|
||||
case n == 1:
|
||||
out.Said = "Issues track 1 condition."
|
||||
default:
|
||||
out.Said = fmt.Sprintf("Issues track %d conditions.", n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func closedStatus(s string) bool { return s == StatusResolved || s == StatusWontfix }
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Which issues track which conditions (novox/hq issue 400, ADR 0285).
|
||||
|
||||
const mountsKey = "module.mounts.shanks.unhealthy"
|
||||
|
||||
func TestTrackingAnswersTheIssuesThatLinkEachCondition(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
now := clocked(h)
|
||||
a := h.open(t, "mounts fail on shanks", "mesh-catalog")
|
||||
b := h.open(t, "a wait for the operator's secret", "mesh-controller")
|
||||
c := h.open(t, "rolled-back is repaired by hand", "mesh-controller")
|
||||
d := h.open(t, "unrelated", "hq")
|
||||
for _, l := range []LinkAsk{
|
||||
{Number: a, Kind: LinkCondition, Target: mountsKey},
|
||||
{Number: b, Kind: LinkCondition, Target: mountsKey},
|
||||
{Number: c, Kind: LinkCondition, Target: "mesh.hand-acts.rolled-back.healer-wanted"},
|
||||
{Number: d, Kind: LinkRelatesTo, Target: "365"},
|
||||
} {
|
||||
if _, err := h.Link(l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := h.Move(MoveAsk{Number: b, Status: StatusFixing, By: "fix-386@g14"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Set(SetAsk{Number: c, Needs: "operator"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(25 * time.Hour)
|
||||
|
||||
got, err := h.Tracking(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Conditions) != 2 || got.Said != "Issues track 2 conditions." {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
m := got.Conditions[mountsKey]
|
||||
if len(m) != 2 || m[0].Number != a || m[1].Number != b {
|
||||
t.Fatalf("mounts: %+v", m)
|
||||
}
|
||||
if m[0].Worker != "" || m[0].Status != StatusOpen {
|
||||
t.Errorf("nobody works on %d: %+v", a, m[0])
|
||||
}
|
||||
if m[1].Worker != "fix-386@g14" || m[1].Since == nil || m[1].Active == nil || !m[1].Stale || m[1].Status != StatusFixing {
|
||||
t.Errorf("the worker of %d, gone quiet a day: %+v", b, m[1])
|
||||
}
|
||||
if r := got.Conditions["mesh.hand-acts.rolled-back.healer-wanted"]; len(r) != 1 || r[0].Number != c || r[0].Needs != "operator" {
|
||||
t.Errorf("rolled-back: %+v", r)
|
||||
}
|
||||
|
||||
// Only the keys asked; one nobody links is said.
|
||||
only, err := h.Tracking([]string{"machine.ace.silent"})
|
||||
if err != nil || len(only.Conditions) != 0 || only.Said != "No issue tracks machine.ace.silent." {
|
||||
t.Fatalf("%+v %v", only, err)
|
||||
}
|
||||
if _, err := h.Tracking([]string{"a key\nwith a line"}); err == nil {
|
||||
t.Fatal("a key not of a condition key's shape was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// A resolved issue comes after those not closed, and the answer holds at most MostTrackingPerCondition for a key.
|
||||
func TestTrackingPutsOpenIssuesFirstAndIsBounded(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
var numbers []uint64
|
||||
for i := 0; i < MostTrackingPerCondition+2; i++ {
|
||||
n := h.open(t, "one of many", "hq")
|
||||
if _, err := h.Link(LinkAsk{Number: n, Kind: LinkCondition, Target: mountsKey}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
numbers = append(numbers, n)
|
||||
}
|
||||
first := numbers[0]
|
||||
h.fixing(t, first)
|
||||
if _, err := h.Move(MoveAsk{Number: first, Status: StatusResolved, LocatedIn: []string{"hq"}, FixedBy: []string{"hq#1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := h.Tracking(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := got.Conditions[mountsKey]
|
||||
if len(list) != MostTrackingPerCondition {
|
||||
t.Fatalf("%d issues, want %d", len(list), MostTrackingPerCondition)
|
||||
}
|
||||
for _, is := range list {
|
||||
if is.Number == first {
|
||||
t.Fatalf("the resolved issue was not put last: %+v", list)
|
||||
}
|
||||
}
|
||||
if list[0].Number != numbers[1] {
|
||||
t.Errorf("not by number: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// Where a fix stands, one word per case, in DeliveryLine's order.
|
||||
func TestFixStateSaysWhereTheFixIs(t *testing.T) {
|
||||
merged := true
|
||||
cases := []struct {
|
||||
name string
|
||||
is Issue
|
||||
want string
|
||||
}{
|
||||
{"no fix named", Issue{}, ""},
|
||||
{"nothing heard", Issue{FixedBy: []string{"mesh-catalog#1"}}, "unheard"},
|
||||
{"not merged", Issue{FixedBy: []string{"mesh-catalog#1"}, Fixes: []Fix{{PullRequest: "mesh-catalog#1", State: "open"}}}, "not-merged"},
|
||||
{"merged", Issue{FixedBy: []string{"mesh-catalog#1"}, Fixes: []Fix{{PullRequest: "mesh-catalog#1", State: "merged", Merged: merged}}}, "merged"},
|
||||
{"held", Issue{FixedBy: []string{"mesh-catalog#1"}, Fixes: []Fix{{PullRequest: "mesh-catalog#1", State: "held", Merged: merged}}}, "held"},
|
||||
{"failed", Issue{FixedBy: []string{"mesh-catalog#1"}, Fixes: []Fix{{PullRequest: "mesh-catalog#1", State: "failed", Merged: merged}}}, "failed"},
|
||||
{"delivered", Issue{FixedBy: []string{"mesh-catalog#1"}, Delivered: []string{"mesh-catalog#1"}}, "delivered"},
|
||||
{"one of two failed", Issue{FixedBy: []string{"mesh-catalog#1", "mesh-controller#2"}, Delivered: []string{"mesh-catalog#1"},
|
||||
Fixes: []Fix{{PullRequest: "mesh-controller#2", State: "stopped", Merged: merged}}}, "failed"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := FixState(&c.is); got != c.want {
|
||||
t.Errorf("%s: %q, want %q (its line: %q)", c.name, got, c.want, DeliveryLine(&c.is))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The verb as the tool runner calls it; its answer carries no report, note or title (nothing the router does not
|
||||
// need), and so no secret an issue's words might hold.
|
||||
func TestTheTrackingVerb(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := h.open(t, "mounts fail", "hq")
|
||||
if _, err := h.Link(LinkAsk{Number: n, Kind: LinkCondition, Target: mountsKey}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Note(n, "the share's password=hunter2 was typed here"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, before := len(h.said()), h.mem.snapshot(IssuesState)
|
||||
out, err := h.call(t, "tracking", map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A read: no event said, nothing written.
|
||||
if len(h.said()) != events || !reflect.DeepEqual(h.mem.snapshot(IssuesState), before) {
|
||||
t.Fatal("tracking said an event or wrote an issue")
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
if !strings.Contains(string(raw), `"`+mountsKey+`":[{"number":`) {
|
||||
t.Fatalf("%s", raw)
|
||||
}
|
||||
for _, leak := range []string{"hunter2", "mounts fail", "seen:"} {
|
||||
if strings.Contains(string(raw), leak) {
|
||||
t.Errorf("the answer carries %q: %s", leak, raw)
|
||||
}
|
||||
}
|
||||
if _, err := h.call(t, "tracking", map[string]any{"conditions": []string{mountsKey}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.call(t, "tracking", map[string]any{"number": 1}); err == nil {
|
||||
t.Fatal("an argument it does not take was accepted")
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"link",
|
||||
"import",
|
||||
"groom",
|
||||
"set"
|
||||
"set",
|
||||
"tracking"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -32,7 +33,8 @@
|
||||
"link",
|
||||
"import",
|
||||
"groom",
|
||||
"set"
|
||||
"set",
|
||||
"tracking"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -108,6 +108,15 @@ condition's resolved line and how long it was open: "Resolved: openrazer works a
|
||||
A condition from a controller older than plain words, or a module's notice without a headline, is said from
|
||||
its summary, with code spans taken out (`cmd/messenger/words.go`).
|
||||
|
||||
**A condition's message says which issue tracks it** (novox/hq ADR 0285, issue 400). Below the explanation,
|
||||
one line from the issue tracker's `tracking` verb, asked outside the work at most once a minute and used for
|
||||
ten minutes: "Tracked as issue 386: fix-386 on g14 is fixing it since 21:10.", "…: nobody is working on it.",
|
||||
"…: its fix is merged and on its way.", "…: it waits for your answer.", up to three issues and then a count;
|
||||
"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 in ten minutes. "Nothing for you to do." becomes "Needs you:
|
||||
answer issue N." while an issue tracking it waits for the operator, and is left out while nobody works on any
|
||||
issue tracking it. The line changes words only, never when a message is sent (`cmd/messenger/tracking.go`).
|
||||
|
||||
**Every explanation opens with a verdict**: "Nothing for you to do." or "Needs you:" and one thing the
|
||||
operator can do themselves. **A warning that needs nothing is not sent**: it stays open here and in the
|
||||
controller's conditions, the history says it was kept quiet, and it is said the moment it needs the
|
||||
|
||||
@@ -290,6 +290,8 @@ type Holder struct {
|
||||
RootFree func(machines []string) (map[string]RootVerdict, error)
|
||||
// Zone is the operator's time zone, which every time in a message is said in; nil is this machine's.
|
||||
Zone func() *time.Location
|
||||
// Tracking asks the issue tracker which issues track which conditions; nil asks it through Ask (tracking.go).
|
||||
Tracking func() (map[string][]TrackedIssue, error)
|
||||
|
||||
work sync.Mutex // one event, call or tick at a time
|
||||
mu sync.Mutex // what the status reads
|
||||
@@ -329,6 +331,8 @@ type Holder struct {
|
||||
rootWord map[string]rootWord
|
||||
// shows is, by kind, the AnswersLostAt this router last acted on in full (restart.go).
|
||||
shows map[string]time.Time
|
||||
// track is the issue tracker's last word on which issues track which conditions (tracking.go).
|
||||
track trackWord
|
||||
}
|
||||
|
||||
func (h *Holder) init() {
|
||||
@@ -406,6 +410,7 @@ func (h *Holder) inGrace(now time.Time) bool {
|
||||
// Condition takes one of the controller's condition events. Whether it is news is decided by its own
|
||||
// time, never by its arrival.
|
||||
func (h *Holder) Condition(event string, c Condition) {
|
||||
h.warmTracking() // outside the work: what is said inside it reads a recent word
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
@@ -691,6 +696,7 @@ func lastSaid(r *Record) time.Time {
|
||||
// enough ago that a new raise is a new message.
|
||||
func (h *Holder) Tick() {
|
||||
h.warmRootFree(false) // outside the work: what is offered inside it reads a recent word
|
||||
h.warmTracking()
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
@@ -762,6 +768,7 @@ func (h *Holder) Tick() {
|
||||
// message waits its window and not a minute more.
|
||||
func (h *Holder) Flush() {
|
||||
h.warmRootFree(false) // outside the work: what is offered inside it reads a recent word
|
||||
h.warmTracking()
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
@@ -870,13 +877,13 @@ func (h *Holder) words(r *Record, what string) Message {
|
||||
if r.Refused != "" {
|
||||
// Refused before: its words stay withheld in every later message too.
|
||||
for w := 1; w <= 3; w++ {
|
||||
m := compose(r, what, now, h.zone(), w)
|
||||
m := h.composeTracked(r, what, now, w)
|
||||
if _, ok := Check(m.Text()); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
m := compose(r, what, now, h.zone(), 0)
|
||||
m := h.composeTracked(r, what, now, 0)
|
||||
refusal, ok := Check(m.Text())
|
||||
if ok {
|
||||
return m
|
||||
@@ -901,7 +908,7 @@ func (h *Holder) words(r *Record, what string) Message {
|
||||
}
|
||||
}
|
||||
for w := 1; w <= 3; w++ {
|
||||
m := compose(r, what, now, h.zone(), w)
|
||||
m := h.composeTracked(r, what, now, w)
|
||||
if _, ok := Check(m.Text()); ok {
|
||||
return m
|
||||
}
|
||||
@@ -909,6 +916,17 @@ func (h *Holder) words(r *Record, what string) Message {
|
||||
return withheld(refusal.What, r.Severity == Urgent)
|
||||
}
|
||||
|
||||
// composeTracked is compose with the line on the issues that track the condition (tracking.go), so the content
|
||||
// rule judges the message as it is sent.
|
||||
func (h *Holder) composeTracked(r *Record, what string, now time.Time, withhold int) Message {
|
||||
m := compose(r, what, now, h.zone(), withhold)
|
||||
if !h.tracksAsked() || withhold >= 2 {
|
||||
return m
|
||||
}
|
||||
issues, known := h.trackedBy(r.Key)
|
||||
return withTracking(m, r, what, issues, known, now, h.zone())
|
||||
}
|
||||
|
||||
// deliver sends a record's message where its severity routes it.
|
||||
|
||||
func (h *Holder) channels() []Channel {
|
||||
|
||||
@@ -84,9 +84,10 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
|
||||
"intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
|
||||
t.Fatalf("consumes %v", m.Consumes)
|
||||
}
|
||||
// It performs nothing: the verbs it calls read the open conditions and whether root is free.
|
||||
// It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues
|
||||
// track which conditions.
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions",
|
||||
"seat:" + ControllerSeat + ".root-free"}) {
|
||||
"seat:" + ControllerSeat + ".root-free", TrackingVerb}) {
|
||||
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
|
||||
}
|
||||
var state []string
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
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]
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A condition's message says which issue tracks it and where that issue stands (novox/hq issue 400, ADR 0285):
|
||||
// one case per wording, each through the message as composed, and the content rule passes every line.
|
||||
|
||||
var trackNow = time.Date(2026, 10, 10, 19, 30, 0, 0, time.UTC)
|
||||
|
||||
func at(h, m int) *time.Time {
|
||||
t := time.Date(2026, 10, 10, h, m, 0, 0, time.UTC)
|
||||
return &t
|
||||
}
|
||||
|
||||
// trackedMessage is the message of a warning that needs nothing, as the controller words a hand repair, with the
|
||||
// issues given tracking it.
|
||||
func trackedMessage(issues []TrackedIssue, known bool) Message {
|
||||
r := &Record{Key: "mesh.hand-acts.rolled-back.healer-wanted", Origin: "condition", Severity: Warning,
|
||||
Headline: "Rolled-back keeps being fixed by hand",
|
||||
Explanation: "Nothing for you to do. A person repaired rolled-back by hand 4 times in 14 days, so an automatic repair is wanted for it. Nothing is broken now.",
|
||||
Raised: trackNow.Add(-26 * time.Hour)}
|
||||
m := compose(r, "quiet-reminder", trackNow, time.UTC, 0)
|
||||
return withTracking(m, r, "quiet-reminder", issues, known, trackNow, time.UTC)
|
||||
}
|
||||
|
||||
func TestAConditionsMessageSaysWhereTheIssueThatTracksItStands(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
issues []TrackedIssue
|
||||
known bool
|
||||
line string
|
||||
verdict string // what the body opens with
|
||||
}{
|
||||
{"no issue", nil, true,
|
||||
"No issue tracks this yet.", "Nothing for you to do."},
|
||||
{"the tracker gave no answer", nil, false,
|
||||
"Whether an issue tracks this could not be checked.", "Nothing for you to do."},
|
||||
{"nobody works on it", []TrackedIssue{{Number: 352, Status: "located"}}, true,
|
||||
"Tracked as issue 352: nobody is working on it.", "A person repaired"},
|
||||
{"an agent is fixing it", []TrackedIssue{{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(21, 10)}}, true,
|
||||
"Tracked as issue 386: fix-386 on g14 is fixing it since 21:10.", "Nothing for you to do."},
|
||||
{"an agent looks for its cause", []TrackedIssue{{Number: 386, Status: "diagnosing", Worker: "jochen@shanks", Since: at(18, 5)}}, true,
|
||||
"Tracked as issue 386: jochen on shanks is looking for its cause since 18:05.", "Nothing for you to do."},
|
||||
{"its worker went quiet", []TrackedIssue{{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(9, 0), Active: at(9, 30), Stale: true}}, true,
|
||||
"Tracked as issue 386: nobody has worked on it since 09:30.", "A person repaired"},
|
||||
{"a fix waits to be merged", []TrackedIssue{{Number: 386, Status: "located", Fix: "not-merged"}}, true,
|
||||
"Tracked as issue 386: a fix is written and waits to be merged.", "Nothing for you to do."},
|
||||
{"its fix is on its way", []TrackedIssue{{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(9, 0), Fix: "merged"}}, true,
|
||||
"Tracked as issue 386: its fix is merged and on its way.", "Nothing for you to do."},
|
||||
{"the delivery was not heard yet", []TrackedIssue{{Number: 386, Status: "fixing", Fix: "unheard"}}, true,
|
||||
"Tracked as issue 386: a fix is named, and nothing is heard yet of its delivery.", "Nothing for you to do."},
|
||||
{"its fix is held", []TrackedIssue{{Number: 386, Status: "fixing", Fix: "held"}}, true,
|
||||
"Tracked as issue 386: its fix is merged and held before it is delivered.", "Nothing for you to do."},
|
||||
{"its fix's delivery failed", []TrackedIssue{{Number: 386, Status: "fixing", Fix: "failed"}}, true,
|
||||
"Tracked as issue 386: its fix is merged, but its delivery failed.", "Nothing for you to do."},
|
||||
{"its fix is delivered", []TrackedIssue{{Number: 386, Status: "fixing", Fix: "delivered"}}, true,
|
||||
"Tracked as issue 386: its fix is delivered and waits to be checked.", "Nothing for you to do."},
|
||||
{"it was resolved", []TrackedIssue{{Number: 386, Status: "resolved", Fix: "delivered"}}, true,
|
||||
"Tracked as issue 386: it was resolved, yet this still happens.", "A person repaired"},
|
||||
{"it will not be fixed", []TrackedIssue{{Number: 386, Status: "wontfix"}}, true,
|
||||
"Tracked as issue 386: it will not be fixed.", "A person repaired"},
|
||||
{"one not fixed after one open", []TrackedIssue{{Number: 100, Status: "wontfix"}, {Number: 200, Status: "open"}}, true,
|
||||
"Tracked as issue 200: nobody is working on it. Also as issue 100: it will not be fixed.", "A person repaired"},
|
||||
{"its worker went quiet on one that waits for a decision", []TrackedIssue{{Number: 400, Status: "fixing", Needs: "decision",
|
||||
Worker: "fix-400@g14", Since: at(9, 0), Active: at(9, 30), Stale: true}}, true,
|
||||
"Tracked as issue 400: nobody has worked on it since 09:30.", "A person repaired"},
|
||||
{"it waits for the operator", []TrackedIssue{{Number: 336, Status: "open", Needs: "operator"}}, true,
|
||||
"Tracked as issue 336: it waits for your answer.", "Needs you: answer issue 336. A person repaired"},
|
||||
{"it waits for a decision", []TrackedIssue{{Number: 400, Status: "open", Needs: "decision"}}, true,
|
||||
"Tracked as issue 400: nobody is working on it; it waits for a decision first.", "A person repaired"},
|
||||
{"two issues, one worked on", []TrackedIssue{
|
||||
{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(21, 10)},
|
||||
{Number: 379, Status: "located"}}, true,
|
||||
"Tracked as issue 379: nobody is working on it. Also as issue 386: fix-386 on g14 is fixing it since 21:10.",
|
||||
"Nothing for you to do."},
|
||||
{"two issues, nobody on either", []TrackedIssue{{Number: 386, Status: "open"}, {Number: 379, Status: "located"}}, true,
|
||||
"Tracked as issue 379: nobody is working on it. Also as issue 386: nobody is working on it.", "A person repaired"},
|
||||
{"one of two waits for the operator", []TrackedIssue{
|
||||
{Number: 386, Status: "fixing", Worker: "fix-386@g14", Since: at(21, 10)},
|
||||
{Number: 379, Status: "open", Needs: "operator"}}, true,
|
||||
"Tracked as issue 379: it waits for your answer. Also as issue 386: fix-386 on g14 is fixing it since 21:10.",
|
||||
"Needs you: answer issue 379."},
|
||||
{"more than three", []TrackedIssue{{Number: 1, Status: "open"}, {Number: 2, Status: "open"}, {Number: 3, Status: "open"},
|
||||
{Number: 4, Status: "open"}, {Number: 5, Status: "resolved"}}, true,
|
||||
"Tracked as issue 1: nobody is working on it. Also as issue 2: nobody is working on it. Also as issue 3: nobody is " +
|
||||
"working on it. And 2 more issues.", "A person repaired"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
m := trackedMessage(c.issues, c.known)
|
||||
lines := strings.Split(m.Body, "\n")
|
||||
if len(lines) != 3 || lines[1] != c.line {
|
||||
t.Fatalf("the issue line:\n got %q\nwant %q", m.Body, c.line)
|
||||
}
|
||||
if !strings.HasPrefix(lines[0], c.verdict) {
|
||||
t.Errorf("the body opens %q, want %q", lines[0], c.verdict)
|
||||
}
|
||||
if !strings.HasPrefix(lines[2], "Open since ") {
|
||||
t.Errorf("since is not last: %q", m.Body)
|
||||
}
|
||||
if r, ok := Check(m.Text()); !ok {
|
||||
t.Errorf("the content rule refuses it: %v", r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Never "Nothing for you to do." while an issue tracking it waits for the operator, nor while nobody works on any.
|
||||
func TestNothingForYouToDoIsNeverSaidWhileTheIssueWaitsOnTheOperatorOrNobodyWorksOnIt(t *testing.T) {
|
||||
for _, issues := range [][]TrackedIssue{
|
||||
{{Number: 336, Status: "open", Needs: "operator"}},
|
||||
{{Number: 336, Status: "fixing", Worker: "fix-336@g14", Since: at(9, 0), Needs: "operator"}},
|
||||
{{Number: 352, Status: "located"}},
|
||||
{{Number: 352, Status: "fixing", Worker: "fix-352@g14", Since: at(9, 0), Stale: true}},
|
||||
} {
|
||||
if m := trackedMessage(issues, true); strings.Contains(m.Body, "Nothing for you to do") {
|
||||
t.Errorf("%+v: %q", issues, m.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A verdict that already needs the operator is left as it is; the line is added below it.
|
||||
func TestAVerdictThatNeedsTheOperatorStands(t *testing.T) {
|
||||
r := &Record{Key: "module.mounts.shanks.unhealthy", Origin: "condition", Severity: Urgent, Headline: "mounts not working on shanks",
|
||||
Explanation: "Needs you: give the share's login. mounts on shanks is not healthy.", Raised: trackNow.Add(-time.Hour)}
|
||||
m := withTracking(compose(r, "raised", trackNow, time.UTC, 0), r, "raised",
|
||||
[]TrackedIssue{{Number: 386, Status: "open", Needs: "operator"}}, true, trackNow, time.UTC)
|
||||
want := "Needs you: give the share's login. mounts on shanks is not healthy.\nTracked as issue 386: it waits for your answer.\nSince 18:30."
|
||||
if m.Body != want {
|
||||
t.Fatalf("got %q\nwant %q", m.Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A clearing, a silence and a notice from a module say nothing of issues.
|
||||
func TestAClearingASilenceAndANoticeSayNoIssue(t *testing.T) {
|
||||
issues := []TrackedIssue{{Number: 352, Status: "located"}}
|
||||
r := &Record{Key: "k", Origin: "condition", Headline: "h", Explanation: "Nothing for you to do. x.", Raised: trackNow.Add(-time.Hour),
|
||||
Cleared: trackNow, SilencedTill: trackNow.Add(time.Hour)}
|
||||
for _, what := range []string{"cleared", "silenced"} {
|
||||
m := compose(r, what, trackNow, time.UTC, 0)
|
||||
if got := withTracking(m, r, what, issues, true, trackNow, time.UTC); got.Body != m.Body {
|
||||
t.Errorf("%s: %q", what, got.Body)
|
||||
}
|
||||
}
|
||||
n := &Record{Key: "n", Origin: "notify", Headline: "a notice", Raised: trackNow}
|
||||
m := compose(n, "raised", trackNow, time.UTC, 0)
|
||||
if got := withTracking(m, n, "raised", issues, true, trackNow, time.UTC); got.Body != m.Body {
|
||||
t.Errorf("notice: %q", got.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// Through the router: the tracker is asked outside the work, its word is kept, a word older than TrackingKept is
|
||||
// not used, and a tracker that fails is said as not checked.
|
||||
func TestTheRouterAsksTheTrackerAndSaysItsWordInTheMessage(t *testing.T) {
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
h.Zone = func() *time.Location { return time.UTC }
|
||||
asked := 0
|
||||
fail := false
|
||||
h.Ask = func(address string, body any) (json.RawMessage, error) {
|
||||
if address != TrackingVerb {
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
asked++
|
||||
if fail {
|
||||
return nil, errors.New("no responders")
|
||||
}
|
||||
return json.RawMessage(`{"conditions":{"module.mounts.shanks.unhealthy":[` +
|
||||
`{"number":379,"status":"located"},` +
|
||||
`{"number":386,"status":"fixing","worker":"fix-386@g14","since":"2026-10-06T11:10:00Z"}]}}`), nil
|
||||
}
|
||||
k := cond("module.mounts.shanks.unhealthy", Urgent, "mounts on shanks is not healthy")
|
||||
k.Explanation, k.Needs = "Nothing for you to do. mounts on shanks is not healthy.", ""
|
||||
k.Raised = c.now()
|
||||
h.Condition(EventRaised, k)
|
||||
settle(h, c)
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
t.Fatalf("urgent: telegram %d, desktop %d", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
body := tg.sends[0].Body
|
||||
if !strings.Contains(body, "\nTracked as issue 379: nobody is working on it. Also as issue 386: fix-386 on g14 is fixing it since 11:10.\n") {
|
||||
t.Fatalf("body: %q", body)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Errorf("asked the tracker %d times within a minute, want once", asked)
|
||||
}
|
||||
keyless(t, tg.sends...)
|
||||
|
||||
// The tracker stops answering: its word is used until it is TrackingKept old, then said as not checked.
|
||||
fail = true
|
||||
c.pass(TrackingKept + time.Minute)
|
||||
h.Tick()
|
||||
h.mu.Lock()
|
||||
r := h.open["module.mounts.shanks.unhealthy"]
|
||||
h.mu.Unlock()
|
||||
if m := h.say(r, "reminder"); !strings.Contains(m.Body, "\nWhether an issue tracks this could not be checked.\n") {
|
||||
t.Fatalf("an old word was used: %q", m.Body)
|
||||
}
|
||||
if asked < 2 {
|
||||
t.Errorf("the tracker was not asked again")
|
||||
}
|
||||
}
|
||||
|
||||
// A router that cannot ask the tracker (a test's) adds nothing, so a missing word is never confused with a router
|
||||
// built without it.
|
||||
func TestARouterThatCannotAskAddsNothing(t *testing.T) {
|
||||
h, _, _, c, _ := newHolder(t)
|
||||
k := cond("machine.ace.silent", Urgent, "ace is silent")
|
||||
k.Raised = c.now()
|
||||
h.Condition(EventRaised, k)
|
||||
h.mu.Lock()
|
||||
r := h.open["machine.ace.silent"]
|
||||
h.mu.Unlock()
|
||||
if m := h.say(r, "raised"); strings.Contains(m.Body, "issue") {
|
||||
t.Fatalf("%q", m.Body)
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ var examples = []example{{
|
||||
resolved: "openrazer delivery no longer waiting", popup: true,
|
||||
title: "Urgent: openrazer delivery waiting to start",
|
||||
body: "Needs you: start it, or stop it. The change to openrazer is merged and built, and mesh-delivery (the module " +
|
||||
"that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may be stuck.\nSince 12:40.",
|
||||
"that decides when a delivery goes out) has not let it start for 5 hours, so mesh-delivery may be stuck.\nNo issue tracks this yet.\nSince 12:40.",
|
||||
offers: "Details", choose: "Details",
|
||||
|
||||
cleared: "Resolved: openrazer delivery no longer waiting, after 5.0 h", openFor: 5 * time.Hour,
|
||||
@@ -80,7 +80,7 @@ var examples = []example{{
|
||||
title: "openrazer not working on g14",
|
||||
body: "Needs you: restart its service openrazer-daemon on g14; if it fails again, the details say why. openrazer " +
|
||||
"on g14 is not healthy: its service openrazer-daemon stopped with an error. It clears as soon as it runs " +
|
||||
"again.\nSince 12:40.",
|
||||
"again.\nNo issue tracks this yet.\nSince 12:40.",
|
||||
offers: "Details", choose: "Details",
|
||||
cleared: "Resolved: openrazer works again on g14, after 2.0 h", openFor: 2 * time.Hour,
|
||||
}, {
|
||||
@@ -104,7 +104,7 @@ var examples = []example{{
|
||||
title: "3 failed services on shanks",
|
||||
body: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, mnt-recalbox " +
|
||||
"(a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh does not repair " +
|
||||
"them.\nSince 12:40.",
|
||||
"them.\nNo issue tracks this yet.\nSince 12:40.",
|
||||
offers: "Details", choose: "Details",
|
||||
cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute,
|
||||
}, {
|
||||
@@ -135,7 +135,7 @@ var examples = []example{{
|
||||
explanation: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.",
|
||||
resolved: "Delivery of hq is no longer held", popup: true,
|
||||
title: "Delivery of hq held for 36 hours",
|
||||
body: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.\nSince 12:40.",
|
||||
body: "Needs you: release it, or stop it. A delivery of hq has been held for 36 hours, past its limit.\nNo issue tracks this yet.\nSince 12:40.",
|
||||
offers: "Details", choose: "Details",
|
||||
|
||||
cleared: "Resolved: delivery of hq is no longer held, after 3 min", openFor: 3 * time.Minute,
|
||||
@@ -165,6 +165,9 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te
|
||||
h.Zone = func() *time.Location { return brussels }
|
||||
var asked []string
|
||||
h.Ask = func(address string, body any) (json.RawMessage, error) {
|
||||
if address == TrackingVerb {
|
||||
return json.RawMessage(`{"conditions":{}}`), nil // no issue tracks these
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
asked = append(asked, address+" "+string(raw))
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
|
||||
@@ -83,7 +83,8 @@
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.conditions",
|
||||
"seat:mesh-controller.root-free"
|
||||
"seat:mesh-controller.root-free",
|
||||
"seat:issue-tracker.tracking"
|
||||
],
|
||||
"state": [
|
||||
"open",
|
||||
|
||||
Reference in New Issue
Block a user