mesh/merge-gate pass: builds mesh-delivery, mesh-issues → novox; 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-group group fix/403-fix-time ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
hq ADR 0313: the operator's five minutes start when an agent moves the issue to fixing, not at the merge. mesh-delivery says on each transition, and in checks, when the pull request opened, when it merged and the class of what it moves. mesh-issues keeps the first move to fixing, the fix time with its four phases once the last fix is delivered, serves fix-times, and says issue-tracker.fix.<class>.over-budget on the operator's channel, cleared by the next fix of that class within budget.
741 lines
32 KiB
Go
741 lines
32 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", "import", "groom", "set", "tracking", "reserve", "fix-times"}
|
|
|
|
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 for the counter, when it is started, and
|
|
// never again for it. The counter never starts below an issue already kept. The import reads it as the
|
|
// boundary between the folders hq numbered before the tracker and the issues the tracker numbered.
|
|
FirstNumber uint64 `json:"first-number"`
|
|
// Owners is the registered list `located-in` may name (to-be 53 §3).
|
|
Owners []string `json:"owners"`
|
|
// DuplicateCheck is "off" to stop the duplicate check on new issues; anything else is on (ADR 0278 §7).
|
|
DuplicateCheck string `json:"duplicate-check,omitempty"`
|
|
// JudgeModel is the model the duplicate check asks; DefaultJudgeModel when empty.
|
|
JudgeModel string `json:"judge-model,omitempty"`
|
|
// ForgeOwner is the owner on the forge of the repositories fixed-by names, for asking a pull request's
|
|
// delivery (ADR 0279 §3).
|
|
ForgeOwner string `json:"forge-owner,omitempty"`
|
|
// StuckAfter is how long a fix may be merged and not delivered before it is said stuck (ADR 0281), as a
|
|
// duration such as 2h; DefaultStuckAfter when empty.
|
|
StuckAfter string `json:"stuck-after,omitempty"`
|
|
// WorkerStaleAfter is how long an issue's worker stays current with nothing done on it (ADR 0284 §4), as a
|
|
// duration such as 24h; DefaultWorkerStaleAfter when empty.
|
|
WorkerStaleAfter string `json:"worker-stale-after,omitempty"`
|
|
// DeliveredNotResolvedAfter is how long an issue may stay in fixing once every fix it names is delivered before
|
|
// the operator is warned (novox/hq issue 483), as a duration such as 1h; DefaultDeliveredNotResolvedAfter when
|
|
// empty.
|
|
DeliveredNotResolvedAfter string `json:"delivered-not-resolved-after,omitempty"`
|
|
// DecisionFloor and ResearchFloor are the highest decision and research numbers taken where the tracker
|
|
// cannot read, such as an open branch of hq on the forge (issue 424). Read once, when the sequence's
|
|
// counter starts: its first number is one above the highest of this floor, the numbers hq's record holds
|
|
// and the reservations kept. Raised by the operator before the first reserve when a branch holds more.
|
|
DecisionFloor uint64 `json:"decision-floor,omitempty"`
|
|
ResearchFloor uint64 `json:"research-floor,omitempty"`
|
|
}
|
|
|
|
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,
|
|
Judge: busJudgement(os.Getenv("MESH_NODE")),
|
|
// Who called, from the runtime (hq issue 365): never from the arguments.
|
|
CallerOf: stdio.CallerOf,
|
|
}
|
|
t.Notify = busNotifier()
|
|
forgeOwner := func() string {
|
|
if s, err := t.Settings(); err == nil && strings.TrimSpace(s.ForgeOwner) != "" {
|
|
return strings.TrimSpace(s.ForgeOwner)
|
|
}
|
|
return "novox"
|
|
}
|
|
t.Deliveries = func(repository, number string) (Delivery, error) {
|
|
return busDeliveryState(forgeOwner())(repository, number)
|
|
}
|
|
t.Approval = busApproval(forgeOwner)
|
|
if len(os.Args) > 1 && os.Args[1] == "dry-run" {
|
|
os.Exit(dryRunAtTheTerminal(t, os.Args[2:]))
|
|
}
|
|
if _, err := t.Settings(); err != nil {
|
|
logf("[mesh-issues] the settings cannot be read (%v): every open and move will say so", err)
|
|
}
|
|
t.Log = runtimeLog{}
|
|
go start(t)
|
|
go hearOpened(t)
|
|
go sweep(t)
|
|
if err := stdio.Serve("", Tools(t)); err != nil {
|
|
logf("%v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// start watches the module's own bucket into memory, then carries, folds and indexes (novox/hq ADR 0300 §3, §5,
|
|
// §6), each asked again until it is done: the runtime answers only once Serve runs. Until it is done, a list
|
|
// reads the bucket, and a write carries the issue it changes first, so no write reaches the log before its
|
|
// issue's carry.
|
|
func start(t *Tracker) {
|
|
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
|
time.Sleep(wait)
|
|
if err := stdio.State(IssuesState).Watch("", t.Watched); err != nil {
|
|
logf("[mesh-issues] the bucket is not watched yet (%v); asking again in %s", err, wait)
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
|
s, err := t.Start()
|
|
if err == nil {
|
|
logf("[mesh-issues] started: %s", s.Said)
|
|
return
|
|
}
|
|
logf("[mesh-issues] the start did not finish (%v); trying again in %s", err, wait)
|
|
time.Sleep(wait)
|
|
}
|
|
}
|
|
|
|
// hearOpened subscribes the triage to the tracker's own `opened` and `moved` events and to mesh-delivery's
|
|
// `transition` (ADR 0278 §1, ADR 0279 §3), each asked again until the runtime takes it: the runtime answers only
|
|
// once Serve runs.
|
|
func hearOpened(t *Tracker) {
|
|
for _, s := range []struct {
|
|
pattern string
|
|
handle func(stdio.Envelope) error
|
|
}{{"mesh-issues." + EventOpened, t.OnOpened}, {"mesh-issues." + EventMoved, t.OnMoved},
|
|
{"mesh-delivery.transition", t.OnTransition}} {
|
|
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
|
time.Sleep(wait)
|
|
err := stdio.Subscribe(s.pattern, s.handle)
|
|
if err == nil {
|
|
logf("[mesh-issues] the triage hears %s", s.pattern)
|
|
break
|
|
}
|
|
logf("[mesh-issues] the triage does not hear %s yet (%v); asking again in %s", s.pattern, err, wait)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sweep says, every five minutes, every fix merged and not delivered past the bound (ADR 0281).
|
|
func sweep(t *Tracker) {
|
|
for range time.Tick(5 * time.Minute) {
|
|
if n, err := t.Sweep(); err != nil {
|
|
logf("[mesh-issues] the sweep for stuck fixes failed: %v", err)
|
|
} else if n > 0 {
|
|
logf("[mesh-issues] said %d stuck fix(es)", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// dryRunAtTheTerminal is `mesh-issues dry-run <hq checkout> <settings.json>`: the import's dry run over a
|
|
// checkout on disk, against no tracker, before the module is rolled out. It reads, writes nothing, and
|
|
// prints what the import would create from that checkout, as the verb's dry run would answer against an
|
|
// empty tracker. Not a way to import: only the verb writes, and it reads the record over the bus.
|
|
func dryRunAtTheTerminal(t *Tracker, args []string) int {
|
|
if len(args) != 2 {
|
|
logf("usage: mesh-issues dry-run <hq checkout> <settings.json>")
|
|
return 2
|
|
}
|
|
t.Store = emptyStore{}
|
|
t.Settings = func() (Settings, error) { return readSettings(args[1]) }
|
|
out, err := t.Import(ImportAsk{DryRun: true}, dirRecords{dir: args[0]})
|
|
if err != nil {
|
|
logf("%v", err)
|
|
return 1
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(out)
|
|
return 0
|
|
}
|
|
|
|
// emptyStore is a Store that holds nothing and takes nothing: the terminal's dry run.
|
|
type emptyStore struct{}
|
|
|
|
func (emptyStore) Get(string, string) (*Entry, error) { return nil, nil }
|
|
func (emptyStore) Create(string, string, any) (uint64, error) {
|
|
return 0, errors.New("a dry run at the terminal writes nothing")
|
|
}
|
|
func (emptyStore) Update(string, string, any, uint64) (uint64, error) {
|
|
return 0, errors.New("a dry run at the terminal writes nothing")
|
|
}
|
|
func (emptyStore) Keys(string) ([]string, error) { return nil, nil }
|
|
func (emptyStore) History(string, string) ([]stdio.StateRevision, error) {
|
|
return []stdio.StateRevision{}, nil
|
|
}
|
|
func (emptyStore) Increment(string, string, int) (uint64, error) {
|
|
return 0, errors.New("a dry run at the terminal writes nothing")
|
|
}
|
|
|
|
// 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"),
|
|
"severity": map[string]any{"type": "string", "enum": Severities, "description": "when known: critical (something the operator relies on is broken, or data at risk), major (a rule does not hold, or people work around it), minor (a contained fix, nothing the operator sees changes); left out, the triage estimates it"},
|
|
"needs": map[string]any{"type": "string", "enum": NeedsKinds, "description": "when known: none, decision (a decision record first) or operator (the operator's answer is required); left out, the triage estimates it"},
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("open", m, "title", "report", "located-in", "record", "severity", "needs")
|
|
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
|
|
}
|
|
if ask.Severity, err = a.str("severity"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Needs, err = a.str("needs"); err != nil {
|
|
return nil, err
|
|
}
|
|
return t.open(t.callerOf(m), ask)
|
|
}},
|
|
{Name: Seat + ".get",
|
|
Description: "One issue whole, by its number: title, status, severity, what it needs, report, who opened it " +
|
|
"and when, the owners it is located in, who works on it and since when (worker, and worker-stale when " +
|
|
"nobody did anything on it for a day), what fixed it and whether that is delivered, its notes and its " +
|
|
"links; the issues it waits for, those that depend on it (needed-by), and whether it is ready; " +
|
|
"whether its fix is delivered, as the delivery last said it (merged, held and why, delivered); and, once " +
|
|
"the last of its fixes is delivered, its fix time (fix-time) from its first move to fixing, its class and " +
|
|
"fix budget, and its phases: implementing, review, checks, merge to running.",
|
|
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.View(n)
|
|
}},
|
|
{Name: Seat + ".list",
|
|
Description: "The issues, newest first, one short row each — number, status, owner, title — optionally only " +
|
|
"those at one status (" + statuses + "), located in one owner, at one severity, needing one thing, " +
|
|
"ready or not, or whose title or report holds a phrase. An agent looking for work asks severity minor, " +
|
|
"needs none, ready. An issue is ready when everything it depends on is closed, or resolved with its fix " +
|
|
"delivered. Each row says whether its fix is delivered, and, for an issue at diagnosing or fixing, who " +
|
|
"works on it (worker) since when, and stale when nobody did anything on it for a day, so it may be " +
|
|
"taken over. flag delivered-not-resolved keeps the issues in fixing whose every fix (fixed-by and the " +
|
|
"pull requests linked) is delivered; flag abandoned the issues whose worker did nothing on them for a " +
|
|
"day with no pull request open, which a move takes over without a reason. Each row says both. Answers " +
|
|
"what is open in one call. search ranks the issues whose title, report or notes hold its words, best first; " +
|
|
"before pages the newest-first list: give the answer's next to read on below it.",
|
|
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)"},
|
|
"severity": map[string]any{"type": "string", "enum": Severities, "description": "only issues at this severity"},
|
|
"needs": map[string]any{"type": "string", "enum": NeedsKinds, "description": "only issues that need this"},
|
|
"ready": map[string]any{"type": "boolean", "description": "only issues ready (true) or waiting (false)"},
|
|
"flag": map[string]any{"type": "string", "enum": Flags, "description": "only issues delivered and not resolved, or the abandoned ones"},
|
|
"search": text("rank the issues whose title, report or notes hold these words, best first"),
|
|
"before": number("only issues numbered below this: the next of the page before, to read on"),
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before")
|
|
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 ask.Severity, err = a.str("severity"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Needs, err = a.str("needs"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Flag, err = a.str("flag"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Search, err = a.str("search"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Before, err = a.number("before", false); err != nil {
|
|
return nil, err
|
|
}
|
|
if v, given := m["ready"]; given && v != nil {
|
|
r, err := a.flag("ready")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ask.Ready = &r
|
|
}
|
|
if _, given := m["limit"]; given {
|
|
l, err := a.number("limit", false)
|
|
if err != nil {
|
|
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. Move an issue to diagnosing or fixing when you start work on it, with by: " +
|
|
"who works on it and its node, as who@node (such as fix-399@g14); the issue shows it until it moves on. " +
|
|
"Refused while someone else works on it, naming them, unless take-over says why (noted on the issue); a " +
|
|
"worker who did nothing on it for a day may be taken over from without a reason, and with no pull request open " +
|
|
"the answer says it was abandoned. located needs located-in, owners on " +
|
|
"the registered list; resolved is reached only from fixing and 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 move to the status it has changes " +
|
|
"only what it holds, such as naming the fix while it stays fixing. A refused move changes nothing and " +
|
|
"says which rule refused it.",
|
|
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"),
|
|
"by": text("for diagnosing and fixing: who works on it and its node, as who@node, such as fix-399@g14 or jochen@shanks"),
|
|
"take-over": text("for diagnosing and fixing, when someone else works on it: why you take it over"),
|
|
}),
|
|
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", "by", "take-over")
|
|
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},
|
|
{"by", &ask.By}, {"take-over", &ask.TakeOver}} {
|
|
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(t.callerOf(m), 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(t.callerOf(m), 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, such as novox/mesh-controller@d2e9dc61590f). 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(t.callerOf(m), ask)
|
|
}},
|
|
{Name: Seat + ".import",
|
|
Description: "Import hq's issue folders (04-ISSUES/NNN-slug/00-report.md), read through the records module at " +
|
|
"the commit its checkout stands at, each as an issue under its own number with the status, owners, fix " +
|
|
"and replay its report gives. It only ever creates an issue that is not there, or fills the status of one " +
|
|
"the tracker holds without a status; it never overwrites an issue, never advances the counter, refuses a " +
|
|
"folder numbered past the counter or past the tracker's first number without an issue, and run again " +
|
|
"changes nothing and says so. With dry-run it writes nothing and says what it would do; with commit it " +
|
|
"refuses a checkout standing elsewhere. The answer is a row per folder and the counts.",
|
|
Input: schema(nil, map[string]any{
|
|
"dry-run": map[string]any{"type": "boolean", "description": "read everything, write nothing, say what would be done"},
|
|
"commit": text("the commit of hq the record must stand at (a prefix is enough); refused when it stands elsewhere"),
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("import", m, "dry-run", "commit")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ask ImportAsk
|
|
if ask.DryRun, err = a.flag("dry-run"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Commit, err = a.str("commit"); err != nil {
|
|
return nil, err
|
|
}
|
|
records := t.Records
|
|
if records == nil {
|
|
records = busRecords{}
|
|
}
|
|
return t.importAs(t.callerOf(m), ask, records)
|
|
}},
|
|
{Name: Seat + ".groom",
|
|
Description: "The triage over the backlog: every issue not resolved and not yet triaged is judged, " +
|
|
"oldest first, against the older ones not resolved, by the agent on this node — its severity, what it " +
|
|
"needs, a suggested owner, the issues it depends on or relates to, and whether it repeats one. Applied, " +
|
|
"it fills only empty values, adds links and never removes one, and closes a certain duplicate as wontfix, " +
|
|
"\"duplicate of N\", its report kept as a note on the older issue, which never moves. Without arguments " +
|
|
"it starts a dry run in the background that writes nothing on any issue and answers its pass id; with " +
|
|
"pass, that pass's progress and what it would do; with confirm and pass, it applies exactly that pass, " +
|
|
"each issue checked again. Resolved and wontfix issues are never touched. One pass at a time.",
|
|
Input: schema(nil, map[string]any{
|
|
"pass": text("a pass's id, as a dry run answered it"),
|
|
"confirm": map[string]any{"type": "boolean", "description": "apply the pass's findings"},
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("groom", m, "pass", "confirm")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ask GroomAsk
|
|
if ask.Pass, err = a.str("pass"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Confirm, err = a.flag("confirm"); err != nil {
|
|
return nil, err
|
|
}
|
|
return t.Groom(ask, func(f func()) { go f() })
|
|
}},
|
|
{Name: Seat + ".set",
|
|
Description: "Change an issue's severity (critical, major, minor), what it needs (none, decision, operator) or its " +
|
|
"suggested owner (an owner on the registered list), as a person decides. A value the triage estimated is " +
|
|
"changed like any other; the triage never changes a value set here. Refused: a value not of its kind, an " +
|
|
"owner not registered, nothing to set.",
|
|
Input: schema([]string{"number"}, map[string]any{
|
|
"number": number("the issue's number"),
|
|
"severity": map[string]any{"type": "string", "enum": Severities},
|
|
"needs": map[string]any{"type": "string", "enum": NeedsKinds},
|
|
"suggested-owner": text("an owner on the registered list, such as mesh-controller"),
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("set", m, "number", "severity", "needs", "suggested-owner")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ask SetAsk
|
|
if ask.Number, err = a.number("number", true); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Severity, err = a.str("severity"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.Needs, err = a.str("needs"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.SuggestedOwner, err = a.str("suggested-owner"); err != nil {
|
|
return nil, err
|
|
}
|
|
return t.set(t.callerOf(m), ask, false)
|
|
}},
|
|
{Name: Seat + ".tracking",
|
|
Description: "Which issues track which conditions: for every condition key an issue links (link kind condition), " +
|
|
"or only the keys given, the issues that link it, those not closed (resolved or wontfix) first, each with its status, " +
|
|
"severity, what it needs, who works on it since when and whether they went quiet (stale), and where its " +
|
|
"fix is (fix: not-merged, merged, held, failed, unheard, delivered, or withdrawn when every pull request named was " +
|
|
"closed without merging; empty when none is named). The " +
|
|
"operator's channel asks it to say in a condition's message which issue tracks it. Refused: a key not of a condition " +
|
|
"key's shape.",
|
|
Input: schema(nil, map[string]any{
|
|
"conditions": texts("only these condition keys, such as module.mounts.shanks.unhealthy; all when not given"),
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("tracking", m, "conditions")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys, err := a.list("conditions")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return t.Tracking(keys)
|
|
}},
|
|
{Name: Seat + ".reserve",
|
|
Description: "Reserve the next decision or research number, before the record is written, and get it: never " +
|
|
"one another caller got, and always above the highest number the knowledge base holds (knowledge_highest; " +
|
|
"hq issue 493). The answer is the kind and the " +
|
|
"number, and the number as a record's name starts with it (0292 for a decision, 046 for research). " +
|
|
"Optionally for: what the record is about, in one line. A reserved number is spent forever, whether or " +
|
|
"not its record lands, and is never handed out again; who reserved it, when and for what are kept. " +
|
|
"Refused, taking no number: a kind that is not decision or research (an issue's number comes from " +
|
|
"open), a for that is not one line of plain text or carries a secret, an unknown argument, and the " +
|
|
"first number of a kind while the knowledge base does not answer its highest number.",
|
|
Input: schema([]string{"kind"}, map[string]any{
|
|
"kind": map[string]any{"type": "string", "enum": ReserveKinds, "description": "which number: decision or research"},
|
|
"for": text("what the record is about, in one line, such as: the knowledge base holds decisions"),
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("reserve", m, "kind", "for")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ask ReserveAsk
|
|
if ask.Kind, err = a.str("kind"); err != nil {
|
|
return nil, err
|
|
}
|
|
if ask.For, err = a.str("for"); err != nil {
|
|
return nil, err
|
|
}
|
|
return t.reserve(t.callerOf(m), ask)
|
|
}},
|
|
{Name: Seat + ".fix-times",
|
|
Description: "How long fixes take, from the move to fixing to the last fix delivered (the fix time): each class's fix budget (leaf 5m, core 10m), the median and worst fix time of the issues whose last " +
|
|
"fix was delivered in the last days (7 unless days says otherwise), how many were within and over, and every " +
|
|
"issue over its budget with its longest phase (implementing, review, checks, merge to running), newest first; " +
|
|
"issues whose fixes move no module, or whose class is not known, are listed apart with no budget. One issue's " +
|
|
"fix time, phase by phase, is in its get. A lever meant to shorten fixes is judged by comparing this median " +
|
|
"over the seven days before it was delivered with the seven days after. Refused: days above 90.",
|
|
Input: schema(nil, map[string]any{
|
|
"days": map[string]any{"type": "integer", "minimum": 1, "maximum": MostFixTimesDays, "description": "the window, in days back from now (default 7)"},
|
|
}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("fix-times", m, "days")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
days, err := a.number("days", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return t.FixTimes(int(days))
|
|
}},
|
|
}
|
|
}
|