From 1124f17912c770e6f27e2c6d8aacbdc389a1e4ac Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 17 Oct 2026 22:50:09 +0200 Subject: [PATCH] Let issue-tracker.list take numbers and worker (issue 1070) --- .../mesh-issues/list_numbers_worker_test.go | 52 +++++++++++++++++++ modules/mesh-issues/cmd/mesh-issues/main.go | 39 +++++++++++++- .../mesh-issues/cmd/mesh-issues/tracker.go | 47 +++++++++++++++-- modules/mesh-issues/cmd/mesh-issues/worker.go | 11 ++-- 4 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 modules/mesh-issues/cmd/mesh-issues/list_numbers_worker_test.go diff --git a/modules/mesh-issues/cmd/mesh-issues/list_numbers_worker_test.go b/modules/mesh-issues/cmd/mesh-issues/list_numbers_worker_test.go new file mode 100644 index 00000000..e4dbb8cd --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/list_numbers_worker_test.go @@ -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) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index 22496a1c..03c7d83c 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -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 { diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go index ad85538d..3aa40394 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -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): diff --git a/modules/mesh-issues/cmd/mesh-issues/worker.go b/modules/mesh-issues/cmd/mesh-issues/worker.go index f7946958..ed1f43b6 100644 --- a/modules/mesh-issues/cmd/mesh-issues/worker.go +++ b/modules/mesh-issues/cmd/mesh-issues/worker.go @@ -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 }