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.
Index the rest of the metadata branch
7f547ca3b8da218249120d3c910c73c9a1a1e52d · Keenan Nemetz · 10d ago · parent c9202f7f
Index the rest of the metadata branch The index held issues and their numbers. Everything else on the branch — change requests, their comments, approvals and closes, and release artifacts — was read from git on every page that needed it, and the repository header read the whole requests subtree just to print a count. Add the tables and derive them from the same commit: - requests, with the body preserved for search; - request_closes and request_approvals, each in its own table so a backlink and a current-approval count are queries rather than a walk of every request; - comments for both kinds of entity, with the anchor a review comment carries; - artifacts, the release pointers, with the size from the pointer. The refresh is still driven by the path delta between two commits: a changed issue or request replaces its own rows, its comments and its sets, and a deleted one removes them while keeping its `#42` number. Rebuild and the first read go through IndexRepo, which is the definition the incremental path has to reproduce; a rebuild now clears the derived tables first so a deleted repository leaves nothing behind. Schema version 4 drops the old rows on first open, which is the existing recovery path: the index is disposable and built from the branch.
forge/index/requests.go +247
| @@ -0,0 +1,247 @@ | |||
| 1 | package index | ||
| 2 | |||
| 3 | // The indexed view of change requests: enough to list, filter, link and count, | ||
| 4 | // not enough to render the conversation. The detail page still reads the request | ||
| 5 | // from git, so a body on that page is never a stale copy. | ||
| 6 | // | ||
| 7 | // A request is one row plus two sets: the issues it promises to close, and the | ||
| 8 | // approvals that name a head and base. Both live in their own tables because a | ||
| 9 | // list that shows "2/1 approvals" has to compare them with the request's current | ||
| 10 | // head, and a backlink has to find the request from the issue. | ||
| 11 | |||
| 12 | import ( | ||
| 13 | "strings" | ||
| 14 | "time" | ||
| 15 | |||
| 16 | "github.com/rodent-software/vole/metadata" | ||
| 17 | ) | ||
| 18 | |||
| 19 | // RequestRow is one indexed request. Comments is a count: the rows themselves | ||
| 20 | // are in the comments table. | ||
| 21 | type RequestRow struct { | ||
| 22 | ID string | ||
| 23 | Title string | ||
| 24 | State string | ||
| 25 | Author string | ||
| 26 | Created time.Time | ||
| 27 | Base string | ||
| 28 | Head string | ||
| 29 | HeadRef string | ||
| 30 | CommentCount int | ||
| 31 | // Number is the `#42` alias from entity_numbers, or 0 when the branch has not | ||
| 32 | // been numbered yet. | ||
| 33 | Number int | ||
| 34 | } | ||
| 35 | |||
| 36 | // RequestFilter narrows a request list. Empty fields are ignored; State "all" | ||
| 37 | // means every state. | ||
| 38 | type RequestFilter struct { | ||
| 39 | State string | ||
| 40 | Query string | ||
| 41 | } | ||
| 42 | |||
| 43 | const requestSelect = `SELECT r.id, r.title, r.state, r.author, r.created, r.base, r.head, r.head_ref, | ||
| 44 | r.comments, COALESCE(n.number, 0) | ||
| 45 | FROM requests r | ||
| 46 | LEFT JOIN entity_numbers n ON n.repo_id = r.repo_id AND n.id = r.id` | ||
| 47 | |||
| 48 | // Requests returns a repo's indexed requests, open first and newest first within | ||
| 49 | // each state. That is the order the list page shows, so paging does not have to | ||
| 50 | // re-sort what the database already ordered. | ||
| 51 | func (s *Store) Requests(repoID string, f RequestFilter) ([]RequestRow, error) { | ||
| 52 | q := requestSelect + ` | ||
| 53 | WHERE r.repo_id=?` | ||
| 54 | args := []any{repoID} | ||
| 55 | if f.State != "" && f.State != "all" { | ||
| 56 | q += ` AND r.state=?` | ||
| 57 | args = append(args, f.State) | ||
| 58 | } | ||
| 59 | if term := strings.TrimSpace(f.Query); term != "" { | ||
| 60 | like := "%" + term + "%" | ||
| 61 | q += ` AND (r.title LIKE ? OR r.body LIKE ? OR r.author LIKE ?)` | ||
| 62 | args = append(args, like, like, like) | ||
| 63 | } | ||
| 64 | q += ` ORDER BY CASE WHEN r.state='open' THEN 0 ELSE 1 END, r.id DESC` | ||
| 65 | |||
| 66 | rows, err := s.db.Query(q, args...) | ||
| 67 | if err != nil { | ||
| 68 | return nil, err | ||
| 69 | } | ||
| 70 | defer rows.Close() | ||
| 71 | var out []RequestRow | ||
| 72 | for rows.Next() { | ||
| 73 | var r RequestRow | ||
| 74 | var created int64 | ||
| 75 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &r.Base, | ||
| 76 | &r.Head, &r.HeadRef, &r.CommentCount, &r.Number); err != nil { | ||
| 77 | return nil, err | ||
| 78 | } | ||
| 79 | r.Created = unixTime(created) | ||
| 80 | out = append(out, r) | ||
| 81 | } | ||
| 82 | return out, rows.Err() | ||
| 83 | } | ||
| 84 | |||
| 85 | // RequestCount returns the requests in one state. An empty state, or "all", | ||
| 86 | // counts every request. The repository header uses it for the open badge, which | ||
| 87 | // every page shows. | ||
| 88 | func (s *Store) RequestCount(repoID, state string) (int, error) { | ||
| 89 | q := `SELECT COUNT(*) FROM requests WHERE repo_id=?` | ||
| 90 | args := []any{repoID} | ||
| 91 | if state != "" && state != "all" { | ||
| 92 | q += ` AND state=?` | ||
| 93 | args = append(args, state) | ||
| 94 | } | ||
| 95 | var n int | ||
| 96 | err := s.db.QueryRow(q, args...).Scan(&n) | ||
| 97 | return n, err | ||
| 98 | } | ||
| 99 | |||
| 100 | // RequestCounts returns the totals the list page's chips show. One query, so the | ||
| 101 | // four numbers cannot describe four different moments. | ||
| 102 | func (s *Store) RequestCounts(repoID string) (open, merged, closed, total int, err error) { | ||
| 103 | rows, err := s.db.Query(`SELECT state, COUNT(*) FROM requests WHERE repo_id=? GROUP BY state`, repoID) | ||
| 104 | if err != nil { | ||
| 105 | return 0, 0, 0, 0, err | ||
| 106 | } | ||
| 107 | defer rows.Close() | ||
| 108 | for rows.Next() { | ||
| 109 | var state string | ||
| 110 | var n int | ||
| 111 | if err := rows.Scan(&state, &n); err != nil { | ||
| 112 | return 0, 0, 0, 0, err | ||
| 113 | } | ||
| 114 | total += n | ||
| 115 | switch state { | ||
| 116 | case "open": | ||
| 117 | open = n | ||
| 118 | case "merged": | ||
| 119 | merged = n | ||
| 120 | default: | ||
| 121 | // A state the format does not define still counts somewhere, so the | ||
| 122 | // chips add up to the total the All chip shows. | ||
| 123 | closed += n | ||
| 124 | } | ||
| 125 | } | ||
| 126 | return open, merged, closed, total, rows.Err() | ||
| 127 | } | ||
| 128 | |||
| 129 | // ApprovalRow is one approval as the index stores it. The list page compares | ||
| 130 | // Head and Base with the request's current pair, so an approval of what was | ||
| 131 | // pushed yesterday stops counting without being deleted. | ||
| 132 | type ApprovalRow struct { | ||
| 133 | Principal string | ||
| 134 | Head string | ||
| 135 | Base string | ||
| 136 | } | ||
| 137 | |||
| 138 | // Approvals returns the approvals for the named requests, keyed by request id. | ||
| 139 | // A list page asks for the rows it is about to show, not for every approval in | ||
| 140 | // the repository. | ||
| 141 | func (s *Store) Approvals(repoID string, requestIDs []string) (map[string][]ApprovalRow, error) { | ||
| 142 | out := map[string][]ApprovalRow{} | ||
| 143 | if len(requestIDs) == 0 { | ||
| 144 | return out, nil | ||
| 145 | } | ||
| 146 | args := make([]any, 0, len(requestIDs)+1) | ||
| 147 | args = append(args, repoID) | ||
| 148 | for _, id := range requestIDs { | ||
| 149 | args = append(args, id) | ||
| 150 | } | ||
| 151 | q := `SELECT request_id, principal, head, base FROM request_approvals | ||
| 152 | WHERE repo_id=? AND request_id IN (?` + strings.Repeat(",?", len(requestIDs)-1) + `)` | ||
| 153 | rows, err := s.db.Query(q, args...) | ||
| 154 | if err != nil { | ||
| 155 | return nil, err | ||
| 156 | } | ||
| 157 | defer rows.Close() | ||
| 158 | for rows.Next() { | ||
| 159 | var id string | ||
| 160 | var a ApprovalRow | ||
| 161 | if err := rows.Scan(&id, &a.Principal, &a.Head, &a.Base); err != nil { | ||
| 162 | return nil, err | ||
| 163 | } | ||
| 164 | out[id] = append(out[id], a) | ||
| 165 | } | ||
| 166 | return out, rows.Err() | ||
| 167 | } | ||
| 168 | |||
| 169 | // Closes returns the requests that promise to close an issue. It is the reverse | ||
| 170 | // of a request's closes list, and it is what an issue page can show without | ||
| 171 | // reading every change. | ||
| 172 | func (s *Store) Closes(repoID, issueID string) ([]string, error) { | ||
| 173 | rows, err := s.db.Query( | ||
| 174 | `SELECT request_id FROM request_closes WHERE repo_id=? AND issue_id=? ORDER BY request_id`, | ||
| 175 | repoID, issueID) | ||
| 176 | if err != nil { | ||
| 177 | return nil, err | ||
| 178 | } | ||
| 179 | defer rows.Close() | ||
| 180 | var out []string | ||
| 181 | for rows.Next() { | ||
| 182 | var id string | ||
| 183 | if err := rows.Scan(&id); err != nil { | ||
| 184 | return nil, err | ||
| 185 | } | ||
| 186 | out = append(out, id) | ||
| 187 | } | ||
| 188 | return out, rows.Err() | ||
| 189 | } | ||
| 190 | |||
| 191 | // insertRequest writes the row and its sets. The caller decides whether it | ||
| 192 | // replaces an existing request, so this stays one shape for the rebuild and the | ||
| 193 | // incremental refresh alike. | ||
| 194 | func insertRequest(tx execer, repoID string, req *metadata.Request) error { | ||
| 195 | if _, err := tx.Exec( | ||
| 196 | `INSERT INTO requests(repo_id, id, title, state, author, created, base, head, head_ref, | ||
| 197 | merged_base, merged_head, merged_by, merged_at, body, comments) | ||
| 198 | VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, | ||
| 199 | repoID, req.ID, req.Title, req.State, req.Author, unixOrZero(req.Created), | ||
| 200 | req.Base, req.Head, req.HeadRef, req.MergedBase, req.MergedHead, req.MergedBy, | ||
| 201 | unixOrZero(req.MergedAt), req.Description, len(req.Comments), | ||
| 202 | ); err != nil { | ||
| 203 | return err | ||
| 204 | } | ||
| 205 | for _, id := range req.Closes { | ||
| 206 | if _, err := tx.Exec( | ||
| 207 | `INSERT OR IGNORE INTO request_closes(repo_id, request_id, issue_id) VALUES(?,?,?)`, | ||
| 208 | repoID, req.ID, id); err != nil { | ||
| 209 | return err | ||
| 210 | } | ||
| 211 | } | ||
| 212 | for _, a := range req.Approvals { | ||
| 213 | if _, err := tx.Exec( | ||
| 214 | `INSERT OR REPLACE INTO request_approvals(repo_id, request_id, principal, head, base, created) | ||
| 215 | VALUES(?,?,?,?,?,?)`, | ||
| 216 | repoID, req.ID, a.Principal, a.Head, a.Base, unixOrZero(a.Created)); err != nil { | ||
| 217 | return err | ||
| 218 | } | ||
| 219 | } | ||
| 220 | return nil | ||
| 221 | } | ||
| 222 | |||
| 223 | // deleteRequest removes one request's rows. Approvals and closes of a request | ||
| 224 | // are part of the request, so they go with it; the `#42` number does not, | ||
| 225 | // because a number is spent for good once history assigned it. | ||
| 226 | func deleteRequest(tx execer, repoID, id string) error { | ||
| 227 | for _, q := range []string{ | ||
| 228 | `DELETE FROM requests WHERE repo_id=? AND id=?`, | ||
| 229 | `DELETE FROM request_closes WHERE repo_id=? AND request_id=?`, | ||
| 230 | `DELETE FROM request_approvals WHERE repo_id=? AND request_id=?`, | ||
| 231 | `DELETE FROM comments WHERE repo_id=? AND kind='request' AND parent=?`, | ||
| 232 | } { | ||
| 233 | if _, err := tx.Exec(q, repoID, id); err != nil { | ||
| 234 | return err | ||
| 235 | } | ||
| 236 | } | ||
| 237 | return nil | ||
| 238 | } | ||
| 239 | |||
| 240 | // unixOrZero keeps an absent timestamp as 0 rather than the Unix time of the | ||
| 241 | // zero Time, which would read back as 1754. | ||
| 242 | func unixOrZero(t time.Time) int64 { | ||
| 243 | if t.IsZero() { | ||
| 244 | return 0 | ||
| 245 | } | ||
| 246 | return t.Unix() | ||
| 247 | } | ||