Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 ./...).

docs/spikes/issue-frontmatter/README.md +200

@@ -0,0 +1,200 @@
1 # Spike: frontmatter as the issue format on `refs/heads/vole`
2
3 **Question.** Issues are `issues/<ulid>/meta.json` + `description.md` today. If the
4 metadata became YAML frontmatter on a single `issue.md`, would the diffs actually
5 get cleaner, and how much of YAML's syntax would the validator have to police?
6
7 **Short answer.** Yes, and almost none — if the writer always double-quotes free
8 text. Measured: 25 changed lines today versus 10 for the same six edits, with the
9 only new rule being "an unquoted title must parse back to its own text".
10
11 ## Method
12
13 `main.go` has two modes.
14
15 ```sh
16 go run . -mode probe # 36 adversarial titles, 15 labels
17 go run . -mode diffs # 7-step edit sequence, real git repos, both formats
18 go run . -mode diffs -no-updated # same, with the updated field dropped
19 ```
20
21 `-mode diffs` initialises a throwaway git repo per format, commits the same edit
22 sequence, and counts what `git diff --numstat` reports — not what the writer
23 believes it wrote. The JSON side reproduces the `meta` struct in
24 `internal/issues/issues.go` — field order, `omitempty` set, two-space
25 `MarshalIndent` — so the numbers describe the format that ships.
26
27 ## Results: changed lines per edit
28
29 One issue, six edits: add a label, close, reopen, retitle, edit the body, remove
30 the last label.
31
32 | Edit | `meta.json` + `description.md` | `issue.md` frontmatter |
33 |---|---|---|
34 | add label | +3 −1 = **4** | +2 −0 = **2** |
35 | close | 4 | 4 |
36 | reopen | 4 | 4 |
37 | retitle | 4 | 4 |
38 | edit body | 4 | 4 |
39 | remove last label | **5** | **3** |
40 | **total** | **25** | **21** |
41
42 The label rows are the difference the format was chosen for: JSON rewrites the
43 previous array element to add a comma, so one label costs two lines and removing
44 one costs three.
45
46 Totals for a labelless issue getting its first label and losing it again:
47
48 | | JSON | frontmatter |
49 |---|---|---|
50 | add first label | **6** | **3** |
51 | remove it again | 5 | 4 |
52 | total | **11** | **7** |
53
54 ### The `updated` field is most of the remaining diff
55
56 Dropping `updated` from both formats, same six edits:
57
58 | | JSON | frontmatter |
59 |---|---|---|
60 | one issue, six edits | **14** | **10** |
61 | first label ever | **10** | **4** |
62
63 So 11 of the 21 frontmatter lines are the timestamp, and 11 of JSON's 25. Neither
64 format needs it: the commit already carries the time, `git log -- issues/<id>`
65 answers "when did this change" per file, and the field is written by `Update`
66 whether or not anyone asked a question it answers.
67
68 Two smaller measured wins:
69
70 - A title containing markup survives readable. JSON writes
71 `"title": "crash on a\u003cb\u003e \u0026 c"`; frontmatter writes
72 `title: "crash on a<b> & c"`.
73 - An edit touches one file instead of two, because the body lives under the
74 metadata instead of beside it.
75
76 ## Results: which YAML cases need policing
77
78 36 titles through three writers: (a) `yaml.Marshal` of a struct, (b) a writer
79 that always double-quotes, (c) a human editing by hand and forgetting the quotes.
80
81 | | count |
82 |---|---|
83 | round-trip correctly through `yaml.Marshal` | 36/36 |
84 | folded onto more than one line by `yaml.Marshal` (200-char title included) | **0/36** |
85 | round-trip through an always-quoting writer | **36/36** |
86 | change value when hand-edited unquoted | 18/36 |
87 | …with a parse error (loud: `pre-receive` names the file) | 11 |
88 | …becoming empty, silently (`&anchor`, `!tag`, `null`, `~`) | 4 |
89 | …truncated at `#`, silently | 1 |
90 | …differing only by trimmed whitespace | 2 |
91 | rejected by the rule below | 16 |
92
93 The eleven loud ones are the YAML indicators — `: `, `- `, `*`, `%`, `@`, `` ` ``,
94 `{`, `[`, `>`, `|`, `?`. A hand-edit that drops the quotes on those gets a parse
95 error, which is the behaviour you want.
96
97 The silent ones are the hazard, and there are two shapes:
98
99 ```
100 title: fix #12 crash → "fix" a comment starts at " #"
101 title: &anchor → "" parsed as an anchor, not a string
102 title: null → ""
103 ```
104
105 Only the first is real data loss — the others are a scoped title that a YAML
106 reader cannot see as a string. Both are caught by the rule below. The two
107 whitespace cases are not corruption: `New` and the validator already trim titles.
108
109 **The rule that closes it:** a title line that is not quoted must parse back to
110 exactly its own text; otherwise reject it and say why. Quoted lines are taken at
111 face value. That catches all 16 ambiguous cases (including the `#` truncation)
112 without a single reserved-word list, and it cannot drift from the parser because
113 it asks the parser.
114
115 Labels behave differently, because a label is decoded into a `string` and yaml.v3
116 is happy to coerce:
117
118 | label | unquoted decodes as | written by `plainSafe` |
119 |---|---|---|
120 | `bug`, `needs-repro`, `a-b_c.d` | unchanged | unquoted |
121 | `yes`, `on`, `no`, `off`, `y`, `n` | unchanged (YAML 1.2, string target) | unquoted |
122 | `123`, `1.5`, `0x10` | unchanged | unquoted |
123 | `null` | parse error | `"null"` |
124 | `~` | parse error | `"~"` (already forbidden by `labelRE`) |
125
126 So quoting only when the plain form fails to round-trip keeps 13 of 15 labels
127 looking exactly as they do today, and the one label a forge is likely to actually
128 have (`null`) is quoted automatically.
129
130 ## Verdict: VALIDATED
131
132 The format is better on every measured axis, and the restrictions it needs are
133 narrower than expected — mostly because the writer, not the validator, does the
134 work by always quoting.
135
136 ### What worked
137
138 - Adding and removing a label costs one changed line instead of two to four.
139 - One file per issue means an edit touches one path, and `git log`/`blame` per
140 issue carry the whole history.
141 - `yaml.Marshal` never folded a 200-byte title, so `maxTitleLen = 200` survives.
142 - The reader can be `yaml.v3` with `KnownFields(true)`, which keeps the current
143 "unknown field" rejection without a hand-written key whitelist.
144
145 ### What didn't
146
147 - YAML still has a silent-truncation hole for hand-edits (`#`). It costs one
148 comparison to close, but it must be written down or the hole reopens.
149 - `null` as a label needs quoting. Cheap, but it is a difference between two
150 labels that look identical in the source.
151 - The `updated` field ruins the good numbers. It should go regardless of format.
152
153 ### Surprises
154
155 - The biggest win wasn't YAML at all: it was dropping `updated` (25 → 14) and
156 folding the body into the same file as the metadata.
157 - Unquoted `yes`/`on`/`no` are safe here — the YAML 1.1 boolean words only bite
158 when the target is untyped. The `null`/`~` failures come from `!!null`, not
159 `!!bool`.
160 - Library emission is the wrong tool: `yaml.Marshal` produces correct YAML, but
161 the always-quoting writer is smaller, deterministic, and can refuse control
162 characters instead of escaping them onto a second line.
163
164 ### Recommendation for the real build
165
166 Format, replacing `meta.json` + `description.md` with one file:
167
168 ```markdown
169 ---
170 title: "crash on a<b> & c"
171 state: open
172 author: keenan
173 created: 2026-10-23T12:00:00Z
174 labels:
175 - bug
176 - needs-repro
177 ---
178 It broke.
179
180 Repro: run vole.
181 ```
182
183 - Writer: double-quote `title` always; quote a label only when `plainSafe` fails.
184 Refuse control characters in titles rather than escaping them.
185 - Reader: `yaml.v3` decoder with `KnownFields(true)` over the fenced head.
186 - New validation rule: an unquoted `title` line must parse back to its own text.
187 - Drop `updated`. Commit time is the timestamp.
188 - Comments keep the format they already have; nothing about them changes.
189 - `requests/<ulid>/meta.json` has the same shape (`base`, `head`, `state`,
190 `author`, `created`) and should move in the same change, or the branch has two
191 dialects.
192
193 Touched by that change: `internal/issues/{issues,write}.go`,
194 `internal/requests/requests.go`, the path whitelist and hook messages in
195 `internal/hook/hook.go`, the format text in `internal/web/templates.go`, the CLI
196 sugar in `cmd/`, and the tree fixtures in the tests.
197
198 Not settled here: existing issues on a live instance need either a one-shot
199 rewrite commit or a release of dual-format reading. The parse and render paths are
200 both confined to the two packages above, so either is small.