Merge pull request '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)' (#172) from feat/the-issue-tracker into main

This commit was merged in pull request #172.
This commit is contained in:
2026-10-10 11:00:55 +00:00
12 changed files with 3147 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# 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.<verb>` 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; when it cannot be confirmed either
way, the answer says the issue may have been written and to `get` that number before opening it again,
never that nothing was opened.
- **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/<module>`. An entry of `located-in` is an owner, or an owner followed by a space or a
slash and where in it (`mesh-controller internal/broker`, `mesh-catalog modules/nats/module.json`). A module
must itself be registered: `mesh-catalog modules/<module>` is never taken as a place in `mesh-catalog`. 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.
@@ -0,0 +1,437 @@
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
// counterLost counts only the counter's updates another opener won: proof the counter itself was contended.
counterLost 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)
if m.Method == "mesh/state.update" && p.State == CountersState {
i.b.counterLost.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, %d of them the counter's updates", b.lost.Load(), b.counterLost.Load())
if b.counterLost.Load() < opens {
t.Fatalf("only %d of the counter's updates were lost to another opener: the test did not make the openers "+
"contend for the counter", b.counterLost.Load())
}
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)
}
}
@@ -0,0 +1,255 @@
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 r == '\u2028' || r == '\u2029' {
return fmt.Errorf("%s carries the line separator %U; break lines with a newline", field, r)
}
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
// `<repository> modules/<module>` (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
// or a slash and where in it — or "". An entry that names a module of a repository (`<repository>
// modules/<module>`) is owned only by that module registered, never by the repository alone, so a module
// not on the list is not taken as a place in the repository.
func (o Owners) owns(entry string) string {
best := ""
for _, owner := range o {
if (entry == owner || strings.HasPrefix(entry, owner+" ") || strings.HasPrefix(entry, owner+"/")) && len(owner) > len(best) {
best = owner
}
}
if repo, rest, ok := strings.Cut(entry, " "); ok && strings.HasPrefix(rest, "modules/") && best == repo {
return ""
}
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}\b`)
// 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)
}
if what := secretShape(t); what != "" {
return "", fmt.Errorf("the target looks like it carries %s; a link names a thing, never a secret", what)
}
return t, nil
}
@@ -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
}
+383
View File
@@ -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)
}},
}
}
@@ -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)
}
}
}
@@ -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
}
@@ -0,0 +1,698 @@
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 = 20
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
}
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++ {
if i > 0 {
t.Sleep(jitter(time.Duration(i) * 50 * time.Millisecond))
}
_, 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?
there, _, rerr := read(t.Store, is.Number)
if rerr != nil {
t.Logf("[mesh-issues] issue %d is kept and could not be read back: %v", is.Number, rerr)
return fmt.Errorf("issue %d may have been written by this open: the key is taken and could not be read "+
"back (%v). Call get %d before opening it again, so it is not opened twice", is.Number, rerr, is.Number)
}
if 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)
}
}
// A write that timed out may still have landed: say so, never "nothing was opened".
t.Logf("[mesh-issues] number %d was taken and the issue's write was not confirmed: %v", is.Number, err)
if there, _, rerr := read(t.Store, is.Number); rerr == nil && there.Opening == is.Opening {
return nil
}
return fmt.Errorf("number %d was taken and the issue's write was not confirmed (%v): it may have been written. "+
"Call get %d before opening it again; if there is no issue %d, it stays unused", is.Number, err, is.Number, 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/<module>\")", 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())
}
@@ -0,0 +1,609 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
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(), "may have been written") || !strings.Contains(err.Error(), "get 367") {
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)
}
}
// lostEvery writes the issue on its first create and then answers every create with a failure.
type lostEvery struct{ *memory }
func (l lostEvery) Create(state, key string, value any) (uint64, error) {
if state == IssuesState {
_, _ = l.memory.Create(state, key, value)
return 0, errors.New("no answer in 30 s")
}
return l.memory.Create(state, key, value)
}
// unreadable answers a taken key with a refusal and cannot read it back.
type unreadable struct{ *memory }
func (u unreadable) Create(state, key string, value any) (uint64, error) {
if state == IssuesState {
return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged)
}
return u.memory.Create(state, key, value)
}
func (u unreadable) Get(state, key string) (*Entry, error) {
if state == IssuesState {
return nil, errors.New("no answer in 30 s")
}
return u.memory.Get(state, key)
}
// An open whose write may have landed never says nothing was opened (the review of 2026-10-10).
func TestAnOpenWhoseWriteMayHaveLandedNeverSaysNothingWasOpened(t *testing.T) {
h := newHarness(t)
h.Store = lostEvery{h.mem}
if o, err := h.Open(OpenAsk{Title: "t", Report: "r"}); err != nil || o.Number != 365 {
t.Fatalf("a write that landed, every answer lost: %v %v", o, err)
}
h.Store = unreadable{h.mem}
_, err := h.Open(OpenAsk{Title: "t", Report: "r"})
if err == nil || strings.Contains(err.Error(), "nothing was opened") || !strings.Contains(err.Error(), "get 366") {
t.Fatalf("a taken key that cannot be read back: %v", 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: LinkCondition, Target: "token=abcdefgh123"}, "secret"},
{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 modules/nats",
"mesh-catalog modules/does-not-exist": "",
"mesh-catalog checks/words": "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)
}
}
}
+22
View File
@@ -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
)
+27
View File
@@ -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=
File diff suppressed because one or more lines are too long