Bring the branch up to main after #354 (issue 1047: the trunk move asked no check)
mesh/repo-check head 4d03755c6598 merged into trunk ad80048c22d8 beside mesh-controller#375@91a7c1ea — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head 4d03755c6598 merged into trunk ad80048c22d8 — pass: group feat/backup-before-send composed: every machine composes with the change as …
mesh/delivery-group group feat/backup-before-send stopped: a member was stopped
mesh/delivery delivered
mesh/repo-check head 4d03755c6598 merged into trunk ad80048c22d8 beside mesh-controller#375@91a7c1ea — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head 4d03755c6598 merged into trunk ad80048c22d8 — pass: group feat/backup-before-send composed: every machine composes with the change as …
mesh/delivery-group group feat/backup-before-send stopped: a member was stopped
mesh/delivery delivered
This commit is contained in:
@@ -18,8 +18,8 @@ what it refuses.
|
|||||||
| verb | takes | does |
|
| 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 |
|
| `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 |
|
| `get` | `number`, optionally `brief` | the issue whole: fields, notes, links; with `brief`, a short answer with only its last note |
|
||||||
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor) | short rows, newest first, or best first for a `search`: number, status, owner, title, and each flag that holds; `next`, the `before` that reads on, when more match |
|
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor), `numbers` (only these issues, in the order given, naming any not an issue in `missing`), `worker` (only that worker's held issues) | short rows, newest first, or best first for a `search`, or in the order given for `numbers`: number, status, owner, title, and each flag that holds; `next`, the `before` that reads on, when more match |
|
||||||
| `move` | `number`, `status`, and what that status needs (`by` for `diagnosing` and `fixing`, optionally `take-over`) | changes the status under the development cycle's rules |
|
| `move` | `number`, `status`, and what that status needs (`by` for `diagnosing` and `fixing`, optionally `take-over`) | changes the status under the development cycle's rules |
|
||||||
| `note` | `number`, `text` | appends a note; a note is never edited |
|
| `note` | `number`, `text` | appends a note; a note is never edited |
|
||||||
| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link |
|
| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link |
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A get with brief: true answers only its last note, saying how many were left out (issue 1071).
|
||||||
|
func TestGetBriefAnswersOnlyTheLastNote(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
n := h.open(t, "the counter skips a number", "hq")
|
||||||
|
for _, text := range []string{"first", "second", "third"} {
|
||||||
|
if _, err := h.Note(n, text); err != nil {
|
||||||
|
t.Fatalf("note: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := h.call(t, "get", map[string]any{"number": float64(n), "brief": true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get brief: %v", err)
|
||||||
|
}
|
||||||
|
b, ok := got.(*Brief)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("want *Brief, got %T", got)
|
||||||
|
}
|
||||||
|
if len(b.Notes) != 1 || b.Notes[0].Text != "third" {
|
||||||
|
t.Fatalf("want only the last note (third), got %+v", b.Notes)
|
||||||
|
}
|
||||||
|
if b.NotesLeftOut != 2 {
|
||||||
|
t.Fatalf("want notes-left-out 2, got %d", b.NotesLeftOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A get without brief is unchanged (issue 1071).
|
||||||
|
func TestGetWithoutBriefIsUnchanged(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
n := h.open(t, "the counter skips a number", "hq")
|
||||||
|
if _, err := h.Note(n, "first"); err != nil {
|
||||||
|
t.Fatalf("note: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := h.call(t, "get", map[string]any{"number": float64(n)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
v, ok := got.(*View)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("want *View, got %T", got)
|
||||||
|
}
|
||||||
|
if len(v.Notes) != 1 || v.Notes[0].Text != "first" {
|
||||||
|
t.Fatalf("want every note, got %+v", v.Notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// list numbers combines search and before as AND filters, never ignoring them (review round 1, issue 1070).
|
||||||
|
func TestListNumbersAppliesSearchAndBeforeAsFilters(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
a := h.open(t, "alpha widget", "hq")
|
||||||
|
b := h.open(t, "beta widget", "hq")
|
||||||
|
c := h.open(t, "gamma widget", "hq")
|
||||||
|
d := h.open(t, "delta nomatch", "hq")
|
||||||
|
|
||||||
|
got, err := h.call(t, "list", map[string]any{
|
||||||
|
"numbers": []any{float64(d), float64(c), float64(b), float64(a)}, "search": "widget"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list numbers+search: %v", err)
|
||||||
|
}
|
||||||
|
l := got.(*Listed)
|
||||||
|
if len(l.Rows) != 3 || l.Rows[0].Number != c || l.Rows[1].Number != b || l.Rows[2].Number != a {
|
||||||
|
t.Fatalf("want c, b, a (widget only, in the order given), got %+v", l.Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}, "before": float64(c)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list numbers+before: %v", err)
|
||||||
|
}
|
||||||
|
l = got.(*Listed)
|
||||||
|
if len(l.Rows) != 2 || l.Rows[0].Number != b || l.Rows[1].Number != a {
|
||||||
|
t.Fatalf("want b, a (below c, in the order given), got %+v", l.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// list numbers' limit defaults to the count of numbers asked, still capped, and said names a cut (review round
|
||||||
|
// 1, issue 1070).
|
||||||
|
func TestListNumbersLimitDefaultsAndSaysWhenCut(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
a := h.open(t, "one", "hq")
|
||||||
|
b := h.open(t, "two", "hq")
|
||||||
|
c := h.open(t, "three", "hq")
|
||||||
|
|
||||||
|
got, err := h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list numbers, default limit: %v", err)
|
||||||
|
}
|
||||||
|
l := got.(*Listed)
|
||||||
|
if len(l.Rows) != 3 {
|
||||||
|
t.Fatalf("want the limit to default to len(numbers)=3, got %d rows", len(l.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}, "limit": float64(2)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list numbers, limit 2: %v", err)
|
||||||
|
}
|
||||||
|
l = got.(*Listed)
|
||||||
|
if len(l.Rows) != 2 || l.Matched != 3 {
|
||||||
|
t.Fatalf("want 2 rows of 3 matched, got rows %d matched %d", len(l.Rows), l.Matched)
|
||||||
|
}
|
||||||
|
if !strings.Contains(l.Said, "first 2") {
|
||||||
|
t.Fatalf("want said to name the cut, got %q", l.Said)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// list numbers answers only those issues, rows in the order given, naming a number that does not exist in
|
||||||
|
// missing (issue 1070).
|
||||||
|
func TestListNumbersAnswersInTheOrderGivenNamingMissing(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
a := h.open(t, "issue a", "hq")
|
||||||
|
b := h.open(t, "issue b", "hq")
|
||||||
|
missing := b + 1000
|
||||||
|
|
||||||
|
got, err := h.call(t, "list", map[string]any{"numbers": []any{float64(b), float64(a), float64(missing)}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list numbers: %v", err)
|
||||||
|
}
|
||||||
|
l, ok := got.(*Listed)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("want *Listed, got %T", got)
|
||||||
|
}
|
||||||
|
if len(l.Rows) != 2 || l.Rows[0].Number != b || l.Rows[1].Number != a {
|
||||||
|
t.Fatalf("want rows b then a, got %+v", l.Rows)
|
||||||
|
}
|
||||||
|
if len(l.Missing) != 1 || l.Missing[0] != missing {
|
||||||
|
t.Fatalf("want missing [%d], got %v", missing, l.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// list worker answers only the issues that worker holds, in diagnosing or fixing (issue 1070).
|
||||||
|
func TestListWorkerAnswersOnlyThatWorkersHeldIssues(t *testing.T) {
|
||||||
|
h := newHarness(t)
|
||||||
|
held := h.open(t, "held by x", "hq")
|
||||||
|
other := h.open(t, "held by someone else", "hq")
|
||||||
|
if _, err := h.Move(MoveAsk{Number: held, Status: StatusFixing, By: "x@n"}); err != nil {
|
||||||
|
t.Fatalf("move held: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := h.Move(MoveAsk{Number: other, Status: StatusFixing, By: "y@n"}); err != nil {
|
||||||
|
t.Fatalf("move other: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := h.call(t, "list", map[string]any{"worker": "x@n"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list worker: %v", err)
|
||||||
|
}
|
||||||
|
l, ok := got.(*Listed)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("want *Listed, got %T", got)
|
||||||
|
}
|
||||||
|
if len(l.Rows) != 1 || l.Rows[0].Number != held {
|
||||||
|
t.Fatalf("want only x@n's held issue, got %+v", l.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,6 +282,30 @@ func (a args) list(k string) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("%s is a list of text", k)
|
return nil, fmt.Errorf("%s is a list of text", k)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// numbers is a list of whole numbers from 1, at most max entries (issue 1070's `numbers`).
|
||||||
|
func (a args) numbers(k string, max int) ([]uint64, error) {
|
||||||
|
v, ok := a.m[k]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
t, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s is a list of whole numbers from 1, such as [312, 313]", k)
|
||||||
|
}
|
||||||
|
if len(t) > max {
|
||||||
|
return nil, fmt.Errorf("%s names %d entries; at most %d", k, len(t), max)
|
||||||
|
}
|
||||||
|
out := make([]uint64, 0, len(t))
|
||||||
|
for _, e := range t {
|
||||||
|
f, ok := e.(float64)
|
||||||
|
if !ok || f < 1 || f > 1e9 || f != math.Trunc(f) {
|
||||||
|
return nil, fmt.Errorf("every entry of %s is a whole number from 1, such as 312", k)
|
||||||
|
}
|
||||||
|
out = append(out, uint64(f))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a args) number(k string, required bool) (uint64, error) {
|
func (a args) number(k string, required bool) (uint64, error) {
|
||||||
v, ok := a.m[k]
|
v, ok := a.m[k]
|
||||||
if !ok || v == nil {
|
if !ok || v == nil {
|
||||||
@@ -387,10 +411,16 @@ func Tools(t *Tracker) []stdio.Tool {
|
|||||||
"links; the issues it waits for, those that depend on it (needed-by), and whether it is ready; " +
|
"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 " +
|
"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 " +
|
"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.",
|
"fix budget, and its phases: implementing, review, checks, merge to running. With brief: true, a short " +
|
||||||
Input: schema([]string{"number"}, map[string]any{"number": number("the issue's number")}),
|
"answer instead: number, title, status, severity, needs, ready, worker and since (when diagnosing or " +
|
||||||
|
"fixing), located-in, fixed-by, the delivery line, its links without who made them or when, opened, and " +
|
||||||
|
"only its last note, with notes-left-out saying how many were left out.",
|
||||||
|
Input: schema([]string{"number"}, map[string]any{
|
||||||
|
"number": number("the issue's number"),
|
||||||
|
"brief": map[string]any{"type": "boolean", "description": "a short answer with only its last note, instead of every note and the whole triage"},
|
||||||
|
}),
|
||||||
Run: func(m map[string]any) (any, error) {
|
Run: func(m map[string]any) (any, error) {
|
||||||
a, err := readArgs("get", m, "number")
|
a, err := readArgs("get", m, "number", "brief")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -398,7 +428,18 @@ func Tools(t *Tracker) []stdio.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return t.View(n)
|
brief, err := a.flag("brief")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v, err := t.View(n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if brief {
|
||||||
|
return briefOf(v), nil
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
}},
|
}},
|
||||||
{Name: Seat + ".list",
|
{Name: Seat + ".list",
|
||||||
Description: "The issues, newest first, one short row each — number, status, owner, title — optionally only " +
|
Description: "The issues, newest first, one short row each — number, status, owner, title — optionally only " +
|
||||||
@@ -411,7 +452,9 @@ func Tools(t *Tracker) []stdio.Tool {
|
|||||||
"pull requests linked) is delivered; flag abandoned the issues whose worker did nothing on them for a " +
|
"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 " +
|
"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; " +
|
"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.",
|
"before pages the newest-first list: give the answer's next to read on below it. numbers answers only " +
|
||||||
|
"those issues, rows in the order given, naming any that is not an issue in missing; worker (who@node) " +
|
||||||
|
"keeps only the issues in diagnosing or fixing whose worker is exactly that.",
|
||||||
Input: schema(nil, map[string]any{
|
Input: schema(nil, map[string]any{
|
||||||
"status": map[string]any{"type": "string", "enum": Statuses, "description": "only issues at this status"},
|
"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"),
|
"owner": text("only issues located in this owner, such as mesh-controller"),
|
||||||
@@ -423,9 +466,12 @@ func Tools(t *Tracker) []stdio.Tool {
|
|||||||
"flag": map[string]any{"type": "string", "enum": Flags, "description": "only issues delivered and not resolved, or the abandoned ones"},
|
"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"),
|
"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"),
|
"before": number("only issues numbered below this: the next of the page before, to read on"),
|
||||||
|
"numbers": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 1},
|
||||||
|
"maxItems": MostNumbers, "description": "only these issues, rows in the order given; one not an issue is named in missing"},
|
||||||
|
"worker": text("only issues in diagnosing or fixing whose worker is exactly this, who@node"),
|
||||||
}),
|
}),
|
||||||
Run: func(m map[string]any) (any, error) {
|
Run: func(m map[string]any) (any, error) {
|
||||||
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before")
|
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before", "numbers", "worker")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -451,9 +497,15 @@ func Tools(t *Tracker) []stdio.Tool {
|
|||||||
if ask.Search, err = a.str("search"); err != nil {
|
if ask.Search, err = a.str("search"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if ask.Worker, err = a.str("worker"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if ask.Before, err = a.number("before", false); err != nil {
|
if ask.Before, err = a.number("before", false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if ask.Numbers, err = a.numbers("numbers", MostNumbers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if v, given := m["ready"]; given && v != nil {
|
if v, given := m["ready"]; given && v != nil {
|
||||||
r, err := a.flag("ready")
|
r, err := a.flag("ready")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The events of ADR 0279.
|
// The events of ADR 0279.
|
||||||
@@ -320,6 +321,53 @@ func (t *Tracker) View(n uint64) (*View, error) {
|
|||||||
DeliveredNotResolved: deliveredNotResolved(is), Abandoned: abandoned(is, now, staleAfter)}, nil
|
DeliveredNotResolved: deliveredNotResolved(is), Abandoned: abandoned(is, now, staleAfter)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BriefLink is a link as brief answers it: its kind and target, without who made it or when (issue 1071).
|
||||||
|
type BriefLink struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief is `get` with brief: true — a short answer naming only what deciding what to do with an issue needs,
|
||||||
|
// with its last note alone and how many were left out (novox/hq issue 1071).
|
||||||
|
type Brief struct {
|
||||||
|
Number uint64 `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
Needs string `json:"needs,omitempty"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Worker string `json:"worker,omitempty"`
|
||||||
|
Since *time.Time `json:"since,omitempty"`
|
||||||
|
LocatedIn []string `json:"located-in,omitempty"`
|
||||||
|
FixedBy []string `json:"fixed-by,omitempty"`
|
||||||
|
Delivery string `json:"delivery,omitempty"`
|
||||||
|
Links []BriefLink `json:"links,omitempty"`
|
||||||
|
Opened time.Time `json:"opened"`
|
||||||
|
Notes []Note `json:"notes,omitempty"`
|
||||||
|
NotesLeftOut int `json:"notes-left-out"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// briefOf is a View shaped for brief: true: only its last note, with how many were left out.
|
||||||
|
func briefOf(v *View) *Brief {
|
||||||
|
b := &Brief{
|
||||||
|
Number: v.Number, Title: v.Title, Status: v.Status, Severity: v.Severity, Needs: v.Needs,
|
||||||
|
Ready: v.Ready, LocatedIn: v.LocatedIn, FixedBy: v.FixedBy, Delivery: v.Delivery, Opened: v.Opened,
|
||||||
|
}
|
||||||
|
if v.Worker != nil {
|
||||||
|
b.Worker = v.Worker.By
|
||||||
|
since := v.Worker.Since
|
||||||
|
b.Since = &since
|
||||||
|
}
|
||||||
|
for _, l := range v.Links {
|
||||||
|
b.Links = append(b.Links, BriefLink{Kind: l.Kind, Target: l.Target})
|
||||||
|
}
|
||||||
|
if n := len(v.Notes); n > 0 {
|
||||||
|
b.Notes = []Note{v.Notes[n-1]}
|
||||||
|
b.NotesLeftOut = n - 1
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
// letGo says, for an issue that may have just let others go (its fix delivered, or wontfix), every issue it
|
// letGo says, for an issue that may have just let others go (its fix delivered, or wontfix), every issue it
|
||||||
// made ready: a note on each, once, and the event `ready`.
|
// made ready: a note on each, once, and the event `ready`.
|
||||||
func (t *Tracker) letGo(dep uint64) error {
|
func (t *Tracker) letGo(dep uint64) error {
|
||||||
|
|||||||
@@ -699,8 +699,17 @@ type ListAsk struct {
|
|||||||
Search string
|
Search string
|
||||||
// Before keeps only the issues numbered below it: the cursor that pages the newest-first list.
|
// Before keeps only the issues numbered below it: the cursor that pages the newest-first list.
|
||||||
Before uint64
|
Before uint64
|
||||||
|
// Numbers keeps only these issues, rows in the order given, combined with the other filters as AND; a
|
||||||
|
// number that is not an issue is named in the answer's Missing (novox/hq issue 1070).
|
||||||
|
Numbers []uint64
|
||||||
|
// Worker keeps only the issues in diagnosing or fixing whose worker is exactly this, who@node (novox/hq
|
||||||
|
// issue 1070).
|
||||||
|
Worker string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MostNumbers is the most `numbers` may name in one list.
|
||||||
|
const MostNumbers = 200
|
||||||
|
|
||||||
// Row is one issue in a list: short enough to read in a chat.
|
// Row is one issue in a list: short enough to read in a chat.
|
||||||
type Row struct {
|
type Row struct {
|
||||||
Number uint64 `json:"number"`
|
Number uint64 `json:"number"`
|
||||||
@@ -731,7 +740,23 @@ type Listed struct {
|
|||||||
Matched int `json:"matched"`
|
Matched int `json:"matched"`
|
||||||
// Next is the before that continues the list where this page ends; 0 when nothing more matches.
|
// Next is the before that continues the list where this page ends; 0 when nothing more matches.
|
||||||
Next uint64 `json:"next,omitempty"`
|
Next uint64 `json:"next,omitempty"`
|
||||||
Said string `json:"said"`
|
// Missing is each number asked for (in Numbers) that is not an issue, in the order given (novox/hq issue
|
||||||
|
// 1070).
|
||||||
|
Missing []uint64 `json:"missing,omitempty"`
|
||||||
|
Said string `json:"said"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchHits ranks the issues whose title, report or notes hold search's words, best first, from memory once it
|
||||||
|
// is loaded on start, else from a fresh index (novox/hq ADR 0300 §5).
|
||||||
|
func (t *Tracker) searchHits(all map[uint64]*Issue, search string) []hit {
|
||||||
|
if v := t.view(); v.ready() {
|
||||||
|
return v.search(search)
|
||||||
|
}
|
||||||
|
x := newIndex()
|
||||||
|
for _, is := range all {
|
||||||
|
x.add(is)
|
||||||
|
}
|
||||||
|
return x.search(search)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List is the issues that match, newest first, or ranked by a search: from memory once it is loaded on start
|
// List is the issues that match, newest first, or ranked by a search: from memory once it is loaded on start
|
||||||
@@ -770,6 +795,10 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
usingNumbers := len(a.Numbers) > 0
|
||||||
|
if len(a.Numbers) > MostNumbers {
|
||||||
|
return nil, fmt.Errorf("numbers names %d entries; at most %d", len(a.Numbers), MostNumbers)
|
||||||
|
}
|
||||||
search := ""
|
search := ""
|
||||||
if strings.TrimSpace(a.Search) != "" {
|
if strings.TrimSpace(a.Search) != "" {
|
||||||
if search, err = singleLine("search", a.Search, MostPhrase); err != nil {
|
if search, err = singleLine("search", a.Search, MostPhrase); err != nil {
|
||||||
@@ -778,14 +807,24 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
|||||||
if len(tokens(search)) == 0 {
|
if len(tokens(search)) == 0 {
|
||||||
return nil, fmt.Errorf("search %q holds no word of two letters or digits", search)
|
return nil, fmt.Errorf("search %q holds no word of two letters or digits", search)
|
||||||
}
|
}
|
||||||
if a.Before != 0 {
|
if a.Before != 0 && !usingNumbers {
|
||||||
return nil, errors.New("before pages the newest-first list; a search is ranked best first, so give limit, " +
|
return nil, errors.New("before pages the newest-first list; a search is ranked best first, so give limit, " +
|
||||||
"or more words, instead")
|
"or more words, instead")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
worker := ""
|
||||||
|
if strings.TrimSpace(a.Worker) != "" {
|
||||||
|
if worker, err = checkWorkerField("worker", a.Worker); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
limit := a.Limit
|
limit := a.Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
if usingNumbers {
|
||||||
|
limit = len(a.Numbers)
|
||||||
|
} else {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
}
|
}
|
||||||
limit = min(limit, MostListResult)
|
limit = min(limit, MostListResult)
|
||||||
all, err := t.allIssues()
|
all, err := t.allIssues()
|
||||||
@@ -793,19 +832,18 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
now, staleAfter := t.now(), t.workerStaleAfter()
|
now, staleAfter := t.now(), t.workerStaleAfter()
|
||||||
var numbers []uint64
|
var numbers, missing []uint64
|
||||||
if search != "" {
|
var searchSet map[uint64]bool
|
||||||
var hits []hit
|
if usingNumbers {
|
||||||
if v := t.view(); v.ready() {
|
numbers = a.Numbers
|
||||||
hits = v.search(search)
|
if search != "" {
|
||||||
} else {
|
searchSet = map[uint64]bool{}
|
||||||
x := newIndex()
|
for _, h := range t.searchHits(all, search) {
|
||||||
for _, is := range all {
|
searchSet[h.Number] = true
|
||||||
x.add(is)
|
|
||||||
}
|
}
|
||||||
hits = x.search(search)
|
|
||||||
}
|
}
|
||||||
for _, h := range hits {
|
} else if search != "" {
|
||||||
|
for _, h := range t.searchHits(all, search) {
|
||||||
if all[h.Number] != nil {
|
if all[h.Number] != nil {
|
||||||
numbers = append(numbers, h.Number)
|
numbers = append(numbers, h.Number)
|
||||||
}
|
}
|
||||||
@@ -822,6 +860,20 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
|||||||
lower := strings.ToLower(phrase)
|
lower := strings.ToLower(phrase)
|
||||||
for _, n := range numbers {
|
for _, n := range numbers {
|
||||||
is := all[n]
|
is := all[n]
|
||||||
|
if is == nil {
|
||||||
|
if usingNumbers {
|
||||||
|
missing = append(missing, n)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if usingNumbers {
|
||||||
|
if a.Before != 0 && n >= a.Before {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if searchSet != nil && !searchSet[n] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if status != "" && is.Status != status {
|
if status != "" && is.Status != status {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -841,6 +893,9 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
|||||||
if owner != "" && !locatedUnder(is.LocatedIn, owner) {
|
if owner != "" && !locatedUnder(is.LocatedIn, owner) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if worker != "" && (is.Worker == nil || is.Worker.By != worker) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) {
|
if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -857,11 +912,22 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
|||||||
out.Rows = append(out.Rows, row)
|
out.Rows = append(out.Rows, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.Missing = missing
|
||||||
below := ""
|
below := ""
|
||||||
if a.Before != 0 {
|
if a.Before != 0 {
|
||||||
below = fmt.Sprintf(" below %d", a.Before)
|
below = fmt.Sprintf(" below %d", a.Before)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case usingNumbers && out.Matched == 0 && len(missing) == 0:
|
||||||
|
out.Said = "No issue asked matches."
|
||||||
|
case usingNumbers:
|
||||||
|
out.Said = fmt.Sprintf("%d of %d asked match, in the order given.", out.Matched, len(a.Numbers))
|
||||||
|
if out.Matched > len(out.Rows) {
|
||||||
|
out.Said += fmt.Sprintf(" The first %d are shown.", len(out.Rows))
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
out.Said += fmt.Sprintf(" %d of the numbers asked are not an issue.", len(missing))
|
||||||
|
}
|
||||||
case out.Matched == 0:
|
case out.Matched == 0:
|
||||||
out.Said = "No issue matches" + below + "."
|
out.Said = "No issue matches" + below + "."
|
||||||
case search != "" && out.Matched > len(out.Rows):
|
case search != "" && out.Matched > len(out.Rows):
|
||||||
|
|||||||
@@ -42,15 +42,18 @@ var workerName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*@[a-z0-9][a-z0-
|
|||||||
func worked(status string) bool { return status == StatusDiagnosing || status == StatusFixing }
|
func worked(status string) bool { return status == StatusDiagnosing || status == StatusFixing }
|
||||||
|
|
||||||
// checkWorker is `by` as kept: who@node, in lower case.
|
// checkWorker is `by` as kept: who@node, in lower case.
|
||||||
func checkWorker(s string) (string, error) {
|
func checkWorker(s string) (string, error) { return checkWorkerField("by", s) }
|
||||||
s, err := singleLine("by", s, MostWorker)
|
|
||||||
|
// checkWorkerField is a field holding who@node (`by`, or `worker` as list filters), as kept: in lower case.
|
||||||
|
func checkWorkerField(field, s string) (string, error) {
|
||||||
|
s, err := singleLine(field, s, MostWorker)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
s = strings.ToLower(s)
|
s = strings.ToLower(s)
|
||||||
if !workerName.MatchString(s) {
|
if !workerName.MatchString(s) {
|
||||||
return "", fmt.Errorf("by %q is not who works on it and its node: give who@node, the agent or person and the "+
|
return "", fmt.Errorf("%s %q is not who works on it and its node: give who@node, the agent or person and the "+
|
||||||
"machine it works on, such as fix-399@g14 or jochen@shanks", s)
|
"machine it works on, such as fix-399@g14 or jochen@shanks", field, s)
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ operator, it is accepted only on the operator's Approve, given on a channel that
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ask` | `question` (at most 500 characters), `bound` | the records and clauses bearing on the question, ranked; each clause says whether it holds, what changed it, or that it is only drafted; each record says what changed it since and how to read its reasoning |
|
| `ask` | `question` (at most 500 characters), `bound` | the records and clauses bearing on the question, ranked; each clause says whether it holds, what changed it, or that it is only drafted; each record says what changed it since and how to read its reasoning |
|
||||||
| `get` | `number`, `kind`, `reasoning`, `section`, `version`, `bound` | one record: clauses, links both ways, what changed it since, versions, reviews, the operator's ask; its reasoning only when asked |
|
| `get` | `number`, `kind`, `reasoning`, `section`, `version`, `bound` | one record: clauses, links both ways, what changed it since, versions, reviews, the operator's ask; its reasoning only when asked |
|
||||||
|
| `list` | `kind`, `status`, `topic`, `flag` (`research-done` or `proposed-old`), `before` (a number, the cursor), `limit` | short rows, newest first, number by number: number, kind, title, status, topic, date, versions, and whether a proposal is pending; `next`, the `before` that reads on, when more match |
|
||||||
|
| `findings` | `number` (optional) | the whole-set findings kept — each a rule's failure, the records it names, what it says, and when; with `number`, only the findings naming that record |
|
||||||
| `holds` | `topic` or `number`, `bound` | the clauses in force for a topic of the reading order or for one record; never a drafted clause |
|
| `holds` | `topic` or `number`, `bound` | the clauses in force for a topic of the reading order or for one record; never a drafted clause |
|
||||||
| `links` | `number`, `kind` | the record's typed links, out and in |
|
| `links` | `number`, `kind` | the record's typed links, out and in |
|
||||||
| `history` | `number`, `kind`, `from`, `to`, `bound` | every version; with `from` and `to`, the difference between the two, line by line |
|
| `history` | `number`, `kind`, `from`, `to`, `bound` | every version; with `from` and `to`, the difference between the two, line by line |
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// findings (novox/hq issue 1069): the whole-set findings kept (judgeSet, kb.go), or only those naming one
|
||||||
|
// record.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindingRow is one whole-set finding as findings answers it.
|
||||||
|
type FindingRow struct {
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
Records []string `json:"records"`
|
||||||
|
Says string `json:"says"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Findings is the answer to findings.
|
||||||
|
type Findings struct {
|
||||||
|
Rows []FindingRow `json:"rows"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Findings is the whole-set findings kept, or only those naming one record, given as a record names it ("276",
|
||||||
|
// "ADR 276", "research 045") (novox/hq issue 1069).
|
||||||
|
func (k *KB) Findings(number string) (*Findings, error) {
|
||||||
|
named := ""
|
||||||
|
if strings.TrimSpace(number) != "" {
|
||||||
|
ref, err := ParseRef(number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key, ok := ref.Record()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("number is a record's number, such as 276 or research 045, not %q", number)
|
||||||
|
}
|
||||||
|
named = key.String()
|
||||||
|
}
|
||||||
|
all, err := k.Store.Findings(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &Findings{Rows: []FindingRow{}}
|
||||||
|
for _, f := range all {
|
||||||
|
if named != "" && !contains(f.Records, named) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Rows = append(out.Rows, FindingRow{Rule: f.Rule, Records: f.Records, Says: f.Says, At: f.At})
|
||||||
|
}
|
||||||
|
out.Count = len(out.Rows)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findings answers the whole-set findings kept, or only those naming one record (issue 1069).
|
||||||
|
func TestFindingsAnswersKeptFindingsOrOnlyTheOneNamingARecord(t *testing.T) {
|
||||||
|
s := newMemStore()
|
||||||
|
at := time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC)
|
||||||
|
seeded := []Finding{
|
||||||
|
{Rule: "cited", Records: []string{"ADR 0276"}, Says: "ADR 0276 is accepted and nothing cites it", At: at},
|
||||||
|
{Rule: "supersession", Records: []string{"ADR 0100", "ADR 0101"}, Says: "ADR 0100 supersedes ADR 0101, which is accepted", At: at},
|
||||||
|
}
|
||||||
|
if err := s.SetFindings(context.Background(), seeded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k := &KB{Store: s}
|
||||||
|
|
||||||
|
all, err := k.Findings("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if all.Count != 2 || len(all.Rows) != 2 {
|
||||||
|
t.Fatalf("want both findings, got %+v", all)
|
||||||
|
}
|
||||||
|
|
||||||
|
one, err := k.Findings("276")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if one.Count != 1 || len(one.Rows) != 1 || one.Rows[0].Rule != "cited" {
|
||||||
|
t.Fatalf("want only the finding naming 0276, got %+v", one)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// list (novox/hq issue 1064): one short row per record, filtered by kind, status, topic or a flag, and paged
|
||||||
|
// newest first, number by number, like issue-tracker.list.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The flags list may be asked for.
|
||||||
|
const (
|
||||||
|
FlagResearchDone = "research-done"
|
||||||
|
FlagProposedOld = "proposed-old"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListFlags are the flags list takes.
|
||||||
|
var ListFlags = []string{FlagResearchDone, FlagProposedOld}
|
||||||
|
|
||||||
|
// ProposedOldAfter is how long a proposal may sit at proposed before flag proposed-old names it.
|
||||||
|
const ProposedOldAfter = 24 * time.Hour
|
||||||
|
|
||||||
|
// MostListResult is the most rows list may answer in one call.
|
||||||
|
const MostListResult = 500
|
||||||
|
|
||||||
|
// AllStatuses are every status a record of either kind may hold.
|
||||||
|
var AllStatuses = []string{StatusReserved, StatusProposed, StatusAccepted, StatusSuperseded, StatusWithdrawn,
|
||||||
|
StatusActive, StatusGraduated, StatusAbandoned}
|
||||||
|
|
||||||
|
// ListAsk narrows a list.
|
||||||
|
type ListAsk struct {
|
||||||
|
Kind string
|
||||||
|
Status string
|
||||||
|
Topic string
|
||||||
|
// Flag keeps only the research efforts done (research-done) or the proposals grown old (proposed-old).
|
||||||
|
Flag string
|
||||||
|
// Before keeps only the records numbered below it: the cursor that pages the newest-first list.
|
||||||
|
Before int
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRow is one record in a list: short enough to read in a chat.
|
||||||
|
type ListRow struct {
|
||||||
|
// Number is the record's number as the records print it: "0276" for a decision, "research 045" for
|
||||||
|
// research.
|
||||||
|
Number string `json:"number"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Topic string `json:"topic,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Versions int `json:"versions"`
|
||||||
|
// Pending says a version waits for the knowledge base's own review, or the operator still waits to Approve
|
||||||
|
// (an ask that ended, approved or declined, is not pending; novox/hq issue 1064, review round 1).
|
||||||
|
Pending bool `json:"pending,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listed is the answer to a list.
|
||||||
|
type Listed struct {
|
||||||
|
Rows []ListRow `json:"rows"`
|
||||||
|
Matched int `json:"matched"`
|
||||||
|
// Next is the before that continues the list where this page ends; 0 when nothing more matches.
|
||||||
|
Next int `json:"next,omitempty"`
|
||||||
|
Said string `json:"said"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rowNumber is a record's number as list's row prints it.
|
||||||
|
func rowNumber(kind string, number int) string {
|
||||||
|
if kind == KindResearch {
|
||||||
|
return fmt.Sprintf("research %03d", number)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%04d", number)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List is the records that match, newest first, number by number (novox/hq issue 1064).
|
||||||
|
func (k *KB) List(a ListAsk) (*Listed, error) {
|
||||||
|
if a.Kind != "" && !contains(Kinds, a.Kind) {
|
||||||
|
return nil, fmt.Errorf("kind is decision or research, not %q", a.Kind)
|
||||||
|
}
|
||||||
|
if a.Status != "" && !contains(AllStatuses, a.Status) {
|
||||||
|
return nil, fmt.Errorf("status %q is not one of %s", a.Status, strings.Join(AllStatuses, ", "))
|
||||||
|
}
|
||||||
|
if a.Flag != "" && !contains(ListFlags, a.Flag) {
|
||||||
|
return nil, fmt.Errorf("flag is %s, not %q", strings.Join(ListFlags, " or "), a.Flag)
|
||||||
|
}
|
||||||
|
limit := a.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
limit = min(limit, MostListResult)
|
||||||
|
ctx := context.Background()
|
||||||
|
all, err := k.Store.All(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byKey := map[Key]*Entry{}
|
||||||
|
for _, e := range all {
|
||||||
|
byKey[Key{e.Current.Kind, e.Current.Number}] = e
|
||||||
|
}
|
||||||
|
keys := make([]Key, 0, len(byKey))
|
||||||
|
for key := range byKey {
|
||||||
|
if a.Before == 0 || key.Number < a.Before {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i].Number != keys[j].Number {
|
||||||
|
return keys[i].Number > keys[j].Number
|
||||||
|
}
|
||||||
|
return keys[i].Kind < keys[j].Kind
|
||||||
|
})
|
||||||
|
out := &Listed{Rows: []ListRow{}}
|
||||||
|
var lastNumber int
|
||||||
|
for _, key := range keys {
|
||||||
|
e := byKey[key]
|
||||||
|
r := e.Current
|
||||||
|
if a.Kind != "" && r.Kind != a.Kind {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.Status != "" && r.Status != a.Status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.Topic != "" && r.Topic != a.Topic {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.Flag != "" {
|
||||||
|
ok, err := k.matchesListFlag(ctx, a.Flag, key, e, byKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Matched++
|
||||||
|
// Never cut a page between two records of the same number: a decision and a research effort may share
|
||||||
|
// one, and a bare-number cursor (before) must stay correct for both (novox/hq issue 1064, review round
|
||||||
|
// 1).
|
||||||
|
if len(out.Rows) < limit || (len(out.Rows) > 0 && key.Number == lastNumber) {
|
||||||
|
out.Rows = append(out.Rows, ListRow{
|
||||||
|
Number: rowNumber(r.Kind, r.Number), Kind: r.Kind, Title: r.Title, Status: r.Status,
|
||||||
|
Topic: r.Topic, Date: r.Date, Versions: e.Versions,
|
||||||
|
Pending: e.Pending != nil || (r.Ask != nil && r.Ask.Said == AskWaiting),
|
||||||
|
})
|
||||||
|
lastNumber = key.Number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
below := ""
|
||||||
|
if a.Before != 0 {
|
||||||
|
below = fmt.Sprintf(" below %d", a.Before)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case out.Matched == 0:
|
||||||
|
out.Said = "No record matches" + below + "."
|
||||||
|
case out.Matched > len(out.Rows):
|
||||||
|
out.Next = lastNumber
|
||||||
|
out.Said = fmt.Sprintf("%d records match%s; the newest %d are shown. The next page is before %d.", out.Matched,
|
||||||
|
below, len(out.Rows), out.Next)
|
||||||
|
default:
|
||||||
|
out.Said = fmt.Sprintf("%d records match%s, newest first.", out.Matched, below)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesListFlag says whether an entry matches a list flag.
|
||||||
|
func (k *KB) matchesListFlag(ctx context.Context, flag string, key Key, e *Entry, byKey map[Key]*Entry) (bool, error) {
|
||||||
|
switch flag {
|
||||||
|
case FlagResearchDone:
|
||||||
|
return researchDone(e, byKey), nil
|
||||||
|
case FlagProposedOld:
|
||||||
|
return k.proposedOld(ctx, key, e)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// researchDone says whether a research effort is active, names what it became in at least one became link to a
|
||||||
|
// decision (a became to a design's path is ignored), and every decision it names that way is accepted: a
|
||||||
|
// candidate for graduated (novox/hq issue 1064).
|
||||||
|
func researchDone(e *Entry, byKey map[Key]*Entry) bool {
|
||||||
|
r := e.Current
|
||||||
|
if r.Kind != KindResearch || r.Status != StatusActive {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
any := false
|
||||||
|
for _, l := range r.Links {
|
||||||
|
if l.Kind != LinkBecame {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target, isRecord := l.To.Record()
|
||||||
|
if !isRecord || target.Kind != KindDecision {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
any = true
|
||||||
|
d := byKey[target]
|
||||||
|
if d == nil || d.Current.Status != StatusAccepted {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any
|
||||||
|
}
|
||||||
|
|
||||||
|
// proposedOld says whether a record is proposed and its current version is older than ProposedOldAfter, by the
|
||||||
|
// KB's clock (novox/hq issue 1064).
|
||||||
|
func (k *KB) proposedOld(ctx context.Context, key Key, e *Entry) (bool, error) {
|
||||||
|
if e.Current.Status != StatusProposed {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
vs, err := k.Store.Versions(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, v := range vs {
|
||||||
|
if v.N == e.CurrentVersion {
|
||||||
|
return k.now().Sub(v.At) > ProposedOldAfter, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// list's pending counts only an ask still waiting, or a pending version: an ask that ended (approved or
|
||||||
|
// declined) does not make an accepted record show pending (review round 1, issue 1064).
|
||||||
|
func TestListPendingIgnoresAnAskThatHasEnded(t *testing.T) {
|
||||||
|
s := newMemStore()
|
||||||
|
seedRecord(t, s, &Record{Kind: KindDecision, Number: 500, Title: "approved, ask ended", Status: StatusAccepted,
|
||||||
|
Ask: &OperatorAsk{ID: "a1", Said: AskApproved}}, time.Now())
|
||||||
|
seedRecord(t, s, &Record{Kind: KindDecision, Number: 501, Title: "still waiting on the operator", Status: StatusProposed,
|
||||||
|
Ask: &OperatorAsk{ID: "a2", Said: AskWaiting}}, time.Now())
|
||||||
|
k := &KB{Store: s}
|
||||||
|
|
||||||
|
got, err := k.List(ListAsk{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
byNumber := map[string]bool{}
|
||||||
|
for _, row := range got.Rows {
|
||||||
|
byNumber[row.Number] = row.Pending
|
||||||
|
}
|
||||||
|
if byNumber["0500"] {
|
||||||
|
t.Fatalf("an ended ask (approved) must not show pending: %+v", got.Rows)
|
||||||
|
}
|
||||||
|
if !byNumber["0501"] {
|
||||||
|
t.Fatalf("an ask still waiting must show pending: %+v", got.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// list's before cursor never cuts a page between a decision and a research effort of the same bare number: the
|
||||||
|
// page extends to include the twin (review round 1, issue 1064).
|
||||||
|
func TestListNeverCutsAPageBetweenRecordsOfTheSameNumber(t *testing.T) {
|
||||||
|
s := newMemStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
// Two records both numbered 100: a decision and a research effort. The decision sorts first (kind
|
||||||
|
// "decision" < "research").
|
||||||
|
if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed",
|
||||||
|
Record: &Record{Kind: KindDecision, Number: 100, Title: "decision 100", Status: StatusAccepted}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed",
|
||||||
|
Record: &Record{Kind: KindResearch, Number: 100, Title: "research 100", Status: StatusActive}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed",
|
||||||
|
Record: &Record{Kind: KindDecision, Number: 99, Title: "decision 99", Status: StatusAccepted}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k := &KB{Store: s}
|
||||||
|
|
||||||
|
// A limit of 1 would otherwise cut between decision 100 and research 100: both must be shown.
|
||||||
|
got, err := k.List(ListAsk{Limit: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Rows) != 2 || got.Rows[0].Number != "0100" || got.Rows[1].Number != "research 100" {
|
||||||
|
t.Fatalf("want both twins of 100 on the page, got %+v", got.Rows)
|
||||||
|
}
|
||||||
|
if got.Next != 100 {
|
||||||
|
t.Fatalf("want next below 100 (past both twins), got %d", got.Next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next page, asked with before the twins' number, must still answer decision 99.
|
||||||
|
got, err = k.List(ListAsk{Before: got.Next})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Rows) != 1 || got.Rows[0].Number != "0099" {
|
||||||
|
t.Fatalf("want decision 99 on the next page, got %+v", got.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedRecord(t *testing.T, s Store, r *Record, at time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if err := s.Create(context.Background(), Version{By: "test", At: at, Why: "seed", Record: r}); err != nil {
|
||||||
|
t.Fatalf("seed %s: %v", Key{r.Kind, r.Number}, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// list filters by kind, status and topic (combined as AND), pages with before, and answers both flags as issue
|
||||||
|
// 1064 specifies.
|
||||||
|
func TestListFiltersPagesAndFlags(t *testing.T) {
|
||||||
|
s := newMemStore()
|
||||||
|
now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||||
|
k := &KB{Store: s, Now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
seedRecord(t, s, &Record{Kind: KindDecision, Number: 100, Title: "accepted in the mesh", Status: StatusAccepted,
|
||||||
|
Topic: "the mesh", Date: "2026-01-01"}, now.Add(-48*time.Hour))
|
||||||
|
seedRecord(t, s, &Record{Kind: KindDecision, Number: 200, Title: "proposed, old", Status: StatusProposed,
|
||||||
|
Topic: "the controller", Date: "2026-10-15"}, now.Add(-30*time.Hour))
|
||||||
|
seedRecord(t, s, &Record{Kind: KindDecision, Number: 201, Title: "proposed, fresh", Status: StatusProposed,
|
||||||
|
Topic: "the controller", Date: "2026-10-17"}, now.Add(-1*time.Hour))
|
||||||
|
seedRecord(t, s, &Record{Kind: KindResearch, Number: 10, Title: "research done", Status: StatusActive,
|
||||||
|
Topic: "the mesh", Links: []Link{{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 100}}}}, now.Add(-72*time.Hour))
|
||||||
|
seedRecord(t, s, &Record{Kind: KindResearch, Number: 20, Title: "research not done: no became", Status: StatusActive,
|
||||||
|
Topic: "the mesh"}, now.Add(-72*time.Hour))
|
||||||
|
seedRecord(t, s, &Record{Kind: KindResearch, Number: 30, Title: "research not done: became to a proposed decision",
|
||||||
|
Status: StatusActive, Topic: "the mesh",
|
||||||
|
Links: []Link{{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 200}}}}, now.Add(-72*time.Hour))
|
||||||
|
|
||||||
|
// kind, status and topic, combined as AND.
|
||||||
|
got, err := k.List(ListAsk{Kind: KindDecision, Status: StatusProposed, Topic: "the controller"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Matched != 2 || len(got.Rows) != 2 {
|
||||||
|
t.Fatalf("want 2 proposed decisions in the controller, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// paging: before.
|
||||||
|
got, err = k.List(ListAsk{Kind: KindDecision, Before: 200})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Rows) != 1 || got.Rows[0].Number != "0100" {
|
||||||
|
t.Fatalf("want only 0100 before 200, got %+v", got.Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag research-done: active, with every became decision link accepted.
|
||||||
|
got, err = k.List(ListAsk{Kind: KindResearch, Flag: FlagResearchDone})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Rows) != 1 || got.Rows[0].Number != "research 010" {
|
||||||
|
t.Fatalf("want only research 010 done, got %+v", got.Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag proposed-old: proposed, current version older than 24h by the KB's clock.
|
||||||
|
got, err = k.List(ListAsk{Flag: FlagProposedOld})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Rows) != 1 || got.Rows[0].Number != "0200" {
|
||||||
|
t.Fatalf("want only 0200 proposed-old, got %+v", got.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The answer for 500 rows stays one call (issue 1064).
|
||||||
|
func TestListOf500RowsStaysOneCall(t *testing.T) {
|
||||||
|
s := newMemStore()
|
||||||
|
ctx := context.Background()
|
||||||
|
for n := 1; n <= 505; n++ {
|
||||||
|
if err := s.Create(ctx, Version{By: "test", Why: "seed",
|
||||||
|
Record: &Record{Kind: KindDecision, Number: n, Title: "seed", Status: StatusAccepted}}); err != nil {
|
||||||
|
t.Fatalf("seed %d: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k := &KB{Store: s}
|
||||||
|
got, err := k.List(ListAsk{Limit: 500})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Matched != 505 || len(got.Rows) != 500 || got.Next == 0 {
|
||||||
|
t.Fatalf("want 500 of 505 matched in one call, got matched %d rows %d next %d", got.Matched, len(got.Rows), got.Next)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ import (
|
|||||||
const Seat = "knowledge-base"
|
const Seat = "knowledge-base"
|
||||||
|
|
||||||
// Verbs are the seat's verbs, as the manifest declares and claims them (ADR 0290 rule 6).
|
// Verbs are the seat's verbs, as the manifest declares and claims them (ADR 0290 rule 6).
|
||||||
var Verbs = []string{"ask", "get", "holds", "links", "history", "reserve", "propose", "import", "return"}
|
var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return"}
|
||||||
|
|
||||||
// HealthTool is the module's own tool the node-engine asks how it is.
|
// HealthTool is the module's own tool the node-engine asks how it is.
|
||||||
const HealthTool = "knowledge_health"
|
const HealthTool = "knowledge_health"
|
||||||
@@ -813,6 +813,62 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
|||||||
}
|
}
|
||||||
return k.Get(g)
|
return k.Get(g)
|
||||||
}},
|
}},
|
||||||
|
{Name: Seat + ".list",
|
||||||
|
Description: "The records that match, newest first, number by number, one short row each — number, kind, title, status, " +
|
||||||
|
"topic, date and versions — optionally only those of one kind (decision or research), at one status, of one topic of " +
|
||||||
|
"the reading order, or named by flag: research-done is an active research effort naming what it became in a became " +
|
||||||
|
"link to a decision, every such decision accepted — a candidate for graduated; proposed-old is a proposal whose " +
|
||||||
|
"current version is more than a day old. before pages the newest-first list: give the answer's next to read on below it.",
|
||||||
|
Input: schema(nil, map[string]any{"kind": kindArg(),
|
||||||
|
"status": map[string]any{"type": "string", "enum": AllStatuses, "description": "only records at this status"},
|
||||||
|
"topic": text("only records of this topic, as the reading order groups them"),
|
||||||
|
"flag": map[string]any{"type": "string", "enum": ListFlags,
|
||||||
|
"description": "only the research efforts done (research-done), or the proposals grown old (proposed-old)"},
|
||||||
|
"before": integer("only records numbered below this: the next of the page before, to read on"),
|
||||||
|
"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, "kind", "status", "topic", "flag", "before", "limit")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ask ListAsk
|
||||||
|
if ask.Kind, err = a.str("kind"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ask.Status, err = a.str("status"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ask.Topic, err = a.str("topic"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ask.Flag, err = a.str("flag"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ask.Before, err = a.num("before", false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ask.Limit, err = a.num("limit", false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return k.List(ask)
|
||||||
|
}},
|
||||||
|
{Name: Seat + ".findings",
|
||||||
|
Description: "The whole-set findings kept: every failure of the rules that judge the whole record set, each saying which " +
|
||||||
|
"records it names and what the rule says of them — a record accepted and cited by nothing, a supersession or a " +
|
||||||
|
"change no accepted record makes. With number, only the findings that name that record.",
|
||||||
|
Input: schema(nil, map[string]any{"number": text("only the findings naming this record, such as 276 or research 045")}),
|
||||||
|
Run: func(m map[string]any) (any, error) {
|
||||||
|
a, err := readArgs("findings", m, "number")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
number, err := a.str("number")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return k.Findings(number)
|
||||||
|
}},
|
||||||
{Name: Seat + ".holds",
|
{Name: Seat + ".holds",
|
||||||
Description: "The clauses in force now, for a topic of the reading order or for one record, each with the record it comes " +
|
Description: "The clauses in force now, for a topic of the reading order or for one record, each with the record it comes " +
|
||||||
"from; a clause changed in part says by what. Never a clause only drafted from prose: those are counted and not served. " +
|
"from; a clause changed in part says by what. Never a clause only drafted from prose: those are counted and not served. " +
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
|
|||||||
if m.Module != Module || !reflect.DeepEqual(m.Seats, want) || !reflect.DeepEqual(m.Claims, want) {
|
if m.Module != Module || !reflect.DeepEqual(m.Seats, want) || !reflect.DeepEqual(m.Claims, want) {
|
||||||
t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims)
|
t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(Verbs, []string{"ask", "get", "holds", "links", "history", "reserve", "propose", "import", "return"}) {
|
if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return"}) {
|
||||||
t.Fatalf("the verbs are not ADR 0290 rule 6's: %v", Verbs)
|
t.Fatalf("the verbs are not ADR 0290 rule 6's: %v", Verbs)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(m.Emits, Events) {
|
if !reflect.DeepEqual(m.Emits, Events) {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"serves": [
|
"serves": [
|
||||||
"ask",
|
"ask",
|
||||||
"get",
|
"get",
|
||||||
|
"list",
|
||||||
|
"findings",
|
||||||
"holds",
|
"holds",
|
||||||
"links",
|
"links",
|
||||||
"history",
|
"history",
|
||||||
@@ -40,6 +42,8 @@
|
|||||||
"serves": [
|
"serves": [
|
||||||
"ask",
|
"ask",
|
||||||
"get",
|
"get",
|
||||||
|
"list",
|
||||||
|
"findings",
|
||||||
"holds",
|
"holds",
|
||||||
"links",
|
"links",
|
||||||
"history",
|
"history",
|
||||||
|
|||||||
Reference in New Issue
Block a user