diff --git a/modules/mesh-issues/cmd/mesh-issues/claims_test.go b/modules/mesh-issues/cmd/mesh-issues/claims_test.go new file mode 100644 index 00000000..aee2f4e6 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/claims_test.go @@ -0,0 +1,307 @@ +package main + +// Tests of novox/hq issue 483: an issue whose fix is delivered stays in fixing, and a claim nobody works on is +// never noticed. The issues the operator found (231, 402, 457, …) named their fix the way the live tracker keeps +// it: a link of kind pull-request, by the worker, while the issue stayed in fixing, with fixed-by given only at +// the resolve (issue-tracker.get 457, 2026-10-11). + +import ( + "encoding/json" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// asJSON is a verb's answer as the tool runner hands it on: what a caller reads, whatever the Go types are. +func asJSON(t *testing.T, v any) map[string]any { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +// linkPR links a pull request the way the live issues do: kind pull-request, target #. +func linkPR(t *testing.T, h *harness, n uint64, target string) { + t.Helper() + if _, err := h.call(t, "link", map[string]any{"number": n, "kind": "pull-request", "target": target}); err != nil { + t.Fatal(err) + } +} + +func rowsOf(t *testing.T, h *harness, args map[string]any) []map[string]any { + t.Helper() + v, err := h.call(t, "list", args) + if err != nil { + t.Fatal(err) + } + var out []map[string]any + for _, r := range asJSON(t, v)["rows"].([]any) { + out = append(out, r.(map[string]any)) + } + return out +} + +// Part 1: when every pull request linked to an issue in fixing is delivered, the issue gains one note naming +// them, when each was delivered, and its worker, and the event ready-to-verify names the worker. +func TestALinkedFixDeliveredIsNotedOnceNamingItsWorker(t *testing.T) { + h := newHarness(t) + now := clocked(h) + n := h.open(t, "a build fails during the registry's clean-up", "mesh-controller") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-457@shanks"}); err != nil { + t.Fatal(err) + } + linkPR(t, h, n, "mesh-catalog#225") + linkPR(t, h, n, "mesh-catalog#226") + active := get(t, h, n).Worker.Active + merged := now.Add(-20 * time.Minute) + transition(t, h, 225, "published", "", "abc", merged) + if v, _ := h.View(n); v.Delivery != "fix merged, not delivered: mesh-catalog#225" { + t.Fatalf("a linked pull request's delivery is not measured: %q", v.Delivery) + } + delivered := now.Add(-10 * time.Minute) + transition(t, h, 225, "delivered", "", "abc", delivered) + if countEvents(h, EventReadyToVerify) != 0 { + t.Fatal("said delivered while mesh-catalog#226 is not") + } + transition(t, h, 226, "delivered", "", "def", delivered.Add(time.Minute)) + transition(t, h, 226, "delivered", "", "def", delivered.Add(time.Minute)) + is := get(t, h, n) + var said []string + for _, nt := range is.Notes { + if strings.HasPrefix(nt.Text, "Ready to verify:") { + said = append(said, nt.Text) + } + } + if len(said) != 1 { + t.Fatalf("want one note, got %q", said) + } + for _, want := range []string{"mesh-catalog#225", "mesh-catalog#226", delivered.Format(time.RFC3339), + delivered.Add(time.Minute).Format(time.RFC3339), "fix-457@shanks"} { + if !strings.Contains(said[0], want) { + t.Fatalf("the note does not say %q: %s", want, said[0]) + } + } + if is.Status != StatusFixing || is.Worker == nil || is.Worker.By != "fix-457@shanks" { + t.Fatalf("the tracker's own note changed the issue's work: %+v", is) + } + var ev map[string]any + for _, e := range h.said() { + if e.Event == EventReadyToVerify { + ev = e.Body + } + } + if countEvents(h, EventReadyToVerify) != 1 || ev["worker"] != "fix-457@shanks" { + t.Fatalf("%d ready-to-verify, last %v", countEvents(h, EventReadyToVerify), ev) + } + // The tracker's own note keeps no worker current: the worker's activity is still its own last act. + if !is.Worker.Active.Equal(active) { + t.Fatalf("the worker was made active by the tracker's note: %v", is.Worker.Active) + } +} + +// Part 2: past the bound, an issue in fixing whose every linked fix is delivered is warned on the operator's +// channel, once, naming the issue and its worker; the warning clears when the issue leaves fixing. +func TestDeliveredNotResolvedIsWarnedAfterTheBoundAndClearedWhenItLeavesFixing(t *testing.T) { + h := newHarness(t) + now := clocked(h) + note := ¬ified{} + h.Notify = note.notify + n := h.open(t, "x", "mesh-catalog modules/records") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-x@g14"}); err != nil { + t.Fatal(err) + } + linkPR(t, h, n, "mesh-catalog#9") + transition(t, h, 9, "delivered", "", "abc", *now) + *now = now.Add(59 * time.Minute) + if _, err := h.Sweep(); err != nil || len(note.said) != 0 { + t.Fatalf("warned under the bound: %q %v", note.said, err) + } + if rows := rowsOf(t, h, map[string]any{"flag": "delivered-not-resolved"}); len(rows) != 1 || + rows[0]["delivered-not-resolved"] != true || rows[0]["worker"] != "fix-x@g14" { + t.Fatalf("list flag delivered-not-resolved: %v", rows) + } + *now = now.Add(2 * time.Minute) + for i := 0; i < 2; i++ { + if _, err := h.Sweep(); err != nil { + t.Fatal(err) + } + } + key := "issue-tracker.delivered-not-resolved.365" + if len(note.said) != 1 || !strings.HasPrefix(note.said[0], key+" warning ") || + !strings.Contains(note.said[0], "fixed and delivered, not resolved") || !strings.Contains(note.said[0], "fix-x@g14") || + !strings.Contains(note.said[0], "365") { + t.Fatalf("said %q", note.said) + } + if v := asJSON(t, mustView(t, h, n)); v["delivered-not-resolved"] != true { + t.Fatalf("get does not say it: %v", v["delivered-not-resolved"]) + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, FixedBy: []string{"mesh-catalog#9"}}); err != nil { + t.Fatal(err) + } + if err := h.OnMoved(envelopeOf(n)); err != nil { + t.Fatal(err) + } + if len(note.clear) != 1 || note.clear[0] != key { + t.Fatalf("not cleared when it left fixing: %q", note.clear) + } + if rows := rowsOf(t, h, map[string]any{"flag": "delivered-not-resolved"}); len(rows) != 0 { + t.Fatalf("a resolved issue still listed: %v", rows) + } + // The setting's bound is read. + h2 := newHarness(t) + now2 := clocked(h2) + n2 := ¬ified{} + h2.Notify = n2.notify + // Set as the operator's settings file gives it. + if err := json.Unmarshal([]byte(`{"delivered-not-resolved-after": "10m"}`), &h2.settings); err != nil { + t.Fatal(err) + } + m := h2.open(t, "y", "mesh-catalog modules/records") + if _, err := h2.Move(MoveAsk{Number: m, Status: StatusFixing, By: "fix-y@g14"}); err != nil { + t.Fatal(err) + } + linkPR(t, h2, m, "mesh-catalog#9") + transition(t, h2, 9, "delivered", "", "abc", *now2) + *now2 = now2.Add(11 * time.Minute) + if _, err := h2.Sweep(); err != nil || len(n2.said) != 1 { + t.Fatalf("the setting's bound was not read: %q %v", n2.said, err) + } +} + +// A warning the channel did not take is said at the next sweep, and an issue's own status is what clears it at +// the sweep too, when the moved event was not heard. +func TestDeliveredNotResolvedIsRetriedAndClearedAtTheSweep(t *testing.T) { + h := newHarness(t) + now := clocked(h) + note := ¬ified{err: errStuck} + h.Notify = note.notify + n := h.open(t, "x", "mesh-catalog modules/records") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-x@g14"}); err != nil { + t.Fatal(err) + } + linkPR(t, h, n, "mesh-catalog#9") + transition(t, h, 9, "delivered", "", "abc", *now) + *now = now.Add(2 * time.Hour) + _, _ = h.Sweep() + note.err = nil + _, _ = h.Sweep() + if len(note.said) != 1 { + t.Fatalf("not said at the next sweep: %q", note.said) + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusLocated}); err != nil { + t.Fatal(err) + } + _, _ = h.Sweep() + if len(note.clear) != 1 { + t.Fatalf("not cleared at the sweep: %q", note.clear) + } +} + +// Part 3: an issue in fixing with no open linked pull request and nothing done on it for a day is a stale claim, +// naming its worker; a new worker takes it over without a reason, and the tracker says the claim was stale. +func TestAStaleClaimIsFlaggedAndTakenOverWithoutAReason(t *testing.T) { + h := newHarness(t) + now := clocked(h) + n := h.open(t, "claimed and left", "hq") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "build-424@shanks"}); err != nil { + t.Fatal(err) + } + busy := h.open(t, "claimed, with a pull request in review", "hq") + if _, err := h.Move(MoveAsk{Number: busy, Status: StatusFixing, By: "fix-b@g14"}); err != nil { + t.Fatal(err) + } + linkPR(t, h, busy, "mesh-catalog#9") + transition(t, h, 9, "ready", "its merge", "", *now) + *now = now.Add(23 * time.Hour) + if rows := rowsOf(t, h, map[string]any{"flag": "stale-claim"}); len(rows) != 0 { + t.Fatalf("stale before the day: %v", rows) + } + *now = now.Add(2 * time.Hour) + rows := rowsOf(t, h, map[string]any{"flag": "stale-claim"}) + if len(rows) != 1 || rows[0]["number"] != float64(n) || rows[0]["worker"] != "build-424@shanks" || rows[0]["stale-claim"] != true { + t.Fatalf("want only %d flagged naming its worker, got %v", n, rows) + } + if v := asJSON(t, mustView(t, h, n)); v["stale-claim"] != true { + t.Fatalf("get does not say it: %v", v) + } + if v := asJSON(t, mustView(t, h, busy)); v["stale-claim"] == true { + t.Fatal("an issue with an open pull request is flagged") + } + m, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-483@shanks"}) + if err != nil { + t.Fatalf("a stale claim was not taken over without a reason: %v", err) + } + if a := asJSON(t, m); a["stale-claim"] != true || !strings.Contains(m.Said, "stale claim") { + t.Fatalf("the answer does not say the claim was stale: %v", a) + } + is := get(t, h, n) + last := is.Notes[len(is.Notes)-1].Text + if !strings.Contains(last, "stale claim") || !strings.Contains(last, "build-424@shanks") { + t.Fatalf("the note does not say the claim was stale: %s", last) + } + for _, e := range h.said() { + if e.Event == EventMoved && e.Body["took-over-from"] == "build-424@shanks" && e.Body["stale-claim"] != true { + t.Fatalf("the moved event does not say the claim was stale: %v", e.Body) + } + } +} + +// Part 4: list refuses a flag it does not know, naming the two it does. +func TestListRefusesAnUnknownFlag(t *testing.T) { + h := newHarness(t) + _, err := h.call(t, "list", map[string]any{"flag": "stuck"}) + if err == nil || !strings.Contains(err.Error(), "delivered-not-resolved") || !strings.Contains(err.Error(), "stale-claim") { + t.Fatalf("want refused naming the flags, got %v", err) + } +} + +// The sweep asks the delivery of a linked pull request it never heard of, a few per sweep, so an issue whose +// pull request merged before it was linked (as the operator's thirteen did) is measured at all. +func TestTheSweepAsksALinkedPullRequestNeverHeardOf(t *testing.T) { + h := newHarness(t) + now := clocked(h) + n := h.open(t, "x", "mesh-catalog modules/records") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusFixing, By: "fix-x@g14"}); err != nil { + t.Fatal(err) + } + linkPR(t, h, n, "mesh-catalog#209") + var asked []string + h.Deliveries = func(repo, number string) (Delivery, error) { + asked = append(asked, repo+"#"+number) + return Delivery{State: "delivered", Since: now.Add(-3 * time.Hour)}, nil + } + if _, err := h.Sweep(); err != nil { + t.Fatal(err) + } + if len(asked) != 1 || asked[0] != "mesh-catalog#209" { + t.Fatalf("asked %q", asked) + } + if v, _ := h.View(n); v.Delivery != "delivered to every machine it should reach" || countEvents(h, EventReadyToVerify) != 1 { + t.Fatalf("%q, %d ready-to-verify", v.Delivery, countEvents(h, EventReadyToVerify)) + } + if _, err := h.Sweep(); err != nil || len(asked) != 1 { + t.Fatalf("asked again once heard: %q %v", asked, err) + } +} + +func envelopeOf(n uint64) stdio.Envelope { + raw, _ := json.Marshal(map[string]any{"number": n}) + return stdio.Envelope{Key: "mesh-issues.moved", Body: raw} +} + +func mustView(t *testing.T, h *harness, n uint64) *View { + t.Helper() + v, err := h.View(n) + if err != nil { + t.Fatal(err) + } + return v +}