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

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

314 lines
10 KiB
Go

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
}