diff --git a/cmd/mesh-controller/delivery_conditions.go b/cmd/mesh-controller/delivery_conditions.go index 335635d9..adaa1e29 100644 --- a/cmd/mesh-controller/delivery_conditions.go +++ b/cmd/mesh-controller/delivery_conditions.go @@ -40,12 +40,27 @@ type stalledLine struct { // Waiting are the merge checks a line of the state `unanswered` waits on, as mesh-delivery says each: // "mesh/merge-gate pending since ", "mesh/repo-check never set" (novox/hq issue 438). Waiting []string `json:"waiting,omitempty"` - For string `json:"for"` - Bound string `json:"bound"` - H2 string `json:"h2"` - Says string `json:"says"` + // Checks are the same merge checks as data, which the controller words in the operator's own time (novox/hq + // issue 443): a time inside a finished sentence cannot be said again in another zone. A mesh-delivery from before + // says none, and Waiting is said as it reads. + Checks []waitingCheck `json:"checks,omitempty"` + For string `json:"for"` + Bound string `json:"bound"` + H2 string `json:"h2"` + Says string `json:"says"` } +// waitingCheck is one merge check a stalled line waits on, as mesh-delivery gives it: its context, its state — +// "pending", or "never-set" — and, for a pending one, since when in RFC 3339, empty when the forge did not say. +type waitingCheck struct { + Context string `json:"context"` + State string `json:"state"` + Since string `json:"since,omitempty"` +} + +// wordsNow is the time the words are said at, which says whether a time needs its day; a seam a test replaces. +var wordsNow = time.Now + // operatorsOnly is whether the table leaves H2 nothing to do for the line: the state is the operator's. func (l stalledLine) operatorsOnly() bool { return l.H2 == "" || strings.HasPrefix(l.H2, "none") } diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index be6165cd..154e156d 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -879,8 +879,8 @@ func unansweredWords(l stalledLine, o conditions.Observation) (headline, explana limit = ", past its limit of " + humanDuration(d) } which := "Its merge check" - if len(l.Waiting) > 0 { - which = "Its merge check (" + strings.Join(l.Waiting, ", ") + ")" + if said := uncheckedWaitWords(l); len(said) > 0 { + which = "Its merge check (" + strings.Join(said, ", ") + ")" } if o.Resolver == conditions.ResolverOperator { needs = "push a new commit to its branch, which asks the check again, or see whether its check still waits " + @@ -892,6 +892,42 @@ func unansweredWords(l stalledLine, o conditions.Observation) (headline, explana fmt.Sprintf("%s has an answer from its merge check, or is closed", pull), needs, nil } +// uncheckedWaitWords are the merge checks an unanswered line waits on, as the operator reads them: from the checks +// given as data, each time in the controller's local zone, as every other time in a message — "mesh/merge-gate +// pending since 02:11" — never the UTC time inside mesh-delivery's finished words, which cannot be said again in +// another zone (novox/hq issue 443). A line from a mesh-delivery that gives no such data is said by its words. +func uncheckedWaitWords(l stalledLine) []string { + if len(l.Checks) == 0 { + return l.Waiting + } + out := make([]string, 0, len(l.Checks)) + for _, c := range l.Checks { + switch c.State { + case "never-set": + out = append(out, c.Context+" never set") + case "pending": + out = append(out, c.Context+" pending"+sinceWords(c.Since)) + default: + out = append(out, c.Context+" not answered") + } + } + return out +} + +// sinceWords is " since 02:11" in the controller's local zone, with its day when that is not today ("since 23:05 on +// 9 Oct"), so the time stays absolute; a time not given, or not readable, is said so and never made up. +func sinceWords(since string) string { + at, err := time.Parse(time.RFC3339, since) + if err != nil { + return ", since a time the forge did not say" + } + local, today := at.Local(), wordsNow().Local() + if local.YearDay() == today.YearDay() && local.Year() == today.Year() { + return " since " + local.Format("15:04") + } + return " since " + local.Format("15:04 on 2 Jan") +} + // causeWords is a hand-act's cause as a person says it. func causeWords(cause string) string { return strings.NewReplacer(".", " ", "_", " ").Replace(cause) diff --git a/cmd/mesh-controller/plain_words_test.go b/cmd/mesh-controller/plain_words_test.go index fa821e1a..a31db03a 100644 --- a/cmd/mesh-controller/plain_words_test.go +++ b/cmd/mesh-controller/plain_words_test.go @@ -402,3 +402,51 @@ func TestAnUnansweredMergeCheckSaysWhichCheckAndWhatToDo(t *testing.T) { t.Fatalf("a line naming no checks reads %q / %q, actions %+v", o.Headline, o.Explanation, o.Actions) } } + +// **An unanswered merge check's time reaches the operator in their own time, never as raw UTC** (novox/hq issue +// 443): mesh-delivery says each check waited on as data — its context, its state and since when, in RFC 3339 — beside +// the finished words it gave before, and the controller words it in its local zone, as every other time in a message. +// The line is the one mesh-delivery says, as in the report of issue 443, with the checks it now carries. +func TestAnUnansweredMergeChecksTimeIsSaidInLocalTime(t *testing.T) { + was, wasNow := time.Local, wordsNow + time.Local = time.FixedZone("CEST", 2*60*60) + wordsNow = func() time.Time { return time.Date(2026, 10, 11, 1, 12, 39, 0, time.UTC) } + t.Cleanup(func() { time.Local, wordsNow = was, wasNow }) + + var l stalledLine + if err := json.Unmarshal([]byte(`{"id":"novox/mesh-controller@c11222026a3b","number":212,"state":"unanswered",`+ + `"waiting":["mesh/merge-gate pending since 2026-10-11T00:11:39Z","mesh/repo-check never set"],`+ + `"checks":[{"context":"mesh/merge-gate","state":"pending","since":"2026-10-11T00:11:39Z"},`+ + `{"context":"mesh/repo-check","state":"never-set"}],"for":"1h1m0s",`+ + `"bound":"1h0m0s","h2":"none: no delivery of it is held here, so there is none to close — the operator's",`+ + `"says":"novox/mesh-controller#212 is open on main, which requires the merge check, and its head's check `+ + `started and never answered: mesh/merge-gate pending since 2026-10-11T00:11:39Z, mesh/repo-check never set. `+ + `No delivery of it is held here, so nothing asks it again. A new commit on its branch announces it and asks `+ + `its check"}`), &l); err != nil { + t.Fatal(err) + } + o := stalledObservations([]stalledLine{l})[0] + t.Logf("explanation: %s", o.Explanation) + for _, raw := range []string{"2026-10-11T00:11:39Z", "00:11", "UTC"} { + if strings.Contains(o.Explanation, raw) || strings.Contains(o.Headline, raw) { + t.Fatalf("the operator reads %q: %q / %q", raw, o.Headline, o.Explanation) + } + } + for _, want := range []string{"mesh/merge-gate pending since 02:11", "mesh/repo-check never set"} { + if !strings.Contains(o.Explanation, want) { + t.Fatalf("its explanation does not say %q: %q", want, o.Explanation) + } + } + + // A check pending since another day than today says which day, so the time stays absolute. + l.Checks[0].Since = "2026-10-09T21:05:00Z" + if o = stalledObservations([]stalledLine{l})[0]; !strings.Contains(o.Explanation, "mesh/merge-gate pending since 23:05 on 9 Oct") { + t.Fatalf("a check pending since an earlier day reads %q", o.Explanation) + } + + // A pending check whose time the forge did not say is said so, with no time made up. + l.Checks[0].Since = "" + if o = stalledObservations([]stalledLine{l})[0]; !strings.Contains(o.Explanation, "mesh/merge-gate pending, since a time the forge did not say") { + t.Fatalf("a pending check without its time reads %q", o.Explanation) + } +}