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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Build out the forge web UI

f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264

Build out the forge web UI

Turn the web surface into a compact, server-rendered forge with HTMX as the
progressive dynamic layer.

- Split templates and assets into embedded files; add a tokenised dark theme,
  Lucide icons, and vendored HTMX.
- Add the repository Overview page and a separate chronological Activity feed
  with type filters and HTMX fragment updates.
- Finish the issue and change workflows: create pages, comments, close/reopen,
  labels and assignees, approval, discussion, line-anchored review comments,
  and HTMX list filters.
- Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware
  code browsing, and a repository Runs list.
- Add a loopback-only development login for local write testing, with explicit
  configuration and listener checks.
- Extend tests for the new pages, fragments, labels, assignees, anchors, and
  development login.

server/web/requests.go +342−148

@@ -1,48 +1,31 @@
1 1 package web
2 2
3 3 import (
4 "fmt"
4 5 "html/template"
5 6 "net/http"
6 7 "sort"
8 "strconv"
7 9 "strings"
8 10
9 11 "github.com/rodent-software/vole/forge/access"
12 "github.com/rodent-software/vole/forge/repo"
10 13 "github.com/rodent-software/vole/metadata"
11 14 )
12 15
13 // Requests lists a repo's change requests. They are read straight from git: the
14 // volume is small and the branch is authoritative, so there is no index to go
15 // stale here.
16 func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) {
17 m, ok := w.open(rw, r)
18 if !ok {
19 return
20 }
21 list, err := metadata.LoadRequests(m.Git(), metadata.Ref)
22 if err != nil {
23 http.Error(rw, "cannot read change requests: "+err.Error(), http.StatusInternalServerError)
24 return
25 }
26 want := r.URL.Query().Get("state")
27 rows := make([]*metadata.Request, 0, len(list))
28 open := 0
29 for _, req := range list {
30 if req.State == "open" {
31 open++
32 }
33 if want == "" || req.State == want {
34 rows = append(rows, req)
35 }
36 }
37 sort.SliceStable(rows, func(i, j int) bool {
38 if (rows[i].State == "open") != (rows[j].State == "open") {
39 return rows[i].State == "open"
40 }
41 return rows[i].ID > rows[j].ID
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.
16 // requestRow is a request plus the number of approvals that still name its
17 // current head and base. The count is per row because an approval of an older
18 // head is history, not a vote for what is being proposed now.
19 // requestFormData is shared by the list page's New change button and the
20 // dedicated form page: the same branch rules must decide what the picker offers.
21 type requestFormData struct {
22 Base string
23 BaseBranches []string
24 HeadBranches []string
25 CanOpen bool
26 }
27
28 func (w *Web) requestForm(r *http.Request, m *repo.Meta) requestFormData {
46 29 var branches []string
47 30 metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/")
48 31 if refs, err := m.Git().ListRefs("refs/heads/"); err == nil {
@@ -61,8 +44,6 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) {
61 44 acl, _ := m.ACL()
62 45 canOpen := acl != nil && accountID != "" && acl.CanOpenRequest(accountID)
63 46 if acl != nil && acl.RoleFor(accountID) == access.Contributor {
64 // The namespace is named after the account name, which is fixed at
65 // creation, so this filter can never disagree with what the hook allows.
66 47 name := accountID
67 48 if w.Ident != nil {
68 49 name = w.Ident.Name(accountID)
@@ -74,22 +55,131 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) {
74 55 }
75 56 }
76 57 }
77 base := m.DefaultBranch
78 w.render(rw, "requests", struct {
58 return requestFormData{
59 Base: m.DefaultBranch, BaseBranches: baseBranches,
60 HeadBranches: headBranches, CanOpen: canOpen,
61 }
62 }
63
64 type requestRow struct {
65 *metadata.Request
66 Current int
67 }
68
69 // Requests lists a repo's change requests. They are read straight from git: the
70 // volume is small and the branch is authoritative, so there is no index to go
71 // stale here.
72 func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) {
73 m, ok := w.open(rw, r)
74 if !ok {
75 return
76 }
77 list, err := metadata.LoadRequests(m.Git(), metadata.Ref)
78 if err != nil {
79 http.Error(rw, "cannot read change requests: "+err.Error(), http.StatusInternalServerError)
80 return
81 }
82 state := strings.TrimSpace(r.URL.Query().Get("state"))
83 if state == "" {
84 state = "open"
85 }
86 if state != "open" && state != "closed" && state != "all" {
87 state = "open"
88 }
89 query := strings.TrimSpace(r.URL.Query().Get("q"))
90 needle := strings.ToLower(query)
91 rows := make([]requestRow, 0, len(list))
92 open := 0
93 for _, req := range list {
94 if req.State == "open" {
95 open++
96 }
97 if state != "all" && req.State != state {
98 continue
99 }
100 if needle != "" {
101 haystack := strings.ToLower(req.Title + "\n" + req.Description + "\n" + req.Author)
102 if !strings.Contains(haystack, needle) {
103 continue
104 }
105 }
106 current := 0
107 for _, a := range req.Approvals {
108 if a.Head == req.Head && a.Base == req.Base {
109 current++
110 }
111 }
112 rows = append(rows, requestRow{Request: req, Current: current})
113 }
114 sort.SliceStable(rows, func(i, j int) bool {
115 if (rows[i].State == "open") != (rows[j].State == "open") {
116 return rows[i].State == "open"
117 }
118 return rows[i].ID > rows[j].ID
119 })
120 form := w.requestForm(r, m)
121
122 acl, _ := m.ACL()
123 required := 0
124 if acl != nil {
125 required = acl.RequiredApprovals()
126 }
127 view := struct {
79 128 Page
80 129 Name string
81 Rows []*metadata.Request
130 Rows []requestRow
82 131 State string
132 Query string
83 133 Open int
84 134 Total int
135 Closed int
136 Required int
137 Filtered bool
85 138 CanOpen bool
86 139 Base string
87 140 BaseBranches []string
88 141 HeadBranches []string
89 142 }{
90 Page: w.page(r, m.Name+" · change requests"), Name: m.Name, Rows: rows, State: want,
91 Open: open, Total: len(list), CanOpen: canOpen, Base: base,
92 BaseBranches: baseBranches, HeadBranches: headBranches,
143 Page: w.page(r, m.Name+" · changes"), Name: m.Name, Rows: rows, State: state,
144 Query: query, Open: open, Total: len(list), Closed: len(list) - open,
145 Required: required, Filtered: query != "" || (state != "open" && len(list) > 0),
146 CanOpen: form.CanOpen, Base: form.Base,
147 BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches,
148 }
149 if htmxRequest(r) {
150 w.render(rw, "change_list", view, 0)
151 return
152 }
153 w.render(rw, "requests", view, 0)
154 }
155
156 // NewChangePage serves the dedicated new-change form. The list page only links
157 // here when the viewer can open a request, and the handler checks again because
158 // a link is not an authorization.
159 func (w *Web) NewChangePage(rw http.ResponseWriter, r *http.Request) {
160 m, ok := w.open(rw, r)
161 if !ok {
162 return
163 }
164 pr, ok := w.requireHTMLUser(rw, r)
165 if !ok {
166 return
167 }
168 acl, err := m.ACL()
169 if err != nil || !acl.CanOpenRequest(pr.account.ID) {
170 http.Error(rw, "you do not have permission to open change requests here", http.StatusForbidden)
171 return
172 }
173 form := w.requestForm(r, m)
174 w.render(rw, "request_new", struct {
175 Page
176 Name string
177 Base string
178 BaseBranches []string
179 HeadBranches []string
180 }{
181 Page: w.page(r, m.Name+" · new change"), Name: m.Name,
182 Base: form.Base, BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches,
93 183 }, 0)
94 184 }
95 185
@@ -104,50 +194,87 @@ type requestApproval struct {
104 194 Current bool
105 195 }
106 196
107 func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
108 m, ok := w.open(rw, r)
109 if !ok {
110 return
111 }
112 id := r.PathValue("id")
113 list, err := metadata.LoadRequests(m.Git(), metadata.Ref)
114 if err != nil {
115 http.Error(rw, "cannot read change requests: "+err.Error(), http.StatusInternalServerError)
116 return
117 }
118 var req *metadata.Request
119 for _, x := range list {
120 if x.ID == id {
121 req = x
122 break
123 }
124 }
125 if req == nil {
126 http.NotFound(rw, r)
127 return
128 }
197 type requestCheck struct {
198 ID, Workflow, Job, Status string
199 Exit int
200 }
201
202 type requestCommentView struct {
203 metadata.Comment
204 Body template.HTML
205 // Current reports whether the file still holds the content this comment was
206 // written about. A stale comment keeps its place in the thread and loses its
207 // marker on the diff.
208 Current bool
209 }
210
211 // requestSidebarView is the data the change sidebar fragments need. One type for
212 // the full page and the HTMX replacements, so an approval or state change can
213 // refresh the whole sidebar without rebuilding the rest of the review.
214 type requestSidebarView struct {
215 Page
216 Name string
217 Request *metadata.Request
218 Approvals []requestApproval
219 Checks []requestCheck
220 Required int
221 Current int
222 CanApprove bool
223 CanClose bool
224 CanDiscuss bool
225 CommentCount int
226 Error string
227 }
228
229 type commentTarget struct {
230 Path string
231 Line int
232 Side string
233 }
234
235 type requestPageView struct {
236 requestSidebarView
237 Description template.HTML
238 CommentTarget *commentTarget
239 LineCommentURL string
240 Files []diffFile
241 Added int
242 Removed int
243 Truncated bool
244 AlreadyMerged bool
245 Comments []requestCommentView
246 Conflicts []string
247 MergeChecked bool
248 ThreadError string
249 }
250
251 func (w *Web) requestSidebar(r *http.Request, m *repo.Meta, req *metadata.Request, title, errMsg string) requestSidebarView {
252 acl, _ := m.ACL()
129 253 approvals := make([]requestApproval, 0, len(req.Approvals))
254 current := 0
130 255 for _, a := range req.Approvals {
131 256 name := a.Principal
132 257 if w.Ident != nil {
133 258 name = w.Ident.Label(a.Principal)
134 259 }
135 approvals = append(approvals, requestApproval{
136 Approval: a,
137 Name: name,
138 Current: a.Head == req.Head && a.Base == req.Base,
139 })
140 }
141 // CI state for the commit under review: the same question the gate asks.
142 type checkRow struct {
143 Workflow, Job, Status string
144 Exit int
145 }
146 var checks []checkRow
260 c := a.Head == req.Head && a.Base == req.Base
261 if c {
262 current++
263 }
264 approvals = append(approvals, requestApproval{Approval: a, Name: name, Current: c})
265 }
266 required := 0
267 if acl != nil {
268 required = acl.RequiredApprovals()
269 }
270 var checks []requestCheck
147 271 if w.Runs != nil && req.Head != "" {
148 272 if res, err := w.Runs.ForSHA(m.Name, req.Head); err == nil {
149 273 for _, run := range res {
150 checks = append(checks, checkRow{run.Workflow, run.Job, run.Status, run.ExitCode})
274 checks = append(checks, requestCheck{
275 ID: run.ID, Workflow: run.Workflow, Job: run.Job,
276 Status: run.Status, Exit: run.ExitCode,
277 })
151 278 }
152 279 sort.Slice(checks, func(i, j int) bool {
153 280 if checks[i].Workflow != checks[j].Workflow {
@@ -157,40 +284,21 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
157 284 })
158 285 }
159 286 }
160
161 // The diff is computed from git on every view: it is cheap for one branch and
162 // always matches what the approver is being asked to approve. Three-dot range:
163 // a review is about what the branch adds since it diverged, not about every
164 // commit main gained meanwhile.
165 var files []diffFile
166 truncated := false
167 alreadyMerged := false
168 if req.Head != "" {
169 if patch, cut, err := m.Git().Diff(req.Base+"..."+req.Head, "", diffCap); err == nil {
170 files = parseDiff(patch)
171 truncated = cut
172 }
173 // An empty diff means one of two very different things, so say which.
174 if in, err := m.Git().IsAncestor(req.Head, req.Base); err == nil && in {
175 alreadyMerged = true
176 }
287 canApprove := acl != nil && req.Open() && w.canApprove(r, acl, req.AuthorID)
288 canClose := false
289 if pr := w.principal(r); pr != nil && acl != nil {
290 canClose = acl.CanTriage(pr.account.ID) || req.AuthorID == pr.account.ID
177 291 }
178 // The review thread. Comments arrive by push, so they cross the same markdown
179 // boundary every other pushed body does — sanitised, never rendered raw.
180 //
181 // An anchored comment names a line and the blob it saw. Whether it is still
182 // about that content is answered here rather than trusted from the file: one
183 // rev-parse per distinct (side, path) is a handful for a review thread, and it
184 // is exact where the diff's abbreviated ids would need prefix matching.
185 type renderedComment struct {
186 metadata.Comment
187 Body template.HTML
188 // Current reports whether the file still holds the content this comment was
189 // written about. A stale comment keeps its place in the thread and loses its
190 // marker on the diff, because a marker on a line that has changed points at
191 // something the comment never saw.
192 Current bool
292 return requestSidebarView{
293 Page: w.page(r, title), Name: m.Name, Request: req,
294 Approvals: approvals, Checks: checks, Required: required, Current: current,
295 CanApprove: canApprove, CanClose: canClose,
296 CanDiscuss: acl != nil && w.canDiscuss(r, acl),
297 CommentCount: len(req.Comments), Error: errMsg,
193 298 }
299 }
300
301 func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []requestCommentView {
194 302 head := req.Head
195 303 baseTip := ""
196 304 if req.Base != "" {
@@ -221,14 +329,124 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
221 329 blobs[key] = blob
222 330 return blob
223 331 }
224 comments := make([]renderedComment, 0, len(req.Comments))
332 comments := make([]requestCommentView, 0, len(req.Comments))
225 333 for _, cm := range req.Comments {
226 rc := renderedComment{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body)}
334 rc := requestCommentView{Comment: cm, Body: w.renderMarkdown(m.Name, cm.Body)}
227 335 if cm.Anchor != nil {
228 336 rc.Current = currentBlob(cm.Anchor.Side, cm.Anchor.Path) == cm.Anchor.Blob
229 337 }
230 338 comments = append(comments, rc)
231 339 }
340 return comments
341 }
342
343 // requestDiscussionView is the comment thread fragment, separate from the
344 // sidebar so posting a comment swaps only the conversation.
345 type requestDiscussionView struct {
346 Page
347 Name string
348 Request *metadata.Request
349 Comments []requestCommentView
350 CanDiscuss bool
351 CommentTarget *commentTarget
352 ThreadError string
353 }
354
355 func (w *Web) requestDiscussion(r *http.Request, m *repo.Meta, req *metadata.Request, target *commentTarget, errMsg string) requestDiscussionView {
356 acl, _ := m.ACL()
357 return requestDiscussionView{
358 Page: w.page(r, ""), Name: m.Name, Request: req,
359 Comments: w.requestComments(m, req), CommentTarget: target,
360 CanDiscuss: acl != nil && w.canDiscuss(r, acl), ThreadError: errMsg,
361 }
362 }
363
364 func commentTargetFromQuery(r *http.Request) *commentTarget {
365 q := r.URL.Query()
366 path := strings.TrimSpace(q.Get("path"))
367 side := strings.TrimSpace(q.Get("side"))
368 if side == "" {
369 side = "new"
370 }
371 line, _ := strconv.Atoi(q.Get("line"))
372 if path == "" || line <= 0 || (side != "new" && side != "old") || !safePath(path) {
373 return nil
374 }
375 return &commentTarget{Path: path, Line: line, Side: side}
376 }
377
378 // commentAnchor resolves a line anchor against the content the reviewer saw.
379 // The blob is what later answers "has this comment gone stale?"; storing only a
380 // path and line would make the comment slide to whatever now occupies that line.
381 func (w *Web) commentAnchor(m *repo.Meta, req *metadata.Request, path string, line int, side string) (*metadata.Anchor, error) {
382 if !safePath(path) {
383 return nil, fmt.Errorf("invalid anchor path")
384 }
385 if line <= 0 {
386 return nil, fmt.Errorf("invalid anchor line")
387 }
388 commit := req.Head
389 if side == "old" {
390 baseTip, err := m.Git().RevParse("refs/heads/" + req.Base)
391 if err != nil || baseTip == "" {
392 return nil, fmt.Errorf("the base branch is not available")
393 }
394 mergeBase, err := m.Git().MergeBase(baseTip, req.Head)
395 if err != nil || mergeBase == "" {
396 return nil, fmt.Errorf("cannot find the merge base for this change")
397 }
398 commit = mergeBase
399 } else if side != "new" {
400 return nil, fmt.Errorf("anchor side must be new or old")
401 }
402 if commit == "" {
403 return nil, fmt.Errorf("this change has no head commit to comment on")
404 }
405 blob, err := m.Git().RevParseAny(commit + ":" + path)
406 if err != nil || blob == "" {
407 return nil, fmt.Errorf("that file is not part of this change")
408 }
409 return &metadata.Anchor{Path: path, Line: line, Side: side, Blob: blob}, nil
410 }
411
412 func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
413 m, ok := w.open(rw, r)
414 if !ok {
415 return
416 }
417 req, err := metadata.GetRequest(m.Git(), r.PathValue("id"))
418 if err != nil {
419 http.NotFound(rw, r)
420 return
421 }
422
423 // The diff is computed from git on every view: it is cheap for one branch and
424 // always matches what the approver is being asked to approve. Three-dot range:
425 // a review is about what the branch adds since it diverged, not about every
426 // commit main gained meanwhile.
427 var files []diffFile
428 truncated := false
429 alreadyMerged := false
430 if req.Head != "" {
431 if patch, cut, err := m.Git().Diff(req.Base+"..."+req.Head, "", diffCap); err == nil {
432 files = parseDiff(patch)
433 truncated = cut
434 }
435 // An empty diff means one of two very different things, so say which.
436 if in, err := m.Git().IsAncestor(req.Head, req.Base); err == nil && in {
437 alreadyMerged = true
438 }
439 }
440
441 // The review thread. Comments arrive by push, so they cross the same markdown
442 // boundary every other pushed body does — sanitised, never rendered raw.
443 //
444 // An anchored comment names a line and the blob it saw. Whether it is still
445 // about that content is answered here rather than trusted from the file: one
446 // rev-parse per distinct (side, path) is a handful for a review thread, and it
447 // is exact where the diff's abbreviated ids would need prefix matching.
448 comments := w.requestComments(m, req)
449
232 450 // A marker on the line each current anchored comment is about. The side decides
233 451 // which path and which line number: a comment on a deleted line belongs to the
234 452 // file as it stood at the merge base.
@@ -269,43 +487,19 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
269 487 }
270 488 }
271 489 }
272 // How many approvals the gate wants, and how many currently apply. The page
273 // used to show the approvals and leave the requirement to memory.
274 required := 0
275 if a, err := m.ACL(); err == nil {
276 required = a.RequiredApprovals()
277 }
278 current := 0
279 for _, a := range approvals {
280 if a.Current {
281 current++
282 }
283 }
284 acl, _ := m.ACL()
285 canApprove := acl != nil && req.Open() && w.canApprove(r, acl, req.AuthorID)
286 490
287 w.render(rw, "request", struct {
288 Page
289 Name string
290 Request *metadata.Request
291 Description template.HTML
292 Approvals []requestApproval
293 Checks []checkRow
294 Files []diffFile
295 Added int
296 Removed int
297 Truncated bool
298 AlreadyMerged bool
299 Comments []renderedComment
300 Conflicts []string
301 MergeChecked bool
302 Required int
303 Current int
304 CanApprove bool
305 }{Page: w.page(r, m.Name+" · "+req.Title), Name: m.Name, Request: req,
306 Description: w.renderMarkdown(m.Name, req.Description), Approvals: approvals, Checks: checks,
307 Files: files, Added: added, Removed: removed, Truncated: truncated, AlreadyMerged: alreadyMerged,
308 Comments: comments,
309 Conflicts: conflicts, MergeChecked: mergeChecked, Required: required, Current: current,
310 CanApprove: canApprove}, 0)
491 w.render(rw, "request", requestPageView{
492 requestSidebarView: w.requestSidebar(r, m, req, m.Name+" · "+req.Title, ""),
493 Description: w.renderMarkdown(m.Name, req.Description),
494 CommentTarget: commentTargetFromQuery(r),
495 LineCommentURL: "/r/" + m.Name + "/requests/" + req.ID,
496 Files: files,
497 Added: added,
498 Removed: removed,
499 Truncated: truncated,
500 AlreadyMerged: alreadyMerged,
501 Comments: comments,
502 Conflicts: conflicts,
503 MergeChecked: mergeChecked,
504 }, 0)
311 505 }