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