diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index 8435c8e4..6328124a 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -604,7 +604,7 @@ func Tools(t *Tracker) []stdio.Tool { }}, {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, " + + "or only the keys given, the issues that link it, those not closed (resolved or wontfix) first, each with its status, " + "severity, what it needs, who works on it since when and whether they went quiet (stale), and where its " + "fix is (fix: not-merged, merged, held, failed, unheard or delivered; empty when none is named). The " + "operator's channel asks it to say in a condition's message which issue tracks it. Refused: a key not of a condition " + diff --git a/modules/messenger/cmd/messenger/tracking.go b/modules/messenger/cmd/messenger/tracking.go index b71f2176..eeea1f24 100644 --- a/modules/messenger/cmd/messenger/tracking.go +++ b/modules/messenger/cmd/messenger/tracking.go @@ -115,10 +115,12 @@ func (h *Holder) warmTracking() { now := h.Now() h.mu.Lock() tried := h.track.tried - h.mu.Unlock() if !tried.IsZero() && now.Sub(tried) >= 0 && now.Sub(tried) < TrackingAskEvery { + h.mu.Unlock() return } + h.track.tried = now // marked before asking, so two callers at once ask once + h.mu.Unlock() got, err := h.askTracking() h.mu.Lock() defer h.mu.Unlock() @@ -162,9 +164,10 @@ func withTracking(m Message, r *Record, what string, issues []TrackedIssue, know 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 + // since is the last line, which never holds a line break; the explanation is everything before it. + explanation, since := "", m.Body + if i := strings.LastIndex(m.Body, "\n"); i >= 0 { + explanation, since = m.Body[:i], m.Body[i+1:] } if rest, ok := strings.CutPrefix(explanation, verdictNothing); ok { switch verdict { @@ -186,12 +189,17 @@ func withTracking(m Message, r *Record, what string, issues []TrackedIssue, know // needsAnswer is the "Needs you:" sentence for the issues that wait for the operator's answer. func needsAnswer(issues []TrackedIssue) string { - var ns []string + var nums []uint64 for _, is := range issues { if waitsForOperator(is) { - ns = append(ns, fmt.Sprint(is.Number)) + nums = append(nums, is.Number) } } + sort.Slice(nums, func(i, j int) bool { return nums[i] < nums[j] }) + var ns []string + for _, n := range nums { + ns = append(ns, fmt.Sprint(n)) + } if len(ns) == 1 { return "answer issue " + ns[0] + "." } @@ -209,7 +217,7 @@ func trackingWords(issues []TrackedIssue, known bool, now time.Time, loc *time.L } sorted := append([]TrackedIssue(nil), issues...) sort.SliceStable(sorted, func(i, j int) bool { - ri, rj := sorted[i].Status == "resolved", sorted[j].Status == "resolved" + ri, rj := closed(sorted[i]), closed(sorted[j]) if ri != rj { return !ri } @@ -307,14 +315,13 @@ func issueState(is TrackedIssue, now time.Time, loc *time.Location) string { 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) + return "nobody has worked on it since " + when(*is.Active, now, loc) } if is.Needs == "decision" { - s += "; it waits for a decision first" + return "nobody is working on it; it waits for a decision first" } - return s + return "nobody is working on it" } // workerWords is who@node as said: "fix-400 on g14". diff --git a/modules/messenger/cmd/messenger/tracking_test.go b/modules/messenger/cmd/messenger/tracking_test.go index 336fbd23..47d2364a 100644 --- a/modules/messenger/cmd/messenger/tracking_test.go +++ b/modules/messenger/cmd/messenger/tracking_test.go @@ -8,7 +8,7 @@ import ( "time" ) -// The alert says which issue tracks its condition and where that issue stands (novox/hq issue 400, ADR 0285): +// A condition's message says which issue tracks it and where that issue stands (novox/hq issue 400, ADR 0285): // one case per wording, each through the message as composed, and the content rule passes every line. var trackNow = time.Date(2026, 10, 10, 19, 30, 0, 0, time.UTC) @@ -29,7 +29,7 @@ func trackedMessage(issues []TrackedIssue, known bool) Message { return withTracking(m, r, "quiet-reminder", issues, known, trackNow, time.UTC) } -func TestAnAlertSaysWhereTheIssueThatTracksItStands(t *testing.T) { +func TestAConditionsMessageSaysWhereTheIssueThatTracksItStands(t *testing.T) { cases := []struct { name string issues []TrackedIssue @@ -65,6 +65,11 @@ func TestAnAlertSaysWhereTheIssueThatTracksItStands(t *testing.T) { "Tracked as issue 386: it was resolved, yet this still happens.", "A person repaired"}, {"it will not be fixed", []TrackedIssue{{Number: 386, Status: "wontfix"}}, true, "Tracked as issue 386: it will not be fixed.", "A person repaired"}, + {"one not fixed after one open", []TrackedIssue{{Number: 100, Status: "wontfix"}, {Number: 200, Status: "open"}}, true, + "Tracked as issue 200: nobody is working on it. Also as issue 100: it will not be fixed.", "A person repaired"}, + {"its worker went quiet on one that waits for a decision", []TrackedIssue{{Number: 400, Status: "fixing", Needs: "decision", + Worker: "fix-400@g14", Since: at(9, 0), Active: at(9, 30), Stale: true}}, true, + "Tracked as issue 400: nobody has worked on it since 09:30.", "A person repaired"}, {"it waits for the operator", []TrackedIssue{{Number: 336, Status: "open", Needs: "operator"}}, true, "Tracked as issue 336: it waits for your answer.", "Needs you: answer issue 336. A person repaired"}, {"it waits for a decision", []TrackedIssue{{Number: 400, Status: "open", Needs: "decision"}}, true, @@ -152,7 +157,7 @@ func TestAClearingASilenceAndANoticeSayNoIssue(t *testing.T) { // 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) { +func TestTheRouterAsksTheTrackerAndSaysItsWordInTheMessage(t *testing.T) { h, tg, dt, c, _ := newHolder(t) h.Zone = func() *time.Location { return time.UTC } asked := 0