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 3: a change request can be discussed
7c6e52e75f3a33be57a81663bc840edd6e14f7f9 · Keenan Nemetz · 17d ago · parent 3c5d11fa
Phase 3: a change request can be discussed Until now the only ways to say something about a request were editing its body or approving it. A review thread is now a pushed file like everything else: requests/<ulid>/comments/<ulid>.md author + created, then the body It is the *same file shape* as a comment on an issue and is read and written by the same code (`issues.ParseComment`, `issues.RenderComment`, with `requests.Comment` as an alias), so the thread inherited the validation, the markdown boundary and the ownership rule instead of reimplementing them. That last one is the part worth having: `pre-receive` compares the author on the old blob to the pushing account, so a review comment is its author's to edit or delete, an admin may repair a thread (audited), and rewriting the author line in the same push does not launder it. `vole request comment <id> -m` prints the comment's id and the page anchor (`#c-<ulid>`), `edit-comment` and `rm-comment` are the author's own words, and `request show` prints the thread so the ids are discoverable at all — an id nobody can see is an id nobody can use. Adding a comment is free, like adding one to an issue: a new file's author is a claim, and the boundary has never pretended otherwise. The request's own file stays outside the ownership rule, for the reason an issue's does: state, head and base are shared, and updating them is the author's normal edit. Verified: TestParseReadsTheDiscussion (a thread plus an approval in one tree, ordered by id), TestCommentNeedsAnAuthorAndATime and TestUnknownFileInARequestIsRefused (the subtree stays a closed format, with both expected shapes named), TestCommentPaths (a request comment is not an issue comment and not an approval); and end to end through the binary, TestRequestDiscussionIsPushedAndOwned — the empty state prints the command, the CLI adds a comment, the page renders it with its anchor and `request show` lists it, a writer is refused rewriting, adopting and deleting somebody else's comment, may join the thread and may edit his own — with the issue-comment empty state assertion added to TestIssuePushedThenRendered earlier. scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
internal/server/integration_test.go +98
| @@ -1162,6 +1162,104 @@ func TestRequestPageSaysWhenItWouldConflict(t *testing.T) { | |||
| 1162 | 1162 | } | |
| 1163 | 1163 | } | |
| 1164 | 1164 | ||
| 1165 | // TestRequestDiscussionIsPushedAndOwned: a change request can be discussed, and | ||
| 1166 | // the discussion is the same kind of file as an issue comment — pushed, validated | ||
| 1167 | // at the boundary, and owned by whoever wrote it. Without this, the only ways to | ||
| 1168 | // say something about a request are editing its body or approving it. | ||
| 1169 | func TestRequestDiscussionIsPushedAndOwned(t *testing.T) { | ||
| 1170 | f := newFixture(t) | ||
| 1171 | writerKey := f.addWriter(t) // wilbur: write access, not admin | ||
| 1172 | f.publish(f.repo) | ||
| 1173 | |||
| 1174 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 1175 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 1176 | t.Fatalf("main push: %s", errs) | ||
| 1177 | } | ||
| 1178 | feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature") | ||
| 1179 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 1180 | t.Fatalf("feature push: %s", errs) | ||
| 1181 | } | ||
| 1182 | meta := f.newMetaRepo(t) | ||
| 1183 | if err := meta.apply(map[string]string{ | ||
| 1184 | "requests/" + reqID + "/request.md": requestFile("main", head, "keenan", f.ownerID, "rationale\n"), | ||
| 1185 | }, "request"); err != nil { | ||
| 1186 | t.Fatalf("request push: %v", err) | ||
| 1187 | } | ||
| 1188 | |||
| 1189 | // An empty discussion prints the command that fills it. | ||
| 1190 | code, body := f.get("/r/" + f.repo + "/requests/" + reqID) | ||
| 1191 | if code != 200 { | ||
| 1192 | t.Fatalf("request page: %d", code) | ||
| 1193 | } | ||
| 1194 | if !strings.Contains(body, "vole request comment "+reqID) { | ||
| 1195 | t.Fatalf("the empty discussion state does not print the command:\n%s", body) | ||
| 1196 | } | ||
| 1197 | |||
| 1198 | // The author comments through the CLI, and the id is printed so the editing | ||
| 1199 | // commands have something to take. | ||
| 1200 | clone := filepath.Join(t.TempDir(), "clone") | ||
| 1201 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil { | ||
| 1202 | t.Fatalf("clone: %s", errs) | ||
| 1203 | } | ||
| 1204 | out, errs, err := f.cli(clone, "request", "comment", reqID, "-m", "Looks good to me.") | ||
| 1205 | if err != nil { | ||
| 1206 | t.Fatalf("request comment: %v\n%s", err, errs) | ||
| 1207 | } | ||
| 1208 | cid := strings.TrimSpace(out) | ||
| 1209 | if len(cid) != 26 { | ||
| 1210 | t.Fatalf("no comment id printed: %q", out) | ||
| 1211 | } | ||
| 1212 | _, body = f.get("/r/" + f.repo + "/requests/" + reqID) | ||
| 1213 | if !strings.Contains(body, "Looks good to me.") || !strings.Contains(body, `id="c-`+cid+`"`) { | ||
| 1214 | t.Fatalf("the comment is not rendered with an anchor:\n%s", body) | ||
| 1215 | } | ||
| 1216 | // `request show` prints the thread with ids, which is how they are found. | ||
| 1217 | if out, _, err := f.cli(clone, "request", "show", reqID); err != nil || | ||
| 1218 | !strings.Contains(out, "--- "+cid+" keenan") { | ||
| 1219 | t.Fatalf("request show does not print the thread: %v\n%s", err, out) | ||
| 1220 | } | ||
| 1221 | |||
| 1222 | commentPath := func(id string) string { return "requests/" + reqID + "/comments/" + id + ".md" } | ||
| 1223 | const created = "2026-10-22T23:00:00Z" | ||
| 1224 | |||
| 1225 | // Someone else may not rewrite it, adopt it, or delete it. | ||
| 1226 | other := f.newMetaRepo(t) | ||
| 1227 | other.key = writerKey | ||
| 1228 | if err := other.apply(map[string]string{commentPath(cid): commentFile("keenan", created, "something else\n")}, "edit foreign"); err == nil { | ||
| 1229 | t.Fatal("a writer rewrote somebody else's review comment") | ||
| 1230 | } else if !strings.Contains(err.Error(), "only keenan can change this comment") { | ||
| 1231 | t.Fatalf("refusal did not name the author: %v", err) | ||
| 1232 | } | ||
| 1233 | if err := other.apply(map[string]string{commentPath(cid): commentFile("wilbur", created, "mine now\n")}, "forge"); err == nil { | ||
| 1234 | t.Fatal("a writer adopted a review comment by rewriting its author field") | ||
| 1235 | } | ||
| 1236 | if err := other.remove(commentPath(cid)); err == nil { | ||
| 1237 | t.Fatal("a writer deleted somebody else's review comment") | ||
| 1238 | } else if !strings.Contains(err.Error(), "only keenan can delete this comment") { | ||
| 1239 | t.Fatalf("refusal did not name the author: %v", err) | ||
| 1240 | } | ||
| 1241 | |||
| 1242 | // Joining the discussion is free, and his own words are his to edit. | ||
| 1243 | wilburCm := "01M5JR2JK29KKMAA1T9MDNTRW1" | ||
| 1244 | if err := other.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "one question\n")}, "join"); err != nil { | ||
| 1245 | t.Fatalf("adding your own comment was refused: %v", err) | ||
| 1246 | } | ||
| 1247 | if err := other.apply(map[string]string{commentPath(wilburCm): commentFile("wilbur", created, "one question, rephrased\n")}, "edit own"); err != nil { | ||
| 1248 | t.Fatalf("editing your own comment was refused: %v", err) | ||
| 1249 | } | ||
| 1250 | _, body = f.get("/r/" + f.repo + "/requests/" + reqID) | ||
| 1251 | if !strings.Contains(body, "one question, rephrased") || strings.Contains(body, "one question</") { | ||
| 1252 | t.Fatalf("the edited comment was not replaced:\n%s", body) | ||
| 1253 | } | ||
| 1254 | if !strings.Contains(body, "Looks good to me.") { | ||
| 1255 | t.Fatalf("the thread lost the first comment:\n%s", body) | ||
| 1256 | } | ||
| 1257 | // An empty comment is refused by the CLI before anything is pushed. | ||
| 1258 | if _, _, err := f.cli(clone, "request", "comment", reqID, "-m", " "); err == nil { | ||
| 1259 | t.Fatal("an empty comment was accepted") | ||
| 1260 | } | ||
| 1261 | } | ||
| 1262 | |||
| 1165 | 1263 | func (f *fixture) serverIssues(t *testing.T) string { | |
| 1166 | 1264 | t.Helper() | |
| 1167 | 1265 | out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), | |