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.

Add browser auth, contributor access, and gated CI

6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073

Add browser auth, contributor access, and gated CI

Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn.

Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page.

Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working.

Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.

server/web/requests.go +51−8

@@ -4,7 +4,9 @@ import (
4 4 "html/template"
5 5 "net/http"
6 6 "sort"
7 "strings"
7 8
9 "github.com/rodent-software/vole/forge/access"
8 10 "github.com/rodent-software/vole/metadata"
9 11 )
10 12
@@ -38,14 +40,51 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) {
38 40 }
39 41 return rows[i].ID > rows[j].ID
40 42 })
43 // The head picker is the interesting one: a contributor may only open a
44 // request from their own namespace, so the branches they can choose are
45 // filtered before the page is rendered. The handler checks again.
46 var branches []string
47 metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/")
48 if refs, err := m.Git().ListRefs("refs/heads/"); err == nil {
49 for ref := range refs {
50 name := strings.TrimPrefix(ref, "refs/heads/")
51 if name == "" || name == metadataBranch {
52 continue
53 }
54 branches = append(branches, name)
55 }
56 sort.Strings(branches)
57 }
58 baseBranches := branches
59 headBranches := branches
60 accountID := w.accountID(r)
61 acl, _ := m.ACL()
62 canOpen := acl != nil && accountID != "" && acl.CanOpenRequest(accountID)
63 if acl != nil && acl.RoleFor(accountID) == access.Contributor {
64 headBranches = nil
65 for _, name := range branches {
66 if access.IsContributorRef(accountID, "refs/heads/"+name) {
67 headBranches = append(headBranches, name)
68 }
69 }
70 }
71 base := m.DefaultBranch
41 72 w.render(rw, "requests", struct {
42 73 Page
43 Name string
44 Rows []*metadata.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)
74 Name string
75 Rows []*metadata.Request
76 State string
77 Open int
78 Total int
79 CanOpen bool
80 Base string
81 BaseBranches []string
82 HeadBranches []string
83 }{
84 Page: w.page(r, m.Name+" · change requests"), Name: m.Name, Rows: rows, State: want,
85 Open: open, Total: len(list), CanOpen: canOpen, Base: base,
86 BaseBranches: baseBranches, HeadBranches: headBranches,
87 }, 0)
49 88 }
50 89
51 90 // RequestView renders one change request: what it would land, what changed, and
@@ -236,6 +275,8 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
236 275 current++
237 276 }
238 277 }
278 acl, _ := m.ACL()
279 canApprove := acl != nil && req.Open() && w.canApprove(r, acl, req.AuthorID)
239 280
240 281 w.render(rw, "request", struct {
241 282 Page
@@ -254,9 +295,11 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
254 295 MergeChecked bool
255 296 Required int
256 297 Current int
257 }{Page: w.base(m.Name + " · " + req.Title), Name: m.Name, Request: req,
298 CanApprove bool
299 }{Page: w.page(r, m.Name+" · "+req.Title), Name: m.Name, Request: req,
258 300 Description: w.renderMarkdown(m.Name, req.Description), Approvals: approvals, Checks: checks,
259 301 Files: files, Added: added, Removed: removed, Truncated: truncated, AlreadyMerged: alreadyMerged,
260 302 Comments: comments,
261 Conflicts: conflicts, MergeChecked: mergeChecked, Required: required, Current: current}, 0)
303 Conflicts: conflicts, MergeChecked: mergeChecked, Required: required, Current: current,
304 CanApprove: canApprove}, 0)
262 305 }