Let issue-tracker.list take numbers and worker (issue 1070)

This commit is contained in:
jochen
2026-10-17 22:50:09 +02:00
parent 01c9ab0cf5
commit 1124f17912
4 changed files with 140 additions and 9 deletions
@@ -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)
}
}
+37 -2
View File
@@ -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 {
@@ -428,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"),
@@ -440,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
}
@@ -468,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 {
+44 -3
View File
@@ -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,10 @@ 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"`
}
// List is the issues that match, newest first, or ranked by a search: from memory once it is loaded on start
@@ -783,6 +795,15 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
"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 {
return nil, err
}
}
limit := a.Limit
if limit <= 0 {
limit = 50
@@ -793,8 +814,11 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
return nil, err
}
now, staleAfter := t.now(), t.workerStaleAfter()
var numbers []uint64
if search != "" {
usingNumbers := len(a.Numbers) > 0
var numbers, missing []uint64
if usingNumbers {
numbers = a.Numbers
} else if search != "" {
var hits []hit
if v := t.view(); v.ready() {
hits = v.search(search)
@@ -822,6 +846,12 @@ 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 status != "" && is.Status != status {
continue
}
@@ -841,6 +871,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 +890,19 @@ 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 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
}