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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 3: change requests, with approvals bound to the commit they reviewed

1eeca4e67a65d71217d51b1c41632330225b351a · Keenan Nemetz · 18d ago · parent 92b37587

Phase 3: change requests, with approvals bound to the commit they reviewed

Review is now a git artifact. A request names a base branch and one head commit;
an approval names that same commit, so a branch that moves after approval is not
approved any more. Because `git push` is the merge, the gate lives in pre-receive
and there is no authenticated write surface to build.

  requests/<ulid>/meta.json          title, author, base, head, state
  requests/<ulid>/description.md
  requests/<ulid>/approvals/<user>.json   base + head + created

Two push shapes are accepted for a protected branch: the new tip is the approved
head, or the new tip is a merge whose parents are the branch tip and the approved
head. "The approved head is an ancestor of the new tip" is refused on purpose —
accepting it would let anyone append unapproved commits and land them together
with an approved one. Admins keep their bypass; `vole acl approvals <repo> <n>`
sets the count, and 0 restores admin-only.

Three integrity holes found while building it, each now covered by a test:

- An approval file names a principal, but nothing tied that name to the pusher, so
  any writer could commit approvals/<someone-else>.json. The hook now requires a
  newly added approval to be named after the SSH identity. That check initially
  skipped the push that *creates* the branch, where there is no old commit to diff
  against — which let whoever created the branch seed any approval they liked.
  Fixed to inspect the whole tree on a first push.
- Approvals were bound to the head but not the branch, so rewriting a request's
  base repointed an approval at somewhere the approver never looked.
- Self-approval was refused at the CLI but not in validation, so a hand-written
  tree could carry one.

Also relaxes the CLI to accept options after arguments, as git does. Refusing
`vole request approve <id> --as keenan` is a local dialect, not git's behaviour,
and `git commit -m msg path` is the shape people already type. Exit 129, no
bundling and `--`/`--end-of-options` are unchanged.

Verified end to end on a real instance with two keys: a writer's direct push to a
protected main is refused with a message naming the exact commit; the same push
still fails with a request open but unapproved; self-approval fails; an admin
approval lets it through; main advances; the web shows the approval as current and
the request as already merged; the audit log records the merge with request id and
approver.

Known rough edge: a merged request stays open until someone closes it. Marking it
merged automatically needs the server to write metadata during a push, which the
design deliberately does not do. Phase 4 CI is the natural place to close it.

internal/web/templates.go +72−1

@@ -107,6 +107,7 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde
107 107 </div>
108 108
109 109 <p><a href="/r/{{.Name}}/issues">Issues</a> <span class="muted">({{.IssueCount}})</span></p>
110 <p><a href="/r/{{.Name}}/requests">Change requests</a></p>
110 111
111 112 <h2>Files</h2>
112 113 {{if .Entries}}
@@ -224,7 +225,7 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde
224 225 {{else if .Filtered}}
225 226 <p class="muted">No issues match that filter.</p>
226 227 {{else}}
227 <p class="muted">No issues yet. An issue is a directory on the <code>issues</code> branch:
228 <p class="muted">No issues yet. An issue is a directory on the <code>vole</code> branch:
228 229 <code>issues/&lt;ulid&gt;/meta.json</code> plus <code>description.md</code>.</p>
229 230 {{end}}
230 231 {{template "footer" .}}
@@ -270,4 +271,74 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde
270 271 {{template "commitlist" .}}
271 272 {{template "footer" .}}
272 273 {{end}}
274
275
276 {{define "requests"}}
277 {{template "header" .}}
278 <h1><a href="/r/{{.Name}}">{{.Name}}</a> · change requests</h1>
279 <p class="muted">{{.Open}} open · {{.Total}} total</p>
280 {{if .Rows}}
281 <table>
282 <tr><th>ID</th><th>Title</th><th>Base</th><th>Head</th><th>Approvals</th><th>State</th><th>Age</th></tr>
283 {{range .Rows}}
284 <tr>
285 <td class="sha">{{short .ID}}</td>
286 <td><a href="/r/{{$.Name}}/requests/{{.ID}}">{{.Title}}</a></td>
287 <td class="muted">{{.Base}}</td>
288 <td class="sha">{{short .Head}}</td>
289 <td class="muted">{{len .Approvals}}</td>
290 <td class="muted">{{.State}}</td>
291 <td class="muted">{{since .Created.Unix}}</td>
292 </tr>
293 {{end}}
294 </table>
295 {{else}}
296 <p class="muted">No change requests. A request is a directory on the <code>vole</code> branch:
297 <code>requests/&lt;ulid&gt;/meta.json</code> naming the <code>base</code> branch and the <code>head</code>
298 commit to land, plus <code>approvals/&lt;user&gt;.json</code> from each approver.</p>
299 {{end}}
300 {{template "footer" .}}
301 {{end}}
302
303 {{define "request"}}
304 {{template "header" .}}
305 <h1>{{.Request.Title}}</h1>
306 <p class="muted">
307 {{.Request.State}} · <span class="sha">{{short .Request.ID}}</span> ·
308 by {{.Request.Author}} · {{since .Request.Created.Unix}}
309 </p>
310 <p>
311 <strong>{{.Request.Base}}</strong> ← <span class="sha">{{short .Request.Head}}</span>
312 {{if .Request.Head}}<a href="/r/{{.Name}}/commits/{{.Request.Base}}">history</a>{{end}}
313 </p>
314 {{if .Description}}<div class="markdown">{{.Description}}</div>{{end}}
315
316 <h2>Approvals</h2>
317 {{if .Approvals}}
318 <table>
319 <tr><th>Approver</th><th>Head approved</th><th>Base</th><th>Status</th><th>When</th></tr>
320 {{range .Approvals}}
321 <tr>
322 <td>{{.Principal}}</td>
323 <td class="sha">{{short .Head}}</td>
324 <td class="muted">{{.Base}}</td>
325 <td class="muted">{{if .Current}}current{{else}}stale (head or base has changed){{end}}</td>
326 <td class="muted">{{since .Created.Unix}}</td>
327 </tr>
328 {{end}}
329 </table>
330 {{else}}
331 <p class="muted">No approvals yet. An approval names one commit, so pushing a new head starts over.</p>
332 {{end}}
333
334 <h2>What would land</h2>
335 {{if .AlreadyMerged}}
336 <p class="muted">Already merged into {{.Request.Base}}.</p>
337 {{else if .Changed}}
338 <ul class="files">{{range .Changed}}<li class="sha">{{.}}</li>{{end}}</ul>
339 {{else}}
340 <p class="muted">No changes, or the head is not reachable from this repository yet.</p>
341 {{end}}
342 {{template "footer" .}}
343 {{end}}
273 344 `