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 9.8 KiB
1package index
2
3import (
4 "database/sql"
5 "strconv"
6 "strings"
7 "time"
8
9 "rodent.software/vole/core/git"
10 "rodent.software/vole/forge/repo"
11 "rodent.software/vole/metadata"
12)
13
14// insertIssue writes the row and filter tables for one issue. The caller decides
15// whether it is replacing an existing issue, so this stays one shape used by the
16// full rebuild and the incremental refresh alike.
17//
18// updated is when the branch last touched the issue directory. It is passed in
19// rather than read from the issue, which has no such field: the commit that
20// changed the file carries the time.
21func insertIssue(tx execer, repoID string, iss *metadata.Issue, updated int64) error {
22 // An issue nobody has touched since it was filed still had one moment of
23 // activity. A commit dated before its own frontmatter is a fixture or a skewed
24 // clock, and reporting an update older than the opening would read as a bug.
25 if created := iss.Created.Unix(); updated < created {
26 updated = created
27 }
28 if _, err := tx.Exec(
29 `INSERT INTO issues(repo_id, id, title, state, author, created, updated, labels, assignees, comments, body)
30 VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
31 repoID, iss.ID, iss.Title, iss.State, iss.Author,
32 iss.Created.Unix(), updated, strings.Join(iss.Labels, "\x1f"), strings.Join(iss.Assignees, "\x1f"),
33 len(iss.Comments), iss.Description,
34 ); err != nil {
35 return err
36 }
37 for _, l := range iss.Labels {
38 if _, err := tx.Exec(
39 `INSERT OR IGNORE INTO issue_labels(repo_id, issue_id, label) VALUES(?,?,?)`,
40 repoID, iss.ID, l); err != nil {
41 return err
42 }
43 }
44 for _, a := range iss.Assignees {
45 if _, err := tx.Exec(
46 `INSERT OR IGNORE INTO issue_assignees(repo_id, issue_id, assignee) VALUES(?,?,?)`,
47 repoID, iss.ID, a); err != nil {
48 return err
49 }
50 }
51 return indexDocument(tx, repoID, KindIssue, iss.ID, "", iss.Title, iss.Author, iss.Description)
52}
53
54// deleteIssue removes one issue's rows without touching its number. A number is
55// spent for good once history assigned it.
56func deleteIssue(tx execer, repoID, id string) error {
57 for _, q := range []string{
58 `DELETE FROM issues WHERE repo_id=? AND id=?`,
59 `DELETE FROM issue_labels WHERE repo_id=? AND issue_id=?`,
60 `DELETE FROM issue_assignees WHERE repo_id=? AND issue_id=?`,
61 `DELETE FROM comments WHERE repo_id=? AND kind='issue' AND parent=?`,
62 } {
63 if _, err := tx.Exec(q, repoID, id); err != nil {
64 return err
65 }
66 }
67 return deleteDocuments(tx, repoID, KindIssue, id)
68}
69
70func insertNumber(tx execer, repoID string, number int, kind, id string) error {
71 _, err := tx.Exec(
72 `INSERT INTO entity_numbers(repo_id, number, kind, id) VALUES(?,?,?,?)`,
73 repoID, number, kind, id)
74 return err
75}
76
77// addNumbers appends the entities a commit range created, continuing the
78// existing sequence. An id the index already knows keeps its number; this is
79// what makes a deleted-then-re-added entity cheap and stable.
80func addNumbers(tx *sql.Tx, repoID string, additions []metadata.Entity) error {
81 rows, err := tx.Query(`SELECT id, number FROM entity_numbers WHERE repo_id=?`, repoID)
82 if err != nil {
83 return err
84 }
85 seen := map[string]bool{}
86 next := 0
87 for rows.Next() {
88 var id string
89 var number int
90 if err := rows.Scan(&id, &number); err != nil {
91 rows.Close()
92 return err
93 }
94 seen[id] = true
95 if number > next {
96 next = number
97 }
98 }
99 if err := rows.Err(); err != nil {
100 rows.Close()
101 return err
102 }
103 rows.Close()
104 for _, e := range additions {
105 if seen[e.ID] {
106 continue
107 }
108 next++
109 if err := insertNumber(tx, repoID, next, e.Kind, e.ID); err != nil {
110 return err
111 }
112 seen[e.ID] = true
113 }
114 return nil
115}
116
117// unixTime turns a stored epoch second back into a time. Zero stays the zero
118// time so a row the index has not filled in reads as absent rather than 1970.
119func unixTime(sec int64) time.Time {
120 if sec == 0 {
121 return time.Time{}
122 }
123 return time.Unix(sec, 0).UTC()
124}
125
126// Filter narrows an issue list. Empty fields are ignored.
127type Filter struct {
128 State string
129 Label string
130 Assignee string
131 Query string
132 // Limit caps the rows when it is positive. The overview asks for what fits in
133 // its sidebar instead of sorting a repository's whole open queue.
134 Limit int
135}
136
137// issueTouchTimes maps the paths one `git log` touched to the issues they belong
138// to, keeping the newest commit time per issue. rev may be a commit range, so an
139// incremental refresh only reads the commits it has not applied.
140func issueTouchTimes(g *git.Repo, rev string) (map[string]int64, error) {
141 touched, err := g.TouchTimes(rev, metadata.IssuesDir)
142 if err != nil {
143 return nil, err
144 }
145 times := make(map[string]int64, len(touched))
146 for path, when := range touched {
147 id, ok := metadata.IssueIDFromPath(path)
148 if !ok {
149 continue
150 }
151 if when > times[id] {
152 times[id] = when
153 }
154 }
155 return times, nil
156}
157
158// issueSelect is the row every issue list scans: the indexed issue with its
159// `#42` alias from the numbering table.
160const issueSelect = `SELECT i.id, i.title, i.state, i.author, i.created, i.updated, i.labels, i.assignees, i.comments,
161 COALESCE(n.number, 0)
162 FROM issues i
163 LEFT JOIN entity_numbers n ON n.repo_id = i.repo_id AND n.id = i.id`
164
165// Issues returns a repo's indexed issues, most recently updated first. That is
166// the order every list shows by default: an issue queue is read for what moved,
167// not for what was filed last.
168func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) {
169 q := issueSelect + `
170 WHERE i.repo_id=?`
171 args := []any{repoID}
172 if f.State != "" && f.State != "all" {
173 q += ` AND i.state=?`
174 args = append(args, f.State)
175 }
176 if f.Label != "" {
177 q += ` AND i.id IN (SELECT issue_id FROM issue_labels WHERE repo_id=? AND label=?)`
178 args = append(args, repoID, f.Label)
179 }
180 if f.Assignee != "" {
181 q += ` AND i.id IN (SELECT issue_id FROM issue_assignees WHERE repo_id=? AND assignee=?)`
182 args = append(args, repoID, f.Assignee)
183 }
184 if term := strings.TrimSpace(f.Query); term != "" {
185 q += ` AND (i.title LIKE ? OR i.body LIKE ?)`
186 like := "%" + term + "%"
187 args = append(args, like, like)
188 }
189 q += ` ORDER BY i.updated DESC, i.id DESC`
190 if f.Limit > 0 {
191 q += ` LIMIT ?`
192 args = append(args, f.Limit)
193 }
194
195 rows, err := s.db.Query(q, args...)
196 if err != nil {
197 return nil, err
198 }
199 defer rows.Close()
200 return scanIssueRows(rows)
201}
202
203// SearchIssues finds the issues a link picker should offer. A term matches a
204// title as a substring, and a number or ULID as a typed reference: `42` and
205// `#42` find issue 42, and a ULID or its prefix matches from the start. Exact
206// references sort ahead of title matches so the issue someone named is first.
207func (s *Store) SearchIssues(repoID, term string, limit int) ([]IssueRow, error) {
208 term = strings.TrimSpace(term)
209 if term == "" {
210 return nil, nil
211 }
212 if limit <= 0 {
213 limit = 8
214 }
215 ref := strings.TrimPrefix(term, "#")
216 number := 0
217 if n, err := strconv.Atoi(ref); err == nil && n > 0 {
218 number = n
219 }
220 q := issueSelect + `
221 WHERE i.repo_id=? AND (i.title LIKE ? OR i.id LIKE ?`
222 args := []any{repoID, "%" + term + "%", ref + "%"}
223 if number > 0 {
224 q += ` OR n.number=?`
225 args = append(args, number)
226 }
227 q += `)
228 ORDER BY CASE
229 WHEN i.id=? THEN 0
230 WHEN n.number=? THEN 1
231 WHEN i.id LIKE ? THEN 2
232 ELSE 3 END, i.id DESC
233 LIMIT ?`
234 args = append(args, ref, number, ref+"%", limit)
235 rows, err := s.db.Query(q, args...)
236 if err != nil {
237 return nil, err
238 }
239 defer rows.Close()
240 return scanIssueRows(rows)
241}
242
243func scanIssueRows(rows *sql.Rows) ([]IssueRow, error) {
244 var out []IssueRow
245 for rows.Next() {
246 var r IssueRow
247 var created, updated int64
248 var labels, assignees string
249 if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &updated, &labels, &assignees, &r.Comments, &r.Number); err != nil {
250 return nil, err
251 }
252 r.Created = unixTime(created)
253 r.Updated = unixTime(updated)
254 if labels != "" {
255 r.Labels = strings.Split(labels, "\x1f")
256 }
257 if assignees != "" {
258 r.Assignees = strings.Split(assignees, "\x1f")
259 }
260 out = append(out, r)
261 }
262 return out, rows.Err()
263}
264
265// LoadIssue reads one issue straight from git, so the rendered body and comments
266// are always the committed ones. Only that issue's files are read.
267func LoadIssue(m *repo.Meta, id string) (*metadata.Issue, error) {
268 iss, found, err := metadata.LoadIssueAt(m.Git(), metadata.Ref, id)
269 if err != nil {
270 return nil, err
271 }
272 if !found {
273 return nil, ErrNotFound
274 }
275 return iss, nil
276}
277
278var ErrNotFound = errNotFound{}
279
280type errNotFound struct{}
281
282func (errNotFound) Error() string { return "not found" }
283
284// Labels returns the labels in use for a repo, for the filter UI.
285func (s *Store) Labels(repoID string) ([]string, error) {
286 rows, err := s.db.Query(
287 `SELECT DISTINCT label FROM issue_labels WHERE repo_id=? ORDER BY label`, repoID)
288 if err != nil {
289 return nil, err
290 }
291 defer rows.Close()
292 var out []string
293 for rows.Next() {
294 var l string
295 if err := rows.Scan(&l); err != nil {
296 return nil, err
297 }
298 out = append(out, l)
299 }
300 return out, rows.Err()
301}
302
303// Assignees returns the assignees in use for a repo, for the filter UI.
304func (s *Store) Assignees(repoID string) ([]string, error) {
305 rows, err := s.db.Query(
306 `SELECT DISTINCT assignee FROM issue_assignees WHERE repo_id=? ORDER BY assignee`, repoID)
307 if err != nil {
308 return nil, err
309 }
310 defer rows.Close()
311 var out []string
312 for rows.Next() {
313 var a string
314 if err := rows.Scan(&a); err != nil {
315 return nil, err
316 }
317 out = append(out, a)
318 }
319 return out, rows.Err()
320}
321
322// IssueCount returns the number of issues in a state. An empty state, or "all",
323// counts every issue. It is used by the repository header, which has to answer
324// without loading the rows it is counting.
325func (s *Store) IssueCount(repoID, state string) (int, error) {
326 q := `SELECT COUNT(*) FROM issues WHERE repo_id=?`
327 args := []any{repoID}
328 if state != "" && state != "all" {
329 q += ` AND state=?`
330 args = append(args, state)
331 }
332 var n int
333 err := s.db.QueryRow(q, args...).Scan(&n)
334 return n, err
335}
336