Files
mesh-catalog/modules/mesh-issues/cmd/mesh-issues/report.go
T
jochen 11afde55d6
mesh/delivery-group group feat/399-fixing-status checking: 0 of 2 member(s) ready
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 superseded: a newer head of the same pull request
mesh-issues: an issue says who works on it, and resolves only from fixing (hq issue 399)
Two agents could start on one issue without seeing each other, and an issue
could be resolved without anyone having said they started its fix. A move to
diagnosing or fixing now names by, the worker as who@node; get and list show
the worker with since and stale; another worker is refused while it is
current unless take-over says why (noted); a worker untouched for
worker-stale-after (24h) may be taken over from without a reason. Resolved is
reached only from fixing. The moved event carries by, since and caller.
Fixing counts as not closed for the triage and the duplicate check.
hq ADR 0283.
2026-10-10 21:04:32 +02:00

340 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 six. 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
}