Issue tracker: list rows say when each issue last moved, and list takes linked (issue 1947)

The knowledge base's progress verb reads a milestone's issues in one call and must say
when each last moved, and the issues linked to a record or a design; neither was
readable without one get per issue.
This commit is contained in:
2026-10-26 16:29:56 +01:00
parent 6b6d5cba71
commit 5e516317e9
4 changed files with 155 additions and 3 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ what it refuses.
|---|---|---|
| `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`, 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 |
| `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), `linked` (only the issues with that link, as `kind:target`, such as `decision:0493`) | short rows, newest first, or best first for a `search`, or in the order given for `numbers`: number, status, owner, title, `moved` (when the issue last changed: opened, moved, noted, linked, worked on, or its fix's delivery said a new state), 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,76 @@
package main
import (
"reflect"
"testing"
"time"
)
// 1947/AC4: each list row says when its issue last moved, and linked keeps only the issues with that link, so the
// knowledge base's progress reads both in one call.
func TestListSaysWhenEachIssueMovedAndKeepsTheLinkedOnes(t *testing.T) {
h := newHarness(t)
clock := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
h.Tracker.Now = func() time.Time { return clock }
quiet := h.open(t, "opened and left")
noted := h.open(t, "noted later")
linked := h.open(t, "linked to a decision")
clock = clock.Add(time.Hour)
if _, err := h.Note(noted, "seen again"); err != nil {
t.Fatal(err)
}
clock = clock.Add(time.Hour)
if _, err := h.Link(LinkAsk{Number: linked, Kind: LinkDecision, Target: "0493"}); err != nil {
t.Fatal(err)
}
if _, err := h.Link(LinkAsk{Number: quiet, Kind: LinkDesign, Target: "03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md"}); err != nil {
t.Fatal(err)
}
start := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
for _, c := range []struct {
name string
ask ListAsk
want []uint64
moved []time.Time
refuse bool
}{
{"numbers say moved", ListAsk{Numbers: []uint64{quiet, noted, linked}}, []uint64{quiet, noted, linked},
[]time.Time{start.Add(2 * time.Hour), start.Add(time.Hour), start.Add(2 * time.Hour)}, false},
{"linked to a decision", ListAsk{Linked: "decision:0493"}, []uint64{linked}, nil, false},
{"linked to a design", ListAsk{Linked: " design:03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md"}, []uint64{quiet}, nil, false},
{"linked and numbers together", ListAsk{Linked: "decision:0493", Numbers: []uint64{quiet, noted}}, nil, nil, false},
{"a link nobody has", ListAsk{Linked: "decision:0001"}, nil, nil, false},
{"no colon", ListAsk{Linked: "decision"}, nil, nil, true},
{"an unknown kind", ListAsk{Linked: "milestone:R6"}, nil, nil, true},
{"a target of the wrong shape", ListAsk{Linked: "decision:ADR 0493"}, nil, nil, true},
} {
l, err := h.List(c.ask)
if c.refuse {
if err == nil {
t.Errorf("%s: listed", c.name)
}
continue
}
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
var got []uint64
var moved []time.Time
for _, r := range l.Rows {
got = append(got, r.Number)
if r.Moved == nil {
t.Fatalf("%s: row %d says no moved", c.name, r.Number)
}
moved = append(moved, *r.Moved)
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: %v, want %v (%s)", c.name, got, c.want, l.Said)
}
if c.moved != nil && !reflect.DeepEqual(moved, c.moved) {
t.Errorf("%s: moved %v, want %v", c.name, moved, c.moved)
}
}
if _, err := h.call(t, "list", map[string]any{"linked": "decision:0493"}); err != nil {
t.Fatalf("the verb does not take linked: %v", err)
}
}
+8 -2
View File
@@ -464,7 +464,9 @@ func Tools(t *Tracker) []stdio.Tool {
"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. 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.",
"keeps only the issues in diagnosing or fixing whose worker is exactly that; linked (kind:target, such as " +
"decision:0493) keeps only the issues with that link. Each row says when the issue last moved (moved): " +
"opened, moved, noted, linked, worked on, or its fix's delivery said a new state.",
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"),
@@ -479,9 +481,10 @@ func Tools(t *Tracker) []stdio.Tool {
"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"),
"linked": text("only issues with this link, as kind:target, such as decision:0493 or design:03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before", "numbers", "worker")
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before", "numbers", "worker", "linked")
if err != nil {
return nil, err
}
@@ -510,6 +513,9 @@ func Tools(t *Tracker) []stdio.Tool {
if ask.Worker, err = a.str("worker"); err != nil {
return nil, err
}
if ask.Linked, err = a.str("linked"); err != nil {
return nil, err
}
if ask.Before, err = a.number("before", false); err != nil {
return nil, err
}
@@ -708,6 +708,10 @@ type ListAsk struct {
// Worker keeps only the issues in diagnosing or fixing whose worker is exactly this, who@node (novox/hq
// issue 1070).
Worker string
// Linked keeps only the issues with this link, given as kind:target, such as decision:0493 or
// design:03-DESIGN/01-to-be/47-delivery-from-commit-to-delivered.md (novox/hq issue 1947): what the knowledge
// base's progress reads to name the issues that build a record or a design (ADR 0424 §2).
Linked string
}
// MostNumbers is the most `numbers` may name in one list.
@@ -735,6 +739,61 @@ type Row struct {
// did nothing on it for worker-stale-after and no pull request of it is open (novox/hq issue 483).
DeliveredNotResolved bool `json:"delivered-not-resolved,omitempty"`
Abandoned bool `json:"abandoned,omitempty"`
// Moved is when the issue last changed: opened, moved, noted, linked, its worker active, or its fix's delivery
// said a new state (novox/hq issue 1947), so a progress report says when each thing last moved in one call.
Moved *time.Time `json:"moved,omitempty"`
}
// lastMoved is when an issue last changed, as its own record says: the latest of its opening, its closing, its
// notes, its links, its worker's start and last act, its import and what the delivery last said of each fix.
func lastMoved(is *Issue) time.Time {
at := is.Opened
later := func(t time.Time) {
if t.After(at) {
at = t
}
}
if is.Closed != nil {
later(*is.Closed)
}
for _, n := range is.Notes {
later(n.At)
}
for _, l := range is.Links {
later(l.At)
}
if w := is.Worker; w != nil {
later(w.Since)
later(w.Active)
}
if is.Imported != nil {
later(is.Imported.At)
}
for _, f := range is.Fixes {
later(f.Since)
}
return at
}
// checkLinked is a list's linked filter, kind:target, said the one way a link of that kind is kept.
func checkLinked(s string) (kind, target string, err error) {
k, t, ok := strings.Cut(strings.TrimSpace(s), ":")
if !ok {
return "", "", fmt.Errorf("linked is kind:target, such as decision:0493, not %q", s)
}
k = strings.TrimSpace(k)
t, err = checkTarget(k, t)
return k, t, err
}
// linkedTo says whether an issue has the link of kind to target, as kept.
func linkedTo(is *Issue, kind, target string) bool {
for _, l := range is.Links {
if l.Kind == kind && l.Target == target {
return true
}
}
return false
}
// Listed is the answer to a list.
@@ -815,6 +874,12 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
"or more words, instead")
}
}
linkKind, linkTarget := "", ""
if strings.TrimSpace(a.Linked) != "" {
if linkKind, linkTarget, err = checkLinked(a.Linked); err != nil {
return nil, err
}
}
worker := ""
if strings.TrimSpace(a.Worker) != "" {
if worker, err = checkWorkerField("worker", a.Worker); err != nil {
@@ -899,6 +964,9 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
if worker != "" && (is.Worker == nil || is.Worker.By != worker) {
continue
}
if linkKind != "" && !linkedTo(is, linkKind, linkTarget) {
continue
}
if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) {
continue
}
@@ -912,6 +980,8 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
}
row.DeliveredNotResolved = deliveredNotResolved(is)
row.Abandoned = abandoned(is, now, staleAfter)
moved := lastMoved(is)
row.Moved = &moved
out.Rows = append(out.Rows, row)
}
}