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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user