Tools to read the knowledge base and the issue tracker in fewer, smaller calls (issues 1064, 1069, 1070, 1071) #354
@@ -18,8 +18,8 @@ what it refuses.
|
||||
| verb | takes | does |
|
||||
|---|---|---|
|
||||
| `open` | `title`, `report`, optionally `located-in`, `record` | checks the issue, then takes the next number and keeps it under that number; answers the number |
|
||||
| `get` | `number` | the issue whole: fields, notes, links |
|
||||
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `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 |
|
||||
| `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), `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 |
|
||||
| `note` | `number`, `text` | appends a note; a note is never edited |
|
||||
| `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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
v, ok := a.m[k]
|
||||
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; " +
|
||||
"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")}),
|
||||
"fix budget, and its phases: implementing, review, checks, merge to running. With brief: true, a short " +
|
||||
"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) {
|
||||
a, err := readArgs("get", m, "number")
|
||||
a, err := readArgs("get", m, "number", "brief")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -398,7 +428,18 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
if err != nil {
|
||||
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",
|
||||
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 " +
|
||||
"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.",
|
||||
"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{
|
||||
"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"),
|
||||
@@ -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"},
|
||||
"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"),
|
||||
"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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -451,9 +497,15 @@ func Tools(t *Tracker) []stdio.Tool {
|
||||
if ask.Search, err = a.str("search"); err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if ask.Numbers, err = a.numbers("numbers", MostNumbers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v, given := m["ready"]; given && v != nil {
|
||||
r, err := a.flag("ready")
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// made ready: a note on each, once, and the event `ready`.
|
||||
func (t *Tracker) letGo(dep uint64) error {
|
||||
|
||||
@@ -699,8 +699,17 @@ type ListAsk struct {
|
||||
Search string
|
||||
// Before keeps only the issues numbered below it: the cursor that pages the newest-first list.
|
||||
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.
|
||||
type Row struct {
|
||||
Number uint64 `json:"number"`
|
||||
@@ -731,7 +740,23 @@ type Listed struct {
|
||||
Matched int `json:"matched"`
|
||||
// Next is the before that continues the list where this page ends; 0 when nothing more matches.
|
||||
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
|
||||
@@ -770,6 +795,10 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
||||
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 := ""
|
||||
if strings.TrimSpace(a.Search) != "" {
|
||||
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 {
|
||||
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, " +
|
||||
"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
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
if usingNumbers {
|
||||
limit = len(a.Numbers)
|
||||
} else {
|
||||
limit = 50
|
||||
}
|
||||
}
|
||||
limit = min(limit, MostListResult)
|
||||
all, err := t.allIssues()
|
||||
@@ -793,19 +832,18 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
||||
return nil, err
|
||||
}
|
||||
now, staleAfter := t.now(), t.workerStaleAfter()
|
||||
var numbers []uint64
|
||||
if search != "" {
|
||||
var hits []hit
|
||||
if v := t.view(); v.ready() {
|
||||
hits = v.search(search)
|
||||
} else {
|
||||
x := newIndex()
|
||||
for _, is := range all {
|
||||
x.add(is)
|
||||
var numbers, missing []uint64
|
||||
var searchSet map[uint64]bool
|
||||
if usingNumbers {
|
||||
numbers = a.Numbers
|
||||
if search != "" {
|
||||
searchSet = map[uint64]bool{}
|
||||
for _, h := range t.searchHits(all, search) {
|
||||
searchSet[h.Number] = true
|
||||
}
|
||||
hits = x.search(search)
|
||||
}
|
||||
for _, h := range hits {
|
||||
} else if search != "" {
|
||||
for _, h := range t.searchHits(all, search) {
|
||||
if all[h.Number] != nil {
|
||||
numbers = append(numbers, h.Number)
|
||||
}
|
||||
@@ -822,6 +860,20 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
||||
lower := strings.ToLower(phrase)
|
||||
for _, n := range numbers {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
@@ -841,6 +893,9 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
||||
if owner != "" && !locatedUnder(is.LocatedIn, owner) {
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
@@ -857,11 +912,22 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
|
||||
out.Rows = append(out.Rows, row)
|
||||
}
|
||||
}
|
||||
out.Missing = missing
|
||||
below := ""
|
||||
if a.Before != 0 {
|
||||
below = fmt.Sprintf(" below %d", a.Before)
|
||||
}
|
||||
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:
|
||||
out.Said = "No issue matches" + below + "."
|
||||
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 }
|
||||
|
||||
// checkWorker is `by` as kept: who@node, in lower case.
|
||||
func checkWorker(s string) (string, error) {
|
||||
s, err := singleLine("by", s, MostWorker)
|
||||
func checkWorker(s string) (string, error) { return checkWorkerField("by", s) }
|
||||
|
||||
// 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 {
|
||||
return "", err
|
||||
}
|
||||
s = strings.ToLower(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 "+
|
||||
"machine it works on, such as fix-399@g14 or jochen@shanks", s)
|
||||
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", field, s)
|
||||
}
|
||||
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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
// 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.
|
||||
const HealthTool = "knowledge_health"
|
||||
@@ -813,6 +813,62 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
}
|
||||
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",
|
||||
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. " +
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Emits, Events) {
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"serves": [
|
||||
"ask",
|
||||
"get",
|
||||
"list",
|
||||
"findings",
|
||||
"holds",
|
||||
"links",
|
||||
"history",
|
||||
@@ -40,6 +42,8 @@
|
||||
"serves": [
|
||||
"ask",
|
||||
"get",
|
||||
"list",
|
||||
"findings",
|
||||
"holds",
|
||||
"links",
|
||||
"history",
|
||||
|
||||
Reference in New Issue
Block a user