Review round 1: list numbers combines search and before as AND, its limit defaults to the count asked and said names a cut; README names brief, numbers and worker (issues 1070, 1071)

This commit is contained in:
jochen
2026-10-17 23:07:29 +02:00
parent b387cddc7c
commit 7bdb8f4782
3 changed files with 107 additions and 18 deletions
+2 -2
View File
@@ -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,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)
}
}
+41 -16
View File
@@ -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))
}