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 }