mesh-issues and mesh-knowledge: milestones are records of the issue tracker, and progress reads them (ADR 0493 §1 to §4, issue 1781)
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 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.
This commit is contained in:
2026-10-26 18:42:51 +01:00
parent 26908a8950
commit 76e4ec4dc6
24 changed files with 2912 additions and 196 deletions
+18 -1
View File
@@ -215,10 +215,18 @@ const (
LinkDesign = "design"
LinkCondition = "condition"
LinkDelivery = "delivery"
// LinkMilestone is the one milestone an issue belongs to, design/id (ADR 0493 §1, §2).
LinkMilestone = "milestone"
// LinkCommit is a commit that fixes the issue, repository@full sha (ADR 0493 §4).
LinkCommit = "commit"
)
// LinkKinds is every kind, in the order they are told.
var LinkKinds = []string{LinkDuplicates, LinkRelatesTo, LinkDependsOn, LinkPullRequest, LinkDecision, LinkDesign, LinkCondition, LinkDelivery}
var LinkKinds = []string{LinkDuplicates, LinkRelatesTo, LinkDependsOn, LinkPullRequest, LinkDecision, LinkDesign, LinkCondition, LinkDelivery,
LinkMilestone, LinkCommit}
// madeByPeople are the kinds only a person or a session links, never the triage (ADR 0493 §4).
var madeByPeople = []string{LinkPullRequest, LinkMilestone, LinkCommit}
var (
pullRequest = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,99}#[1-9][0-9]{0,6}$`)
@@ -280,6 +288,15 @@ func checkTarget(kind, target string) (string, error) {
case LinkDelivery:
ok, shape = deliveryID.MatchString(t), "a delivery's id as mesh-delivery names it: the repository as owner/name, @ and twelve "+
"characters of its commit, such as novox/mesh-controller@d2e9dc61590f"
case LinkMilestone:
var c string
c, ok = checkMilestoneTarget(t)
if ok {
t = c
}
shape = "a milestone as design/id, such as 47/R6"
case LinkCommit:
ok, shape = commitRef.MatchString(t), "a repository, @ and the commit's full 40-character id, such as mesh-catalog@"+strings.Repeat("0", 40)
default:
return "", fmt.Errorf("kind %q is not a kind of link: one of %s", kind, strings.Join(LinkKinds, ", "))
}
+124 -7
View File
@@ -22,7 +22,8 @@ import (
const Seat = "issue-tracker"
// Verbs are the seat's verbs, as the manifest declares and claims them.
var Verbs = []string{"open", "get", "list", "move", "note", "link", "import", "groom", "set", "tracking", "reserve", "fix-times"}
var Verbs = []string{"open", "get", "list", "move", "note", "link", "milestones", "milestone-set", "milestone-move", "import",
"groom", "set", "tracking", "reserve", "fix-times"}
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
@@ -617,8 +618,9 @@ func Tools(t *Tracker) []stdio.Tool {
Description: "Link an issue to something, or with remove, take a link away. Kinds: duplicates or relates-to " +
"another issue (its number), pull-request (such as mesh-controller#246), decision (a decision record's " +
"number, such as 0275), design (a design document's path), condition (a condition's key) and delivery " +
"(a delivery's id, such as novox/mesh-controller@d2e9dc61590f). Refused: a target not of its kind's shape, an issue that does not exist, a link it " +
"already has.",
"(a delivery's id, such as novox/mesh-controller@d2e9dc61590f), milestone (the one milestone it belongs to, design/id such as " +
"47/R6) and commit (a commit that fixes it, repository@full sha). Refused: a target not of its kind's shape, an issue that does not exist, a link it " +
"already has, a second milestone (remove the first to move it), a milestone that does not exist or is closed.",
Input: schema([]string{"number", "kind", "target"}, map[string]any{
"number": number("the issue's number"),
"kind": map[string]any{"type": "string", "enum": LinkKinds, "description": "what the target is"},
@@ -645,6 +647,98 @@ func Tools(t *Tracker) []stdio.Tool {
}
return t.link(t.callerOf(m), ask)
}},
{Name: Seat + ".milestones",
Description: "Read the milestones of the designs: each milestone (a design's milestone, design/id such as 47/R6) with " +
"its outcome, its place in the design's table, its status, who made and moved it, and the issues linked to it " +
"with how many are done. Narrow by design or status, or read one milestone with its history.",
Input: schema(nil, map[string]any{
"design": number("only this design's milestones, such as 47"),
"status": map[string]any{"type": "string", "enum": MilestoneStatuses, "description": "only milestones with this status"},
"one": text("one milestone, design/id such as 47/R6, with its history"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("milestones", m, "design", "status", "one")
if err != nil {
return nil, err
}
var ask MilestonesAsk
d, err := a.number("design", false)
if err != nil {
return nil, fmt.Errorf("design is a design's number, such as 47")
}
ask.Design = int(d)
if ask.Status, err = a.str("status"); err != nil {
return nil, err
}
if ask.One, err = a.str("one"); err != nil {
return nil, err
}
return t.milestones(ask)
}},
{Name: Seat + ".milestone-set",
Description: "Make a milestone of a design (its id as the design's table writes it, and its outcome), or change its " +
"outcome or its place in the table; with announce, say it again on the bus unchanged. Its issues are linked " +
"with link, kind milestone. Refused: a design that is no number, an id that is no short name, a new milestone without an outcome.",
Input: schema([]string{"design", "id"}, map[string]any{
"design": number("the design's number, such as 47"),
"id": text("the milestone's id as the table writes it, such as R6 or 3 fix"),
"outcome": text("what the milestone delivers, in one line"),
"order": number("its place in the design's table, first 1"),
"announce": map[string]any{"type": "boolean", "description": "say the milestone again on the bus, as it is"},
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("milestone-set", m, "design", "id", "outcome", "order", "announce")
if err != nil {
return nil, err
}
var ask MilestoneSetAsk
d, err := a.number("design", true)
if err != nil {
return nil, fmt.Errorf("design is a design's number, such as 47")
}
o, err := a.number("order", false)
if err != nil {
return nil, fmt.Errorf("order is its place in the design's table, from 1")
}
ask.Design, ask.Order = int(d), int(o)
if ask.ID, err = a.str("id"); err != nil {
return nil, err
}
if ask.Outcome, err = a.str("outcome"); err != nil {
return nil, err
}
if ask.Announce, err = a.flag("announce"); err != nil {
return nil, err
}
return t.milestoneSet(t.callerOf(m), ask)
}},
{Name: Seat + ".milestone-move",
Description: "Open or close a milestone (design/id such as 47/R6), with the reason. Closing is refused while an " +
"issue linked to it is neither resolved nor wontfix, naming each; a closed milestone takes no new issue.",
Input: schema([]string{"milestone", "status", "reason"}, map[string]any{
"milestone": text("the milestone, design/id such as 47/R6"),
"status": map[string]any{"type": "string", "enum": MilestoneStatuses, "description": "its new status"},
"reason": text("why, in one line"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("milestone-move", m, "milestone", "status", "reason")
if err != nil {
return nil, err
}
ref, err := a.str("milestone")
if err != nil {
return nil, err
}
status, err := a.str("status")
if err != nil {
return nil, err
}
reason, err := a.str("reason")
if err != nil {
return nil, err
}
return t.milestoneMove(t.callerOf(m), ref, status, reason)
}},
{Name: Seat + ".import",
Description: "Import hq's issue folders (04-ISSUES/NNN-slug/00-report.md), read through the records module at " +
"the commit its checkout stands at, each as an issue under its own number with the status, owners, fix " +
@@ -652,16 +746,39 @@ func Tools(t *Tracker) []stdio.Tool {
"the tracker holds without a status; it never overwrites an issue, never advances the counter, refuses a " +
"folder numbered past the counter or past the tracker's first number without an issue, and run again " +
"changes nothing and says so. With dry-run it writes nothing and says what it would do; with commit it " +
"refuses a checkout standing elsewhere. The answer is a row per folder and the counts.",
"refuses a checkout standing elsewhere. The answer is a row per folder and the counts. With milestones and a " +
"design's number it seeds that design's milestones from its milestone table instead: makes each milestone not " +
"made yet, links each issue the table names that is in no milestone, refuses (and names) an issue two rows name " +
"or another milestone holds, closes a milestone whose issues are all done, and run again changes nothing.",
Input: schema(nil, map[string]any{
"dry-run": map[string]any{"type": "boolean", "description": "read everything, write nothing, say what would be done"},
"commit": text("the commit of hq the record must stand at (a prefix is enough); refused when it stands elsewhere"),
"dry-run": map[string]any{"type": "boolean", "description": "read everything, write nothing, say what would be done"},
"milestones": number("seed this design's milestones from its milestone table, such as 47"),
"commit": text("the commit of hq the record must stand at (a prefix is enough); refused when it stands elsewhere"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("import", m, "dry-run", "commit")
a, err := readArgs("import", m, "dry-run", "commit", "milestones")
if err != nil {
return nil, err
}
design, err := a.number("milestones", false)
if err != nil {
return nil, fmt.Errorf("milestones is a design's number, such as 47")
}
if design != 0 {
dry, err := a.flag("dry-run")
if err != nil {
return nil, err
}
commit, err := a.str("commit")
if err != nil {
return nil, err
}
records := t.Records
if records == nil {
records = busRecords{}
}
return t.importMilestones(t.callerOf(m), MilestoneImportAsk{Design: int(design), DryRun: dry, Commit: commit}, records)
}
var ask ImportAsk
if ask.DryRun, err = a.flag("dry-run"); err != nil {
return nil, err
@@ -86,18 +86,18 @@ func TestItDeclaresAndHoldsTheIssueTrackerSeatOnceForTheMesh(t *testing.T) {
t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims)
}
if !reflect.DeepEqual(m.Emits, []string{EventOpened, EventMoved, EventNoted, EventLinked, EventImported, EventSet,
EventTriaged, EventReady, EventReadyToVerify, EventReserved}) {
EventTriaged, EventReady, EventReadyToVerify, EventReserved, EventMilestoneSet, EventMilestoneMoved}) {
t.Fatalf("emits %v", m.Emits)
}
// The record is the log `changes` (ADR 0300 §1). Four states, none expiring: issues the fold of the log (§3),
// still keeping its older values until the carry has read them (they go to one value in a follow-up), the
// counters one value each, the backlog's pass of the duplicate check one value (ADR 0278 §6), each reserved
// number one value (issue 424).
// number one value (issue 424), and the milestones (issue 1781) a short history each, their trail beside their events.
if !reflect.DeepEqual(m.Logs, []string{ChangesLog}) {
t.Fatalf("logs %v", m.Logs)
}
d := m.stateDeclarations(t)
if len(d) != 4 || d[0].Name != IssuesState || d[0].History < 2 || d[0].TTLSeconds != 0 ||
if len(d) != 5 || d[4].Name != MilestonesState || d[4].History < 2 || d[4].TTLSeconds != 0 || d[0].Name != IssuesState || d[0].History < 2 || d[0].TTLSeconds != 0 ||
d[1].Name != CountersState || d[1].History > 1 || d[1].TTLSeconds != 0 ||
d[2].Name != GroomingState || d[2].History > 1 || d[2].TTLSeconds != 0 ||
d[3].Name != ReservationsState || d[3].History > 1 || d[3].TTLSeconds != 0 {
@@ -28,7 +28,7 @@ type memory struct {
}
func newMemory() *memory {
return &memory{data: map[string]map[string]Entry{IssuesState: {}, CountersState: {}, GroomingState: {}, ReservationsState: {}}, fail: map[string]error{},
return &memory{data: map[string]map[string]Entry{IssuesState: {}, CountersState: {}, GroomingState: {}, ReservationsState: {}, MilestonesState: {}}, fail: map[string]error{},
hist: map[string]map[string][]Entry{}}
}
@@ -0,0 +1,441 @@
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
}
@@ -0,0 +1,84 @@
//go:build integration
package main
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
)
// Issue 1781 (ADR 0493 §1, §2, §4), the module whole over a real bus: a milestone made by its verb is kept in the
// state the manifest declares, and two instances linking one issue to two milestones at the same moment leave it in
// exactly one; the other is refused naming the first, and the milestones and the list say the same.
func TestAnIssueRaceIntoTwoMilestonesOverTheBusEndsInOne(t *testing.T) {
b := newBus(t)
var events []event
var evMu sync.Mutex
settings := writeSettings(t, 365)
one, two := b.start(t, settings, &events, &evMu), b.start(t, settings, &events, &evMu)
must := func(raw json.RawMessage, err error) json.RawMessage {
t.Helper()
if err != nil {
t.Fatal(err)
}
return raw
}
must(one.call("open", map[string]any{"title": "a milestone's issue", "report": "seen"}))
must(one.call("milestone-set", map[string]any{"design": 47, "id": "R6", "outcome": "nodes apply their wants", "order": 1}))
must(two.call("milestone-set", map[string]any{"design": 47, "id": "3 fix", "outcome": "a group merges in one pass", "order": 2}))
errs := make([]error, 2)
var wg sync.WaitGroup
for k, c := range []struct {
i *instance
target string
}{{one, "47/R6"}, {two, "47/3-fix"}} {
wg.Add(1)
go func() {
defer wg.Done()
_, errs[k] = c.i.call("link", map[string]any{"number": 365, "kind": "milestone", "target": c.target})
}()
}
wg.Wait()
if (errs[0] == nil) == (errs[1] == nil) {
t.Fatalf("both or neither linked: %v", errs)
}
refused := errs[0]
if refused == nil {
refused = errs[1]
}
if !strings.Contains(refused.Error(), "is in milestone") {
t.Fatalf("refused with %v", refused)
}
var read MilestonesRead
_ = json.Unmarshal(must(two.call("milestones", map[string]any{"design": 47})), &read)
if len(read.Milestones) != 2 || read.Milestones[0].ID != "R6" || read.Milestones[1].ID != "3-fix" ||
len(read.Milestones[0].Issues)+len(read.Milestones[1].Issues) != 1 {
t.Fatalf("%+v", read.Milestones)
}
in := "47/R6"
if len(read.Milestones[1].Issues) == 1 {
in = "47/3-fix"
}
var l Listed
_ = json.Unmarshal(must(one.call("list", map[string]any{"linked": "milestone:" + in})), &l)
if len(l.Rows) != 1 || l.Rows[0].Number != 365 {
t.Fatalf("listed %+v", l)
}
var hist MilestonesRead
_ = json.Unmarshal(must(one.call("milestones", map[string]any{"one": "47/R6"})), &hist)
if len(hist.History) != 1 {
t.Fatalf("history %d values", len(hist.History))
}
evMu.Lock()
defer evMu.Unlock()
var kinds []string
for _, e := range events {
kinds = append(kinds, e.Key)
}
if fmt.Sprint(kinds) != "[opened milestone-set milestone-set linked]" {
t.Fatalf("events %v", kinds)
}
}
@@ -0,0 +1,187 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// Issue 1781 (ADR 0493 §1 to §4): a milestone is a record made by its verb; an issue is linked to at most one, only to
// an open one, and only by the verb; the triage links no milestone, pull request or commit.
func TestAMilestoneIsMadeByItsVerbAndAnIssueBelongsToAtMostOne(t *testing.T) {
h := newHarness(t)
a, b := h.open(t, "a"), h.open(t, "b")
for _, c := range []struct {
ask MilestoneSetAsk
says string
}{
{MilestoneSetAsk{Design: 0, ID: "R6", Outcome: "x"}, "design's number"},
{MilestoneSetAsk{Design: 47, ID: "R6/x", Outcome: "x"}, "short name"},
{MilestoneSetAsk{Design: 47, ID: "R6"}, "needs its outcome"},
{MilestoneSetAsk{Design: 47, ID: "R6", Outcome: "nodes apply their wants", Order: 2}, ""},
{MilestoneSetAsk{Design: 47, ID: "3 fix", Outcome: "a group merges in one pass", Order: 1}, ""},
{MilestoneSetAsk{Design: 47, ID: "R6", Outcome: "nodes apply their wants"}, "nothing changed"},
} {
out, err := h.milestoneSet("lead@shanks", c.ask)
if c.says == "" && err != nil || c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)) &&
(out == nil || !strings.Contains(out.Said, c.says)) {
t.Fatalf("%+v: want %q, got %+v %v", c.ask, c.says, out, err)
}
}
links := []struct {
l LinkAsk
says string
}{
{LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/R6"}, ""},
{LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/3 fix"}, "in milestone 47/R6 already"},
{LinkAsk{Number: b, Kind: LinkMilestone, Target: "47/R9"}, "there is no milestone 47/R9"},
{LinkAsk{Number: b, Kind: LinkMilestone, Target: "R6"}, "design/id"},
{LinkAsk{Number: b, Kind: LinkCommit, Target: "mesh-catalog@" + strings.Repeat("a", 40)}, ""},
{LinkAsk{Number: b, Kind: LinkCommit, Target: "mesh-catalog@abc123"}, "full 40-character"},
{LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/R6", Remove: true}, ""},
{LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/3-fix"}, ""},
{LinkAsk{Number: b, Kind: LinkMilestone, Target: "47/R6"}, ""},
}
for _, c := range links {
_, err := h.link("lead@shanks", c.l)
if c.says == "" && err != nil || c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)) {
t.Fatalf("%+v: want %q, got %v", c.l, c.says, err)
}
}
for _, kind := range madeByPeople {
target := map[string]string{LinkPullRequest: "mesh-catalog#1", LinkMilestone: "47/R6", LinkCommit: "mesh-catalog@" + strings.Repeat("b", 40)}[kind]
if _, err := h.link(GroomBy, LinkAsk{Number: a, Kind: kind, Target: target}); err == nil || !strings.Contains(err.Error(), "triage links no") {
t.Fatalf("the triage linked %s: %v", kind, err)
}
}
read, err := h.milestones(MilestonesAsk{Design: 47})
if err != nil {
t.Fatal(err)
}
if len(read.Milestones) != 2 || read.Milestones[0].Ref != "47/3-fix" || read.Milestones[1].Ref != "47/R6" ||
fmt.Sprint(read.Milestones[0].Issues) != fmt.Sprint([]uint64{a}) || fmt.Sprint(read.Milestones[1].Issues) != fmt.Sprint([]uint64{b}) {
t.Fatalf("%+v", read.Milestones)
}
one, err := h.milestones(MilestonesAsk{One: "47/R6"})
if err != nil || len(one.Milestones) != 1 || len(one.History) == 0 || one.Milestones[0].Made.By != "lead@shanks" {
t.Fatalf("%+v %v", one, err)
}
// Closing waits for its issues; a closed milestone takes no new one; each change is said.
if _, err := h.milestoneMove("lead@shanks", "47/R6", MilestoneClosed, "delivered"); err == nil || !strings.Contains(err.Error(), fmt.Sprint(b)) {
t.Fatalf("closed over an open issue: %v", err)
}
if _, err := h.Move(MoveAsk{Number: b, Status: StatusWontfix, Reason: "not needed"}); err != nil {
t.Fatal(err)
}
if _, err := h.milestoneMove("lead@shanks", "47/R6", MilestoneClosed, "delivered"); err != nil {
t.Fatal(err)
}
if _, err := h.link("lead@shanks", LinkAsk{Number: a, Kind: LinkMilestone, Target: "47/R6", Remove: true}); err == nil {
t.Fatal("a removed a link it does not have")
}
c := h.open(t, "c")
if _, err := h.link("lead@shanks", LinkAsk{Number: c, Kind: LinkMilestone, Target: "47/R6"}); err == nil || !strings.Contains(err.Error(), "is closed") {
t.Fatalf("linked to a closed milestone: %v", err)
}
if out, err := h.milestoneSet("lead@shanks", MilestoneSetAsk{Design: 47, ID: "R6", Announce: true}); err != nil || !out.Announced {
t.Fatalf("announce: %+v %v", out, err)
}
var kinds []string
for _, e := range h.said() {
if strings.HasPrefix(e.Event, "milestone") {
kinds = append(kinds, e.Event+":"+fmt.Sprint(e.Body["milestone"]))
}
}
if strings.Join(kinds, " ") != "milestone-set:47/R6 milestone-set:47/3-fix milestone-moved:47/R6 milestone-set:47/R6" {
t.Fatalf("said %v", kinds)
}
}
// listRecords is a record holding one design document.
type listRecords struct {
path, content string
}
func (r listRecords) Standing() (string, error) {
return "0123456789abcdef0123456789abcdef01234567", nil
}
func (r listRecords) List(string) ([]string, []string, error) {
return nil, []string{"12-other.md", filepath.Base(r.path)}, nil
}
func (r listRecords) Read(string) (string, string, error) { return r.content, "", nil }
// Issue 1781: the seed makes each row's milestone, links what one row names, refuses what two rows name or another
// milestone holds, closes a milestone whose issues are done, and run again changes nothing.
func TestTheSeedTakesADesignsTableOnce(t *testing.T) {
h := newHarness(t)
a, b, c, d := h.open(t, "a"), h.open(t, "b"), h.open(t, "c"), h.open(t, "d")
if _, err := h.Move(MoveAsk{Number: d, Status: StatusWontfix, Reason: "folded"}); err != nil {
t.Fatal(err)
}
table := fmt.Sprintf("# 47 — Delivery\n\n## Milestones\n\n| # | Outcome | Issues |\n|---|---|---|\n"+
"| 1 | the first | %d, %d (ADR 0493 §2) |\n| 3 fix | a fix | %d, %d, 999 |\n| R4d | none yet | opened when it is prepared |\n"+
"| 6 | done | %d |\n\n## Next\n\n| a | Issues |\n|---|---|\n| x | %d |\n", a, b, c, b, d, a)
rec := listRecords{path: "03-DESIGN/01-to-be/47-delivery.md", content: table}
dry, err := h.importMilestones("lead@shanks", MilestoneImportAsk{Design: 47, DryRun: true}, rec)
if err != nil {
t.Fatal(err)
}
if dry.Made != 4 || dry.Linked != 3 || dry.Refused != 2 || dry.Closed != 1 || len(dry.Rows[1].Missing) != 1 {
t.Fatalf("dry run %+v", dry)
}
if read, _ := h.milestones(MilestonesAsk{}); len(read.Milestones) != 0 {
t.Fatalf("a dry run wrote %+v", read.Milestones)
}
got, err := h.importMilestones("lead@shanks", MilestoneImportAsk{Design: 47}, rec)
if err != nil {
t.Fatal(err)
}
if fmt.Sprintf("%d %d %d %d", got.Made, got.Linked, got.Refused, got.Closed) != "4 3 2 1" {
t.Fatalf("%+v", got)
}
read, _ := h.milestones(MilestonesAsk{Design: 47})
var say []string
for _, m := range read.Milestones {
say = append(say, fmt.Sprintf("%s:%s:%v", m.ID, m.Status, m.Issues))
}
if want := fmt.Sprintf("1:open:[%d] 3-fix:open:[%d] R4d:open:[] 6:closed:[%d]", a, c, d); strings.Join(say, " ") != want {
t.Fatalf("got %s, want %s", strings.Join(say, " "), want)
}
if read.Milestones[3].Imported == nil || read.Milestones[3].Imported.Path != rec.path {
t.Fatalf("%+v", read.Milestones[3])
}
again, err := h.importMilestones("lead@shanks", MilestoneImportAsk{Design: 47}, rec)
if err != nil || again.Made+again.Linked+again.Closed != 0 || !strings.HasPrefix(again.Said, "Changed nothing") {
t.Fatalf("again %+v %v", again, err)
}
if _, err := h.importMilestones("lead@shanks", MilestoneImportAsk{Design: 48}, rec); err == nil {
t.Fatal("seeded a design the record does not hold")
}
}
// Issue 1781 S1: design 47's table, as hq's branch writes it, names every issue in one row and nothing in words the
// seed would guess.
func TestDesign47sTableNamesEachIssueOnce(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "milestones", "47-delivery-from-commit-to-delivered.md"))
if err != nil {
t.Fatal(err)
}
rows, warn := parseTable(string(raw))
if len(rows) != 33 || len(warn) != 0 {
t.Fatalf("%d rows, warnings %v", len(rows), warn)
}
seen := map[uint64]string{}
for _, r := range rows {
if _, err := canonicalID(r.ID); err != nil {
t.Error(err)
}
for _, n := range r.Issues {
if other, ok := seen[n]; ok {
t.Errorf("issue %d is in rows %s and %s", n, other, r.ID)
}
seen[n] = r.ID
}
}
}
@@ -0,0 +1,313 @@
package main
// The seed of a design's milestones (novox/hq issue 1781, to-be 53 §13): its `## Milestones` table, read once through
// the records module, made into milestone records with their issues linked. The table parse moved here from the
// knowledge base's progress (issue 1947), which reads the records since. The Issues cell is prose, so what the parse
// cannot read for certain it says, and an issue two rows name is refused, never placed by a guess (ADR 0493 §2).
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)
// DesignsFolder is where the designs being built are, in hq.
const DesignsFolder = "03-DESIGN/01-to-be"
// TableRow is one row of a design's milestone table.
type TableRow struct {
ID string
Outcome string
Issues []uint64
}
var (
designFile = regexp.MustCompile(`^([0-9]{2,3})-[a-z0-9-]+\.md$`)
// notAnIssue is a number the Issues cell names that is not an issue: a record, a clause, a test, a release, a pull
// request, a document.
notAnIssue = regexp.MustCompile(`(?i)(ADR|research|acceptance tests?|tests?|§|clauses?|release|design|to-be|as-is|playbook|version)\s*[0-9][0-9, and]*|[a-z-]*#[0-9]+|[0-9]{4}-[0-9]{2}-[0-9]{2}|[0-9]{1,2}:[0-9]{2}`)
rangeWords = regexp.MustCompile(`\b([0-9]{3,5}) to ([0-9]{3,5})\b`)
butOne = regexp.MustCompile(`\bbut ([0-9]{3,5})\b`)
issueNum = regexp.MustCompile(`\b[0-9]{3,5}\b`)
)
// MostRange is the widest range in words the parse expands; a wider one is said and not expanded.
const MostRange = 60
// parseTable reads a design's milestone table: its rows in order, and what it could not read for certain.
func parseTable(content string) ([]TableRow, []string) {
var rows []TableRow
var warn []string
lines := strings.Split(content, "\n")
inMilestones, header := false, -1
issueCol, outcomeCol := -1, -1
for i, raw := range lines {
l := strings.TrimSpace(raw)
if strings.HasPrefix(l, "## ") {
inMilestones = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(l, "## ")), "Milestones")
header = -1
continue
}
if !inMilestones || !strings.HasPrefix(l, "|") {
continue
}
cells := cellsOf(l)
if header < 0 {
header = i
issueCol, outcomeCol = -1, -1
for c, h := range cells {
switch strings.ToLower(h) {
case "issues", "issue":
issueCol = c
case "outcome":
outcomeCol = c
}
}
continue
}
if i == header+1 && strings.Trim(l, "|-: ") == "" {
continue
}
if issueCol < 0 || issueCol >= len(cells) || len(cells) == 0 {
continue
}
r := TableRow{ID: cells[0]}
if outcomeCol >= 0 && outcomeCol < len(cells) {
r.Outcome = cells[outcomeCol]
}
var w []string
r.Issues, w = issuesOf(cells[issueCol])
for _, x := range w {
warn = append(warn, fmt.Sprintf("milestone %s: %s", r.ID, x))
}
rows = append(rows, r)
}
return rows, warn
}
// issuesOf is the issue numbers a table's Issues cell names, in order, and what it could not read for certain.
func issuesOf(cell string) ([]uint64, []string) {
var warn []string
s := notAnIssue.ReplaceAllString(cell, " ")
var nums []uint64
for _, m := range rangeWords.FindAllStringSubmatch(s, -1) {
lo, _ := strconv.Atoi(m[1])
hi, _ := strconv.Atoi(m[2])
if hi < lo || hi-lo > MostRange {
warn = append(warn, fmt.Sprintf("%q is a range in words the reader does not expand", m[0]))
continue
}
warn = append(warn, fmt.Sprintf("%q is a range in words, read as the %d issues %d to %d", m[0], hi-lo+1, lo, hi))
for n := lo; n <= hi; n++ {
nums = append(nums, uint64(n))
}
}
but := map[uint64]bool{}
for _, m := range butOne.FindAllStringSubmatch(s, -1) {
n, _ := strconv.Atoi(m[1])
but[uint64(n)] = true
}
s = rangeWords.ReplaceAllString(s, " ")
s = butOne.ReplaceAllString(s, " ")
for _, m := range issueNum.FindAllString(s, -1) {
n, _ := strconv.Atoi(m)
nums = append(nums, uint64(n))
}
out, have := []uint64{}, map[uint64]bool{}
for _, n := range nums {
if !have[n] && !but[n] {
have[n] = true
out = append(out, n)
}
}
return out, warn
}
func cellsOf(row string) []string {
row = strings.TrimSpace(row)
row = strings.TrimPrefix(row, "|")
row = strings.TrimSuffix(row, "|")
parts := strings.Split(row, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
// MilestoneImportAsk is what a seed of one design's milestones carries.
type MilestoneImportAsk struct {
Design int
DryRun bool
Commit string
}
// MilestoneImportRow is what the seed did, or would do, for one row of the table.
type MilestoneImportRow struct {
Milestone string `json:"milestone"`
Made bool `json:"made,omitempty"`
Linked []uint64 `json:"linked,omitempty"`
// Kept is the issues linked to it already.
Kept []uint64 `json:"kept,omitempty"`
// Refused is each issue not linked, and why.
Refused []string `json:"refused,omitempty"`
// Missing is the numbers the table names that are no issue.
Missing []uint64 `json:"missing,omitempty"`
Closed bool `json:"closed,omitempty"`
}
// MilestonesImported is the answer to a seed.
type MilestonesImported struct {
DryRun bool `json:"dry-run"`
Design int `json:"design"`
Path string `json:"path"`
Commit string `json:"commit"`
Rows []MilestoneImportRow `json:"rows"`
Made int `json:"made"`
Linked int `json:"linked"`
Closed int `json:"closed"`
Refused int `json:"refused"`
Warnings []string `json:"warnings,omitempty"`
Said string `json:"said"`
}
// importMilestones seeds one design's milestones from its table.
func (t *Tracker) importMilestones(by string, a MilestoneImportAsk, records Records) (*MilestonesImported, error) {
if a.Design < 1 || a.Design > 999 {
return nil, fmt.Errorf("milestones is a design's number, 1 to 999, not %d", a.Design)
}
commit, err := records.Standing()
if err != nil {
return nil, fmt.Errorf("the record cannot be read (%v); nothing was imported", err)
}
want := strings.TrimSpace(a.Commit)
if want != "" && !commitPrefix.MatchString(want) {
return nil, errors.New("commit is a commit's id or a prefix of it, 7 to 40 hex digits; nothing was imported")
}
if want != "" && !strings.HasPrefix(commit, want) {
return nil, fmt.Errorf("the record stands at hq commit %s, not %s: bring it up to date (records_sync), or "+
"name the commit it stands at. Nothing was imported", short(commit), want)
}
_, docs, err := records.List(DesignsFolder)
if err != nil {
return nil, fmt.Errorf("the record's %s cannot be listed (%v); nothing was imported", DesignsFolder, err)
}
path := ""
for _, d := range docs {
if m := designFile.FindStringSubmatch(d); m != nil {
if n, _ := strconv.Atoi(m[1]); n == a.Design {
path = DesignsFolder + "/" + d
}
}
}
if path == "" {
return nil, fmt.Errorf("no design %d is in %s at hq %s; nothing was imported", a.Design, DesignsFolder, short(commit))
}
content, _, err := records.Read(path)
if err != nil {
return nil, fmt.Errorf("%s cannot be read (%v); nothing was imported", path, err)
}
table, warn := parseTable(content)
if len(table) == 0 {
return nil, fmt.Errorf("%s has no milestone table with an Issues column; nothing was imported", path)
}
out := &MilestonesImported{DryRun: a.DryRun, Design: a.Design, Path: path, Commit: commit, Rows: []MilestoneImportRow{}, Warnings: warn}
// An issue two rows name is placed by nobody's guess (ADR 0493 §2).
rowsOf := map[uint64][]string{}
for _, r := range table {
for _, n := range r.Issues {
rowsOf[n] = append(rowsOf[n], r.ID)
}
}
all, err := t.allIssues()
if err != nil {
return nil, err
}
for i, r := range table {
id, err := canonicalID(r.ID)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("row %d: %v; skipped", i+1, err))
continue
}
ref := MilestoneRef(a.Design, id)
row := MilestoneImportRow{Milestone: ref}
m, _, err := t.readMilestone(a.Design, id)
if err != nil {
return nil, err
}
if m == nil {
row.Made = true
out.Made++
if !a.DryRun {
outcome := r.Outcome
if outcome == "" {
outcome = "(the table gives no outcome)"
}
if _, err := t.milestoneSet(by, MilestoneSetAsk{Design: a.Design, ID: id, Outcome: clipText(outcome, MostOutcome),
Order: i + 1, imported: &MilestoneSource{Commit: commit, Path: path}}); err != nil {
return nil, fmt.Errorf("%v; the rows before %s are kept, and an import run again goes on from there", err, ref)
}
}
m = &Milestone{Design: a.Design, ID: id, Status: MilestoneOpen}
}
var members []*Issue
for _, n := range r.Issues {
is := all[n]
switch {
case is == nil:
row.Missing = append(row.Missing, n)
case len(rowsOf[n]) > 1:
row.Refused = append(row.Refused, fmt.Sprintf("%d: rows %s all name it; an issue belongs to at most one", n, strings.Join(rowsOf[n], ", ")))
case milestoneOf(is) == ref:
row.Kept = append(row.Kept, n)
members = append(members, is)
case milestoneOf(is) != "":
row.Refused = append(row.Refused, fmt.Sprintf("%d: in milestone %s already", n, milestoneOf(is)))
case m.Status != MilestoneOpen:
row.Refused = append(row.Refused, fmt.Sprintf("%d: the milestone is %s", n, m.Status))
default:
if !a.DryRun {
if _, err := t.link(by, LinkAsk{Number: n, Kind: LinkMilestone, Target: ref}); err != nil {
row.Refused = append(row.Refused, fmt.Sprintf("%d: %v", n, err))
continue
}
}
row.Linked = append(row.Linked, n)
members = append(members, is)
}
}
out.Linked += len(row.Linked)
out.Refused += len(row.Refused)
if m.Status == MilestoneOpen && len(members) > 0 && len(row.Refused) == 0 {
done := true
for _, is := range members {
done = done && milestoneDone(is)
}
if done {
row.Closed = true
out.Closed++
if !a.DryRun {
if _, err := t.milestoneMove(by, ref, MilestoneClosed,
fmt.Sprintf("seeded closed: every issue done (hq %s)", short(commit))); err != nil {
return nil, err
}
}
}
}
out.Rows = append(out.Rows, row)
}
verb := map[bool]string{true: "Would make", false: "Made"}[a.DryRun]
if out.Made+out.Linked+out.Closed == 0 {
out.Said = fmt.Sprintf("Changed nothing: design %d's %d milestones hold what its table names", a.Design, len(out.Rows))
} else {
out.Said = fmt.Sprintf("%s %d milestones of design %d, linked %d issues and closed %d, from %s at hq %s",
verb, out.Made, a.Design, out.Linked, out.Closed, path, short(commit))
}
if out.Refused > 0 {
out.Said += fmt.Sprintf("; refused %d issues, each named in its row", out.Refused)
}
out.Said += "."
return out, nil
}
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"math/big"
"slices"
"sort"
"strings"
"sync"
@@ -650,6 +651,22 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) {
}
}
}
if by == GroomBy && slices.Contains(madeByPeople, kind) {
return nil, fmt.Errorf("the triage links no %s: that link is made by the one who did the work (ADR 0493 §4)", kind)
}
if kind == LinkMilestone && !a.Remove {
d, id, _ := parseMilestoneRef(target)
m, _, err := t.readMilestone(d, id)
if err != nil {
return nil, err
}
if m == nil {
return nil, fmt.Errorf("there is no milestone %s: it is made from its design's table (milestone-set, or import with milestones)", target)
}
if m.Status != MilestoneOpen {
return nil, fmt.Errorf("milestone %s is %s: a fix found on the way joins its design's open milestone, or a milestone of its own (ADR 0493 §3)", target, m.Status)
}
}
at := t.now()
is, err := t.change(a.Number, Act{Op: EventLinked, By: by}, func(is *Issue) error {
for i, l := range is.Links {
@@ -665,6 +682,12 @@ func (t *Tracker) link(by string, a LinkAsk) (*Linked, error) {
if a.Remove {
return fmt.Errorf("issue %d has no %s link to %s to remove", is.Number, kind, target)
}
if kind == LinkMilestone {
if in := milestoneOf(is); in != "" {
return fmt.Errorf("issue %d is in milestone %s already, and an issue belongs to at most one (ADR 0493 §2): "+
"remove that link first to move it", is.Number, in)
}
}
if len(is.Links) >= MostLinks {
return fmt.Errorf("issue %d has %d links, the most one issue keeps", is.Number, MostLinks)
}