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/write.go +31−38

@@ -1,11 +1,12 @@
1 1 package issues
2 2
3 3 import (
4 "encoding/json"
5 4 "fmt"
5 "sort"
6 6 "strings"
7 7 "time"
8 8
9 "github.com/rodent-software/vole/internal/frontmatter"
9 10 "github.com/rodent-software/vole/internal/gitx"
10 11 )
11 12
@@ -40,17 +41,16 @@ func Create(g *gitx.Repo, iss *Issue, author string) error {
40 41 if err := validateForWrite(iss); err != nil {
41 42 return err
42 43 }
43 meta, err := marshalMeta(iss)
44 raw, err := renderIssue(iss)
44 45 if err != nil {
45 46 return err
46 47 }
47 48 base := fmt.Sprintf("%s/%s/", Dir, iss.ID)
48 49 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
49 if _, exists := files[base+"meta.json"]; exists {
50 if _, exists := files[base+issueName]; exists {
50 51 return fmt.Errorf("issues: %s already exists", iss.ID)
51 52 }
52 files[base+"meta.json"] = meta
53 files[base+"description.md"] = []byte(iss.Description)
53 files[base+issueName] = raw
54 54 return nil
55 55 }, fmt.Sprintf("issue: %s (%s)", iss.Title, iss.ID), actorOr(author, iss.Author))
56 56 return err
@@ -75,18 +75,16 @@ func Update(g *gitx.Repo, id string, mutate func(*Issue) error, actor string) (*
75 75 if err := mutate(target); err != nil {
76 76 return nil, err
77 77 }
78 target.Updated = time.Now().UTC()
79 78 if err := validateForWrite(target); err != nil {
80 79 return nil, err
81 80 }
82 meta, err := marshalMeta(target)
81 raw, err := renderIssue(target)
83 82 if err != nil {
84 83 return nil, err
85 84 }
86 85 base := fmt.Sprintf("%s/%s/", Dir, target.ID)
87 86 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
88 files[base+"meta.json"] = meta
89 files[base+"description.md"] = []byte(target.Description)
87 files[base+issueName] = raw
90 88 return nil
91 89 }, fmt.Sprintf("issue: update %s", target.ID), actorOr(actor, target.Author))
92 90 if err != nil {
@@ -120,47 +118,42 @@ func AddComment(g *gitx.Repo, id string, c *Comment, actor string) error {
120 118 if c.Created.IsZero() {
121 119 c.Created = time.Now().UTC()
122 120 }
121 raw, err := renderCommentFile(c)
122 if err != nil {
123 return err
124 }
123 125 path := fmt.Sprintf("%s/%s/%s/%s.md", Dir, id, commentsDir, c.ID)
124 126 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
125 127 if _, exists := files[path]; exists {
126 128 return fmt.Errorf("issues: comment %s already exists", c.ID)
127 129 }
128 files[path] = []byte(renderCommentFile(c))
130 files[path] = raw
129 131 return nil
130 132 }, fmt.Sprintf("issue: comment on %s", id), c.Author)
131 133 return err
132 134 }
133 135
134 func renderCommentFile(c *Comment) string {
135 var b strings.Builder
136 b.WriteString("---\n")
137 fmt.Fprintf(&b, "author: %s\n", c.Author)
138 fmt.Fprintf(&b, "created: %s\n", c.Created.UTC().Format(time.RFC3339))
139 b.WriteString("---\n")
140 b.WriteString(c.Body)
141 if !strings.HasSuffix(c.Body, "\n") {
142 b.WriteString("\n")
143 }
144 return b.String()
136 func renderCommentFile(c *Comment) ([]byte, error) {
137 d := frontmatter.New()
138 d.Word("author", c.Author)
139 d.Word("created", c.Created.UTC().Format(time.RFC3339))
140 return d.Bytes(strings.TrimRight(c.Body, "\n"))
145 141 }
146 142
147 func marshalMeta(iss *Issue) ([]byte, error) {
148 m := meta{
149 Title: iss.Title,
150 State: iss.State,
151 Author: iss.Author,
152 Created: iss.Created.UTC().Format(time.RFC3339),
153 Labels: iss.Labels,
154 Assignees: iss.Assignees,
155 }
156 if !iss.Updated.IsZero() {
157 m.Updated = iss.Updated.UTC().Format(time.RFC3339)
158 }
159 b, err := json.MarshalIndent(m, "", " ")
160 if err != nil {
161 return nil, err
162 }
163 return append(b, '\n'), nil
143 // renderIssue writes an issue as one file: the frontmatter head, then the
144 // description as the body. One path per issue means one file to diff, blame or
145 // log, and no way for the two halves to disagree.
146 func renderIssue(iss *Issue) ([]byte, error) {
147 d := frontmatter.New()
148 d.Text("title", iss.Title)
149 d.Word("state", iss.State)
150 d.Word("author", iss.Author)
151 d.Word("created", iss.Created.UTC().Format(time.RFC3339))
152 labels := append([]string(nil), iss.Labels...)
153 sort.Strings(labels)
154 d.List("labels", labels)
155 d.List("assignees", iss.Assignees)
156 return d.Bytes(strings.TrimRight(iss.Description, "\n"))
164 157 }
165 158
166 159 // validateForWrite runs the same checks the push boundary applies, so the CLI