Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/main.go
T
jochen 2493e3e048
mesh/merge-gate pass: builds new: modules/mesh-issues, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Add mesh-issues, the issue tracker, so an issue's number comes from one counter no two openers share (hq ADR 0275, to-be 53 slice 1)
The seat issue-tracker, held once for the mesh: open, get, list, move, note
and link. The counter is advanced by the SDK's compare-and-set Increment
(go/v0.1.12) and started once at the setting first-number (365, after hq's
highest), never below an issue kept. The cycle's issue rules are refused at
the write; every change is an event. The runtime does not yet say who called,
so every change is recorded as by "unproven" and no argument can name a caller.
2026-10-10 12:51:15 +02:00

384 lines
13 KiB
Go

// 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)
}},
}
}