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/issues.go

Line attribution on main · 335 line(s)

37c01e5a
Keenan Nemetz
18d ago
1 package index
2
3 import (
34a404b3
Keenan Nemetz
10d ago
4 "database/sql"
838f282e
Keenan Nemetz
10d ago
5 "strconv"
37c01e5a
Keenan Nemetz
18d ago
6 "strings"
7 "time"
8
d37d301f
Keenan Nemetz
17h ago
9 "rodent.software/vole/core/git"
10 "rodent.software/vole/forge/repo"
11 "rodent.software/vole/metadata"
37c01e5a
Keenan Nemetz
18d ago
12 )
13
34a404b3
Keenan Nemetz
10d ago
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.
db4081ec
Keenan Nemetz
4d ago
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.
21 func 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 }
34a404b3
Keenan Nemetz
10d ago
28 if _, err := tx.Exec(
db4081ec
Keenan Nemetz
4d ago
29 `INSERT INTO issues(repo_id, id, title, state, author, created, updated, labels, assignees, comments, body)
30 VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
34a404b3
Keenan Nemetz
10d ago
31 repoID, iss.ID, iss.Title, iss.State, iss.Author,
db4081ec
Keenan Nemetz
4d ago
32 iss.Created.Unix(), updated, strings.Join(iss.Labels, "\x1f"), strings.Join(iss.Assignees, "\x1f"),
34a404b3
Keenan Nemetz
10d ago
33 len(iss.Comments), iss.Description,
34 ); err != nil {
35 return err
36 }
37 for _, l := range iss.Labels {
e05f1c48
Keenan Nemetz
13d ago
38 if _, err := tx.Exec(
34a404b3
Keenan Nemetz
10d ago
39 `INSERT OR IGNORE INTO issue_labels(repo_id, issue_id, label) VALUES(?,?,?)`,
40 repoID, iss.ID, l); err != nil {
e05f1c48
Keenan Nemetz
13d ago
41 return err
42 }
43 }
34a404b3
Keenan Nemetz
10d ago
44 for _, a := range iss.Assignees {
37c01e5a
Keenan Nemetz
18d ago
45 if _, err := tx.Exec(
34a404b3
Keenan Nemetz
10d ago
46 `INSERT OR IGNORE INTO issue_assignees(repo_id, issue_id, assignee) VALUES(?,?,?)`,
47 repoID, iss.ID, a); err != nil {
37c01e5a
Keenan Nemetz
18d ago
48 return err
49 }
34a404b3
Keenan Nemetz
10d ago
50 }
6762a520
Keenan Nemetz
10d ago
51 return indexDocument(tx, repoID, KindIssue, iss.ID, "", iss.Title, iss.Author, iss.Description)
34a404b3
Keenan Nemetz
10d ago
52 }
53
54 // deleteIssue removes one issue's rows without touching its number. A number is
55 // spent for good once history assigned it.
56 func 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=?`,
7f547ca3
Keenan Nemetz
10d ago
61 `DELETE FROM comments WHERE repo_id=? AND kind='issue' AND parent=?`,
34a404b3
Keenan Nemetz
10d ago
62 } {
63 if _, err := tx.Exec(q, repoID, id); err != nil {
64 return err
c1e3e3ca
Keenan Nemetz
18d ago
65 }
34a404b3
Keenan Nemetz
10d ago
66 }
6762a520
Keenan Nemetz
10d ago
67 return deleteDocuments(tx, repoID, KindIssue, id)
34a404b3
Keenan Nemetz
10d ago
68 }
69
70 func 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.
80 func 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
67dd8854
Keenan Nemetz
17d ago
97 }
37c01e5a
Keenan Nemetz
18d ago
98 }
34a404b3
Keenan Nemetz
10d ago
99 if err := rows.Err(); err != nil {
100 rows.Close()
37c01e5a
Keenan Nemetz
18d ago
101 return err
102 }
34a404b3
Keenan Nemetz
10d ago
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
37c01e5a
Keenan Nemetz
18d ago
115 }
116
db4081ec
Keenan Nemetz
4d ago
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.
37c01e5a
Keenan Nemetz
18d ago
119 func unixTime(sec int64) time.Time {
120 if sec == 0 {
121 return time.Time{}
122 }
123 return time.Unix(sec, 0).UTC()
124 }
125
c1e3e3ca
Keenan Nemetz
18d ago
126 // Filter narrows an issue list. Empty fields are ignored.
127 type Filter struct {
67dd8854
Keenan Nemetz
17d ago
128 State string
129 Label string
130 Assignee string
131 Query string
2a8f7cef
Keenan Nemetz
9d ago
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
c1e3e3ca
Keenan Nemetz
18d ago
135 }
136
db4081ec
Keenan Nemetz
4d ago
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.
140 func 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
838f282e
Keenan Nemetz
10d ago
158 // issueSelect is the row every issue list scans: the indexed issue with its
159 // `#42` alias from the numbering table.
db4081ec
Keenan Nemetz
4d ago
160 const issueSelect = `SELECT i.id, i.title, i.state, i.author, i.created, i.updated, i.labels, i.assignees, i.comments,
e05f1c48
Keenan Nemetz
13d ago
161 COALESCE(n.number, 0)
162 FROM issues i
838f282e
Keenan Nemetz
10d ago
163 LEFT JOIN entity_numbers n ON n.repo_id = i.repo_id AND n.id = i.id`
164
db4081ec
Keenan Nemetz
4d ago
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.
838f282e
Keenan Nemetz
10d ago
168 func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) {
169 q := issueSelect + `
e05f1c48
Keenan Nemetz
13d ago
170 WHERE i.repo_id=?`
c1e3e3ca
Keenan Nemetz
18d ago
171 args := []any{repoID}
172 if f.State != "" && f.State != "all" {
e05f1c48
Keenan Nemetz
13d ago
173 q += ` AND i.state=?`
c1e3e3ca
Keenan Nemetz
18d ago
174 args = append(args, f.State)
175 }
176 if f.Label != "" {
e05f1c48
Keenan Nemetz
13d ago
177 q += ` AND i.id IN (SELECT issue_id FROM issue_labels WHERE repo_id=? AND label=?)`
c1e3e3ca
Keenan Nemetz
18d ago
178 args = append(args, repoID, f.Label)
179 }
67dd8854
Keenan Nemetz
17d ago
180 if f.Assignee != "" {
e05f1c48
Keenan Nemetz
13d ago
181 q += ` AND i.id IN (SELECT issue_id FROM issue_assignees WHERE repo_id=? AND assignee=?)`
67dd8854
Keenan Nemetz
17d ago
182 args = append(args, repoID, f.Assignee)
183 }
c1e3e3ca
Keenan Nemetz
18d ago
184 if term := strings.TrimSpace(f.Query); term != "" {
e05f1c48
Keenan Nemetz
13d ago
185 q += ` AND (i.title LIKE ? OR i.body LIKE ?)`
c1e3e3ca
Keenan Nemetz
18d ago
186 like := "%" + term + "%"
187 args = append(args, like, like)
188 }
db4081ec
Keenan Nemetz
4d ago
189 q += ` ORDER BY i.updated DESC, i.id DESC`
2a8f7cef
Keenan Nemetz
9d ago
190 if f.Limit > 0 {
191 q += ` LIMIT ?`
192 args = append(args, f.Limit)
193 }
c1e3e3ca
Keenan Nemetz
18d ago
194
195 rows, err := s.db.Query(q, args...)
37c01e5a
Keenan Nemetz
18d ago
196 if err != nil {
197 return nil, err
198 }
199 defer rows.Close()
838f282e
Keenan Nemetz
10d ago
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.
207 func (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
243 func scanIssueRows(rows *sql.Rows) ([]IssueRow, error) {
37c01e5a
Keenan Nemetz
18d ago
244 var out []IssueRow
245 for rows.Next() {
246 var r IssueRow
db4081ec
Keenan Nemetz
4d ago
247 var created, updated int64
67dd8854
Keenan Nemetz
17d ago
248 var labels, assignees string
db4081ec
Keenan Nemetz
4d ago
249 if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &updated, &labels, &assignees, &r.Comments, &r.Number); err != nil {
37c01e5a
Keenan Nemetz
18d ago
250 return nil, err
251 }
252 r.Created = unixTime(created)
db4081ec
Keenan Nemetz
4d ago
253 r.Updated = unixTime(updated)
37c01e5a
Keenan Nemetz
18d ago
254 if labels != "" {
255 r.Labels = strings.Split(labels, "\x1f")
256 }
67dd8854
Keenan Nemetz
17d ago
257 if assignees != "" {
258 r.Assignees = strings.Split(assignees, "\x1f")
259 }
37c01e5a
Keenan Nemetz
18d ago
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
34a404b3
Keenan Nemetz
10d ago
266 // are always the committed ones. Only that issue's files are read.
c5902451
Keenan Nemetz
16d ago
267 func LoadIssue(m *repo.Meta, id string) (*metadata.Issue, error) {
34a404b3
Keenan Nemetz
10d ago
268 iss, found, err := metadata.LoadIssueAt(m.Git(), metadata.Ref, id)
37c01e5a
Keenan Nemetz
18d ago
269 if err != nil {
270 return nil, err
271 }
34a404b3
Keenan Nemetz
10d ago
272 if !found {
273 return nil, ErrNotFound
37c01e5a
Keenan Nemetz
18d ago
274 }
34a404b3
Keenan Nemetz
10d ago
275 return iss, nil
37c01e5a
Keenan Nemetz
18d ago
276 }
277
278 var ErrNotFound = errNotFound{}
279
280 type errNotFound struct{}
281
282 func (errNotFound) Error() string { return "not found" }
283
c1e3e3ca
Keenan Nemetz
18d ago
284 // Labels returns the labels in use for a repo, for the filter UI.
285 func (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
67dd8854
Keenan Nemetz
17d ago
303 // Assignees returns the assignees in use for a repo, for the filter UI.
304 func (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
a196e404
Keenan Nemetz
11d ago
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.
325 func (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 }
37c01e5a
Keenan Nemetz
18d ago
332 var n int
a196e404
Keenan Nemetz
11d ago
333 err := s.db.QueryRow(q, args...).Scan(&n)
37c01e5a
Keenan Nemetz
18d ago
334 return n, err
335 }