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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 28m ago 32290114
history blame raw 10.9 KiB
1package web
2
3// Change requests: the list, the filters, and the form that opens one. The
4// conversation page is request_view.go and the Files tab is request_files.go;
5// this file is the surface a reader arrives at.
6//
7// The list and the counts are read from the index, refreshed at the start of the
8// request from the metadata branch. The conversation page still reads its one
9// request from git: the index is for finding and listing, git is for rendering
10// the record itself.
11
12import (
13 "net/http"
14 "net/url"
15 "sort"
16 "strconv"
17 "strings"
18 "time"
19
20 "rodent.software/vole/forge/access"
21 "rodent.software/vole/forge/index"
22 "rodent.software/vole/forge/repo"
23 "rodent.software/vole/metadata"
24)
25
26// requestRow is one row of the change list: the indexed request plus the number
27// of approvals that still name its current head and base. The count is per row
28// because an approval of an older head is history, not a vote for what is being
29// proposed now.
30type requestRow struct {
31 ID string
32 Title string
33 State string
34 Author string
35 Base string
36 Head string
37 HeadRef string
38 Created time.Time
39 Updated time.Time
40 CommentCount int
41 Number int
42 Current int
43 Required int
44 Status changeStatus
45}
46
47// requestFormData is shared by the list page's New change button and the
48// dedicated form page: the same branch rules must decide what the picker offers.
49type requestFormData struct {
50 Base string
51 BaseBranches []string
52 HeadBranches []string
53 CanOpen bool
54}
55
56func (w *Web) requestForm(r *http.Request, m *repo.Meta) requestFormData {
57 branches := branchNames(m)
58 accountID := w.accountName(r)
59 acl, _ := m.ACL()
60 canOpen := acl != nil && accountID != "" && acl.CanOpenRequest(accountID)
61 return requestFormData{
62 Base: m.DefaultBranch, BaseBranches: branches,
63 HeadBranches: headBranches(branches, acl, accountID), CanOpen: canOpen,
64 }
65}
66
67// branchNames lists the repository's normal branches, without the metadata branch.
68// The new-request form and the control that moves a request's head draw their
69// choices from the same list, so a branch is offered in both or in neither.
70func branchNames(m *repo.Meta) []string {
71 metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/")
72 var out []string
73 if refs, err := m.Git().ListRefs("refs/heads/"); err == nil {
74 for ref := range refs {
75 name := strings.TrimPrefix(ref, "refs/heads/")
76 if name == "" || name == metadataBranch {
77 continue
78 }
79 out = append(out, name)
80 }
81 sort.Strings(out)
82 }
83 return out
84}
85
86// headBranches narrows the choices to the branches an account may propose as a
87// change request's head: any normal branch for a maintainer, only its own
88// contrib/ namespace for a contributor. That is the same rule the pre-receive hook
89// applies to the account's pushes, so a control cannot offer a branch the hook
90// would refuse.
91func headBranches(all []string, acl *access.ACL, account string) []string {
92 if acl == nil || account == "" || acl.RoleFor(account) != access.Contribute {
93 return all
94 }
95 out := make([]string, 0, len(all))
96 for _, branch := range all {
97 if access.IsContributorRef(account, "refs/heads/"+branch) {
98 out = append(out, branch)
99 }
100 }
101 return out
102}
103
104// Requests lists a repo's change requests from the index. The rows are derived
105// from the metadata branch and refreshed at the start of the request, so the
106// list is as current as git without reading the branch on every view.
107func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) {
108 m, ok := w.open(rw, r)
109 if !ok {
110 return
111 }
112 state := strings.TrimSpace(r.URL.Query().Get("state"))
113 if state == "" {
114 state = "open"
115 }
116 if state != "open" && state != "merged" && state != "closed" && state != "all" {
117 state = "open"
118 }
119 query := strings.TrimSpace(r.URL.Query().Get("q"))
120 rows, counts, err := w.requestList(r, m, state, query)
121 if err != nil {
122 w.serverError(rw, r, "cannot read changes: "+err.Error())
123 return
124 }
125
126 page, _ := strconv.Atoi(r.URL.Query().Get("page"))
127 pg := newPager(len(rows), listPageSize, page, func(n int) string {
128 return requestListURL(m.Name, state, query, n)
129 }).forSwap("Change pages", "#change-list")
130 from, to := pg.rows()
131 rows = rows[from:to]
132 // Status is worked out for the page, not for everything the filter matched:
133 // a ready row costs a merge, and a repository with hundreds of changes should
134 // not pay for the ones nobody is looking at.
135 w.decorateChangeStatuses(m, rows)
136 form := w.requestForm(r, m)
137
138 acl, _ := m.ACL()
139 required := 0
140 if acl != nil {
141 required = acl.RequiredApprovals()
142 }
143 view := struct {
144 Page
145 Name string
146 Rows []requestRow
147 State string
148 Query string
149 Open int
150 Merged int
151 Total int
152 Closed int
153 Required int
154 Filtered bool
155 CanOpen bool
156 Base string
157 BaseBranches []string
158 HeadBranches []string
159 Pager pager
160 }{
161 Page: w.page(r, m.Name+" · changes"), Name: m.Name, Rows: rows, State: state,
162 Query: query, Open: counts[0], Merged: counts[1], Closed: counts[2], Total: counts[3],
163 Required: required, Filtered: query != "" || (state != "open" && counts[3] > 0),
164 CanOpen: form.CanOpen, Base: form.Base,
165 BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches,
166 Pager: pg,
167 }
168 if htmxRequest(r) {
169 w.render(rw, "change_list", view, 0)
170 return
171 }
172 w.render(rw, "requests", view, 0)
173}
174
175// requestList returns the rows the list page shows and the chip counts, which
176// describe the whole repository rather than the filtered page. The index answers
177// both; a test or a server without an index falls back to reading git.
178func (w *Web) requestList(r *http.Request, m *repo.Meta, state, query string) ([]requestRow, [4]int, error) {
179 var counts [4]int
180 if w.Index == nil {
181 list, err := w.requestsAt(memoOf(r), m)
182 if err != nil {
183 return nil, counts, err
184 }
185 rows := make([]requestRow, 0, len(list))
186 acl, _ := m.ACL()
187 required := 0
188 if acl != nil {
189 required = acl.RequiredApprovals()
190 }
191 for _, req := range list {
192 switch req.State {
193 case "open":
194 counts[0]++
195 case "merged":
196 counts[1]++
197 }
198 if state != "all" && req.State != state {
199 continue
200 }
201 if query != "" && !strings.Contains(strings.ToLower(req.Title+"\n"+req.Description+"\n"+req.Author), strings.ToLower(query)) {
202 continue
203 }
204 current := 0
205 for _, a := range req.Approvals {
206 if a.Head == req.Head && a.Base == req.Base && acl != nil && acl.EligibleApprover(a.Principal) {
207 current++
208 }
209 }
210 rows = append(rows, requestRow{
211 ID: req.ID, Title: req.Title, State: req.State, Author: req.Author,
212 Base: req.Base, Head: req.Head, HeadRef: req.HeadRef, Created: req.Created,
213 Updated: req.Created, CommentCount: len(req.Comments),
214 Current: current, Required: required,
215 })
216 }
217 counts[2] = len(list) - counts[0] - counts[1]
218 counts[3] = len(list)
219 sort.SliceStable(rows, func(i, j int) bool {
220 if (rows[i].State == "open") != (rows[j].State == "open") {
221 return rows[i].State == "open"
222 }
223 if !rows[i].Updated.Equal(rows[j].Updated) {
224 return rows[i].Updated.After(rows[j].Updated)
225 }
226 return rows[i].ID > rows[j].ID
227 })
228 return rows, counts, nil
229 }
230
231 if err := w.ensureIndex(memoOf(r), m); err != nil {
232 return nil, counts, err
233 }
234 list, err := w.Index.Requests(m.ID, index.RequestFilter{State: state, Query: query})
235 if err != nil {
236 return nil, counts, err
237 }
238 open, merged, closed, total, err := w.Index.RequestCounts(m.ID)
239 if err != nil {
240 return nil, counts, err
241 }
242 counts = [4]int{open, merged, closed, total}
243 ids := make([]string, len(list))
244 for i, row := range list {
245 ids[i] = row.ID
246 }
247 approvals, err := w.Index.Approvals(m.ID, ids)
248 if err != nil {
249 return nil, counts, err
250 }
251 acl, _ := m.ACL()
252 required := 0
253 if acl != nil {
254 required = acl.RequiredApprovals()
255 }
256 rows := make([]requestRow, 0, len(list))
257 for _, row := range list {
258 current := 0
259 for _, a := range approvals[row.ID] {
260 if a.Head == row.Head && a.Base == row.Base && acl != nil && acl.EligibleApprover(a.Principal) {
261 current++
262 }
263 }
264 rows = append(rows, requestRow{
265 ID: row.ID, Title: row.Title, State: row.State, Author: row.Author,
266 Base: row.Base, Head: row.Head, HeadRef: row.HeadRef, Created: row.Created,
267 Updated: row.Updated, CommentCount: row.CommentCount, Number: row.Number,
268 Current: current, Required: required,
269 })
270 }
271 return rows, counts, nil
272}
273
274func requestListURL(name, state, query string, page int) string {
275 v := url.Values{}
276 if state != "" {
277 v.Set("state", state)
278 }
279 if query != "" {
280 v.Set("q", query)
281 }
282 if page > 1 {
283 v.Set("page", strconv.Itoa(page))
284 }
285 return "/" + name + "/changes?" + v.Encode()
286}
287
288// NewChangePage serves the dedicated new-change form. The list page only links
289// here when the viewer can open a request, and the handler checks again because
290// a link is not an authorization.
291func (w *Web) NewChangePage(rw http.ResponseWriter, r *http.Request) {
292 m, ok := w.open(rw, r)
293 if !ok {
294 return
295 }
296 pr, ok := w.requireHTMLUser(rw, r)
297 if !ok {
298 return
299 }
300 acl, err := m.ACL()
301 if err != nil || !acl.CanOpenRequest(pr.account.Name) {
302 http.Error(rw, "you do not have permission to open changes here", http.StatusForbidden)
303 return
304 }
305 form := w.requestForm(r, m)
306 w.render(rw, "request_new", struct {
307 Page
308 Name string
309 Base string
310 BaseBranches []string
311 HeadBranches []string
312 RequiresLinkedIssue bool
313 }{
314 Page: w.page(r, m.Name+" · new change"), Name: m.Name,
315 Base: form.Base, BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches,
316 RequiresLinkedIssue: acl.RequiresLinkedIssue(),
317 }, 0)
318}
319
320// requestComments renders the change's discussion, marking each anchored comment
321// with whether the line it names is still what the reviewer saw.
322func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []commentView {
323 head := req.Head
324 baseTip := ""
325 if req.Base != "" {
326 if tip, err := m.Git().RevParse("refs/heads/" + req.Base); err == nil {
327 baseTip = tip
328 }
329 }
330 mergeBase := ""
331 if baseTip != "" && head != "" {
332 if mb, err := m.Git().MergeBase(baseTip, head); err == nil {
333 mergeBase = mb
334 }
335 }
336 blobs := map[string]string{}
337 currentBlob := func(side, path string) string {
338 key := side + "|" + path
339 if blob, ok := blobs[key]; ok {
340 return blob
341 }
342 commit := head
343 if side == "old" {
344 commit = mergeBase
345 }
346 blob := ""
347 if commit != "" {
348 blob, _ = m.Git().RevParseAny(commit + ":" + path)
349 }
350 blobs[key] = blob
351 return blob
352 }
353 snippetBlobs := map[string][]string{}
354 comments := w.commentViews(m, req.Comments)
355 for i := range comments {
356 c := &comments[i]
357 if c.Anchor == nil || c.Deleted {
358 continue
359 }
360 c.Current = currentBlob(c.Anchor.Side, c.Anchor.Path) == c.Anchor.Blob
361 c.Snippet = buildCommentSnippet(m.Git(), c.Anchor, snippetBlobs)
362 if c.Snippet != nil {
363 c.Snippet.FilesURL = "/" + m.Name + "/changes/" + req.ID +
364 "/files/" + pathURL(c.Snippet.Path) + "#inline-c-" + c.ID
365 }
366 }
367 return threadComments(comments)
368}
369