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/<ulid>/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/<ulid>/meta.json</code> naming the <code>base</code> branch and the <code>head</code> | ||
| 298 | commit to land, plus <code>approvals/<user>.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 | ` | |