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.

Blame · forge/index/requests.go

Line attribution on main · 363 line(s)

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 }