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/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"),