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.
| 1 | package 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 | |
| 12 | import ( |
| 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. |
| 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 |
| 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. |
| 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 { |
| 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 { |
| 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. |
| 91 | func 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. |
| 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 | } |
| 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. |
| 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)) |
| 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 | |
| 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 | } |
| 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. |
| 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() |
| 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. |
| 322 | func (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 |