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.

Reorganise the web package around one implementation per rule

c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 14d ago · parent 4506a4d3

Reorganise the web package around one implementation per rule

server/web had grown by feature, so several rules and widgets existed twice: the
comment stack for issues and change requests, the pager, the file tree, the write
preamble, and the ACL predicates. Each pair agreed today and would drift
tomorrow. No route, stored format, or permission rule changes; the few behaviour
differences found along the way are listed at the end.

Files now say what they answer:

  web.go          the Web type, render, open, the error pages
  page.go         Page, pageSection, who, nav
  templatefuncs.go the template function map, with the escaping rules noted
  ui.go           relative time, avatars, breadcrumbs, the pager
  markdown.go     the goldmark and bluemonday boundary
  codebrowser.go  the code browser, out of web.go
  tokens.go       the label/assignee token input
  rolepicker.go   the role control, out of admin.go
  comments.go     the comment view, thread builder, action menus, snippet
  comment_writes.go  add, edit and delete a comment, for both surfaces
  write_context.go   beginWrite/beginPost/beginAdminPost, fail, refuse
  capabilities.go    the ACL rules a page and a handler share
  audit.go        the audit log
  request_view.go the change conversation page, out of requests.go
  admin_access.go the instance-admin access rules, out of settings.go

A comment is one thing, so it has one view and one thread builder: metadata.Comment
is the same type on both surfaces and only a change request can anchor one to a
diff line. Adding, editing and deleting are one implementation each, with a
commentTopic naming what differs per surface. The pager is one control for issues,
changes and the instance feed; the file tree is one widget for the code browser
and a change's Files tab.

Every write starts at beginWrite (session, body bound, CSRF, repository, rules,
form) and asks capabilities.go for the one rule it needs, so the control a page
draws and the check a handler makes cannot disagree. A rejected write answers
through refuse; a write that fails for another reason answers through fail, which
re-renders the section for HTMX and returns text otherwise.

Templates follow the same split: chrome.html, components.html, diff.html and
role-picker.html hold what more than one surface renders, and each page owns its
fragments. The route table moved to routes.go, one function per surface.

Behaviour changes found while unifying:

  - A reply cycle (two comments answering each other, possible through a pushed
    metadata branch) used to drop both comments from the page; anything the walk
    does not reach is now emitted as a root.
  - A rejected anchored comment keeps its "Commenting on path:line" note and
    hidden anchor fields, so the reviewer does not have to pick the line again.
  - A repository whose access rules cannot be read answers 500 rather than 403;
    the account was being blamed for a broken instance.
  - Pager wording is now "Showing a-b of n / page x of y" with Previous/Next
    everywhere, and the instance feed's page links keep their kind filter.
  - The activity feed, issue list and change list share one page control.

Docs: docs/ui-plan.md gains a "Where the code lives" map, the write gate, the
shared comment model, the route table, and rules for rejected writes, shared
template fragments, and the stylesheet's section order. The passkey script is now
an ordinary asset (static/auth.js) under the same CSP as the rest.

server/web/request_files.go +107−13

