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/requests/requests_test.go +129
| @@ -0,0 +1,129 @@ | |||
| 1 | package requests | ||
| 2 | |||
| 3 | // A review thread lives in the same tree as the request it discusses, so the | ||
| 4 | // parser is also the boundary that decides what a comment file may look like. | ||
| 5 | // These tests hold the two halves: a comment is accepted exactly when it has an | ||
| 6 | // author and a timestamp, and anything else in the subtree is refused with the | ||
| 7 | // path named. | ||
| 8 | |||
| 9 | import ( | ||
| 10 | "strings" | ||
| 11 | "testing" | ||
| 12 | ) | ||
| 13 | |||
| 14 | const ( | ||
| 15 | idA = "01M5JR2JK29KKMAA1T9MDNTRB1" // the request | ||
| 16 | idB = "01M5JR2JK29KKMAA1T9MDNTRB2" // a comment | ||
| 17 | idC = "01M5JR2JK29KKMAA1T9MDNTRB3" // the author's account, and what the writer last saw | ||
| 18 | idD = "01M5JR2JK29KKMAA1T9MDNTRB4" // an approver who is not the author | ||
| 19 | ) | ||
| 20 | |||
| 21 | func requestBody(head string) string { | ||
| 22 | return "---\n" + | ||
| 23 | `title: "Land the retry"` + "\n" + | ||
| 24 | "state: open\n" + | ||
| 25 | "author: keenan\n" + | ||
| 26 | "author_id: " + idC + "\n" + | ||
| 27 | "base: main\n" + | ||
| 28 | "head: " + head + "\n" + | ||
| 29 | "created: 2026-10-22T22:00:00Z\n" + | ||
| 30 | "---\n" + | ||
| 31 | "why this change\n" | ||
| 32 | } | ||
| 33 | |||
| 34 | const ( | ||
| 35 | headSHA = "0123456789abcdef0123456789abcdef01234567" | ||
| 36 | sideSHA = "89abcdef0123456789abcdef0123456789abcdef" | ||
| 37 | ) | ||
| 38 | |||
| 39 | func TestParseReadsTheDiscussion(t *testing.T) { | ||
| 40 | list, err := Parse(map[string]string{ | ||
| 41 | Dir + "/" + idA + "/request.md": requestBody(headSHA), | ||
| 42 | Dir + "/" + idA + "/comments/" + idB + ".md": "---\nauthor: wilbur\ncreated: 2026-10-22T23:00:00Z\n---\nOne question.\n", | ||
| 43 | Dir + "/" + idA + "/approvals/" + idD + ".md": "---\nbase: main\nhead: " + headSHA + "\ncreated: 2026-10-22T23:10:00Z\n---\n", | ||
| 44 | Dir + "/" + idA + "/comments/01M5JR2JK29KKMAA1T9MDNTRBZ.md": "---\nauthor: keenan\ncreated: 2026-10-22T23:05:00Z\n---\nEarlier.\n", | ||
| 45 | }) | ||
| 46 | if err != nil { | ||
| 47 | t.Fatalf("unexpected error: %v", err) | ||
| 48 | } | ||
| 49 | if len(list) != 1 { | ||
| 50 | t.Fatalf("requests = %d, want 1", len(list)) | ||
| 51 | } | ||
| 52 | req := list[0] | ||
| 53 | if len(req.Comments) != 2 { | ||
| 54 | t.Fatalf("comments = %d, want 2", len(req.Comments)) | ||
| 55 | } | ||
| 56 | // Sorted by id, which is time-ordered, so a thread reads in the order it was | ||
| 57 | // written rather than in whatever order the tree was walked. | ||
| 58 | if req.Comments[0].Author != "wilbur" || req.Comments[1].Author != "keenan" { | ||
| 59 | t.Fatalf("comment order: %s then %s", req.Comments[0].Author, req.Comments[1].Author) | ||
| 60 | } | ||
| 61 | if strings.TrimSpace(req.Comments[0].Body) != "One question." { | ||
| 62 | t.Fatalf("body = %q", req.Comments[0].Body) | ||
| 63 | } | ||
| 64 | if len(req.Approvals) != 1 || req.Approvals[0].Principal != idD { | ||
| 65 | t.Fatalf("approvals = %+v", req.Approvals) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | func TestCommentNeedsAnAuthorAndATime(t *testing.T) { | ||
| 70 | cases := []struct{ name, body, want string }{ | ||
| 71 | {"no author", "---\ncreated: 2026-10-22T23:00:00Z\n---\ntext\n", "author is required"}, | ||
| 72 | {"no created", "---\nauthor: wilbur\n---\ntext\n", "created is required"}, | ||
| 73 | {"bad created", "---\nauthor: wilbur\ncreated: yesterday\n---\ntext\n", "created is required and must be RFC3339"}, | ||
| 74 | {"no frontmatter", "just text\n", "a comment is frontmatter with author and created"}, | ||
| 75 | } | ||
| 76 | for _, c := range cases { | ||
| 77 | _, err := Parse(map[string]string{ | ||
| 78 | Dir + "/" + idA + "/request.md": requestBody(headSHA), | ||
| 79 | Dir + "/" + idA + "/comments/" + idB + ".md": c.body, | ||
| 80 | }) | ||
| 81 | ve, ok := err.(*ValidationError) | ||
| 82 | if !ok { | ||
| 83 | t.Fatalf("%s: accepted: %v", c.name, err) | ||
| 84 | } | ||
| 85 | joined := strings.Join(ve.Problems, "; ") | ||
| 86 | if !strings.Contains(joined, "comments/"+idB+".md") || !strings.Contains(joined, c.want) { | ||
| 87 | t.Fatalf("%s: unhelpful refusal: %v", c.name, ve.Problems) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | // TestUnknownFileInARequestIsRefused: the subtree is a closed format, so a review | ||
| 93 | // note saved under the wrong name is a rejection with both shapes named, not a | ||
| 94 | // file the web silently ignores. | ||
| 95 | func TestUnknownFileInARequestIsRefused(t *testing.T) { | ||
| 96 | _, err := Parse(map[string]string{ | ||
| 97 | Dir + "/" + idA + "/request.md": requestBody(headSHA), | ||
| 98 | Dir + "/" + idA + "/review.md": "---\nauthor: wilbur\ncreated: 2026-10-22T23:00:00Z\n---\nnote\n", | ||
| 99 | }) | ||
| 100 | ve, ok := err.(*ValidationError) | ||
| 101 | if !ok { | ||
| 102 | t.Fatalf("accepted: %v", err) | ||
| 103 | } | ||
| 104 | joined := strings.Join(ve.Problems, "; ") | ||
| 105 | for _, want := range []string{"review.md", "request.md", "approvals/", "comments/"} { | ||
| 106 | if !strings.Contains(joined, want) { | ||
| 107 | t.Fatalf("refusal does not mention %q: %v", want, ve.Problems) | ||
| 108 | } | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | func TestCommentPaths(t *testing.T) { | ||
| 113 | path := CommentPath(idA, idB) | ||
| 114 | if path != "requests/"+idA+"/comments/"+idB+".md" { | ||
| 115 | t.Fatalf("path = %q", path) | ||
| 116 | } | ||
| 117 | if !IsCommentPath(path) { | ||
| 118 | t.Fatal("a comment path was not recognised") | ||
| 119 | } | ||
| 120 | for _, other := range []string{ | ||
| 121 | "requests/" + idA + "/request.md", | ||
| 122 | "requests/" + idA + "/approvals/" + idB + ".md", | ||
| 123 | "issues/" + idA + "/comments/" + idB + ".md", | ||
| 124 | } { | ||
| 125 | if IsCommentPath(other) { | ||
| 126 | t.Fatalf("%q was mistaken for a request comment", other) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | } | ||