issue-tracker.changes reads each issue's moves over a window from the tracker's own log, so progress since says from what each issue moved, not only where it is
mesh/delivery merged: source.main moved to novox/mesh-catalog@9cdac09ad962
mesh/check being carried to dfe490d9e245 on 9cdac09ad962 | from 25f615456423

This commit is contained in:
2026-10-28 12:33:01 +01:00
parent 4f317201c9
commit dfe490d9e2
13 changed files with 431 additions and 25 deletions
+1
View File
@@ -33,6 +33,7 @@ what it refuses.
| `reserve` | `kind` (`decision` or `research`), optionally `for` (one line: what the record is about) | the next number of that kind, spent forever; answers `kind` and `number` (and `prefix`, the number as the record's name starts with it); see below |
| `fix-times` | optionally `days` (1 to 90, default 7) | each class's fix budget, the median and worst fix time of the issues whose last fix was delivered in the window, within and over, and every issue over its budget with its longest phase; see *A fix's time* |
| `learn-fixes` | `number` | asks again for the fixes of one issue the tracker keeps no record of (a pull request merged before it heard deliveries): the delivery, then the forge, say whether each is merged (and the commit it landed as) or closed without merging; answers what each is kept as and the delivery line |
| `changes` | `since` (a window such as `60m`, at most `24h`), `number` (optional) | each issue's moves over the window, read from the tracker's own log `changes`: opened, moved, resolved or wontfix, the status it moved from and to, who moved it (and the worker, for diagnosing or fixing) and when, oldest first, at most 500; the window's first entry is found by its time, so a day's window reads only that day of the log. knowledge-base.progress with `since` asks it for each move's "from" |
**The cycle's rules, at the write** (hq playbook 03, which `00-META/checks/cycle.py` enforced on the
folders until the import, and hq ADR 0284): a status is one of `open`,
+2 -1
View File
@@ -23,7 +23,7 @@ const Seat = "issue-tracker"
// Verbs are the seat's verbs, as the manifest declares and claims them.
var Verbs = []string{"open", "get", "list", "move", "note", "link", "milestones", "milestone-set", "milestone-move", "import",
"groom", "set", "tracking", "reserve", "fix-times", "learn-fixes"}
"groom", "set", "tracking", "reserve", "fix-times", "learn-fixes", "changes"}
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
@@ -943,5 +943,6 @@ func Tools(t *Tracker) []stdio.Tool {
}
return t.LearnFixes(n)
}},
changesTool(t),
}
}
@@ -72,6 +72,13 @@ func TestAnIssueRaceIntoTwoMilestonesOverTheBusEndsInOne(t *testing.T) {
if len(hist.History) != 1 {
t.Fatalf("history %d values", len(hist.History))
}
// The moves history over the real log: the open, from nothing to open, read by the other instance.
var ch ChangesRead
_ = json.Unmarshal(must(two.call("changes", map[string]any{"since": "60m"})), &ch)
if len(ch.Changes) != 1 || ch.Changes[0].Number != 365 || ch.Changes[0].Kind != OpOpened || ch.Changes[0].To != StatusOpen ||
ch.Changes[0].Title != "a milestone's issue" {
t.Fatalf("changes %+v", ch)
}
evMu.Lock()
defer evMu.Unlock()
var kinds []string
@@ -0,0 +1,222 @@
package main
// changes: each issue's moves over a window, read from the tracker's own record, its log `changes` (novox/hq ADR 0300),
// which holds every operation with who made it and when. The bucket `issues` keeps only what they add up to, so this
// is the one place an issue's earlier status is known: knowledge-base.progress with since asks it for the "from" of
// each move (the operator, 2026-10-28: the progress made in the last 60 minutes).
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// MostChangesWindow is the longest window changes reads.
const MostChangesWindow = 24 * time.Hour
// MostChanges is the most changes one answer holds, the newest kept.
const MostChanges = 500
// changesSlack is how far before the window the log is read from: an entry's time is the bus's, an operation's the
// tracker's, and the two may differ by a little.
const changesSlack = time.Minute
// Change is one issue's status changing: opened, moved, resolved or wontfix, from what to what, by whom and when.
type Change struct {
Number uint64 `json:"number"`
Title string `json:"title,omitempty"`
Kind string `json:"kind"`
From string `json:"from"`
To string `json:"to"`
By string `json:"by"`
// Worker is who works on it, for a move to diagnosing or fixing: whom by names (the caller is By).
Worker string `json:"worker,omitempty"`
At time.Time `json:"at"`
}
// ChangesRead is the answer of changes.
type ChangesRead struct {
Since time.Time `json:"since"`
Changes []Change `json:"changes"`
// Entries is how many entries of the log were read for it.
Entries int `json:"entries-read"`
Said string `json:"said"`
}
// Changes reads the moves of the last window, of every issue or of one.
func (t *Tracker) Changes(window time.Duration, number uint64) (*ChangesRead, error) {
if window <= 0 || window > MostChangesWindow {
return nil, fmt.Errorf("since is a window more than nothing and at most 24h, not %s", window)
}
if t.Log == nil {
return nil, errNoLog
}
from := t.now().Add(-window)
out := &ChangesRead{Since: from, Changes: []Change{}}
var numbers []uint64
if number != 0 {
numbers = []uint64{number}
} else {
var err error
if numbers, err = t.movedSince(from, out); err != nil {
return nil, err
}
}
for _, n := range numbers {
entries, err := t.readKey(n, 1)
if err != nil {
return nil, err
}
if len(entries) == 0 {
if number != 0 {
return nil, fmt.Errorf("%d is not an issue the tracker holds", n)
}
continue
}
out.Entries += len(entries)
var cur *Issue
for _, e := range entries {
var op Operation
if err := json.Unmarshal(e.Value, &op); err != nil {
return nil, fmt.Errorf("the entry at %d is no operation this module reads: %v", e.Sequence, err)
}
next, err := fold(cur, &op, e.Sequence)
if err != nil {
return nil, fmt.Errorf("issue %d cannot be folded from its log: %v", n, err)
}
was := ""
if cur != nil {
was = cur.Status
}
cur = next
if op.Op == OpCarried || op.At.Before(from) || (was == next.Status && was != "") {
continue
}
kind := EventMoved
switch {
case was == "" && (op.Op == OpOpened || op.Op == OpImported):
kind = op.Op
case next.Status == StatusResolved || next.Status == StatusWontfix:
kind = next.Status
}
c := Change{Number: n, Kind: kind, From: was, To: next.Status, By: op.By, At: op.At}
if next.Worker != nil && (next.Status == StatusDiagnosing || next.Status == StatusFixing) {
c.Worker = next.Worker.By
}
out.Changes = append(out.Changes, c)
}
for i := range out.Changes {
if out.Changes[i].Number == n && cur != nil {
out.Changes[i].Title = cur.Title
}
}
}
sort.SliceStable(out.Changes, func(i, j int) bool {
if !out.Changes[i].At.Equal(out.Changes[j].At) {
return out.Changes[i].At.Before(out.Changes[j].At)
}
return out.Changes[i].Number < out.Changes[j].Number
})
cut := ""
if len(out.Changes) > MostChanges {
cut = fmt.Sprintf("; the oldest %d are left out", len(out.Changes)-MostChanges)
out.Changes = out.Changes[len(out.Changes)-MostChanges:]
}
out.Said = fmt.Sprintf("%d changes of status since %s, from the tracker's log (%d entries read)%s.",
len(out.Changes), from.Format(time.RFC3339), out.Entries, cut)
return out, nil
}
// movedSince is every issue an operation of which was written from the window on, found in the whole log by its
// entries' times: the first entry of the window is searched for, then the log is read on from it.
func (t *Tracker) movedSince(from time.Time, out *ChangesRead) ([]uint64, error) {
first, err := t.Log.Read(1, "", 1)
if err != nil {
return nil, fmt.Errorf("the log could not be read: %v", err)
}
if len(first.Entries) == 0 {
return nil, nil
}
lo, hi := first.Entries[0].Sequence, first.Last
start := from.Add(-changesSlack)
// The first sequence whose entry is at or after start: the entries are in the order they were written.
for lo < hi {
mid := lo + (hi-lo)/2
p, err := t.Log.Read(mid, "", 1)
if err != nil {
return nil, fmt.Errorf("the log could not be read at %d: %v", mid, err)
}
out.Entries++
if len(p.Entries) == 0 || !p.Entries[0].At.Before(start) {
hi = mid
} else {
lo = p.Entries[0].Sequence + 1
}
}
seen := map[uint64]bool{}
var numbers []uint64
for seq := lo; seq != 0; {
p, err := t.Log.Read(seq, "", logPage)
if err != nil {
return nil, fmt.Errorf("the log could not be read from %d: %v", seq, err)
}
out.Entries += len(p.Entries)
for _, e := range p.Entries {
n, ok := numberOfKey(e.Key)
if !ok || seen[n] || e.At.Before(start) || !movesStatus(e) {
continue
}
seen[n] = true
numbers = append(numbers, n)
}
seq = p.Next
}
return numbers, nil
}
// movesStatus says whether a log entry may change an issue's status: one that holds the issue whole, or sets it.
func movesStatus(e stdio.LogEntry) bool {
var op Operation
if json.Unmarshal(e.Value, &op) != nil {
return true
}
_, sets := op.Set["status"]
return sets || (op.Issue != nil && op.Op != OpCarried)
}
// changesTool is the verb, its arguments read as the other verbs read them.
func changesTool(t *Tracker) stdio.Tool {
return stdio.Tool{Name: Seat + ".changes",
Description: "Each issue's moves over a window, from the tracker's own log of every change: the issue moves history " +
"of the last minutes or hours (since, such as 60m, at most 24h), every issue's or one's (number), each change " +
"opened, moved, resolved or wontfix with the status it moved from and to, who moved it and when, oldest first. " +
"What the issues are now is list and get; this is what they were before. Refused: a window of nothing or over " +
"24h, a number that is not an issue.",
Input: schema([]string{"since"}, map[string]any{
"since": text("the window, back from now: 60m, 5m, 2h, at most 24h"),
"number": number("only this issue"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("changes", m, "since", "number")
if err != nil {
return nil, err
}
s, err := a.str("since")
if err != nil {
return nil, err
}
d, err := time.ParseDuration(strings.TrimSpace(s))
if err != nil {
return nil, fmt.Errorf("since is a window such as 60m, 5m or 2h, not %q", s)
}
n, err := a.number("number", false)
if err != nil {
return nil, err
}
return t.Changes(d, n)
}}
}
@@ -0,0 +1,76 @@
package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
// changes reads the tracker's own log over a window (the lead, 2026-10-28, for knowledge-base.progress since): each
// move from what to what, by whom and when, and each open, as the tracker's verbs wrote them into its log here.
func TestChangesSaysEachMoveFromWhatToWhatOverAWindow(t *testing.T) {
h := newHarness(t)
clock := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC)
h.Tracker.Now = func() time.Time { return clock }
at := func(hh, mm int) { clock = time.Date(2026, 10, 28, hh, mm, 0, 0, time.UTC); h.log.clock = clock }
at(9, 0)
a := h.open(t, "an issue fixed in the window", "hq")
at(9, 10)
b := h.open(t, "an issue given up in the window")
at(10, 0)
if _, err := h.Move(MoveAsk{Number: a, Status: StatusDiagnosing, By: "a@g14"}); err != nil {
t.Fatal(err)
}
at(10, 30)
if _, err := h.Move(MoveAsk{Number: a, Status: StatusFixing, By: "a@g14"}); err != nil {
t.Fatal(err)
}
at(10, 40)
if _, err := h.Note(a, "a note moves nothing"); err != nil {
t.Fatal(err)
}
at(11, 0)
if _, err := h.Move(MoveAsk{Number: a, Status: StatusResolved, FixedBy: []string{"hq#1"}}); err != nil {
t.Fatal(err)
}
at(11, 5)
if _, err := h.Move(MoveAsk{Number: b, Status: StatusWontfix, Reason: "the module is retired"}); err != nil {
t.Fatal(err)
}
at(11, 30)
say := func(r *ChangesRead) string {
var lines []string
for _, c := range r.Changes {
lines = append(lines, strings.Join([]string{c.At.Format("15:04"), c.Kind, itoa(int(c.Number)), c.From + ">" + c.To, c.By + "/" + c.Worker}, " "))
}
return strings.Join(lines, "\n")
}
got, err := h.call(t, "changes", map[string]any{"since": "60m"})
if err != nil {
t.Fatal(err)
}
r := got.(*ChangesRead)
if want := "10:30 moved 365 diagnosing>fixing " + Unproven + "/a@g14\n11:00 resolved 365 fixing>resolved " + Unproven + "/\n11:05 wontfix 366 open>wontfix " + Unproven + "/"; say(r) != want {
t.Errorf("the last hour:\n%s\nwant\n%s", say(r), want)
}
if !r.Since.Equal(time.Date(2026, 10, 28, 10, 30, 0, 0, time.UTC)) || r.Changes[0].Title != "an issue fixed in the window" || !strings.Contains(r.Said, "3 changes") {
t.Errorf("since %v, title %q, said %q", r.Since, r.Changes[0].Title, r.Said)
}
got, err = h.call(t, "changes", map[string]any{"since": "3h", "number": b})
if err != nil {
t.Fatal(err)
}
if want := "09:10 opened 366 >open " + Unproven + "/\n11:05 wontfix 366 open>wontfix " + Unproven + "/"; say(got.(*ChangesRead)) != want {
t.Errorf("one issue:\n%s", say(got.(*ChangesRead)))
}
raw, _ := json.Marshal(got)
if !strings.Contains(string(raw), `"from":"open","to":"wontfix"`) {
t.Errorf("as JSON: %s", raw)
}
for _, bad := range []map[string]any{{"since": "25h"}, {"since": "0m"}, {}, {"since": "60m", "number": 9999}} {
if _, err := h.call(t, "changes", bad); err == nil {
t.Errorf("%v was answered", bad)
}
}
}
+4 -2
View File
@@ -22,7 +22,8 @@
"tracking",
"reserve",
"fix-times",
"learn-fixes"
"learn-fixes",
"changes"
]
}
],
@@ -46,7 +47,8 @@
"tracking",
"reserve",
"fix-times",
"learn-fixes"
"learn-fixes",
"changes"
]
}
],
+1 -1
View File
@@ -27,7 +27,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
| `withdraw` | `number`, `kind`, `why` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn |
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound`, `since` (a window such as `60m`, at most `24h`) | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. A design's title and state are read through the records module, cached by hq's commit; its milestones and their issues are the issue tracker's milestone records (`issue-tracker.milestones`, issue 1781), asked once per answer; a design with none is refused, naming the seed (`issue-tracker.import` with `milestones`). With `since`, only what changed in the window under `of`, headed "N changes in the last 60 min": milestones moved (from what to what, from `issue-tracker.milestones` `one`), issues opened, moved, noted, linked or closed (from `issue-tracker.get`), pull requests opened, checked, merged, held or closed (from `mesh-delivery.show`'s transitions and check). It keeps no state; two things neither seat keeps are said in its warnings: an issue's earlier status (the tracker's log `changes` holds it, no verb reads it) and when a merge reached its machines |
| `progress` | `of` (`mesh`, `design <n>`, `milestone <design>/<id>`, `issue <n>`, `decision <n>` or `ADR <n>`, `research <n>`), `detail` (`minimal` or `detailed`), `bound`, `since` (a window such as `60m`, at most `24h`) | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. A design's title and state are read through the records module, cached by hq's commit; its milestones and their issues are the issue tracker's milestone records (`issue-tracker.milestones`, issue 1781), asked once per answer; a design with none is refused, naming the seed (`issue-tracker.import` with `milestones`). With `since`, only what changed in the window under `of`, headed "N changes in the last 60 min": milestones moved (from what to what, from `issue-tracker.milestones` `one`), issues opened or moved from what to what (from `issue-tracker.changes`, the tracker's log), noted or linked (from `issue-tracker.get`), pull requests opened, checked, merged, held or closed (from `mesh-delivery.show`'s transitions and check). It keeps no state; its warnings say when the tracker cannot answer `changes` (a move is then said by the status it reached, not from what) and that mesh-delivery keeps no time a merge reached its machines |
`ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut
and how to read the rest. The module's own tool `knowledge_health` answers its health (below).
@@ -80,11 +80,11 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
}
if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "claude-code.claude_code_judged", "records.records_status",
"records.records_list", "records.records_read", "seat:operator-channel.notify", "seat:issue-tracker.list", "seat:issue-tracker.get",
"seat:issue-tracker.milestones", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
"seat:issue-tracker.milestones", "seat:issue-tracker.changes", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) {
t.Fatalf("invokes %v", m.Invokes)
}
src := ""
for _, f := range []string{"main.go", "hq.go", "progress.go", "milestones.go"} {
for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go"} {
src += readFile(t, f)
}
for _, inv := range m.Invokes {
@@ -5,16 +5,17 @@ package main
// call: the knowledge base keeps no state of its own, so it reads what the other seats keep, each a request and reply
// over the bus:
//
// - the issue tracker's moves over the window, from its own log (`issue-tracker.changes`): each issue opened or
// moved, from what to what, by whom and when;
// - the issue tracker's milestones with their last move, and a moved one's history (`issue-tracker.milestones`
// with one): what it was before, what it is now, who moved it and when;
// - each issue that moved in the window whole (`issue-tracker.get`): when it was opened, its notes and links with who
// and when, its worker since when, and when it was closed;
// - mesh-delivery's deliveries and each one's transitions and its check (`mesh-delivery.deliveries`, `.show`).
//
// Two things the window needs are kept by neither seat, and each answer that meets one says so: the tracker keeps an
// issue's moves only in its log, which no verb reads, so a move is said by the status it reached (with its worker's
// since or its closed time), not from what it moved; and mesh-delivery keeps no time a merge reached its machines, so
// "on the machines" is as it reads now.
// When the tracker cannot say its moves (a tracker without `changes`), a move is said by the status it reached, with its
// worker's since or its closed time, not from what it moved, and the answer says so. mesh-delivery keeps no time a
// merge reached its machines, so "on the machines" is as it reads now, said too.
import (
"encoding/json"
@@ -217,10 +218,13 @@ func (k *KB) issueChanges(p *Progress, w window) []change {
}
}
sort.Slice(moved, func(i, j int) bool { return moved[i].Moved.After(*moved[j].Moved) })
var moves map[int][]trackerMove
var merr error
if len(moved) > 0 {
p.Warnings = append(p.Warnings, "the issue tracker keeps no history of an issue's moves that a verb reads (only its "+
"log changes holds them): a move is said by the status it reached, its worker's since and its closed time, not "+
"from what it moved")
if moves, merr = k.trackerMoves(w); merr != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("issue-tracker.changes: %v; a move is said by the status it reached, "+
"its worker's since and its closed time, not from what it moved", merr))
}
}
var out []change
for i, r := range moved {
@@ -253,16 +257,35 @@ func (k *KB) issueChanges(p *Progress, w window) []change {
}
var evs []event
var says []string
if w.holds(g.Opened) {
if merr == nil {
for _, m := range moves[r.Number] {
if m.Kind == "opened" || m.Kind == "imported" {
evs = append(evs, event{at: m.At, line: fmt.Sprintf("%s %s by %s", m.Kind, when(&m.At), m.By)})
says = append(says, m.Kind+" by "+m.By+" "+when(&m.At))
continue
}
who, line := m.By, fmt.Sprintf("%s → %s %s by %s", m.From, m.To, when(&m.At), m.By)
if m.Worker != "" {
who, line = m.Worker, line+", worker "+m.Worker
}
short := fmt.Sprintf("%s → %s by %s %s", m.From, m.To, who, when(&m.At))
if (m.To == "resolved" || m.To == "wontfix") && len(g.FixedBy) > 0 {
short += ", fixed by " + strings.Join(g.FixedBy, ", ")
}
evs = append(evs, event{at: m.At, line: line})
says = append(says, short)
}
}
if merr != nil && w.holds(g.Opened) {
evs = append(evs, event{at: g.Opened, line: fmt.Sprintf("opened %s by %s", when(&g.Opened), g.OpenedBy)})
says = append(says, "opened by "+g.OpenedBy+" "+when(&g.Opened))
}
if g.Worker != nil && w.holds(g.Worker.Since) && (g.Status == "diagnosing" || g.Status == "fixing") {
if merr != nil && g.Worker != nil && w.holds(g.Worker.Since) && (g.Status == "diagnosing" || g.Status == "fixing") {
s := fmt.Sprintf("→ %s by %s %s", g.Status, g.Worker.By, when(&g.Worker.Since))
evs = append(evs, event{at: g.Worker.Since, line: s})
says = append(says, s)
}
if g.Closed != nil && w.holds(*g.Closed) {
if merr != nil && g.Closed != nil && w.holds(*g.Closed) {
s := fmt.Sprintf("→ %s %s", g.Status, when(g.Closed))
if len(g.FixedBy) > 0 {
s += ", fixed by " + strings.Join(g.FixedBy, ", ")
@@ -438,3 +461,38 @@ func (k *KB) pullChanges(p *Progress, w window) []change {
}
return out
}
// trackerMove is one change of `issue-tracker.changes`: an issue opened or moved, from what to what, by whom and when.
type trackerMove struct {
Number int `json:"number"`
Kind string `json:"kind"`
From string `json:"from"`
To string `json:"to"`
By string `json:"by"`
Worker string `json:"worker"`
At time.Time `json:"at"`
}
// trackerMoves asks the tracker for every issue's moves in the window, from its own log, by issue, oldest first.
func (k *KB) trackerMoves(w window) (map[int][]trackerMove, error) {
raw, err := k.Seats(ChangesVerb, map[string]any{"since": w.to.Sub(w.from).String()})
if err != nil {
return nil, err
}
var r struct {
Changes []trackerMove `json:"changes"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("answered something else: %v", err)
}
out := map[int][]trackerMove{}
for _, m := range r.Changes {
if w.holds(m.At) {
out[m.Number] = append(out[m.Number], m)
}
}
return out, nil
}
// ChangesVerb is the tracker's verb that answers its issues' moves over a window.
const ChangesVerb = "seat:issue-tracker.changes"
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -396,8 +397,10 @@ func TestProgressOfAnIssueAndARecord(t *testing.T) {
}
// sinceWorld plays the tracker and the delivery seat from what they answered on 2026-10-28 (testdata/progress/since):
// a get or a show it holds no answer for is refused, as a seat that cannot answer.
func sinceWorld(t *testing.T) func(string, any) (json.RawMessage, error) {
// a get or a show it holds no answer for is refused, as a seat that cannot answer. The tracker's changes answers what
// mesh-issues' verb made of 1422's open and move to fixing (testdata/progress/README.md); without changes it is
// refused, as by a tracker that has no such verb yet.
func sinceWorld(t *testing.T, changes bool) func(string, any) (json.RawMessage, error) {
t.Helper()
read := func(name string) json.RawMessage { return readFixture(t, filepath.Join("since", name)) }
var rows []json.RawMessage
@@ -432,6 +435,11 @@ func sinceWorld(t *testing.T) func(string, any) (json.RawMessage, error) {
}
}
return json.Marshal(map[string]any{"rows": out, "matched": len(out)})
case "seat:issue-tracker.changes":
if changes {
return read("changes.json"), nil
}
return nil, errors.New("mesh-issues has no tool issue-tracker.changes")
case "seat:issue-tracker.get":
if a.Number == 1422 || a.Number == 1956 {
return read(fmt.Sprintf("get-%d.json", a.Number)), nil
@@ -454,7 +462,7 @@ func sinceWorld(t *testing.T) func(string, any) (json.RawMessage, error) {
// tracker's notes, links, workers and closes, its milestones' history and mesh-delivery's transitions and checks.
func TestProgressSinceAWindowSaysOnlyWhatChanged(t *testing.T) {
at := func(s string) *time.Time { v, _ := time.Parse(time.RFC3339, s); return &v }
k := &KB{Records: progressRecords(), Seats: sinceWorld(t), Now: func() time.Time { return *at("2026-10-28T11:13:30Z") }}
k := &KB{Records: progressRecords(), Seats: sinceWorld(t, true), Now: func() time.Time { return *at("2026-10-28T11:13:30Z") }}
p, err := k.Progress(ProgressAsk{Of: "issue 1422", Since: "2h"})
if err != nil {
t.Fatal(err)
@@ -464,7 +472,7 @@ func TestProgressSinceAWindowSaysOnlyWhatChanged(t *testing.T) {
t.Fatalf("%d changes, since %q:\n%s", p.Changes, p.Since, p.Tree)
}
for _, want := range []string{
"▶ 1422 " + clip("The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none", 80) + " — → fixing by fix-trunk-unknown@shanks " + when(at("2026-10-28T09:38:37Z")) +
"▶ 1422 " + clip("The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none", 80) + " — open → fixing by fix-trunk-unknown@shanks " + when(at("2026-10-28T09:38:37Z")) +
"; 9 notes, the last " + when(at("2026-10-28T11:05:21Z")) + " by shanks.node-tools",
"✓ mesh-catalog#537 (1422) opened " + when(at("2026-10-28T10:20:56Z")) + " · check pass " + when(at("2026-10-28T10:30:45Z")) + " · merged " + when(at("2026-10-28T10:34:49Z")) + ", on the machines",
"✓ mesh-delivery#94 (1126, 1422) opened ",
@@ -478,16 +486,25 @@ func TestProgressSinceAWindowSaysOnlyWhatChanged(t *testing.T) {
t.Errorf("a change outside issue 1422 or the window is said:\n%s", p.Tree)
}
w := strings.Join(p.Warnings, "\n")
for _, want := range []string{"keeps no history of an issue's moves", "keeps no time a merge reached its machines"} {
if !strings.Contains(w, want) {
t.Errorf("no %q in the warnings: %v", want, p.Warnings)
}
if !strings.Contains(w, "keeps no time a merge reached its machines") || strings.Contains(w, "issue-tracker.changes") {
t.Errorf("the warnings: %v", p.Warnings)
}
// A tracker that cannot say its moves: each is said by the status it reached, and that is said.
old := &KB{Records: progressRecords(), Seats: sinceWorld(t, false), Now: k.Now}
o, err := old.Progress(ProgressAsk{Of: "issue 1422", Since: "2h"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(o.Tree, " — → fixing by fix-trunk-unknown@shanks "+when(at("2026-10-28T09:38:37Z"))) || o.Changes != 17 ||
!strings.Contains(strings.Join(o.Warnings, "\n"), "issue-tracker.changes: mesh-issues has no tool issue-tracker.changes; a move is said by the status it reached") {
t.Errorf("without changes:\n%s\n%v", o.Tree, o.Warnings)
}
d, err := k.Progress(ProgressAsk{Of: "issue 1422", Since: "2h", Detail: DetailDetailed})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"│ ├── noted " + when(at("2026-10-28T09:38:09Z")) + " by shanks.node-tools: lead-47@shanks 2026-10-28 09:40Z.",
for _, want := range []string{"│ ├── open → fixing " + when(at("2026-10-28T09:38:37Z")) + " by shanks.node-tools, worker fix-trunk-unknown@shanks",
"│ ├── noted " + when(at("2026-10-28T09:38:09Z")) + " by shanks.node-tools: lead-47@shanks 2026-10-28 09:40Z.",
" └── merged " + when(at("2026-10-28T10:34:49Z")) + ": on the trunk its modules follow: merged there"} {
if !strings.Contains(d.Tree, want) {
t.Errorf("detailed: no %q in\n%s", want, d.Tree)
@@ -13,3 +13,7 @@ Captured for the progress verb's tests (novox/hq issue 1947), never written by h
the triage and each note's text after its first sentence or two cut), `mesh-delivery.deliveries` (its first ten
rows) and `.show` of four deliveries (the ready record, the ask and the paths cut), and `issue-tracker.milestones`
with `one` 47/6, its milestones joined by 47/L2's row from `one` 47/L2.
- `since/changes.json`: the answer of mesh-issues' `issue-tracker.changes` (window 2h at 2026-10-28 11:13:30Z) made by
the tracker's own code over its log, replaying issue 1422's open (10-20 14:36:07Z) and its move to fixing by
fix-trunk-unknown@shanks (10-28 09:38:37Z) through its verbs: the live verb was not delivered yet to capture from, and
the status 1422 moved from (open) is the replay's, as no verb read the live log then. Recapture once it is delivered.
@@ -0,0 +1,17 @@
{
"since": "2026-10-28T09:13:30Z",
"changes": [
{
"number": 1422,
"title": "The catch-up reads the trunk 'main' of repositories the catalogue follows by another branch or by a commit, and the forge holds none",
"kind": "moved",
"from": "open",
"to": "fixing",
"by": "shanks.node-tools",
"worker": "fix-trunk-unknown@shanks",
"at": "2026-10-28T09:38:37Z"
}
],
"entries-read": 4,
"said": "1 changes of status since 2026-10-28T09:13:30Z, from the tracker's log (4 entries read)."
}
+1
View File
@@ -88,6 +88,7 @@
"seat:issue-tracker.list",
"seat:issue-tracker.get",
"seat:issue-tracker.milestones",
"seat:issue-tracker.changes",
"seat:mesh-delivery.deliveries",
"seat:mesh-delivery.show",
"seat:mesh-delivery.groups"