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.
|
7f547ca3
Keenan Nemetz
10d ago
|
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 ( | |
|
122cbe4f
Keenan Nemetz
7d ago
|
13 | "database/sql" |
| 14 | "errors" | |
|
7f547ca3
Keenan Nemetz
10d ago
|
15 | "strings" |
| 16 | "time" | |
| 17 | ||
|
d37d301f
Keenan Nemetz
17h ago
|
18 | "rodent.software/vole/core/git" |
| 19 | "rodent.software/vole/forge/repo" | |
| 20 | "rodent.software/vole/metadata" | |
|
7f547ca3
Keenan Nemetz
10d ago
|
21 | ) |
| 22 | ||
| 23 | // RequestRow is one indexed request. Comments is a count: the rows themselves | |
| 24 | // are in the comments table. | |
| 25 | type RequestRow struct { | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
26 | ID string |
| 27 | Title string | |
| 28 | State string | |
| 29 | Author string | |
| 30 | Created time.Time | |
| 31 | // Updated is when the metadata branch last touched the request: an edit of | |
| 32 | // its body or target, a comment, or an approval. The branch keeps no such | |
| 33 | // field, so the index dates it from the commits, exactly like an issue. | |
| 34 | Updated time.Time | |
|
7f547ca3
Keenan Nemetz
10d ago
|
35 | Base string |
| 36 | Head string | |
| 37 | HeadRef string | |
| 38 | CommentCount int | |
| 39 | // Number is the `#42` alias from entity_numbers, or 0 when the branch has not | |
| 40 | // been numbered yet. | |
| 41 | Number int | |
| 42 | } | |
| 43 | ||
| 44 | // RequestFilter narrows a request list. Empty fields are ignored; State "all" | |
| 45 | // means every state. | |
| 46 | type RequestFilter struct { | |
| 47 | State string | |
| 48 | Query string | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
49 | // Limit caps the rows when it is positive; the overview's sidebar says how |
| 50 | // many it shows and does not need the rest of the queue. | |
| 51 | Limit int | |
|
7f547ca3
Keenan Nemetz
10d ago
|
52 | } |
| 53 | ||
|
7cc36ba0
Keenan Nemetz
4d ago
|
54 | const requestSelect = `SELECT r.id, r.title, r.state, r.author, r.created, r.updated, r.base, r.head, r.head_ref, |
|
7f547ca3
Keenan Nemetz
10d ago
|
55 | r.comments, COALESCE(n.number, 0) |
| 56 | FROM requests r | |
| 57 | LEFT JOIN entity_numbers n ON n.repo_id = r.repo_id AND n.id = r.id` | |
| 58 | ||
|
122cbe4f
Keenan Nemetz
7d ago
|
59 | // RequestsFollowing reports whether an open request follows branch, and whether that |
| 60 | // answer can be trusted. | |
| 61 | // | |
| 62 | // It exists so a push to a branch nobody is proposing from does not read the metadata | |
| 63 | // branch: the answer is one indexed row rather than the whole tree, and that push is | |
| 64 | // the common case by a wide margin. ok is false when the rows were built from a | |
| 65 | // different metadata commit than the branch is at now — a push that landed while the | |
| 66 | // index was behind, or a repository the index has not seen. A caller that must not | |
| 67 | // miss a follower reads that as "possibly", because a sync that does not happen is not | |
| 68 | // repaired by anything afterwards. | |
| 69 | func (s *Store) RequestsFollowing(m *repo.Meta, branch string) (follows, ok bool, err error) { | |
| 70 | var stored string | |
| 71 | if err := s.db.QueryRow(`SELECT issues_sha FROM repos WHERE id=?`, m.ID).Scan(&stored); err != nil { | |
| 72 | return false, false, err | |
| 73 | } | |
| 74 | current, err := m.Git().RefCommit(metadata.Ref) | |
| 75 | if err != nil { | |
| 76 | return false, false, err | |
| 77 | } | |
| 78 | if stored != current { | |
| 79 | return false, false, nil | |
| 80 | } | |
| 81 | var one int | |
| 82 | err = s.db.QueryRow(`SELECT 1 FROM requests WHERE repo_id=? AND state='open' AND head_ref=? LIMIT 1`, | |
| 83 | m.ID, branch).Scan(&one) | |
| 84 | if errors.Is(err, sql.ErrNoRows) { | |
| 85 | return false, true, nil | |
| 86 | } | |
| 87 | if err != nil { | |
| 88 | return false, false, err | |
| 89 | } | |
| 90 | return true, true, nil | |
| 91 | } | |
| 92 | ||
|
7cc36ba0
Keenan Nemetz
4d ago
|
93 | // Requests returns a repo's indexed requests, most recently updated first within |
| 94 | // each state, with open ones ahead of merged and closed. That is the order the | |
| 95 | // list page shows, so paging does not have to re-sort what the database already | |
| 96 | // ordered. | |
|
7f547ca3
Keenan Nemetz
10d ago
|
97 | func (s *Store) Requests(repoID string, f RequestFilter) ([]RequestRow, error) { |
| 98 | q := requestSelect + ` | |
| 99 | WHERE r.repo_id=?` | |
| 100 | args := []any{repoID} | |
| 101 | if f.State != "" && f.State != "all" { | |
| 102 | q += ` AND r.state=?` | |
| 103 | args = append(args, f.State) | |
| 104 | } | |
| 105 | if term := strings.TrimSpace(f.Query); term != "" { | |
| 106 | like := "%" + term + "%" | |
| 107 | q += ` AND (r.title LIKE ? OR r.body LIKE ? OR r.author LIKE ?)` | |
| 108 | args = append(args, like, like, like) | |
| 109 | } | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
110 | q += ` ORDER BY CASE WHEN r.state='open' THEN 0 ELSE 1 END, r.updated DESC, r.id DESC` |
|
2a8f7cef
Keenan Nemetz
9d ago
|
111 | if f.Limit > 0 { |
| 112 | q += ` LIMIT ?` | |
| 113 | args = append(args, f.Limit) | |
| 114 | } | |
|
7f547ca3
Keenan Nemetz
10d ago
|
115 | |
| 116 | rows, err := s.db.Query(q, args...) | |
| 117 | if err != nil { | |
| 118 | return nil, err | |
| 119 | } | |
| 120 | defer rows.Close() | |
|
fd9aa8ed
Keenan Nemetz
1d ago
|
121 | return scanRequestRows(rows) |
| 122 | } | |
| 123 | ||
| 124 | // RequestsByID returns the indexed rows for the named requests, keyed by id. An | |
| 125 | // issue page asks for the changes that link to it, and it needs the same facts a | |
| 126 | // list row shows rather than each request's conversation. | |
| 127 | func (s *Store) RequestsByID(repoID string, ids []string) (map[string]RequestRow, error) { | |
| 128 | out := make(map[string]RequestRow, len(ids)) | |
| 129 | if len(ids) == 0 { | |
| 130 | return out, nil | |
| 131 | } | |
| 132 | args := make([]any, 0, len(ids)+1) | |
| 133 | args = append(args, repoID) | |
| 134 | for _, id := range ids { | |
| 135 | args = append(args, id) | |
| 136 | } | |
| 137 | q := requestSelect + ` WHERE r.repo_id=? AND r.id IN (?` + strings.Repeat(",?", len(ids)-1) + `)` | |
| 138 | rows, err := s.db.Query(q, args...) | |
| 139 | if err != nil { | |
| 140 | return nil, err | |
| 141 | } | |
| 142 | defer rows.Close() | |
| 143 | list, err := scanRequestRows(rows) | |
| 144 | if err != nil { | |
| 145 | return nil, err | |
| 146 | } | |
| 147 | for _, r := range list { | |
| 148 | out[r.ID] = r | |
| 149 | } | |
| 150 | return out, nil | |
| 151 | } | |
| 152 | ||
| 153 | func scanRequestRows(rows *sql.Rows) ([]RequestRow, error) { | |
|
7f547ca3
Keenan Nemetz
10d ago
|
154 | var out []RequestRow |
| 155 | for rows.Next() { | |
| 156 | var r RequestRow | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
157 | var created, updated int64 |
| 158 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &updated, &r.Base, | |
|
7f547ca3
Keenan Nemetz
10d ago
|
159 | &r.Head, &r.HeadRef, &r.CommentCount, &r.Number); err != nil { |
| 160 | return nil, err | |
| 161 | } | |
| 162 | r.Created = unixTime(created) | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
163 | r.Updated = unixTime(updated) |
|
7f547ca3
Keenan Nemetz
10d ago
|
164 | out = append(out, r) |
| 165 | } | |
| 166 | return out, rows.Err() | |
| 167 | } | |
| 168 | ||
|
7cc36ba0
Keenan Nemetz
4d ago
|
169 | // requestTouchTimes maps the paths one `git log` touched to the requests they |
| 170 | // belong to, keeping the newest commit time per request. rev may be a commit | |
| 171 | // range, so an incremental refresh only reads the commits it has not applied. | |
| 172 | func requestTouchTimes(g *git.Repo, rev string) (map[string]int64, error) { | |
|
b492b557
Keenan Nemetz
3d ago
|
173 | touched, err := g.TouchTimes(rev, metadata.ChangesDir) |
|
7cc36ba0
Keenan Nemetz
4d ago
|
174 | if err != nil { |
| 175 | return nil, err | |
| 176 | } | |
| 177 | times := make(map[string]int64, len(touched)) | |
| 178 | for path, when := range touched { | |
| 179 | id, ok := metadata.RequestIDFromPath(path) | |
| 180 | if !ok { | |
| 181 | continue | |
| 182 | } | |
| 183 | if when > times[id] { | |
| 184 | times[id] = when | |
| 185 | } | |
| 186 | } | |
| 187 | return times, nil | |
| 188 | } | |
| 189 | ||
|
7f547ca3
Keenan Nemetz
10d ago
|
190 | // RequestCount returns the requests in one state. An empty state, or "all", |
| 191 | // counts every request. The repository header uses it for the open badge, which | |
| 192 | // every page shows. | |
| 193 | func (s *Store) RequestCount(repoID, state string) (int, error) { | |
| 194 | q := `SELECT COUNT(*) FROM requests WHERE repo_id=?` | |
| 195 | args := []any{repoID} | |
| 196 | if state != "" && state != "all" { | |
| 197 | q += ` AND state=?` | |
| 198 | args = append(args, state) | |
| 199 | } | |
| 200 | var n int | |
| 201 | err := s.db.QueryRow(q, args...).Scan(&n) | |
| 202 | return n, err | |
| 203 | } | |
| 204 | ||
| 205 | // RequestCounts returns the totals the list page's chips show. One query, so the | |
| 206 | // four numbers cannot describe four different moments. | |
| 207 | func (s *Store) RequestCounts(repoID string) (open, merged, closed, total int, err error) { | |
| 208 | rows, err := s.db.Query(`SELECT state, COUNT(*) FROM requests WHERE repo_id=? GROUP BY state`, repoID) | |
| 209 | if err != nil { | |
| 210 | return 0, 0, 0, 0, err | |
| 211 | } | |
| 212 | defer rows.Close() | |
| 213 | for rows.Next() { | |
| 214 | var state string | |
| 215 | var n int | |
| 216 | if err := rows.Scan(&state, &n); err != nil { | |
| 217 | return 0, 0, 0, 0, err | |
| 218 | } | |
| 219 | total += n | |
| 220 | switch state { | |
| 221 | case "open": | |
| 222 | open = n | |
| 223 | case "merged": | |
| 224 | merged = n | |
| 225 | default: | |
| 226 | // A state the format does not define still counts somewhere, so the | |
| 227 | // chips add up to the total the All chip shows. | |
| 228 | closed += n | |
| 229 | } | |
| 230 | } | |
| 231 | return open, merged, closed, total, rows.Err() | |
| 232 | } | |
| 233 | ||
| 234 | // ApprovalRow is one approval as the index stores it. The list page compares | |
| 235 | // Head and Base with the request's current pair, so an approval of what was | |
| 236 | // pushed yesterday stops counting without being deleted. | |
| 237 | type ApprovalRow struct { | |
| 238 | Principal string | |
| 239 | Head string | |
| 240 | Base string | |
|
9d8dd1bc
Keenan Nemetz
10d ago
|
241 | Created time.Time |
|
7f547ca3
Keenan Nemetz
10d ago
|
242 | } |
| 243 | ||
| 244 | // Approvals returns the approvals for the named requests, keyed by request id. | |
| 245 | // A list page asks for the rows it is about to show, not for every approval in | |
| 246 | // the repository. | |
| 247 | func (s *Store) Approvals(repoID string, requestIDs []string) (map[string][]ApprovalRow, error) { | |
| 248 | out := map[string][]ApprovalRow{} | |
| 249 | if len(requestIDs) == 0 { | |
| 250 | return out, nil | |
| 251 | } | |
| 252 | args := make([]any, 0, len(requestIDs)+1) | |
| 253 | args = append(args, repoID) | |
| 254 | for _, id := range requestIDs { | |
| 255 | args = append(args, id) | |
| 256 | } | |
|
9d8dd1bc
Keenan Nemetz
10d ago
|
257 | q := `SELECT request_id, principal, head, base, created FROM request_approvals |
| 258 | WHERE repo_id=? AND request_id IN (?` + strings.Repeat(",?", len(requestIDs)-1) + `) | |
| 259 | ORDER BY request_id, principal` | |
|
7f547ca3
Keenan Nemetz
10d ago
|
260 | rows, err := s.db.Query(q, args...) |
| 261 | if err != nil { | |
| 262 | return nil, err | |
| 263 | } | |
| 264 | defer rows.Close() | |
| 265 | for rows.Next() { | |
| 266 | var id string | |
| 267 | var a ApprovalRow | |
|
9d8dd1bc
Keenan Nemetz
10d ago
|
268 | var created int64 |
| 269 | if err := rows.Scan(&id, &a.Principal, &a.Head, &a.Base, &created); err != nil { | |
|
7f547ca3
Keenan Nemetz
10d ago
|
270 | return nil, err |
| 271 | } | |
|
9d8dd1bc
Keenan Nemetz
10d ago
|
272 | a.Created = unixTime(created) |
|
7f547ca3
Keenan Nemetz
10d ago
|
273 | out[id] = append(out[id], a) |
| 274 | } | |
| 275 | return out, rows.Err() | |
| 276 | } | |
| 277 | ||
| 278 | // Closes returns the requests that promise to close an issue. It is the reverse | |
| 279 | // of a request's closes list, and it is what an issue page can show without | |
| 280 | // reading every change. | |
| 281 | func (s *Store) Closes(repoID, issueID string) ([]string, error) { | |
| 282 | rows, err := s.db.Query( | |
| 283 | `SELECT request_id FROM request_closes WHERE repo_id=? AND issue_id=? ORDER BY request_id`, | |
| 284 | repoID, issueID) | |
| 285 | if err != nil { | |
| 286 | return nil, err | |
| 287 | } | |
| 288 | defer rows.Close() | |
| 289 | var out []string | |
| 290 | for rows.Next() { | |
| 291 | var id string | |
| 292 | if err := rows.Scan(&id); err != nil { | |
| 293 | return nil, err | |
| 294 | } | |
| 295 | out = append(out, id) | |
| 296 | } | |
| 297 | return out, rows.Err() | |
| 298 | } | |
| 299 | ||
| 300 | // insertRequest writes the row and its sets. The caller decides whether it | |
| 301 | // replaces an existing request, so this stays one shape for the rebuild and the | |
| 302 | // incremental refresh alike. | |
|
7cc36ba0
Keenan Nemetz
4d ago
|
303 | // |
| 304 | // updated is when the branch last touched the request directory, passed in | |
| 305 | // because the request has no such field: the commit that changed it carries the | |
| 306 | // time. | |
| 307 | func insertRequest(tx execer, repoID string, req *metadata.Request, updated int64) error { | |
| 308 | if created := unixOrZero(req.Created); updated < created { | |
| 309 | updated = created | |
| 310 | } | |
|
7f547ca3
Keenan Nemetz
10d ago
|
311 | if _, err := tx.Exec( |
|
7cc36ba0
Keenan Nemetz
4d ago
|
312 | `INSERT INTO requests(repo_id, id, title, state, author, created, updated, base, head, head_ref, |
|
7f547ca3
Keenan Nemetz
10d ago
|
313 | merged_base, merged_head, merged_by, merged_at, body, comments) |
|
7cc36ba0
Keenan Nemetz
4d ago
|
314 | VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, |
| 315 | repoID, req.ID, req.Title, req.State, req.Author, unixOrZero(req.Created), updated, | |
|
7f547ca3
Keenan Nemetz
10d ago
|
316 | req.Base, req.Head, req.HeadRef, req.MergedBase, req.MergedHead, req.MergedBy, |
| 317 | unixOrZero(req.MergedAt), req.Description, len(req.Comments), | |
| 318 | ); err != nil { | |
| 319 | return err | |
| 320 | } | |
|
9d8dd1bc
Keenan Nemetz
10d ago
|
321 | for i, id := range req.Closes { |
|
7f547ca3
Keenan Nemetz
10d ago
|
322 | if _, err := tx.Exec( |
|
9d8dd1bc
Keenan Nemetz
10d ago
|
323 | `INSERT OR IGNORE INTO request_closes(repo_id, request_id, issue_id, position) VALUES(?,?,?,?)`, |
| 324 | repoID, req.ID, id, i); err != nil { | |
|
7f547ca3
Keenan Nemetz
10d ago
|
325 | return err |
| 326 | } | |
| 327 | } | |
| 328 | for _, a := range req.Approvals { | |
| 329 | if _, err := tx.Exec( | |
| 330 | `INSERT OR REPLACE INTO request_approvals(repo_id, request_id, principal, head, base, created) | |
| 331 | VALUES(?,?,?,?,?,?)`, | |
| 332 | repoID, req.ID, a.Principal, a.Head, a.Base, unixOrZero(a.Created)); err != nil { | |
| 333 | return err | |
| 334 | } | |
| 335 | } | |
|
6762a520
Keenan Nemetz
10d ago
|
336 | return indexDocument(tx, repoID, KindRequest, req.ID, "", req.Title, req.Author, req.Description) |
|
7f547ca3
Keenan Nemetz
10d ago
|
337 | } |
| 338 | ||
| 339 | // deleteRequest removes one request's rows. Approvals and closes of a request | |
| 340 | // are part of the request, so they go with it; the `#42` number does not, | |
| 341 | // because a number is spent for good once history assigned it. | |
| 342 | func deleteRequest(tx execer, repoID, id string) error { | |
| 343 | for _, q := range []string{ | |
| 344 | `DELETE FROM requests WHERE repo_id=? AND id=?`, | |
| 345 | `DELETE FROM request_closes WHERE repo_id=? AND request_id=?`, | |
| 346 | `DELETE FROM request_approvals WHERE repo_id=? AND request_id=?`, | |
| 347 | `DELETE FROM comments WHERE repo_id=? AND kind='request' AND parent=?`, | |
| 348 | } { | |
| 349 | if _, err := tx.Exec(q, repoID, id); err != nil { | |
| 350 | return err | |
| 351 | } | |
| 352 | } | |
|
6762a520
Keenan Nemetz
10d ago
|
353 | return deleteDocuments(tx, repoID, KindRequest, id) |
|
7f547ca3
Keenan Nemetz
10d ago
|
354 | } |
| 355 | ||
| 356 | // unixOrZero keeps an absent timestamp as 0 rather than the Unix time of the | |
| 357 | // zero Time, which would read back as 1754. | |
| 358 | func unixOrZero(t time.Time) int64 { | |
| 359 | if t.IsZero() { | |
| 360 | return 0 | |
| 361 | } | |
| 362 | return t.Unix() | |
| 363 | } |