From 63dba87587c069acdddd3298e2fa9b1b61c2ef36 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 21:44:53 +0200 Subject: [PATCH] Say in a condition's message which issue tracks it and where that issue stands (issue 400) The operator could not tell from a notification whether its cause was known, tracked and being fixed, or whether they had to act. The issue tracker gains the verb tracking, answering for every condition key an issue links which issues link it and where each stands; the operator channel's router asks it outside the work and says one line from it in every condition's message, per hq ADR 0285. --- modules/mesh-issues/README.md | 1 + modules/mesh-issues/cmd/mesh-issues/main.go | 23 +- .../mesh-issues/cmd/mesh-issues/tracking.go | 148 ++++++++ .../cmd/mesh-issues/tracking_test.go | 159 ++++++++ modules/mesh-issues/module.json | 6 +- modules/messenger/README.md | 9 + modules/messenger/cmd/messenger/holder.go | 24 +- .../messenger/cmd/messenger/manifest_test.go | 5 +- modules/messenger/cmd/messenger/tracking.go | 338 ++++++++++++++++++ .../messenger/cmd/messenger/tracking_test.go | 217 +++++++++++ modules/messenger/cmd/messenger/words_test.go | 11 +- modules/messenger/module.json | 3 +- 12 files changed, 931 insertions(+), 13 deletions(-) create mode 100644 modules/mesh-issues/cmd/mesh-issues/tracking.go create mode 100644 modules/mesh-issues/cmd/mesh-issues/tracking_test.go create mode 100644 modules/messenger/cmd/messenger/tracking.go create mode 100644 modules/messenger/cmd/messenger/tracking_test.go 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",