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.
|
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 | } |