Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/progress.go
T
jschoubben 87fe2c45cf
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…
Knowledge base: progress counts an issue done only once its fix is delivered, and says every gap (issue 1947, review round 1)
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.
2026-10-26 17:02:47 +01:00

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
}
}