mesh/delivery merged: source.main moved to novox/mesh-catalog@26908a89506f
mesh/delivery-group group feat/1947-progress merged: every member is merged
mesh/check pass 87fe2c45cfed on 26908a89506f | carried from 27a7757b970e, hq 3a5c4c7c4d9c | unit 2091 run, 2327 kept, 6 skipped | integration 2112 run…
A resolved issue whose delivery is held or failed is stuck, one merged and not delivered is moving; a row the tracker did not answer, an answer that does not read and linked issues cut by the list's limit are said as warnings instead of passing silently.
858 lines
27 KiB
Go
858 lines
27 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 through the records
|
|
// module (design.go, cached by hq's commit), 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
|
|
}
|
|
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)
|
|
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
|
|
}
|
|
d := ds[0]
|
|
p.Source = fmt.Sprintf("the milestone table of %s at hq %s (until the tracker's milestone records, issue 1781)", d.Path, short(commit))
|
|
p.Warnings = append(p.Warnings, d.Warnings...)
|
|
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
|
|
}
|
|
p.Source = fmt.Sprintf("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)
|
|
}
|
|
|
|
// fitProgress keeps an answer within its bound: the items go first, then the tree's last lines, saying what was cut.
|
|
func fitProgress(p *Progress, bound int) {
|
|
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
|
|
}
|
|
}
|