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.

Blame · server/web/requests.go

Line attribution on main · 368 line(s)

1eeca4e6
Keenan Nemetz
17d ago
1 package web
2
c717d4eb
Keenan Nemetz
13d ago
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 //
b739915c
Keenan Nemetz
10d ago
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.
c717d4eb
Keenan Nemetz
13d ago
11
1eeca4e6
Keenan Nemetz
17d ago
12 import (
13 "net/http"
5cab7b18
Keenan Nemetz
15d ago
14 "net/url"
1eeca4e6
Keenan Nemetz
17d ago
15 "sort"
f09ef39e
Keenan Nemetz
15d ago
16 "strconv"
6c84d387
Keenan Nemetz
15d ago
17 "strings"
b739915c
Keenan Nemetz
10d ago
18 "time"
1eeca4e6
Keenan Nemetz
17d ago
19
d37d301f
Keenan Nemetz
15h ago
20 "rodent.software/vole/forge/access"
21 "rodent.software/vole/forge/index"
22 "rodent.software/vole/forge/repo"
23 "rodent.software/vole/metadata"
1eeca4e6
Keenan Nemetz
17d ago
24 )
25
b739915c
Keenan Nemetz
10d ago
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.
30 type 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
7cc36ba0
Keenan Nemetz
4d ago
39 Updated time.Time
b739915c
Keenan Nemetz
10d ago
40 CommentCount int
41 Number int
42 Current int
7cc36ba0
Keenan Nemetz
4d ago
43 Required int
44 Status changeStatus
b739915c
Keenan Nemetz
10d ago
45 }
46
f09ef39e
Keenan Nemetz
15d ago
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.
49 type requestFormData struct {
50 Base string
51 BaseBranches []string
52 HeadBranches []string
53 CanOpen bool
54 }
55
56 func (w *Web) requestForm(r *http.Request, m *repo.Meta) requestFormData {
53ff967b
Keenan Nemetz
13d ago
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.
70 func branchNames(m *repo.Meta) []string {
6c84d387
Keenan Nemetz
15d ago
71 metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/")
53ff967b
Keenan Nemetz
13d ago
72 var out []string
6c84d387
Keenan Nemetz
15d ago
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 }
53ff967b
Keenan Nemetz
13d ago
79 out = append(out, name)
6c84d387
Keenan Nemetz
15d ago
80 }
53ff967b
Keenan Nemetz
13d ago
81 sort.Strings(out)
6c84d387
Keenan Nemetz
15d ago
82 }
53ff967b
Keenan Nemetz
13d ago
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.
91 func headBranches(all []string, acl *access.ACL, account string) []string {
92 if acl == nil || account == "" || acl.RoleFor(account) != access.Contribute {
93 return all
6c84d387
Keenan Nemetz
15d ago
94 }
53ff967b
Keenan Nemetz
13d ago
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 }
f09ef39e
Keenan Nemetz
15d ago
100 }
53ff967b
Keenan Nemetz
13d ago
101 return out
f09ef39e
Keenan Nemetz
15d ago
102 }
103
b739915c
Keenan Nemetz
10d ago
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.
f09ef39e
Keenan Nemetz
15d ago
107 func (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 }
0e35a6a0
Keenan Nemetz
11d ago
116 if state != "open" && state != "merged" && state != "closed" && state != "all" {
f09ef39e
Keenan Nemetz
15d ago
117 state = "open"
118 }
119 query := strings.TrimSpace(r.URL.Query().Get("q"))
b739915c
Keenan Nemetz
10d ago
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
f09ef39e
Keenan Nemetz
15d ago
124 }
b739915c
Keenan Nemetz
10d ago
125
5cab7b18
Keenan Nemetz
15d ago
126 page, _ := strconv.Atoi(r.URL.Query().Get("page"))
c717d4eb
Keenan Nemetz
13d ago
127 pg := newPager(len(rows), listPageSize, page, func(n int) string {
5cab7b18
Keenan Nemetz
15d ago
128 return requestListURL(m.Name, state, query, n)
b06184d1
Keenan Nemetz
11d ago
129 }).forSwap("Change pages", "#change-list")
c717d4eb
Keenan Nemetz
13d ago
130 from, to := pg.rows()
131 rows = rows[from:to]
e54a7eb1
Keenan Nemetz
4d ago
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)
f09ef39e
Keenan Nemetz
15d ago
136 form := w.requestForm(r, m)
137
b739915c
Keenan Nemetz
10d ago
138 acl, _ := m.ACL()
f09ef39e
Keenan Nemetz
15d ago
139 required := 0
140 if acl != nil {
141 required = acl.RequiredApprovals()
142 }
143 view := struct {
1eeca4e6
Keenan Nemetz
17d ago
144 Page
6c84d387
Keenan Nemetz
15d ago
145 Name string
f09ef39e
Keenan Nemetz
15d ago
146 Rows []requestRow
6c84d387
Keenan Nemetz
15d ago
147 State string
f09ef39e
Keenan Nemetz
15d ago
148 Query string
6c84d387
Keenan Nemetz
15d ago
149 Open int
0e35a6a0
Keenan Nemetz
11d ago
150 Merged int
6c84d387
Keenan Nemetz
15d ago
151 Total int
f09ef39e
Keenan Nemetz
15d ago
152 Closed int
153 Required int
154 Filtered bool
6c84d387
Keenan Nemetz
15d ago
155 CanOpen bool
156 Base string
157 BaseBranches []string
158 HeadBranches []string
5cab7b18
Keenan Nemetz
15d ago
159 Pager pager
6c84d387
Keenan Nemetz
15d ago
160 }{
b06184d1
Keenan Nemetz
11d ago
161 Page: w.page(r, m.Name+" · changes"), Name: m.Name, Rows: rows, State: state,
b739915c
Keenan Nemetz
10d ago
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),
f09ef39e
Keenan Nemetz
15d ago
164 CanOpen: form.CanOpen, Base: form.Base,
165 BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches,
5cab7b18
Keenan Nemetz
15d ago
166 Pager: pg,
f09ef39e
Keenan Nemetz
15d ago
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
b739915c
Keenan Nemetz
10d ago
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.
178 func (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))
7cc36ba0
Keenan Nemetz
4d ago
186 acl, _ := m.ACL()
187 required := 0
188 if acl != nil {
189 required = acl.RequiredApprovals()
190 }
b739915c
Keenan Nemetz
10d ago
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 }
7cc36ba0
Keenan Nemetz
4d ago
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 }
b739915c
Keenan Nemetz
10d ago
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,
7cc36ba0
Keenan Nemetz
4d ago
213 Updated: req.Created, CommentCount: len(req.Comments),
214 Current: current, Required: required,
b739915c
Keenan Nemetz
10d ago
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 }
7cc36ba0
Keenan Nemetz
4d ago
223 if !rows[i].Updated.Equal(rows[j].Updated) {
224 return rows[i].Updated.After(rows[j].Updated)
225 }
b739915c
Keenan Nemetz
10d ago
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()
7cc36ba0
Keenan Nemetz
4d ago
252 required := 0
253 if acl != nil {
254 required = acl.RequiredApprovals()
255 }
b739915c
Keenan Nemetz
10d ago
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,
7cc36ba0
Keenan Nemetz
4d ago
267 Updated: row.Updated, CommentCount: row.CommentCount, Number: row.Number,
268 Current: current, Required: required,
b739915c
Keenan Nemetz
10d ago
269 })
270 }
271 return rows, counts, nil
272 }
273
5cab7b18
Keenan Nemetz
15d ago
274 func 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 }
ed03b3d1
Keenan Nemetz
2h ago
285 return "/" + name + "/changes?" + v.Encode()
5cab7b18
Keenan Nemetz
15d ago
286 }
287
f09ef39e
Keenan Nemetz
15d ago
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.
291 func (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()
62576fa8
Keenan Nemetz
14d ago
301 if err != nil || !acl.CanOpenRequest(pr.account.Name) {
b06184d1
Keenan Nemetz
11d ago
302 http.Error(rw, "you do not have permission to open changes here", http.StatusForbidden)
f09ef39e
Keenan Nemetz
15d ago
303 return
304 }
305 form := w.requestForm(r, m)
306 w.render(rw, "request_new", struct {
307 Page
0789f22e
Keenan Nemetz
9d ago
308 Name string
309 Base string
310 BaseBranches []string
311 HeadBranches []string
312 RequiresLinkedIssue bool
f09ef39e
Keenan Nemetz
15d ago
313 }{
b06184d1
Keenan Nemetz
11d ago
314 Page: w.page(r, m.Name+" · new change"), Name: m.Name,
f09ef39e
Keenan Nemetz
15d ago
315 Base: form.Base, BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches,
0789f22e
Keenan Nemetz
9d ago
316 RequiresLinkedIssue: acl.RequiresLinkedIssue(),
6c84d387
Keenan Nemetz
15d ago
317 }, 0)
1eeca4e6
Keenan Nemetz
17d ago
318 }
319
c717d4eb
Keenan Nemetz
13d ago
320 // requestComments renders the change's discussion, marking each anchored comment
321 // with whether the line it names is still what the reviewer saw.
322 func (w *Web) requestComments(m *repo.Meta, req *metadata.Request) []commentView {
9cc2f087
Keenan Nemetz
17d ago
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
7c6e52e7
Keenan Nemetz
17d ago
352 }
f6f4b1db
Keenan Nemetz
15d ago
353 snippetBlobs := map[string][]string{}
c717d4eb
Keenan Nemetz
13d ago
354 comments := w.commentViews(m, req.Comments)
f6f4b1db
Keenan Nemetz
15d ago
355 for i := range comments {
356 c := &comments[i]
c717d4eb
Keenan Nemetz
13d ago
357 if c.Anchor == nil || c.Deleted {
358 continue
f6f4b1db
Keenan Nemetz
15d ago
359 }
c717d4eb
Keenan Nemetz
13d ago
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 {
ed03b3d1
Keenan Nemetz
2h ago
363 c.Snippet.FilesURL = "/" + m.Name + "/changes/" + req.ID +
c717d4eb
Keenan Nemetz
13d ago
364 "/files/" + pathURL(c.Snippet.Path) + "#inline-c-" + c.ID
f6f4b1db
Keenan Nemetz
15d ago
365 }
366 }
c717d4eb
Keenan Nemetz
13d ago
367 return threadComments(comments)
f6f4b1db
Keenan Nemetz
15d ago
368 }