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