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.
314 lines
10 KiB
Go
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
|
|
}
|