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
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.
384 lines
13 KiB
Go
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)
|
|
}},
|
|
}
|
|
}
|