diff --git a/modules/mesh-issues/README.md b/modules/mesh-issues/README.md index 803adfc8..4ad24027 100644 --- a/modules/mesh-issues/README.md +++ b/modules/mesh-issues/README.md @@ -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 | diff --git a/modules/mesh-issues/cmd/mesh-issues/list_progress_test.go b/modules/mesh-issues/cmd/mesh-issues/list_progress_test.go new file mode 100644 index 00000000..ef252169 --- /dev/null +++ b/modules/mesh-issues/cmd/mesh-issues/list_progress_test.go @@ -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) + } +} diff --git a/modules/mesh-issues/cmd/mesh-issues/main.go b/modules/mesh-issues/cmd/mesh-issues/main.go index 742f1503..40d880f2 100644 --- a/modules/mesh-issues/cmd/mesh-issues/main.go +++ b/modules/mesh-issues/cmd/mesh-issues/main.go @@ -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 } diff --git a/modules/mesh-issues/cmd/mesh-issues/tracker.go b/modules/mesh-issues/cmd/mesh-issues/tracker.go index 5bea5f3f..dd8fc4ac 100644 --- a/modules/mesh-issues/cmd/mesh-issues/tracker.go +++ b/modules/mesh-issues/cmd/mesh-issues/tracker.go @@ -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) } }