diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md index 865dda67..35955f8a 100644 --- a/modules/mesh-issues/README.md +++ b/modules/mesh-issues/README.md @@ -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 | diff --git a/modules/mesh-issues/cmd/mesh-issues/list_numbers_review1_test.go b/modules/mesh-issues/cmd/mesh-issues/list_numbers_review1_test.go new file mode 100644 index 00000000..1f61a09e --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/list_numbers_review1_test.go @@ -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) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go index 3aa40394..ec5adb20 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -746,6 +746,19 @@ type Listed struct { 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 // (novox/hq ADR 0300 §5). func (t *Tracker) List(a ListAsk) (*Listed, error) { @@ -782,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 { @@ -790,14 +807,11 @@ 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") } } - if len(a.Numbers) > MostNumbers { - return nil, fmt.Errorf("numbers names %d entries; at most %d", len(a.Numbers), MostNumbers) - } worker := "" if strings.TrimSpace(a.Worker) != "" { if worker, err = checkWorkerField("worker", a.Worker); err != nil { @@ -806,7 +820,11 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { } 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() @@ -814,22 +832,18 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { return nil, err } now, staleAfter := t.now(), t.workerStaleAfter() - usingNumbers := len(a.Numbers) > 0 var numbers, missing []uint64 + var searchSet map[uint64]bool if usingNumbers { numbers = a.Numbers - } else 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) + 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) } @@ -852,6 +866,14 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { } continue } + if usingNumbers { + if a.Before != 0 && n >= a.Before { + continue + } + if searchSet != nil && !searchSet[n] { + continue + } + } if status != "" && is.Status != status { continue } @@ -900,6 +922,9 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) { 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)) }