886 lines
28 KiB
Go
886 lines
28 KiB
Go
package main
|
|
|
|
// progress: how far a design, a milestone, an issue or a record has come, in one call (novox/hq issue 1947). The
|
|
// knowledge base works a design's state out and nobody writes it (ADR 0424 §1, §2); this verb says it at every
|
|
// level, in two forms: minimal, one line per child, cheap enough for every five minutes; detailed, the tree of a
|
|
// lead's report with the pull requests, the held groups and what waits on whom, for about every thirty.
|
|
//
|
|
// It keeps no state of its own. It reads, each a request and reply over the bus: the designs' titles and states through
|
|
// the records module (design.go, cached by hq's commit), their milestones from the issue tracker (milestones.go,
|
|
// issue 1781), the issues from the issue tracker (`issue-tracker.list`, whose rows
|
|
// say each issue's delivery and when it last moved), and the deliveries and groups from mesh-delivery.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The two forms of an answer.
|
|
const (
|
|
DetailMinimal = "minimal"
|
|
DetailDetailed = "detailed"
|
|
)
|
|
|
|
// The states, each with the mark a report draws.
|
|
const (
|
|
StateDone = "done"
|
|
StateMoving = "moving"
|
|
StateWaiting = "waiting"
|
|
StateStuck = "stuck"
|
|
)
|
|
|
|
var marks = map[string]string{StateDone: "✓", StateMoving: "▶", StateWaiting: "⏸", StateStuck: "✗"}
|
|
|
|
// rank orders the states from best to worst, for a parent taking its children's.
|
|
var rank = map[string]int{StateDone: 0, StateWaiting: 1, StateMoving: 2, StateStuck: 3}
|
|
|
|
// The most a detailed answer reads one by one, so one call stays one call's cost.
|
|
const (
|
|
MostShows = 30
|
|
MostGets = 20
|
|
// MostIssuesAsked is the most the tracker's list answers by numbers in one call.
|
|
MostIssuesAsked = 200
|
|
)
|
|
|
|
// SeatAsk asks another seat's verb or a module's tool over the bus and answers its result (stdio.Ask).
|
|
type SeatAsk func(key string, body any) (json.RawMessage, error)
|
|
|
|
// ProgressAsk is what progress is asked.
|
|
type ProgressAsk struct {
|
|
Of string
|
|
Detail string
|
|
Bound int
|
|
}
|
|
|
|
// Item is one thing whose progress is said: a design, a milestone, an issue, a pull request.
|
|
type Item struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
Title string `json:"title,omitempty"`
|
|
Done int `json:"done,omitempty"`
|
|
Total int `json:"total,omitempty"`
|
|
Moved *time.Time `json:"moved,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
Children []Item `json:"children,omitempty"`
|
|
}
|
|
|
|
// Progress is the answer.
|
|
type Progress struct {
|
|
Of string `json:"of"`
|
|
Detail string `json:"detail"`
|
|
State string `json:"state"`
|
|
Tree string `json:"tree"`
|
|
Items []Item `json:"items,omitempty"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
Source string `json:"source"`
|
|
ReadAt time.Time `json:"read-at"`
|
|
Cut string `json:"cut,omitempty"`
|
|
}
|
|
|
|
// trackerRow is a row of `issue-tracker.list`.
|
|
type trackerRow struct {
|
|
Number int `json:"number"`
|
|
Status string `json:"status"`
|
|
Title string `json:"title"`
|
|
Needs string `json:"needs"`
|
|
Ready bool `json:"ready"`
|
|
Delivery string `json:"delivery"`
|
|
Worker string `json:"worker"`
|
|
Since *time.Time `json:"since"`
|
|
Stale bool `json:"stale"`
|
|
Abandoned bool `json:"abandoned"`
|
|
Moved *time.Time `json:"moved"`
|
|
}
|
|
|
|
// deliveryRow is a row of `mesh-delivery.deliveries`, with the issues `mesh-delivery.show` names for it.
|
|
type deliveryRow struct {
|
|
ID string `json:"id"`
|
|
Number int `json:"number"`
|
|
State string `json:"state"`
|
|
Since time.Time `json:"since"`
|
|
Waits string `json:"waits"`
|
|
Group string `json:"group"`
|
|
Issues []int `json:"-"`
|
|
Title string `json:"-"`
|
|
}
|
|
|
|
// groupRow is a row of `mesh-delivery.groups`.
|
|
type groupRow struct {
|
|
ID string `json:"id"`
|
|
State string `json:"state"`
|
|
Why string `json:"why"`
|
|
Members []string `json:"members"`
|
|
}
|
|
|
|
var (
|
|
designOf = regexp.MustCompile(`^design\s+([0-9]{1,3})$`)
|
|
milestoneOf = regexp.MustCompile(`(?i)^milestone\s+([0-9]{1,3})/(.+)$`)
|
|
issueOf = regexp.MustCompile(`^issue\s+([0-9]{1,6})$`)
|
|
recordOf = regexp.MustCompile(`(?i)^(ADR|decision|research)\s+([0-9]{1,4})$`)
|
|
)
|
|
|
|
// Progress says how far what `of` names has come.
|
|
func (k *KB) Progress(a ProgressAsk) (*Progress, error) {
|
|
detail := strings.TrimSpace(a.Detail)
|
|
if detail == "" {
|
|
detail = DetailMinimal
|
|
}
|
|
if detail != DetailMinimal && detail != DetailDetailed {
|
|
return nil, fmt.Errorf("detail is %s or %s, not %q", DetailMinimal, DetailDetailed, a.Detail)
|
|
}
|
|
if k.Seats == nil {
|
|
return nil, errors.New("the knowledge base reaches no other seat here")
|
|
}
|
|
of := strings.Join(strings.Fields(a.Of), " ")
|
|
p := &Progress{Of: of, Detail: detail, ReadAt: k.now()}
|
|
var err error
|
|
switch {
|
|
case strings.EqualFold(of, "mesh"):
|
|
err = k.progressMesh(p)
|
|
case designOf.MatchString(strings.ToLower(of)):
|
|
n, _ := strconv.Atoi(designOf.FindStringSubmatch(strings.ToLower(of))[1])
|
|
err = k.progressDesign(p, n, "")
|
|
case milestoneOf.MatchString(of):
|
|
m := milestoneOf.FindStringSubmatch(of)
|
|
n, _ := strconv.Atoi(m[1])
|
|
err = k.progressDesign(p, n, m[2])
|
|
case issueOf.MatchString(strings.ToLower(of)):
|
|
n, _ := strconv.Atoi(issueOf.FindStringSubmatch(strings.ToLower(of))[1])
|
|
err = k.progressIssue(p, n)
|
|
case recordOf.MatchString(of):
|
|
m := recordOf.FindStringSubmatch(of)
|
|
n, _ := strconv.Atoi(m[2])
|
|
kind := "ADR"
|
|
if strings.EqualFold(m[1], "research") {
|
|
kind = "research"
|
|
}
|
|
err = k.progressRecord(p, kind, n)
|
|
default:
|
|
return nil, fmt.Errorf("of names mesh, design <n>, milestone <design>/<id> (such as milestone 47/R6), issue <n>, "+
|
|
"decision <n> (or ADR <n>) or research <n>, not %q", a.Of)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// A cut takes the warnings from the last: the calls that failed are said before the answers that did not parse.
|
|
callsFirst(p.Warnings)
|
|
fitProgress(p, boundOf(a.Bound))
|
|
return p, nil
|
|
}
|
|
|
|
// issueRows asks the tracker for these issues, in chunks it takes, and answers them by number with the numbers it
|
|
// does not know.
|
|
func (k *KB) issueRows(numbers []int) (map[int]trackerRow, []int, error) {
|
|
rows := map[int]trackerRow{}
|
|
var missing []int
|
|
for from := 0; from < len(numbers); from += MostIssuesAsked {
|
|
chunk := numbers[from:min(from+MostIssuesAsked, len(numbers))]
|
|
raw, err := k.Seats("seat:issue-tracker.list", map[string]any{"numbers": chunk, "limit": len(chunk)})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("the issue tracker does not answer: %v", err)
|
|
}
|
|
var l struct {
|
|
Rows []trackerRow `json:"rows"`
|
|
Missing []int `json:"missing"`
|
|
}
|
|
if err := json.Unmarshal(raw, &l); err != nil {
|
|
return nil, nil, fmt.Errorf("issue-tracker.list answered something else: %v", err)
|
|
}
|
|
for _, r := range l.Rows {
|
|
rows[r.Number] = r
|
|
}
|
|
missing = append(missing, l.Missing...)
|
|
}
|
|
return rows, missing, nil
|
|
}
|
|
|
|
// issueItem is one issue's state, from its tracker row (1947/AC3).
|
|
func issueItem(r trackerRow) (Item, bool) {
|
|
it := Item{ID: strconv.Itoa(r.Number), Title: r.Title, Moved: r.Moved}
|
|
if it.Moved == nil {
|
|
it.Moved = r.Since
|
|
}
|
|
held := strings.HasPrefix(r.Delivery, "held") || strings.Contains(r.Delivery, " failed")
|
|
switch {
|
|
case r.Status == "wontfix":
|
|
return it, false
|
|
case r.Status == "resolved":
|
|
// Done only when its fix is delivered, or it has nothing to deliver (ADR 0424 §2, §4; 1947/AC3).
|
|
switch d := r.Delivery; {
|
|
case d == "":
|
|
it.State, it.Why = StateDone, "resolved, nothing to deliver"
|
|
case strings.HasPrefix(d, "delivered"):
|
|
it.State, it.Why = StateDone, "resolved, delivered"
|
|
case held || strings.Contains(d, " stopped"):
|
|
it.State, it.Why = StateStuck, "resolved · "+d
|
|
case strings.HasPrefix(d, "fix merged, not delivered"):
|
|
it.State, it.Why = StateMoving, "resolved · "+d
|
|
default:
|
|
it.State, it.Why = StateWaiting, "resolved · "+d
|
|
}
|
|
case r.Abandoned || r.Stale:
|
|
it.State, it.Why = StateStuck, fmt.Sprintf("%s, worker %s stale since %s", r.Status, r.Worker, when(r.Since))
|
|
case held:
|
|
it.State, it.Why = StateStuck, r.Status+" · "+r.Delivery
|
|
case r.Status == "diagnosing" || r.Status == "fixing":
|
|
it.State, it.Why = StateMoving, fmt.Sprintf("%s since %s (%s)", r.Status, when(r.Since), r.Worker)
|
|
if r.Delivery != "" {
|
|
it.Why += " · " + r.Delivery
|
|
}
|
|
case r.Needs == "decision" || r.Needs == "operator":
|
|
it.State, it.Why = StateWaiting, r.Status+", needs "+map[string]string{"decision": "a decision", "operator": "the operator"}[r.Needs]
|
|
case !r.Ready:
|
|
it.State, it.Why = StateWaiting, r.Status+", waits for what it depends on"
|
|
default:
|
|
it.State, it.Why = StateWaiting, r.Status+", ready"
|
|
}
|
|
return it, true
|
|
}
|
|
|
|
// rollUp gives a parent its children's state: stuck when one is, else moving when one moves, else waiting when one
|
|
// waits, else done; done and total counted; moved the latest of theirs (1947/AC3).
|
|
func rollUp(parent *Item, children []Item) {
|
|
parent.State, parent.Done, parent.Total = StateDone, 0, len(children)
|
|
if len(children) == 0 {
|
|
parent.State, parent.Why = StateWaiting, "no issues yet"
|
|
return
|
|
}
|
|
worst := StateDone
|
|
for _, c := range children {
|
|
if c.State == StateDone {
|
|
parent.Done++
|
|
}
|
|
if rank[c.State] > rank[worst] {
|
|
worst = c.State
|
|
}
|
|
if c.Moved != nil && (parent.Moved == nil || c.Moved.After(*parent.Moved)) {
|
|
m := *c.Moved
|
|
parent.Moved = &m
|
|
}
|
|
}
|
|
parent.State = worst
|
|
}
|
|
|
|
// milestoneItems is each milestone of a design as an item with its issues as children.
|
|
func milestoneItems(d *Design, rows map[int]trackerRow, missing map[int]bool) ([]Item, []string) {
|
|
var items []Item
|
|
var warn []string
|
|
var unanswered []string
|
|
for _, m := range d.Milestones {
|
|
mi := Item{ID: m.ID, Title: m.Outcome}
|
|
var kids []Item
|
|
for _, n := range m.Issues {
|
|
if missing[n] {
|
|
warn = append(warn, fmt.Sprintf("milestone %s names %d, which is not an issue", m.ID, n))
|
|
continue
|
|
}
|
|
r, ok := rows[n]
|
|
if !ok {
|
|
unanswered = append(unanswered, strconv.Itoa(n))
|
|
continue
|
|
}
|
|
if it, counts := issueItem(r); counts {
|
|
kids = append(kids, it)
|
|
}
|
|
}
|
|
rollUp(&mi, kids)
|
|
if m.Status == "closed" && len(kids) == 0 {
|
|
mi.State, mi.Why = StateDone, "closed: "+m.Reason
|
|
}
|
|
mi.Children = kids
|
|
items = append(items, mi)
|
|
}
|
|
if len(unanswered) > 0 {
|
|
warn = append(warn, fmt.Sprintf("the tracker neither answered nor named missing %d issues the milestones name, left out of the counts: %s",
|
|
len(unanswered), strings.Join(unanswered, ", ")))
|
|
}
|
|
return items, warn
|
|
}
|
|
|
|
// progressDesign says a design's milestones, or with milestone one milestone's issues.
|
|
func (k *KB) progressDesign(p *Progress, number int, milestone string) error {
|
|
ds, commit, err := k.designs(false, number)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ds, err = k.withMilestones(ds, number); err != nil {
|
|
return err
|
|
}
|
|
d := ds[0]
|
|
if len(d.Milestones) == 0 {
|
|
return fmt.Errorf("design %d has no milestone records in the issue tracker: seed them from its table with "+
|
|
"issue-tracker.import milestones %d", number, number)
|
|
}
|
|
p.Source = fmt.Sprintf("the issue tracker's milestone records of design %d, and %s at hq %s", number, d.Path, short(commit))
|
|
ms := d
|
|
if milestone != "" {
|
|
m := d.milestone(milestone)
|
|
if m == nil {
|
|
var ids []string
|
|
for _, x := range d.Milestones {
|
|
ids = append(ids, x.ID)
|
|
}
|
|
return fmt.Errorf("design %d has no milestone %q; its milestones are %s", number, milestone, strings.Join(ids, ", "))
|
|
}
|
|
ms = &Design{Number: d.Number, Path: d.Path, Title: d.Title, Milestones: []Milestone{*m}}
|
|
}
|
|
rows, missingList, err := k.issueRows(ms.issues())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
missing := map[int]bool{}
|
|
for _, n := range missingList {
|
|
missing[n] = true
|
|
}
|
|
items, warn := milestoneItems(ms, rows, missing)
|
|
p.Warnings = append(p.Warnings, warn...)
|
|
var extra *extras
|
|
if p.Detail == DetailDetailed {
|
|
if extra, err = k.readExtras(items); err != nil {
|
|
return err
|
|
}
|
|
p.Warnings = append(p.Warnings, extra.warnings...)
|
|
}
|
|
top := Item{ID: fmt.Sprintf("design %d", d.Number), Title: d.Title}
|
|
rollUp(&top, items)
|
|
top.Children = items
|
|
if milestone != "" {
|
|
top = items[0]
|
|
top.ID = fmt.Sprintf("milestone %d/%s", d.Number, items[0].ID)
|
|
}
|
|
p.State = top.State
|
|
p.Items = []Item{top}
|
|
p.Tree = renderTree(top, p.Detail, extra, milestone != "")
|
|
return nil
|
|
}
|
|
|
|
// progressMesh says every design being built: those with milestones one line each, the rest counted.
|
|
func (k *KB) progressMesh(p *Progress) error {
|
|
ds, commit, err := k.designs(true, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ds, err = k.withMilestones(ds, 0); err != nil {
|
|
return err
|
|
}
|
|
p.Source = fmt.Sprintf("the issue tracker's milestone records, and the designs in %s at hq %s", DesignsFolder, short(commit))
|
|
var with []*Design
|
|
var without []string
|
|
for _, d := range ds {
|
|
if d.Status != "in-progress" {
|
|
continue
|
|
}
|
|
if len(d.Milestones) == 0 {
|
|
without = append(without, fmt.Sprintf("%02d", d.Number))
|
|
continue
|
|
}
|
|
with = append(with, d)
|
|
}
|
|
var numbers []int
|
|
for _, d := range with {
|
|
numbers = append(numbers, d.issues()...)
|
|
}
|
|
rows, missingList, err := k.issueRows(numbers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
missing := map[int]bool{}
|
|
for _, n := range missingList {
|
|
missing[n] = true
|
|
}
|
|
top := Item{ID: "mesh", Title: "the designs being built"}
|
|
var kids []Item
|
|
for _, d := range with {
|
|
items, _ := milestoneItems(d, rows, missing)
|
|
di := Item{ID: fmt.Sprintf("design %d", d.Number), Title: d.Title}
|
|
rollUp(&di, items)
|
|
if p.Detail == DetailDetailed {
|
|
di.Children = items
|
|
}
|
|
kids = append(kids, di)
|
|
}
|
|
rollUp(&top, kids)
|
|
top.Children = kids
|
|
p.State = top.State
|
|
p.Items = []Item{top}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "The mesh · %d designs in progress, %d with milestones\n", len(with)+len(without), len(with))
|
|
for i, di := range kids {
|
|
branch := "├── "
|
|
if i == len(kids)-1 && len(without) == 0 {
|
|
branch = "└── "
|
|
}
|
|
fmt.Fprintf(&b, "%s%s %s · %s — %d of %d milestones done%s\n", branch, marks[di.State], di.ID, di.Title, di.Done, di.Total, movedSay(di.Moved))
|
|
}
|
|
if len(without) > 0 {
|
|
fmt.Fprintf(&b, "└── %d designs in progress name no milestones (issue 845): %s\n", len(without), strings.Join(without, ", "))
|
|
}
|
|
p.Tree = strings.TrimRight(b.String(), "\n")
|
|
return nil
|
|
}
|
|
|
|
// progressIssue says one issue: its state, its pull requests and their deliveries, what it waits for.
|
|
func (k *KB) progressIssue(p *Progress, n int) error {
|
|
rows, missing, err := k.issueRows([]int{n})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r, ok := rows[n]
|
|
if len(missing) > 0 || !ok {
|
|
return fmt.Errorf("%d is not an issue the tracker holds", n)
|
|
}
|
|
it, counts := issueItem(r)
|
|
if !counts {
|
|
it.State, it.Why = StateDone, "wontfix"
|
|
}
|
|
it.ID = fmt.Sprintf("issue %d", n)
|
|
p.Source = "the issue tracker and mesh-delivery"
|
|
extra, err := k.readExtras([]Item{{Children: []Item{{ID: strconv.Itoa(n), State: StateMoving}}}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Warnings = append(p.Warnings, extra.warnings...)
|
|
p.State = it.State
|
|
p.Items = []Item{it}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%s issue %d · %s — %s%s\n", marks[it.State], n, it.Title, it.Why, movedSay(it.Moved))
|
|
if g := extra.gets[n]; g != nil {
|
|
fmt.Fprintf(&b, "├── %d notes; last %s\n", g.notes, g.last)
|
|
if len(g.waitsFor) > 0 {
|
|
fmt.Fprintf(&b, "├── depends on %s\n", strings.Join(g.waitsFor, ", "))
|
|
}
|
|
}
|
|
lines := extra.pullLines(n)
|
|
if len(lines) == 0 {
|
|
b.WriteString("└── no pull request open or ended in the last day names it\n")
|
|
}
|
|
for i, l := range lines {
|
|
branch := "├── "
|
|
if i == len(lines)-1 {
|
|
branch = "└── "
|
|
}
|
|
b.WriteString(branch + l + "\n")
|
|
}
|
|
p.Tree = strings.TrimRight(b.String(), "\n")
|
|
return nil
|
|
}
|
|
|
|
// progressRecord says what builds a decision record or a research effort: the designs that name it and the issues
|
|
// linked to it.
|
|
func (k *KB) progressRecord(p *Progress, kind string, n int) error {
|
|
key := Key{KindDecision, n}
|
|
name := fmt.Sprintf("ADR %04d", n)
|
|
if kind == "research" {
|
|
key, name = Key{KindResearch, n}, fmt.Sprintf("research %03d", n)
|
|
}
|
|
g, err := k.Get(GetAsk{Kind: key.Kind, Number: key.Number, Bound: LeastBound})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ds, commit, err := k.designs(true, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Source = fmt.Sprintf("the knowledge base, the designs at hq %s and the issue tracker's links", short(commit))
|
|
var designs []string
|
|
for _, d := range ds {
|
|
for _, dn := range d.Decisions {
|
|
if dn == name {
|
|
designs = append(designs, fmt.Sprintf("design %d (%s)", d.Number, d.Status))
|
|
}
|
|
}
|
|
}
|
|
var kids []Item
|
|
if kind == "ADR" {
|
|
raw, err := k.Seats("seat:issue-tracker.list", map[string]any{"linked": fmt.Sprintf("decision:%04d", n), "limit": MostIssuesAsked})
|
|
if err != nil {
|
|
return fmt.Errorf("the issue tracker does not answer: %v", err)
|
|
}
|
|
var l struct {
|
|
Rows []trackerRow `json:"rows"`
|
|
Matched int `json:"matched"`
|
|
}
|
|
if err := json.Unmarshal(raw, &l); err != nil {
|
|
return fmt.Errorf("issue-tracker.list answered something else: %v", err)
|
|
}
|
|
if l.Matched > len(l.Rows) {
|
|
p.Warnings = append(p.Warnings, fmt.Sprintf("%d issues are linked to %s; the first %d are counted", l.Matched, name, len(l.Rows)))
|
|
}
|
|
for _, r := range l.Rows {
|
|
if it, counts := issueItem(r); counts {
|
|
kids = append(kids, it)
|
|
}
|
|
}
|
|
}
|
|
top := Item{ID: name, Title: g.Title}
|
|
rollUp(&top, kids)
|
|
top.Children = kids
|
|
if len(kids) == 0 {
|
|
top.Why = "no issue is linked to it"
|
|
}
|
|
p.State = top.State
|
|
p.Items = []Item{top}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%s · %s — %s\n", name, g.Title, g.Status)
|
|
if len(designs) == 0 {
|
|
b.WriteString("├── no design names it\n")
|
|
} else {
|
|
fmt.Fprintf(&b, "├── named by %s\n", strings.Join(designs, ", "))
|
|
}
|
|
if kind != "ADR" {
|
|
b.WriteString("└── issues link decisions, not research efforts: see what it became (knowledge-base.links)\n")
|
|
} else if len(kids) == 0 {
|
|
b.WriteString("└── no issue is linked to it (issue-tracker.link, kind decision)\n")
|
|
} else {
|
|
fmt.Fprintf(&b, "└── %s %d of %d linked issues done%s\n", marks[top.State], top.Done, top.Total, movedSay(top.Moved))
|
|
if p.Detail == DetailDetailed {
|
|
for i, c := range kids {
|
|
branch := " ├── "
|
|
if i == len(kids)-1 {
|
|
branch = " └── "
|
|
}
|
|
fmt.Fprintf(&b, "%s%s %s %s — %s%s\n", branch, marks[c.State], c.ID, c.Title, c.Why, movedSay(c.Moved))
|
|
}
|
|
}
|
|
}
|
|
p.Tree = strings.TrimRight(b.String(), "\n")
|
|
return nil
|
|
}
|
|
|
|
// extras is what a detailed answer reads beyond the rows: the open deliveries with their issues, the groups, and
|
|
// each issue not done read whole.
|
|
type extras struct {
|
|
deliveries []deliveryRow
|
|
groups map[string]groupRow
|
|
gets map[int]*issueGot
|
|
warnings []string
|
|
}
|
|
|
|
type issueGot struct {
|
|
notes int
|
|
last string
|
|
waitsFor []string
|
|
}
|
|
|
|
// final says a delivery's state says nothing more will happen to it.
|
|
func final(state string) bool {
|
|
return state == "merged" || state == "closed" || state == "delivered"
|
|
}
|
|
|
|
// readExtras reads the deliveries not final with the issues each names, the groups, and the issues not done.
|
|
func (k *KB) readExtras(items []Item) (*extras, error) {
|
|
x := &extras{groups: map[string]groupRow{}, gets: map[int]*issueGot{}}
|
|
raw, err := k.Seats("seat:mesh-delivery.deliveries", map[string]any{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mesh-delivery does not answer: %v", err)
|
|
}
|
|
var dl struct {
|
|
Deliveries []deliveryRow `json:"deliveries"`
|
|
}
|
|
if err := json.Unmarshal(raw, &dl); err != nil {
|
|
return nil, fmt.Errorf("mesh-delivery.deliveries answered something else: %v", err)
|
|
}
|
|
shown := 0
|
|
for _, d := range dl.Deliveries {
|
|
if final(d.State) {
|
|
continue
|
|
}
|
|
if shown == MostShows {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("more than %d deliveries are open; 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 {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("mesh-delivery.show %s: %v", d.ID, err))
|
|
continue
|
|
}
|
|
var s struct {
|
|
Delivery struct {
|
|
Issues []int `json:"issues"`
|
|
Title string `json:"title"`
|
|
} `json:"delivery"`
|
|
}
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("mesh-delivery.show %s answered something else: %v", d.ID, err))
|
|
}
|
|
d.Issues, d.Title = s.Delivery.Issues, s.Delivery.Title
|
|
x.deliveries = append(x.deliveries, d)
|
|
}
|
|
raw, err = k.Seats("seat:mesh-delivery.groups", map[string]any{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mesh-delivery does not answer: %v", err)
|
|
}
|
|
var gs []groupRow
|
|
if err := json.Unmarshal(raw, &gs); err != nil {
|
|
return nil, fmt.Errorf("mesh-delivery.groups answered something else: %v", err)
|
|
}
|
|
for _, g := range gs {
|
|
x.groups[g.ID] = g
|
|
}
|
|
got := 0
|
|
for _, m := range items {
|
|
for _, c := range m.Children {
|
|
if c.State == StateDone {
|
|
continue
|
|
}
|
|
n, _ := strconv.Atoi(c.ID)
|
|
if got == MostGets {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("more than %d issues are not done; the rest are not read whole", MostGets))
|
|
return x, nil
|
|
}
|
|
got++
|
|
raw, err := k.Seats("seat:issue-tracker.get", map[string]any{"number": n, "brief": true})
|
|
if err != nil {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("issue-tracker.get %d: %v", n, err))
|
|
continue
|
|
}
|
|
var g struct {
|
|
Notes []struct {
|
|
At time.Time `json:"at"`
|
|
By string `json:"by"`
|
|
Text string `json:"text"`
|
|
} `json:"notes"`
|
|
Left int `json:"notes-left-out"`
|
|
Links []struct {
|
|
Kind string `json:"kind"`
|
|
Target string `json:"target"`
|
|
} `json:"links"`
|
|
}
|
|
if err := json.Unmarshal(raw, &g); err != nil {
|
|
x.warnings = append(x.warnings, fmt.Sprintf("issue-tracker.get %d answered something else: %v", n, err))
|
|
continue
|
|
}
|
|
ig := &issueGot{notes: len(g.Notes) + g.Left, last: "none"}
|
|
if len(g.Notes) > 0 {
|
|
last := g.Notes[len(g.Notes)-1]
|
|
ig.last = fmt.Sprintf("%s by %s", when(&last.At), last.By)
|
|
}
|
|
for _, l := range g.Links {
|
|
if l.Kind == "depends-on" {
|
|
ig.waitsFor = append(ig.waitsFor, l.Target)
|
|
}
|
|
}
|
|
x.gets[n] = ig
|
|
}
|
|
}
|
|
return x, nil
|
|
}
|
|
|
|
// pullLines is one line per open delivery naming issue n, and per group it is in.
|
|
func (x *extras) pullLines(n int) []string {
|
|
if x == nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
groups := map[string]bool{}
|
|
for _, d := range x.deliveries {
|
|
named := false
|
|
for _, i := range d.Issues {
|
|
if i == n {
|
|
named = true
|
|
}
|
|
}
|
|
if !named {
|
|
continue
|
|
}
|
|
state := StateWaiting
|
|
if strings.HasPrefix(d.State, "held") || strings.Contains(d.Waits, "a person") {
|
|
state = StateStuck
|
|
} else if d.State == "checking" || d.State == "merging" || d.State == "delivering" {
|
|
state = StateMoving
|
|
}
|
|
line := fmt.Sprintf("%s %s %s since %s", marks[state], d.ID, d.State, when(&d.Since))
|
|
if d.Waits != "" {
|
|
line += ", waits for " + d.Waits
|
|
}
|
|
out = append(out, line)
|
|
if d.Group != "" {
|
|
groups[d.Group] = true
|
|
}
|
|
}
|
|
var gids []string
|
|
for g := range groups {
|
|
gids = append(gids, g)
|
|
}
|
|
sort.Strings(gids)
|
|
for _, id := range gids {
|
|
g, ok := x.groups[id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
mark := marks[StateMoving]
|
|
if g.State == "held" {
|
|
mark = marks[StateStuck]
|
|
}
|
|
out = append(out, fmt.Sprintf("%s group %s %s: %s", mark, g.ID, g.State, g.Why))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderTree draws a design's or a milestone's tree: minimal, one line per milestone with the done ones folded into
|
|
// one line; detailed, each milestone not done with its issues, their pull requests and groups.
|
|
func renderTree(top Item, detail string, x *extras, oneMilestone bool) string {
|
|
var b strings.Builder
|
|
if oneMilestone {
|
|
fmt.Fprintf(&b, "%s %s · %s — %d of %d done%s\n", marks[top.State], top.ID, top.Title, top.Done, top.Total, movedSay(top.Moved))
|
|
writeIssues(&b, "", top.Children, detail, x)
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
fmt.Fprintf(&b, "%s %s · %s — %d of %d milestones done%s\n", marks[top.State], top.ID, top.Title, top.Done, top.Total, movedSay(top.Moved))
|
|
var done []string
|
|
issuesDone := 0
|
|
var open []Item
|
|
for _, m := range top.Children {
|
|
if m.State == StateDone {
|
|
done = append(done, m.ID)
|
|
issuesDone += m.Total
|
|
continue
|
|
}
|
|
open = append(open, m)
|
|
}
|
|
if len(done) > 0 {
|
|
branch := "├── "
|
|
if len(open) == 0 {
|
|
branch = "└── "
|
|
}
|
|
fmt.Fprintf(&b, "%s%s %d milestones done (%d issues): %s\n", branch, marks[StateDone], len(done), issuesDone, strings.Join(done, ", "))
|
|
}
|
|
for i, m := range open {
|
|
branch, under := "├── ", "│ "
|
|
if i == len(open)-1 {
|
|
branch, under = "└── ", " "
|
|
}
|
|
left := notDone(m.Children)
|
|
if m.Total == 0 {
|
|
left = m.Why
|
|
}
|
|
if detail == DetailMinimal {
|
|
fmt.Fprintf(&b, "%s%s %s %d/%d %s%s\n", branch, marks[m.State], m.ID, m.Done, m.Total, left, movedSay(m.Moved))
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "%s%s %s · %s — %d of %d done%s\n", branch, marks[m.State], m.ID, clip(m.Title, 90), m.Done, m.Total, movedSay(m.Moved))
|
|
writeIssues(&b, under, m.Children, detail, x)
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
// writeIssues draws a milestone's issues: minimal, those not done; detailed, every issue, and under each not done
|
|
// its pull requests, groups and what it depends on.
|
|
func writeIssues(b *strings.Builder, indent string, kids []Item, detail string, x *extras) {
|
|
var show []Item
|
|
for _, c := range kids {
|
|
if detail == DetailDetailed || c.State != StateDone {
|
|
show = append(show, c)
|
|
}
|
|
}
|
|
for i, c := range show {
|
|
branch, under := "├── ", "│ "
|
|
if i == len(show)-1 {
|
|
branch, under = "└── ", " "
|
|
}
|
|
fmt.Fprintf(b, "%s%s%s %s %s — %s%s\n", indent, branch, marks[c.State], c.ID, clip(c.Title, 70), c.Why, movedSay(c.Moved))
|
|
if detail != DetailDetailed || c.State == StateDone || x == nil {
|
|
continue
|
|
}
|
|
n, _ := strconv.Atoi(c.ID)
|
|
var lines []string
|
|
if g := x.gets[n]; g != nil {
|
|
if len(g.waitsFor) > 0 {
|
|
lines = append(lines, "depends on "+strings.Join(g.waitsFor, ", "))
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%d notes; last %s", g.notes, g.last))
|
|
}
|
|
lines = append(lines, x.pullLines(n)...)
|
|
for j, l := range lines {
|
|
br := "├── "
|
|
if j == len(lines)-1 {
|
|
br = "└── "
|
|
}
|
|
fmt.Fprintf(b, "%s%s%s%s\n", indent, under, br, l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// notDone is a short say of the issues of a milestone not done, for its one line.
|
|
func notDone(kids []Item) string {
|
|
var parts []string
|
|
for _, c := range kids {
|
|
if c.State != StateDone {
|
|
parts = append(parts, c.ID+" "+c.Why)
|
|
}
|
|
}
|
|
if len(parts) > 3 {
|
|
return fmt.Sprintf("%s; and %d more", strings.Join(parts[:3], "; "), len(parts)-3)
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
// when is a time as the operator reads it: local, its day and its hour.
|
|
func when(t *time.Time) string {
|
|
if t == nil || t.IsZero() {
|
|
return "an unknown time"
|
|
}
|
|
return t.Local().Format("01-02 15:04")
|
|
}
|
|
|
|
func movedSay(t *time.Time) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
return " · moved " + when(t)
|
|
}
|
|
|
|
// callsFirst puts the warnings of calls that failed before those of answers that did not parse, in place.
|
|
func callsFirst(w []string) {
|
|
sort.SliceStable(w, func(i, j int) bool {
|
|
return !strings.Contains(w[i], " answered something else") && strings.Contains(w[j], " answered something else")
|
|
})
|
|
}
|
|
|
|
// fitProgress keeps an answer within its bound: the items go first, then the tree's last lines, saying what was cut.
|
|
// A minimal answer never carries the items: its tree says the same, one line per milestone, so leaving them out is how
|
|
// the minimal form is made (the items of design 47 alone passed 8192 bytes) and no cut. A detailed answer's items, and
|
|
// everything else that goes, are said in cut.
|
|
func fitProgress(p *Progress, bound int) {
|
|
if p.Detail == DetailMinimal && p.Tree != "" {
|
|
p.Items = nil
|
|
}
|
|
if size(p) <= bound {
|
|
return
|
|
}
|
|
const raise = ": raise bound (at most 65536) or ask a smaller of, such as one milestone"
|
|
p.Cut = "the items, some warnings and the tree's last lines" + raise
|
|
var cut []string
|
|
if len(p.Items) > 0 {
|
|
p.Items = nil
|
|
cut = append(cut, "the items (the tree says the same)")
|
|
}
|
|
warned := len(p.Warnings)
|
|
for size(p) > bound && len(p.Warnings) > 0 {
|
|
p.Warnings = p.Warnings[:len(p.Warnings)-1]
|
|
}
|
|
if len(p.Warnings) < warned {
|
|
cut = append(cut, fmt.Sprintf("the last %d of %d warnings", warned-len(p.Warnings), warned))
|
|
}
|
|
lines := strings.Split(p.Tree, "\n")
|
|
dropped := 0
|
|
for size(p) > bound && len(lines) > 1 {
|
|
lines = lines[:len(lines)-1]
|
|
dropped++
|
|
p.Tree = strings.Join(lines, "\n")
|
|
}
|
|
if dropped > 0 {
|
|
cut = append(cut, fmt.Sprintf("the tree's last %d lines", dropped))
|
|
}
|
|
if exact := strings.Join(cut, "; ") + raise; len(exact) <= len(p.Cut) {
|
|
p.Cut = exact
|
|
}
|
|
}
|