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.
442 lines
14 KiB
Go
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
|
|
}
|