Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/triage_test.go
T
jochen 01bde68f96
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh-issues: an issue knows whether its fix is delivered, says it ready to verify, and a fix stuck after its merge is said (hq ADR 0281)
The operator approved measuring from the delivery's own events whether each fix an
issue names reached the machines; delivered is not resolved, and a merged fix that
does not arrive is said on the issue and the operator's channel instead of found.
2026-10-10 18:12:52 +02:00

811 lines
31 KiB
Go

package main
import (
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// stubJudge answers the triage's two questions as a test says, and keeps every question asked. The verdict's
// estimates default to minor, none and no owner.
type stubJudge struct {
mu sync.Mutex
shortlist []uint64
verdict map[string]any
err error
asked []string
}
func (s *stubJudge) judge(question string, schema map[string]any, model string) (json.RawMessage, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.asked = append(s.asked, question)
if s.err != nil {
return nil, "", s.err
}
if _, second := schema["properties"].(map[string]any)["verdict"]; second {
v := map[string]any{"verdict": "none", "issue": 0, "certain": false, "reason": "", "severity": "minor",
"severity_reason": "a contained fix", "needs": "none", "needs_reason": "an agent can fix it", "owner": "",
"owner_reason": "", "depends_on": []any{}, "relates_to": []any{}}
for k, x := range s.verdict {
v[k] = x
}
raw, _ := json.Marshal(v)
return raw, "the agent on control (" + model + ")", nil
}
raw, _ := json.Marshal(map[string]any{"candidates": s.shortlist})
return raw, "the agent on control (" + model + ")", nil
}
func (s *stubJudge) questions() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.asked)
}
func withJudge(h *harness, s *stubJudge) *stubJudge {
h.Judge = s.judge
return s
}
func get(t *testing.T, h *harness, n uint64) *Issue {
t.Helper()
is, err := h.Get(n)
if err != nil {
t.Fatal(err)
}
return is
}
func triageNotes(is *Issue) []string {
var out []string
for _, n := range is.Notes {
if byTriage(n) {
out = append(out, n.Text)
}
}
return out
}
func resolve(t *testing.T, h *harness, n uint64, fixedBy ...string) {
t.Helper()
if len(fixedBy) == 0 {
fixedBy = []string{"mesh-catalog#1"}
}
if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, LocatedIn: []string{"mesh-catalog modules/records"},
FixedBy: fixedBy}); err != nil {
t.Fatal(err)
}
}
func events(h *harness, skip ...string) []string {
var out []string
for _, e := range h.said() {
if !contains(skip, e.Event) {
out = append(out, e.Event)
}
}
return out
}
func ptr[T any](v T) *T { return &v }
// A clear duplicate: the triage is kept first; the newer gets its values, is linked duplicates, its report kept
// on the older, and moves to wontfix "duplicate of N" last; the older stays open.
func TestAClearDuplicateIsClosedAndItsReportKeptOnTheOlder(t *testing.T) {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
other := h.open(t, "the desk shows nothing")
newer := h.open(t, "a number is skipped by the counter")
s := withJudge(h, &stubJudge{shortlist: []uint64{older}, verdict: map[string]any{
"verdict": "duplicate", "issue": older, "certain": true, "reason": "both say the counter skips a number"}})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n := get(t, h, newer)
if n.Status != StatusWontfix || n.Reason != "duplicate of 365" || n.Triage == nil || n.Severity != "minor" || n.Needs != "none" {
t.Fatalf("newer %s %q %+v %s %s", n.Status, n.Reason, n.Triage, n.Severity, n.Needs)
}
if len(n.Links) != 1 || n.Links[0].Kind != LinkDuplicates || n.Links[0].Target != "365" || n.Links[0].By != GroomBy {
t.Fatalf("links %+v", n.Links)
}
notes := triageNotes(n)
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Triage of this issue: duplicate of 365.") ||
!strings.Contains(notes[0], "the agent on control (sonnet)") || !strings.Contains(notes[0], "Severity: minor — a contained fix") {
t.Fatalf("newer's notes %q", notes)
}
o := get(t, h, older)
if o.Status != StatusOpen || len(o.Links) != 0 || o.Severity != "" {
t.Fatalf("the older moved, was linked or set: %s %+v", o.Status, o.Links)
}
notes = triageNotes(o)
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Triage: issue 367 was opened as a duplicate") ||
!strings.Contains(notes[0], "seen: a number is skipped by the counter") {
t.Fatalf("older's notes %q", notes)
}
if len(get(t, h, other).Notes) != 0 {
t.Fatal("an issue the verdict did not name was written")
}
if got := events(h, EventOpened); !reflect.DeepEqual(got, []string{EventTriaged, EventNoted, EventSet, EventLinked, EventNoted, EventMoved}) {
t.Fatalf("order %v", got)
}
if s.questions() != 2 {
t.Fatalf("%d questions", s.questions())
}
}
// Related, or a duplicate not certain: linked relates-to and noted on both; nothing moves.
func TestRelatedOrNotCertainIsLinkedAndNotedAndNothingMoves(t *testing.T) {
for _, v := range []map[string]any{
{"verdict": "related", "issue": 365, "certain": true, "reason": "the same counter, another symptom"},
{"verdict": "duplicate", "issue": 365, "certain": false, "reason": "it may be the same skip"},
} {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
newer := h.open(t, "the counter is slow")
withJudge(h, &stubJudge{shortlist: []uint64{older}, verdict: v})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n, o := get(t, h, newer), get(t, h, older)
if n.Status != StatusOpen || o.Status != StatusOpen {
t.Fatalf("%v: a status moved: %s %s", v, n.Status, o.Status)
}
if len(n.Links) != 1 || n.Links[0].Kind != LinkRelatesTo || n.Links[0].Target != "365" {
t.Fatalf("%v: links %+v", v, n.Links)
}
if nn := triageNotes(n); len(nn) != 1 || !strings.HasPrefix(nn[0], "Triage of this issue: not a duplicate; closest is 365.") ||
!strings.Contains(nn[0], "Relates to 365") {
t.Fatalf("%v: %q", v, nn)
}
if on := triageNotes(o); len(on) != 1 || !strings.HasPrefix(on[0], "Triage: issue 366 relates to this one") {
t.Fatalf("%v: %q", v, on)
}
}
}
// None: one note and the values; an empty shortlist still asks the second question, and the first issue of all
// asks only it.
func TestAnIssueThatRepeatsNothingGainsOneNoteAndItsValues(t *testing.T) {
h := newHarness(t)
a := h.open(t, "the counter skips a number")
b := h.open(t, "the desk shows nothing")
newer := h.open(t, "a backup took an hour")
s := withJudge(h, &stubJudge{verdict: map[string]any{"severity": "major", "needs": "operator", "needs_reason": "only the operator knows the backup window",
"owner": "mesh-catalog modules/records", "owner_reason": "it reads the record"}})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n := get(t, h, newer)
if n.Status != StatusOpen || len(n.Links) != 0 || n.Severity != "major" || n.Needs != "operator" ||
n.SuggestedOwner != "mesh-catalog modules/records" {
t.Fatalf("%+v", n)
}
if nn := triageNotes(n); len(nn) != 1 || !strings.HasPrefix(nn[0], "Triage of this issue: none of 2 older issues") ||
!strings.Contains(nn[0], "Needs: operator — only the operator knows") || !strings.Contains(nn[0], "Suggested owner: mesh-catalog modules/records") {
t.Fatalf("%q", nn)
}
if len(get(t, h, a).Notes)+len(get(t, h, b).Notes) != 0 || s.questions() != 2 {
t.Fatalf("older issues written, or %d questions", s.questions())
}
h2 := newHarness(t)
first := h2.open(t, "the first")
s2 := withJudge(h2, &stubJudge{verdict: map[string]any{"owner": "not-a-registered-owner"}})
if err := h2.Check(first); err != nil {
t.Fatal(err)
}
f := get(t, h2, first)
if nn := triageNotes(f); len(nn) != 1 || s2.questions() != 1 || f.Severity != "minor" || f.SuggestedOwner != "" {
t.Fatalf("%q, %d questions, %+v", nn, s2.questions(), f)
}
}
// The triage fills only what is empty: a value the issuer gave stands, and the note says the triage disagrees.
func TestAValueTheIssuerGaveStandsAndADisagreementIsNoted(t *testing.T) {
h := newHarness(t)
o, err := h.Open(OpenAsk{Title: "the controller loses a plan", Report: "seen: a plan vanished", Severity: "critical"})
if err != nil {
t.Fatal(err)
}
withJudge(h, &stubJudge{verdict: map[string]any{"severity": "minor", "severity_reason": "nothing is lost", "needs": "decision"}})
if err := h.Check(o.Number); err != nil {
t.Fatal(err)
}
is := get(t, h, o.Number)
if is.Severity != "critical" || is.Needs != "decision" {
t.Fatalf("%s %s", is.Severity, is.Needs)
}
if nn := triageNotes(is); len(nn) != 1 || !strings.Contains(nn[0], "Severity: the triage says minor — nothing is lost; the value given, critical, stands.") {
t.Fatalf("%q", nn)
}
if _, err := h.Open(OpenAsk{Title: "x", Report: "y", Severity: "urgent"}); err == nil {
t.Fatal("a severity not of its kind was taken")
}
}
// A resolved or wontfix issue is never offered, and a verdict naming one — or any issue not offered, or a
// newer one — changes nothing and fails loudly.
func TestAResolvedIssueIsNeverOfferedOrTouched(t *testing.T) {
h := newHarness(t)
resolved := h.open(t, "the counter skips a number")
resolve(t, h, resolved)
closed := h.open(t, "the counter skips again")
if _, err := h.Move(MoveAsk{Number: closed, Status: StatusWontfix, Reason: "not ours"}); err != nil {
t.Fatal(err)
}
open := h.open(t, "the desk shows nothing")
newer := h.open(t, "a number is skipped")
later := h.open(t, "a later one")
before := map[uint64]int{}
for _, n := range []uint64{resolved, closed, open} {
before[n] = len(get(t, h, n).Notes)
}
for _, named := range []uint64{resolved, closed, later, 999} {
s := withJudge(h, &stubJudge{shortlist: []uint64{resolved, closed, open, later}, verdict: map[string]any{
"verdict": "duplicate", "issue": named, "certain": true, "reason": "the same",
"depends_on": []any{map[string]any{"issue": resolved, "reason": "x"}}}})
err := h.Check(newer)
if err == nil || !strings.Contains(err.Error(), "not offered") {
t.Fatalf("a verdict naming %d: %v", named, err)
}
for _, q := range s.asked {
if strings.Contains(q, "365: ") || strings.Contains(q, "366: ") || strings.Contains(q, "OLDER ISSUE 365") ||
strings.Contains(q, "OLDER ISSUE 366") || strings.Contains(q, "369") {
t.Fatalf("a resolved, wontfix or newer issue was offered: %s", q)
}
}
}
for n, was := range before {
is := get(t, h, n)
if len(is.Notes) != was || len(is.Links) != 0 {
t.Fatalf("issue %d was changed", n)
}
}
n := get(t, h, newer)
if n.Status != StatusOpen || len(n.Links) != 0 || recorded(n) != nil {
t.Fatalf("the newer was acted on: %s %+v", n.Status, n.Links)
}
if _, err := h.apply(newer, &Verdict{Verdict: VerdictDuplicate, Issue: resolved, Certain: true, Reason: "x", By: "y"}); err == nil {
t.Fatal("applied against a resolved issue")
}
if _, err := h.apply(open, &Verdict{Verdict: VerdictDuplicate, Issue: newer, Certain: true, Reason: "x", By: "y"}); err == nil {
t.Fatal("closed the older of two")
}
if err := h.Check(resolved); err != nil || len(get(t, h, resolved).Notes) != before[resolved] {
t.Fatalf("a resolved issue was triaged: %v", err)
}
}
// Once per issue: the same event twice judges once; a triage kept and not applied is finished without judging;
// a note in the triage's words by anybody else is not the triage.
func TestTheSameEventTwiceJudgesOnceAndFinishesWhatIsMissing(t *testing.T) {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
newer := h.open(t, "a number is skipped")
s := withJudge(h, &stubJudge{shortlist: []uint64{older}, verdict: map[string]any{
"verdict": "duplicate", "issue": older, "certain": true, "reason": "the same skip"}})
ev := stdio.Envelope{Key: "mesh-issues.opened", Body: json.RawMessage(`{"number":366}`)}
for i := 0; i < 2; i++ {
if err := h.OnOpened(ev); err != nil {
t.Fatal(err)
}
}
if s.questions() != 2 || len(triageNotes(get(t, h, newer))) != 1 || len(triageNotes(get(t, h, older))) != 1 {
t.Fatalf("judged again or written twice: %d questions", s.questions())
}
h2 := newHarness(t)
o2 := h2.open(t, "the counter skips a number")
n2 := h2.open(t, "a number is skipped")
kept := &Verdict{Verdict: VerdictDuplicate, Issue: o2, Certain: true, Reason: "the same", Severity: "minor", Needs: "none", By: "the agent"}
if _, err := h2.change(n2, func(i *Issue) error { i.Triage = kept; return nil }); err != nil {
t.Fatal(err)
}
s2 := withJudge(h2, &stubJudge{err: errors.New("must not be asked")})
if err := h2.Check(n2); err != nil {
t.Fatal(err)
}
if n := get(t, h2, n2); s2.questions() != 0 || n.Status != StatusWontfix || len(n.Links) != 1 || len(triageNotes(get(t, h2, o2))) != 1 {
t.Fatalf("not finished, or judged again: %s %+v", n.Status, n.Links)
}
h3 := newHarness(t)
h3.open(t, "the counter skips a number")
n3 := h3.open(t, "a number is skipped")
if _, err := h3.Note(n3, "Duplicate check: duplicate of 365."); err != nil {
t.Fatal(err)
}
withJudge(h3, &stubJudge{})
if err := h3.Check(n3); err != nil {
t.Fatal(err)
}
if n := get(t, h3, n3); n.Status != StatusOpen {
t.Fatal("a caller's note closed an issue")
}
}
// An issue the duplicate check of ADR 0278 judged is not judged again on its event; the backlog's pass adds the
// rest and keeps its verdict.
func TestAnIssueTheDuplicateCheckJudgedKeepsItsVerdictAndGainsTheRest(t *testing.T) {
h := newHarness(t)
h.open(t, "the counter skips a number")
n := h.open(t, "a number is skipped")
if _, err := h.note(LegacyBy, n, "Duplicate check: related to 365.\n\nWhy: the same counter."); err != nil {
t.Fatal(err)
}
s := withJudge(h, &stubJudge{shortlist: []uint64{365}, verdict: map[string]any{"verdict": "duplicate", "issue": 365, "certain": true, "reason": "the same"}})
if err := h.Check(n); err != nil || s.questions() != 0 {
t.Fatalf("judged again on its event: %v, %d", err, s.questions())
}
var run func()
out, err := h.Groom(GroomAsk{}, func(f func()) { run = f })
if err != nil {
t.Fatal(err)
}
run()
if _, err := h.Groom(GroomAsk{Pass: out.Pass.ID, Confirm: true}, nil); err != nil {
t.Fatal(err)
}
is := get(t, h, n)
if is.Status != StatusOpen || is.Severity != "minor" || is.Triage == nil || is.Triage.Verdict != VerdictRelated {
t.Fatalf("%s %s %+v", is.Status, is.Severity, is.Triage)
}
}
// A judgement that fails is loud: the event is not taken, and one note says why however often it comes.
func TestAFailedJudgementLeavesOneNoteAndIsNotTaken(t *testing.T) {
for _, s := range []*stubJudge{
{err: errors.New("the agent on control did not answer within 25s")},
{shortlist: []uint64{365}, verdict: map[string]any{"verdict": "maybe", "issue": 365, "certain": true, "reason": "?"}},
{shortlist: []uint64{365}, verdict: map[string]any{"verdict": "duplicate", "issue": 365, "certain": true, "reason": ""}},
{shortlist: []uint64{365}, verdict: map[string]any{"severity": "urgent"}},
} {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
newer := h.open(t, "a number is skipped")
withJudge(h, s)
ev := stdio.Envelope{Key: "mesh-issues.opened", Body: json.RawMessage(`{"number":366}`)}
for i := 0; i < 5; i++ {
if err := h.OnOpened(ev); err == nil {
t.Fatal("a failed judgement was taken")
}
}
notes := triageNotes(get(t, h, newer))
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Triage: could not judge") || !strings.Contains(notes[0], "max-deliveries") {
t.Fatalf("%q", notes)
}
if n := get(t, h, newer); n.Status != StatusOpen || len(n.Links) != 0 || n.Severity != "" || len(get(t, h, older).Notes) != 0 {
t.Fatal("a failed judgement changed something")
}
}
h := newHarness(t)
h.open(t, "one")
s := withJudge(h, &stubJudge{})
h.settings.DuplicateCheck = "off"
if err := h.OnOpened(stdio.Envelope{Body: json.RawMessage(`{"number":365}`)}); err != nil || s.questions() != 0 ||
len(get(t, h, 365).Notes) != 0 {
t.Fatalf("triaged while off: %v", err)
}
h.settings.DuplicateCheck = ""
if err := h.OnOpened(stdio.Envelope{Body: json.RawMessage(`{}`)}); err != nil {
t.Fatal(err)
}
}
// The triage links what an issue depends on and notes the older; never a cycle, never a closed target. The verb
// refuses a cycle of two and of three.
func TestTheTriageLinksDependenciesButNoCycleAndNoClosedIssue(t *testing.T) {
h := newHarness(t)
a := h.open(t, "the bus drops a grant")
b := h.open(t, "the controller cannot issue a grant")
c := h.open(t, "a closed one")
if _, err := h.Move(MoveAsk{Number: c, Status: StatusWontfix, Reason: "x"}); err != nil {
t.Fatal(err)
}
if _, err := h.Link(LinkAsk{Number: a, Kind: LinkDependsOn, Target: keyOf(b)}); err != nil {
t.Fatal(err)
}
newer := h.open(t, "a module cannot reach its grant")
withJudge(h, &stubJudge{shortlist: []uint64{a, b}, verdict: map[string]any{
"depends_on": []any{map[string]any{"issue": b, "reason": "it needs the grant first"}, map[string]any{"issue": c, "reason": "closed"}},
"relates_to": []any{map[string]any{"issue": a, "reason": "the same grants"}}}})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n := get(t, h, newer)
if !hasLink(n, LinkDependsOn, b) || !hasLink(n, LinkRelatesTo, a) || hasLink(n, LinkDependsOn, c) {
t.Fatalf("%+v", n.Links)
}
if nn := triageNotes(get(t, h, b)); len(nn) != 1 || !strings.HasPrefix(nn[0], "Triage: issue 368 depends on this one") {
t.Fatalf("%q", nn)
}
if _, err := h.Link(LinkAsk{Number: b, Kind: LinkDependsOn, Target: keyOf(a)}); !errors.Is(err, ErrCycle) {
t.Fatalf("a cycle of two: %v", err)
}
if _, err := h.Link(LinkAsk{Number: b, Kind: LinkDependsOn, Target: keyOf(newer)}); !errors.Is(err, ErrCycle) {
t.Fatalf("a cycle through the triage's link: %v", err)
}
v, err := h.View(b)
if err != nil || !reflect.DeepEqual(v.NeededBy, []uint64{a, newer}) {
t.Fatalf("%v %+v", err, v)
}
}
// Ready: no dependency; one open; resolved and not delivered; resolved and delivered; wontfix.
func TestAnIssueIsReadyOnlyWhenWhatItDependsOnIsDelivered(t *testing.T) {
h := newHarness(t)
dep := h.open(t, "the fix first")
w := h.open(t, "waits for it")
free := h.open(t, "depends on nothing")
if _, err := h.Link(LinkAsk{Number: w, Kind: LinkDependsOn, Target: keyOf(dep)}); err != nil {
t.Fatal(err)
}
ready := func(h *harness, n uint64) bool {
v, err := h.View(n)
if err != nil {
t.Fatal(err)
}
return v.Ready
}
if !ready(h, free) || ready(h, w) {
t.Fatal("ready wrong with an open dependency")
}
asked := 0
h.Deliveries = func(repo, number string) (Delivery, error) {
asked++
if repo != "mesh-catalog" || number != "9" {
t.Errorf("asked %s#%s", repo, number)
}
return Delivery{State: "ready"}, nil
}
resolve(t, h, dep, "novox/mesh-catalog#9", "a commit abc123")
if err := h.OnMoved(stdio.Envelope{Body: json.RawMessage(`{"number":365,"to":"resolved"}`)}); err != nil {
t.Fatal(err)
}
if asked != 1 || ready(h, w) {
t.Fatalf("ready before its fix is delivered (asked %d)", asked)
}
tr := stdio.Envelope{Key: "mesh-delivery.transition", Body: json.RawMessage(`{"repository":"novox/mesh-catalog","number":9,"to":"delivered"}`)}
for i := 0; i < 2; i++ {
if err := h.OnTransition(tr); err != nil {
t.Fatal(err)
}
}
if !ready(h, w) || !reflect.DeepEqual(get(t, h, dep).Delivered, []string{"mesh-catalog#9"}) {
t.Fatal("not ready once delivered")
}
if nn := triageNotes(get(t, h, w)); len(nn) != 1 || !strings.HasPrefix(nn[0], "Ready: issue 365") {
t.Fatalf("%q", nn)
}
readies := 0
for _, e := range h.said() {
if e.Event == EventReady {
readies++
}
}
if readies != 1 {
t.Fatalf("%d ready events", readies)
}
rows, err := h.List(ListAsk{Ready: ptr(true), Status: StatusOpen})
if err != nil || rows.Matched != 2 {
t.Fatalf("%v %+v", err, rows)
}
// A fix delivered before its issue was resolved is found at the move; a wontfix dependency lets go at once.
h2 := newHarness(t)
d2 := h2.open(t, "fixed earlier")
w2 := h2.open(t, "waits")
d3 := h2.open(t, "abandoned")
w3 := h2.open(t, "waits on the abandoned one")
_, _ = h2.Link(LinkAsk{Number: w2, Kind: LinkDependsOn, Target: keyOf(d2)})
_, _ = h2.Link(LinkAsk{Number: w3, Kind: LinkDependsOn, Target: keyOf(d3)})
h2.Deliveries = func(string, string) (Delivery, error) { return Delivery{State: "delivered"}, nil }
resolve(t, h2, d2, "mesh-controller#5")
_ = h2.OnMoved(stdio.Envelope{Body: json.RawMessage(`{"number":365,"to":"resolved"}`)})
_, _ = h2.Move(MoveAsk{Number: d3, Status: StatusWontfix, Reason: "abandoned"})
_ = h2.OnMoved(stdio.Envelope{Body: json.RawMessage(`{"number":367,"to":"wontfix"}`)})
for _, n := range []uint64{w2, w3} {
if !ready(h2, n) || len(triageNotes(get(t, h2, n))) != 1 {
t.Fatalf("issue %d not let go", n)
}
}
}
// set changes the three values as a person decides, refuses another, and says the event; the triage never
// changes what was set.
func TestSetChangesWhatAPersonDecidesAndTheTriageLeavesIt(t *testing.T) {
h := newHarness(t)
n := h.open(t, "one")
if _, err := h.call(t, "set", map[string]any{"number": n, "severity": "urgent"}); err == nil {
t.Fatal("a severity not of its kind was set")
}
if _, err := h.call(t, "set", map[string]any{"number": n, "suggested-owner": "nowhere"}); err == nil {
t.Fatal("an owner not registered was set")
}
if _, err := h.call(t, "set", map[string]any{"number": n}); err == nil {
t.Fatal("nothing was set and not refused")
}
if _, err := h.call(t, "set", map[string]any{"number": n, "severity": "major", "needs": "decision"}); err != nil {
t.Fatal(err)
}
withJudge(h, &stubJudge{verdict: map[string]any{"severity": "minor", "needs": "none"}})
if err := h.Check(n); err != nil {
t.Fatal(err)
}
is := get(t, h, n)
if is.Severity != "major" || is.Needs != "decision" {
t.Fatalf("%s %s", is.Severity, is.Needs)
}
sets := 0
for _, e := range h.said() {
if e.Event == EventSet {
sets++
}
}
if sets != 1 {
t.Fatalf("%d set events", sets)
}
rows, _ := h.List(ListAsk{Severity: "major", Needs: "decision"})
if rows.Matched != 1 || rows.Rows[0].Severity != "major" {
t.Fatalf("%+v", rows)
}
}
// The dry run writes no issue; confirm applies exactly its findings, each checked again.
func TestTheDryRunChangesNothingAndConfirmAppliesItsFindings(t *testing.T) {
h := newHarness(t)
a := h.open(t, "the counter skips a number") // 365
b := h.open(t, "a number is skipped") // 366, duplicate of 365
c := h.open(t, "the counter skips numbers too") // 367, duplicate of 365 (366 is not offered: found a duplicate)
d := h.open(t, "the desk is slow") // 368, related to 365, which is resolved before confirm
e := h.open(t, "a backup took an hour") // 369, none
verdicts := map[string]map[string]any{
"366": {"verdict": "duplicate", "issue": a, "certain": true, "reason": "same skip"},
"367": {"verdict": "duplicate", "issue": a, "certain": true, "reason": "same skip again"},
"368": {"verdict": "related", "issue": a, "certain": false, "reason": "same desk"},
}
var mu sync.Mutex
var offered367 string
h.Judge = func(q string, schema map[string]any, model string) (json.RawMessage, string, error) {
mu.Lock()
defer mu.Unlock()
_, second := schema["properties"].(map[string]any)["verdict"]
v := map[string]any{"verdict": "none", "issue": 0, "certain": false, "reason": "", "severity": "minor",
"severity_reason": "r", "needs": "none", "needs_reason": "r", "owner": "", "owner_reason": "",
"depends_on": []any{}, "relates_to": []any{}}
for n, x := range verdicts {
if strings.Contains(q, "new issue, "+n+":") || strings.Contains(q, "NEW ISSUE, "+n+":") {
if n == "367" && !second {
offered367 = q
}
if !second {
return json.RawMessage(`{"candidates":[365,366]}`), "the agent", nil
}
for k, y := range x {
v[k] = y
}
}
}
if !second {
return json.RawMessage(`{"candidates":[]}`), "the agent", nil
}
raw, _ := json.Marshal(v)
return raw, "the agent on control (sonnet)", nil
}
before := h.mem.snapshot(IssuesState)
var run func()
out, err := h.Groom(GroomAsk{}, func(f func()) { run = f })
if err != nil {
t.Fatal(err)
}
if _, err := h.Groom(GroomAsk{}, func(func()) {}); err == nil {
t.Fatal("a second pass started while one runs")
}
run()
if !reflect.DeepEqual(before, h.mem.snapshot(IssuesState)) {
t.Fatal("the dry run changed an issue")
}
if offered367 == "" || strings.Contains(offered367, "366: ") {
t.Fatal("an issue found a duplicate in the pass was offered again")
}
shown, err := h.Groom(GroomAsk{Pass: out.Pass.ID}, nil)
if err != nil {
t.Fatal(err)
}
p := shown.Pass
if p.State != PassDone || p.Judged != 5 || len(p.Findings) != 5 || len(p.Failures) != 0 {
t.Fatalf("%+v", p)
}
if p.Findings[1].Issue != b || !contains(p.Findings[1].WouldDo, "close as wontfix, duplicate of 365; keep its report on 365") ||
!contains(p.Findings[1].WouldDo, "severity minor") {
t.Fatalf("%+v", p.Findings[1])
}
resolve(t, h, a) // the older side moved on between the dry run and confirm
if _, err := h.Groom(GroomAsk{Confirm: true}, nil); err == nil {
t.Fatal("confirm without a pass")
}
applied, err := h.Groom(GroomAsk{Pass: p.ID, Confirm: true}, nil)
if err != nil {
t.Fatal(err)
}
if len(applied.Pass.Skipped) != 3 {
t.Fatalf("skipped %v, applied %v", applied.Pass.Skipped, applied.Pass.Applied)
}
for _, n := range []uint64{b, c} {
if is := get(t, h, n); is.Status != StatusOpen || len(is.Links) != 0 || is.Severity != "" {
t.Fatalf("issue %d was changed against a resolved issue", n)
}
}
if is := get(t, h, d); is.Status != StatusOpen || hasLink(is, LinkRelatesTo, a) || is.Severity != "minor" {
t.Fatalf("issue %d: %+v", d, is)
}
if is := get(t, h, e); is.Severity != "minor" || len(triageNotes(is)) != 1 {
t.Fatalf("issue %d was not triaged: %+v", e, is)
}
if _, err := h.Groom(GroomAsk{Pass: p.ID, Confirm: true}, nil); err == nil {
t.Fatal("a pass applied twice")
}
}
func TestConfirmClosesOnlyTheNewerAndKeepsTheOlderOpen(t *testing.T) {
h := newHarness(t)
a := h.open(t, "the counter skips a number")
b := h.open(t, "a number is skipped")
withJudge(h, &stubJudge{shortlist: []uint64{a}, verdict: map[string]any{"verdict": "duplicate", "issue": a, "certain": true, "reason": "same"}})
var run func()
out, err := h.Groom(GroomAsk{}, func(f func()) { run = f })
if err != nil {
t.Fatal(err)
}
run()
if _, err := h.Groom(GroomAsk{Pass: out.Pass.ID, Confirm: true}, nil); err != nil {
t.Fatal(err)
}
if get(t, h, a).Status != StatusOpen || get(t, h, b).Status != StatusWontfix || get(t, h, b).Reason != "duplicate of 365" {
t.Fatal("not the newer alone closed")
}
p, _, _ := h.readPass()
p.State = PassRunning
_ = h.savePass(p)
shown, err := h.Groom(GroomAsk{Pass: p.ID}, nil)
if err != nil || shown.Pass.State != PassInterrupted {
t.Fatalf("%v %+v", err, shown)
}
if _, err := h.Groom(GroomAsk{Pass: p.ID, Confirm: true}, nil); err == nil {
t.Fatal("an interrupted pass was applied")
}
}
// An issue the triage judged, then resolved by a person, is not touched when its event comes again.
func TestATriagedIssueAPersonResolvedIsNotTouchedAgain(t *testing.T) {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
newer := h.open(t, "a number is skipped")
kept := &Verdict{Verdict: VerdictDuplicate, Issue: older, Certain: true, Reason: "same"}
if _, err := h.change(newer, func(i *Issue) error { i.Triage = kept; return nil }); err != nil {
t.Fatal(err)
}
resolve(t, h, newer)
withJudge(h, &stubJudge{err: errors.New("must not be asked")})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
if len(get(t, h, newer).Links) != 0 || len(get(t, h, older).Notes) != 0 {
t.Fatal("a resolved issue was linked, or its older noted")
}
}
// The pass runs in the background for real, while its answer is read and a new issue is triaged; the race
// detector watches.
func TestThePassRunsBesideTheTriageOfNewIssues(t *testing.T) {
h := newHarness(t)
for i := 0; i < 6; i++ {
h.open(t, "issue "+string(rune('a'+i)))
}
withJudge(h, &stubJudge{})
finished := make(chan struct{})
out, err := h.Groom(GroomAsk{}, func(f func()) { go func() { f(); close(finished) }() })
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(out)
newer := h.open(t, "a new one")
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
<-finished
if !strings.Contains(string(raw), out.Pass.ID) {
t.Fatal(string(raw))
}
p, _, _ := h.readPass()
if p.State != PassDone || p.Judged != 6 {
t.Fatalf("%+v", p)
}
if nn := triageNotes(get(t, h, newer)); len(nn) != 1 || !strings.HasPrefix(nn[0], "Triage of this issue: none of 6 older") {
t.Fatalf("%q", nn)
}
}
// After a judgement that failed, the next one still writes its verdict note (review of PR 184).
func TestAFailureNoteDoesNotHideTheVerdictNote(t *testing.T) {
h := newHarness(t)
h.open(t, "the counter skips a number")
n := h.open(t, "a number is skipped")
s := withJudge(h, &stubJudge{err: errors.New("down")})
if err := h.Check(n); err == nil {
t.Fatal("a failure was taken")
}
s.err = nil
if err := h.Check(n); err != nil {
t.Fatal(err)
}
nn := triageNotes(get(t, h, n))
if len(nn) != 2 || !strings.HasPrefix(nn[1], "Triage of this issue: none of 1") {
t.Fatalf("%q", nn)
}
}
// One delivery that lets two dependencies go says ready once; a wontfix duplicate lets go only with its original;
// fixed-by is read in any case and as the forge's page; a delivery that cannot be asked is not taken.
func TestReadyIsSaidOnceFollowsDuplicatesAndAskingFailsLoudly(t *testing.T) {
h := newHarness(t)
a := h.open(t, "a")
b := h.open(t, "b")
w := h.open(t, "waits on both")
_, _ = h.Link(LinkAsk{Number: w, Kind: LinkDependsOn, Target: keyOf(a)})
_, _ = h.Link(LinkAsk{Number: w, Kind: LinkDependsOn, Target: keyOf(b)})
h.Deliveries = func(string, string) (Delivery, error) { return Delivery{State: "published"}, nil }
resolve(t, h, a, "Mesh-Catalog#9")
resolve(t, h, b, "https://forge.example/novox/mesh-catalog/pulls/9")
if got := pullRequests(get(t, h, a)); !reflect.DeepEqual(got, []string{"mesh-catalog#9"}) {
t.Fatalf("%v", got)
}
if got := pullRequests(get(t, h, b)); !reflect.DeepEqual(got, []string{"mesh-catalog#9"}) {
t.Fatalf("%v", got)
}
tr := stdio.Envelope{Body: json.RawMessage(`{"repository":"novox/mesh-catalog","number":9,"to":"delivered"}`)}
if err := h.OnTransition(tr); err != nil {
t.Fatal(err)
}
readies := 0
for _, e := range h.said() {
if e.Event == EventReady {
readies++
}
}
if readies != 1 || len(triageNotes(get(t, h, w))) != 1 {
t.Fatalf("%d ready events, notes %q", readies, triageNotes(get(t, h, w)))
}
h2 := newHarness(t)
orig := h2.open(t, "the original")
dup := h2.open(t, "its duplicate")
w2 := h2.open(t, "waits on the duplicate")
_, _ = h2.Link(LinkAsk{Number: w2, Kind: LinkDependsOn, Target: keyOf(dup)})
_, _ = h2.Link(LinkAsk{Number: dup, Kind: LinkDuplicates, Target: keyOf(orig)})
_, _ = h2.Move(MoveAsk{Number: dup, Status: StatusWontfix, Reason: "duplicate of 365"})
_ = h2.OnMoved(stdio.Envelope{Body: json.RawMessage(`{"number":366,"to":"wontfix"}`)})
if v, _ := h2.View(w2); v.Ready {
t.Fatal("ready while the original is open")
}
h2.Deliveries = func(string, string) (Delivery, error) { return Delivery{}, errors.New("mesh-delivery did not answer") }
resolve(t, h2, orig, "mesh-catalog#3")
if err := h2.OnMoved(stdio.Envelope{Body: json.RawMessage(`{"number":365,"to":"resolved"}`)}); err == nil {
t.Fatal("a delivery that could not be asked was taken")
}
h2.Deliveries = func(string, string) (Delivery, error) { return Delivery{State: "delivered"}, nil }
if err := h2.OnMoved(stdio.Envelope{Body: json.RawMessage(`{"number":365,"to":"resolved"}`)}); err != nil {
t.Fatal(err)
}
if v, _ := h2.View(w2); !v.Ready || len(triageNotes(get(t, h2, w2))) != 1 {
t.Fatalf("not let go through the duplicate: %+v", v)
}
}