mesh-issues: triage every new issue — severity, needs, suggested owner, dependencies — and say an issue ready when what it depends on is delivered (hq ADR 0279)
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

The operator asked for the judgement that already reads the backlog to fill what an
issuer cannot know, and for dependencies between issues, an issue being workable
only once what it waits for is on the machines. The triage fills only empty values,
adds links and removes none; readiness follows mesh-delivery's own events.
This commit is contained in:
jochen
2026-10-10 17:49:27 +02:00
parent 25c7bee3b8
commit 745985fd23
13 changed files with 1863 additions and 821 deletions
+6 -4
View File
@@ -204,8 +204,10 @@ func checkDesign(e string) (string, error) {
// The kinds of link (to-be 53 §3), each with what its target is.
const (
LinkDuplicates = "duplicates"
LinkRelatesTo = "relates-to"
LinkDuplicates = "duplicates"
LinkRelatesTo = "relates-to"
// LinkDependsOn is an issue that must be delivered first (ADR 0279).
LinkDependsOn = "depends-on"
LinkPullRequest = "pull-request"
LinkDecision = "decision"
LinkDesign = "design"
@@ -214,7 +216,7 @@ const (
)
// LinkKinds is every kind, in the order they are told.
var LinkKinds = []string{LinkDuplicates, LinkRelatesTo, LinkPullRequest, LinkDecision, LinkDesign, LinkCondition, LinkDelivery}
var LinkKinds = []string{LinkDuplicates, LinkRelatesTo, LinkDependsOn, LinkPullRequest, LinkDecision, LinkDesign, LinkCondition, LinkDelivery}
var (
pullRequest = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}#[1-9][0-9]{0,6}$`)
@@ -230,7 +232,7 @@ func checkTarget(kind, target string) (string, error) {
ok := false
shape := ""
switch kind {
case LinkDuplicates, LinkRelatesTo:
case LinkDuplicates, LinkRelatesTo, LinkDependsOn:
ok, shape = issueNumber.MatchString(t), "another issue's number, such as 312"
case LinkPullRequest:
ok, shape = pullRequest.MatchString(t), "a repository and a pull request's number, such as mesh-controller#246"
File diff suppressed because it is too large Load Diff
@@ -1,492 +0,0 @@
package main
import (
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// stubJudge answers the duplicate check's two questions as a test says, and keeps every question asked.
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 {
raw, _ := json.Marshal(s.verdict)
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 checkNotes(is *Issue) []string {
var out []string
for _, n := range is.Notes {
if n.By == GroomBy {
out = append(out, n.Text)
}
}
return out
}
func resolve(t *testing.T, h *harness, n uint64) {
t.Helper()
if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, LocatedIn: []string{"mesh-catalog modules/records"},
FixedBy: []string{"mesh-catalog#1"}}); err != nil {
t.Fatal(err)
}
}
// A clear duplicate: the newer is linked duplicates, its report kept on the older, its verdict noted first,
// and it moves to wontfix "duplicate of N"; 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" {
t.Fatalf("newer %s %q", n.Status, n.Reason)
}
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 := checkNotes(n)
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Duplicate check: duplicate of 365.") ||
!strings.Contains(notes[0], "the agent on control (sonnet)") || !strings.Contains(notes[0], "both say") {
t.Fatalf("newer's notes %q", notes)
}
o := get(t, h, older)
if o.Status != StatusOpen || len(o.Links) != 0 {
t.Fatalf("the older moved or was linked: %s %+v", o.Status, o.Links)
}
notes = checkNotes(o)
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Duplicate check: issue 367 was opened as a duplicate") ||
!strings.Contains(notes[0], "seen: a number is skipped by the counter") || !strings.Contains(notes[0], "both say") {
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")
}
// The verdict note was written before anything else was changed.
var order []string
for _, e := range h.said() {
if e.Event != EventOpened {
order = append(order, e.Event)
}
}
if !reflect.DeepEqual(order, []string{EventNoted, EventLinked, EventNoted, EventMoved}) {
t.Fatalf("order %v", order)
}
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 := checkNotes(n); len(nn) != 1 || !strings.HasPrefix(nn[0], "Duplicate check: related to 365.") {
t.Fatalf("%v: %q", v, nn)
}
if on := checkNotes(o); len(on) != 1 || !strings.HasPrefix(on[0], "Duplicate check: issue 366, opened later, is related") {
t.Fatalf("%v: %q", v, on)
}
}
}
// None: one note, nothing else; an empty shortlist asks no second question.
func TestAnIssueThatRepeatsNothingGainsOneNote(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{})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n := get(t, h, newer)
if n.Status != StatusOpen || len(n.Links) != 0 {
t.Fatalf("%s %+v", n.Status, n.Links)
}
if nn := checkNotes(n); len(nn) != 1 || !strings.HasPrefix(nn[0], "Duplicate check: none of 2 older issues") {
t.Fatalf("%q", nn)
}
if len(get(t, h, a).Notes)+len(get(t, h, b).Notes) != 0 || s.questions() != 1 {
t.Fatalf("older issues written, or %d questions", s.questions())
}
// The first issue of all has nothing to repeat, and is noted so without a question.
h2 := newHarness(t)
first := h2.open(t, "the first")
s2 := withJudge(h2, &stubJudge{})
if err := h2.Check(first); err != nil {
t.Fatal(err)
}
if nn := checkNotes(get(t, h2, first)); len(nn) != 1 || s2.questions() != 0 {
t.Fatalf("%q, %d questions", nn, s2.questions())
}
}
// 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"}})
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)
}
// Directly, too: apply never notes or links a resolved issue, and never closes the older of two.
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")
}
// An issue not at open when its event comes is not checked.
if err := h.Check(resolved); err != nil || len(get(t, h, resolved).Notes) != before[resolved] {
t.Fatalf("a resolved issue was checked: %v", err)
}
}
// Once per issue: the same event twice judges once; after a partial apply only the missing steps are done.
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(checkNotes(get(t, h, newer))) != 1 || len(checkNotes(get(t, h, older))) != 1 {
t.Fatalf("judged again or written twice: %d questions", s.questions())
}
// A delivery that stopped after the verdict note: the next one finishes without judging.
h2 := newHarness(t)
o2 := h2.open(t, "the counter skips a number")
n2 := h2.open(t, "a number is skipped")
if _, err := h2.note(GroomBy, n2, "Duplicate check: duplicate of 365.\n\nWhy: the same."); 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 s2.questions() != 0 {
t.Fatal("judged again")
}
if n := get(t, h2, n2); n.Status != StatusWontfix || len(n.Links) != 1 || len(checkNotes(get(t, h2, o2))) != 1 {
t.Fatalf("not finished: %s %+v", n.Status, n.Links)
}
// A note in the same words by anybody else is not the check's verdict.
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")
}
}
// 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": ""}},
} {
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 := checkNotes(get(t, h, newer))
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Duplicate check: 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 || len(get(t, h, older).Notes) != 0 {
t.Fatal("a failed judgement changed something")
}
}
// Off, or an event without a number: taken, and nothing done.
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("checked while off: %v", err)
}
h.settings.DuplicateCheck = ""
if err := h.OnOpened(stdio.Envelope{Body: json.RawMessage(`{}`)}); err != nil {
t.Fatal(err)
}
}
// 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()
for n, v := range verdicts {
if strings.Contains(q, "new issue, "+n+":") || strings.Contains(q, "NEW ISSUE, "+n+":") {
if n == "367" && strings.Contains(q, "older issues that are not resolved") {
offered367 = q
}
if _, second := schema["properties"].(map[string]any)["verdict"]; second {
raw, _ := json.Marshal(v)
return raw, "the agent on control (sonnet)", nil
}
return json.RawMessage(`{"candidates":[365,366]}`), "the agent", nil
}
}
return json.RawMessage(`{"candidates":[]}`), "the agent", 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 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) != 3 || len(p.Failures) != 0 {
t.Fatalf("%+v", p)
}
if p.Findings[0].Issue != b || p.Findings[0].Older != a || !strings.Contains(p.Findings[0].WouldDo, "close 366") {
t.Fatalf("%+v", p.Findings[0])
}
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, d, e} {
if is := get(t, h, n); is.Status != StatusOpen || len(is.Links) != 0 {
t.Fatalf("issue %d was changed against a resolved issue", n)
}
}
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")
}
// A pass the module did not finish — it restarted — is said interrupted, and a new one may start.
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 check already judged, then resolved by a person, is not touched when its event comes again.
func TestAJudgedIssueAPersonResolvedIsNotTouchedAgain(t *testing.T) {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
newer := h.open(t, "a number is skipped")
if _, err := h.note(GroomBy, newer, "Duplicate check: duplicate of 365.\n\nWhy: the same."); 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 checked; the race
// detector watches.
func TestThePassRunsBesideTheChecksOfNewIssues(t *testing.T) {
h := newHarness(t)
for i := 0; i < 6; i++ {
h.open(t, "issue "+string(rune('a'+i)))
}
withJudge(h, &stubJudge{})
done := make(chan struct{})
out, err := h.Groom(GroomAsk{}, func(f func()) { go func() { f(); close(done) }() })
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(out) // read while the pass runs
newer := h.open(t, "a new one")
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
<-done
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 := checkNotes(get(t, h, newer)); len(nn) != 1 || !strings.HasPrefix(nn[0], "Duplicate check: none of 6 older") {
t.Fatalf("%q", nn)
}
}
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -32,6 +33,25 @@ const (
// Statuses is every status an issue may have, in the order the cycle walks them.
var Statuses = []string{StatusOpen, StatusDiagnosing, StatusLocated, StatusResolved, StatusWontfix}
// Severities say how much an issue costs (ADR 0279): critical — something the operator relies on is broken, or
// data is at risk; major — a rule of the mesh does not hold, or people work around it; minor — a contained fix,
// and nothing the operator sees changes.
var Severities = []string{"critical", "major", "minor"}
// NeedsKinds say what an issue needs before an agent can fix it (ADR 0279): none; decision — a decision record
// first; operator — the operator's answer or feedback.
var NeedsKinds = []string{"none", "decision", "operator"}
func oneOf(field, v string, allowed []string) (string, error) {
v = strings.TrimSpace(v)
for _, a := range allowed {
if v == a {
return v, nil
}
}
return "", fmt.Errorf("%s %q is not one of %s", field, v, strings.Join(allowed, ", "))
}
// Unproven is who acted, as recorded, while the runtime does not tell a module who called it: a tool call
// reaches a bundle as a name and arguments only (mesh-sdk Tool.Run), and the tool runner is not told who
// called it either (hq issue 340). The caller is never taken from an argument, so this is recorded rather
@@ -64,6 +84,15 @@ type Issue struct {
Record string `json:"record,omitempty"`
Notes []Note `json:"notes,omitempty"`
Links []Link `json:"links,omitempty"`
// Severity, Needs and SuggestedOwner are given at open, set by a person, or estimated by the triage where
// nobody gave them (ADR 0279); empty until then.
Severity string `json:"severity,omitempty"`
Needs string `json:"needs,omitempty"`
SuggestedOwner string `json:"suggested-owner,omitempty"`
// Delivered is the pull requests of FixedBy the delivery said are delivered.
Delivered []string `json:"delivered,omitempty"`
// Triage is the triage's judgement as it was made, kept before it is applied.
Triage *Verdict `json:"triage,omitempty"`
// Opening is the id of the call that opened it, so a write whose answer was lost is known as its own.
Opening string `json:"opening"`
// Imported says that the issue, or its status, was taken from its folder in hq by the import of
+104 -28
View File
@@ -22,7 +22,7 @@ import (
const Seat = "issue-tracker"
// Verbs are the seat's verbs, as the manifest declares and claims them.
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom"}
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set"}
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
@@ -38,6 +38,9 @@ type Settings struct {
DuplicateCheck string `json:"duplicate-check,omitempty"`
// JudgeModel is the model the duplicate check asks; DefaultJudgeModel when empty.
JudgeModel string `json:"judge-model,omitempty"`
// ForgeOwner is the owner on the forge of the repositories fixed-by names, for asking a pull request's
// delivery (ADR 0279 §3).
ForgeOwner string `json:"forge-owner,omitempty"`
}
func readSettings(path string) (Settings, error) {
@@ -73,6 +76,13 @@ func main() {
Sleep: time.Sleep,
Judge: busJudgement(os.Getenv("MESH_NODE")),
}
t.Deliveries = func(repository, number string) (string, error) {
owner := "novox"
if s, err := t.Settings(); err == nil && strings.TrimSpace(s.ForgeOwner) != "" {
owner = strings.TrimSpace(s.ForgeOwner)
}
return busDeliveryState(owner)(repository, number)
}
if len(os.Args) > 1 && os.Args[1] == "dry-run" {
os.Exit(dryRunAtTheTerminal(t, os.Args[2:]))
}
@@ -86,17 +96,24 @@ func main() {
}
}
// hearOpened subscribes the duplicate check to the tracker's own `opened` event (ADR 0278 §1), asking again
// until the runtime takes it: the runtime answers only once Serve runs.
// hearOpened subscribes the triage to the tracker's own `opened` and `moved` events and to mesh-delivery's
// `transition` (ADR 0278 §1, ADR 0279 §3), each asked again until the runtime takes it: the runtime answers only
// once Serve runs.
func hearOpened(t *Tracker) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
time.Sleep(wait)
err := stdio.Subscribe("mesh-issues."+EventOpened, t.OnOpened)
if err == nil {
logf("[mesh-issues] the duplicate check hears every issue opened")
return
for _, s := range []struct {
pattern string
handle func(stdio.Envelope) error
}{{"mesh-issues." + EventOpened, t.OnOpened}, {"mesh-issues." + EventMoved, t.OnMoved},
{"mesh-delivery.transition", t.OnTransition}} {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
time.Sleep(wait)
err := stdio.Subscribe(s.pattern, s.handle)
if err == nil {
logf("[mesh-issues] the triage hears %s", s.pattern)
break
}
logf("[mesh-issues] the triage does not hear %s yet (%v); asking again in %s", s.pattern, err, wait)
}
logf("[mesh-issues] the duplicate check does not hear issues opened yet (%v); asking again in %s", err, wait)
}
}
@@ -271,9 +288,11 @@ func Tools(t *Tracker) []stdio.Tool {
"report": text("what was seen, what was expected, and how it was found"),
"located-in": texts("the owners it is in, each a repository such as mesh-controller or a module such as \"mesh-catalog modules/nats\", optionally followed by where in it"),
"record": text("for an issue with a folder in hq: the folder's name without its number, such as the-counter-skips-a-number"),
"severity": map[string]any{"type": "string", "enum": Severities, "description": "when known: critical (something the operator relies on is broken, or data at risk), major (a rule does not hold, or people work around it), minor (a contained fix, nothing the operator sees changes); left out, the triage estimates it"},
"needs": map[string]any{"type": "string", "enum": NeedsKinds, "description": "when known: none, decision (a decision record first) or operator (the operator's answer is required); left out, the triage estimates it"},
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("open", m, "title", "report", "located-in", "record")
a, err := readArgs("open", m, "title", "report", "located-in", "record", "severity", "needs")
if err != nil {
return nil, err
}
@@ -290,11 +309,18 @@ func Tools(t *Tracker) []stdio.Tool {
if ask.Record, err = a.str("record"); err != nil {
return nil, err
}
if ask.Severity, err = a.str("severity"); err != nil {
return nil, err
}
if ask.Needs, err = a.str("needs"); err != nil {
return nil, err
}
return t.Open(ask)
}},
{Name: Seat + ".get",
Description: "One issue whole, by its number: title, status, report, who opened it and when, the owners it " +
"is located in, what fixed it, its notes and its links.",
Description: "One issue whole, by its number: title, status, severity, what it needs, report, who opened it " +
"and when, the owners it is located in, what fixed it and whether that is delivered, its notes and its " +
"links; and the issues it waits for, those that depend on it (needed-by), and whether it is ready.",
Input: schema([]string{"number"}, map[string]any{"number": number("the issue's number")}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("get", m, "number")
@@ -305,20 +331,25 @@ func Tools(t *Tracker) []stdio.Tool {
if err != nil {
return nil, err
}
return t.Get(n)
return t.View(n)
}},
{Name: Seat + ".list",
Description: "The issues, newest first, one short row each — number, status, owner, title — optionally only " +
"those at one status (" + statuses + "), located in one owner, or whose title or report holds a phrase. " +
"Answers what is open in one call.",
"those at one status (" + statuses + "), located in one owner, at one severity, needing one thing, " +
"ready or not, or whose title or report holds a phrase. An agent looking for work asks severity minor, " +
"needs none, ready. An issue is ready when everything it depends on is closed, or resolved with its fix " +
"delivered. Answers what is open in one call.",
Input: schema(nil, map[string]any{
"status": map[string]any{"type": "string", "enum": Statuses, "description": "only issues at this status"},
"owner": text("only issues located in this owner, such as mesh-controller"),
"phrase": text("only issues whose title or report holds these words"),
"limit": map[string]any{"type": "integer", "minimum": 1, "maximum": MostListResult, "description": "at most this many rows (default 50)"},
"status": map[string]any{"type": "string", "enum": Statuses, "description": "only issues at this status"},
"owner": text("only issues located in this owner, such as mesh-controller"),
"phrase": text("only issues whose title or report holds these words"),
"limit": map[string]any{"type": "integer", "minimum": 1, "maximum": MostListResult, "description": "at most this many rows (default 50)"},
"severity": map[string]any{"type": "string", "enum": Severities, "description": "only issues at this severity"},
"needs": map[string]any{"type": "string", "enum": NeedsKinds, "description": "only issues that need this"},
"ready": map[string]any{"type": "boolean", "description": "only issues ready (true) or waiting (false)"},
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("list", m, "status", "owner", "phrase", "limit")
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready")
if err != nil {
return nil, err
}
@@ -332,6 +363,19 @@ func Tools(t *Tracker) []stdio.Tool {
if ask.Phrase, err = a.str("phrase"); err != nil {
return nil, err
}
if ask.Severity, err = a.str("severity"); err != nil {
return nil, err
}
if ask.Needs, err = a.str("needs"); err != nil {
return nil, err
}
if v, given := m["ready"]; given && v != nil {
r, err := a.flag("ready")
if err != nil {
return nil, err
}
ask.Ready = &r
}
if _, given := m["limit"]; given {
l, err := a.number("limit", false)
if err != nil {
@@ -470,13 +514,14 @@ func Tools(t *Tracker) []stdio.Tool {
return t.Import(ask, records)
}},
{Name: Seat + ".groom",
Description: "The duplicate check over the backlog: every issue not resolved is judged, " +
"oldest first, against the older ones not resolved, by the agent on this node; a certain duplicate is " +
"closed as wontfix, \"duplicate of N\", its report kept as a note on the older issue, which never moves; " +
"a related pair is linked relates-to and noted. Without arguments it starts a dry run in the background " +
"that writes nothing on any issue and answers its pass id; with pass, that pass's progress and findings; " +
"with confirm and pass, it applies exactly that pass's findings, each checked again (one whose issues have " +
"since moved on is skipped and said). Resolved and wontfix issues are never touched. One pass at a time.",
Description: "The triage over the backlog: every issue not resolved and not yet triaged is judged, " +
"oldest first, against the older ones not resolved, by the agent on this node — its severity, what it " +
"needs, a suggested owner, the issues it depends on or relates to, and whether it repeats one. Applied, " +
"it fills only empty values, adds links and never removes one, and closes a certain duplicate as wontfix, " +
"\"duplicate of N\", its report kept as a note on the older issue, which never moves. Without arguments " +
"it starts a dry run in the background that writes nothing on any issue and answers its pass id; with " +
"pass, that pass's progress and what it would do; with confirm and pass, it applies exactly that pass, " +
"each issue checked again. Resolved and wontfix issues are never touched. One pass at a time.",
Input: schema(nil, map[string]any{
"pass": text("a pass's id, as a dry run answered it"),
"confirm": map[string]any{"type": "boolean", "description": "apply the pass's findings"},
@@ -495,5 +540,36 @@ func Tools(t *Tracker) []stdio.Tool {
}
return t.Groom(ask, func(f func()) { go f() })
}},
{Name: Seat + ".set",
Description: "Change an issue's severity (critical, major, minor), what it needs (none, decision, operator) or its " +
"suggested owner (an owner on the registered list), as a person decides. A value the triage estimated is " +
"changed like any other; the triage never changes a value set here. Refused: a value not of its kind, an " +
"owner not registered, nothing to set.",
Input: schema([]string{"number"}, map[string]any{
"number": number("the issue's number"),
"severity": map[string]any{"type": "string", "enum": Severities},
"needs": map[string]any{"type": "string", "enum": NeedsKinds},
"suggested-owner": text("an owner on the registered list, such as mesh-controller"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("set", m, "number", "severity", "needs", "suggested-owner")
if err != nil {
return nil, err
}
var ask SetAsk
if ask.Number, err = a.number("number", true); err != nil {
return nil, err
}
if ask.Severity, err = a.str("severity"); err != nil {
return nil, err
}
if ask.Needs, err = a.str("needs"); err != nil {
return nil, err
}
if ask.SuggestedOwner, err = a.str("suggested-owner"); err != nil {
return nil, err
}
return t.Set(ask)
}},
}
}
@@ -83,7 +83,8 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) {
if m.Module != "mesh-issues" || !reflect.DeepEqual(m.Seats, want) || !reflect.DeepEqual(m.Claims, want) {
t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims)
}
if !reflect.DeepEqual(m.Emits, []string{EventOpened, EventMoved, EventNoted, EventLinked, EventImported}) {
if !reflect.DeepEqual(m.Emits, []string{EventOpened, EventMoved, EventNoted, EventLinked, EventImported, EventSet,
EventTriaged, EventReady}) {
t.Fatalf("emits %v", m.Emits)
}
// Three states, none expiring: issues keep a short history per key, the counter one value, the backlog's
@@ -95,8 +96,8 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) {
t.Fatalf("state %+v", d)
}
// The duplicate check hears the tracker's own opens and asks the agent module on its node (ADR 0278 §1, §5).
if !reflect.DeepEqual(m.Consumes, []string{"mesh-issues." + EventOpened}) ||
!reflect.DeepEqual(m.Invokes, []string{"claude-code.claude_code_judge"}) {
if !reflect.DeepEqual(m.Consumes, []string{"mesh-issues." + EventOpened, "mesh-issues." + EventMoved, "mesh-delivery.transition"}) ||
!reflect.DeepEqual(m.Invokes, []string{"claude-code.claude_code_judge", "seat:mesh-delivery.checks"}) {
t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes)
}
if len(m.Build.Artifacts) != 1 || m.Build.Artifacts[0].From != "cmd/mesh-issues" ||
Binary file not shown.
@@ -0,0 +1,397 @@
package main
// Severity, needs, dependencies and readiness (novox/hq ADR 0279 §1–§3). An issue is ready when it is not
// resolved or wontfix and every issue it depends on is wontfix, or resolved with every pull request its fixed-by
// names delivered. The tracker learns a delivery from mesh-delivery's `transition` event, and asks
// mesh-delivery's `checks` once when it hears its own `moved` to resolved; an issue made ready gains a note and
// the event `ready`.
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The events of ADR 0279.
const (
EventSet = "set"
EventTriaged = "triaged"
EventReady = "ready"
)
// ErrCycle is a depends-on link that would close a cycle.
var ErrCycle = errors.New("it would close a cycle")
// SetAsk is what `set` carries: the values a person decides.
type SetAsk struct {
Number uint64
Severity string
Needs string
SuggestedOwner string
}
// SetDone is set's answer.
type SetDone struct {
Number uint64 `json:"number"`
Changed []string `json:"changed"`
By string `json:"by"`
Said string `json:"said"`
Announced bool `json:"announced"`
Warning string `json:"warning,omitempty"`
}
// Set changes an issue's severity, needs or suggested owner, as a person decides.
func (t *Tracker) Set(a SetAsk) (*SetDone, error) { return t.set(t.by(), a, false) }
// set is Set as the one who acts. onlyEmpty fills only a value that is empty, as the triage does: a value a
// person gave meanwhile stands.
func (t *Tracker) set(by string, a SetAsk, onlyEmpty bool) (*SetDone, error) {
var err error
if a.Severity != "" {
if a.Severity, err = oneOf("severity", a.Severity, Severities); err != nil {
return nil, err
}
}
if a.Needs != "" {
if a.Needs, err = oneOf("needs", a.Needs, NeedsKinds); err != nil {
return nil, err
}
}
if strings.TrimSpace(a.SuggestedOwner) != "" {
owners, err := t.owners()
if err != nil {
return nil, err
}
if a.SuggestedOwner, err = owners.locatedIn(a.SuggestedOwner); err != nil {
return nil, fmt.Errorf("suggested-owner: %v", err)
}
}
if a.Severity == "" && a.Needs == "" && a.SuggestedOwner == "" {
return nil, errors.New("set changes severity, needs or suggested-owner: give at least one")
}
var changed []string
is, err := t.change(a.Number, func(is *Issue) error {
changed = nil
for _, f := range []struct {
name string
to string
at *string
}{{"severity", a.Severity, &is.Severity}, {"needs", a.Needs, &is.Needs}, {"suggested-owner", a.SuggestedOwner, &is.SuggestedOwner}} {
if f.to == "" || *f.at == f.to || (onlyEmpty && *f.at != "") {
continue
}
*f.at = f.to
changed = append(changed, f.name)
}
if len(changed) == 0 {
return errNothing
}
return nil
})
if errors.Is(err, errNothing) {
return &SetDone{Number: a.Number, Changed: []string{}, By: by, Said: fmt.Sprintf("Issue %d already holds that.", a.Number)}, nil
}
if err != nil {
return nil, err
}
out := &SetDone{Number: a.Number, Changed: changed, By: by,
Said: fmt.Sprintf("Issue %d: set %s.", a.Number, strings.Join(changed, ", "))}
body := map[string]any{"number": a.Number, "by": by, "at": t.now()}
for _, c := range changed {
switch c {
case "severity":
body[c] = is.Severity
case "needs":
body[c] = is.Needs
case "suggested-owner":
body[c] = is.SuggestedOwner
}
}
out.Announced, out.Warning = t.emit(EventSet, body)
return out, nil
}
var errNothing = errors.New("nothing to change")
// dependsOn is the issues an issue depends on.
func dependsOn(is *Issue) []uint64 {
var out []uint64
for _, l := range is.Links {
if l.Kind == LinkDependsOn {
if n, ok := numberOfKey(l.Target); ok {
out = append(out, n)
}
}
}
return out
}
// reaches says whether `from` depends on `to`, directly or through others.
func reaches(all map[uint64]*Issue, from, to uint64) bool {
seen := map[uint64]bool{}
stack := []uint64{from}
for len(stack) > 0 {
n := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if n == to {
return true
}
if seen[n] {
continue
}
seen[n] = true
if is := all[n]; is != nil {
stack = append(stack, dependsOn(is)...)
}
}
return false
}
// pullRequestRef is a fixed-by entry naming a pull request: `<repository>#<number>`, optionally with the owner.
var pullRequestRef = regexp.MustCompile(`^(?:[A-Za-z0-9._-]+/)?([a-z0-9][a-z0-9._-]{0,99})#([1-9][0-9]{0,6})$`)
// pullRequests is the pull requests an issue's fixed-by names, as `<repository>#<number>`.
func pullRequests(is *Issue) []string {
var out []string
for _, f := range is.FixedBy {
if m := pullRequestRef.FindStringSubmatch(strings.TrimSpace(f)); m != nil {
out = append(out, m[1]+"#"+m[2])
}
}
return out
}
func contains(list []string, s string) bool {
for _, e := range list {
if e == s {
return true
}
}
return false
}
// done says whether an issue another depends on lets it go: wontfix, or resolved with every pull request its
// fix names delivered.
func done(dep *Issue) bool {
if dep.Status == StatusWontfix {
return true
}
if dep.Status != StatusResolved {
return false
}
for _, pr := range pullRequests(dep) {
if !contains(dep.Delivered, pr) {
return false
}
}
return true
}
// waitsFor is the issues an issue depends on that do not let it go yet; an issue not kept counts as waiting.
func waitsFor(all map[uint64]*Issue, is *Issue) []uint64 {
var out []uint64
for _, n := range dependsOn(is) {
if dep := all[n]; dep == nil || !done(dep) {
out = append(out, n)
}
}
return out
}
// isReady says whether an issue is ready (ADR 0279 §3).
func isReady(all map[uint64]*Issue, is *Issue) bool {
return unresolved(is) && len(waitsFor(all, is)) == 0
}
// neededBy is the issues that depend on an issue.
func neededBy(all map[uint64]*Issue, n uint64) []uint64 {
var out []uint64
for _, is := range all {
for _, d := range dependsOn(is) {
if d == n {
out = append(out, is.Number)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// View is one issue as `get` answers it: the issue, what depends on it, what it waits for, and whether it is
// ready.
type View struct {
*Issue
NeededBy []uint64 `json:"needed-by,omitempty"`
WaitsFor []uint64 `json:"waits-for,omitempty"`
Ready bool `json:"ready"`
}
// View is `get`.
func (t *Tracker) View(n uint64) (*View, error) {
all, err := t.allIssues()
if err != nil {
return nil, err
}
is := all[n]
if is == nil {
return nil, fmt.Errorf("there is no issue %d: %w", n, ErrNoIssue)
}
return &View{Issue: is, NeededBy: neededBy(all, n), WaitsFor: waitsFor(all, is), Ready: isReady(all, is)}, nil
}
// markDelivered records that a pull request is delivered on every issue whose fixed-by names it, and answers
// those issues' numbers.
func (t *Tracker) markDelivered(pr string, only uint64) ([]uint64, error) {
all, err := t.allIssues()
if err != nil {
return nil, err
}
var marked []uint64
for n, is := range all {
if (only != 0 && n != only) || !contains(pullRequests(is), pr) || contains(is.Delivered, pr) {
continue
}
if _, err := t.change(n, func(i *Issue) error {
if !contains(i.Delivered, pr) {
i.Delivered = append(i.Delivered, pr)
}
return nil
}); err != nil {
return marked, err
}
marked = append(marked, n)
}
sort.Slice(marked, func(i, j int) bool { return marked[i] < marked[j] })
return marked, nil
}
// letGo says, for an issue that may have just let others go (its fix delivered, or wontfix), every issue it
// made ready: a note on each, once, and the event `ready`.
func (t *Tracker) letGo(dep uint64) error {
all, err := t.allIssues()
if err != nil {
return err
}
d := all[dep]
if d == nil || !done(d) {
return nil
}
for _, w := range neededBy(all, dep) {
is := all[w]
if is == nil || !isReady(all, is) {
continue
}
prefix := fmt.Sprintf("Ready: issue %d", dep)
if noted(is, prefix) {
continue
}
why := "is resolved and its fix is delivered"
if d.Status == StatusWontfix {
why = "was closed as wontfix"
}
if _, err := t.note(GroomBy, w, fmt.Sprintf("%s, which this one depends on, %s; nothing it depends on is left "+
"waiting, so it is ready. (novox/hq ADR 0279)", prefix, why)); err != nil {
return err
}
t.emit(EventReady, map[string]any{"number": w, "after": dep, "at": t.now()})
}
return nil
}
// DeliveryState answers a pull request's delivery state, as mesh-delivery's `checks` says it; a test stubs it.
type DeliveryState func(repository string, number string) (string, error)
// busDeliveryState asks mesh-delivery's checks over the bus.
func busDeliveryState(owner string) DeliveryState {
return func(repository, number string) (string, error) {
raw, err := stdio.Ask("seat:mesh-delivery.checks", map[string]any{"repository": owner + "/" + repository, "number": number})
if err != nil {
return "", err
}
var out struct {
Delivery struct {
State string `json:"state"`
} `json:"delivery"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("mesh-delivery's checks answered no delivery: %.200s", raw)
}
return out.Delivery.State, nil
}
}
// OnTransition is the handler of mesh-delivery's `transition` event: a pull request delivered marks every issue
// whose fixed-by names it, and lets go what waited on them.
func (t *Tracker) OnTransition(e stdio.Envelope) error {
var body struct {
Repository string `json:"repository"`
Number int `json:"number"`
To string `json:"to"`
}
if err := json.Unmarshal(e.Body, &body); err != nil || body.To != "delivered" || body.Number <= 0 {
return nil
}
_, repo, ok := strings.Cut(body.Repository, "/")
if !ok {
repo = body.Repository
}
pr := fmt.Sprintf("%s#%d", strings.ToLower(repo), body.Number)
marked, err := t.markDelivered(pr, 0)
if err != nil {
return err
}
for _, n := range marked {
if err := t.letGo(n); err != nil {
return err
}
}
return nil
}
// OnMoved is the handler of the tracker's own `moved` event: an issue resolved has each pull request of its fix
// asked of mesh-delivery once, for a delivery that happened before it was resolved; an issue resolved or
// closed lets go what waited on it.
func (t *Tracker) OnMoved(e stdio.Envelope) error {
var body struct {
Number uint64 `json:"number"`
To string `json:"to"`
}
if err := json.Unmarshal(e.Body, &body); err != nil || body.Number == 0 {
return nil
}
if body.To != StatusResolved && body.To != StatusWontfix {
return nil
}
is, _, err := read(t.Store, body.Number)
if errors.Is(err, ErrNoIssue) {
return nil
}
if err != nil {
return err
}
if body.To == StatusResolved && t.Deliveries != nil {
for _, pr := range pullRequests(is) {
if contains(is.Delivered, pr) {
continue
}
repo, number, _ := strings.Cut(pr, "#")
state, err := t.Deliveries(repo, number)
if err != nil {
// Not known now: the delivery's own event marks it when it comes.
t.Logf("[mesh-issues] the delivery of %s (issue %d) could not be asked: %v", pr, body.Number, err)
continue
}
if state == "delivered" {
if _, err := t.markDelivered(pr, body.Number); err != nil {
return err
}
}
}
}
return t.letGo(body.Number)
}
+78 -25
View File
@@ -28,9 +28,11 @@ type Tracker struct {
Sleep func(time.Duration)
// Records is the record the import reads; nil is the records module over the bus.
Records Records
// Judge is the duplicate check's way to a model (ADR 0278); nil judges nothing.
// Judge is the triage's way to a model (ADR 0278, ADR 0279); nil judges nothing.
Judge Judgement
groom grooming
// Deliveries answers a pull request's delivery state (ADR 0279 §3); nil asks nothing.
Deliveries DeliveryState
groom grooming
}
// The events this module says, one per kind of change.
@@ -84,6 +86,8 @@ type OpenAsk struct {
Report string
LocatedIn []string
Record string
Severity string
Needs string
}
// Opened is the answer to an open.
@@ -125,8 +129,20 @@ func (t *Tracker) Open(a OpenAsk) (*Opened, error) {
return nil, err
}
}
var severity, needs string
if strings.TrimSpace(a.Severity) != "" {
if severity, err = oneOf("severity", a.Severity, Severities); err != nil {
return nil, err
}
}
if strings.TrimSpace(a.Needs) != "" {
if needs, err = oneOf("needs", a.Needs, NeedsKinds); err != nil {
return nil, err
}
}
at := t.now()
is := Issue{Title: title, Status: StatusOpen, Report: report, Opened: at, OpenedBy: t.by(), LocatedIn: located, Opening: newID()}
is := Issue{Title: title, Status: StatusOpen, Report: report, Opened: at, OpenedBy: t.by(), LocatedIn: located, Opening: newID(),
Severity: severity, Needs: needs}
n, err := t.next()
if err != nil {
@@ -146,7 +162,7 @@ func (t *Tracker) Open(a OpenAsk) (*Opened, error) {
}
out.Announced, out.Warning = t.emit(EventOpened, map[string]any{
"number": n, "title": title, "status": is.Status, "located-in": located, "record": is.Record,
"by": is.OpenedBy, "at": at})
"severity": severity, "needs": needs, "by": is.OpenedBy, "at": at})
return out, nil
}
@@ -525,7 +541,7 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) {
if err != nil {
return nil, err
}
if (kind == LinkDuplicates || kind == LinkRelatesTo) && !a.Remove {
if (kind == LinkDuplicates || kind == LinkRelatesTo || kind == LinkDependsOn) && !a.Remove {
other, ok := numberOfKey(target)
if !ok {
return nil, fmt.Errorf("%q is not an issue's number", target)
@@ -536,6 +552,18 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) {
if _, _, err := read(t.Store, other); err != nil {
return nil, err
}
if kind == LinkDependsOn {
// A cycle is refused, naming it (ADR 0279 §1). Read whole here; a race of two links closing one cycle
// at the same moment is not ruled out, and readiness then waits, which is the safe side.
all, err := t.allIssues()
if err != nil {
return nil, err
}
if reaches(all, other, a.Number) {
return nil, fmt.Errorf("issue %d depending on %d: %w, as %d already depends on %d", a.Number, other,
ErrCycle, other, a.Number)
}
}
}
at := t.now()
is, err := t.change(a.Number, func(is *Issue) error {
@@ -573,18 +601,25 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) {
// ListAsk narrows a list.
type ListAsk struct {
Status string
Owner string
Phrase string
Limit int
Status string
Owner string
Phrase string
Limit int
Severity string
Needs string
// Ready, when set, keeps only the issues ready (true) or not ready (false).
Ready *bool
}
// Row is one issue in a list: short enough to read in a chat.
type Row struct {
Number uint64 `json:"number"`
Status string `json:"status"`
Owner string `json:"owner,omitempty"`
Title string `json:"title"`
Number uint64 `json:"number"`
Status string `json:"status"`
Owner string `json:"owner,omitempty"`
Title string `json:"title"`
Severity string `json:"severity,omitempty"`
Needs string `json:"needs,omitempty"`
Ready bool `json:"ready"`
}
// Listed is the answer to a list.
@@ -612,33 +647,46 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
return nil, err
}
}
severity, needs := strings.TrimSpace(a.Severity), strings.TrimSpace(a.Needs)
if severity != "" {
if _, err := oneOf("severity", severity, Severities); err != nil {
return nil, err
}
}
if needs != "" {
if _, err := oneOf("needs", needs, NeedsKinds); err != nil {
return nil, err
}
}
limit := a.Limit
if limit <= 0 {
limit = 50
}
limit = min(limit, MostListResult)
keys, err := t.Store.Keys(IssuesState)
all, err := t.allIssues()
if err != nil {
return nil, fmt.Errorf("the issues cannot be listed: %v", err)
return nil, err
}
var numbers []uint64
for _, k := range keys {
if n, ok := numberOfKey(k); ok {
numbers = append(numbers, n)
}
for n := range all {
numbers = append(numbers, n)
}
sort.Slice(numbers, func(i, j int) bool { return numbers[i] > numbers[j] })
out := &Listed{Rows: []Row{}}
lower := strings.ToLower(phrase)
for _, n := range numbers {
is, _, err := read(t.Store, n)
if errors.Is(err, ErrNoIssue) {
is := all[n]
if status != "" && is.Status != status {
continue
}
if err != nil {
return nil, err
if severity != "" && is.Severity != severity {
continue
}
if status != "" && is.Status != status {
if needs != "" && is.Needs != needs {
continue
}
ready := isReady(all, is)
if a.Ready != nil && ready != *a.Ready {
continue
}
if owner != "" && !locatedUnder(is.LocatedIn, owner) {
@@ -649,7 +697,12 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
}
out.Matched++
if len(out.Rows) < limit {
out.Rows = append(out.Rows, Row{Number: n, Status: is.Status, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title})
owner := strings.Join(is.LocatedIn, "; ")
if owner == "" && is.SuggestedOwner != "" {
owner = is.SuggestedOwner + " (suggested)"
}
out.Rows = append(out.Rows, Row{Number: n, Status: is.Status, Owner: owner, Title: is.Title,
Severity: is.Severity, Needs: is.Needs, Ready: ready})
}
}
switch {
@@ -579,7 +579,7 @@ func TestTheVerbsReadTheirArgumentsStrictly(t *testing.T) {
t.Errorf("%s %v was taken", r.verb, r.args)
}
}
if out, err := h.call(t, "get", map[string]any{"number": keyOf(n)}); err != nil || out.(*Issue).Number != n {
if out, err := h.call(t, "get", map[string]any{"number": keyOf(n)}); err != nil || out.(*View).Number != n {
t.Fatalf("a number as text: %v", err)
}
if out, err := h.call(t, "open", map[string]any{"title": "t", "report": "r", "located-in": "hq"}); err != nil ||
@@ -0,0 +1,734 @@
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: 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: 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: 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) (string, error) {
asked++
if repo != "mesh-catalog" || number != "9" {
t.Errorf("asked %s#%s", repo, number)
}
return "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) (string, error) { return "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: none of 6 older") {
t.Fatalf("%q", nn)
}
}