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 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 }