Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 11.8 KiB
1package 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
12import (
13 "database/sql"
14 "errors"
15 "strings"
16 "time"
17
18 "rodent.software/vole/core/git"
19 "rodent.software/vole/forge/repo"
20 "rodent.software/vole/metadata"
21)
22
23// RequestRow is one indexed request. Comments is a count: the rows themselves
24// are in the comments table.
25type RequestRow struct {
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
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.
46type RequestFilter struct {
47 State string
48 Query string
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
52}
53
54const requestSelect = `SELECT r.id, r.title, r.state, r.author, r.created, r.updated, r.base, r.head, r.head_ref,
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
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.
69func (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
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.
97func (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 }
110 q += ` ORDER BY CASE WHEN r.state='open' THEN 0 ELSE 1 END, r.updated DESC, r.id DESC`
111 if f.Limit > 0 {
112 q += ` LIMIT ?`
113 args = append(args, f.Limit)
114 }
115
116 rows, err := s.db.Query(q, args...)
117 if err != nil {
118 return nil, err
119 }
120 defer rows.Close()
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.
127func (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
153func scanRequestRows(rows *sql.Rows) ([]RequestRow, error) {
154 var out []RequestRow
155 for rows.Next() {
156 var r RequestRow
157 var created, updated int64
158 if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &updated, &r.Base,
159 &r.Head, &r.HeadRef, &r.CommentCount, &r.Number); err != nil {
160 return nil, err
161 }
162 r.Created = unixTime(created)
163 r.Updated = unixTime(updated)
164 out = append(out, r)
165 }
166 return out, rows.Err()
167}
168
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.
172func requestTouchTimes(g *git.Repo, rev string) (map[string]int64, error) {
173 touched, err := g.TouchTimes(rev, metadata.ChangesDir)
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
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.
193func (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.
207func (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.
237type ApprovalRow struct {
238 Principal string
239 Head string
240 Base string
241 Created time.Time
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.
247func (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 }
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`
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
268 var created int64
269 if err := rows.Scan(&id, &a.Principal, &a.Head, &a.Base, &created); err != nil {
270 return nil, err
271 }
272 a.Created = unixTime(created)
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.
281func (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.
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.
307func insertRequest(tx execer, repoID string, req *metadata.Request, updated int64) error {
308 if created := unixOrZero(req.Created); updated < created {
309 updated = created
310 }
311 if _, err := tx.Exec(
312 `INSERT INTO requests(repo_id, id, title, state, author, created, updated, base, head, head_ref,
313 merged_base, merged_head, merged_by, merged_at, body, comments)
314 VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
315 repoID, req.ID, req.Title, req.State, req.Author, unixOrZero(req.Created), updated,
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 }
321 for i, id := range req.Closes {
322 if _, err := tx.Exec(
323 `INSERT OR IGNORE INTO request_closes(repo_id, request_id, issue_id, position) VALUES(?,?,?,?)`,
324 repoID, req.ID, id, i); err != nil {
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 }
336 return indexDocument(tx, repoID, KindRequest, req.ID, "", req.Title, req.Author, req.Description)
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.
342func 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 }
353 return deleteDocuments(tx, repoID, KindRequest, id)
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.
358func unixOrZero(t time.Time) int64 {
359 if t.IsZero() {
360 return 0
361 }
362 return t.Unix()
363}
364