mesh-issues: import hq's issue folders at their own numbers, and an issue with a folder is whole in the tracker (hq ADR 0275, to-be 53 slice 3)
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-issues → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// An issue report in hq, `04-ISSUES/NNN-slug/00-report.md`, as the import of slice 3 reads it (novox/hq
|
||||
// to-be 53 §8): a YAML frontmatter of a few keys, a heading that is the title, and the report. The
|
||||
// frontmatter is read as hq's own checks read it (00-META/checks/cycle.py), a little more carefully: a
|
||||
// list in brackets may run over lines and hold commas inside parentheses or quotes, a block list's items
|
||||
// may be quoted, and a long value may wrap onto an indented line.
|
||||
|
||||
// Report is one issue report as read from its folder.
|
||||
type Report struct {
|
||||
Number uint64
|
||||
// Folder is the issue's folder, `04-ISSUES/NNN-slug`.
|
||||
Folder string
|
||||
// Path is the report, `<Folder>/00-report.md`.
|
||||
Path string
|
||||
Title string
|
||||
Status string
|
||||
// Opened and Closed are dates as the frontmatter gives them, YYYY-MM-DD, or empty.
|
||||
Opened string
|
||||
Closed string
|
||||
LocatedIn []string
|
||||
FixedBy []string
|
||||
AmendedDesign []string
|
||||
Replay string
|
||||
ReplayNone string
|
||||
// Body is the report without its frontmatter and its title.
|
||||
Body string
|
||||
// Issues and Decisions are the other issues and the decision records the report names.
|
||||
Issues []uint64
|
||||
Decisions []string
|
||||
// Odd is what was odd about the report and did not stop it from being read.
|
||||
Odd []string
|
||||
}
|
||||
|
||||
var (
|
||||
issueFolder = regexp.MustCompile(`^([0-9]{1,4})-([a-z0-9]+(?:-[a-z0-9]+)*)$`)
|
||||
frontKey = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9-]*):(.*)$`)
|
||||
blockItem = regexp.MustCompile(`^\s+-\s*(.*)$`)
|
||||
continued = regexp.MustCompile(`^\s+(\S.*)$`)
|
||||
heading = regexp.MustCompile(`(?m)^#\s+(.+?)\s*$`)
|
||||
numbered = regexp.MustCompile(`^([0-9]{1,4})\s*([.:]|—|–|-)?\s*(.*)$`)
|
||||
dateShape = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`)
|
||||
namesIssue = regexp.MustCompile(`(?i)\bissue ([0-9]{1,4})\b|04-ISSUES/([0-9]{1,4})-`)
|
||||
namesRecord = regexp.MustCompile(`\bADR ([0-9]{4})\b|02-DECISIONS/([0-9]{4})-`)
|
||||
)
|
||||
|
||||
// folderNumber is the number an issue folder's name carries, or false for a name that is not one.
|
||||
func folderNumber(name string) (uint64, bool) {
|
||||
m := issueFolder.FindStringSubmatch(name)
|
||||
if m == nil || len(m[2]) > 120 {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseUint(m[1], 10, 64)
|
||||
if err != nil || n == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// frontmatter is the YAML block between the first two `---` lines as {key: values}, and the text after
|
||||
// it. A scalar is one value; a list, in brackets or as block items, its items; an empty value no values.
|
||||
// A key given twice keeps the last. False when the text has no frontmatter.
|
||||
func frontmatter(text string) (map[string][]string, string, bool) {
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return nil, "", false
|
||||
}
|
||||
rest := text[4:]
|
||||
end := -1
|
||||
if strings.HasPrefix(rest, "---\n") || rest == "---" {
|
||||
end = 0
|
||||
} else if i := strings.Index(rest, "\n---\n"); i >= 0 {
|
||||
end = i + 1
|
||||
} else if strings.HasSuffix(rest, "\n---") {
|
||||
end = len(rest) - 3
|
||||
}
|
||||
if end < 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
block := rest[:end]
|
||||
body := strings.TrimPrefix(rest[end:], "---")
|
||||
body = strings.TrimPrefix(body, "\n")
|
||||
|
||||
out := map[string][]string{}
|
||||
key := ""
|
||||
const (
|
||||
none = iota
|
||||
scalar
|
||||
list
|
||||
flow
|
||||
)
|
||||
mode := none
|
||||
flowText := ""
|
||||
closeFlow := func() {
|
||||
out[key] = splitFlow(flowText)
|
||||
mode = list
|
||||
}
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
continue
|
||||
}
|
||||
if mode == flow {
|
||||
flowText += " " + strings.TrimSpace(line)
|
||||
if flowClosed(flowText) {
|
||||
closeFlow()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m := frontKey.FindStringSubmatch(line); m != nil && !strings.HasPrefix(line, " ") {
|
||||
key = m[1]
|
||||
value := strings.TrimSpace(m[2])
|
||||
switch {
|
||||
case value == "":
|
||||
out[key] = nil
|
||||
mode = list
|
||||
case strings.HasPrefix(value, "["):
|
||||
flowText = value
|
||||
mode = flow
|
||||
if flowClosed(flowText) {
|
||||
closeFlow()
|
||||
}
|
||||
default:
|
||||
out[key] = []string{unquote(value)}
|
||||
mode = scalar
|
||||
}
|
||||
continue
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if m := blockItem.FindStringSubmatch(line); m != nil && mode == list {
|
||||
out[key] = append(out[key], unquote(strings.TrimSpace(m[1])))
|
||||
continue
|
||||
}
|
||||
if m := continued.FindStringSubmatch(line); m != nil && len(out[key]) > 0 {
|
||||
last := len(out[key]) - 1
|
||||
out[key][last] = strings.TrimSpace(out[key][last] + " " + strings.TrimSpace(m[1]))
|
||||
}
|
||||
}
|
||||
if mode == flow {
|
||||
// Never closed: what was read of it, without the bracket.
|
||||
out[key] = splitFlow(flowText + "]")
|
||||
}
|
||||
return out, body, true
|
||||
}
|
||||
|
||||
// flowClosed says whether a bracketed list has its closing bracket, outside quotes. A quote opens an item
|
||||
// only at the item's start: an apostrophe inside prose ("the provisioner's ACL") is no quote.
|
||||
func flowClosed(s string) bool {
|
||||
depth, quote, atStart := 0, rune(0), true
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case quote != 0:
|
||||
if r == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
case (r == '"' || r == '\'') && atStart:
|
||||
quote = r
|
||||
case r == '[':
|
||||
depth++
|
||||
case r == ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
atStart = r == '[' || r == ',' || atStart && unicode.IsSpace(r)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitFlow is a bracketed list's items: split on the commas outside parentheses, brackets and quotes,
|
||||
// a quote opening an item only at its start.
|
||||
func splitFlow(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "[")
|
||||
s = strings.TrimSuffix(s, "]")
|
||||
var items []string
|
||||
var cur strings.Builder
|
||||
depth, quote, atStart := 0, rune(0), true
|
||||
flush := func() {
|
||||
if v := unquote(strings.TrimSpace(cur.String())); v != "" {
|
||||
items = append(items, v)
|
||||
}
|
||||
cur.Reset()
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case quote != 0:
|
||||
if r == quote {
|
||||
quote = 0
|
||||
}
|
||||
case (r == '"' || r == '\'') && atStart:
|
||||
quote = r
|
||||
case r == '(' || r == '[':
|
||||
depth++
|
||||
case r == ')' || r == ']':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
case r == ',' && depth == 0:
|
||||
flush()
|
||||
atStart = true
|
||||
continue
|
||||
}
|
||||
cur.WriteRune(r)
|
||||
atStart = atStart && unicode.IsSpace(r)
|
||||
}
|
||||
flush()
|
||||
return items
|
||||
}
|
||||
|
||||
// unquote strips one pair of matching quotes around a value, and the escapes a double-quoted YAML
|
||||
// scalar uses for its own quote.
|
||||
func unquote(v string) string {
|
||||
if len(v) >= 2 && (v[0] == '"' && v[len(v)-1] == '"' || v[0] == '\'' && v[len(v)-1] == '\'') {
|
||||
inner := v[1 : len(v)-1]
|
||||
if v[0] == '"' {
|
||||
inner = strings.ReplaceAll(inner, `\"`, `"`)
|
||||
} else {
|
||||
inner = strings.ReplaceAll(inner, `''`, `'`)
|
||||
}
|
||||
return strings.TrimSpace(inner)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ParseReport reads one issue report. The folder is its name, `NNN-slug`; the text is the report whole.
|
||||
// What makes a report unreadable is an error: no frontmatter, no title, no status, or a status that
|
||||
// is not one of the five. What is odd but readable is said in Odd.
|
||||
func ParseReport(folder, text string) (*Report, error) {
|
||||
n, ok := folderNumber(folder)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%q is not an issue folder's name: NNN-slug, lower-case words joined by hyphens", folder)
|
||||
}
|
||||
front, body, ok := frontmatter(text)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s/00-report.md has no frontmatter", folder)
|
||||
}
|
||||
r := &Report{Number: n, Folder: "04-ISSUES/" + folder, Path: "04-ISSUES/" + folder + "/00-report.md"}
|
||||
one := func(key string) string {
|
||||
if v := front[key]; len(v) > 0 {
|
||||
if len(v) > 1 {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("%s: is a list of %d; one value was expected, and they were joined", key, len(v)))
|
||||
return strings.Join(v, "; ")
|
||||
}
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
r.Status = one("status")
|
||||
if r.Status == "" {
|
||||
return nil, fmt.Errorf("%s/00-report.md has no status", folder)
|
||||
}
|
||||
if !known(r.Status) {
|
||||
return nil, fmt.Errorf("%s/00-report.md has the status %q, not one of %s", folder, r.Status, strings.Join(Statuses, ", "))
|
||||
}
|
||||
r.Opened = one("opened")
|
||||
if r.Opened != "" && !dateShape.MatchString(r.Opened) {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("opened: %q is not a date", r.Opened))
|
||||
r.Opened = ""
|
||||
}
|
||||
for _, key := range []string{"resolved", "closed"} {
|
||||
if v := one(key); v != "" {
|
||||
if dateShape.MatchString(v) {
|
||||
r.Closed = v
|
||||
} else {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("%s: %q is not a date", key, v))
|
||||
}
|
||||
}
|
||||
}
|
||||
r.LocatedIn = front["located-in"]
|
||||
r.FixedBy = front["fixed-by"]
|
||||
r.AmendedDesign = front["amended-design"]
|
||||
r.Replay = one("replay")
|
||||
r.ReplayNone = one("replay-none")
|
||||
imported := map[string]bool{"status": true, "opened": true, "resolved": true, "closed": true, "located-in": true,
|
||||
"fixed-by": true, "amended-design": true, "replay": true, "replay-none": true}
|
||||
var unknown []string
|
||||
for k := range front {
|
||||
if !imported[k] {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
r.Odd = append(r.Odd, "frontmatter keys not imported: "+strings.Join(unknown, ", "))
|
||||
}
|
||||
|
||||
m := heading.FindStringSubmatchIndex(body)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("%s/00-report.md has no title: no `# ` heading", folder)
|
||||
}
|
||||
title := body[m[2]:m[3]]
|
||||
// "362. The title", "043 — The title": the number and its separator are the folder's, not the title's. A
|
||||
// title that starts with a bare number ("2 nodes …") keeps it, unless the number is the folder's.
|
||||
if nm := numbered.FindStringSubmatch(title); nm != nil {
|
||||
heads, _ := strconv.ParseUint(nm[1], 10, 64)
|
||||
if nm[2] != "" || heads == n {
|
||||
title = nm[3]
|
||||
if heads != n && title != "" {
|
||||
r.Odd = append(r.Odd, fmt.Sprintf("the title is numbered %s; the folder is %d", nm[1], n))
|
||||
}
|
||||
}
|
||||
}
|
||||
r.Title = strings.TrimSpace(title)
|
||||
if r.Title == "" {
|
||||
return nil, fmt.Errorf("%s/00-report.md has an empty title", folder)
|
||||
}
|
||||
r.Body = strings.TrimSpace(body[:m[0]] + body[m[1]:])
|
||||
|
||||
seen := map[uint64]bool{n: true}
|
||||
for _, mm := range namesIssue.FindAllStringSubmatch(text, -1) {
|
||||
if other, err := strconv.ParseUint(mm[1]+mm[2], 10, 64); err == nil && !seen[other] {
|
||||
seen[other] = true
|
||||
r.Issues = append(r.Issues, other)
|
||||
}
|
||||
}
|
||||
sort.Slice(r.Issues, func(i, j int) bool { return r.Issues[i] < r.Issues[j] })
|
||||
records := map[string]bool{}
|
||||
for _, mm := range namesRecord.FindAllStringSubmatch(text, -1) {
|
||||
if d := mm[1] + mm[2]; !records[d] {
|
||||
records[d] = true
|
||||
r.Decisions = append(r.Decisions, d)
|
||||
}
|
||||
}
|
||||
sort.Strings(r.Decisions)
|
||||
return r, nil
|
||||
}
|
||||
Reference in New Issue
Block a user