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 }