mesh/delivery merged: source.main moved to novox/mesh-catalog@9cdac09ad962
mesh/check being carried to dfe490d9e245 on 9cdac09ad962 | from 25f615456423
223 lines
7.3 KiB
Go
223 lines
7.3 KiB
Go
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)
|
|
}}
|
|
}
|