Merge pull request 'records: list the decision records by topic when asked (hq ADR 0248)' (#115) from feat/records-decisions into main
This commit was merged in pull request #115.
This commit is contained in:
@@ -19,6 +19,7 @@ lagging behind it, and the lag is in `records_status`.
|
||||
| `records_search` `{query, limit?}` | where a phrase appears, as written: document, line, nearest heading, and the commit read |
|
||||
| `records_read` `{path}` | one document, whole |
|
||||
| `records_list` `{folder?}` | what a folder holds |
|
||||
| `records_decisions` `{topic?}` | the decision records by topic, in the reading order `02-DECISIONS/README.md` writes: number, title, path and status, generated when asked and never stored (hq ADR 0248) |
|
||||
| `records_status` | repository, forge, commit and its date, last sync, document count, last error |
|
||||
| `records_sync` | bring the checkout up to date now |
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
// The decision records, by topic, in reading order (novox/hq ADR 0248, issue 297).
|
||||
//
|
||||
// The repository writes its reading order in `02-DECISIONS/README.md`, one line per topic:
|
||||
//
|
||||
// 1. **What the mesh is** — `topic: the mesh`. What it is for, ...
|
||||
//
|
||||
// and each record names its own `topic:`. The list of records under each topic is generated when it is
|
||||
// read and never stored, because a stored list was a line every decision added to one file, and any two
|
||||
// decisions open at once conflicted there. This is the same list hq's own `index.py --print` prints, for a
|
||||
// reader on the mesh rather than in a checkout. Nothing is kept between calls: every answer is read from
|
||||
// the checkout at the commit it names.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DecisionsFolder is where the records and their README live.
|
||||
const DecisionsFolder = "02-DECISIONS"
|
||||
|
||||
var (
|
||||
topicLine = regexp.MustCompile("(?m)^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*\\s+—\\s+`topic:\\s*([^`]+)`")
|
||||
recordFile = regexp.MustCompile(`^(\d{4})-.+\.md$`)
|
||||
recordTitle = regexp.MustCompile(`(?m)^# \d+\.\s*(.+)$`)
|
||||
)
|
||||
|
||||
// Decision is one record, as the list names it.
|
||||
type Decision struct {
|
||||
Number string `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Topic is one topic of the reading order and the records under it, by number.
|
||||
type Topic struct {
|
||||
Topic string `json:"topic"`
|
||||
Label string `json:"label"`
|
||||
Decisions []Decision `json:"decisions"`
|
||||
}
|
||||
|
||||
// DecisionsAnswer is the reading order with its records, and what fits none of its topics.
|
||||
type DecisionsAnswer struct {
|
||||
Topics []Topic `json:"topics"`
|
||||
// Unfiled is every record whose topic is not one of the reading order's — hq's `index.py` fails on it,
|
||||
// so on a merged commit it is empty.
|
||||
Unfiled []Decision `json:"unfiled,omitempty"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// frontField is one `name: value` line of a document's frontmatter.
|
||||
func frontField(text, name string) string {
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return ""
|
||||
}
|
||||
end := strings.Index(text[4:], "\n---")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(text[4:4+end], "\n") {
|
||||
if v, ok := strings.CutPrefix(line, name+":"); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Decisions is the records by topic, in the reading order the README writes; with a topic named, that
|
||||
// topic alone. A repository that writes no reading order is answered with why, not with an empty list.
|
||||
func (r *Records) Decisions(only string) (DecisionsAnswer, error) {
|
||||
folder := filepath.Join(r.Dir(), DecisionsFolder)
|
||||
readme, err := os.ReadFile(filepath.Join(folder, "README.md"))
|
||||
if err != nil {
|
||||
return DecisionsAnswer{}, fmt.Errorf("the repository holds no %s/README.md, so it writes no reading order", DecisionsFolder)
|
||||
}
|
||||
answer := DecisionsAnswer{Topics: []Topic{}}
|
||||
at := map[string]int{}
|
||||
for _, m := range topicLine.FindAllStringSubmatch(string(readme), -1) {
|
||||
name := strings.TrimSpace(m[2])
|
||||
at[name] = len(answer.Topics)
|
||||
answer.Topics = append(answer.Topics, Topic{Topic: name, Label: strings.TrimSpace(m[1]), Decisions: []Decision{}})
|
||||
}
|
||||
if len(answer.Topics) == 0 {
|
||||
return DecisionsAnswer{}, fmt.Errorf("%s/README.md writes no reading order: no line of the form "+
|
||||
"\"1. **Label** — `topic: name`. ...\"", DecisionsFolder)
|
||||
}
|
||||
only = strings.TrimSpace(only)
|
||||
if _, ok := at[only]; only != "" && !ok {
|
||||
names := make([]string, len(answer.Topics))
|
||||
for i, t := range answer.Topics {
|
||||
names[i] = t.Topic
|
||||
}
|
||||
return DecisionsAnswer{}, fmt.Errorf("%q is not a topic of the reading order: %s", only, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
return DecisionsAnswer{}, err
|
||||
}
|
||||
names := []string{}
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() && recordFile.MatchString(e.Name()) {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
raw, err := os.ReadFile(filepath.Join(folder, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := string(raw)
|
||||
d := Decision{
|
||||
Number: recordFile.FindStringSubmatch(name)[1],
|
||||
Title: "(no heading)",
|
||||
Path: DecisionsFolder + "/" + name,
|
||||
Status: frontField(text, "status"),
|
||||
}
|
||||
if m := recordTitle.FindStringSubmatch(text); m != nil {
|
||||
d.Title = strings.TrimSpace(m[1])
|
||||
}
|
||||
topic := frontField(text, "topic")
|
||||
i, ok := at[topic]
|
||||
switch {
|
||||
case !ok && only == "":
|
||||
answer.Unfiled = append(answer.Unfiled, d)
|
||||
case ok && (only == "" || only == topic):
|
||||
answer.Topics[i].Decisions = append(answer.Topics[i].Decisions, d)
|
||||
}
|
||||
}
|
||||
if only != "" {
|
||||
answer.Topics = []Topic{answer.Topics[at[only]]}
|
||||
}
|
||||
answer.Commit = r.Standing().Commit
|
||||
return answer, nil
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// records: the mesh's record — decisions, designs and issues — read where it is written (novox/hq ADR 0025,
|
||||
// ADR 0153). A Go bundle the node's runtime launches; it keeps a checkout of one repository current on every
|
||||
// merge the forge announces and on a timer, and answers five questions about it. stdout is the MCP channel;
|
||||
// merge the forge announces and on a timer, and answers six questions about it. stdout is the MCP channel;
|
||||
// what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
@@ -51,6 +51,11 @@ func tools(r *Records) []stdio.Tool {
|
||||
Description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
|
||||
Input: map[string]any{"folder": str("a folder inside the repository (optional)")},
|
||||
Run: func(a map[string]any) (any, error) { return r.List(strArg(a, "folder")) }},
|
||||
{Name: "records_decisions",
|
||||
Description: "The decision records by topic, in the reading order the repository writes in 02-DECISIONS/README.md: " +
|
||||
"each record's number, title, path and status. Generated from the records when asked, never stored (novox/hq ADR 0248).",
|
||||
Input: map[string]any{"topic": str("one topic of the reading order, e.g. \"how we work\" (optional; every topic when omitted)")},
|
||||
Run: func(a map[string]any) (any, error) { return r.Decisions(strArg(a, "topic")) }},
|
||||
{Name: "records_status",
|
||||
Description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
|
||||
Run: func(map[string]any) (any, error) { return r.Standing(), nil }},
|
||||
@@ -118,7 +123,7 @@ func keep(r *Records) {
|
||||
func main() {
|
||||
r, err := FromEnv(os.Getenv)
|
||||
if err != nil {
|
||||
// Without a repository to read there is nothing to answer: no tools rather than five that fail.
|
||||
// Without a repository to read there is nothing to answer: no tools rather than six that fail.
|
||||
fmt.Fprintf(os.Stderr, "[records] no tools — %v\n", err)
|
||||
if err := stdio.Serve("", nil); err != nil {
|
||||
os.Exit(1)
|
||||
|
||||
@@ -145,3 +145,63 @@ func TestTheReaderRefusesToGuess(t *testing.T) {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The decision records by topic, in the order the README writes, generated from each record's own
|
||||
// frontmatter (novox/hq ADR 0248): a record lands under its topic, by number, with its status; a record
|
||||
// whose topic the reading order does not name is unfiled rather than lost; a topic asked for alone is
|
||||
// answered alone; a repository with no reading order says so.
|
||||
func TestTheDecisionsAreListedByTopicInTheReadingOrderTheReadmeWrites(t *testing.T) {
|
||||
origin := aRepository(t)
|
||||
write(t, filepath.Join(origin, "02-DECISIONS/README.md"), "# 02-DECISIONS\n\n## Reading order\n\n"+
|
||||
"1. **What the mesh is** — `topic: the mesh`. What it is for.\n"+
|
||||
"2. **How we work** — `topic: how we work`. This repository.\n")
|
||||
write(t, filepath.Join(origin, "02-DECISIONS/0001-a-decision.md"),
|
||||
"---\ntopic: how we work\nstatus: accepted\n---\n\n# 1. A decision\n")
|
||||
write(t, filepath.Join(origin, "02-DECISIONS/0003-a-later-one.md"),
|
||||
"---\ntopic: the mesh\nstatus: superseded\nsuperseded-by: 02-DECISIONS/0004-x.md\n---\n\n# 3. A later one\n")
|
||||
write(t, filepath.Join(origin, "02-DECISIONS/0002-an-earlier-one.md"),
|
||||
"---\ntopic: the mesh\nstatus: accepted\n---\n\n# 2. An earlier one\n")
|
||||
write(t, filepath.Join(origin, "02-DECISIONS/0005-astray.md"),
|
||||
"---\ntopic: elsewhere\nstatus: proposed\n---\n\n# 5. Astray\n")
|
||||
run(t, origin, "add", "-A")
|
||||
run(t, origin, "commit", "--quiet", "-m", "records")
|
||||
|
||||
r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin}
|
||||
r.Sync()
|
||||
got, err := r.Decisions("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Topics) != 2 || got.Topics[0].Topic != "the mesh" || got.Topics[1].Label != "How we work" {
|
||||
t.Fatalf("not the reading order: %+v", got.Topics)
|
||||
}
|
||||
mesh := got.Topics[0].Decisions
|
||||
if len(mesh) != 2 || mesh[0].Number != "0002" || mesh[1].Number != "0003" || mesh[1].Status != "superseded" ||
|
||||
mesh[0].Title != "An earlier one" || mesh[0].Path != "02-DECISIONS/0002-an-earlier-one.md" {
|
||||
t.Fatalf("the mesh's records: %+v", mesh)
|
||||
}
|
||||
if len(got.Topics[1].Decisions) != 1 || got.Topics[1].Decisions[0].Number != "0001" {
|
||||
t.Fatalf("how we work's records: %+v", got.Topics[1].Decisions)
|
||||
}
|
||||
if len(got.Unfiled) != 1 || got.Unfiled[0].Number != "0005" {
|
||||
t.Fatalf("a record off the reading order is not unfiled: %+v", got.Unfiled)
|
||||
}
|
||||
if got.Commit == "" {
|
||||
t.Fatal("the answer names no commit")
|
||||
}
|
||||
|
||||
one, err := r.Decisions("how we work")
|
||||
if err != nil || len(one.Topics) != 1 || one.Topics[0].Topic != "how we work" || len(one.Unfiled) != 0 {
|
||||
t.Fatalf("one topic: %+v %v", one, err)
|
||||
}
|
||||
if _, err := r.Decisions("nowhere"); err == nil || !strings.Contains(err.Error(), "the mesh, how we work") {
|
||||
t.Fatalf("an unknown topic is not refused with the topics: %v", err)
|
||||
}
|
||||
|
||||
write(t, filepath.Join(origin, "02-DECISIONS/README.md"), "# 02-DECISIONS\n\nNo order here.\n")
|
||||
run(t, origin, "commit", "--quiet", "-am", "no order")
|
||||
r.Sync()
|
||||
if _, err := r.Decisions(""); err == nil || !strings.Contains(err.Error(), "writes no reading order") {
|
||||
t.Fatalf("a README with no reading order is not said: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"records_search",
|
||||
"records_read",
|
||||
"records_list",
|
||||
"records_decisions",
|
||||
"records_status",
|
||||
"records_sync"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user