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