diff --git a/cmd/mesh-controller/delivery_conditions.go b/cmd/mesh-controller/delivery_conditions.go index 4fe74503..335635d9 100644 --- a/cmd/mesh-controller/delivery_conditions.go +++ b/cmd/mesh-controller/delivery_conditions.go @@ -37,10 +37,13 @@ type stalledLine struct { // Number is the pull request's, for a line of a head the forge never announced (novox/hq issue 347). Number int `json:"number,omitempty"` State string `json:"state"` - For string `json:"for"` - Bound string `json:"bound"` - H2 string `json:"h2"` - Says string `json:"says"` + // 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"` } // operatorsOnly is whether the table leaves H2 nothing to do for the line: the state is the operator's. diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 9fe68c35..66e3da98 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -828,6 +828,9 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio "to check it: the forge never announced it. It cannot merge until it is checked.", pull, long), fmt.Sprintf("%s has a merge check now, or is closed", pull), needs, nil } + if l.State == "unanswered" { + return unansweredWords(l, o) + } held := l.State if held == "" { held = "held" @@ -857,6 +860,38 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio fmt.Sprintf("Delivery of %s is no longer %s", name, held), needs, actions } +// unansweredWords are the plain words of a pull request's head whose merge check was asked and has not answered +// within its bound (novox/hq issue 438): mesh-delivery holds no delivery of it, so there is nothing to stop, release +// or close, and no action is offered. The operator's acts are a new commit, which asks the check again, or a look +// at the build seat's queue, where a check that never ran may still wait. +func unansweredWords(l stalledLine, o conditions.Observation) (headline, explanation, resolved, needs string, + actions []conditions.Action) { + repository, _, _ := strings.Cut(l.ID, "@") + pull := "A pull request of " + repoName(repository) + if l.Number > 0 { + pull = fmt.Sprintf("Pull request %s #%d", repoName(repository), l.Number) + } + long, limit := "for too long", "" + if d, err := time.ParseDuration(l.For); err == nil { + long = "for " + humanDuration(d) + } + if d, err := time.ParseDuration(l.Bound); err == nil { + 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 o.Resolver == conditions.ResolverOperator { + needs = "push a new commit to its branch, which asks the check again, or see whether the check still waits " + + "in the build seat's queue: mesh-controller.queue." + } + return fmt.Sprintf("%s has a merge check that has not answered %s", pull, long), + fmt.Sprintf("%s is open on a branch that requires the merge check. %s was asked and has not answered %s%s. "+ + "It cannot merge until its check answers.", pull, which, long, limit), + fmt.Sprintf("%s has an answer from its merge check, or is closed", pull), needs, nil +} + // 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 cd180050..35396062 100644 --- a/cmd/mesh-controller/plain_words_test.go +++ b/cmd/mesh-controller/plain_words_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "regexp" "strings" "testing" @@ -347,3 +348,58 @@ func TestAnUnannouncedPullRequestSaysToPushANewCommit(t *testing.T) { t.Fatalf("it reads %q / %q", o.Headline, o.Explanation) } } + +// **A pull request whose merge check never answered says which check, since when, and what to do** (novox/hq issue +// 438): mesh-delivery says one as a stalled line in state `unanswered`, the line below exactly as its test makes it. +// No delivery of the head is held, so there is nothing to stop, release or close: no action is offered, and the +// operator's acts are a new commit, which asks the check again, or a look at the build seat's queue. +func TestAnUnansweredMergeCheckSaysWhichCheckAndWhatToDo(t *testing.T) { + 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"],"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) + } + obs := stalledObservations([]stalledLine{l}) + if len(obs) != 1 || obs[0].Resolver != conditions.ResolverOperator { + t.Fatalf("an unanswered merge check is not the operator's: %+v", obs) + } + o := obs[0] + t.Logf("headline: %s\nexplanation: %s\nneeds: %s\nresolved: %s", o.Headline, o.Explanation, o.Needs, o.Resolved) + if len(o.Actions) != 0 { + t.Fatalf("it offers an action a head with no delivery cannot take: %+v", o.Actions) + } + if strings.Contains(o.Needs, "stop") || strings.Contains(o.Needs, "release") || !strings.Contains(o.Needs, "commit") || + !strings.Contains(o.Needs, "mesh-controller.queue") { + t.Fatalf("it says to %q", o.Needs) + } + if !strings.Contains(o.Headline, "#212") || !strings.Contains(o.Headline, "merge check") || + strings.Contains(o.Headline, "Delivery of") { + t.Fatalf("its headline reads %q", o.Headline) + } + for _, want := range []string{"mesh/merge-gate pending since 2026-10-11T00:11:39Z", "mesh/repo-check never set", + "past its limit of 60 minutes"} { + if !strings.Contains(o.Explanation, want) { + t.Fatalf("its explanation does not say %q: %q", want, o.Explanation) + } + } + if strings.Contains(o.Explanation, "never asked") || strings.Contains(o.Explanation, "never announced") { + t.Fatalf("it says the mesh was never asked, of a head whose check started: %q", o.Explanation) + } + + // A line from a mesh-delivery that names no checks still says the check did not answer, and offers nothing. + var bare stalledLine + if err := json.Unmarshal([]byte(`{"id":"novox/mesh-controller@c11222026a3b","number":212,"state":"unanswered",`+ + `"for":"1h1m0s","bound":"1h0m0s","h2":"none: no delivery of it is held here, so there is none to close — the operator's"}`), + &bare); err != nil { + t.Fatal(err) + } + o = stalledObservations([]stalledLine{bare})[0] + if len(o.Actions) != 0 || !strings.Contains(o.Explanation, "has not answered") || strings.Contains(o.Headline, "Delivery of") { + t.Fatalf("a line naming no checks reads %q / %q, actions %+v", o.Headline, o.Explanation, o.Actions) + } +}