vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Issues and requests are frontmatter, not JSON
307cbc2aa624ca9b76b79849e8ce8926414be435 · Keenan Nemetz · 17d ago · parent 427c4761
Issues and requests are frontmatter, not JSON
issues/<ulid>/issue.md and requests/<ulid>/request.md replace meta.json plus
description.md, and approvals/<account>.md replaces approvals/<account>.json.
Every file on refs/heads/vole is now frontmatter markdown read by one package, so
there is one reader and one writer for issues, requests, comments and approvals
instead of a JSON path and a hand-rolled key/value path.
The diff is the interface. A JSON array rewrites its neighbour to add a comma, so
one label cost two changed lines and removing one cost three; a block sequence
costs one. docs/spikes/issue-frontmatter measured the same six edits at 25 changed
lines as meta.json and 10 as frontmatter, and one file per entity means one path
to diff, blame and log.
Three rules make YAML safe to hand-edit rather than something to police:
- the writer always double-quotes free text, so no indicator, reserved word or
" # " can change what a title means
- a label is quoted only when the parser would not read it back bare ("null"),
which leaves ordinary labels as they were
- a reader refuses an unquoted title that the parser read differently: "title:
fix #12 crash" would otherwise be stored as "fix" and nobody would find out
Control characters are refused on both sides, so a tree the server accepts is a
tree it can always rewrite.
`updated` is gone from issues, requests and the index's issues table. It cost a
changed line on every edit — 11 of the 21 frontmatter lines in the spike's
sequence — and the commit that changed the file already carries the time.
Verified: TestRenderParseRoundTrip puts 13 titles that break naive YAML (hash,
colon, leading dash, null, quotes, backslash, unicode, 190 bytes) through the
writer and the reader; the server tests push a rendered issue over SSH and render
it, and the approval-ownership tests withdraw approvals/<account>.md. Then
scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
internal/frontmatter/frontmatter.go +250
| @@ -0,0 +1,250 @@ | |||
| 1 | // Package frontmatter reads and writes the fenced block that every metadata file | ||
| 2 | // on refs/heads/vole begins with: `key: value` lines between two `---` fences, | ||
| 3 | // then the markdown body. | ||
| 4 | // | ||
| 5 | // --- | ||
| 6 | // title: "crash on startup" | ||
| 7 | // state: open | ||
| 8 | // labels: | ||
| 9 | // - bug | ||
| 10 | // --- | ||
| 11 | // It broke. | ||
| 12 | // | ||
| 13 | // The writer decides the shape of the file so the reader can stay boring. Free | ||
| 14 | // text is always double-quoted, which means no YAML indicator, reserved word or | ||
| 15 | // `#` can change what it means. Constrained values (a state, a timestamp, an | ||
| 16 | // account name) are written bare and quoted only when the parser would not read | ||
| 17 | // them back unchanged. A list is one item per line, so adding an entry is an | ||
| 18 | // added line and never a rewritten one — which is the whole reason this format | ||
| 19 | // replaced JSON. | ||
| 20 | // | ||
| 21 | // The one thing quoting cannot fix is a hand-edit that drops the quotes on a | ||
| 22 | // title containing ` # `: YAML would read a comment and silently store a | ||
| 23 | // shorter title. RoundTrip closes that hole by comparing what the parser saw | ||
| 24 | // with what the line said. | ||
| 25 | package frontmatter | ||
| 26 | |||
| 27 | import ( | ||
| 28 | "errors" | ||
| 29 | "fmt" | ||
| 30 | "io" | ||
| 31 | "strings" | ||
| 32 | |||
| 33 | "gopkg.in/yaml.v3" | ||
| 34 | ) | ||
| 35 | |||
| 36 | const fence = "---\n" | ||
| 37 | |||
| 38 | // Split separates a file into its head (the text between the fences, without | ||
| 39 | // them) and its body. Only the first closing fence ends the head, so a body may | ||
| 40 | // contain `---` rules of its own. | ||
| 41 | func Split(raw []byte) (head, body string, err error) { | ||
| 42 | s := string(raw) | ||
| 43 | if !strings.HasPrefix(s, fence) { | ||
| 44 | return "", "", errors.New("a metadata file must open with --- and its frontmatter") | ||
| 45 | } | ||
| 46 | rest := s[len(fence):] | ||
| 47 | if i := strings.Index(rest, "\n"+fence); i >= 0 { | ||
| 48 | return rest[:i], rest[i+1+len(fence):], nil | ||
| 49 | } | ||
| 50 | // A hand-written file may end at the closing fence without a trailing newline. | ||
| 51 | if strings.HasSuffix(rest, "\n---") { | ||
| 52 | return rest[:len(rest)-len("\n---")], "", nil | ||
| 53 | } | ||
| 54 | return "", "", errors.New("unterminated frontmatter: the closing --- is missing") | ||
| 55 | } | ||
| 56 | |||
| 57 | // Decode reads the head into v, refusing fields v does not declare. An empty head | ||
| 58 | // decodes to v unchanged. | ||
| 59 | func Decode(head string, v any) error { | ||
| 60 | dec := yaml.NewDecoder(strings.NewReader(head)) | ||
| 61 | dec.KnownFields(true) | ||
| 62 | if err := dec.Decode(v); err != nil { | ||
| 63 | if errors.Is(err, io.EOF) { | ||
| 64 | return nil | ||
| 65 | } | ||
| 66 | return err | ||
| 67 | } | ||
| 68 | return nil | ||
| 69 | } | ||
| 70 | |||
| 71 | // RoundTrip rejects an unquoted field that the parser read as something other | ||
| 72 | // than what the line says. A quoted value is taken at face value: the writer | ||
| 73 | // chose the quotes, and YAML reads them back exactly. | ||
| 74 | // | ||
| 75 | // Without this, `title: fix #12 crash` is stored as "fix" and nobody finds out | ||
| 76 | // until the bug is quoted in a search that misses it. | ||
| 77 | func RoundTrip(head, key, got string) error { | ||
| 78 | raw, ok := bareValue(head, key) | ||
| 79 | if !ok { | ||
| 80 | return nil | ||
| 81 | } | ||
| 82 | if raw == got { | ||
| 83 | return nil | ||
| 84 | } | ||
| 85 | return fmt.Errorf("%s must be double-quoted: the line reads %q but the value parsed as %q", key, raw, got) | ||
| 86 | } | ||
| 87 | |||
| 88 | // Text is the reader-side check for a free-text field: it must have survived the | ||
| 89 | // parse unchanged, and it must fit on one line. Both halves matter — a truncated | ||
| 90 | // title and a two-line title are the same bug seen from different ends. | ||
| 91 | func Text(head, key, value string) error { | ||
| 92 | if err := RoundTrip(head, key, value); err != nil { | ||
| 93 | return err | ||
| 94 | } | ||
| 95 | return ValidText(value) | ||
| 96 | } | ||
| 97 | |||
| 98 | // bareValue returns the text of a top-level unquoted `key: value` line, and | ||
| 99 | // reports whether the line was bare (rather than quoted or absent). | ||
| 100 | func bareValue(head, key string) (string, bool) { | ||
| 101 | for _, line := range strings.Split(head, "\n") { | ||
| 102 | if !strings.HasPrefix(line, key+":") { | ||
| 103 | continue | ||
| 104 | } | ||
| 105 | value := strings.TrimSpace(strings.TrimPrefix(line, key+":")) | ||
| 106 | if strings.HasPrefix(value, `"`) || strings.HasPrefix(value, "'") { | ||
| 107 | return "", false | ||
| 108 | } | ||
| 109 | return value, true | ||
| 110 | } | ||
| 111 | return "", false | ||
| 112 | } | ||
| 113 | |||
| 114 | // Doc accumulates fields in the order they are written and renders the file. It | ||
| 115 | // records the first problem instead of returning an error from every setter, so | ||
| 116 | // a constructor can read as a list of fields. | ||
| 117 | type Doc struct { | ||
| 118 | lines []string | ||
| 119 | err error | ||
| 120 | } | ||
| 121 | |||
| 122 | // New starts an empty document. | ||
| 123 | func New() *Doc { return &Doc{} } | ||
| 124 | |||
| 125 | // Text writes a free-text field, always double-quoted. | ||
| 126 | func (d *Doc) Text(key, value string) { | ||
| 127 | q, err := quote(value) | ||
| 128 | if err != nil { | ||
| 129 | d.fail(err) | ||
| 130 | return | ||
| 131 | } | ||
| 132 | d.lines = append(d.lines, key+": "+q) | ||
| 133 | } | ||
| 134 | |||
| 135 | // Word writes a constrained value bare, quoting it only if the parser would not | ||
| 136 | // read it back unchanged. | ||
| 137 | func (d *Doc) Word(key, value string) { | ||
| 138 | if plain(value) { | ||
| 139 | d.lines = append(d.lines, key+": "+value) | ||
| 140 | return | ||
| 141 | } | ||
| 142 | q, err := quote(value) | ||
| 143 | if err != nil { | ||
| 144 | d.fail(err) | ||
| 145 | return | ||
| 146 | } | ||
| 147 | d.lines = append(d.lines, key+": "+q) | ||
| 148 | } | ||
| 149 | |||
| 150 | // List writes a sequence with one item per line. Labels are quoted only when a | ||
| 151 | // bare item would not survive the parse (`null` is the one a forge actually | ||
| 152 | // hits). | ||
| 153 | func (d *Doc) List(key string, values []string) { | ||
| 154 | if len(values) == 0 { | ||
| 155 | return | ||
| 156 | } | ||
| 157 | d.lines = append(d.lines, key+":") | ||
| 158 | for _, v := range values { | ||
| 159 | if plain(v) { | ||
| 160 | d.lines = append(d.lines, " - "+v) | ||
| 161 | continue | ||
| 162 | } | ||
| 163 | q, err := quote(v) | ||
| 164 | if err != nil { | ||
| 165 | d.fail(err) | ||
| 166 | return | ||
| 167 | } | ||
| 168 | d.lines = append(d.lines, " - "+q) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | // Bytes renders the document and the body that follows it. An empty body leaves | ||
| 173 | // the file ending at the closing fence, which is what a record with no prose | ||
| 174 | // (an approval) looks like. | ||
| 175 | func (d *Doc) Bytes(body string) ([]byte, error) { | ||
| 176 | if d.err != nil { | ||
| 177 | return nil, d.err | ||
| 178 | } | ||
| 179 | var b strings.Builder | ||
| 180 | b.WriteString(fence) | ||
| 181 | for _, line := range d.lines { | ||
| 182 | b.WriteString(line) | ||
| 183 | b.WriteByte('\n') | ||
| 184 | } | ||
| 185 | b.WriteString(fence) | ||
| 186 | if body == "" { | ||
| 187 | return []byte(b.String()), nil | ||
| 188 | } | ||
| 189 | b.WriteString(body) | ||
| 190 | if !strings.HasSuffix(body, "\n") { | ||
| 191 | b.WriteByte('\n') | ||
| 192 | } | ||
| 193 | return []byte(b.String()), nil | ||
| 194 | } | ||
| 195 | |||
| 196 | func (d *Doc) fail(err error) { | ||
| 197 | if d.err == nil { | ||
| 198 | d.err = err | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | // ValidText reports whether s can be written as a quoted scalar: one line, no | ||
| 203 | // control characters. The writer refuses exactly these, so a value the validator | ||
| 204 | // accepts is a value the writer can reproduce — a tree the reader takes but the | ||
| 205 | // writer cannot render is a tree nobody can close an issue in. | ||
| 206 | func ValidText(s string) error { | ||
| 207 | for _, r := range s { | ||
| 208 | if r < 0x20 || r == 0x7f || r == '\u2028' || r == '\u2029' { | ||
| 209 | return fmt.Errorf("control character %q is not allowed here; keep the value on one line", r) | ||
| 210 | } | ||
| 211 | } | ||
| 212 | return nil | ||
| 213 | } | ||
| 214 | |||
| 215 | // quote renders a double-quoted scalar. | ||
| 216 | func quote(s string) (string, error) { | ||
| 217 | if err := ValidText(s); err != nil { | ||
| 218 | return "", err | ||
| 219 | } | ||
| 220 | var b strings.Builder | ||
| 221 | b.WriteByte('"') | ||
| 222 | for _, r := range s { | ||
| 223 | switch r { | ||
| 224 | case '"': | ||
| 225 | b.WriteString(`\"`) | ||
| 226 | case '\\': | ||
| 227 | b.WriteString(`\\`) | ||
| 228 | default: | ||
| 229 | b.WriteRune(r) | ||
| 230 | } | ||
| 231 | } | ||
| 232 | b.WriteByte('"') | ||
| 233 | return b.String(), nil | ||
| 234 | } | ||
| 235 | |||
| 236 | // plain reports whether s can be written bare and read back unchanged. It asks | ||
| 237 | // the parser rather than carrying a list of YAML's reserved words, so the answer | ||
| 238 | // cannot drift from the reader. | ||
| 239 | func plain(s string) bool { | ||
| 240 | if s == "" || strings.TrimSpace(s) != s { | ||
| 241 | return false | ||
| 242 | } | ||
| 243 | var probe struct { | ||
| 244 | K string `yaml:"k"` | ||
| 245 | } | ||
| 246 | if err := yaml.Unmarshal([]byte("k: "+s+"\n"), &probe); err != nil { | ||
| 247 | return false | ||
| 248 | } | ||
| 249 | return probe.K == s | ||
| 250 | } | ||