From 2493e3e048eca9eb3f60a20da2ff0e21ac425d99 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 12:51:08 +0200 Subject: [PATCH] Add mesh-issues, the issue tracker, so an issue's number comes from one counter no two openers share (hq ADR 0275, to-be 53 slice 1) The seat issue-tracker, held once for the mesh: open, get, list, move, note and link. The counter is advanced by the SDK's compare-and-set Increment (go/v0.1.12) and started once at the setting first-number (365, after hq's highest), never below an issue kept. The cycle's issue rules are refused at the write; every change is an event. The runtime does not yet say who called, so every change is recorded as by "unproven" and no argument can name a caller. --- modules/mesh-issues/README.md | 101 +++ .../mesh-issues/cmd/mesh-issues/bus_test.go | 431 +++++++++++ modules/mesh-issues/cmd/mesh-issues/check.go | 244 +++++++ modules/mesh-issues/cmd/mesh-issues/issue.go | 152 ++++ modules/mesh-issues/cmd/mesh-issues/main.go | 383 ++++++++++ .../cmd/mesh-issues/manifest_test.go | 160 ++++ .../cmd/mesh-issues/memory_test.go | 219 ++++++ .../mesh-issues/cmd/mesh-issues/tracker.go | 688 ++++++++++++++++++ .../cmd/mesh-issues/tracker_test.go | 562 ++++++++++++++ modules/mesh-issues/go.mod | 22 + modules/mesh-issues/go.sum | 27 + modules/mesh-issues/module.json | 81 +++ 12 files changed, 3070 insertions(+) create mode 100644 modules/mesh-issues/README.md create mode 100644 modules/mesh-issues/cmd/mesh-issues/bus_test.go create mode 100644 modules/mesh-issues/cmd/mesh-issues/check.go create mode 100644 modules/mesh-issues/cmd/mesh-issues/issue.go create mode 100644 modules/mesh-issues/cmd/mesh-issues/main.go create mode 100644 modules/mesh-issues/cmd/mesh-issues/manifest_test.go create mode 100644 modules/mesh-issues/cmd/mesh-issues/memory_test.go create mode 100644 modules/mesh-issues/cmd/mesh-issues/tracker.go create mode 100644 modules/mesh-issues/cmd/mesh-issues/tracker_test.go create mode 100644 modules/mesh-issues/go.mod create mode 100644 modules/mesh-issues/go.sum create mode 100644 modules/mesh-issues/module.json diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md new file mode 100644 index 00000000..f595a354 --- /dev/null +++ b/modules/mesh-issues/README.md @@ -0,0 +1,101 @@ +# mesh-issues + +The issue tracker: the mesh numbers and tracks its issues in a module of its own (novox/hq ADR 0275, +to-be 53). An **issue** is a question about the mesh that someone must answer; this module holds the +mesh's issues and hands out their numbers. It holds the seat `issue-tracker`, once for the mesh, on the +control-node. + +Built on the model of [hits](https://github.com/impire-io/hits), a headless issue tracker on NATS, studied +at release v0.7.0: its statuses, fields, append-only notes, typed links and compare-and-set numbering are +taken as ideas. It carries none of its code, under a licence that is not an open-source one. + +## Verbs + +Called as `issue-tracker.` through the mesh's tools; each says in its description what it does and +what it refuses. + +| verb | takes | does | +|---|---|---| +| `open` | `title`, `report`, optionally `located-in`, `record` | checks the issue, then takes the next number and keeps it under that number; answers the number | +| `get` | `number` | the issue whole: fields, notes, links | +| `list` | optionally `status`, `owner`, `phrase`, `limit` | short rows, newest first: number, status, owner, title | +| `move` | `number`, `status`, and what that status needs | changes the status under the development cycle's rules | +| `note` | `number`, `text` | appends a note; a note is never edited | +| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link | + +**The cycle's rules, at the write** (hq `00-META/checks/cycle.py`): a status is one of `open`, +`diagnosing`, `located`, `resolved`, `wontfix`; `located` needs `located-in`; `resolved` needs +`located-in` and `fixed-by`; a core issue (located in the controller, the node-engine, the tool runner, +the SDK, or the catalogue's bus, forge or build agent) resolves with `replay` or `replay-none`; `wontfix` +needs a `reason`. A refused change changes nothing and says which rule refused it. + +**An issue with a folder in hq** (`record` at open: the folder's name, without the number) keeps its +status in that folder's report until the import of slice 3. The tracker holds its number, title, folder, +notes and links, and `move` refuses it, saying where its status lives. + +**Every argument is read strictly.** One a verb does not take is refused, never dropped. Every field has a +size limit, a one-line field takes no control character, a folder's name is no path, and text shaped like +a credential (a private key, an API key, a forge or bus token, `password=…`) is refused: an issue is read by +every agent on the mesh. The shapes catch the ordinary mistake, not every secret. + +## Numbers + +One counter, the key `issue` of the state `counters`, advanced only by compare-and-set: the SDK's +`State(...).Increment` reads it and writes the next number only at the revision it read, so two openers — +in one instance or two — never take one number. A write another opener won is read and tried again, a +bounded number of times with a short random wait; past the bound the open is refused and takes no number. + +- **A refused open takes no number**: an issue is checked before its number is taken. +- **A number is never handed out twice, and may be skipped.** A number taken and then not kept — the bus + failing between the counter and the issue — stays unused, and the answer says which. A write whose answer + was lost is recognised as its own when tried again, so it leaves no gap. +- **An issue is never overwritten.** It is kept by a write that is refused when the key exists; if the + counter ever hands out a number already kept, the open is refused loudly, the number stays unused, and + nothing is overwritten. +- **The counter starts once**, the first time a number is taken: at the setting `first-number`, or after + the highest issue already kept, whichever is higher, made only if it does not exist. The setting is never + read again, so a number never moves. + +## State and events + +Two states, declared in the manifest and made by the controller; the module never makes a bucket +(ADR 0201): `issues`, one key per issue by number, keeping 8 values per key; `counters`, one value per key. +Neither expires. One issue is kept in at most 256 KiB; a note that would pass it is refused. + +Every change is an event: `mesh-issues.opened`, `.moved`, `.noted`, `.linked`, each with the number, what +changed, who changed it and when. The events are the audit trail. A change whose event the bus did not take +is kept, and its answer says the event was not said (`announced: false`). + +## Who acted + +ADR 0275 §5 records the caller from the bus credential, never from an argument. **The runtime does not yet +tell a module who called it**: a tool call reaches a bundle as a name and arguments, and the tool runner is +not told who called it either (hq issue 340). Until it does, every change is recorded as by `unproven`, no +verb takes an argument naming a caller, and none can be given one. + +## Settings + +``` +{ + "first-number": 365, + "owners": ["hq", "mesh-catalog", "mesh-catalog modules/nats", "mesh-controller", …] +} +``` + +- `first-number` — the number the first issue gets: the one after the highest issue number on hq's main + and every open branch at the moment the module is switched on. Read once. +- `owners` — the registered list `located-in` may name: repositories, and modules as + `mesh-catalog modules/`. An entry of `located-in` is an owner, or an owner followed by a space and + where in it (`mesh-controller internal/broker`). It starts with the mesh's repositories and the modules + the catalogue held when this module was written; a module added since is added here through the + controller's settings verb. + +## Tests + +`go test ./...` in this directory. The acceptance — an agent opens an issue and gets the next number; two +agents opening at the same moment never get the same one — is +`TestFiftyOpensAtOnceFromTwoInstancesGetFiftyDistinctConsecutiveNumbers`: a JetStream server linked into +the test, two instances of this bundle (the test binary started again) speaking to a runtime played by the +test against the buckets the manifest declares, fifty opens at once, fifty distinct numbers from the first +in a row. It counts the writes lost to another opener, so it fails when the openers never collided. With +the counter written without its revision check, it fails. diff --git a/modules/mesh-issues/cmd/mesh-issues/bus_test.go b/modules/mesh-issues/cmd/mesh-issues/bus_test.go new file mode 100644 index 00000000..11295de2 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/bus_test.go @@ -0,0 +1,431 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// The acceptance (novox/hq to-be 53): "an agent opens an issue and gets the next number; two agents opening +// at the same moment never get the same one" — proved over a real bus. A JetStream server is linked into the +// test and started for it alone; the module is this test binary started again as the bundle, as many +// instances as a test asks, each speaking MCP on its standard streams to a runtime played here, which does +// what the tool runner does with a bundle's state and events: each state verb on the bucket the controller +// would have made from the manifest, a lost compare-and-set answered with its code (-32010). + +const bundleVar = "MESH_ISSUES_TEST_BUNDLE" + +func TestMain(m *testing.M) { + if os.Getenv(bundleVar) == "1" { + main() + os.Exit(0) + } + os.Exit(m.Run()) +} + +// bus is one test's JetStream server and the buckets the controller makes from the manifest. +type bus struct { + // lost counts the compare-and-sets another writer won: proof the openers collided. + lost atomic.Int64 + nc *nats.Conn + js jetstream.JetStream + kvs map[string]jetstream.KeyValue +} + +func newBus(t *testing.T) *bus { + t.Helper() + opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), + NoLog: true, NoSigs: true} + s, err := server.NewServer(opts) + if err != nil { + t.Fatalf("a bus for this test could not be made: %v", err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + s.Shutdown() + t.Fatal("a bus for this test did not come up within 30s") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + nc, err := nats.Connect(s.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + b := &bus{nc: nc, js: js, kvs: map[string]jetstream.KeyValue{}} + // The controller's job: a bucket per declared state, with the options the manifest gives it. + m, _ := readManifest(t) + for _, d := range m.stateDeclarations(t) { + history := uint8(1) + if d.History > 0 { + history = uint8(d.History) + } + kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{ + Bucket: m.Module + "_" + d.Name, History: history}) + if err != nil { + t.Fatal(err) + } + b.kvs[d.Name] = kv + } + return b +} + +// instance is one running bundle, and the runtime's half of its conversation. +type instance struct { + t *testing.T + b *bus + cmd *exec.Cmd + in io.WriteCloser + mu sync.Mutex + next int + wait map[string]chan json.RawMessage + events *[]event + evMu *sync.Mutex +} + +// event is one event a bundle asked the runtime to publish. +type event struct { + Key string `json:"key"` + Body map[string]any `json:"body"` + Headers map[string]string `json:"headers"` +} + +func (b *bus) start(t *testing.T, settings string, events *[]event, evMu *sync.Mutex) *instance { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^$") + cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_ISSUES_SETTINGS="+settings, + "MESH_SERVED_MODULE=mesh-issues", "MESH_MODULE=mesh-issues", "MESH_NODE=test") + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + cmd.Stderr = testWriter{t} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + i := &instance{t: t, b: b, cmd: cmd, in: in, wait: map[string]chan json.RawMessage{}, events: events, evMu: evMu} + go i.read(out) + t.Cleanup(func() { _ = in.Close(); _ = cmd.Wait() }) + return i +} + +type testWriter struct{ t *testing.T } + +func (w testWriter) Write(p []byte) (int, error) { w.t.Logf("bundle: %s", p); return len(p), nil } + +type rpc struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (i *instance) say(m map[string]any) { + m["jsonrpc"] = "2.0" + raw, _ := json.Marshal(m) + i.mu.Lock() + defer i.mu.Unlock() + _, _ = i.in.Write(append(raw, '\n')) +} + +func (i *instance) read(out io.Reader) { + sc := bufio.NewScanner(out) + sc.Buffer(make([]byte, 1<<20), 1<<24) + for sc.Scan() { + var m rpc + if json.Unmarshal(sc.Bytes(), &m) != nil { + continue + } + if m.Method == "" { + var id string + _ = json.Unmarshal(m.ID, &id) + i.mu.Lock() + ch := i.wait[id] + delete(i.wait, id) + i.mu.Unlock() + if ch != nil { + ch <- sc.Bytes() + } + continue + } + go i.answer(m) + } +} + +// answer is the runtime's answer to what the bundle asked: its state, and its events. +func (i *instance) answer(m rpc) { + var p struct { + State string `json:"state"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Revision uint64 `json:"revision"` + } + _ = json.Unmarshal(m.Params, &p) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + result, err := func() (any, error) { + if m.Method == "mesh/publish" { + var e event + if err := json.Unmarshal(m.Params, &e); err != nil { + return nil, err + } + i.evMu.Lock() + *i.events = append(*i.events, e) + i.evMu.Unlock() + return map[string]any{}, nil + } + kv := i.b.kvs[p.State] + if kv == nil { + return nil, fmt.Errorf("the state %q is not declared", p.State) + } + switch m.Method { + case "mesh/state.get": + e, err := kv.Get(ctx, p.Key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return map[string]any{"key": p.Key, "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil + case "mesh/state.create": + rev, err := kv.Create(ctx, p.Key, p.Value) + return map[string]any{"revision": rev}, err + case "mesh/state.update": + if p.Revision == 0 { + return nil, errors.New("an update names the revision it read") + } + rev, err := kv.Update(ctx, p.Key, p.Value, p.Revision) + return map[string]any{"revision": rev}, err + case "mesh/state.put": + rev, err := kv.Put(ctx, p.Key, p.Value) + return map[string]any{"revision": rev}, err + case "mesh/state.keys": + keys, err := kv.Keys(ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return []string{}, nil + } + return keys, err + } + return nil, fmt.Errorf("the runtime offers no %s to this bundle", m.Method) + }() + if err != nil { + code := -32000 + var api *jetstream.APIError + if errors.Is(err, jetstream.ErrKeyExists) || + errors.As(err, &api) && api.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence { + code = -32010 + i.b.lost.Add(1) + } + i.say(map[string]any{"id": m.ID, "error": map[string]any{"code": code, "message": err.Error()}}) + return + } + if result == nil { + i.say(map[string]any{"id": m.ID, "result": nil}) + return + } + i.say(map[string]any{"id": m.ID, "result": result}) +} + +// call is one tools/call, as the tool runner makes it, and its answer: the result, or the refusal's words. +func (i *instance) call(verb string, args map[string]any) (json.RawMessage, error) { + i.mu.Lock() + i.next++ + id := "runtime-" + strconv.Itoa(i.next) + ch := make(chan json.RawMessage, 1) + i.wait[id] = ch + i.mu.Unlock() + i.say(map[string]any{"id": id, "method": "tools/call", "params": map[string]any{"name": Seat + "." + verb, "arguments": args}}) + select { + case raw := <-ch: + var m struct { + Result struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &m); err != nil || len(m.Result.Content) == 0 { + return nil, fmt.Errorf("an answer that is not one: %s", raw) + } + if m.Result.IsError { + return nil, errors.New(m.Result.Content[0].Text) + } + return json.RawMessage(m.Result.Content[0].Text), nil + case <-time.After(60 * time.Second): + return nil, fmt.Errorf("%s did not answer in 60 s", verb) + } +} + +func writeSettings(t *testing.T, first int) string { + t.Helper() + path := filepath.Join(t.TempDir(), "settings.json") + raw, _ := json.Marshal(map[string]any{"first-number": first, "owners": []string{"hq", "mesh-controller"}}) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// Two instances on one bus, fifty opens at the same moment: fifty numbers, each once, consecutive from the +// first, and an issue and an event for each. With the counter written without its revision check, numbers +// are handed out twice and this fails. +func TestFiftyOpensAtOnceFromTwoInstancesGetFiftyDistinctConsecutiveNumbers(t *testing.T) { + b := newBus(t) + settings := writeSettings(t, 365) + var events []event + var evMu sync.Mutex + instances := []*instance{b.start(t, settings, &events, &evMu), b.start(t, settings, &events, &evMu)} + const opens = 50 + numbers := make([]uint64, opens) + var wg sync.WaitGroup + start := make(chan struct{}) + for k := 0; k < opens; k++ { + wg.Add(1) + go func(k int) { + defer wg.Done() + <-start + raw, err := instances[k%2].call("open", map[string]any{"title": fmt.Sprintf("symptom %d", k), "report": "seen at once"}) + if err != nil { + t.Errorf("open %d: %v", k, err) + return + } + var o Opened + _ = json.Unmarshal(raw, &o) + numbers[k] = o.Number + }(k) + } + close(start) + wg.Wait() + if t.Failed() { + return + } + sorted := append([]uint64(nil), numbers...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + for k, n := range sorted { + if n != uint64(365+k) { + t.Fatalf("the numbers handed out are %v: not fifty distinct numbers from 365 in a row", sorted) + } + } + // The openers collided: writes were refused because another opener had moved the counter first. + t.Logf("%d writes lost to another opener and tried again", b.lost.Load()) + if b.lost.Load() == 0 { + t.Fatal("no opener ever lost a write: the test did not make them collide") + } + ctx := context.Background() + e, err := b.kvs[CountersState].Get(ctx, IssueCounter) + if err != nil || string(e.Value()) != "414" { + t.Fatalf("the counter holds %v (%v), want 414", e, err) + } + keys, _ := b.kvs[IssuesState].Keys(ctx) + if len(keys) != opens { + t.Fatalf("%d issues kept, want %d", len(keys), opens) + } + for k, n := range numbers { + e, err := b.kvs[IssuesState].Get(ctx, keyOf(n)) + if err != nil { + t.Fatal(err) + } + var is Issue + _ = json.Unmarshal(e.Value(), &is) + if is.Title != fmt.Sprintf("symptom %d", k) || is.Number != n { + t.Fatalf("issue %d holds %+v, not open %d's", n, is, k) + } + } + evMu.Lock() + defer evMu.Unlock() + said := map[float64]bool{} + for _, ev := range events { + if ev.Key != EventOpened || ev.Headers["x-source"] != "mesh-issues" { + t.Fatalf("event %+v", ev) + } + said[ev.Body["number"].(float64)] = true + } + if len(said) != opens { + t.Fatalf("%d opened events for distinct numbers, want %d", len(said), opens) + } +} + +// One instance, every verb over the bus: what an agent does with an issue from opening to resolving. +func TestAnIssueIsOpenedNotedLinkedMovedAndListedOverTheBus(t *testing.T) { + b := newBus(t) + var events []event + var evMu sync.Mutex + i := b.start(t, writeSettings(t, 365), &events, &evMu) + must := func(raw json.RawMessage, err error) json.RawMessage { + t.Helper() + if err != nil { + t.Fatal(err) + } + return raw + } + // A refused open takes no number. + if _, err := i.call("open", map[string]any{"title": "", "report": "r"}); err == nil { + t.Fatal("an empty title was opened") + } + var o Opened + _ = json.Unmarshal(must(i.call("open", map[string]any{"title": "the gate passes and composed nothing", "report": "seen on a merge"})), &o) + if o.Number != 365 { + t.Fatalf("opened %d after a refusal, want 365", o.Number) + } + must(i.call("note", map[string]any{"number": 365, "text": "found in the gate's plan"})) + must(i.call("link", map[string]any{"number": 365, "kind": "decision", "target": "0237"})) + if _, err := i.call("move", map[string]any{"number": 365, "status": "resolved", "fixed-by": []string{"mesh-controller#300"}}); err == nil { + t.Fatal("resolved without located-in") + } + must(i.call("move", map[string]any{"number": 365, "status": "located", "located-in": []string{"mesh-controller internal/gate"}})) + if _, err := i.call("move", map[string]any{"number": 365, "status": "resolved", "fixed-by": []string{"mesh-controller#300"}}); err == nil { + t.Fatal("a core issue resolved without a replay") + } + must(i.call("move", map[string]any{"number": 365, "status": "resolved", "fixed-by": []string{"mesh-controller#300"}, "replay": "R365"})) + var is Issue + _ = json.Unmarshal(must(i.call("get", map[string]any{"number": 365})), &is) + if is.Status != StatusResolved || len(is.Notes) != 1 || len(is.Links) != 1 || is.Replay != "R365" || is.Closed == nil { + t.Fatalf("kept %+v", is) + } + var l Listed + _ = json.Unmarshal(must(i.call("list", map[string]any{"status": "resolved"})), &l) + if len(l.Rows) != 1 || l.Rows[0].Number != 365 { + t.Fatalf("listed %+v", l) + } + evMu.Lock() + defer evMu.Unlock() + var kinds []string + for _, e := range events { + kinds = append(kinds, e.Key) + if e.Body["by"] != Unproven || e.Body["number"] != float64(365) { + t.Fatalf("event %+v", e) + } + } + if fmt.Sprint(kinds) != "[opened noted linked moved moved]" { + t.Fatalf("events %v", kinds) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/check.go b/modules/mesh-issues/cmd/mesh-issues/check.go new file mode 100644 index 00000000..bc7e180f --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/check.go @@ -0,0 +1,244 @@ +package main + +import ( + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +// What every field may hold. Each refusal says what was wrong and what would be accepted (to-be 53 §2). + +// Limits on what one call may carry, in bytes. +const ( + MostTitle = 200 + MostReport = 64 << 10 + MostNote = 16 << 10 + MostLine = 300 // one entry of located-in or fixed-by, a link's target, a replay + MostReason = 2000 // a wontfix's reason, a replay-none + MostEntries = 20 // entries in one list + MostPhrase = 200 + MostLinks = 200 + MostListResult = 500 +) + +// singleLine is a field of one line: not empty, valid text, no control character, at most max bytes. +func singleLine(field, s string, max int) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "", fmt.Errorf("%s is empty: give it in words", field) + } + if err := plainText(field, s, max, false); err != nil { + return "", err + } + return s, nil +} + +// multiLine is a field of text that may run over lines and indent with tabs, and nothing else unprintable. +func multiLine(field, s string, max int) (string, error) { + s = strings.TrimSpace(strings.ReplaceAll(s, "\r\n", "\n")) + if s == "" { + return "", fmt.Errorf("%s is empty: give it in words", field) + } + if err := plainText(field, s, max, true); err != nil { + return "", err + } + return s, nil +} + +func plainText(field, s string, max int, lines bool) error { + if len(s) > max { + return fmt.Errorf("%s is %d bytes; at most %d are kept", field, len(s), max) + } + if !utf8.ValidString(s) { + return fmt.Errorf("%s is not valid UTF-8 text", field) + } + for _, r := range s { + if lines && (r == '\n' || r == '\t') { + continue + } + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) && r != '‍' { + if lines { + return fmt.Errorf("%s carries the unprintable character %U; only line breaks and tabs are kept", field, r) + } + return fmt.Errorf("%s carries the unprintable character %U; it is one line of plain text", field, r) + } + } + if what := secretShape(s); what != "" { + return fmt.Errorf("%s looks like it carries %s; an issue is read by every agent on the mesh and "+ + "carries no secret — say what it is, not its value", field, what) + } + return nil +} + +// secretShapes are the shapes of a credential that are told by their form alone. A guard against the +// ordinary mistake of pasting one, not a determined one: no check can tell every secret from text. +var secretShapes = []struct { + what string + re *regexp.Regexp +}{ + {"a private key", regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----`)}, + {"an Anthropic key", regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_-]{16,}`)}, + {"an API key", regexp.MustCompile(`\bsk-[A-Za-z0-9]{32,}`)}, + {"a forge token", regexp.MustCompile(`\b(ghp|gho|ghs|github_pat|glpat)_[A-Za-z0-9_]{20,}`)}, + {"a chat token", regexp.MustCompile(`\bxox[abpr]-[A-Za-z0-9-]{10,}`)}, + {"a cloud access key", regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)}, + {"a bus credential", regexp.MustCompile(`\b(SU|SA|SO)[A-Z2-7]{56}\b`)}, + {"a signed token", regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`)}, + {"a password", regexp.MustCompile(`(?i)\b(password|passwd|secret|token|api[_-]?key)=\S{8,}`)}, +} + +func secretShape(s string) string { + for _, sh := range secretShapes { + if sh.re.MatchString(s) { + return sh.what + } + } + return "" +} + +// slug is the name part of an issue's folder: lower-case words joined by hyphens, nothing else. +var slug = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// RecordOf is the folder in hq an issue numbered n keeps its narrative in, from the slug its opener gave. +func RecordOf(n uint64, s string) string { return fmt.Sprintf("04-ISSUES/%03d-%s", n, s) } + +func checkSlug(s string) (string, error) { + s = strings.TrimSpace(s) + if len(s) > 120 || !slug.MatchString(s) { + return "", fmt.Errorf("record %q is not a folder's name: lower-case words joined by hyphens, at most 120 "+ + "characters, without the number (the tracker gives it) and without a path", s) + } + return s, nil +} + +// entries is a list of one-line entries, each checked by one. +func entries(field string, list []string, one func(string) (string, error)) ([]string, error) { + if len(list) > MostEntries { + return nil, fmt.Errorf("%s names %d entries; at most %d", field, len(list), MostEntries) + } + var out []string + seen := map[string]bool{} + for _, e := range list { + v, err := one(e) + if err != nil { + return nil, err + } + if seen[v] { + return nil, fmt.Errorf("%s names %q twice", field, v) + } + seen[v] = true + out = append(out, v) + } + return out, nil +} + +// Owners is the registered list `located-in` may name: repositories, and modules as +// ` modules/` (to-be 53 §3). +type Owners []string + +// owns is the registered owner an entry names — the longest one it is or starts with, followed by a space +// and where in it — or "". +func (o Owners) owns(entry string) string { + best := "" + for _, owner := range o { + if (entry == owner || strings.HasPrefix(entry, owner+" ")) && len(owner) > len(best) { + best = owner + } + } + return best +} + +// locatedIn checks one entry of located-in against the registered list. +func (o Owners) locatedIn(e string) (string, error) { + v, err := singleLine("an entry of located-in", e, MostLine) + if err != nil { + return "", err + } + if o.owns(v) == "" { + return "", fmt.Errorf("located-in %q names no registered owner: an entry starts with a repository such as "+ + "mesh-controller, or a module such as \"mesh-catalog modules/nats\", optionally followed by where in it; "+ + "the registered list is the module's setting `owners`", v) + } + return v, nil +} + +// core is an owner whose issues are core issues (hq cycle.py, to-be 45): the controller, the node-engine, +// the tool runner and the console, the SDK, and the catalogue's bus, forge and build agent. +var core = regexp.MustCompile(`\b(mesh-controller|mesh-host|mesh-tools|mesh-sdk|node-tools|` + + `mesh-catalog\s+modules/(nats|gitea|build-agent))\b`) + +// IsCore says whether an issue located in these owners is a core issue (ADR 0237). +func IsCore(locatedIn []string) bool { + for _, e := range locatedIn { + if core.MatchString(e) { + return true + } + } + return false +} + +// replayID is a replay's id in mesh-lab's replays register, optionally followed by words. +var replayID = regexp.MustCompile(`^R[0-9]{1,6}( .*)?$`) + +// designPath is a design document in hq, as `amended-design` names one. +var designPath = regexp.MustCompile(`^03-DESIGN/(00-as-is|01-to-be)/[0-9]{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$`) + +func checkDesign(e string) (string, error) { + v := strings.TrimSpace(e) + if !designPath.MatchString(v) { + return "", fmt.Errorf("amended-design %q is not a design document: 03-DESIGN/00-as-is/NN-name.md or "+ + "03-DESIGN/01-to-be/NN-name.md", v) + } + return v, nil +} + +// The kinds of link (to-be 53 §3), each with what its target is. +const ( + LinkDuplicates = "duplicates" + LinkRelatesTo = "relates-to" + LinkPullRequest = "pull-request" + LinkDecision = "decision" + LinkDesign = "design" + LinkCondition = "condition" + LinkDelivery = "delivery" +) + +// LinkKinds is every kind, in the order they are told. +var LinkKinds = []string{LinkDuplicates, LinkRelatesTo, LinkPullRequest, LinkDecision, LinkDesign, LinkCondition, LinkDelivery} + +var ( + pullRequest = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}#[1-9][0-9]{0,6}$`) + decision = regexp.MustCompile(`^[0-9]{4}$`) + conditionKey = regexp.MustCompile(`^[a-z0-9][a-z0-9._:=@-]{0,199}$`) + deliveryID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$`) + issueNumber = regexp.MustCompile(`^[1-9][0-9]{0,8}$`) +) + +// checkTarget is a link's target, as its kind takes it, said the one way it is kept. +func checkTarget(kind, target string) (string, error) { + t := strings.TrimSpace(target) + ok := false + shape := "" + switch kind { + case LinkDuplicates, LinkRelatesTo: + 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" + case LinkDecision: + ok, shape = decision.MatchString(t), "a decision record's four-digit number, such as 0275" + case LinkDesign: + ok, shape = designPath.MatchString(t), "a design document, such as 03-DESIGN/01-to-be/53-the-issue-tracker.md" + case LinkCondition: + ok, shape = conditionKey.MatchString(t), "a condition's key as the controller names it" + case LinkDelivery: + ok, shape = deliveryID.MatchString(t), "a delivery's id as the controller names it" + default: + return "", fmt.Errorf("kind %q is not a kind of link: one of %s", kind, strings.Join(LinkKinds, ", ")) + } + if !ok { + return "", fmt.Errorf("a %s link's target is %s, not %q", kind, shape, t) + } + return t, nil +} diff --git a/modules/mesh-issues/cmd/mesh-issues/issue.go b/modules/mesh-issues/cmd/mesh-issues/issue.go new file mode 100644 index 00000000..f0a0aebf --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/issue.go @@ -0,0 +1,152 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// What the tracker keeps (novox/hq to-be 53 §3, §4): one issue per key of the state `issues`, keyed by +// its number, and one counter per sequence in the state `counters`. Both are declared in the manifest and +// made by the controller; this module never makes a bucket (ADR 0201). +const ( + IssuesState = "issues" + CountersState = "counters" + // IssueCounter is the key of the issue numbers' counter in `counters`. + IssueCounter = "issue" +) + +// The five statuses of an issue, as the development cycle names them (hq 00-META/checks/cycle.py). +const ( + StatusOpen = "open" + StatusDiagnosing = "diagnosing" + StatusLocated = "located" + StatusResolved = "resolved" + StatusWontfix = "wontfix" +) + +// Statuses is every status an issue may have, in the order the cycle walks them. +var Statuses = []string{StatusOpen, StatusDiagnosing, StatusLocated, StatusResolved, StatusWontfix} + +// 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 +// than a name anybody could type (novox/hq ADR 0275 §5). +const Unproven = "unproven" + +// Issue is one issue as the tracker keeps it. +type Issue struct { + Number uint64 `json:"number"` + Title string `json:"title"` + // Status is one of the five; empty for an issue whose folder in hq keeps its status (to-be 53 §3). + Status string `json:"status,omitempty"` + Report string `json:"report"` + Opened time.Time `json:"opened"` + OpenedBy string `json:"opened-by"` + Closed *time.Time `json:"closed,omitempty"` + // LocatedIn names registered owners, each optionally followed by where in it. + LocatedIn []string `json:"located-in,omitempty"` + FixedBy []string `json:"fixed-by,omitempty"` + AmendedDesign []string `json:"amended-design,omitempty"` + Replay string `json:"replay,omitempty"` + ReplayNone string `json:"replay-none,omitempty"` + // Reason is why it will not be fixed: what a wontfix needs. + Reason string `json:"reason,omitempty"` + // Record is the issue's folder in hq, when it has one. + Record string `json:"record,omitempty"` + Notes []Note `json:"notes,omitempty"` + Links []Link `json:"links,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"` +} + +// Note is one note on an issue: appended, never edited. +type Note struct { + By string `json:"by"` + At time.Time `json:"at"` + Text string `json:"text"` +} + +// Link is one typed link from an issue. +type Link struct { + Kind string `json:"kind"` + Target string `json:"target"` + By string `json:"by"` + At time.Time `json:"at"` +} + +// MostIssueBytes is the most one issue may be as kept: a value the bus holds (to-be 53 §4). The largest +// issue folder in hq was 25 KiB on 2026-10-10. +const MostIssueBytes = 256 << 10 + +// Entry is one key's value and the revision it was read at. +type Entry struct { + Value json.RawMessage + Revision uint64 +} + +// Store is the module's state as the runtime offers it (novox/hq ADR 0201, ADR 0259): a write that must win +// once is a compare-and-set, refused with stdio.ErrChanged when another writer was first. +type Store interface { + Get(state, key string) (*Entry, error) + Create(state, key string, value any) (uint64, error) + Update(state, key string, value any, revision uint64) (uint64, error) + Keys(state string) ([]string, error) + Increment(state, key string, attempts int) (uint64, error) +} + +// runtimeStore is Store through the runtime that launched this bundle. +type runtimeStore struct{} + +func (runtimeStore) Get(state, key string) (*Entry, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, err + } + return &Entry{Value: e.Value, Revision: e.Revision}, nil +} +func (runtimeStore) Create(state, key string, value any) (uint64, error) { + return stdio.State(state).Create(key, value) +} +func (runtimeStore) Update(state, key string, value any, revision uint64) (uint64, error) { + return stdio.State(state).Update(key, value, revision) +} +func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } +func (runtimeStore) Increment(state, key string, attempts int) (uint64, error) { + return stdio.State(state).Increment(key, attempts) +} + +// keyOf is an issue's key: its number in decimal. +func keyOf(n uint64) string { return strconv.FormatUint(n, 10) } + +// numberOfKey is the number a key of `issues` holds, or false for a key that is not one. +func numberOfKey(k string) (uint64, bool) { + n, err := strconv.ParseUint(k, 10, 64) + if err != nil || n == 0 || keyOf(n) != k { + return 0, false + } + return n, true +} + +// ErrNoIssue is a number the tracker holds no issue under. +var ErrNoIssue = errors.New("no such issue") + +// read is one issue and the revision it was read at. +func read(s Store, n uint64) (*Issue, uint64, error) { + e, err := s.Get(IssuesState, keyOf(n)) + if err != nil { + return nil, 0, fmt.Errorf("issue %d could not be read: %v", n, err) + } + if e == nil { + return nil, 0, fmt.Errorf("there is no issue %d: %w", n, ErrNoIssue) + } + var is Issue + if err := json.Unmarshal(e.Value, &is); err != nil { + return nil, 0, fmt.Errorf("issue %d is kept in a form this module cannot read: %v", n, err) + } + return &is, e.Revision, nil +} diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go new file mode 100644 index 00000000..8b8c7682 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -0,0 +1,383 @@ +// mesh-issues: the issue tracker, holder of the issue-tracker seat (novox/hq ADR 0275, to-be 53). A Go bundle +// the control-node's runtime launches. It hands out every issue's number from one counter advanced only by +// compare-and-set, keeps each issue in its declared state, refuses a change the development cycle would +// refuse, and says every change as an event. Built on the model of hits, a headless issue tracker on NATS; +// it carries none of its code. stdout is the MCP channel; what this module says, it says on stderr. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "os" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat is the role this module holds, once for the mesh. +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"} + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// Settings are the operator's values for this module, merged by the mesh into one JSON file. +type Settings struct { + // FirstNumber is the number the first issue gets: read once, when the counter is started, and never + // again. The counter never starts below an issue already kept. + FirstNumber uint64 `json:"first-number"` + // Owners is the registered list `located-in` may name (to-be 53 §3). + Owners []string `json:"owners"` +} + +func readSettings(path string) (Settings, error) { + var s Settings + if path == "" { + return s, errors.New("the module was started without a settings file") + } + raw, err := os.ReadFile(path) + if err != nil { + return s, err + } + if err := json.Unmarshal(raw, &s); err != nil { + return s, fmt.Errorf("the settings file is not the module's settings: %v", err) + } + var owners []string + for _, o := range s.Owners { + if o = strings.TrimSpace(o); o != "" { + owners = append(owners, o) + } + } + s.Owners = owners + return s, nil +} + +func main() { + file := os.Getenv("MESH_ISSUES_SETTINGS") + t := &Tracker{ + Store: runtimeStore{}, + Emit: func(event string, body any) error { return stdio.Emit(event, body) }, + Settings: func() (Settings, error) { return readSettings(file) }, + Now: time.Now, + Logf: logf, + Sleep: time.Sleep, + } + if _, err := t.Settings(); err != nil { + logf("[mesh-issues] the settings cannot be read (%v): every open and move will say so", err) + } + if err := stdio.Serve("", Tools(t)); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +// args reads one call's arguments strictly: an argument the verb does not take is refused, never ignored, +// so nothing a caller sends is silently dropped — and no argument can say who the caller is. +type args struct { + verb string + m map[string]any +} + +func readArgs(verb string, m map[string]any, takes ...string) (args, error) { + allowed := map[string]bool{} + for _, k := range takes { + allowed[k] = true + } + var unknown []string + for k := range m { + if !allowed[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return args{}, fmt.Errorf("%s takes %s; it does not take %s", verb, strings.Join(takes, ", "), strings.Join(unknown, ", ")) + } + return args{verb: verb, m: m}, nil +} + +func (a args) str(k string) (string, error) { + v, ok := a.m[k] + if !ok || v == nil { + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s is text", k) + } + return s, nil +} + +func (a args) list(k string) ([]string, error) { + v, ok := a.m[k] + if !ok || v == nil { + return nil, nil + } + switch t := v.(type) { + case string: + if strings.TrimSpace(t) == "" { + return nil, nil + } + return []string{t}, nil + case []any: + if len(t) > MostEntries { + return nil, fmt.Errorf("%s names %d entries; at most %d", k, len(t), MostEntries) + } + out := make([]string, 0, len(t)) + for _, e := range t { + s, ok := e.(string) + if !ok { + return nil, fmt.Errorf("every entry of %s is text", k) + } + out = append(out, s) + } + return out, nil + } + return nil, fmt.Errorf("%s is a list of text", k) +} + +func (a args) number(k string, required bool) (uint64, error) { + v, ok := a.m[k] + if !ok || v == nil { + if required { + return 0, fmt.Errorf("%s needs %s: an issue's number", a.verb, k) + } + return 0, nil + } + switch t := v.(type) { + case float64: + if t >= 1 && t <= 1e9 && t == math.Trunc(t) { + return uint64(t), nil + } + case string: + if n, ok := numberOfKey(strings.TrimSpace(t)); ok && n <= 1e9 { + return n, nil + } + } + return 0, fmt.Errorf("%s is a whole number from 1, such as 312", k) +} + +func (a args) flag(k string) (bool, error) { + v, ok := a.m[k] + if !ok || v == nil { + return false, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s is true or false", k) + } + return b, nil +} + +func text(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func texts(description string) map[string]any { + return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": description} +} + +func number(description string) map[string]any { + return map[string]any{"type": "integer", "minimum": 1, "description": description} +} + +func schema(required []string, props map[string]any) map[string]any { + s := map[string]any{"type": "object", "properties": props, "additionalProperties": false} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// Tools are the seat's verbs, each said in plain words: what it does and what it refuses (to-be 53 §2). +func Tools(t *Tracker) []stdio.Tool { + statuses := strings.Join(Statuses, ", ") + return []stdio.Tool{ + {Name: Seat + ".open", + Description: "Open an issue — a question about the mesh someone must answer — and get its number, the next " + + "one, never one another caller got. Give a title and the report in plain words (what was seen, what " + + "was expected); optionally located-in, the owners it is in; and, for a design or governance issue whose " + + "narrative goes in hq, record: the folder's name in lower-case words joined by hyphens, without the " + + "number. Refused, taking no number: an empty title or report, an owner not on the registered list, " + + "a secret, or an unknown argument.", + Input: schema([]string{"title", "report"}, map[string]any{ + "title": text("one line: the symptom, in plain words"), + "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"), + }), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("open", m, "title", "report", "located-in", "record") + if err != nil { + return nil, err + } + var ask OpenAsk + if ask.Title, err = a.str("title"); err != nil { + return nil, err + } + if ask.Report, err = a.str("report"); err != nil { + return nil, err + } + if ask.LocatedIn, err = a.list("located-in"); err != nil { + return nil, err + } + if ask.Record, err = a.str("record"); 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.", + 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") + if err != nil { + return nil, err + } + n, err := a.number("number", true) + if err != nil { + return nil, err + } + return t.Get(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.", + 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)"}, + }), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("list", m, "status", "owner", "phrase", "limit") + if err != nil { + return nil, err + } + var ask ListAsk + if ask.Status, err = a.str("status"); err != nil { + return nil, err + } + if ask.Owner, err = a.str("owner"); err != nil { + return nil, err + } + if ask.Phrase, err = a.str("phrase"); err != nil { + return nil, err + } + if _, given := m["limit"]; given { + l, err := a.number("limit", false) + if err != nil { + return nil, err + } + ask.Limit = int(min(l, MostListResult)) + } + return t.List(ask) + }}, + {Name: Seat + ".move", + Description: "Change an issue's status — " + statuses + " — with what that status needs, under the " + + "development cycle's rules: located needs located-in, owners on the registered list; resolved needs " + + "located-in and fixed-by (the pull requests or commits), and an issue in the mesh's core also replay " + + "(the replay's id, such as R361) or replay-none (why no replay is possible); wontfix needs a reason. " + + "A refused move changes nothing and says which rule refused it. An issue with a folder in hq keeps its " + + "status there and is refused here.", + Input: schema([]string{"number", "status"}, map[string]any{ + "number": number("the issue's number"), + "status": map[string]any{"type": "string", "enum": Statuses, "description": "the status it moves to"}, + "located-in": texts("the owners it is in; replaces what it said"), + "fixed-by": texts("what fixed it: pull requests or commits, such as mesh-controller#246; replaces what it said"), + "amended-design": texts("the design documents the fix changed, such as 03-DESIGN/00-as-is/12-the-controller.md"), + "replay": text("for a core issue resolved: the replay's id in mesh-lab's replays register, such as R361"), + "replay-none": text("for a core issue resolved: why no replay is possible"), + "reason": text("for wontfix: why it will not be fixed"), + }), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("move", m, "number", "status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "reason") + if err != nil { + return nil, err + } + var ask MoveAsk + if ask.Number, err = a.number("number", true); err != nil { + return nil, err + } + for _, f := range []struct { + k string + p *string + }{{"status", &ask.Status}, {"replay", &ask.Replay}, {"replay-none", &ask.ReplayNone}, {"reason", &ask.Reason}} { + if *f.p, err = a.str(f.k); err != nil { + return nil, err + } + } + for _, f := range []struct { + k string + p *[]string + }{{"located-in", &ask.LocatedIn}, {"fixed-by", &ask.FixedBy}, {"amended-design", &ask.AmendedDesign}} { + if *f.p, err = a.list(f.k); err != nil { + return nil, err + } + } + return t.Move(ask) + }}, + {Name: Seat + ".note", + Description: "Add a note to an issue: what was found, tried or decided, in plain words. A note is kept as " + + "written and never edited. Refused: an empty note, a secret, or one that would make the issue larger " + + "than an issue is kept in.", + Input: schema([]string{"number", "text"}, map[string]any{ + "number": number("the issue's number"), + "text": text("the note"), + }), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("note", m, "number", "text") + if err != nil { + return nil, err + } + n, err := a.number("number", true) + if err != nil { + return nil, err + } + s, err := a.str("text") + if err != nil { + return nil, err + } + return t.Note(n, s) + }}, + {Name: Seat + ".link", + Description: "Link an issue to something, or with remove, take a link away. Kinds: duplicates or relates-to " + + "another issue (its number), pull-request (such as mesh-controller#246), decision (a decision record's " + + "number, such as 0275), design (a design document's path), condition (a condition's key) and delivery " + + "(a delivery's id). Refused: a target not of its kind's shape, an issue that does not exist, a link it " + + "already has.", + Input: schema([]string{"number", "kind", "target"}, map[string]any{ + "number": number("the issue's number"), + "kind": map[string]any{"type": "string", "enum": LinkKinds, "description": "what the target is"}, + "target": text("what it links to, in the kind's shape"), + "remove": map[string]any{"type": "boolean", "description": "take the link away instead"}, + }), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("link", m, "number", "kind", "target", "remove") + if err != nil { + return nil, err + } + var ask LinkAsk + if ask.Number, err = a.number("number", true); err != nil { + return nil, err + } + if ask.Kind, err = a.str("kind"); err != nil { + return nil, err + } + if ask.Target, err = a.str("target"); err != nil { + return nil, err + } + if ask.Remove, err = a.flag("remove"); err != nil { + return nil, err + } + return t.Link(ask) + }}, + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/manifest_test.go b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go new file mode 100644 index 00000000..515f190f --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/manifest_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +// The manifest says what the code does (novox/hq to-be 53 §1): the seat it declares and holds once for the +// mesh with the verbs it serves, the events it says, the two states it keeps (made by the controller, never +// by this module), and its settings: the first number and the registered owners. + +type manifest struct { + Module string `json:"module"` + Seats []seat `json:"seats"` + Claims []seat `json:"claims"` + Emits []string `json:"emits"` + State []json.RawMessage `json:"state"` + Tools []string `json:"tools"` + Resources []struct { + ID string `json:"id"` + Path string `json:"path"` + Content string `json:"content"` + } `json:"resources"` + Build struct { + Artifacts []struct { + From string `json:"from"` + Loads []string `json:"loads"` + Env map[string]string `json:"env"` + } `json:"artifacts"` + } `json:"build"` +} + +type seat struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` +} + +type stateDeclaration struct { + Name string `json:"name"` + History int `json:"history"` + TTLSeconds int `json:"ttl-seconds"` +} + +func readManifest(t *testing.T) (manifest, string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m, string(raw) +} + +func (m manifest) stateDeclarations(t *testing.T) []stateDeclaration { + t.Helper() + var out []stateDeclaration + for _, s := range m.State { + var d stateDeclaration + if json.Unmarshal(s, &d.Name) != nil { + if err := json.Unmarshal(s, &d); err != nil { + t.Fatal(err) + } + } + out = append(out, d) + } + return out +} + +func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) { + m, _ := readManifest(t) + want := []seat{{Name: Seat, Scope: "mesh", Serves: Verbs}} + 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}) { + t.Fatalf("emits %v", m.Emits) + } + // Two states, neither expiring: issues keep a short history per key, the counter one value. + d := m.stateDeclarations(t) + if len(d) != 2 || d[0].Name != IssuesState || d[0].History < 2 || d[0].TTLSeconds != 0 || + d[1].Name != CountersState || d[1].History > 1 || d[1].TTLSeconds != 0 { + t.Fatalf("state %+v", d) + } + if len(m.Build.Artifacts) != 1 || m.Build.Artifacts[0].From != "cmd/mesh-issues" || + m.Build.Artifacts[0].Env["MESH_ISSUES_SETTINGS"] == "" || !reflect.DeepEqual(m.Build.Artifacts[0].Loads, []string{"mesh-issues"}) { + t.Fatalf("build %+v", m.Build) + } +} + +func TestTheToolsAreTheSeatsVerbsAndSayWhatTheyRefuse(t *testing.T) { + m, _ := readManifest(t) + var verbs []string + for _, tool := range Tools(&Tracker{}) { + s, verb, ok := strings.Cut(tool.Name, ".") + if !ok || s != Seat { + t.Errorf("%s is not a verb of %s", tool.Name, Seat) + } + verbs = append(verbs, verb) + if len(tool.Description) < 80 { + t.Errorf("%s says too little of what it does", tool.Name) + } + for _, internal := range []string{"ADR", "to-be", "bucket", "compare-and-set", "JetStream", "KV"} { + if strings.Contains(tool.Description, internal) { + t.Errorf("%s's description uses the internal name %q", tool.Name, internal) + } + } + } + if !reflect.DeepEqual(verbs, m.Claims[0].Serves) || len(m.Tools) != 0 { + t.Fatalf("verbs %v, claim %v, own tools %v", verbs, m.Claims[0].Serves, m.Tools) + } +} + +// The settings the manifest starts with: the first number, and a registered list of owners in which every +// module named is one the catalogue holds. +func TestTheSettingsStartTheCounterAndRegisterTheOwners(t *testing.T) { + m, raw := readManifest(t) + var content string + for _, r := range m.Resources { + if r.ID == "settings" { + content = r.Content + } + } + var s Settings + if err := json.Unmarshal([]byte(content), &s); err != nil { + t.Fatalf("the settings the manifest writes are not the module's: %v", err) + } + if s.FirstNumber < 365 { + t.Fatalf("first-number %d: the first issue continues hq's numbering, after 364", s.FirstNumber) + } + if !sort.StringsAreSorted(s.Owners) { + t.Error("the owners are not sorted") + } + for _, repo := range []string{"hq", "mesh-catalog", "mesh-controller", "mesh-host", "mesh-sdk", "mesh-tools"} { + if Owners(s.Owners).owns(repo) != repo { + t.Errorf("%s is not registered", repo) + } + } + modules := filepath.Join("..", "..", "..") + for _, o := range s.Owners { + if name, ok := strings.CutPrefix(o, "mesh-catalog modules/"); ok { + if _, err := os.Stat(filepath.Join(modules, name, "module.json")); err != nil { + t.Errorf("the owner %q names a module the catalogue does not hold", o) + } + } + } + for _, never := range []string{"/home/", "jochen", "shanks", "novox.be"} { + if strings.Contains(strings.ToLower(raw), never) { + t.Errorf("module.json names %q", never) + } + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/memory_test.go b/modules/mesh-issues/cmd/mesh-issues/memory_test.go new file mode 100644 index 00000000..59bf55d3 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/memory_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// memory is Store kept in this process with the bus's compare-and-set: a create is refused when the key +// has a value, an update when its revision is not the one given — each refusal stdio.ErrChanged, as the +// runtime answers it. +type memory struct { + mu sync.Mutex + rev uint64 + data map[string]map[string]Entry + // fail, when set, is the error the next write of that state answers. + fail map[string]error +} + +func newMemory() *memory { + return &memory{data: map[string]map[string]Entry{IssuesState: {}, CountersState: {}}, fail: map[string]error{}} +} + +func (m *memory) bucket(state string) (map[string]Entry, error) { + b, ok := m.data[state] + if !ok { + return nil, fmt.Errorf("%s is not declared", state) + } + return b, nil +} + +func (m *memory) Get(state, key string) (*Entry, error) { + m.mu.Lock() + defer m.mu.Unlock() + b, err := m.bucket(state) + if err != nil { + return nil, err + } + e, ok := b[key] + if !ok { + return nil, nil + } + return &Entry{Value: append(json.RawMessage(nil), e.Value...), Revision: e.Revision}, nil +} + +func (m *memory) write(state, key string, value any, check func(Entry, bool) bool) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + m.mu.Lock() + defer m.mu.Unlock() + if err := m.fail[state]; err != nil { + delete(m.fail, state) + return 0, err + } + b, err := m.bucket(state) + if err != nil { + return 0, err + } + e, ok := b[key] + if !check(e, ok) { + return 0, fmt.Errorf("%s.%s: %w", state, key, stdio.ErrChanged) + } + m.rev++ + b[key] = Entry{Value: raw, Revision: m.rev} + return m.rev, nil +} + +func (m *memory) Create(state, key string, value any) (uint64, error) { + return m.write(state, key, value, func(_ Entry, there bool) bool { return !there }) +} + +func (m *memory) Update(state, key string, value any, revision uint64) (uint64, error) { + if revision == 0 { + return 0, errors.New("an update names the revision it read") + } + return m.write(state, key, value, func(e Entry, there bool) bool { return there && e.Revision == revision }) +} + +func (m *memory) put(state, key string, value any) { + _, _ = m.write(state, key, value, func(Entry, bool) bool { return true }) +} + +func (m *memory) Keys(state string) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + b, err := m.bucket(state) + if err != nil { + return nil, err + } + var keys []string + for k := range b { + keys = append(keys, k) + } + sort.Strings(keys) + return keys, nil +} + +// Increment is the SDK's (mesh-sdk go/state.go), over this store. +func (m *memory) Increment(state, key string, attempts int) (uint64, error) { + for i := 0; i < attempts; i++ { + e, err := m.Get(state, key) + if err != nil { + return 0, err + } + var next uint64 = 1 + if e == nil { + _, err = m.Create(state, key, next) + } else { + var n uint64 + if err := json.Unmarshal(e.Value, &n); err != nil { + return 0, err + } + next = n + 1 + _, err = m.Update(state, key, next, e.Revision) + } + if err == nil { + return next, nil + } + if !errors.Is(err, stdio.ErrChanged) { + return 0, err + } + } + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) +} + +func (m *memory) counter(t *testing.T) uint64 { + t.Helper() + e, _ := m.Get(CountersState, IssueCounter) + if e == nil { + return 0 + } + n, err := strconv.ParseUint(string(e.Value), 10, 64) + if err != nil { + t.Fatalf("the counter holds %s", e.Value) + } + return n +} + +// said is one event the tracker said. +type said struct { + Event string + Body map[string]any +} + +type harness struct { + *Tracker + mem *memory + mu sync.Mutex + events []said + settings Settings + emitErr error +} + +func newHarness(t *testing.T) *harness { + t.Helper() + h := &harness{mem: newMemory(), settings: Settings{FirstNumber: 365, Owners: []string{ + "hq", "mesh-catalog", "mesh-controller", "mesh-host", "mesh-sdk", "mesh-tools", + "mesh-catalog modules/nats", "mesh-catalog modules/records", "mesh-catalog modules/mesh-issues"}}} + clock := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) + h.Tracker = &Tracker{ + Store: h.mem, + Emit: func(event string, body any) error { + h.mu.Lock() + defer h.mu.Unlock() + if h.emitErr != nil { + return h.emitErr + } + raw, _ := json.Marshal(body) + var m map[string]any + _ = json.Unmarshal(raw, &m) + h.events = append(h.events, said{event, m}) + return nil + }, + Settings: func() (Settings, error) { return h.settings, nil }, + Now: func() time.Time { return clock }, + Logf: t.Logf, + Sleep: func(time.Duration) {}, + } + return h +} + +func (h *harness) said() []said { + h.mu.Lock() + defer h.mu.Unlock() + return append([]said(nil), h.events...) +} + +func (h *harness) open(t *testing.T, title string, located ...string) uint64 { + t.Helper() + o, err := h.Open(OpenAsk{Title: title, Report: "seen: " + title, LocatedIn: located}) + if err != nil { + t.Fatalf("open %q: %v", title, err) + } + return o.Number +} + +// call runs one verb as the tool runner does: by its tool, with arguments as JSON gives them. +func (h *harness) call(t *testing.T, verb string, args map[string]any) (any, error) { + t.Helper() + for _, tool := range Tools(h.Tracker) { + if tool.Name == Seat+"."+verb { + raw, _ := json.Marshal(args) + var m map[string]any + _ = json.Unmarshal(raw, &m) + return tool.Run(m) + } + } + t.Fatalf("no verb %s", verb) + return nil, nil +} diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go new file mode 100644 index 00000000..0d3f340d --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -0,0 +1,688 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Tracker is the holder of the issue-tracker seat (novox/hq ADR 0275, to-be 53): it hands out each issue's +// number from one counter, keeps each issue, refuses what the development cycle refuses, and says every +// change as an event. +type Tracker struct { + Store Store + // Emit says an event as this module (ADR 0041); the events are the audit trail. + Emit func(event string, body any) error + Settings func() (Settings, error) + Now func() time.Time + Logf func(format string, a ...any) + // Sleep waits between rounds of a counter that other openers keep winning. + Sleep func(time.Duration) +} + +// The events this module says, one per kind of change. +const ( + EventOpened = "opened" + EventMoved = "moved" + EventNoted = "noted" + EventLinked = "linked" +) + +// Bounds on how often a write another writer won is tried again. Each is a bounded loop: past it the +// caller is told, and nothing was written. +const ( + // counterRounds × counterAttempts is how many times one open tries to advance the counter. + counterRounds = 12 + counterAttempts = 8 + // issueAttempts is how many times a change to one issue is read and written again. + issueAttempts = 16 + // createAttempts is how many times the new issue's key is written after a failure that was not a refusal. + createAttempts = 3 +) + +// by is who acted on this call. The runtime does not hand a module its caller, so it is Unproven; it is +// never read from the call's arguments, so no caller can name another (ADR 0275 §5). +func (t *Tracker) by() string { return Unproven } + +func (t *Tracker) now() time.Time { return t.Now().UTC().Truncate(time.Second) } + +func (t *Tracker) owners() (Owners, error) { + s, err := t.Settings() + if err != nil { + return nil, fmt.Errorf("the registered list of owners cannot be read: %v", err) + } + return Owners(s.Owners), nil +} + +// emit says an event, and answers whether it was said: a change already written is never undone because +// its event could not be said, and never reported as said when it was not. +func (t *Tracker) emit(event string, body map[string]any) (bool, string) { + if err := t.Emit(event, body); err != nil { + t.Logf("[mesh-issues] issue %v was changed and its %s event could not be said: %v", body["number"], event, err) + return false, fmt.Sprintf("the change is kept, and its %s event could not be said on the bus (%v): "+ + "what watches the events did not hear of it", event, err) + } + return true, "" +} + +// OpenAsk is what an open carries. +type OpenAsk struct { + Title string + Report string + LocatedIn []string + Record string +} + +// Opened is the answer to an open. +type Opened struct { + Number uint64 `json:"number"` + Title string `json:"title"` + Status string `json:"status,omitempty"` + LocatedIn []string `json:"located-in,omitempty"` + Record string `json:"record,omitempty"` + By string `json:"by"` + Said string `json:"said"` + Announced bool `json:"announced"` + Warning string `json:"warning,omitempty"` +} + +// Open checks an issue, then takes the next number and writes the issue under it (to-be 53 §4). A refused +// issue takes no number. A number taken and then not written — the bus failing between the two — stays +// unused, and the answer says which: numbers may skip, never repeat. +func (t *Tracker) Open(a OpenAsk) (*Opened, error) { + title, err := singleLine("title", a.Title, MostTitle) + if err != nil { + return nil, err + } + report, err := multiLine("report", a.Report, MostReport) + if err != nil { + return nil, err + } + owners, err := t.owners() + if err != nil { + return nil, err + } + located, err := entries("located-in", a.LocatedIn, owners.locatedIn) + if err != nil { + return nil, err + } + record := "" + if strings.TrimSpace(a.Record) != "" { + if record, err = checkSlug(a.Record); err != nil { + return nil, err + } + if len(located) > 0 { + return nil, errors.New("an issue with a folder in hq keeps located-in in that folder's report until " + + "the import: open it without located-in, or without record") + } + } + at := t.now() + is := Issue{Title: title, Report: report, Opened: at, OpenedBy: t.by(), LocatedIn: located, Opening: newID()} + if record == "" { + is.Status = StatusOpen + } + if size, _ := json.Marshal(is); len(size) > MostIssueBytes-1024 { + return nil, fmt.Errorf("the issue is %d bytes; one issue is kept in at most %d", len(size), MostIssueBytes) + } + + n, err := t.next() + if err != nil { + return nil, err + } + is.Number = n + if record != "" { + is.Record = RecordOf(n, record) + } + if err := t.create(&is); err != nil { + return nil, err + } + out := &Opened{Number: n, Title: title, Status: is.Status, LocatedIn: located, Record: is.Record, By: is.OpenedBy} + out.Said = fmt.Sprintf("Opened issue %d: %s.", n, title) + if is.Record != "" { + out.Said += fmt.Sprintf(" Its narrative goes in hq's %s/00-report.md, which keeps its status.", is.Record) + } + 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}) + return out, nil +} + +// create writes a new issue under its number, never over one: a key that is there already is a fault said +// loudly, and the number stays unused. +func (t *Tracker) create(is *Issue) error { + key := keyOf(is.Number) + var err error + for i := 0; i < createAttempts; i++ { + _, err = t.Store.Create(IssuesState, key, is) + if err == nil { + return nil + } + if errors.Is(err, stdio.ErrChanged) { + // Ours, written by an attempt whose answer was lost? + if there, _, rerr := read(t.Store, is.Number); rerr == nil && there.Opening == is.Opening { + return nil + } + t.Logf("[mesh-issues] the counter handed out %d and an issue %d is already kept: not overwritten", is.Number, is.Number) + return fmt.Errorf("a fault, not your mistake: the counter handed out %d, and an issue %d is already kept. "+ + "Nothing was overwritten and nothing was opened; %d stays unused. Open it again for the next number, "+ + "and tell the operator the counter is behind the issues kept", is.Number, is.Number, is.Number) + } + } + t.Logf("[mesh-issues] number %d was taken and the issue could not be written: %v", is.Number, err) + return fmt.Errorf("number %d was taken and the issue could not be written (%v); nothing was opened and %d "+ + "stays unused. Open it again", is.Number, err, is.Number) +} + +// next takes the next issue number: the counter advanced only at the revision read, so two openers — in +// one instance or two — never take one number (ADR 0275 §3). +func (t *Tracker) next() (uint64, error) { + e, err := t.Store.Get(CountersState, IssueCounter) + if err != nil { + return 0, fmt.Errorf("the issue counter cannot be read (%v); no number was taken", err) + } + if e == nil { + if err := t.seed(); err != nil { + return 0, err + } + } + for round := 0; round < counterRounds; round++ { + n, err := t.Store.Increment(CountersState, IssueCounter, counterAttempts) + if err == nil { + return n, nil + } + if !errors.Is(err, stdio.ErrChanged) { + t.Logf("[mesh-issues] the issue counter could not be advanced: %v", err) + return 0, fmt.Errorf("the issue counter could not be advanced (%v); nothing was opened. If the bus took "+ + "the write before it failed, one number stays unused", err) + } + // Others are opening at this moment: wait a little, longer each round, and try again. + t.Sleep(jitter(time.Duration(round+1) * 10 * time.Millisecond)) + } + return 0, fmt.Errorf("so many issues are being opened at this moment that the counter was taken by another "+ + "opener %d times; no number was taken and nothing was opened. Open it again", counterRounds*counterAttempts) +} + +// seed starts the counter where the setting `first-number` says, made only when there is none, and never +// below an issue already kept: the first number handed out is the setting's, or the one after the highest +// kept, whichever is higher. +func (t *Tracker) seed() error { + s, err := t.Settings() + if err != nil { + return fmt.Errorf("the issue counter has not started and the settings cannot be read (%v); no number was taken", err) + } + if s.FirstNumber < 1 { + return errors.New("the issue counter has not started and the setting first-number is not set: it is the " + + "number the first issue gets, the one after the highest anywhere else. No number was taken") + } + start := s.FirstNumber - 1 + keys, err := t.Store.Keys(IssuesState) + if err != nil { + return fmt.Errorf("the issue counter has not started and the issues kept cannot be listed (%v); no number was taken", err) + } + for _, k := range keys { + if n, ok := numberOfKey(k); ok && n > start { + start = n + } + } + if _, err := t.Store.Create(CountersState, IssueCounter, start); err != nil && !errors.Is(err, stdio.ErrChanged) { + return fmt.Errorf("the issue counter could not be started (%v); no number was taken", err) + } + // ErrChanged: another opener started it first, which is the same start. + return nil +} + +// change reads an issue, applies f, and writes it only at the revision read; a write another writer won +// is read and applied again, so no note, link or move is lost and none is applied to a stale issue. +func (t *Tracker) change(n uint64, f func(*Issue) error) (*Issue, error) { + for i := 0; i < issueAttempts; i++ { + is, rev, err := read(t.Store, n) + if err != nil { + return nil, err + } + if err := f(is); err != nil { + return nil, err + } + raw, err := json.Marshal(is) + if err != nil { + return nil, err + } + if len(raw) > MostIssueBytes { + return nil, fmt.Errorf("issue %d would be %d bytes; one issue is kept in at most %d (256 KiB). Nothing "+ + "was changed: say it shorter, or in a document the issue links", n, len(raw), MostIssueBytes) + } + _, err = t.Store.Update(IssuesState, keyOf(n), json.RawMessage(raw), rev) + if err == nil { + return is, nil + } + if !errors.Is(err, stdio.ErrChanged) { + return nil, fmt.Errorf("issue %d could not be written (%v); nothing was changed", n, err) + } + t.Sleep(jitter(time.Duration(i+1) * 5 * time.Millisecond)) + } + return nil, fmt.Errorf("issue %d was changed by another caller %d times while this change was made; nothing was "+ + "changed. Try again", n, issueAttempts) +} + +// Get is one issue whole. +func (t *Tracker) Get(n uint64) (*Issue, error) { + is, _, err := read(t.Store, n) + return is, err +} + +// MoveAsk is what a move carries: the status, and what it needs. +type MoveAsk struct { + Number uint64 + Status string + LocatedIn []string + FixedBy []string + AmendedDesign []string + Replay string + ReplayNone string + Reason string +} + +// Moved is the answer to a move. +type Moved struct { + Number uint64 `json:"number"` + From string `json:"from"` + To string `json:"to"` + Changed []string `json:"changed,omitempty"` + By string `json:"by"` + Said string `json:"said"` + Announced bool `json:"announced"` + Warning string `json:"warning,omitempty"` +} + +func known(status string) bool { + for _, s := range Statuses { + if s == status { + return true + } + } + return false +} + +// Move changes an issue's status under the cycle's rules (to-be 53 §5). A refused move changes nothing and +// says which rule refused it. +func (t *Tracker) Move(a MoveAsk) (*Moved, error) { + to := strings.TrimSpace(a.Status) + if !known(to) { + return nil, fmt.Errorf("status %q is not one of %s", to, strings.Join(Statuses, ", ")) + } + owners, err := t.owners() + if err != nil { + return nil, err + } + located, err := entries("located-in", a.LocatedIn, owners.locatedIn) + if err != nil { + return nil, err + } + fixed, err := entries("fixed-by", a.FixedBy, func(e string) (string, error) { return singleLine("an entry of fixed-by", e, MostLine) }) + if err != nil { + return nil, err + } + designs, err := entries("amended-design", a.AmendedDesign, checkDesign) + if err != nil { + return nil, err + } + var replay, replayNone, reason string + if strings.TrimSpace(a.Replay) != "" { + if replay, err = singleLine("replay", a.Replay, MostLine); err != nil { + return nil, err + } + if !replayID.MatchString(replay) { + return nil, fmt.Errorf("replay %q names no replay: an id from mesh-lab's replays register, R and a number, "+ + "such as R361", replay) + } + } + if strings.TrimSpace(a.ReplayNone) != "" { + if replayNone, err = multiLine("replay-none", a.ReplayNone, MostReason); err != nil { + return nil, err + } + } + if strings.TrimSpace(a.Reason) != "" { + if reason, err = multiLine("reason", a.Reason, MostReason); err != nil { + return nil, err + } + } + if (replay != "" || replayNone != "") && to != StatusResolved { + return nil, fmt.Errorf("replay and replay-none are what resolving a core issue needs; this moves to %s", to) + } + if replay != "" && replayNone != "" { + return nil, errors.New("give replay or replay-none, not both: a replay proves the fix, replay-none says why none can") + } + if reason != "" && to != StatusWontfix { + return nil, fmt.Errorf("reason is what wontfix needs; this moves to %s", to) + } + + var from string + var changed []string + is, err := t.change(a.Number, func(is *Issue) error { + changed = nil + if is.Record != "" { + return fmt.Errorf("issue %d keeps its status in its folder %s until the import of slice 3 has run: "+ + "change it there. The tracker holds its number, title, notes and links", is.Number, is.Record) + } + from = is.Status + if from == to { + return fmt.Errorf("issue %d is already %s", is.Number, to) + } + set := func(name string, cond bool, apply func()) { + if cond { + apply() + changed = append(changed, name) + } + } + set("located-in", located != nil, func() { is.LocatedIn = located }) + set("fixed-by", fixed != nil, func() { is.FixedBy = fixed }) + set("amended-design", designs != nil, func() { is.AmendedDesign = designs }) + set("replay", replay != "", func() { is.Replay, is.ReplayNone = replay, "" }) + set("replay-none", replayNone != "", func() { is.ReplayNone, is.Replay = replayNone, "" }) + set("reason", reason != "", func() { is.Reason = reason }) + if err := cycleRules(is, to); err != nil { + return err + } + is.Status = to + if to == StatusResolved || to == StatusWontfix { + at := t.now() + is.Closed = &at + } else { + is.Closed = nil + } + return nil + }) + if err != nil { + return nil, err + } + out := &Moved{Number: is.Number, From: from, To: to, Changed: changed, By: t.by(), + Said: fmt.Sprintf("Issue %d moved from %s to %s.", is.Number, from, to)} + body := map[string]any{"number": is.Number, "from": from, "to": to, "by": out.By, "at": t.now()} + for _, c := range changed { + switch c { + case "located-in": + body[c] = is.LocatedIn + case "fixed-by": + body[c] = is.FixedBy + case "amended-design": + body[c] = is.AmendedDesign + case "replay": + body[c] = is.Replay + case "replay-none": + body[c] = is.ReplayNone + case "reason": + body[c] = is.Reason + } + } + out.Announced, out.Warning = t.emit(EventMoved, body) + return out, nil +} + +// cycleRules is what the development cycle asks of an issue at a status (hq cycle.py, playbook 03, to-be 53 +// §5), said as the rule that refused it. +func cycleRules(is *Issue, to string) error { + switch to { + case StatusLocated: + if len(is.LocatedIn) == 0 { + return fmt.Errorf("issue %d cannot be located without located-in: name at least one registered owner "+ + "(a repository, or \"mesh-catalog modules/\")", is.Number) + } + case StatusResolved: + if len(is.LocatedIn) == 0 { + return fmt.Errorf("issue %d cannot be resolved without located-in: name the owner that was fixed", is.Number) + } + if len(is.FixedBy) == 0 { + return fmt.Errorf("issue %d cannot be resolved without fixed-by: name the pull requests or commits that fixed it", is.Number) + } + if IsCore(is.LocatedIn) && is.Replay == "" && is.ReplayNone == "" { + return fmt.Errorf("issue %d is a core issue (it is located in the controller, the node-engine, the tool "+ + "runner, the SDK, or the catalogue's bus, forge or build agent): it resolves with replay, the replay's id "+ + "in mesh-lab's replays register, or replay-none, why no replay is possible (ADR 0237)", is.Number) + } + case StatusWontfix: + if strings.TrimSpace(is.Reason) == "" { + return fmt.Errorf("issue %d cannot be closed as wontfix without a reason: say why it will not be fixed", is.Number) + } + } + return nil +} + +// Noted is the answer to a note. +type Noted struct { + Number uint64 `json:"number"` + Notes int `json:"notes"` + By string `json:"by"` + Said string `json:"said"` + Announced bool `json:"announced"` + Warning string `json:"warning,omitempty"` +} + +// Note appends a note to an issue; a note is never edited. +func (t *Tracker) Note(n uint64, text string) (*Noted, error) { + text, err := multiLine("text", text, MostNote) + if err != nil { + return nil, err + } + note := Note{By: t.by(), At: t.now(), Text: text} + is, err := t.change(n, func(is *Issue) error { + is.Notes = append(is.Notes, note) + return nil + }) + if err != nil { + return nil, err + } + out := &Noted{Number: n, Notes: len(is.Notes), By: note.By, + Said: fmt.Sprintf("Noted on issue %d (note %d).", n, len(is.Notes))} + out.Announced, out.Warning = t.emit(EventNoted, map[string]any{"number": n, "by": note.By, "at": note.At, "text": text}) + return out, nil +} + +// LinkAsk is what a link carries. +type LinkAsk struct { + Number uint64 + Kind string + Target string + Remove bool +} + +// Linked is the answer to a link. +type Linked struct { + Number uint64 `json:"number"` + Kind string `json:"kind"` + Target string `json:"target"` + Removed bool `json:"removed"` + Links int `json:"links"` + By string `json:"by"` + Said string `json:"said"` + Announced bool `json:"announced"` + Warning string `json:"warning,omitempty"` +} + +// Link adds or removes a typed link. +func (t *Tracker) Link(a LinkAsk) (*Linked, error) { + kind := strings.TrimSpace(a.Kind) + target, err := checkTarget(kind, a.Target) + if err != nil { + return nil, err + } + if (kind == LinkDuplicates || kind == LinkRelatesTo) && !a.Remove { + other, ok := numberOfKey(target) + if !ok { + return nil, fmt.Errorf("%q is not an issue's number", target) + } + if other == a.Number { + return nil, fmt.Errorf("issue %d cannot be linked to itself", a.Number) + } + if _, _, err := read(t.Store, other); err != nil { + return nil, err + } + } + by, at := t.by(), t.now() + is, err := t.change(a.Number, func(is *Issue) error { + for i, l := range is.Links { + if l.Kind == kind && l.Target == target { + if !a.Remove { + return fmt.Errorf("issue %d already has the %s link to %s", is.Number, kind, target) + } + is.Links = append(is.Links[:i:i], is.Links[i+1:]...) + return nil + } + } + if a.Remove { + return fmt.Errorf("issue %d has no %s link to %s to remove", is.Number, kind, target) + } + if len(is.Links) >= MostLinks { + return fmt.Errorf("issue %d has %d links, the most one issue keeps", is.Number, MostLinks) + } + is.Links = append(is.Links, Link{Kind: kind, Target: target, By: by, At: at}) + return nil + }) + if err != nil { + return nil, err + } + out := &Linked{Number: a.Number, Kind: kind, Target: target, Removed: a.Remove, Links: len(is.Links), By: by} + if a.Remove { + out.Said = fmt.Sprintf("Removed the %s link from issue %d to %s.", kind, a.Number, target) + } else { + out.Said = fmt.Sprintf("Linked issue %d to %s (%s).", a.Number, target, kind) + } + out.Announced, out.Warning = t.emit(EventLinked, map[string]any{"number": a.Number, "kind": kind, "target": target, + "removed": a.Remove, "by": by, "at": at}) + return out, nil +} + +// ListAsk narrows a list. +type ListAsk struct { + Status string + Owner string + Phrase string + Limit int +} + +// 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"` +} + +// Listed is the answer to a list. +type Listed struct { + Rows []Row `json:"rows"` + Matched int `json:"matched"` + Said string `json:"said"` +} + +// InFolder is the status a list shows for an issue whose folder in hq keeps its status. +const InFolder = "kept in its folder" + +// List is the issues that match, newest first. +func (t *Tracker) List(a ListAsk) (*Listed, error) { + status := strings.TrimSpace(a.Status) + if status != "" && !known(status) { + return nil, fmt.Errorf("status %q is not one of %s", status, strings.Join(Statuses, ", ")) + } + owner, phrase := "", "" + var err error + if strings.TrimSpace(a.Owner) != "" { + if owner, err = singleLine("owner", a.Owner, MostLine); err != nil { + return nil, err + } + } + if strings.TrimSpace(a.Phrase) != "" { + if phrase, err = singleLine("phrase", a.Phrase, MostPhrase); err != nil { + return nil, err + } + } + limit := a.Limit + if limit <= 0 { + limit = 50 + } + limit = min(limit, MostListResult) + keys, err := t.Store.Keys(IssuesState) + if err != nil { + return nil, fmt.Errorf("the issues cannot be listed: %v", err) + } + var numbers []uint64 + for _, k := range keys { + if n, ok := numberOfKey(k); ok { + 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) { + continue + } + if err != nil { + return nil, err + } + if status != "" && is.Status != status { + continue + } + if owner != "" && !locatedUnder(is.LocatedIn, owner) { + continue + } + if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) { + continue + } + out.Matched++ + if len(out.Rows) < limit { + st := is.Status + if is.Record != "" { + st = InFolder + } + out.Rows = append(out.Rows, Row{Number: n, Status: st, Owner: strings.Join(is.LocatedIn, "; "), Title: is.Title}) + } + } + switch { + case out.Matched == 0: + out.Said = "No issue matches." + case out.Matched > len(out.Rows): + out.Said = fmt.Sprintf("%d issues match; the newest %d are shown.", out.Matched, len(out.Rows)) + default: + out.Said = fmt.Sprintf("%d issues match, newest first.", out.Matched) + } + return out, nil +} + +// locatedUnder says whether an issue is located in the owner asked for: an entry that is the owner, or +// starts with it and a space. +func locatedUnder(located []string, owner string) bool { + for _, e := range located { + if e == owner || strings.HasPrefix(e, owner+" ") { + return true + } + } + return false +} + +// newID is a random id for one open. +func newID() string { + var b [12]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// jitter is a random wait up to d, so openers that collided do not collide again in step. +func jitter(d time.Duration) time.Duration { + if d <= 0 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(d))) + if err != nil { + return d + } + return time.Duration(n.Int64()) +} diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker_test.go b/modules/mesh-issues/cmd/mesh-issues/tracker_test.go new file mode 100644 index 00000000..0e3d6552 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/tracker_test.go @@ -0,0 +1,562 @@ +package main + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +func TestTheFirstIssueGetsTheSettingsNumberAndEachNextOneMore(t *testing.T) { + h := newHarness(t) + for want := uint64(365); want < 368; want++ { + if got := h.open(t, "a symptom"); got != want { + t.Fatalf("opened %d, want %d", got, want) + } + } + is, err := h.Get(366) + if err != nil { + t.Fatal(err) + } + if is.Status != StatusOpen || is.Title != "a symptom" || is.Report != "seen: a symptom" || is.OpenedBy != Unproven || + is.Opened.IsZero() || is.Closed != nil { + t.Fatalf("kept %+v", is) + } +} + +func TestTheCounterNeverStartsBelowAnIssueAlreadyKept(t *testing.T) { + h := newHarness(t) + h.mem.put(IssuesState, "400", Issue{Number: 400, Title: "kept before", Status: StatusOpen, Opening: "x"}) + if got := h.open(t, "after it"); got != 401 { + t.Fatalf("opened %d, want 401: the counter started below an issue kept", got) + } +} + +func TestTheCounterIsStartedOnlyOnceAndTheSettingIsThenNotRead(t *testing.T) { + h := newHarness(t) + h.open(t, "first") + h.settings.FirstNumber = 10 // a later setting cannot move a number + if got := h.open(t, "second"); got != 366 { + t.Fatalf("opened %d, want 366", got) + } + h2 := newHarness(t) + h2.settings.FirstNumber = 0 + if _, err := h2.Open(OpenAsk{Title: "t", Report: "r"}); err == nil || !strings.Contains(err.Error(), "first-number") { + t.Fatalf("an unset first number: %v", err) + } + if h2.mem.counter(t) != 0 { + t.Fatal("a counter was started without its first number") + } +} + +func TestARefusedOpenSpendsNoNumber(t *testing.T) { + h := newHarness(t) + refused := []OpenAsk{ + {Title: "", Report: "r"}, + {Title: "t", Report: " "}, + {Title: "two\nlines", Report: "r"}, + {Title: strings.Repeat("x", MostTitle+1), Report: "r"}, + {Title: "t", Report: strings.Repeat("x", MostReport+1)}, + {Title: "t", Report: "r", LocatedIn: []string{"somewhere-else"}}, + {Title: "t", Report: "r", LocatedIn: []string{"mesh-controllerx"}}, + {Title: "t", Report: "r", Record: "../escape"}, + {Title: "t", Report: "r", Record: "04-ISSUES/365-x"}, + {Title: "t", Report: "r", Record: "Upper"}, + {Title: "t", Report: "the key is sk-ant-api03-abcdefghijklmnopqrstuvwxyz"}, + {Title: "t\x1b[31m", Report: "r"}, + {Title: "t‮", Report: "r"}, + {Title: "t", Report: "r\x00"}, + {Title: "t", Report: "r", Record: "a-folder", LocatedIn: []string{"mesh-controller"}}, + } + for i, a := range refused { + if _, err := h.Open(a); err == nil { + t.Errorf("case %d (%+v) was opened", i, a) + } + } + if h.mem.counter(t) != 0 || len(h.said()) != 0 { + t.Fatalf("a refused open moved the counter to %d or said %v", h.mem.counter(t), h.said()) + } + if got := h.open(t, "valid"); got != 365 { + t.Fatalf("the first valid open got %d, want 365", got) + } +} + +func TestAnIssuesKeyIsNeverOverwrittenAndTheFaultIsSaid(t *testing.T) { + h := newHarness(t) + h.open(t, "first") // 365, the counter started + h.mem.put(IssuesState, "366", Issue{Number: 366, Title: "already here", Opening: "other"}) + _, err := h.Open(OpenAsk{Title: "second", Report: "r"}) + if err == nil || !strings.Contains(err.Error(), "already kept") || !strings.Contains(err.Error(), "366 stays unused") { + t.Fatalf("open over a kept key: %v", err) + } + is, _ := h.Get(366) + if is.Title != "already here" { + t.Fatal("the kept issue was overwritten") + } + if got := h.open(t, "third"); got != 367 { + t.Fatalf("the next open got %d, want 367", got) + } +} + +func TestANumberTakenAndNotWrittenIsSaidAndNeverHandedOutAgain(t *testing.T) { + h := newHarness(t) + h.open(t, "first") + h.mem.fail[IssuesState] = errors.New("the bus did not answer") + // One failure is tried again and succeeds: no gap. + if got := h.open(t, "second"); got != 366 { + t.Fatalf("got %d", got) + } + // Failing every time leaves a gap, said. + failing := &failingStore{memory: h.mem, failCreates: createAttempts} + h.Store = failing + _, err := h.Open(OpenAsk{Title: "third", Report: "r"}) + if err == nil || !strings.Contains(err.Error(), "367 stays unused") { + t.Fatalf("a write that never landed: %v", err) + } + h.Store = h.mem + if got := h.open(t, "fourth"); got != 368 { + t.Fatalf("got %d, want 368: a number is never handed out twice", got) + } +} + +// failingStore fails the next creates of issues, as a bus that takes no write. +type failingStore struct { + *memory + failCreates int +} + +func (f *failingStore) Create(state, key string, value any) (uint64, error) { + if state == IssuesState && f.failCreates > 0 { + f.failCreates-- + return 0, errors.New("the bus did not answer") + } + return f.memory.Create(state, key, value) +} + +// lostAnswer writes the issue and then says it failed, as a bus whose answer was lost. +type lostAnswer struct { + *memory + once bool +} + +func (l *lostAnswer) Create(state, key string, value any) (uint64, error) { + rev, err := l.memory.Create(state, key, value) + if state == IssuesState && !l.once && err == nil { + l.once = true + return 0, errors.New("no answer in 30 s") + } + return rev, err +} + +func TestAWriteWhoseAnswerWasLostIsKnownAsItsOwn(t *testing.T) { + h := newHarness(t) + h.Store = &lostAnswer{memory: h.mem} + o, err := h.Open(OpenAsk{Title: "t", Report: "r"}) + if err != nil || o.Number != 365 { + t.Fatalf("%v %v", o, err) + } +} + +func TestOpenSaysAnEventWithWhatChangedAndWhoChangedIt(t *testing.T) { + h := newHarness(t) + o, err := h.Open(OpenAsk{Title: "a title", Report: "a report", LocatedIn: []string{"mesh-controller internal/broker"}}) + if err != nil { + t.Fatal(err) + } + if !o.Announced || o.By != Unproven || o.Status != StatusOpen || !strings.Contains(o.Said, "Opened issue 365") { + t.Fatalf("answer %+v", o) + } + ev := h.said() + if len(ev) != 1 || ev[0].Event != EventOpened || ev[0].Body["number"] != float64(365) || ev[0].Body["title"] != "a title" || + ev[0].Body["by"] != Unproven || ev[0].Body["status"] != StatusOpen { + t.Fatalf("events %+v", ev) + } + if _, carried := ev[0].Body["report"]; carried { + t.Fatal("the opened event carries the whole report; it says what changed, and get has the rest") + } +} + +func TestAChangeWhoseEventCannotBeSaidIsKeptAndSaysSo(t *testing.T) { + h := newHarness(t) + h.emitErr = errors.New("the bus refused it") + o, err := h.Open(OpenAsk{Title: "t", Report: "r"}) + if err != nil { + t.Fatal(err) + } + if o.Announced || !strings.Contains(o.Warning, "could not be said") { + t.Fatalf("a change not announced was reported as announced: %+v", o) + } + if _, err := h.Get(o.Number); err != nil { + t.Fatal("the change was not kept") + } +} + +func TestAnIssueWithAFolderIsNumberedTitledAndPointsAtItsFolder(t *testing.T) { + h := newHarness(t) + o, err := h.Open(OpenAsk{Title: "t", Report: "r", Record: "the-counter-skips-a-number"}) + if err != nil { + t.Fatal(err) + } + if o.Record != "04-ISSUES/365-the-counter-skips-a-number" || o.Status != "" { + t.Fatalf("answer %+v", o) + } + _, err = h.Move(MoveAsk{Number: 365, Status: StatusDiagnosing}) + if err == nil || !strings.Contains(err.Error(), "keeps its status in its folder") { + t.Fatalf("a move of an issue with a folder: %v", err) + } + if _, err := h.Note(365, "notes are taken"); err != nil { + t.Fatal(err) + } + l, _ := h.List(ListAsk{}) + if l.Rows[0].Status != InFolder { + t.Fatalf("listed %+v", l.Rows) + } +} + +// The cycle's rules at the write (to-be 53 §5): one case per row, each refused case changing nothing. +func TestTheCyclesRulesHoldAtTheWrite(t *testing.T) { + cases := []struct { + name string + located []string // at open + ask MoveAsk + refused string + }{ + {"an unknown status", nil, MoveAsk{Status: "closed"}, "not one of"}, + {"located without located-in", nil, MoveAsk{Status: StatusLocated}, "without located-in"}, + {"located with an unregistered owner", nil, MoveAsk{Status: StatusLocated, LocatedIn: []string{"elsewhere"}}, "no registered owner"}, + {"located with located-in", nil, MoveAsk{Status: StatusLocated, LocatedIn: []string{"mesh-catalog modules/records"}}, ""}, + {"located, named at open", []string{"hq"}, MoveAsk{Status: StatusLocated}, ""}, + {"resolved without located-in", nil, MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#1"}}, "without located-in"}, + {"resolved without fixed-by", []string{"hq"}, MoveAsk{Status: StatusResolved}, "without fixed-by"}, + {"resolved", []string{"hq"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"hq#280"}}, ""}, + {"a core issue resolved without a replay", []string{"mesh-controller"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-controller#9"}}, "core issue"}, + {"a core issue in the bus resolved without a replay", []string{"mesh-catalog modules/nats"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-catalog#9"}}, "core issue"}, + {"a core issue resolved with a replay", []string{"mesh-tools node-tools/internal/bus"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-tools#25"}, Replay: "R361"}, ""}, + {"a core issue resolved with replay-none", []string{"mesh-sdk"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-sdk#16"}, ReplayNone: "it needs two machines"}, ""}, + {"a replay that names none", []string{"mesh-sdk"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"mesh-sdk#16"}, Replay: "yes"}, "names no replay"}, + {"replay and replay-none", []string{"mesh-sdk"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"x"}, Replay: "R1", ReplayNone: "y"}, "not both"}, + {"a replay on another status", []string{"mesh-sdk"}, MoveAsk{Status: StatusLocated, Replay: "R1"}, "what resolving"}, + {"wontfix without a reason", nil, MoveAsk{Status: StatusWontfix}, "without a reason"}, + {"wontfix", nil, MoveAsk{Status: StatusWontfix, Reason: "the module is retired"}, ""}, + {"a reason on another status", nil, MoveAsk{Status: StatusDiagnosing, Reason: "x"}, "what wontfix needs"}, + {"to the status it has", nil, MoveAsk{Status: StatusOpen}, "already open"}, + {"diagnosing", nil, MoveAsk{Status: StatusDiagnosing}, ""}, + {"an amended design that is not one", nil, MoveAsk{Status: StatusDiagnosing, AmendedDesign: []string{"../etc/passwd"}}, "not a design document"}, + {"a fixed-by of two lines", []string{"hq"}, MoveAsk{Status: StatusResolved, FixedBy: []string{"a\nb"}}, "one line"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := newHarness(t) + n := h.open(t, "an issue", c.located...) + before, _ := h.mem.Get(IssuesState, keyOf(n)) + c.ask.Number = n + m, err := h.Move(c.ask) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Fatalf("want refused with %q, got %v", c.refused, err) + } + after, _ := h.mem.Get(IssuesState, keyOf(n)) + if after.Revision != before.Revision || string(after.Value) != string(before.Value) { + t.Fatal("a refused move changed the issue") + } + if len(h.said()) != 1 { + t.Fatalf("a refused move said %v", h.said()) + } + return + } + if err != nil { + t.Fatalf("refused: %v", err) + } + is, _ := h.Get(n) + if is.Status != c.ask.Status || m.From != StatusOpen || m.To != c.ask.Status { + t.Fatalf("moved %+v, kept %+v", m, is) + } + closed := c.ask.Status == StatusResolved || c.ask.Status == StatusWontfix + if (is.Closed != nil) != closed { + t.Fatalf("closed %v at %s", is.Closed, is.Status) + } + ev := h.said() + if last := ev[len(ev)-1]; last.Event != EventMoved || last.Body["to"] != c.ask.Status || last.Body["from"] != StatusOpen || + last.Body["by"] != Unproven { + t.Fatalf("event %+v", last) + } + }) + } +} + +func TestReopeningClearsWhenItClosed(t *testing.T) { + h := newHarness(t) + n := h.open(t, "x", "hq") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusResolved, FixedBy: []string{"hq#1"}}); err != nil { + t.Fatal(err) + } + if _, err := h.Move(MoveAsk{Number: n, Status: StatusOpen}); err != nil { + t.Fatal(err) + } + is, _ := h.Get(n) + if is.Closed != nil || is.Status != StatusOpen { + t.Fatalf("%+v", is) + } +} + +func TestAMoveOfAnIssueThatDoesNotExistIsRefused(t *testing.T) { + h := newHarness(t) + if _, err := h.Move(MoveAsk{Number: 9, Status: StatusDiagnosing}); !errors.Is(err, ErrNoIssue) { + t.Fatalf("%v", err) + } + if _, err := h.Get(9); err == nil || !strings.Contains(err.Error(), "there is no issue 9") { + t.Fatalf("%v", err) + } +} + +func TestNotesAreAppendedNeverEditedAndEachIsSaid(t *testing.T) { + h := newHarness(t) + n := h.open(t, "x") + for _, text := range []string{"first finding", "second, over\ntwo lines"} { + if _, err := h.Note(n, text); err != nil { + t.Fatal(err) + } + } + is, _ := h.Get(n) + if len(is.Notes) != 2 || is.Notes[0].Text != "first finding" || is.Notes[1].Text != "second, over\ntwo lines" || + is.Notes[0].By != Unproven { + t.Fatalf("%+v", is.Notes) + } + ev := h.said() + if ev[2].Event != EventNoted || ev[2].Body["text"] != "second, over\ntwo lines" || ev[2].Body["number"] != float64(n) { + t.Fatalf("%+v", ev) + } + for _, bad := range []string{"", "\x07", "password=hunter2hunter2", strings.Repeat("x", MostNote+1)} { + if _, err := h.Note(n, bad); err == nil { + t.Errorf("note %q taken", bad) + } + } + if _, err := h.Note(404, "x"); !errors.Is(err, ErrNoIssue) { + t.Fatalf("a note on no issue: %v", err) + } +} + +func TestANoteThatWouldMakeTheIssueTooLargeIsRefusedAndNothingChanges(t *testing.T) { + h := newHarness(t) + n := h.open(t, "x") + note := strings.Repeat("word ", MostNote/5-1) + var err error + for i := 0; i < 40 && err == nil; i++ { + _, err = h.Note(n, note) + } + if err == nil || !strings.Contains(err.Error(), "256 KiB") { + t.Fatalf("no refusal: %v", err) + } + e, _ := h.mem.Get(IssuesState, keyOf(n)) + if len(e.Value) > MostIssueBytes { + t.Fatalf("kept %d bytes", len(e.Value)) + } +} + +func TestConcurrentNotesAreAllKept(t *testing.T) { + h := newHarness(t) + h.Sleep = time.Sleep + n := h.open(t, "x") + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := h.Note(n, "a note"); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + is, _ := h.Get(n) + if len(is.Notes) != 20 { + t.Fatalf("kept %d of 20 notes: a write over a stale read lost some", len(is.Notes)) + } +} + +func TestLinksAreTypedAddedOnceAndRemoved(t *testing.T) { + h := newHarness(t) + a, b := h.open(t, "a"), h.open(t, "b") + good := []LinkAsk{ + {Kind: LinkDuplicates, Target: keyOf(b)}, + {Kind: LinkRelatesTo, Target: keyOf(b)}, + {Kind: LinkPullRequest, Target: "mesh-controller#246"}, + {Kind: LinkDecision, Target: "0275"}, + {Kind: LinkDesign, Target: "03-DESIGN/01-to-be/53-the-issue-tracker.md"}, + {Kind: LinkCondition, Target: "healer-wanted.ace.backup"}, + {Kind: LinkDelivery, Target: "d-2026-10-10.3"}, + } + for _, l := range good { + l.Number = a + if _, err := h.Link(l); err != nil { + t.Fatalf("%+v: %v", l, err) + } + } + bad := []struct { + l LinkAsk + says string + }{ + {LinkAsk{Kind: "blocks", Target: "1"}, "not a kind"}, + {LinkAsk{Kind: LinkDuplicates, Target: keyOf(a)}, "itself"}, + {LinkAsk{Kind: LinkDuplicates, Target: "999"}, "there is no issue 999"}, + {LinkAsk{Kind: LinkPullRequest, Target: "mesh-controller#246"}, "already has"}, + {LinkAsk{Kind: LinkPullRequest, Target: "../x#1"}, "target is"}, + {LinkAsk{Kind: LinkDecision, Target: "275"}, "four-digit"}, + {LinkAsk{Kind: LinkDesign, Target: "03-DESIGN/../../etc/passwd"}, "design document"}, + {LinkAsk{Kind: LinkCondition, Target: "a key\nwith a line"}, "condition"}, + {LinkAsk{Kind: LinkRelatesTo, Target: keyOf(b), Remove: true}, ""}, + {LinkAsk{Kind: LinkRelatesTo, Target: keyOf(b), Remove: true}, "no relates-to link"}, + } + for _, c := range bad { + c.l.Number = a + _, err := h.Link(c.l) + if c.says == "" { + if err != nil { + t.Fatalf("%+v: %v", c.l, err) + } + continue + } + if err == nil || !strings.Contains(err.Error(), c.says) { + t.Fatalf("%+v: want %q, got %v", c.l, c.says, err) + } + } + is, _ := h.Get(a) + if len(is.Links) != len(good)-1 { + t.Fatalf("kept %d links", len(is.Links)) + } + ev := h.said() + last := ev[len(ev)-1] + if last.Event != EventLinked || last.Body["removed"] != true || last.Body["kind"] != LinkRelatesTo || last.Body["by"] != Unproven { + t.Fatalf("%+v", last) + } +} + +func TestListIsNewestFirstAndNarrows(t *testing.T) { + h := newHarness(t) + h.open(t, "the bus drops a message", "mesh-catalog modules/nats") + h.open(t, "a check passes and did nothing", "mesh-controller internal/catalogue") + n := h.open(t, "the console names a ghost") + if _, err := h.Move(MoveAsk{Number: n, Status: StatusDiagnosing}); err != nil { + t.Fatal(err) + } + l, err := h.List(ListAsk{}) + if err != nil { + t.Fatal(err) + } + var got []uint64 + for _, r := range l.Rows { + got = append(got, r.Number) + } + if !reflect.DeepEqual(got, []uint64{367, 366, 365}) || l.Rows[1].Owner != "mesh-controller internal/catalogue" || + l.Rows[0].Status != StatusDiagnosing { + t.Fatalf("%+v", l) + } + for _, c := range []struct { + ask ListAsk + want []uint64 + }{ + {ListAsk{Status: StatusOpen}, []uint64{366, 365}}, + {ListAsk{Owner: "mesh-controller"}, []uint64{366}}, + {ListAsk{Owner: "mesh-catalog"}, []uint64{365}}, + {ListAsk{Owner: "mesh-catalog modules/nats"}, []uint64{365}}, + {ListAsk{Phrase: "GHOST"}, []uint64{367}}, + {ListAsk{Phrase: "seen: a check"}, []uint64{366}}, + {ListAsk{Limit: 1}, []uint64{367}}, + {ListAsk{Status: StatusResolved}, nil}, + } { + l, err := h.List(c.ask) + if err != nil { + t.Fatal(err) + } + var got []uint64 + for _, r := range l.Rows { + got = append(got, r.Number) + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%+v: %v, want %v (%s)", c.ask, got, c.want, l.Said) + } + } + if _, err := h.List(ListAsk{Status: "closed"}); err == nil { + t.Fatal("an unknown status was listed") + } +} + +// The caller is the bus credential, never an argument (ADR 0275 §5): no verb takes one, so none can be +// named, and what is recorded is never what an argument said. +func TestNoArgumentNamesWhoActed(t *testing.T) { + h := newHarness(t) + for _, verb := range Verbs { + for _, field := range []string{"by", "actor", "caller", "opened-by", "user"} { + _, err := h.call(t, verb, map[string]any{field: "operator", "number": 1, "title": "t", "report": "r", + "status": "open", "text": "x", "kind": LinkDecision, "target": "0001"}) + if err == nil || !strings.Contains(err.Error(), "does not take "+field) && !strings.Contains(err.Error(), field) { + t.Errorf("%s with %s: %v", verb, field, err) + } + } + } + out, err := h.call(t, "open", map[string]any{"title": "t", "report": "r"}) + if err != nil { + t.Fatal(err) + } + if out.(*Opened).By != Unproven { + t.Fatal("open recorded a caller nothing proved") + } + is, _ := h.Get(out.(*Opened).Number) + raw, _ := json.Marshal(is) + if strings.Contains(string(raw), "operator") { + t.Fatal("an argument's name reached the record") + } +} + +func TestTheVerbsReadTheirArgumentsStrictly(t *testing.T) { + h := newHarness(t) + n := h.open(t, "x") + refusals := []struct { + verb string + args map[string]any + }{ + {"open", map[string]any{"title": 5, "report": "r"}}, + {"open", map[string]any{"title": "t", "report": "r", "located-in": []any{1}}}, + {"get", map[string]any{}}, + {"get", map[string]any{"number": -1}}, + {"get", map[string]any{"number": 1.5}}, + {"get", map[string]any{"number": "../365"}}, + {"get", map[string]any{"number": "0365"}}, + {"move", map[string]any{"number": n, "status": true}}, + {"note", map[string]any{"number": n}}, + {"link", map[string]any{"number": n, "kind": LinkDecision, "target": "0275", "remove": "yes"}}, + {"list", map[string]any{"limit": "all"}}, + } + for _, r := range refusals { + if _, err := h.call(t, r.verb, r.args); err == nil { + 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 { + 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 || + !reflect.DeepEqual(out.(*Opened).LocatedIn, []string{"hq"}) { + t.Fatalf("one owner as text: %v", err) + } +} + +func TestAnOwnersEntryIsTheRegisteredOneOrStartsWithIt(t *testing.T) { + o := Owners{"mesh-catalog", "mesh-catalog modules/nats", "mesh-controller"} + for entry, want := range map[string]string{ + "mesh-catalog": "mesh-catalog", + "mesh-catalog modules/nats": "mesh-catalog modules/nats", + "mesh-catalog modules/nats/module.go": "mesh-catalog", + "mesh-catalog modules/nats (the gate)": "mesh-catalog modules/nats", + "mesh-controller cmd/mesh-controller": "mesh-controller", + "mesh-controllers": "", + "hal": "", + } { + if got := o.owns(entry); got != want { + t.Errorf("%q is owned by %q, want %q", entry, got, want) + } + } +} diff --git a/modules/mesh-issues/go.mod b/modules/mesh-issues/go.mod new file mode 100644 index 00000000..52731589 --- /dev/null +++ b/modules/mesh-issues/go.mod @@ -0,0 +1,22 @@ +module mesh-issues + +go 1.25.0 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.12 + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.51.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/mesh-issues/go.sum b/modules/mesh-issues/go.sum new file mode 100644 index 00000000..be0532de --- /dev/null +++ b/modules/mesh-issues/go.sum @@ -0,0 +1,27 @@ +git.novox.be/novox/mesh-sdk/go v0.1.12 h1:QvmLlT88F+OYt8MOytMGM5ZEOo0fKmNZvNVeJe6gnTk= +git.novox.be/novox/mesh-sdk/go v0.1.12/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= +github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/mesh-issues/module.json b/modules/mesh-issues/module.json new file mode 100644 index 00000000..f1607758 --- /dev/null +++ b/modules/mesh-issues/module.json @@ -0,0 +1,81 @@ +{ + "module": "mesh-issues", + "version": "1", + "slug": "issues", + "seats": [ + { + "name": "issue-tracker", + "scope": "mesh", + "serves": [ + "open", + "get", + "list", + "move", + "note", + "link" + ] + } + ], + "claims": [ + { + "name": "issue-tracker", + "scope": "mesh", + "serves": [ + "open", + "get", + "list", + "move", + "note", + "link" + ] + } + ], + "emits": [ + "opened", + "moved", + "noted", + "linked" + ], + "state": [ + { + "name": "issues", + "history": 8 + }, + "counters" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "settings", + "trusted": true, + "type": "file", + "path": "${dir:state}/settings.json", + "mode": "0600", + "merge": "json", + "content": "{\n \"first-number\": 365,\n \"owners\": [\n \"hq\",\n \"mesh-catalog\",\n \"mesh-catalog modules/adwaita\",\n \"mesh-catalog modules/artifact-store-tools\",\n \"mesh-catalog modules/asus-zephyrus-g14\",\n \"mesh-catalog modules/audit-logger\",\n \"mesh-catalog modules/avahi\",\n \"mesh-catalog modules/baserow\",\n \"mesh-catalog modules/betterbird\",\n \"mesh-catalog modules/blueman\",\n \"mesh-catalog modules/bluetooth\",\n \"mesh-catalog modules/build-agent\",\n \"mesh-catalog modules/ca-trust\",\n \"mesh-catalog modules/claude-code\",\n \"mesh-catalog modules/claude-licence-manager\",\n \"mesh-catalog modules/clipmenu\",\n \"mesh-catalog modules/confluence\",\n \"mesh-catalog modules/cups\",\n \"mesh-catalog modules/dbus\",\n \"mesh-catalog modules/de-spiegel\",\n \"mesh-catalog modules/desk-channel\",\n \"mesh-catalog modules/disk-load\",\n \"mesh-catalog modules/distribution\",\n \"mesh-catalog modules/dmenu\",\n \"mesh-catalog modules/dnsmasq\",\n \"mesh-catalog modules/docker\",\n \"mesh-catalog modules/docker-compose\",\n \"mesh-catalog modules/dunst\",\n \"mesh-catalog modules/fail2ban\",\n \"mesh-catalog modules/feh\",\n \"mesh-catalog modules/flatpak\",\n \"mesh-catalog modules/fonts\",\n \"mesh-catalog modules/forticlient\",\n \"mesh-catalog modules/gitea\",\n \"mesh-catalog modules/gitlab\",\n \"mesh-catalog modules/gnome-keyring\",\n \"mesh-catalog modules/grafana\",\n \"mesh-catalog modules/hello-web\",\n \"mesh-catalog modules/home-assistant\",\n \"mesh-catalog modules/hostname\",\n \"mesh-catalog modules/i3\",\n \"mesh-catalog modules/i3status-rust\",\n \"mesh-catalog modules/icecast\",\n \"mesh-catalog modules/influxdb\",\n \"mesh-catalog modules/invoicing\",\n \"mesh-catalog modules/jira\",\n \"mesh-catalog modules/keycloak\",\n \"mesh-catalog modules/lab\",\n \"mesh-catalog modules/lemurs\",\n \"mesh-catalog modules/letta\",\n \"mesh-catalog modules/local-model-consumer\",\n \"mesh-catalog modules/localization\",\n \"mesh-catalog modules/logrotate\",\n \"mesh-catalog modules/mailu\",\n \"mesh-catalog modules/marrytts\",\n \"mesh-catalog modules/matrix\",\n \"mesh-catalog modules/memory-pressure\",\n \"mesh-catalog modules/mesh-catalog\",\n \"mesh-catalog modules/mesh-cli\",\n \"mesh-catalog modules/mesh-delivery\",\n \"mesh-catalog modules/mesh-vault\",\n \"mesh-catalog modules/mesh-watcher\",\n \"mesh-catalog modules/messenger\",\n \"mesh-catalog modules/minio\",\n \"mesh-catalog modules/model-usage\",\n \"mesh-catalog modules/mongodb\",\n \"mesh-catalog modules/mosquitto\",\n \"mesh-catalog modules/mounts\",\n \"mesh-catalog modules/mssql\",\n \"mesh-catalog modules/n8n\",\n \"mesh-catalog modules/nats\",\n \"mesh-catalog modules/netcheck\",\n \"mesh-catalog modules/networkmanager\",\n \"mesh-catalog modules/nextcloud\",\n \"mesh-catalog modules/nextcloud-client\",\n \"mesh-catalog modules/nfs-server\",\n \"mesh-catalog modules/nftables\",\n \"mesh-catalog modules/nm-applet\",\n \"mesh-catalog modules/node-env\",\n \"mesh-catalog modules/nodered\",\n \"mesh-catalog modules/ollama\",\n \"mesh-catalog modules/only-office\",\n \"mesh-catalog modules/openai-consumer\",\n \"mesh-catalog modules/openrazer\",\n \"mesh-catalog modules/pacman\",\n \"mesh-catalog modules/photos-eef\",\n \"mesh-catalog modules/photos-filip\",\n \"mesh-catalog modules/picom\",\n \"mesh-catalog modules/polychromatic\",\n \"mesh-catalog modules/postgres\",\n \"mesh-catalog modules/power\",\n \"mesh-catalog modules/powerlevel10k\",\n \"mesh-catalog modules/records\",\n \"mesh-catalog modules/redis\",\n \"mesh-catalog modules/restic\",\n \"mesh-catalog modules/rofi\",\n \"mesh-catalog modules/route-adapter\",\n \"mesh-catalog modules/route-proxy\",\n \"mesh-catalog modules/screen-lock\",\n \"mesh-catalog modules/searxng\",\n \"mesh-catalog modules/sensors\",\n \"mesh-catalog modules/showcase\",\n \"mesh-catalog modules/slack\",\n \"mesh-catalog modules/snapd\",\n \"mesh-catalog modules/ssh-client\",\n \"mesh-catalog modules/sshd\",\n \"mesh-catalog modules/step-ca\",\n \"mesh-catalog modules/sudo\",\n \"mesh-catalog modules/supabase\",\n \"mesh-catalog modules/systemd\",\n \"mesh-catalog modules/systemd-networkd\",\n \"mesh-catalog modules/systemd-resolved\",\n \"mesh-catalog modules/telegram\",\n \"mesh-catalog modules/time-sync\",\n \"mesh-catalog modules/triggerhappy\",\n \"mesh-catalog modules/umami\",\n \"mesh-catalog modules/unifi\",\n \"mesh-catalog modules/website\",\n \"mesh-catalog modules/xclip\",\n \"mesh-catalog modules/xdg\",\n \"mesh-catalog modules/xorg\",\n \"mesh-catalog modules/xterm\",\n \"mesh-catalog modules/zfs\",\n \"mesh-catalog modules/zsh\",\n \"mesh-catalog modules/zsh-autosuggestions\",\n \"mesh-catalog modules/zsh-syntax-highlighting\",\n \"mesh-controller\",\n \"mesh-host\",\n \"mesh-lab\",\n \"mesh-media-catalog\",\n \"mesh-sdk\",\n \"mesh-tools\"\n ]\n}\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-issues", + "binary": "mesh-issues", + "loads": [ + "mesh-issues" + ], + "env": { + "MESH_ISSUES_SETTINGS": "${dir:state}/settings.json" + } + } + ] + } +}