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.
Read the change list and its counts from the index
b739915c0941be0f97cef8b96fd28d8f4c4685ae · Keenan Nemetz · 10d ago · parent 7f547ca3
Read the change list and its counts from the index The header, the change list and the release artifact list now come from the derived rows instead of the metadata branch: - The header's open-changes badge is one COUNT, so a page that only shows the badge no longer reads `ls-tree`, `batch-check` and `batch` on every view. That is the read that grew with the repository's issues and comments. - The change list, its state chips and its approval counts are queries. An approval still only counts when it names the request's current head and base, and eligibility is still the ACL's decision, so a stale approval is shown as history exactly as before. - The release page's artifact list is the indexed pointer set. - A change page reads its one request rather than every request; the conversation page keeps reading git, so the record it renders is authoritative rather than a copy. On the fixture (40 files, 30 issues, 30 changes), git calls per page: issues list 5 -> 2 change list 6 -> 3 releases 6 -> 3 commits 7 -> 4 overview 19 -> 6 (repeat) code 12 -> 9 change view 14 -> 12 The remaining calls are the one `rev-parse` the refresh needs, the branch and tree reads the page itself shows, and the detail pages' own git read.
server/web/requests.go +114−54
| @@ -4,9 +4,10 @@ package web | |||
| 4 | 4 | // conversation page is request_view.go and the Files tab is request_files.go; | |
| 5 | 5 | // this file is the surface a reader arrives at. | |
| 6 | 6 | // | |
| 7 | // A change request is read straight from git rather than through the index. The | ||
| 8 | // volume is small and the branch is authoritative, so there is no second copy to | ||
| 9 | // go stale. | ||
| 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. | ||
| 10 | 11 | ||
| 11 | 12 | import ( | |
| 12 | 13 | "net/http" | |
| @@ -14,15 +15,32 @@ import ( | |||
| 14 | 15 | "sort" | |
| 15 | 16 | "strconv" | |
| 16 | 17 | "strings" | |
| 18 | "time" | ||
| 17 | 19 | ||
| 18 | 20 | "github.com/rodent-software/vole/forge/access" | |
| 21 | "github.com/rodent-software/vole/forge/index" | ||
| 19 | 22 | "github.com/rodent-software/vole/forge/repo" | |
| 20 | 23 | "github.com/rodent-software/vole/metadata" | |
| 21 | 24 | ) | |
| 22 | 25 | ||
| 23 | // requestRow is a request plus the number of approvals that still name its | ||
| 24 | // current head and base. The count is per row because an approval of an older | ||
| 25 | // head is history, not a vote for what is being proposed now. | ||
| 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 | CommentCount int | ||
| 40 | Number int | ||
| 41 | Current int | ||
| 42 | } | ||
| 43 | |||
| 26 | 44 | // requestFormData is shared by the list page's New change button and the | |
| 27 | 45 | // dedicated form page: the same branch rules must decide what the picker offers. | |
| 28 | 46 | type requestFormData struct { | |
| @@ -80,26 +98,14 @@ func headBranches(all []string, acl *access.ACL, account string) []string { | |||
| 80 | 98 | return out | |
| 81 | 99 | } | |
| 82 | 100 | ||
| 83 | type requestRow struct { | ||
| 84 | *metadata.Request | ||
| 85 | Current int | ||
| 86 | // Number is the `#42` alias from the numbers map the page was built with. | ||
| 87 | Number int | ||
| 88 | } | ||
| 89 | |||
| 90 | // Requests lists a repo's change requests. They are read straight from git: the | ||
| 91 | // volume is small and the branch is authoritative, so there is no index to go | ||
| 92 | // stale here. | ||
| 101 | // Requests lists a repo's change requests from the index. The rows are derived | ||
| 102 | // from the metadata branch and refreshed at the start of the request, so the | ||
| 103 | // list is as current as git without reading the branch on every view. | ||
| 93 | 104 | func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |
| 94 | 105 | m, ok := w.open(rw, r) | |
| 95 | 106 | if !ok { | |
| 96 | 107 | return | |
| 97 | 108 | } | |
| 98 | list, err := w.requestsAt(memoOf(r), m) | ||
| 99 | if err != nil { | ||
| 100 | w.serverError(rw, r, "cannot read changes: "+err.Error()) | ||
| 101 | return | ||
| 102 | } | ||
| 103 | 109 | state := strings.TrimSpace(r.URL.Query().Get("state")) | |
| 104 | 110 | if state == "" { | |
| 105 | 111 | state = "open" | |
| @@ -108,36 +114,12 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 108 | 114 | state = "open" | |
| 109 | 115 | } | |
| 110 | 116 | query := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 111 | needle := strings.ToLower(query) | ||
| 112 | nums := w.entityNumbers(memoOf(r), m) | ||
| 113 | acl, _ := m.ACL() | ||
| 114 | rows := make([]requestRow, 0, len(list)) | ||
| 115 | open, merged := 0, 0 | ||
| 116 | for _, req := range list { | ||
| 117 | switch req.State { | ||
| 118 | case "open": | ||
| 119 | open++ | ||
| 120 | case "merged": | ||
| 121 | merged++ | ||
| 122 | } | ||
| 123 | if state != "all" && req.State != state { | ||
| 124 | continue | ||
| 125 | } | ||
| 126 | if needle != "" { | ||
| 127 | haystack := strings.ToLower(req.Title + "\n" + req.Description + "\n" + req.Author) | ||
| 128 | if !strings.Contains(haystack, needle) { | ||
| 129 | continue | ||
| 130 | } | ||
| 131 | } | ||
| 132 | current := currentApprovals(acl, req) | ||
| 133 | rows = append(rows, requestRow{Request: req, Current: current, Number: nums[req.ID]}) | ||
| 117 | rows, counts, err := w.requestList(r, m, state, query) | ||
| 118 | if err != nil { | ||
| 119 | w.serverError(rw, r, "cannot read changes: "+err.Error()) | ||
| 120 | return | ||
| 134 | 121 | } | |
| 135 | sort.SliceStable(rows, func(i, j int) bool { | ||
| 136 | if (rows[i].State == "open") != (rows[j].State == "open") { | ||
| 137 | return rows[i].State == "open" | ||
| 138 | } | ||
| 139 | return rows[i].ID > rows[j].ID | ||
| 140 | }) | ||
| 122 | |||
| 141 | 123 | page, _ := strconv.Atoi(r.URL.Query().Get("page")) | |
| 142 | 124 | pg := newPager(len(rows), listPageSize, page, func(n int) string { | |
| 143 | 125 | return requestListURL(m.Name, state, query, n) | |
| @@ -146,6 +128,7 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 146 | 128 | rows = rows[from:to] | |
| 147 | 129 | form := w.requestForm(r, m) | |
| 148 | 130 | ||
| 131 | acl, _ := m.ACL() | ||
| 149 | 132 | required := 0 | |
| 150 | 133 | if acl != nil { | |
| 151 | 134 | required = acl.RequiredApprovals() | |
| @@ -154,7 +137,6 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 154 | 137 | Page | |
| 155 | 138 | Name string | |
| 156 | 139 | Rows []requestRow | |
| 157 | Numbers map[string]int | ||
| 158 | 140 | State string | |
| 159 | 141 | Query string | |
| 160 | 142 | Open int | |
| @@ -170,9 +152,8 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 170 | 152 | Pager pager | |
| 171 | 153 | }{ | |
| 172 | 154 | Page: w.page(r, m.Name+" · changes"), Name: m.Name, Rows: rows, State: state, | |
| 173 | Numbers: nums, | ||
| 174 | Query: query, Open: open, Merged: merged, Total: len(list), Closed: len(list) - open - merged, | ||
| 175 | Required: required, Filtered: query != "" || (state != "open" && len(list) > 0), | ||
| 155 | Query: query, Open: counts[0], Merged: counts[1], Closed: counts[2], Total: counts[3], | ||
| 156 | Required: required, Filtered: query != "" || (state != "open" && counts[3] > 0), | ||
| 176 | 157 | CanOpen: form.CanOpen, Base: form.Base, | |
| 177 | 158 | BaseBranches: form.BaseBranches, HeadBranches: form.HeadBranches, | |
| 178 | 159 | Pager: pg, | |
| @@ -184,6 +165,85 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 184 | 165 | w.render(rw, "requests", view, 0) | |
| 185 | 166 | } | |
| 186 | 167 | ||
| 168 | // requestList returns the rows the list page shows and the chip counts, which | ||
| 169 | // describe the whole repository rather than the filtered page. The index answers | ||
| 170 | // both; a test or a server without an index falls back to reading git. | ||
| 171 | func (w *Web) requestList(r *http.Request, m *repo.Meta, state, query string) ([]requestRow, [4]int, error) { | ||
| 172 | var counts [4]int | ||
| 173 | if w.Index == nil { | ||
| 174 | list, err := w.requestsAt(memoOf(r), m) | ||
| 175 | if err != nil { | ||
| 176 | return nil, counts, err | ||
| 177 | } | ||
| 178 | rows := make([]requestRow, 0, len(list)) | ||
| 179 | for _, req := range list { | ||
| 180 | switch req.State { | ||
| 181 | case "open": | ||
| 182 | counts[0]++ | ||
| 183 | case "merged": | ||
| 184 | counts[1]++ | ||
| 185 | } | ||
| 186 | if state != "all" && req.State != state { | ||
| 187 | continue | ||
| 188 | } | ||
| 189 | if query != "" && !strings.Contains(strings.ToLower(req.Title+"\n"+req.Description+"\n"+req.Author), strings.ToLower(query)) { | ||
| 190 | continue | ||
| 191 | } | ||
| 192 | rows = append(rows, requestRow{ | ||
| 193 | ID: req.ID, Title: req.Title, State: req.State, Author: req.Author, | ||
| 194 | Base: req.Base, Head: req.Head, HeadRef: req.HeadRef, Created: req.Created, | ||
| 195 | CommentCount: len(req.Comments), | ||
| 196 | }) | ||
| 197 | } | ||
| 198 | counts[2] = len(list) - counts[0] - counts[1] | ||
| 199 | counts[3] = len(list) | ||
| 200 | sort.SliceStable(rows, func(i, j int) bool { | ||
| 201 | if (rows[i].State == "open") != (rows[j].State == "open") { | ||
| 202 | return rows[i].State == "open" | ||
| 203 | } | ||
| 204 | return rows[i].ID > rows[j].ID | ||
| 205 | }) | ||
| 206 | return rows, counts, nil | ||
| 207 | } | ||
| 208 | |||
| 209 | if err := w.ensureIndex(memoOf(r), m); err != nil { | ||
| 210 | return nil, counts, err | ||
| 211 | } | ||
| 212 | list, err := w.Index.Requests(m.ID, index.RequestFilter{State: state, Query: query}) | ||
| 213 | if err != nil { | ||
| 214 | return nil, counts, err | ||
| 215 | } | ||
| 216 | open, merged, closed, total, err := w.Index.RequestCounts(m.ID) | ||
| 217 | if err != nil { | ||
| 218 | return nil, counts, err | ||
| 219 | } | ||
| 220 | counts = [4]int{open, merged, closed, total} | ||
| 221 | ids := make([]string, len(list)) | ||
| 222 | for i, row := range list { | ||
| 223 | ids[i] = row.ID | ||
| 224 | } | ||
| 225 | approvals, err := w.Index.Approvals(m.ID, ids) | ||
| 226 | if err != nil { | ||
| 227 | return nil, counts, err | ||
| 228 | } | ||
| 229 | acl, _ := m.ACL() | ||
| 230 | rows := make([]requestRow, 0, len(list)) | ||
| 231 | for _, row := range list { | ||
| 232 | current := 0 | ||
| 233 | for _, a := range approvals[row.ID] { | ||
| 234 | if a.Head == row.Head && a.Base == row.Base && acl != nil && acl.EligibleApprover(a.Principal) { | ||
| 235 | current++ | ||
| 236 | } | ||
| 237 | } | ||
| 238 | rows = append(rows, requestRow{ | ||
| 239 | ID: row.ID, Title: row.Title, State: row.State, Author: row.Author, | ||
| 240 | Base: row.Base, Head: row.Head, HeadRef: row.HeadRef, Created: row.Created, | ||
| 241 | CommentCount: row.CommentCount, Number: row.Number, Current: current, | ||
| 242 | }) | ||
| 243 | } | ||
| 244 | return rows, counts, nil | ||
| 245 | } | ||
| 246 | |||
| 187 | 247 | func requestListURL(name, state, query string, page int) string { | |
| 188 | 248 | v := url.Values{} | |
| 189 | 249 | if state != "" { | |