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.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 }