Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/milestone.go
T
jschoubben 76e4ec4dc6
mesh/delivery merged: source.main moved to novox/mesh-catalog@5a4ac4991395
mesh/delivery-group group feat/1781-milestone-records merged: every member is merged
mesh/check checking 76e4ec4dc680 on 26908a89506f | novox/mesh-catalog#506 in group feat/1781-milestone-records
mesh-issues and mesh-knowledge: milestones are records of the issue tracker, and progress reads them (ADR 0493 §1 to §4, issue 1781)
mesh-issues keeps each design's milestone in the state milestones (history 5), says milestone-set and
milestone-moved, and serves milestones, milestone-set and milestone-move. An issue's milestone is its own link
of kind milestone, so at most one milestone per issue is checked inside the issue's own write; kind commit
links a fixing commit. The triage links no milestone, pull request or commit. import with milestones=N seeds
a design's table once. mesh-knowledge's progress reads issue-tracker.milestones and its table parse is gone.

Squashed from four commits so the branch history does not carry a compiled bundle that was committed by
mistake (6 MB); the path is now in .gitignore.
2026-10-26 18:42:51 +01:00

442 lines
14 KiB
Go

package main
// Milestones (novox/hq ADR 0493 §1 to §4, issue 1781, to-be 53 §13): each row of a design's milestone table is a record
// of the tracker, kept in the state `milestones` under its design's number and its id. An issue's milestone is the
// issue's own link of kind `milestone`, written in the issue's log like any link, so "an issue belongs to at most one
// milestone" (§2) is checked inside that issue's one write. A milestone's issues are read from the issues kept.
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
const (
// MilestonesState keeps one milestone per key, named by MilestoneKey.
MilestonesState = "milestones"
// EventMilestoneSet is said when a milestone is made or changed, or said again on request.
EventMilestoneSet = "milestone-set"
// EventMilestoneMoved is said when a milestone's status changes.
EventMilestoneMoved = "milestone-moved"
MilestoneOpen = "open"
MilestoneClosed = "closed"
// MostOutcome is the longest outcome a milestone keeps.
MostOutcome = 2048
// MostMilestones is the most milestones one list answers.
MostMilestones = 500
)
// MilestoneStatuses is every status a milestone may have. Released is added by the one ask per milestone (issue 1783).
var MilestoneStatuses = []string{MilestoneOpen, MilestoneClosed}
var (
milestoneID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$`)
milestoneRef = regexp.MustCompile(`^([1-9][0-9]{0,2})/(.+)$`)
commitRef = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}@[0-9a-f]{40}$`)
)
// Stamp is who did something and when.
type Stamp struct {
By string `json:"by"`
At time.Time `json:"at"`
}
// MilestoneSource is where a seeded milestone was read: hq's commit and the design's path.
type MilestoneSource struct {
Commit string `json:"commit"`
Path string `json:"path"`
}
// Milestone is one milestone as the tracker keeps it.
type Milestone struct {
Design int `json:"design"`
ID string `json:"id"`
Outcome string `json:"outcome"`
// Order is its place in its design's table, first 1.
Order int `json:"order"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Made Stamp `json:"made"`
Moved *Stamp `json:"moved,omitempty"`
Imported *MilestoneSource `json:"imported,omitempty"`
}
// Ref is the milestone as a link names it: design/id, such as 47/R6.
func (m *Milestone) Ref() string { return MilestoneRef(m.Design, m.ID) }
// MilestoneRef is design/id.
func MilestoneRef(design int, id string) string { return fmt.Sprintf("%d/%s", design, id) }
// MilestoneKey is a milestone's key in `milestones`: design.id, such as 47.R6.
func MilestoneKey(design int, id string) string { return fmt.Sprintf("%d.%s", design, id) }
// canonicalID is a milestone's id as kept: the table's words joined by hyphens ("3 fix" is 3-fix).
func canonicalID(id string) (string, error) {
c := strings.Join(strings.Fields(id), "-")
if !milestoneID.MatchString(c) {
return "", fmt.Errorf("a milestone's id is a short name of letters, digits, - and _, such as R6 or 3-fix, not %q", id)
}
return c, nil
}
// parseMilestoneRef reads design/id, the id said the one way it is kept.
func parseMilestoneRef(s string) (int, string, error) {
m := milestoneRef.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
return 0, "", fmt.Errorf("a milestone is named design/id, such as 47/R6, not %q", s)
}
d, _ := strconv.Atoi(m[1])
id, err := canonicalID(m[2])
if err != nil {
return 0, "", err
}
return d, id, nil
}
// checkMilestoneTarget is a milestone link's target, said the one way it is kept.
func checkMilestoneTarget(s string) (string, bool) {
d, id, err := parseMilestoneRef(s)
if err != nil {
return "", false
}
return MilestoneRef(d, id), true
}
// readMilestone is a milestone and the revision it was read at; nil when there is none.
func (t *Tracker) readMilestone(design int, id string) (*Milestone, uint64, error) {
e, err := t.Store.Get(MilestonesState, MilestoneKey(design, id))
if err != nil {
return nil, 0, fmt.Errorf("milestone %s cannot be read: %v", MilestoneRef(design, id), err)
}
if e == nil {
return nil, 0, nil
}
var m Milestone
if err := json.Unmarshal(e.Value, &m); err != nil {
return nil, 0, fmt.Errorf("milestone %s is kept in a shape the tracker cannot read: %v", MilestoneRef(design, id), err)
}
return &m, e.Revision, nil
}
// milestoneDone says an issue is done for its milestone: wontfix, or resolved with every live fix delivered.
func milestoneDone(is *Issue) bool {
switch is.Status {
case StatusWontfix:
return true
case StatusResolved:
return len(liveFixes(is)) == 0 || allDelivered(is)
}
return false
}
// milestoneOf is the milestone an issue is linked to, or "".
func milestoneOf(is *Issue) string {
for _, l := range is.Links {
if l.Kind == LinkMilestone {
return l.Target
}
}
return ""
}
// MilestoneSetAsk is what milestone-set carries.
type MilestoneSetAsk struct {
Design int
ID string
Outcome string
Order int
Announce bool
imported *MilestoneSource
}
// MilestoneSaid is the answer to milestone-set and milestone-move.
type MilestoneSaid struct {
Milestone Milestone `json:"milestone"`
Made bool `json:"made,omitempty"`
Changed []string `json:"changed,omitempty"`
Said string `json:"said"`
Announced bool `json:"announced"`
Warning string `json:"warning,omitempty"`
}
// milestoneSet makes a milestone, or changes its outcome or order, at the revision read; with Announce it says the
// record again.
func (t *Tracker) milestoneSet(by string, a MilestoneSetAsk) (*MilestoneSaid, error) {
if a.Design < 1 || a.Design > 999 {
return nil, fmt.Errorf("design is a design's number, 1 to 999, not %d", a.Design)
}
id, err := canonicalID(a.ID)
if err != nil {
return nil, err
}
outcome := strings.TrimSpace(a.Outcome)
if outcome != "" {
if outcome, err = singleLine("outcome", outcome, MostOutcome); err != nil {
return nil, err
}
}
if a.Order < 0 {
return nil, fmt.Errorf("order is its place in its design's table, from 1, not %d", a.Order)
}
for attempt := 0; attempt < issueAttempts; attempt++ {
m, rev, err := t.readMilestone(a.Design, id)
if err != nil {
return nil, err
}
at := t.now()
out := &MilestoneSaid{}
if m == nil {
if outcome == "" {
return nil, fmt.Errorf("milestone %s does not exist; making it needs its outcome", MilestoneRef(a.Design, id))
}
m = &Milestone{Design: a.Design, ID: id, Outcome: outcome, Order: a.Order, Status: MilestoneOpen,
Made: Stamp{By: by, At: at}, Imported: a.imported}
if _, err := t.Store.Create(MilestonesState, MilestoneKey(a.Design, id), m); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return nil, fmt.Errorf("milestone %s could not be written (%v); nothing was made", m.Ref(), err)
}
out.Made = true
out.Said = fmt.Sprintf("Made milestone %s: %s.", m.Ref(), clipText(outcome, 120))
} else {
if outcome != "" && outcome != m.Outcome {
m.Outcome = outcome
out.Changed = append(out.Changed, "outcome")
}
if a.Order != 0 && a.Order != m.Order {
m.Order = a.Order
out.Changed = append(out.Changed, "order")
}
if len(out.Changed) == 0 && !a.Announce {
out.Milestone = *m
out.Said = fmt.Sprintf("Milestone %s already holds that; nothing changed.", m.Ref())
return out, nil
}
if len(out.Changed) > 0 {
m.Moved = &Stamp{By: by, At: at}
if _, err := t.Store.Update(MilestonesState, MilestoneKey(a.Design, id), m, rev); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return nil, fmt.Errorf("milestone %s could not be written (%v); nothing changed", m.Ref(), err)
}
out.Said = fmt.Sprintf("Changed the %s of milestone %s.", strings.Join(out.Changed, " and "), m.Ref())
} else {
out.Said = fmt.Sprintf("Said milestone %s again, unchanged.", m.Ref())
}
}
out.Milestone = *m
out.Announced, out.Warning = t.emit(EventMilestoneSet, map[string]any{"milestone": m.Ref(), "record": m,
"made": out.Made, "changed": out.Changed, "by": by, "at": at})
return out, nil
}
return nil, fmt.Errorf("milestone %s was changed by others %d times while this was written; nothing changed. Ask again",
MilestoneRef(a.Design, id), issueAttempts)
}
// milestoneMove opens or closes a milestone, with a reason. Closing is refused while an issue linked to it is neither
// resolved nor wontfix.
func (t *Tracker) milestoneMove(by, ref, status, reason string) (*MilestoneSaid, error) {
design, id, err := parseMilestoneRef(ref)
if err != nil {
return nil, err
}
if status, err = oneOf("status", status, MilestoneStatuses); err != nil {
return nil, err
}
if reason, err = singleLine("reason", reason, MostTitle); err != nil {
return nil, fmt.Errorf("a milestone is moved with its reason: %v", err)
}
if status == MilestoneClosed {
all, err := t.allIssues()
if err != nil {
return nil, err
}
var open []string
for _, n := range sortedNumbers(all) {
is := all[n]
if milestoneOf(is) == MilestoneRef(design, id) && is.Status != StatusResolved && is.Status != StatusWontfix {
open = append(open, fmt.Sprintf("%d (%s)", n, is.Status))
}
}
if len(open) > 0 {
return nil, fmt.Errorf("milestone %s keeps issues neither resolved nor wontfix: %s. Resolve them, or move them "+
"to another milestone, before it is closed", MilestoneRef(design, id), strings.Join(open, ", "))
}
}
for attempt := 0; attempt < issueAttempts; attempt++ {
m, rev, err := t.readMilestone(design, id)
if err != nil {
return nil, err
}
if m == nil {
return nil, fmt.Errorf("there is no milestone %s", MilestoneRef(design, id))
}
if m.Status == status {
return &MilestoneSaid{Milestone: *m, Said: fmt.Sprintf("Milestone %s is %s already; nothing changed.", m.Ref(), status)}, nil
}
from, at := m.Status, t.now()
m.Status, m.Reason, m.Moved = status, reason, &Stamp{By: by, At: at}
if _, err := t.Store.Update(MilestonesState, MilestoneKey(design, id), m, rev); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return nil, fmt.Errorf("milestone %s could not be written (%v); nothing changed", m.Ref(), err)
}
out := &MilestoneSaid{Milestone: *m, Changed: []string{"status"},
Said: fmt.Sprintf("Milestone %s moved from %s to %s: %s.", m.Ref(), from, status, reason)}
out.Announced, out.Warning = t.emit(EventMilestoneMoved, map[string]any{"milestone": m.Ref(), "from": from,
"to": status, "reason": reason, "by": by, "at": at})
return out, nil
}
return nil, fmt.Errorf("milestone %s was changed by others %d times while this was written; nothing changed. Ask again",
MilestoneRef(design, id), issueAttempts)
}
// MilestonesAsk narrows the milestones read.
type MilestonesAsk struct {
Design int
Status string
// One is one milestone, design/id: answered with its history.
One string
}
// MilestoneRow is one milestone with its issues.
type MilestoneRow struct {
Milestone
Ref string `json:"ref"`
Issues []uint64 `json:"issues"`
ByStatus map[string]int `json:"by-status,omitempty"`
// Done is how many of its issues are wontfix, or resolved with their fixes delivered.
Done int `json:"done"`
}
// MilestonesRead is the answer to milestones.
type MilestonesRead struct {
Milestones []MilestoneRow `json:"milestones"`
// History is every value one milestone's key keeps, oldest first, when one was asked.
History []json.RawMessage `json:"history,omitempty"`
Said string `json:"said"`
}
// milestones reads the milestones, ordered by design and their place in its table, each with its issues.
func (t *Tracker) milestones(a MilestonesAsk) (*MilestonesRead, error) {
status := strings.TrimSpace(a.Status)
if status != "" {
var err error
if status, err = oneOf("status", status, MilestoneStatuses); err != nil {
return nil, err
}
}
var ms []*Milestone
out := &MilestonesRead{Milestones: []MilestoneRow{}}
if strings.TrimSpace(a.One) != "" {
d, id, err := parseMilestoneRef(a.One)
if err != nil {
return nil, err
}
m, _, err := t.readMilestone(d, id)
if err != nil {
return nil, err
}
if m == nil {
return nil, fmt.Errorf("there is no milestone %s", MilestoneRef(d, id))
}
ms = append(ms, m)
hist, err := t.Store.History(MilestonesState, MilestoneKey(d, id))
if err != nil {
return nil, fmt.Errorf("milestone %s's history cannot be read: %v", m.Ref(), err)
}
for _, h := range hist {
out.History = append(out.History, h.Value)
}
} else {
keys, err := t.Store.Keys(MilestonesState)
if err != nil {
return nil, fmt.Errorf("the milestones cannot be listed: %v", err)
}
for _, k := range keys {
ds, id, ok := strings.Cut(k, ".")
d, err := strconv.Atoi(ds)
if !ok || err != nil || (a.Design != 0 && d != a.Design) {
continue
}
m, _, err := t.readMilestone(d, id)
if err != nil {
return nil, err
}
if m == nil || (status != "" && m.Status != status) {
continue
}
ms = append(ms, m)
}
}
sort.Slice(ms, func(i, j int) bool {
if ms[i].Design != ms[j].Design {
return ms[i].Design < ms[j].Design
}
if ms[i].Order != ms[j].Order {
return ms[i].Order < ms[j].Order
}
return ms[i].ID < ms[j].ID
})
if len(ms) > MostMilestones {
ms = ms[:MostMilestones]
}
all, err := t.allIssues()
if err != nil {
return nil, err
}
rows := map[string]*MilestoneRow{}
for _, m := range ms {
out.Milestones = append(out.Milestones, MilestoneRow{Milestone: *m, Ref: m.Ref(), Issues: []uint64{}, ByStatus: map[string]int{}})
}
for i := range out.Milestones {
rows[out.Milestones[i].Ref] = &out.Milestones[i]
}
for _, n := range sortedNumbers(all) {
is := all[n]
r := rows[milestoneOf(is)]
if r == nil {
continue
}
r.Issues = append(r.Issues, n)
r.ByStatus[is.Status]++
if milestoneDone(is) {
r.Done++
}
}
out.Said = fmt.Sprintf("%d milestones.", len(out.Milestones))
if len(ms) == MostMilestones {
out.Said += fmt.Sprintf(" At most %d are answered: narrow by design.", MostMilestones)
}
return out, nil
}
// sortedNumbers is the issues' numbers, lowest first.
func sortedNumbers(all map[uint64]*Issue) []uint64 {
ns := make([]uint64, 0, len(all))
for n := range all {
ns = append(ns, n)
}
sort.Slice(ns, func(i, j int) bool { return ns[i] < ns[j] })
return ns
}
func clipText(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}