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.go +65−74
| @@ -2,11 +2,15 @@ | |||
| 2 | 2 | // branch in the repository itself. | |
| 3 | 3 | // | |
| 4 | 4 | // refs/heads/vole | |
| 5 | // issues/<ulid>/meta.json | ||
| 6 | // issues/<ulid>/description.md | ||
| 7 | // issues/<ulid>/comments/<ulid>.md | ||
| 5 | // issues/<ulid>/issue.md frontmatter + body | ||
| 6 | // issues/<ulid>/comments/<ulid>.md frontmatter + body | ||
| 8 | 7 | // requests/... reserved for change requests | |
| 9 | 8 | // | |
| 9 | // Metadata is frontmatter markdown rather than JSON because the diffs are the | ||
| 10 | // interface: one field per line, one label per line, so closing an issue changes | ||
| 11 | // one line and adding a label adds one. A JSON array rewrites its neighbour to | ||
| 12 | // add a comma, which is how a one-word change becomes a four-line diff. | ||
| 13 | // | ||
| 10 | 14 | // Issues and change requests share one branch because they are both human-authored | |
| 11 | 15 | // and low-frequency, and because a single ref means one push, one fetch and one | |
| 12 | 16 | // validation path. Anything machine-written (CI runs, statuses) must NOT go here: | |
| @@ -24,13 +28,13 @@ package issues | |||
| 24 | 28 | ||
| 25 | 29 | import ( | |
| 26 | 30 | "crypto/rand" | |
| 27 | "encoding/json" | ||
| 28 | 31 | "fmt" | |
| 29 | 32 | "regexp" | |
| 30 | 33 | "sort" | |
| 31 | 34 | "strings" | |
| 32 | 35 | "time" | |
| 33 | 36 | ||
| 37 | "github.com/rodent-software/vole/internal/frontmatter" | ||
| 34 | 38 | "github.com/rodent-software/vole/internal/gitx" | |
| 35 | 39 | "github.com/rodent-software/vole/internal/releases" | |
| 36 | 40 | ) | |
| @@ -43,8 +47,7 @@ const ( | |||
| 43 | 47 | // RequestsDir is reserved for change requests. | |
| 44 | 48 | RequestsDir = "requests" | |
| 45 | 49 | ||
| 46 | metaName = "meta.json" | ||
| 47 | descName = "description.md" | ||
| 50 | issueName = "issue.md" | ||
| 48 | 51 | commentsDir = "comments" | |
| 49 | 52 | maxTitleLen = 200 | |
| 50 | 53 | maxIssuesScan = 20000 | |
| @@ -52,7 +55,7 @@ const ( | |||
| 52 | 55 | ||
| 53 | 56 | var ( | |
| 54 | 57 | ulidRE = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`) | |
| 55 | pathRE = regexp.MustCompile(`^issues/([0-9A-HJKMNP-TV-Z]{26})(?:/(meta\.json|description\.md)|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`) | ||
| 58 | pathRE = regexp.MustCompile(`^issues/([0-9A-HJKMNP-TV-Z]{26})(?:/(issue\.md)|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`) | ||
| 56 | 59 | labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) | |
| 57 | 60 | ) | |
| 58 | 61 | ||
| @@ -62,7 +65,6 @@ type Issue struct { | |||
| 62 | 65 | State string | |
| 63 | 66 | Author string | |
| 64 | 67 | Created time.Time | |
| 65 | Updated time.Time | ||
| 66 | 68 | Labels []string | |
| 67 | 69 | Assignees []string | |
| 68 | 70 | Description string | |
| @@ -79,15 +81,19 @@ type Comment struct { | |||
| 79 | 81 | ||
| 80 | 82 | func (i *Issue) Open() bool { return i.State == "open" } | |
| 81 | 83 | ||
| 82 | // meta is the on-disk JSON. Field names are part of the public format. | ||
| 84 | // meta is the frontmatter head of issue.md. Field names are part of the public | ||
| 85 | // format. | ||
| 86 | // | ||
| 87 | // There is no updated field. The commit that changed the file already carries | ||
| 88 | // the time, `git log -- issues/<id>` answers it per issue, and writing it here | ||
| 89 | // cost a changed line on every single edit — more than half the diff volume. | ||
| 83 | 90 | type meta struct { | |
| 84 | Title string `json:"title"` | ||
| 85 | State string `json:"state"` | ||
| 86 | Author string `json:"author"` | ||
| 87 | Created string `json:"created"` | ||
| 88 | Updated string `json:"updated,omitempty"` | ||
| 89 | Labels []string `json:"labels,omitempty"` | ||
| 90 | Assignees []string `json:"assignees,omitempty"` | ||
| 91 | Title string `yaml:"title"` | ||
| 92 | State string `yaml:"state"` | ||
| 93 | Author string `yaml:"author"` | ||
| 94 | Created string `yaml:"created"` | ||
| 95 | Labels []string `yaml:"labels,omitempty"` | ||
| 96 | Assignees []string `yaml:"assignees,omitempty"` | ||
| 91 | 97 | } | |
| 92 | 98 | ||
| 93 | 99 | // Load reads every issue at ref. It validates as it goes, so a malformed tree | |
| @@ -140,7 +146,7 @@ func Parse(contents map[string]string) ([]*Issue, error) { | |||
| 140 | 146 | } | |
| 141 | 147 | m := pathRE.FindStringSubmatch(path) | |
| 142 | 148 | if m == nil { | |
| 143 | problems = append(problems, fmt.Sprintf("%s: not part of the issue format (expected issues/<ulid>/meta.json, description.md or comments/<ulid>.md)", path)) | ||
| 149 | problems = append(problems, fmt.Sprintf("%s: not part of the issue format (expected issues/<ulid>/issue.md or comments/<ulid>.md)", path)) | ||
| 144 | 150 | continue | |
| 145 | 151 | } | |
| 146 | 152 | id := m[1] | |
| @@ -150,12 +156,8 @@ func Parse(contents map[string]string) ([]*Issue, error) { | |||
| 150 | 156 | byID[id] = iss | |
| 151 | 157 | } | |
| 152 | 158 | switch { | |
| 153 | case m[2] == metaName: | ||
| 154 | if err := applyMeta(iss, contents[path], path, &problems); err != nil { | ||
| 155 | return nil, err | ||
| 156 | } | ||
| 157 | case m[2] == descName: | ||
| 158 | iss.Description = contents[path] | ||
| 159 | case m[2] == issueName: | ||
| 160 | applyIssue(iss, contents[path], path, &problems) | ||
| 159 | 161 | case m[3] != "": | |
| 160 | 162 | c, prob := parseComment(m[3], contents[path], path) | |
| 161 | 163 | if prob != "" { | |
| @@ -169,8 +171,8 @@ func Parse(contents map[string]string) ([]*Issue, error) { | |||
| 169 | 171 | out := make([]*Issue, 0, len(byID)) | |
| 170 | 172 | for _, iss := range byID { | |
| 171 | 173 | if iss.Title == "" { | |
| 172 | if _, ok := contents[iss.Path+"/"+metaName]; !ok { | ||
| 173 | problems = append(problems, fmt.Sprintf("%s: missing %s (an issue needs a title)", iss.Path, metaName)) | ||
| 174 | if _, ok := contents[iss.Path+"/"+issueName]; !ok { | ||
| 175 | problems = append(problems, fmt.Sprintf("%s: missing %s (an issue needs a title)", iss.Path, issueName)) | ||
| 174 | 176 | } | |
| 175 | 177 | } | |
| 176 | 178 | sort.Slice(iss.Comments, func(a, b int) bool { return iss.Comments[a].ID < iss.Comments[b].ID }) | |
| @@ -184,13 +186,24 @@ func Parse(contents map[string]string) ([]*Issue, error) { | |||
| 184 | 186 | return out, nil | |
| 185 | 187 | } | |
| 186 | 188 | ||
| 187 | func applyMeta(iss *Issue, raw, path string, problems *[]string) error { | ||
| 189 | func applyIssue(iss *Issue, raw, path string, problems *[]string) { | ||
| 190 | head, body, err := frontmatter.Split([]byte(raw)) | ||
| 191 | if err != nil { | ||
| 192 | *problems = append(*problems, fmt.Sprintf("%s: %v", path, err)) | ||
| 193 | return | ||
| 194 | } | ||
| 188 | 195 | var m meta | |
| 189 | dec := json.NewDecoder(strings.NewReader(raw)) | ||
| 190 | dec.DisallowUnknownFields() | ||
| 191 | if err := dec.Decode(&m); err != nil { | ||
| 192 | *problems = append(*problems, fmt.Sprintf("%s: cannot parse: %v", path, err)) | ||
| 193 | return nil | ||
| 196 | if err := frontmatter.Decode(head, &m); err != nil { | ||
| 197 | *problems = append(*problems, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err)) | ||
| 198 | return | ||
| 199 | } | ||
| 200 | // A title that holds " # " truncates to the text before it unless it is | ||
| 201 | // quoted, and a truncated title is worse than a rejected push. | ||
| 202 | if err := frontmatter.Text(head, "title", m.Title); err != nil { | ||
| 203 | *problems = append(*problems, fmt.Sprintf("%s: title: %v", path, err)) | ||
| 204 | } | ||
| 205 | if err := frontmatter.Text(head, "author", m.Author); err != nil { | ||
| 206 | *problems = append(*problems, fmt.Sprintf("%s: author: %v", path, err)) | ||
| 194 | 207 | } | |
| 195 | 208 | iss.Title = strings.TrimSpace(m.Title) | |
| 196 | 209 | switch { | |
| @@ -217,11 +230,6 @@ func applyMeta(iss *Issue, raw, path string, problems *[]string) error { | |||
| 217 | 230 | } else { | |
| 218 | 231 | iss.Created = t.UTC() | |
| 219 | 232 | } | |
| 220 | if m.Updated != "" { | ||
| 221 | if t, err := time.Parse(time.RFC3339, m.Updated); err == nil { | ||
| 222 | iss.Updated = t.UTC() | ||
| 223 | } | ||
| 224 | } | ||
| 225 | 233 | for _, l := range m.Labels { | |
| 226 | 234 | if !labelRE.MatchString(l) { | |
| 227 | 235 | *problems = append(*problems, fmt.Sprintf("%s: invalid label %q (lowercase letters, digits, dot, dash, underscore)", path, l)) | |
| @@ -230,54 +238,37 @@ func applyMeta(iss *Issue, raw, path string, problems *[]string) error { | |||
| 230 | 238 | iss.Labels = append(iss.Labels, l) | |
| 231 | 239 | } | |
| 232 | 240 | iss.Assignees = m.Assignees | |
| 241 | iss.Description = body | ||
| 233 | 242 | sort.Strings(iss.Labels) | |
| 234 | return nil | ||
| 235 | 243 | } | |
| 236 | 244 | ||
| 237 | // parseComment reads the small frontmatter block comments carry. | ||
| 245 | // parseComment reads the frontmatter block a comment carries. Comments share the | ||
| 246 | // issue format so the whole branch has one reader. | ||
| 238 | 247 | func parseComment(id, raw, path string) (Comment, string) { | |
| 239 | 248 | c := Comment{ID: id} | |
| 240 | body := raw | ||
| 241 | if strings.HasPrefix(raw, "---\n") { | ||
| 242 | rest := raw[4:] | ||
| 243 | end := strings.Index(rest, "\n---\n") | ||
| 244 | if end < 0 { | ||
| 245 | return c, fmt.Sprintf("%s: unterminated frontmatter", path) | ||
| 246 | } | ||
| 247 | head := rest[:end] | ||
| 248 | body = rest[end+5:] | ||
| 249 | for _, line := range strings.Split(head, "\n") { | ||
| 250 | line = strings.TrimSpace(line) | ||
| 251 | if line == "" { | ||
| 252 | continue | ||
| 253 | } | ||
| 254 | k, v, ok := strings.Cut(line, ":") | ||
| 255 | if !ok { | ||
| 256 | return c, fmt.Sprintf("%s: frontmatter line %q is not key: value", path, line) | ||
| 257 | } | ||
| 258 | k, v = strings.TrimSpace(k), strings.TrimSpace(v) | ||
| 259 | switch k { | ||
| 260 | case "author": | ||
| 261 | c.Author = v | ||
| 262 | case "created": | ||
| 263 | t, err := time.Parse(time.RFC3339, v) | ||
| 264 | if err != nil { | ||
| 265 | return c, fmt.Sprintf("%s: created must be RFC3339: %v", path, err) | ||
| 266 | } | ||
| 267 | c.Created = t.UTC() | ||
| 268 | default: | ||
| 269 | return c, fmt.Sprintf("%s: unknown frontmatter field %q", path, k) | ||
| 270 | } | ||
| 271 | } | ||
| 272 | } else { | ||
| 273 | return c, fmt.Sprintf("%s: comment needs frontmatter with author and created", path) | ||
| 249 | head, body, err := frontmatter.Split([]byte(raw)) | ||
| 250 | if err != nil { | ||
| 251 | return c, fmt.Sprintf("%s: %v (a comment is frontmatter with author and created)", path, err) | ||
| 252 | } | ||
| 253 | var m struct { | ||
| 254 | Author string `yaml:"author"` | ||
| 255 | Created string `yaml:"created"` | ||
| 256 | } | ||
| 257 | if err := frontmatter.Decode(head, &m); err != nil { | ||
| 258 | return c, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err) | ||
| 274 | 259 | } | |
| 260 | if err := frontmatter.Text(head, "author", m.Author); err != nil { | ||
| 261 | return c, fmt.Sprintf("%s: author: %v", path, err) | ||
| 262 | } | ||
| 263 | c.Author = strings.TrimSpace(m.Author) | ||
| 275 | 264 | if c.Author == "" { | |
| 276 | 265 | return c, fmt.Sprintf("%s: author is required", path) | |
| 277 | 266 | } | |
| 278 | if c.Created.IsZero() { | ||
| 279 | return c, fmt.Sprintf("%s: created is required", path) | ||
| 267 | t, err := time.Parse(time.RFC3339, m.Created) | ||
| 268 | if err != nil { | ||
| 269 | return c, fmt.Sprintf("%s: created is required and must be RFC3339: %v", path, err) | ||
| 280 | 270 | } | |
| 271 | c.Created = t.UTC() | ||
| 281 | 272 | c.Body = strings.TrimLeft(body, "\n") | |
| 282 | 273 | return c, "" | |
| 283 | 274 | } | |