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