Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/progress_since.go
T
jschoubben dfe490d9e2
mesh/delivery merged: source.main moved to novox/mesh-catalog@9cdac09ad962
mesh/check being carried to dfe490d9e245 on 9cdac09ad962 | from 25f615456423
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
2026-10-28 12:33:01 +01:00

499 lines
17 KiB
Go

package main
// progress with since: only what changed in a window under of (the operator, 2026-10-28: "can the progress tool also
// report the progress made since the last call, or maybe better, the last 60 minutes"). A window, not a caller's last
// 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`).
//
// 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"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// MostSince is the longest window since asks: the deliveries mesh-delivery lists end a day back.
const MostSince = 24 * time.Hour
// parseSince reads a window such as 60m, 5m or 2h; empty is none.
func parseSince(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 || d > MostSince {
return 0, fmt.Errorf("since is a window such as 60m, 5m or 2h, more than nothing and at most %s, not %q", "24h", s)
}
return d, nil
}
// sinceScope is what an answer about a window is about: the issues under of, as the tracker answered their rows, and
// the milestones; every says that of is the mesh, so every delivery counts.
type sinceScope struct {
rows map[int]trackerRow
milestones []sinceMilestone
every bool
}
type sinceMilestone struct {
design int
m Milestone
}
// hold keeps the rows and the designs' milestones for a window's answer; on a nil scope it does nothing.
func (s *sinceScope) hold(rows map[int]trackerRow, ds ...*Design) {
if s == nil {
return
}
for n, r := range rows {
s.rows[n] = r
}
for _, d := range ds {
for _, m := range d.Milestones {
s.milestones = append(s.milestones, sinceMilestone{design: d.Number, m: m})
}
}
}
// change is one thing that changed in the window, with its changes, newest last.
type change struct {
line string
last time.Time
events []event
}
type event struct {
at time.Time
line string
// brief is the event in the change's one line, when it differs from line.
brief string
}
// window is the time between from and to.
type window struct{ from, to time.Time }
func (w window) holds(t time.Time) bool { return !t.IsZero() && !t.Before(w.from) && !t.After(w.to) }
// progressSince replaces the answer's tree with what changed in the window under p.scope.
func (k *KB) progressSince(p *Progress, d time.Duration) {
w := window{from: p.ReadAt.Add(-d), to: p.ReadAt}
p.Since, p.Items = d.String(), nil
var milestones, issues, pulls []change
milestones = k.milestoneChanges(p, w)
issues = k.issueChanges(p, w)
pulls = k.pullChanges(p, w)
for _, cs := range [][]change{milestones, issues, pulls} {
sort.SliceStable(cs, func(i, j int) bool { return cs[i].last.After(cs[j].last) })
}
all := append(append(milestones, issues...), pulls...)
for _, c := range all {
p.Changes += len(c.events)
}
var b strings.Builder
noun := "changes"
if p.Changes == 1 {
noun = "change"
}
fmt.Fprintf(&b, "%d %s in the last %d min under %s (since %s)\n", p.Changes, noun, int(d.Minutes()), p.Of, when(&w.from))
for i, c := range all {
branch, under := "├── ", "│ "
if i == len(all)-1 {
branch, under = "└── ", " "
}
b.WriteString(branch + c.line + "\n")
if p.Detail != DetailDetailed {
continue
}
for j, e := range c.events {
br := "├── "
if j == len(c.events)-1 {
br = "└── "
}
b.WriteString(under + br + e.line + "\n")
}
}
p.Tree = strings.TrimRight(b.String(), "\n")
p.Source += "; the window from the tracker's milestones and their history, its issues' notes, links, workers and " +
"closes, and mesh-delivery's transitions and checks"
}
// milestoneChanges is each milestone moved in the window, from its history: what it was and what it is.
func (k *KB) milestoneChanges(p *Progress, w window) []change {
var out []change
asked := 0
for _, sm := range p.scope.milestones {
m := sm.m
if m.Moved == nil || !w.holds(m.Moved.At) {
continue
}
ref := fmt.Sprintf("%d/%s", sm.design, m.ID)
now := m.Status
if m.Reason != "" {
now += ": " + m.Reason
}
was := "a state its history does not say"
if asked < MostGets {
asked++
if prev, err := k.milestoneBefore(ref); err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("issue-tracker.milestones one %s: %v; said without what it was", ref, err))
} else if prev != "" {
was = prev
}
}
mark := marks[StateMoving]
if m.Status == "closed" {
mark = marks[StateDone]
}
line := fmt.Sprintf("%s milestone %s %s → %s · by %s %s", mark, ref, was, now, m.Moved.By, when(&m.Moved.At))
out = append(out, change{line: line, last: m.Moved.At, events: []event{{at: m.Moved.At, line: fmt.Sprintf("moved %s by %s", when(&m.Moved.At), m.Moved.By)}}})
}
return out
}
// milestoneBefore is a milestone's status before its last move, from the tracker's history of it.
func (k *KB) milestoneBefore(ref string) (string, error) {
raw, err := k.Seats(MilestonesVerb, map[string]any{"one": ref})
if err != nil {
return "", err
}
var r struct {
History []struct {
Status string `json:"status"`
} `json:"history"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("answered something else: %v", err)
}
if len(r.History) < 2 {
return "", nil
}
return r.History[len(r.History)-2].Status, nil
}
// issueGotWhole is the part of `issue-tracker.get` a window reads.
type issueGotWhole struct {
Status string `json:"status"`
Opened time.Time `json:"opened"`
OpenedBy string `json:"opened-by"`
Closed *time.Time `json:"closed"`
FixedBy []string `json:"fixed-by"`
Worker *struct {
By string `json:"by"`
Since time.Time `json:"since"`
} `json:"worker"`
Notes []struct {
At time.Time `json:"at"`
By string `json:"by"`
Text string `json:"text"`
} `json:"notes"`
Links []struct {
At time.Time `json:"at"`
By string `json:"by"`
Kind string `json:"kind"`
Target string `json:"target"`
} `json:"links"`
}
// issueChanges is each issue under of that moved in the window, read whole: opened, moved, noted, linked, closed.
func (k *KB) issueChanges(p *Progress, w window) []change {
var moved []trackerRow
for _, r := range p.scope.rows {
if r.Moved != nil && w.holds(*r.Moved) {
moved = append(moved, r)
}
}
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 {
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 {
it, _ := issueItem(r)
if it.State == "" {
it.State = StateDone
}
head := fmt.Sprintf("%s %d %s — ", marks[it.State], r.Number, clip(r.Title, 80))
fallback := func(why string) {
p.Warnings = append(p.Warnings, why)
line := fmt.Sprintf("moved %s, now %s", when(r.Moved), r.Status)
if r.Worker != "" {
line += " (" + r.Worker + ")"
}
out = append(out, change{line: head + line, last: *r.Moved, events: []event{{at: *r.Moved, line: line}}})
}
if i >= MostGets {
fallback(fmt.Sprintf("more than %d issues moved; the rest are said by their rows' moved time", MostGets))
continue
}
raw, err := k.Seats("seat:issue-tracker.get", map[string]any{"number": r.Number})
if err != nil {
fallback(fmt.Sprintf("issue-tracker.get %d: %v; said by its row's moved time", r.Number, err))
continue
}
var g issueGotWhole
if err := json.Unmarshal(raw, &g); err != nil {
fallback(fmt.Sprintf("issue-tracker.get %d answered something else: %v; said by its row's moved time", r.Number, err))
continue
}
var evs []event
var says []string
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 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 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, ", ")
}
evs = append(evs, event{at: *g.Closed, line: s})
says = append(says, s)
}
notes := 0
var last string
for _, n := range g.Notes {
if w.holds(n.At) {
notes++
last = fmt.Sprintf("the last %s by %s", when(&n.At), n.By)
evs = append(evs, event{at: n.At, line: fmt.Sprintf("noted %s by %s: %s", when(&n.At), n.By, clip(n.Text, 80))})
}
}
if notes == 1 {
says = append(says, "1 note, "+last)
} else if notes > 1 {
says = append(says, fmt.Sprintf("%d notes, %s", notes, last))
}
links := 0
for _, l := range g.Links {
if w.holds(l.At) {
links++
evs = append(evs, event{at: l.At, line: fmt.Sprintf("linked %s %s by %s %s", l.Kind, l.Target, l.By, when(&l.At))})
}
}
if links > 0 {
says = append(says, fmt.Sprintf("%d links", links))
}
if len(evs) == 0 {
// It moved by what the tracker records of nobody: its fix's delivery said a new state.
s := fmt.Sprintf("moved %s, now %s", when(r.Moved), it.Why)
evs, says = []event{{at: *r.Moved, line: s}}, []string{s}
}
if it.State == StateStuck {
says = append(says, "stuck: "+it.Why)
}
sort.SliceStable(evs, func(i, j int) bool { return evs[i].at.Before(evs[j].at) })
out = append(out, change{line: head + strings.Join(says, "; "), last: *r.Moved, events: evs})
}
return out
}
// deliveryWhole is the part of `mesh-delivery.show` a window reads.
type deliveryWhole struct {
Delivery struct {
Repository string `json:"repository"`
Number int `json:"number"`
Issues []int `json:"issues"`
State string `json:"state"`
Check *struct {
At time.Time `json:"at"`
Gate string `json:"gate"`
Summary string `json:"summary"`
} `json:"check"`
Transitions []struct {
At time.Time `json:"at"`
Event string `json:"event"`
To string `json:"to"`
Why string `json:"why"`
} `json:"transitions"`
} `json:"delivery"`
Machines string `json:"machines"`
Waits string `json:"waits"`
}
// pullChanges is each pull request naming an issue under of (every one, for the mesh) that was opened, checked,
// merged, held or closed in the window.
func (k *KB) pullChanges(p *Progress, w window) []change {
raw, err := k.Seats("seat:mesh-delivery.deliveries", map[string]any{})
if err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery does not answer, so no pull request is said: %v", err))
return nil
}
var dl struct {
Deliveries []deliveryRow `json:"deliveries"`
}
if err := json.Unmarshal(raw, &dl); err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.deliveries answered something else, so no pull request is said: %v", err))
return nil
}
var out []change
shown, reached := 0, false
for _, d := range dl.Deliveries {
// A delivery's since is its last transition; one not final may still have been checked since.
if final(d.State) && !w.holds(d.Since) {
continue
}
if shown == MostShows {
p.Warnings = append(p.Warnings, fmt.Sprintf("more than %d deliveries moved; the rest are not read", MostShows))
break
}
shown++
raw, err := k.Seats("seat:mesh-delivery.show", map[string]any{"id": d.ID})
if err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.show %s: %v", d.ID, err))
continue
}
var s deliveryWhole
if err := json.Unmarshal(raw, &s); err != nil {
p.Warnings = append(p.Warnings, fmt.Sprintf("mesh-delivery.show %s answered something else: %v", d.ID, err))
continue
}
under := p.scope.every
var names []string
for _, n := range s.Delivery.Issues {
names = append(names, strconv.Itoa(n))
if _, ok := p.scope.rows[n]; ok {
under = true
}
}
if !under {
continue
}
var evs []event
for _, t := range s.Delivery.Transitions {
if !w.holds(t.At) {
continue
}
switch {
case t.Event == "announced":
evs = append(evs, event{at: t.At, line: "opened " + when(&t.At)})
case t.To == "merged" || t.To == "closed" || strings.HasPrefix(t.To, "held") || t.To == "delivered":
evs = append(evs, event{t.At, fmt.Sprintf("%s %s: %s", t.To, when(&t.At), clip(t.Why, 80)), t.To + " " + when(&t.At)})
}
}
if c := s.Delivery.Check; c != nil && w.holds(c.At) {
evs = append(evs, event{c.At, fmt.Sprintf("check %s %s: %s", c.Gate, when(&c.At), clip(c.Summary, 80)), fmt.Sprintf("check %s %s", c.Gate, when(&c.At))})
}
if len(evs) == 0 {
continue
}
sort.SliceStable(evs, func(i, j int) bool { return evs[i].at.Before(evs[j].at) })
var says []string
for _, e := range evs {
if e.brief != "" {
says = append(says, e.brief)
} else {
says = append(says, e.line)
}
}
state := s.Delivery.State
mark := marks[StateMoving]
switch {
case state == "merged" || state == "delivered":
mark = marks[StateDone]
case strings.HasPrefix(state, "held"):
mark = marks[StateStuck]
case state == "closed":
mark = marks[StateWaiting]
}
repo := s.Delivery.Repository
if i := strings.LastIndex(repo, "/"); i >= 0 {
repo = repo[i+1:]
}
line := fmt.Sprintf("%s %s#%d (%s) %s", mark, repo, s.Delivery.Number, strings.Join(names, ", "), strings.Join(says, " · "))
switch {
case state == "merged" && s.Machines != "":
line += ", " + s.Machines
reached = true
case !final(state):
line += ", now " + state
if s.Waits != "" {
line += ", waits for " + s.Waits
}
}
out = append(out, change{line: line, last: evs[len(evs)-1].at, events: evs})
}
if reached {
p.Warnings = append(p.Warnings, "mesh-delivery keeps no time a merge reached its machines: \"on the machines\" is as it reads now")
}
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"