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/requests.go +112
| @@ -0,0 +1,112 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "html/template" | ||
| 5 | "net/http" | ||
| 6 | "sort" | ||
| 7 | |||
| 8 | "github.com/rodent-software/vole/internal/requests" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // Requests lists a repo's change requests. They are read straight from git: the | ||
| 12 | // volume is small and the branch is authoritative, so there is no index to go | ||
| 13 | // stale here. | ||
| 14 | func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | ||
| 15 | m, ok := w.open(rw, r) | ||
| 16 | if !ok { | ||
| 17 | return | ||
| 18 | } | ||
| 19 | list, err := requests.Load(m.Git(), requests.Ref) | ||
| 20 | if err != nil { | ||
| 21 | http.Error(rw, "cannot read change requests: "+err.Error(), http.StatusInternalServerError) | ||
| 22 | return | ||
| 23 | } | ||
| 24 | want := r.URL.Query().Get("state") | ||
| 25 | rows := make([]*requests.Request, 0, len(list)) | ||
| 26 | open := 0 | ||
| 27 | for _, req := range list { | ||
| 28 | if req.State == "open" { | ||
| 29 | open++ | ||
| 30 | } | ||
| 31 | if want == "" || req.State == want { | ||
| 32 | rows = append(rows, req) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | sort.SliceStable(rows, func(i, j int) bool { | ||
| 36 | if (rows[i].State == "open") != (rows[j].State == "open") { | ||
| 37 | return rows[i].State == "open" | ||
| 38 | } | ||
| 39 | return rows[i].ID > rows[j].ID | ||
| 40 | }) | ||
| 41 | w.render(rw, "requests", struct { | ||
| 42 | Page | ||
| 43 | Name string | ||
| 44 | Rows []*requests.Request | ||
| 45 | State string | ||
| 46 | Open int | ||
| 47 | Total int | ||
| 48 | }{Page: w.base(m.Name + " · change requests"), Name: m.Name, Rows: rows, State: want, Open: open, Total: len(list)}, 0) | ||
| 49 | } | ||
| 50 | |||
| 51 | // RequestView renders one change request: what it would land, what changed, and | ||
| 52 | // which approvals currently apply. | ||
| 53 | type requestApproval struct { | ||
| 54 | requests.Approval | ||
| 55 | // Current is false when the approval names a commit or base that is no longer | ||
| 56 | // what the request asks for, which is the normal state after a force-push. | ||
| 57 | Current bool | ||
| 58 | } | ||
| 59 | |||
| 60 | func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | ||
| 61 | m, ok := w.open(rw, r) | ||
| 62 | if !ok { | ||
| 63 | return | ||
| 64 | } | ||
| 65 | id := r.PathValue("id") | ||
| 66 | list, err := requests.Load(m.Git(), requests.Ref) | ||
| 67 | if err != nil { | ||
| 68 | http.Error(rw, "cannot read change requests: "+err.Error(), http.StatusInternalServerError) | ||
| 69 | return | ||
| 70 | } | ||
| 71 | var req *requests.Request | ||
| 72 | for _, x := range list { | ||
| 73 | if x.ID == id { | ||
| 74 | req = x | ||
| 75 | break | ||
| 76 | } | ||
| 77 | } | ||
| 78 | if req == nil { | ||
| 79 | http.NotFound(rw, r) | ||
| 80 | return | ||
| 81 | } | ||
| 82 | approvals := make([]requestApproval, 0, len(req.Approvals)) | ||
| 83 | for _, a := range req.Approvals { | ||
| 84 | approvals = append(approvals, requestApproval{ | ||
| 85 | Approval: a, | ||
| 86 | Current: a.Head == req.Head && a.Base == req.Base, | ||
| 87 | }) | ||
| 88 | } | ||
| 89 | // The diff is computed from git on every view: it is cheap for one branch and | ||
| 90 | // always matches what the approver is being asked to approve. | ||
| 91 | var changed []string | ||
| 92 | alreadyMerged := false | ||
| 93 | if req.Head != "" { | ||
| 94 | if files, err := m.Git().DiffNameStatus(req.Base, req.Head); err == nil { | ||
| 95 | changed = files | ||
| 96 | } | ||
| 97 | // An empty diff means one of two very different things, so say which. | ||
| 98 | if in, err := m.Git().IsAncestor(req.Head, req.Base); err == nil && in { | ||
| 99 | alreadyMerged = true | ||
| 100 | } | ||
| 101 | } | ||
| 102 | w.render(rw, "request", struct { | ||
| 103 | Page | ||
| 104 | Name string | ||
| 105 | Request *requests.Request | ||
| 106 | Description template.HTML | ||
| 107 | Approvals []requestApproval | ||
| 108 | Changed []string | ||
| 109 | AlreadyMerged bool | ||
| 110 | }{Page: w.base(m.Name + " · " + req.Title), Name: m.Name, Request: req, | ||
| 111 | Description: w.renderMarkdown(m.Name, req.Description), Approvals: approvals, Changed: changed, AlreadyMerged: alreadyMerged}, 0) | ||
| 112 | } | ||