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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0: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/issues/issues_test.go +106−16

@@ -14,8 +14,16 @@ const (
14 14
15 15 func validContents() map[string]string {
16 16 return map[string]string{
17 "issues/" + idA + "/meta.json": `{"title":"It broke","state":"open","author":"keenan","created":"2026-10-22T21:00:00Z","labels":["bug","needs-repro"]}`,
18 "issues/" + idA + "/description.md": "Steps to reproduce.\n",
17 "issues/" + idA + "/issue.md": "---\n" +
18 `title: "It broke"` + "\n" +
19 "state: open\n" +
20 "author: keenan\n" +
21 "created: 2026-10-22T21:00:00Z\n" +
22 "labels:\n" +
23 " - bug\n" +
24 " - needs-repro\n" +
25 "---\n" +
26 "Steps to reproduce.\n",
19 27 "issues/" + idA + "/comments/" + idB + ".md": "---\nauthor: alice\ncreated: 2026-10-22T21:01:00Z\n---\nMe too.\n",
20 28 "issues/" + idA + "/comments/" + idC + ".md": "---\nauthor: bob\ncreated: 2026-10-22T21:02:00Z\n---\nFixed in main.\n",
21 29 }
@@ -59,41 +67,123 @@ func TestParseIgnoresUnrelatedFiles(t *testing.T) {
59 67 }
60 68 }
61 69
70 // TestRenderParseRoundTrip is the format's contract: whatever the writer emits,
71 // the reader reads back. The titles are the ones that break naive YAML — an
72 // unquoted `#` truncates, a leading `-` reads as a sequence, `null` reads as
73 // nothing — which is why free text is always quoted.
74 func TestRenderParseRoundTrip(t *testing.T) {
75 titles := []string{
76 "crash on startup",
77 "crash on a<b> & c",
78 "fix #12 crash",
79 "panic: nil map",
80 "- not a list",
81 "*anchor",
82 "null",
83 "~",
84 "123",
85 `it's "quoted"`,
86 `path C:\temp`,
87 "café ☕ breaks",
88 strings.Repeat("long title ", 17) + "end",
89 }
90 for _, title := range titles {
91 iss := &Issue{
92 ID: idA,
93 Title: title,
94 State: "open",
95 Author: "keenan",
96 Created: time.Date(2026, 10, 22, 21, 0, 0, 0, time.UTC),
97 Labels: []string{"bug", "null"},
98 Description: "It broke.\n",
99 }
100 raw, err := renderIssue(iss)
101 if err != nil {
102 t.Fatalf("%q: render: %v", title, err)
103 }
104 got, err := Parse(map[string]string{"issues/" + idA + "/issue.md": string(raw)})
105 if err != nil {
106 t.Fatalf("%q: parse:\n%s\nerror: %v", title, raw, err)
107 }
108 if got[0].Title != title {
109 t.Fatalf("title came back as %q, want %q:\n%s", got[0].Title, title, raw)
110 }
111 if got[0].Description != "It broke.\n" {
112 t.Fatalf("%q: description = %q", title, got[0].Description)
113 }
114 if len(got[0].Labels) != 2 || got[0].Labels[1] != "null" {
115 t.Fatalf("%q: labels = %v", title, got[0].Labels)
116 }
117 }
118 }
119
120 // TestUnquotedTitleIsRefused: a hand-edit that drops the quotes on a title with
121 // " # " would otherwise store a shorter title and say nothing.
122 func TestUnquotedTitleIsRefused(t *testing.T) {
123 c := validContents()
124 c["issues/"+idA+"/issue.md"] = "---\ntitle: fix #12 crash\nstate: open\nauthor: keenan\n" +
125 "created: 2026-10-22T21:00:00Z\n---\nbody\n"
126 _, err := Parse(c)
127 ve, ok := err.(*ValidationError)
128 if !ok {
129 t.Fatalf("error is %T, want *ValidationError: %v", err, err)
130 }
131 if !strings.Contains(err.Error(), "must be double-quoted") {
132 t.Fatalf("the refusal does not explain the quotes: %v", ve.Problems)
133 }
134 }
135
136 // TestTitleOnOneLine: the writer refuses control characters, so the reader must
137 // too — otherwise a tree the server accepts is a tree nobody can edit again.
138 func TestTitleOnOneLine(t *testing.T) {
139 c := validContents()
140 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"two\\nlines\"\nstate: open\nauthor: keenan\n" +
141 "created: 2026-10-22T21:00:00Z\n---\nbody\n"
142 if _, err := Parse(c); err == nil {
143 t.Fatal("a title with a newline was accepted")
144 }
145 }
146
62 147 func TestValidationFailures(t *testing.T) {
63 148 for name, mutate := range map[string]func(map[string]string){
64 149 "bad ulid directory": func(c map[string]string) {
65 c["issues/not-a-ulid/meta.json"] = `{"title":"x","state":"open","author":"k","created":"2026-10-22T21:00:00Z"}`
150 c["issues/not-a-ulid/issue.md"] = "---\ntitle: \"x\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
66 151 },
67 "missing meta": func(c map[string]string) {
68 c["issues/"+idA+"/description.md"] = "orphan\n"
69 delete(c, "issues/"+idA+"/meta.json")
152 "missing issue file": func(c map[string]string) {
153 delete(c, "issues/"+idA+"/issue.md")
70 154 },
71 155 "empty title": func(c map[string]string) {
72 c["issues/"+idA+"/meta.json"] = `{"title":" ","state":"open","author":"k","created":"2026-10-22T21:00:00Z"}`
156 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
73 157 },
74 158 "long title": func(c map[string]string) {
75 c["issues/"+idA+"/meta.json"] = `{"title":"` + strings.Repeat("x", 300) + `","state":"open","author":"k","created":"2026-10-22T21:00:00Z"}`
159 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"" + strings.Repeat("x", 300) + "\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
76 160 },
77 161 "bad state": func(c map[string]string) {
78 c["issues/"+idA+"/meta.json"] = `{"title":"t","state":"wontfix","author":"k","created":"2026-10-22T21:00:00Z"}`
162 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: wontfix\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
79 163 },
80 164 "missing author": func(c map[string]string) {
81 c["issues/"+idA+"/meta.json"] = `{"title":"t","state":"open","created":"2026-10-22T21:00:00Z"}`
165 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\ncreated: 2026-10-22T21:00:00Z\n---\n"
82 166 },
83 167 "bad date": func(c map[string]string) {
84 c["issues/"+idA+"/meta.json"] = `{"title":"t","state":"open","author":"k","created":"yesterday"}`
168 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: yesterday\n---\n"
85 169 },
86 "unknown meta field": func(c map[string]string) {
87 c["issues/"+idA+"/meta.json"] = `{"title":"t","state":"open","author":"k","created":"2026-10-22T21:00:00Z","sneaky":true}`
170 "unknown frontmatter field": func(c map[string]string) {
171 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nsneaky: true\n---\n"
88 172 },
89 173 "bad label": func(c map[string]string) {
90 c["issues/"+idA+"/meta.json"] = `{"title":"t","state":"open","author":"k","created":"2026-10-22T21:00:00Z","labels":["Has Spaces"]}`
174 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nlabels:\n - \"Has Spaces\"\n---\n"
175 },
176 "no frontmatter": func(c map[string]string) {
177 c["issues/"+idA+"/issue.md"] = "just text\n"
178 },
179 "unterminated frontmatter": func(c map[string]string) {
180 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nbody\n"
91 181 },
92 182 "comment without frontmatter": func(c map[string]string) {
93 183 c["issues/"+idA+"/comments/"+idB+".md"] = "just text\n"
94 184 },
95 185 "comment unknown field": func(c map[string]string) {
96 c["issues/"+idA+"/comments/"+idB+".md"] = "---\nauthor: a\ncreated: 2026-10-22T21:00:00Z\nedited: yes\n---\nx\n"
186 c["issues/"+idA+"/comments/"+idB+".md"] = "---\nauthor: a\ncreated: 2026-10-22T21:00:00Z\nedited: \"yes\"\n---\nx\n"
97 187 },
98 188 "comment unterminated": func(c map[string]string) {
99 189 c["issues/"+idA+"/comments/"+idB+".md"] = "---\nauthor: a\ncreated: 2026-10-22T21:00:00Z\nx\n"
@@ -119,7 +209,7 @@ func TestValidationFailures(t *testing.T) {
119 209
120 210 func TestAllProblemsReported(t *testing.T) {
121 211 c := validContents()
122 c["issues/"+idA+"/meta.json"] = `{"title":"","state":"nope","author":"","created":""}`
212 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"\"\nstate: nope\nauthor: \"\"\ncreated: \"\"\n---\n"
123 213 _, err := Parse(c)
124 214 ve, ok := err.(*ValidationError)
125 215 if !ok {