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