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/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 }