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.

Phase 2, first slice: issues as files on a branch

37c01e5aa9b98bbea8567e741cd3af508ae1ba7d · Keenan Nemetz · 18d ago · parent 197ecaa2

Phase 2, first slice: issues as files on a branch

Push-based issues, per the decision to keep the web read-only. The format is the
contract; nothing here requires a Vole binary on a contributor's machine.

  issues/<ulid>/meta.json
  issues/<ulid>/description.md
  issues/<ulid>/comments/<ulid>.md      on refs/heads/issues

Design points worth recording:

- IDs are ULIDs generated by the writer, so there is no server-assigned counter,
  no contention, and no ID allocation in the write path at all.
- Issues live on a branch rather than a custom ref, so a plain `git clone` carries
  them. That is the same lesson that moved the user roster off refs/vole/users:
  `git clone` only mirrors refs/heads/*.
- File-per-comment means two people replying to the same issue never touch the
  same file, so the only merge conflict is two edits to one comment.
- Validation happens in pre-receive, so a malformed tree never reaches the web.
  Every problem is reported at once with the offending path, and a rejected push
  leaves the branch unmoved.

The index gained issue rows, refreshed when the issues ref moves rather than by a
hook, plus coverage in the full rebuild. The list page reads the index; the
detail page reads git, so bodies and comments are never stale.

Markdown is rendered with goldmark and sanitised with bluemonday. Issue bodies
arrive by push, which makes them untrusted input on a public page; a test asserts
that <script> and javascript: URLs do not survive.

Verified by hand with a user who has an editor and a key: filed an issue, added a
comment, saw both rendered, then pushed an issue with no title and got back
"issues/01M5.../meta.json: title is required" with the branch unmoved.

internal/issues/issues_test.go +151

@@ -0,0 +1,151 @@
1 package issues
2
3 import (
4 "strings"
5 "testing"
6 "time"
7 )
8
9 const (
10 idA = "01M5JR2JK29KKMAA1T9MDNTPBP"
11 idB = "01M5JR2JK29KKMAA1T9MDNTPBQ"
12 idC = "01M5JR2JK29KKMAA1T9MDNTPBR"
13 )
14
15 func validContents() map[string]string {
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",
19 "issues/" + idA + "/comments/" + idB + ".md": "---\nauthor: alice\ncreated: 2026-10-22T21:01:00Z\n---\nMe too.\n",
20 "issues/" + idA + "/comments/" + idC + ".md": "---\nauthor: bob\ncreated: 2026-10-22T21:02:00Z\n---\nFixed in main.\n",
21 }
22 }
23
24 func TestParseValid(t *testing.T) {
25 list, err := Parse(validContents())
26 if err != nil {
27 t.Fatalf("unexpected error: %v", err)
28 }
29 if len(list) != 1 {
30 t.Fatalf("issues = %d, want 1", len(list))
31 }
32 iss := list[0]
33 if iss.Title != "It broke" || iss.State != "open" || iss.Author != "keenan" {
34 t.Fatalf("bad issue: %+v", iss)
35 }
36 if len(iss.Labels) != 2 || iss.Labels[0] != "bug" {
37 t.Fatalf("labels = %v", iss.Labels)
38 }
39 if iss.Description != "Steps to reproduce.\n" {
40 t.Fatalf("description = %q", iss.Description)
41 }
42 if len(iss.Comments) != 2 {
43 t.Fatalf("comments = %d, want 2", len(iss.Comments))
44 }
45 // Sorted by id, which is time-ordered.
46 if iss.Comments[0].Author != "alice" || iss.Comments[1].Author != "bob" {
47 t.Fatalf("comment order: %s then %s", iss.Comments[0].Author, iss.Comments[1].Author)
48 }
49 if strings.TrimSpace(iss.Comments[0].Body) != "Me too." {
50 t.Fatalf("comment body = %q", iss.Comments[0].Body)
51 }
52 }
53
54 func TestParseIgnoresUnrelatedFiles(t *testing.T) {
55 c := validContents()
56 c["README.md"] = "notes about the issues branch\n"
57 if _, err := Parse(c); err != nil {
58 t.Fatalf("unrelated files should be ignored: %v", err)
59 }
60 }
61
62 func TestValidationFailures(t *testing.T) {
63 for name, mutate := range map[string]func(map[string]string){
64 "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"}`
66 },
67 "missing meta": func(c map[string]string) {
68 c["issues/"+idA+"/description.md"] = "orphan\n"
69 delete(c, "issues/"+idA+"/meta.json")
70 },
71 "empty title": func(c map[string]string) {
72 c["issues/"+idA+"/meta.json"] = `{"title":" ","state":"open","author":"k","created":"2026-10-22T21:00:00Z"}`
73 },
74 "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"}`
76 },
77 "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"}`
79 },
80 "missing author": func(c map[string]string) {
81 c["issues/"+idA+"/meta.json"] = `{"title":"t","state":"open","created":"2026-10-22T21:00:00Z"}`
82 },
83 "bad date": func(c map[string]string) {
84 c["issues/"+idA+"/meta.json"] = `{"title":"t","state":"open","author":"k","created":"yesterday"}`
85 },
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}`
88 },
89 "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"]}`
91 },
92 "comment without frontmatter": func(c map[string]string) {
93 c["issues/"+idA+"/comments/"+idB+".md"] = "just text\n"
94 },
95 "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"
97 },
98 "comment unterminated": func(c map[string]string) {
99 c["issues/"+idA+"/comments/"+idB+".md"] = "---\nauthor: a\ncreated: 2026-10-22T21:00:00Z\nx\n"
100 },
101 "stray file in issue dir": func(c map[string]string) {
102 c["issues/"+idA+"/notes.txt"] = "nope"
103 },
104 "nested too deep": func(c map[string]string) {
105 c["issues/"+idA+"/comments/x/y.md"] = "nope"
106 },
107 } {
108 t.Run(name, func(t *testing.T) {
109 c := validContents()
110 mutate(c)
111 if _, err := Parse(c); err == nil {
112 t.Fatal("expected a validation error")
113 } else if _, ok := err.(*ValidationError); !ok {
114 t.Fatalf("error is %T, want *ValidationError: %v", err, err)
115 }
116 })
117 }
118 }
119
120 func TestAllProblemsReported(t *testing.T) {
121 c := validContents()
122 c["issues/"+idA+"/meta.json"] = `{"title":"","state":"nope","author":"","created":""}`
123 _, err := Parse(c)
124 ve, ok := err.(*ValidationError)
125 if !ok {
126 t.Fatalf("error is %T: %v", err, err)
127 }
128 if len(ve.Problems) < 4 {
129 t.Fatalf("want several problems, got %d: %v", len(ve.Problems), ve.Problems)
130 }
131 }
132
133 func TestULIDAndOrdering(t *testing.T) {
134 a, err := NewULID(time.Unix(1700000000, 0))
135 if err != nil {
136 t.Fatal(err)
137 }
138 b, err := NewULID(time.Unix(1700000001, 0))
139 if err != nil {
140 t.Fatal(err)
141 }
142 if !ValidULID(a) || !ValidULID(b) {
143 t.Fatalf("invalid ulids: %q %q", a, b)
144 }
145 if a >= b {
146 t.Fatalf("ulids do not sort by time: %q >= %q", a, b)
147 }
148 if ValidULID("lowercase0123456789ABCDEFGH") {
149 t.Fatal("lowercase ulid accepted")
150 }
151 }