Merge pull request 'mesh-issues: triage every new issue — severity, needs, suggested owner, dependencies — and ready when what it depends on is delivered (hq ADR 0279)' (#184) from feat/mesh-issues-triage into main

This commit was merged in pull request #184.
This commit is contained in:
2026-10-10 16:11:57 +00:00
13 changed files with 2032 additions and 823 deletions
+1
View File
@@ -15,3 +15,4 @@ modules/claude-code/claude-code
modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-delivery/mesh-delivery
modules/mesh-issues/cmd/mesh-issues/mesh-issues
+40 -26
View File
@@ -23,7 +23,8 @@ what it refuses.
| `note` | `number`, `text` | appends a note; a note is never edited |
| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link |
| `import` | optionally `dry-run`, `commit` | hq's issue folders, read through the records module, each written as an issue under its own number; see below |
| `groom` | optionally `pass`, `confirm` | the duplicate check over the backlog: without arguments a dry run in the background, answering its pass id; with `pass` its progress and findings; with `confirm` and `pass` exactly that pass's findings applied, each checked again; see below |
| `set` | `number`, any of `severity`, `needs`, `suggested-owner` | changes them as a person decides |
| `groom` | optionally `pass`, `confirm` | the triage over the backlog: without arguments a dry run in the background, answering its pass id; with `pass` its progress and findings; with `confirm` and `pass` exactly that pass's findings applied, each checked again; see below |
**The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the
folders until the import): a status is one of `open`,
@@ -36,36 +37,49 @@ needs a `reason`. A refused change changes nothing and says which rule refused i
tracker like any other: the folder holds its narrative, the tracker its status. Until the import of
slice 3 ran, such an issue kept its status in the folder and `move` refused it; the import filled it.
## The duplicate check (hq ADR 0278)
## The triage (hq ADR 0278, ADR 0279)
Every issue opened is checked by the module itself: it consumes its own `mesh-issues.opened` event, and for an
Every issue opened is triaged by the module itself: it consumes its own `mesh-issues.opened` event, and for an
issue still at `open` asks the agent on its node — `claude-code.claude_code_judge@<node>`, which runs the agent
with no tools under the node's licence — two questions: a shortlist of at most five older issues by their titles,
then a verdict on the reports (`duplicate`, `related` or `none`, whether it is certain, and why). Only issues at
`open`, `diagnosing` or `located` that are older than the new one are offered; a resolved or wontfix issue is
never offered, linked or noted.
then, always, a judgement on the reports: whether it repeats one (`duplicate`, `related` or `none`, whether it is
certain, and why), its **severity** (critical, major, minor), what it **needs** (none, decision, operator), a
suggested owner when it names none, and the shortlisted issues it **depends on** or **relates to**, each with a
one-line reason. Only issues at `open`, `diagnosing` or `located` that are older than the new one are offered.
| verdict | done, through the module's own `note`, `link` and `move`, each recorded as by `duplicate-check` |
|---|---|
| `duplicate`, certain | the verdict noted on the new issue; linked `duplicates` to the older; its title and report noted on the older; moved to `wontfix`, reason "duplicate of N" |
| `related`, or `duplicate` not certain | the verdict noted; linked `relates-to`; noted on the older; nothing moves |
| `none` | one note: checked against so many issues |
The judgement is kept on the issue (`triage`) and said as the event `triaged` before anything else, then applied
through the module's own `note`, `set`, `link` and `move`, each recorded as by `triage` and each only when not
done yet:
The older issue never moves. The verdict note is written first, its first line in a fixed form (`Duplicate
check: duplicate of N.`), so a second delivery of the event, or the backlog's pass, does only the steps not yet
done and never judges again. A judgement that fails — no answer, a late one, an answer outside the shape, an
issue not offered — leaves one note saying why, and the event is not taken: the bus offers it again, and after
its fifth delivery the controller raises `max-deliveries` on the module's consumer and keeps the event for a
person to deliver again.
1. one note saying the whole judgement, a line per value with its reason; where the issuer gave a severity or
needs the triage disagrees with, the line says so and the given value stands;
2. each empty value filled — never one a person gave;
3. each `depends-on` and `relates-to` link added, unless it is there, would close a cycle, or its target is
resolved or wontfix; the older issue gains a note;
4. a certain duplicate: linked `duplicates`, its title and report noted on the older, moved to `wontfix`
"duplicate of N" last. The older issue never moves.
`groom` runs the same judgement over every issue not resolved and not yet checked, oldest first, against the
older ones not resolved and not found a duplicate in the same pass. It writes only the pass, in the state
`grooming` (the last pass, one key), and applies that pass's findings on `confirm` — never a fresh judgement.
A finding whose issues have moved on is skipped and said. It adds no "none" note to the backlog.
A second delivery of the event finds the kept judgement and finishes what is missing without judging again. A
judgement that fails leaves one note, and the event is not taken: after its fifth delivery the controller raises
`max-deliveries` on the module's consumer. Notes the duplicate check of ADR 0278 wrote as `duplicate-check` are
still read as that issue's verdict.
The tracker asks one judgement at a time; the pass waits while a new issue's check is waiting, and one verdict is
applied at a time. A check may take up to a minute (two judgements of at most 25 seconds each), which the tool
runner allows a handler while keeping its event alive (hq issue 387).
**Ready.** 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 (`<repository>#<number>`) delivered. The module consumes
`mesh-delivery.transition` and marks a pull request delivered on every issue naming it; at its own `moved` to
resolved it asks `seat:mesh-delivery.checks` once for what it has not heard. An issue made ready gains a note and
the event `ready`. `get` answers `waits-for`, `needed-by` and `ready`; `list` filters on `severity`, `needs` and
`ready` — an agent looking for work asks `severity=minor`, `needs=none`, `ready=true`. `set` changes severity,
needs and suggested owner as a person decides. `link` refuses a `depends-on` that would close a cycle.
`groom` triages every issue not resolved and not yet triaged, oldest first, against the older ones not resolved
and not found a duplicate in the same pass. It writes only the pass, in the state `grooming` (the last pass, one
key), each finding saying what applying it would do, and applies exactly that pass on `confirm`, each issue
checked again. One judgement runs at a time; the pass waits while a new issue's triage waits. A triage may take up
to two minutes, which the tool runner allows a handler while keeping its event alive (hq issue 387).
The new values are optional keys of an issue's one JSON value: an issue kept before reads as having none; the
key-value state has no schema to migrate.
## The import of hq's issues (slice 3)
@@ -159,8 +173,8 @@ verb takes an argument naming a caller, and none can be given one.
## Settings
`duplicate-check` (`on`; `off` stops the check on new issues) and `judge-model` (`sonnet`; `haiku` or `opus`)
steer the duplicate check.
`duplicate-check` (`on`; `off` stops the triage of new issues), `judge-model` (`sonnet`; `haiku` or `opus`) and
`forge-owner` (the forge's owner of the repositories `fixed-by` names, for asking a delivery) steer the triage.
```
{
+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,21 @@ 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"`
// Undelivered is the pull requests of FixedBy asked of the delivery at the move to resolved and not yet
// delivered then; their own delivery event marks them.
Undelivered []string `json:"undelivered,omitempty"`
// ReadyFor is the issues it depended on when it was last said ready (ADR 0279 §3), so one spell of
// readiness is said once however many dependencies let it go at one delivery.
ReadyFor []uint64 `json:"ready-for,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" ||
@@ -0,0 +1,480 @@
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")
// MostDeliveryAsks is how many deliveries one handling of a `moved` event asks of mesh-delivery.
const MostDeliveryAsks = 3
func equalNumbers(a, b []uint64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// 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;
// pullRequestPage the forge's page of one.
var (
pullRequestRef = regexp.MustCompile(`^(?:[a-z0-9._-]+/)?([a-z0-9][a-z0-9._-]{0,99})#([1-9][0-9]{0,6})$`)
pullRequestPage = regexp.MustCompile(`^https?://\S+/([a-z0-9][a-z0-9._-]{0,99})/pulls?/([1-9][0-9]{0,6})/?$`)
)
// pullRequests is the pull requests an issue's fixed-by names, as `<repository>#<number>`, lower-case.
func pullRequests(is *Issue) []string {
var out []string
for _, f := range is.FixedBy {
f = strings.ToLower(strings.TrimSpace(f))
m := pullRequestRef.FindStringSubmatch(f)
if m == nil {
m = pullRequestPage.FindStringSubmatch(f)
}
if m != nil && !contains(out, m[1]+"#"+m[2]) {
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: resolved with every pull request its fix names
// delivered, or wontfix — but a wontfix closed as the duplicate of another lets go only when that one does, as
// the work moved there (ADR 0279 §3).
func done(all map[uint64]*Issue, dep *Issue) bool {
seen := map[uint64]bool{}
for dep != nil && dep.Status == StatusWontfix {
if seen[dep.Number] {
return false
}
seen[dep.Number] = true
original := uint64(0)
for _, l := range dep.Links {
if l.Kind == LinkDuplicates {
original, _ = numberOfKey(l.Target)
}
}
if original == 0 {
return true
}
dep = all[original]
}
if dep == nil {
return false
}
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(all, 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(all, d) {
return nil
}
// Those that depend on it, and on anything closed as its duplicate.
waiting := neededBy(all, dep)
for n, is := range all {
for _, l := range is.Links {
if l.Kind == LinkDuplicates && l.Target == keyOf(dep) {
waiting = append(waiting, neededBy(all, n)...)
}
}
}
for _, w := range waiting {
is := all[w]
if is == nil || !isReady(all, is) {
continue
}
// Said once per spell of readiness: the dependencies it was said ready for are kept, claimed by
// compare-and-set before the note, so a second dependency letting it go at the same delivery, or the
// same event again, says nothing.
// At most once: a note that fails after the claim is not said again; the issue still reads ready.
deps := dependsOn(is)
sort.Slice(deps, func(i, j int) bool { return deps[i] < deps[j] })
claimed := false
if _, err := t.change(w, func(i *Issue) error {
claimed = false
if equalNumbers(i.ReadyFor, deps) {
return nil
}
i.ReadyFor, claimed = deps, true
return nil
}); err != nil {
return err
}
if !claimed {
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("Ready: issue %d, which this one depends on, %s; nothing it depends "+
"on is left waiting, so it is ready. (novox/hq ADR 0279)", dep, 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 {
// At most a few asks per handling, each up to the bus's 30 seconds, under the runtime's two minutes; one
// that fails, or more left to ask, has the event offered again, so a delivery not asked is never silent:
// past the last offer the controller raises max-deliveries.
asked := 0
for _, pr := range pullRequests(is) {
if contains(is.Delivered, pr) || contains(is.Undelivered, pr) {
continue
}
if asked == MostDeliveryAsks {
return fmt.Errorf("issue %d names more pull requests than are asked at once; the rest at the next offer", body.Number)
}
asked++
repo, number, _ := strings.Cut(pr, "#")
state, err := t.Deliveries(repo, number)
if err != nil {
return fmt.Errorf("the delivery of %s (issue %d) could not be asked: %v", pr, body.Number, err)
}
if state == "delivered" {
if _, err := t.markDelivered(pr, body.Number); err != nil {
return err
}
continue
}
// Asked and not delivered yet: the delivery's own event marks it when it comes.
if _, err := t.change(body.Number, func(i *Issue) error {
if !contains(i.Undelivered, pr) {
i.Undelivered = append(i.Undelivered, pr)
}
return nil
}); err != nil {
return err
}
}
}
return t.letGo(body.Number)
}
+76 -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,27 @@ 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"`
// SuggestedOwner is the triage's or a person's suggestion, for an issue located nowhere yet.
SuggestedOwner string `json:"suggested-owner,omitempty"`
}
// Listed is the answer to a list.
@@ -612,33 +649,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 +699,8 @@ 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})
out.Rows = append(out.Rows, Row{Number: n, Status: is.Status, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title,
Severity: is.Severity, Needs: is.Needs, Ready: ready, SuggestedOwner: is.SuggestedOwner})
}
}
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,810 @@
package main
import (
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// stubJudge answers the triage's two questions as a test says, and keeps every question asked. The verdict's
// estimates default to minor, none and no owner.
type stubJudge struct {
mu sync.Mutex
shortlist []uint64
verdict map[string]any
err error
asked []string
}
func (s *stubJudge) judge(question string, schema map[string]any, model string) (json.RawMessage, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.asked = append(s.asked, question)
if s.err != nil {
return nil, "", s.err
}
if _, second := schema["properties"].(map[string]any)["verdict"]; second {
v := map[string]any{"verdict": "none", "issue": 0, "certain": false, "reason": "", "severity": "minor",
"severity_reason": "a contained fix", "needs": "none", "needs_reason": "an agent can fix it", "owner": "",
"owner_reason": "", "depends_on": []any{}, "relates_to": []any{}}
for k, x := range s.verdict {
v[k] = x
}
raw, _ := json.Marshal(v)
return raw, "the agent on control (" + model + ")", nil
}
raw, _ := json.Marshal(map[string]any{"candidates": s.shortlist})
return raw, "the agent on control (" + model + ")", nil
}
func (s *stubJudge) questions() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.asked)
}
func withJudge(h *harness, s *stubJudge) *stubJudge {
h.Judge = s.judge
return s
}
func get(t *testing.T, h *harness, n uint64) *Issue {
t.Helper()
is, err := h.Get(n)
if err != nil {
t.Fatal(err)
}
return is
}
func triageNotes(is *Issue) []string {
var out []string
for _, n := range is.Notes {
if byTriage(n) {
out = append(out, n.Text)
}
}
return out
}
func resolve(t *testing.T, h *harness, n uint64, fixedBy ...string) {
t.Helper()
if len(fixedBy) == 0 {
fixedBy = []string{"mesh-catalog#1"}
}
if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, LocatedIn: []string{"mesh-catalog modules/records"},
FixedBy: fixedBy}); err != nil {
t.Fatal(err)
}
}
func events(h *harness, skip ...string) []string {
var out []string
for _, e := range h.said() {
if !contains(skip, e.Event) {
out = append(out, e.Event)
}
}
return out
}
func ptr[T any](v T) *T { return &v }
// A clear duplicate: the triage is kept first; the newer gets its values, is linked duplicates, its report kept
// on the older, and moves to wontfix "duplicate of N" last; the older stays open.
func TestAClearDuplicateIsClosedAndItsReportKeptOnTheOlder(t *testing.T) {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
other := h.open(t, "the desk shows nothing")
newer := h.open(t, "a number is skipped by the counter")
s := withJudge(h, &stubJudge{shortlist: []uint64{older}, verdict: map[string]any{
"verdict": "duplicate", "issue": older, "certain": true, "reason": "both say the counter skips a number"}})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n := get(t, h, newer)
if n.Status != StatusWontfix || n.Reason != "duplicate of 365" || n.Triage == nil || n.Severity != "minor" || n.Needs != "none" {
t.Fatalf("newer %s %q %+v %s %s", n.Status, n.Reason, n.Triage, n.Severity, n.Needs)
}
if len(n.Links) != 1 || n.Links[0].Kind != LinkDuplicates || n.Links[0].Target != "365" || n.Links[0].By != GroomBy {
t.Fatalf("links %+v", n.Links)
}
notes := triageNotes(n)
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Triage of this issue: duplicate of 365.") ||
!strings.Contains(notes[0], "the agent on control (sonnet)") || !strings.Contains(notes[0], "Severity: minor — a contained fix") {
t.Fatalf("newer's notes %q", notes)
}
o := get(t, h, older)
if o.Status != StatusOpen || len(o.Links) != 0 || o.Severity != "" {
t.Fatalf("the older moved, was linked or set: %s %+v", o.Status, o.Links)
}
notes = triageNotes(o)
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Triage: issue 367 was opened as a duplicate") ||
!strings.Contains(notes[0], "seen: a number is skipped by the counter") {
t.Fatalf("older's notes %q", notes)
}
if len(get(t, h, other).Notes) != 0 {
t.Fatal("an issue the verdict did not name was written")
}
if got := events(h, EventOpened); !reflect.DeepEqual(got, []string{EventTriaged, EventNoted, EventSet, EventLinked, EventNoted, EventMoved}) {
t.Fatalf("order %v", got)
}
if s.questions() != 2 {
t.Fatalf("%d questions", s.questions())
}
}
// Related, or a duplicate not certain: linked relates-to and noted on both; nothing moves.
func TestRelatedOrNotCertainIsLinkedAndNotedAndNothingMoves(t *testing.T) {
for _, v := range []map[string]any{
{"verdict": "related", "issue": 365, "certain": true, "reason": "the same counter, another symptom"},
{"verdict": "duplicate", "issue": 365, "certain": false, "reason": "it may be the same skip"},
} {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
newer := h.open(t, "the counter is slow")
withJudge(h, &stubJudge{shortlist: []uint64{older}, verdict: v})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n, o := get(t, h, newer), get(t, h, older)
if n.Status != StatusOpen || o.Status != StatusOpen {
t.Fatalf("%v: a status moved: %s %s", v, n.Status, o.Status)
}
if len(n.Links) != 1 || n.Links[0].Kind != LinkRelatesTo || n.Links[0].Target != "365" {
t.Fatalf("%v: links %+v", v, n.Links)
}
if nn := triageNotes(n); len(nn) != 1 || !strings.HasPrefix(nn[0], "Triage of this issue: not a duplicate; closest is 365.") ||
!strings.Contains(nn[0], "Relates to 365") {
t.Fatalf("%v: %q", v, nn)
}
if on := triageNotes(o); len(on) != 1 || !strings.HasPrefix(on[0], "Triage: issue 366 relates to this one") {
t.Fatalf("%v: %q", v, on)
}
}
}
// None: one note and the values; an empty shortlist still asks the second question, and the first issue of all
// asks only it.
func TestAnIssueThatRepeatsNothingGainsOneNoteAndItsValues(t *testing.T) {
h := newHarness(t)
a := h.open(t, "the counter skips a number")
b := h.open(t, "the desk shows nothing")
newer := h.open(t, "a backup took an hour")
s := withJudge(h, &stubJudge{verdict: map[string]any{"severity": "major", "needs": "operator", "needs_reason": "only the operator knows the backup window",
"owner": "mesh-catalog modules/records", "owner_reason": "it reads the record"}})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n := get(t, h, newer)
if n.Status != StatusOpen || len(n.Links) != 0 || n.Severity != "major" || n.Needs != "operator" ||
n.SuggestedOwner != "mesh-catalog modules/records" {
t.Fatalf("%+v", n)
}
if nn := triageNotes(n); len(nn) != 1 || !strings.HasPrefix(nn[0], "Triage of this issue: none of 2 older issues") ||
!strings.Contains(nn[0], "Needs: operator — only the operator knows") || !strings.Contains(nn[0], "Suggested owner: mesh-catalog modules/records") {
t.Fatalf("%q", nn)
}
if len(get(t, h, a).Notes)+len(get(t, h, b).Notes) != 0 || s.questions() != 2 {
t.Fatalf("older issues written, or %d questions", s.questions())
}
h2 := newHarness(t)
first := h2.open(t, "the first")
s2 := withJudge(h2, &stubJudge{verdict: map[string]any{"owner": "not-a-registered-owner"}})
if err := h2.Check(first); err != nil {
t.Fatal(err)
}
f := get(t, h2, first)
if nn := triageNotes(f); len(nn) != 1 || s2.questions() != 1 || f.Severity != "minor" || f.SuggestedOwner != "" {
t.Fatalf("%q, %d questions, %+v", nn, s2.questions(), f)
}
}
// The triage fills only what is empty: a value the issuer gave stands, and the note says the triage disagrees.
func TestAValueTheIssuerGaveStandsAndADisagreementIsNoted(t *testing.T) {
h := newHarness(t)
o, err := h.Open(OpenAsk{Title: "the controller loses a plan", Report: "seen: a plan vanished", Severity: "critical"})
if err != nil {
t.Fatal(err)
}
withJudge(h, &stubJudge{verdict: map[string]any{"severity": "minor", "severity_reason": "nothing is lost", "needs": "decision"}})
if err := h.Check(o.Number); err != nil {
t.Fatal(err)
}
is := get(t, h, o.Number)
if is.Severity != "critical" || is.Needs != "decision" {
t.Fatalf("%s %s", is.Severity, is.Needs)
}
if nn := triageNotes(is); len(nn) != 1 || !strings.Contains(nn[0], "Severity: the triage says minor — nothing is lost; the value given, critical, stands.") {
t.Fatalf("%q", nn)
}
if _, err := h.Open(OpenAsk{Title: "x", Report: "y", Severity: "urgent"}); err == nil {
t.Fatal("a severity not of its kind was taken")
}
}
// A resolved or wontfix issue is never offered, and a verdict naming one — or any issue not offered, or a
// newer one — changes nothing and fails loudly.
func TestAResolvedIssueIsNeverOfferedOrTouched(t *testing.T) {
h := newHarness(t)
resolved := h.open(t, "the counter skips a number")
resolve(t, h, resolved)
closed := h.open(t, "the counter skips again")
if _, err := h.Move(MoveAsk{Number: closed, Status: StatusWontfix, Reason: "not ours"}); err != nil {
t.Fatal(err)
}
open := h.open(t, "the desk shows nothing")
newer := h.open(t, "a number is skipped")
later := h.open(t, "a later one")
before := map[uint64]int{}
for _, n := range []uint64{resolved, closed, open} {
before[n] = len(get(t, h, n).Notes)
}
for _, named := range []uint64{resolved, closed, later, 999} {
s := withJudge(h, &stubJudge{shortlist: []uint64{resolved, closed, open, later}, verdict: map[string]any{
"verdict": "duplicate", "issue": named, "certain": true, "reason": "the same",
"depends_on": []any{map[string]any{"issue": resolved, "reason": "x"}}}})
err := h.Check(newer)
if err == nil || !strings.Contains(err.Error(), "not offered") {
t.Fatalf("a verdict naming %d: %v", named, err)
}
for _, q := range s.asked {
if strings.Contains(q, "365: ") || strings.Contains(q, "366: ") || strings.Contains(q, "OLDER ISSUE 365") ||
strings.Contains(q, "OLDER ISSUE 366") || strings.Contains(q, "369") {
t.Fatalf("a resolved, wontfix or newer issue was offered: %s", q)
}
}
}
for n, was := range before {
is := get(t, h, n)
if len(is.Notes) != was || len(is.Links) != 0 {
t.Fatalf("issue %d was changed", n)
}
}
n := get(t, h, newer)
if n.Status != StatusOpen || len(n.Links) != 0 || recorded(n) != nil {
t.Fatalf("the newer was acted on: %s %+v", n.Status, n.Links)
}
if _, err := h.apply(newer, &Verdict{Verdict: VerdictDuplicate, Issue: resolved, Certain: true, Reason: "x", By: "y"}); err == nil {
t.Fatal("applied against a resolved issue")
}
if _, err := h.apply(open, &Verdict{Verdict: VerdictDuplicate, Issue: newer, Certain: true, Reason: "x", By: "y"}); err == nil {
t.Fatal("closed the older of two")
}
if err := h.Check(resolved); err != nil || len(get(t, h, resolved).Notes) != before[resolved] {
t.Fatalf("a resolved issue was triaged: %v", err)
}
}
// Once per issue: the same event twice judges once; a triage kept and not applied is finished without judging;
// a note in the triage's words by anybody else is not the triage.
func TestTheSameEventTwiceJudgesOnceAndFinishesWhatIsMissing(t *testing.T) {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
newer := h.open(t, "a number is skipped")
s := withJudge(h, &stubJudge{shortlist: []uint64{older}, verdict: map[string]any{
"verdict": "duplicate", "issue": older, "certain": true, "reason": "the same skip"}})
ev := stdio.Envelope{Key: "mesh-issues.opened", Body: json.RawMessage(`{"number":366}`)}
for i := 0; i < 2; i++ {
if err := h.OnOpened(ev); err != nil {
t.Fatal(err)
}
}
if s.questions() != 2 || len(triageNotes(get(t, h, newer))) != 1 || len(triageNotes(get(t, h, older))) != 1 {
t.Fatalf("judged again or written twice: %d questions", s.questions())
}
h2 := newHarness(t)
o2 := h2.open(t, "the counter skips a number")
n2 := h2.open(t, "a number is skipped")
kept := &Verdict{Verdict: VerdictDuplicate, Issue: o2, Certain: true, Reason: "the same", Severity: "minor", Needs: "none", By: "the agent"}
if _, err := h2.change(n2, func(i *Issue) error { i.Triage = kept; return nil }); err != nil {
t.Fatal(err)
}
s2 := withJudge(h2, &stubJudge{err: errors.New("must not be asked")})
if err := h2.Check(n2); err != nil {
t.Fatal(err)
}
if n := get(t, h2, n2); s2.questions() != 0 || n.Status != StatusWontfix || len(n.Links) != 1 || len(triageNotes(get(t, h2, o2))) != 1 {
t.Fatalf("not finished, or judged again: %s %+v", n.Status, n.Links)
}
h3 := newHarness(t)
h3.open(t, "the counter skips a number")
n3 := h3.open(t, "a number is skipped")
if _, err := h3.Note(n3, "Duplicate check: duplicate of 365."); err != nil {
t.Fatal(err)
}
withJudge(h3, &stubJudge{})
if err := h3.Check(n3); err != nil {
t.Fatal(err)
}
if n := get(t, h3, n3); n.Status != StatusOpen {
t.Fatal("a caller's note closed an issue")
}
}
// An issue the duplicate check of ADR 0278 judged is not judged again on its event; the backlog's pass adds the
// rest and keeps its verdict.
func TestAnIssueTheDuplicateCheckJudgedKeepsItsVerdictAndGainsTheRest(t *testing.T) {
h := newHarness(t)
h.open(t, "the counter skips a number")
n := h.open(t, "a number is skipped")
if _, err := h.note(LegacyBy, n, "Duplicate check: related to 365.\n\nWhy: the same counter."); err != nil {
t.Fatal(err)
}
s := withJudge(h, &stubJudge{shortlist: []uint64{365}, verdict: map[string]any{"verdict": "duplicate", "issue": 365, "certain": true, "reason": "the same"}})
if err := h.Check(n); err != nil || s.questions() != 0 {
t.Fatalf("judged again on its event: %v, %d", err, s.questions())
}
var run func()
out, err := h.Groom(GroomAsk{}, func(f func()) { run = f })
if err != nil {
t.Fatal(err)
}
run()
if _, err := h.Groom(GroomAsk{Pass: out.Pass.ID, Confirm: true}, nil); err != nil {
t.Fatal(err)
}
is := get(t, h, n)
if is.Status != StatusOpen || is.Severity != "minor" || is.Triage == nil || is.Triage.Verdict != VerdictRelated {
t.Fatalf("%s %s %+v", is.Status, is.Severity, is.Triage)
}
}
// A judgement that fails is loud: the event is not taken, and one note says why however often it comes.
func TestAFailedJudgementLeavesOneNoteAndIsNotTaken(t *testing.T) {
for _, s := range []*stubJudge{
{err: errors.New("the agent on control did not answer within 25s")},
{shortlist: []uint64{365}, verdict: map[string]any{"verdict": "maybe", "issue": 365, "certain": true, "reason": "?"}},
{shortlist: []uint64{365}, verdict: map[string]any{"verdict": "duplicate", "issue": 365, "certain": true, "reason": ""}},
{shortlist: []uint64{365}, verdict: map[string]any{"severity": "urgent"}},
} {
h := newHarness(t)
older := h.open(t, "the counter skips a number")
newer := h.open(t, "a number is skipped")
withJudge(h, s)
ev := stdio.Envelope{Key: "mesh-issues.opened", Body: json.RawMessage(`{"number":366}`)}
for i := 0; i < 5; i++ {
if err := h.OnOpened(ev); err == nil {
t.Fatal("a failed judgement was taken")
}
}
notes := triageNotes(get(t, h, newer))
if len(notes) != 1 || !strings.HasPrefix(notes[0], "Triage: could not judge") || !strings.Contains(notes[0], "max-deliveries") {
t.Fatalf("%q", notes)
}
if n := get(t, h, newer); n.Status != StatusOpen || len(n.Links) != 0 || n.Severity != "" || len(get(t, h, older).Notes) != 0 {
t.Fatal("a failed judgement changed something")
}
}
h := newHarness(t)
h.open(t, "one")
s := withJudge(h, &stubJudge{})
h.settings.DuplicateCheck = "off"
if err := h.OnOpened(stdio.Envelope{Body: json.RawMessage(`{"number":365}`)}); err != nil || s.questions() != 0 ||
len(get(t, h, 365).Notes) != 0 {
t.Fatalf("triaged while off: %v", err)
}
h.settings.DuplicateCheck = ""
if err := h.OnOpened(stdio.Envelope{Body: json.RawMessage(`{}`)}); err != nil {
t.Fatal(err)
}
}
// The triage links what an issue depends on and notes the older; never a cycle, never a closed target. The verb
// refuses a cycle of two and of three.
func TestTheTriageLinksDependenciesButNoCycleAndNoClosedIssue(t *testing.T) {
h := newHarness(t)
a := h.open(t, "the bus drops a grant")
b := h.open(t, "the controller cannot issue a grant")
c := h.open(t, "a closed one")
if _, err := h.Move(MoveAsk{Number: c, Status: StatusWontfix, Reason: "x"}); err != nil {
t.Fatal(err)
}
if _, err := h.Link(LinkAsk{Number: a, Kind: LinkDependsOn, Target: keyOf(b)}); err != nil {
t.Fatal(err)
}
newer := h.open(t, "a module cannot reach its grant")
withJudge(h, &stubJudge{shortlist: []uint64{a, b}, verdict: map[string]any{
"depends_on": []any{map[string]any{"issue": b, "reason": "it needs the grant first"}, map[string]any{"issue": c, "reason": "closed"}},
"relates_to": []any{map[string]any{"issue": a, "reason": "the same grants"}}}})
if err := h.Check(newer); err != nil {
t.Fatal(err)
}
n := get(t, h, newer)
if !hasLink(n, LinkDependsOn, b) || !hasLink(n, LinkRelatesTo, a) || hasLink(n, LinkDependsOn, c) {
t.Fatalf("%+v", n.Links)
}
if nn := triageNotes(get(t, h, b)); len(nn) != 1 || !strings.HasPrefix(nn[0], "Triage: issue 368 depends on this one") {
t.Fatalf("%q", nn)
}
if _, err := h.Link(LinkAsk{Number: b, Kind: LinkDependsOn, Target: keyOf(a)}); !errors.Is(err, ErrCycle) {
t.Fatalf("a cycle of two: %v", err)
}
if _, err := h.Link(LinkAsk{Number: b, Kind: LinkDependsOn, Target: keyOf(newer)}); !errors.Is(err, ErrCycle) {
t.Fatalf("a cycle through the triage's link: %v", err)
}
v, err := h.View(b)
if err != nil || !reflect.DeepEqual(v.NeededBy, []uint64{a, newer}) {
t.Fatalf("%v %+v", err, v)
}
}
// Ready: no dependency; one open; resolved and not delivered; resolved and delivered; wontfix.
func TestAnIssueIsReadyOnlyWhenWhatItDependsOnIsDelivered(t *testing.T) {
h := newHarness(t)
dep := h.open(t, "the fix first")
w := h.open(t, "waits for it")
free := h.open(t, "depends on nothing")
if _, err := h.Link(LinkAsk{Number: w, Kind: LinkDependsOn, Target: keyOf(dep)}); err != nil {
t.Fatal(err)
}
ready := func(h *harness, n uint64) bool {
v, err := h.View(n)
if err != nil {
t.Fatal(err)
}
return v.Ready
}
if !ready(h, free) || ready(h, w) {
t.Fatal("ready wrong with an open dependency")
}
asked := 0
h.Deliveries = func(repo, number string) (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 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) (string, error) { return "merged", 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) (string, error) { return "", 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) (string, error) { return "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)
}
}
File diff suppressed because one or more lines are too long