@@ -4,27 +4,21 @@ package web
4 4 // files this change touches beside the diff for the selected file. The change
5 5 // conversation stays on its own page, so neither surface has to share width
6 6 // with the other.
7 //
8 // The inline review comment lives here too: its composer opens beneath a diff
9 // line, and the anchor it carries is what ties a comment to the code the reviewer
10 // was looking at.
7 11
8 12 import (
13 "fmt"
9 14 "net/http"
15 "strconv"
10 16 "strings"
11 17
18 "github.com/rodent-software/vole/forge/repo"
12 19 "github.com/rodent-software/vole/metadata"
13 20 )
14 21
15 // reviewTreeView is the recursive context for the changed-file tree. The
16 // request id is carried alongside the repository name because every row links
17 // back into this change, not into the source browser.
18 type reviewTreeView struct {
19 Name string
20 RequestID string
21 Nodes []*treeNode
22 }
23
24 func reviewTreeContext(name, requestID string, nodes []*treeNode) reviewTreeView {
25 return reviewTreeView{Name: name, RequestID: requestID, Nodes: nodes}
26 }
27
28 22 // reviewFilePath is the path a changed file is known by in the review tree.
29 23 // Deleted files only have their old path; everything else uses the new one.
30 24 func reviewFilePath(f diffFile) string {
@@ -175,3 +169,103 @@ func (w *Web) RequestFiles(rw http.ResponseWriter, r *http.Request) {
175 169 Main: main,
176 170 }, 0)
177 171 }
172
173 type commentTarget struct {
174 Path string
175 Line int
176 Side string
177 }
178
179 // requestInlineCommentView is the row-shaped composer inserted under a diff
180 // line. It carries the anchor in hidden fields so the posted comment lands on
181 // the same line the reviewer clicked.
182 type requestInlineCommentView struct {
183 Page
184 Name string
185 Request *metadata.Request
186 Target *commentTarget
187 }
188
189 func commentTargetFromQuery(r *http.Request) *commentTarget {
190 q := r.URL.Query()
191 path := strings.TrimSpace(q.Get("path"))
192 side := strings.TrimSpace(q.Get("side"))
193 if side == "" {
194 side = "new"
195 }
196 line, _ := strconv.Atoi(q.Get("line"))
197 if path == "" || line <= 0 || (side != "new" && side != "old") || !safePath(path) {
198 return nil
199 }
200 return &commentTarget{Path: path, Line: line, Side: side}
201 }
202
203 // RequestCommentForm returns the inline composer for one diff line. The link in
204 // the diff is a normal request fallback; with HTMX the row is inserted directly
205 // beneath the line instead of sending the reviewer to the bottom of the page.
206 func (w *Web) RequestCommentForm(rw http.ResponseWriter, r *http.Request) {
207 pr, ok := w.requireHTMLUser(rw, r)
208 if !ok {
209 return
210 }
211 m, ok := w.open(rw, r)
212 if !ok {
213 return
214 }
215 acl, err := m.ACL()
216 if err != nil || !acl.CanDiscuss(pr.account.Name) {
217 http.Error(rw, "you do not have permission to comment here", http.StatusForbidden)
218 return
219 }
220 req, err := metadata.GetRequest(m.Git(), r.PathValue("id"))
221 if err != nil {
222 w.notFound(rw, r)
223 return
224 }
225 target := commentTargetFromQuery(r)
226 if target == nil {
227 http.Error(rw, "choose a line to comment on", http.StatusBadRequest)
228 return
229 }
230 if _, err := w.commentAnchor(m, req, target.Path, target.Line, target.Side); err != nil {
231 http.Error(rw, err.Error(), http.StatusBadRequest)
232 return
233 }
234 w.render(rw, "request_inline_comment", requestInlineCommentView{
235 Page: w.page(r, ""), Name: m.Name, Request: req, Target: target,
236 }, 0)
237 }
238
239 // commentAnchor resolves a line anchor against the content the reviewer saw.
240 // The blob is what later answers "has this comment gone stale?"; storing only a
241 // path and line would make the comment slide to whatever now occupies that line.
242 func (w *Web) commentAnchor(m *repo.Meta, req *metadata.Request, path string, line int, side string) (*metadata.Anchor, error) {
243 if !safePath(path) {
244 return nil, fmt.Errorf("invalid anchor path")
245 }
246 if line <= 0 {
247 return nil, fmt.Errorf("invalid anchor line")
248 }
249 commit := req.Head
250 if side == "old" {
251 baseTip, err := m.Git().RevParse("refs/heads/" + req.Base)
252 if err != nil || baseTip == "" {
253 return nil, fmt.Errorf("the base branch is not available")
254 }
255 mergeBase, err := m.Git().MergeBase(baseTip, req.Head)
256 if err != nil || mergeBase == "" {
257 return nil, fmt.Errorf("cannot find the merge base for this change")
258 }
259 commit = mergeBase
260 } else if side != "new" {
261 return nil, fmt.Errorf("anchor side must be new or old")
262 }
263 if commit == "" {
264 return nil, fmt.Errorf("this change has no head commit to comment on")
265 }
266 blob, err := m.Git().RevParseAny(commit + ":" + path)
267 if err != nil || blob == "" {
268 return nil, fmt.Errorf("that file is not part of this change")
269 }
270 return &metadata.Anchor{Path: path, Line: line, Side: side, Blob: blob}, nil
271 }