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/requests/write.go +221
| @@ -0,0 +1,221 @@ | |||
| 1 | package requests | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "time" | ||
| 7 | |||
| 8 | "github.com/rodent-software/vole/internal/gitx" | ||
| 9 | "github.com/rodent-software/vole/internal/issues" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // New builds a request with a fresh ULID. Nothing is assigned server-side, so | ||
| 13 | // concurrent authors never contend. | ||
| 14 | func New(title, body, author, base, head string, now time.Time) (*Request, error) { | ||
| 15 | id, err := issues.NewULID(now) | ||
| 16 | if err != nil { | ||
| 17 | return nil, err | ||
| 18 | } | ||
| 19 | r := &Request{ | ||
| 20 | ID: id, | ||
| 21 | Title: title, | ||
| 22 | Author: author, | ||
| 23 | Base: base, | ||
| 24 | Head: head, | ||
| 25 | State: "open", | ||
| 26 | Created: now.UTC(), | ||
| 27 | Description: body, | ||
| 28 | } | ||
| 29 | if err := validateForWrite(r); err != nil { | ||
| 30 | return nil, err | ||
| 31 | } | ||
| 32 | return r, nil | ||
| 33 | } | ||
| 34 | |||
| 35 | func validateForWrite(r *Request) error { | ||
| 36 | if r.Title == "" { | ||
| 37 | return fmt.Errorf("requests: title is required") | ||
| 38 | } | ||
| 39 | if len(r.Title) > maxTitleLen { | ||
| 40 | return fmt.Errorf("requests: title is %d bytes, limit %d", len(r.Title), maxTitleLen) | ||
| 41 | } | ||
| 42 | if r.Author == "" { | ||
| 43 | return fmt.Errorf("requests: author is required") | ||
| 44 | } | ||
| 45 | if !branchNameRE.MatchString(r.Base) { | ||
| 46 | return fmt.Errorf("requests: base %q is not a branch name", r.Base) | ||
| 47 | } | ||
| 48 | if r.Head != "" && !shaRE.MatchString(r.Head) { | ||
| 49 | return fmt.Errorf("requests: head %q is not a full commit id", r.Head) | ||
| 50 | } | ||
| 51 | return nil | ||
| 52 | } | ||
| 53 | |||
| 54 | func marshalMeta(r *Request) ([]byte, error) { | ||
| 55 | m := meta{ | ||
| 56 | Title: r.Title, | ||
| 57 | Author: r.Author, | ||
| 58 | Base: r.Base, | ||
| 59 | Head: r.Head, | ||
| 60 | State: r.State, | ||
| 61 | Created: r.Created.UTC().Format(time.RFC3339), | ||
| 62 | } | ||
| 63 | if !r.Updated.IsZero() { | ||
| 64 | m.Updated = r.Updated.UTC().Format(time.RFC3339) | ||
| 65 | } | ||
| 66 | b, err := json.MarshalIndent(m, "", " ") | ||
| 67 | if err != nil { | ||
| 68 | return nil, err | ||
| 69 | } | ||
| 70 | return append(b, '\n'), nil | ||
| 71 | } | ||
| 72 | |||
| 73 | // Create writes a new request onto the metadata branch. | ||
| 74 | func Create(g *gitx.Repo, r *Request, actor string) error { | ||
| 75 | if err := validateForWrite(r); err != nil { | ||
| 76 | return err | ||
| 77 | } | ||
| 78 | raw, err := marshalMeta(r) | ||
| 79 | if err != nil { | ||
| 80 | return err | ||
| 81 | } | ||
| 82 | base := fmt.Sprintf("%s/%s/", Dir, r.ID) | ||
| 83 | _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 84 | if _, exists := files[base+metaName]; exists { | ||
| 85 | return fmt.Errorf("requests: %s already exists", r.ID) | ||
| 86 | } | ||
| 87 | files[base+metaName] = raw | ||
| 88 | files[base+descName] = []byte(r.Description) | ||
| 89 | return nil | ||
| 90 | }, fmt.Sprintf("request: %s (%s)", r.Title, r.ID), actor) | ||
| 91 | return err | ||
| 92 | } | ||
| 93 | |||
| 94 | // Get returns one request by id. | ||
| 95 | func Get(g *gitx.Repo, id string) (*Request, error) { | ||
| 96 | list, err := Load(g, Ref) | ||
| 97 | if err != nil { | ||
| 98 | return nil, err | ||
| 99 | } | ||
| 100 | for _, r := range list { | ||
| 101 | if r.ID == id { | ||
| 102 | return r, nil | ||
| 103 | } | ||
| 104 | } | ||
| 105 | return nil, fmt.Errorf("requests: no request %s", id) | ||
| 106 | } | ||
| 107 | |||
| 108 | // SetHead repoints a request at a new commit. The approvals stay in the tree but | ||
| 109 | // stop matching, because they name the commit they reviewed. | ||
| 110 | func SetHead(g *gitx.Repo, id, head string, now time.Time, actor string) (*Request, error) { | ||
| 111 | var out *Request | ||
| 112 | base := fmt.Sprintf("%s/%s/", Dir, id) | ||
| 113 | _, err := gitx.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 114 | r, err := loadFrom(files, id) | ||
| 115 | if err != nil { | ||
| 116 | return err | ||
| 117 | } | ||
| 118 | r.Head = head | ||
| 119 | r.Updated = now.UTC() | ||
| 120 | if err := validateForWrite(r); err != nil { | ||
| 121 | return err | ||
| 122 | } | ||
| 123 | raw, err := marshalMeta(r) | ||
| 124 | if err != nil { | ||
| 125 | return err | ||
| 126 | } | ||
| 127 | files[base+metaName] = raw | ||
| 128 | out = r | ||
| 129 | return nil | ||
| 130 | }, fmt.Sprintf("request: %s head -> %s", id, shortSHA(head)), actor) | ||
| 131 | return out, err | ||
| 132 | } | ||
| 133 | |||
| 134 | // Approve records principal's approval of the request's current head and base. | ||
| 135 | func Approve(g *gitx.Repo, id, principal string, now time.Time, actor string) (*Request, error) { | ||
| 136 | if !userNameRE.MatchString(principal) { | ||
| 137 | return nil, fmt.Errorf("requests: %q is not a valid Vole user name", principal) | ||
| 138 | } | ||
| 139 | var out *Request | ||
| 140 | base := fmt.Sprintf("%s/%s/", Dir, id) | ||
| 141 | _, err := gitx.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 142 | r, err := loadFrom(files, id) | ||
| 143 | if err != nil { | ||
| 144 | return err | ||
| 145 | } | ||
| 146 | if r.Author == principal { | ||
| 147 | return fmt.Errorf("requests: %s cannot approve their own request", principal) | ||
| 148 | } | ||
| 149 | if r.Head == "" { | ||
| 150 | return fmt.Errorf("requests: %s has no head commit to approve", id) | ||
| 151 | } | ||
| 152 | body, err := json.MarshalIndent(struct { | ||
| 153 | Base string `json:"base"` | ||
| 154 | Head string `json:"head"` | ||
| 155 | Created string `json:"created"` | ||
| 156 | }{r.Base, r.Head, now.UTC().Format(time.RFC3339)}, "", " ") | ||
| 157 | if err != nil { | ||
| 158 | return err | ||
| 159 | } | ||
| 160 | files[fmt.Sprintf("%s%s/%s.json", base, approvalDir, principal)] = append(body, '\n') | ||
| 161 | out = r | ||
| 162 | return nil | ||
| 163 | }, fmt.Sprintf("approve: %s by %s", id, principal), actor) | ||
| 164 | return out, err | ||
| 165 | } | ||
| 166 | |||
| 167 | // SetState moves a request between open and closed. | ||
| 168 | func SetState(g *gitx.Repo, id, state string, now time.Time, actor string) (*Request, error) { | ||
| 169 | switch state { | ||
| 170 | case "open", "merged", "closed": | ||
| 171 | default: | ||
| 172 | return nil, fmt.Errorf("requests: state must be open or closed") | ||
| 173 | } | ||
| 174 | var out *Request | ||
| 175 | base := fmt.Sprintf("%s/%s/", Dir, id) | ||
| 176 | _, err := gitx.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 177 | r, err := loadFrom(files, id) | ||
| 178 | if err != nil { | ||
| 179 | return err | ||
| 180 | } | ||
| 181 | r.State = state | ||
| 182 | r.Updated = now.UTC() | ||
| 183 | raw, err := marshalMeta(r) | ||
| 184 | if err != nil { | ||
| 185 | return err | ||
| 186 | } | ||
| 187 | files[base+metaName] = raw | ||
| 188 | out = r | ||
| 189 | return nil | ||
| 190 | }, fmt.Sprintf("request: %s %s", id, state), actor) | ||
| 191 | return out, err | ||
| 192 | } | ||
| 193 | |||
| 194 | // loadFrom reads a request out of the tree map a commit is being built from, so | ||
| 195 | // mutations always see the tip they are about to modify. | ||
| 196 | func loadFrom(files map[string][]byte, id string) (*Request, error) { | ||
| 197 | contents := make(map[string]string, len(files)) | ||
| 198 | for k, v := range files { | ||
| 199 | contents[k] = string(v) | ||
| 200 | } | ||
| 201 | list, err := Parse(contents) | ||
| 202 | if err != nil { | ||
| 203 | if ve, ok := err.(*ValidationError); ok { | ||
| 204 | return nil, fmt.Errorf("requests: the metadata tree is invalid: %s", ve.Problems[0]) | ||
| 205 | } | ||
| 206 | return nil, err | ||
| 207 | } | ||
| 208 | for _, r := range list { | ||
| 209 | if r.ID == id { | ||
| 210 | return r, nil | ||
| 211 | } | ||
| 212 | } | ||
| 213 | return nil, fmt.Errorf("requests: no request %s", id) | ||
| 214 | } | ||
| 215 | |||
| 216 | func shortSHA(s string) string { | ||
| 217 | if len(s) > 8 { | ||
| 218 | return s[:8] | ||
| 219 | } | ||
| 220 | return s | ||
| 221 | } | ||