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.

Reorganise the web package around one implementation per rule

c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 13d 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_view.go +293

@@ -0,0 +1,293 @@
1 package web
2
3 // The conversation page of a change request: the description, the discussion, and
4 // the sidebar that says whether it can land. The list and the new-change form are
5 // in requests.go; the Files tab is request_files.go.
6
7 import (
8 "html/template"
9 "net/http"
10 "sort"
11 "strings"
12
13 "github.com/rodent-software/vole/forge/access"
14 "github.com/rodent-software/vole/forge/repo"
15 "github.com/rodent-software/vole/metadata"
16 )
17
18 // RequestView renders one change request: what it would land, what changed, and
19 // which approvals currently apply.
20 type requestApproval struct {
21 metadata.Approval
22 // Name is the approver's account name; the file itself is keyed by that name.
23 Name string
24 // Current is false when the approval names a commit or base that is no longer
25 // what the request asks for, which is the normal state after a force-push.
26 Current bool
27 }
28
29 type requestCheck struct {
30 ID, Workflow, Job, Status string
31 Exit int
32 }
33
34 // requestCI is the approval a contributor branch needs before its workflow may
35 // run. It belongs on the request because that is where a maintainer reviews the
36 // code the tip contains.
37 type requestCI struct {
38 Ref string
39 CanAllow bool
40 }
41
42 // requestSidebarView is the data the change sidebar fragments need. One type for
43 // the full page and the HTMX replacements, so an approval or state change can
44 // refresh the whole sidebar without rebuilding the rest of the review.
45 type requestSidebarView struct {
46 Page
47 Name string
48 Request *metadata.Request
49 Approvals []requestApproval
50 Checks []requestCheck
51 Required int
52 Current int
53 CanApprove bool
54 CanClose bool
55 CanDiscuss bool
56 ContributorCI *requestCI
57 Error string
58 }
59
60 type requestPageView struct {
61 requestSidebarView
62 Tab string
63 Description template.HTML
64 CommentTarget *commentTarget
65 LineCommentURL string
66 FileCount int
67 Added int
68 Removed int
69 AlreadyMerged bool
70 Thread threadView
71 Conflicts []string
72 MergeChecked bool
73 }
74
75 func (w *Web) requestSidebar(r *http.Request, m *repo.Meta, req *metadata.Request, title, errMsg string) requestSidebarView {
76 acl, _ := m.ACL()
77 approvals := make([]requestApproval, 0, len(req.Approvals))
78 current := 0
79 for _, a := range req.Approvals {
80 c := a.Head == req.Head && a.Base == req.Base
81 if c {
82 current++
83 }
84 approvals = append(approvals, requestApproval{Approval: a, Name: w.who(a.Principal), Current: c})
85 }
86 required := 0
87 if acl != nil {
88 required = acl.RequiredApprovals()
89 }
90 var checks []requestCheck
91 if w.Runs != nil && req.Head != "" {
92 if res, err := w.Runs.ForSHA(m.Name, req.Head); err == nil {
93 for _, run := range res {
94 checks = append(checks, requestCheck{
95 ID: run.ID, Workflow: run.Workflow, Job: run.Job,
96 Status: run.Status, Exit: run.ExitCode,
97 })
98 }
99 sort.Slice(checks, func(i, j int) bool {
100 if checks[i].Workflow != checks[j].Workflow {
101 return checks[i].Workflow < checks[j].Workflow
102 }
103 return checks[i].Job < checks[j].Job
104 })
105 }
106 }
107 who := w.accountName(r)
108 approvable := req.Open() && canApproveRequest(acl, who, req.Author)
109 canClose := canTriage(acl, who, req.Author)
110 var contributorCI *requestCI
111 if req.Open() {
112 if ref := w.pendingContributorCI(m, req.Head); ref != "" {
113 contributorCI = &requestCI{
114 Ref: strings.TrimPrefix(ref, "refs/heads/"),
115 CanAllow: canApprove(acl, who),
116 }
117 }
118 }
119 return requestSidebarView{
120 Page: w.page(r, title), Name: m.Name, Request: req,
121 Approvals: approvals, Checks: checks, Required: required, Current: current,
122 CanApprove: approvable, CanClose: canClose,
123 CanDiscuss: w.canDiscuss(r, acl),
124 ContributorCI: contributorCI,
125 Error: errMsg,
126 }
127 }
128
129 // pendingContributorCI finds the contributor branch whose tip is head and whose
130 // CI still waits for a maintainer. It is keyed by the commit: a branch that has
131 // moved past this request is not what is under review here.
132 func (w *Web) pendingContributorCI(m *repo.Meta, head string) string {
133 if w.Runs == nil || head == "" {
134 return ""
135 }
136 refs, err := m.Git().ListRefs("refs/heads/")
137 if err != nil {
138 return ""
139 }
140 var candidates []string
141 for ref, tip := range refs {
142 if tip != head {
143 continue
144 }
145 if _, ok := access.ContributorRefName(ref); ok {
146 candidates = append(candidates, ref)
147 }
148 }
149 sort.Strings(candidates)
150 for _, ref := range candidates {
151 allowed, err := w.Runs.CIAllowed(m.Name, ref, head)
152 if err == nil && !allowed {
153 return ref
154 }
155 }
156 return ""
157 }
158
159 // requestDiscussionView is the comment thread fragment, separate from the
160 // sidebar so posting a comment swaps only the conversation.
161 // requestThread is the discussion section of a change request. It is the same
162 // fragment an issue renders; the change adds a line anchor, and keeps its tab on
163 // the URL after a post.
164 func (w *Web) requestThread(r *http.Request, m *repo.Meta, req *metadata.Request, target *commentTarget, errMsg string) threadView {
165 return w.requestThreadFrom(r, m, req, target, errMsg, w.requestComments(m, req))
166 }
167
168 // requestThreadFrom is requestThread for a caller that already has the comments:
169 // the page needs the same list to mark the diff lines they are anchored to.
170 func (w *Web) requestThreadFrom(r *http.Request, m *repo.Meta, req *metadata.Request, target *commentTarget, errMsg string, views []commentView) threadView {
171 return w.thread(r, m, threadView{
172 Name: m.Name,
173 Section: "request-discussion",
174 Composer: "request-comment-form",
175 PostURL: "/r/" + m.Name + "/requests/" + req.ID + "/comments",
176 PushURL: "/r/" + m.Name + "/requests/" + req.ID,
177 BackURL: "/r/" + m.Name + "/requests/" + req.ID,
178 Title: "Discussion",
179 EmptyText: "No discussion yet.",
180 Hint: `vole change comment ` + req.ID + ` -m "..."`,
181 Topic: req.ID,
182 Target: target,
183 ThreadError: errMsg,
184 }, views)
185 }
186
187 func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) {
188 m, ok := w.open(rw, r)
189 if !ok {
190 return
191 }
192 req, err := metadata.GetRequest(m.Git(), r.PathValue("id"))
193 if err != nil {
194 w.notFound(rw, r)
195 return
196 }
197
198 files, _, alreadyMerged := w.requestDiff(m, req)
199 comments := w.requestComments(m, req)
200 attachRequestComments(files, comments)
201 added, removed := diffSummary(files)
202
203 // Would it land? A diff says what changed; it cannot say whether the branch
204 // still merges. merge-tree answers that in memory, so the reviewer sees "this
205 // needs a rebase" before anybody approves — and the answer is only claimed
206 // when the question could actually be asked (base branch present, head
207 // reachable), because a page that says "clean" when it did not check is worse
208 // than one that says nothing.
209 var conflicts []string
210 mergeChecked := false
211 if req.Open() && req.Head != "" {
212 if tip, err := m.Git().RevParse("refs/heads/" + req.Base); err == nil && tip != "" {
213 if paths, err := m.Git().MergeConflicts(tip, req.Head); err == nil {
214 conflicts, mergeChecked = paths, true
215 }
216 }
217 }
218
219 sidebar := w.requestSidebar(r, m, req, m.Name+" · "+req.Title, "")
220 w.render(rw, "request", requestPageView{
221 requestSidebarView: sidebar,
222 Tab: "conversation",
223 Description: w.renderMarkdown(m.Name, req.Description),
224 CommentTarget: commentTargetFromQuery(r),
225 LineCommentURL: "/r/" + m.Name + "/requests/" + req.ID,
226 FileCount: len(files),
227 Added: added,
228 Removed: removed,
229 AlreadyMerged: alreadyMerged,
230 Thread: w.requestThreadFrom(r, m, req, commentTargetFromQuery(r), "", comments),
231 Conflicts: conflicts,
232 MergeChecked: mergeChecked,
233 }, 0)
234 }
235
236 // requestDiff computes the three-dot patch for a change and answers whether it
237 // is already merged. The diff is cheap for one branch and always matches what
238 // the approver is being asked to approve.
239 func (w *Web) requestDiff(m *repo.Meta, req *metadata.Request) (files []diffFile, truncated, alreadyMerged bool) {
240 if req.Head == "" {
241 return nil, false, false
242 }
243 if patch, cut, err := m.Git().Diff(req.Base+"..."+req.Head, "", diffCap); err == nil {
244 files = parseDiff(patch)
245 truncated = cut
246 }
247 // An empty diff means one of two very different things, so say which.
248 if in, err := m.Git().IsAncestor(req.Head, req.Base); err == nil && in {
249 alreadyMerged = true
250 }
251 return files, truncated, alreadyMerged
252 }
253
254 // reviewPathFor maps an anchor's path to the file row a review URL should open.
255 // A comment on the old side of a rename names the old path, while the Files tree
256 // keys the change by its new path.
257 func (w *Web) reviewPathFor(m *repo.Meta, req *metadata.Request, path string) string {
258 files, _, _ := w.requestDiff(m, req)
259 for _, f := range files {
260 if f.NewPath == path || f.OldPath == path {
261 return reviewFilePath(f)
262 }
263 }
264 return path
265 }
266
267 // attachRequestComments puts each current anchored comment beneath the line it
268 // names. A comment on a deleted line belongs to the file as it stood at the
269 // merge base; stale comments stay in the discussion and lose their line note.
270 func attachRequestComments(files []diffFile, comments []commentView) {
271 for i := range files {
272 f := &files[i]
273 for h := range f.Hunks {
274 lines := f.Hunks[h].Lines
275 for l := range lines {
276 for _, rc := range comments {
277 if rc.Anchor == nil || rc.Deleted || !rc.Current {
278 continue
279 }
280 path, no := f.NewPath, lines[l].NewNo
281 if rc.Anchor.Side == "old" {
282 path, no = f.OldPath, lines[l].OldNo
283 }
284 if path == rc.Anchor.Path && no == rc.Anchor.Line {
285 lines[l].Comments = append(lines[l].Comments, diffComment{
286 ID: rc.ID, Author: rc.Author, Created: rc.Created, Body: rc.Body,
287 })
288 }
289 }
290 }
291 }
292 }
293 }