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) }} }