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.
This commit is contained in:
@@ -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, ", "))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+1146
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user