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/requests/write.go +41−39

@@ -1,10 +1,11 @@
1 1 package requests
2 2
3 3 import (
4 "encoding/json"
5 4 "fmt"
5 "strings"
6 6 "time"
7 7
8 "github.com/rodent-software/vole/internal/frontmatter"
8 9 "github.com/rodent-software/vole/internal/gitx"
9 10 voleid "github.com/rodent-software/vole/internal/id"
10 11 "github.com/rodent-software/vole/internal/issues"
@@ -19,8 +20,8 @@ func New(title, body, author, authorID, base, head string, now time.Time) (*Requ
19 20 }
20 21 r := &Request{
21 22 ID: id,
22 Title: title,
23 Author: author,
23 Title: strings.TrimSpace(title),
24 Author: strings.TrimSpace(author),
24 25 AuthorID: authorID,
25 26 Base: base,
26 27 Head: head,
@@ -56,24 +57,27 @@ func validateForWrite(r *Request) error {
56 57 return nil
57 58 }
58 59
59 func marshalMeta(r *Request) ([]byte, error) {
60 m := meta{
61 Title: r.Title,
62 Author: r.Author,
63 AuthorID: r.AuthorID,
64 Base: r.Base,
65 Head: r.Head,
66 State: r.State,
67 Created: r.Created.UTC().Format(time.RFC3339),
68 }
69 if !r.Updated.IsZero() {
70 m.Updated = r.Updated.UTC().Format(time.RFC3339)
71 }
72 b, err := json.MarshalIndent(m, "", " ")
73 if err != nil {
74 return nil, err
75 }
76 return append(b, '\n'), nil
60 // renderRequest writes a request as one file: the head names the branch and the
61 // commit under review, the body says why.
62 func renderRequest(r *Request) ([]byte, error) {
63 d := frontmatter.New()
64 d.Text("title", r.Title)
65 d.Word("state", r.State)
66 d.Word("author", r.Author)
67 d.Word("author_id", r.AuthorID)
68 d.Word("base", r.Base)
69 d.Word("head", r.Head)
70 d.Word("created", r.Created.UTC().Format(time.RFC3339))
71 return d.Bytes(strings.TrimRight(r.Description, "\n"))
72 }
73
74 // renderApproval writes an approval: three constrained fields and no body.
75 func renderApproval(a Approval) ([]byte, error) {
76 d := frontmatter.New()
77 d.Word("base", a.Base)
78 d.Word("head", a.Head)
79 d.Word("created", a.Created.UTC().Format(time.RFC3339))
80 return d.Bytes("")
77 81 }
78 82
79 83 // Create writes a new request onto the metadata branch.
@@ -81,17 +85,16 @@ func Create(g *gitx.Repo, r *Request, actor string) error {
81 85 if err := validateForWrite(r); err != nil {
82 86 return err
83 87 }
84 raw, err := marshalMeta(r)
88 raw, err := renderRequest(r)
85 89 if err != nil {
86 90 return err
87 91 }
88 92 base := fmt.Sprintf("%s/%s/", Dir, r.ID)
89 93 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
90 if _, exists := files[base+metaName]; exists {
94 if _, exists := files[base+requestName]; exists {
91 95 return fmt.Errorf("requests: %s already exists", r.ID)
92 96 }
93 files[base+metaName] = raw
94 files[base+descName] = []byte(r.Description)
97 files[base+requestName] = raw
95 98 return nil
96 99 }, fmt.Sprintf("request: %s (%s)", r.Title, r.ID), actor)
97 100 return err
@@ -113,7 +116,7 @@ func Get(g *gitx.Repo, id string) (*Request, error) {
113 116
114 117 // SetHead repoints a request at a new commit. The approvals stay in the tree but
115 118 // stop matching, because they name the commit they reviewed.
116 func SetHead(g *gitx.Repo, id, head string, now time.Time, actor string) (*Request, error) {
119 func SetHead(g *gitx.Repo, id, head, actor string) (*Request, error) {
117 120 var out *Request
118 121 base := fmt.Sprintf("%s/%s/", Dir, id)
119 122 _, err := gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
@@ -122,15 +125,14 @@ func SetHead(g *gitx.Repo, id, head string, now time.Time, actor string) (*Reque
122 125 return err
123 126 }
124 127 r.Head = head
125 r.Updated = now.UTC()
126 128 if err := validateForWrite(r); err != nil {
127 129 return err
128 130 }
129 raw, err := marshalMeta(r)
131 raw, err := renderRequest(r)
130 132 if err != nil {
131 133 return err
132 134 }
133 files[base+metaName] = raw
135 files[base+requestName] = raw
134 136 out = r
135 137 return nil
136 138 }, fmt.Sprintf("request: %s head -> %s", id, shortSHA(head)), actor)
@@ -155,15 +157,16 @@ func Approve(g *gitx.Repo, id, principal string, now time.Time, actor string) (*
155 157 if r.Head == "" {
156 158 return fmt.Errorf("requests: %s has no head commit to approve", id)
157 159 }
158 body, err := json.MarshalIndent(struct {
159 Base string `json:"base"`
160 Head string `json:"head"`
161 Created string `json:"created"`
162 }{r.Base, r.Head, now.UTC().Format(time.RFC3339)}, "", " ")
160 raw, err := renderApproval(Approval{
161 Principal: principal,
162 Base: r.Base,
163 Head: r.Head,
164 Created: now.UTC(),
165 })
163 166 if err != nil {
164 167 return err
165 168 }
166 files[fmt.Sprintf("%s%s/%s.json", base, approvalDir, principal)] = append(body, '\n')
169 files[fmt.Sprintf("%s%s/%s.md", base, approvalDir, principal)] = raw
167 170 out = r
168 171 return nil
169 172 }, fmt.Sprintf("approve: %s by %s", id, principal), actor)
@@ -171,7 +174,7 @@ func Approve(g *gitx.Repo, id, principal string, now time.Time, actor string) (*
171 174 }
172 175
173 176 // SetState moves a request between open and closed.
174 func SetState(g *gitx.Repo, id, state string, now time.Time, actor string) (*Request, error) {
177 func SetState(g *gitx.Repo, id, state, actor string) (*Request, error) {
175 178 switch state {
176 179 case "open", "merged", "closed":
177 180 default:
@@ -185,12 +188,11 @@ func SetState(g *gitx.Repo, id, state string, now time.Time, actor string) (*Req
185 188 return err
186 189 }
187 190 r.State = state
188 r.Updated = now.UTC()
189 raw, err := marshalMeta(r)
191 raw, err := renderRequest(r)
190 192 if err != nil {
191 193 return err
192 194 }
193 files[base+metaName] = raw
195 files[base+requestName] = raw
194 196 out = r
195 197 return nil
196 198 }, fmt.Sprintf("request: %s %s", id, state), actor)