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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | package index |
| 2 | |
| 3 | import ( |
| 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. |
| 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 | } |
| 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. |
| 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=?`, |
| 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 | |
| 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 |
| 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. |
| 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 | |
| 126 | // Filter narrows an issue list. Empty fields are ignored. |
| 127 | type 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. |
| 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 | |
| 158 | // issueSelect is the row every issue list scans: the indexed issue with its |
| 159 | // `#42` alias from the numbering table. |
| 160 | const 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. |
| 168 | func (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. |
| 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) { |
| 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. |
| 267 | func 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 | |
| 278 | var ErrNotFound = errNotFound{} |
| 279 | |
| 280 | type errNotFound struct{} |
| 281 | |
| 282 | func (errNotFound) Error() string { return "not found" } |
| 283 | |
| 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 | |
| 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 | |
| 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 | } |
| 332 | var n int |
| 333 | err := s.db.QueryRow(q, args...).Scan(&n) |
| 334 | return n, err |
| 335 | } |
| 336 |