mesh/delivery-group group feat/399-fixing-status checking: 0 of 2 member(s) ready
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 superseded: a newer head of the same pull request
Two agents could start on one issue without seeing each other, and an issue could be resolved without anyone having said they started its fix. A move to diagnosing or fixing now names by, the worker as who@node; get and list show the worker with since and stale; another worker is refused while it is current unless take-over says why (noted); a worker untouched for worker-stale-after (24h) may be taken over from without a reason. Resolved is reached only from fixing. The moved event carries by, since and caller. Fixing counts as not closed for the triage and the duplicate check. hq ADR 0283.
827 lines
32 KiB
Go
827 lines
32 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"}
|
|
}
|
|
h.fixing(t, n)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A verdict of related or duplicate naming issue 0 names none: read as none, not a failure.
|
|
func TestAVerdictNamingIssueZeroIsNone(t *testing.T) {
|
|
h := newHarness(t)
|
|
h.open(t, "the counter skips a number")
|
|
n := h.open(t, "the desk is slow")
|
|
withJudge(h, &stubJudge{verdict: map[string]any{"verdict": "related", "issue": 0, "reason": "nothing close"}})
|
|
if err := h.Check(n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
is := get(t, h, n)
|
|
if is.Triage == nil || is.Triage.Verdict != VerdictNone || len(is.Links) != 0 || is.Severity != "minor" {
|
|
t.Fatalf("%+v", is)
|
|
}
|
|
}
|