diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md index 197ffeae..be0160e4 100644 --- a/modules/mesh-issues/README.md +++ b/modules/mesh-issues/README.md @@ -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`, diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index f1730e89..8435c8e4 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -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 resolved 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) + }}, } } diff --git a/modules/mesh-issues/cmd/mesh-issues/tracking.go b/modules/mesh-issues/cmd/mesh-issues/tracking.go new file mode 100644 index 00000000..aaf8b9a7 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/tracking.go @@ -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 +} diff --git a/modules/mesh-issues/cmd/mesh-issues/tracking_test.go b/modules/mesh-issues/cmd/mesh-issues/tracking_test.go new file mode 100644 index 00000000..e34d7e87 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/tracking_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "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) + } + out, err := h.call(t, "tracking", map[string]any{}) + if err != nil { + t.Fatal(err) + } + 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") + } +} diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json index 7659a40c..37950085 100644 --- a/modules/mesh-issues/module.json +++ b/modules/mesh-issues/module.json @@ -15,7 +15,8 @@ "link", "import", "groom", - "set" + "set", + "tracking" ] } ], @@ -32,7 +33,8 @@ "link", "import", "groom", - "set" + "set", + "tracking" ] } ], diff --git a/modules/messenger/README.md b/modules/messenger/README.md index bf4771ff..cc76a080 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -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 diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 4aaaa9bf..298698b2 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -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 { diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 6484fc2a..abc3fd05 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -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 diff --git a/modules/messenger/cmd/messenger/tracking.go b/modules/messenger/cmd/messenger/tracking.go new file mode 100644 index 00000000..b71f2176 --- /dev/null +++ b/modules/messenger/cmd/messenger/tracking.go @@ -0,0 +1,338 @@ +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 + h.mu.Unlock() + if !tried.IsZero() && now.Sub(tried) >= 0 && now.Sub(tried) < TrackingAskEvery { + return + } + 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) + explanation, since, _ := strings.Cut(m.Body, "\n") + if !strings.Contains(m.Body, "\n") { + explanation, since = "", m.Body + } + 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 ns []string + for _, is := range issues { + if waitsForOperator(is) { + ns = append(ns, fmt.Sprint(is.Number)) + } + } + 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 := sorted[i].Status == "resolved", sorted[j].Status == "resolved" + 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" + } + s := "nobody is working on it" + if is.Worker != "" && is.Active != nil { + s = "nobody has worked on it since " + when(*is.Active, now, loc) + } + if is.Needs == "decision" { + s += "; it waits for a decision first" + } + return s +} + +// 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] +} diff --git a/modules/messenger/cmd/messenger/tracking_test.go b/modules/messenger/cmd/messenger/tracking_test.go new file mode 100644 index 00000000..336fbd23 --- /dev/null +++ b/modules/messenger/cmd/messenger/tracking_test.go @@ -0,0 +1,217 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +// The alert says which issue tracks its condition 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 TestAnAlertSaysWhereTheIssueThatTracksItStands(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"}, + {"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 TestTheRouterAsksTheTrackerAndSaysItsWordInTheAlert(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) + } +} diff --git a/modules/messenger/cmd/messenger/words_test.go b/modules/messenger/cmd/messenger/words_test.go index 99cd60e6..f646a7cc 100644 --- a/modules/messenger/cmd/messenger/words_test.go +++ b/modules/messenger/cmd/messenger/words_test.go @@ -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 diff --git a/modules/messenger/module.json b/modules/messenger/module.json index aa8bd5d8..1c426333 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -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",