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.
Number issues and change requests for human references
e05f1c48d4ee55635cd7b77889f0545137198106 · Keenan Nemetz · 13d ago · parent 41d1fee9
Number issues and change requests for human references A ULID is an unreadable handle, and the 8-character prefix the list showed is mostly timestamp: this repository's 27 issues shared seven distinct six-character prefixes and two pairs already collided at eight. The part people actually type carried almost no information. Issues and change requests now share one gap-free sequence (#1, #42, ...) derived from the creation order in the metadata branch's history, the way one forge counts both in a single run. The number is not stored. It is the rank of the entity's creation commit, so nothing is written when an entity is created, a repository needs no backfill, the same history gives the same numbers on every clone, and `vole admin index rebuild` reproduces them from git. A ULID stays the identity: paths, front matter, `closes:` and audit entries are unchanged, and data/index.db only caches the mapping so a page render does not walk history. Every id-taking command accepts a number, `#42` links in a body through a kind-agnostic /r/<repo>/n/42 route, and --closes resolves an alias to the ULID before writing. Closes #45
forge/index/issues.go +39−12
| @@ -8,9 +8,10 @@ import ( | |||
| 8 | 8 | "github.com/rodent-software/vole/metadata" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | // EnsureIssues refreshes a repo's indexed issues if the issues branch moved. | ||
| 12 | // Called on read, so a push is visible without a hook or a full rebuild. | ||
| 13 | func (s *Store) EnsureIssues(m *repo.Meta) error { | ||
| 11 | // Ensure refreshes everything a repo derives from its metadata branch: the issue | ||
| 12 | // rows, the label and assignee filters, and the `#42` numbering. Called on read, | ||
| 13 | // so a push is visible without a hook or a full rebuild. | ||
| 14 | func (s *Store) Ensure(m *repo.Meta) error { | ||
| 14 | 15 | current := "" | |
| 15 | 16 | if sha, err := m.Git().RevParse(metadata.Ref); err == nil { | |
| 16 | 17 | current = sha | |
| @@ -26,12 +27,21 @@ func (s *Store) EnsureIssues(m *repo.Meta) error { | |||
| 26 | 27 | return s.IndexIssues(m) | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | // IndexIssues replaces a repo's issue rows from the issues branch. | ||
| 30 | // EnsureIssues keeps the older name for the issue surfaces that read it. It is | ||
| 31 | // Ensure, because numbers live in the same refresh as the issue rows. | ||
| 32 | func (s *Store) EnsureIssues(m *repo.Meta) error { return s.Ensure(m) } | ||
| 33 | |||
| 34 | // IndexIssues replaces a repo's issue rows and its entity numbers from the | ||
| 35 | // metadata branch. | ||
| 30 | 36 | func (s *Store) IndexIssues(m *repo.Meta) error { | |
| 31 | 37 | list, err := metadata.LoadIssues(m.Git(), metadata.Ref) | |
| 32 | 38 | if err != nil { | |
| 33 | 39 | return err | |
| 34 | 40 | } | |
| 41 | numbers, err := metadata.LoadNumbers(m.Git(), metadata.Ref) | ||
| 42 | if err != nil { | ||
| 43 | return err | ||
| 44 | } | ||
| 35 | 45 | sha, err := m.Git().RevParse(metadata.Ref) | |
| 36 | 46 | if err != nil { | |
| 37 | 47 | return err | |
| @@ -51,6 +61,20 @@ func (s *Store) IndexIssues(m *repo.Meta) error { | |||
| 51 | 61 | if _, err := tx.Exec(`DELETE FROM issue_assignees WHERE repo_id=?`, m.ID); err != nil { | |
| 52 | 62 | return err | |
| 53 | 63 | } | |
| 64 | if _, err := tx.Exec(`DELETE FROM entity_numbers WHERE repo_id=?`, m.ID); err != nil { | ||
| 65 | return err | ||
| 66 | } | ||
| 67 | for number := 1; number <= numbers.Len(); number++ { | ||
| 68 | id, kind, ok := numbers.At(number) | ||
| 69 | if !ok { | ||
| 70 | continue | ||
| 71 | } | ||
| 72 | if _, err := tx.Exec( | ||
| 73 | `INSERT INTO entity_numbers(repo_id, number, kind, id) VALUES(?,?,?,?)`, | ||
| 74 | m.ID, number, kind, id); err != nil { | ||
| 75 | return err | ||
| 76 | } | ||
| 77 | } | ||
| 54 | 78 | for _, iss := range list { | |
| 55 | 79 | if _, err := tx.Exec( | |
| 56 | 80 | `INSERT INTO issues(repo_id, id, title, state, author, created, labels, assignees, comments, body) | |
| @@ -99,27 +123,30 @@ type Filter struct { | |||
| 99 | 123 | } | |
| 100 | 124 | ||
| 101 | 125 | func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) { | |
| 102 | q := `SELECT id, title, state, author, created, labels, assignees, comments | ||
| 103 | FROM issues WHERE repo_id=?` | ||
| 126 | q := `SELECT i.id, i.title, i.state, i.author, i.created, i.labels, i.assignees, i.comments, | ||
| 127 | COALESCE(n.number, 0) | ||
| 128 | FROM issues i | ||
| 129 | LEFT JOIN entity_numbers n ON n.repo_id = i.repo_id AND n.id = i.id | ||
| 130 | WHERE i.repo_id=?` | ||
| 104 | 131 | args := []any{repoID} | |
| 105 | 132 | if f.State != "" && f.State != "all" { | |
| 106 | q += ` AND state=?` | ||
| 133 | q += ` AND i.state=?` | ||
| 107 | 134 | args = append(args, f.State) | |
| 108 | 135 | } | |
| 109 | 136 | if f.Label != "" { | |
| 110 | q += ` AND id IN (SELECT issue_id FROM issue_labels WHERE repo_id=? AND label=?)` | ||
| 137 | q += ` AND i.id IN (SELECT issue_id FROM issue_labels WHERE repo_id=? AND label=?)` | ||
| 111 | 138 | args = append(args, repoID, f.Label) | |
| 112 | 139 | } | |
| 113 | 140 | if f.Assignee != "" { | |
| 114 | q += ` AND id IN (SELECT issue_id FROM issue_assignees WHERE repo_id=? AND assignee=?)` | ||
| 141 | q += ` AND i.id IN (SELECT issue_id FROM issue_assignees WHERE repo_id=? AND assignee=?)` | ||
| 115 | 142 | args = append(args, repoID, f.Assignee) | |
| 116 | 143 | } | |
| 117 | 144 | if term := strings.TrimSpace(f.Query); term != "" { | |
| 118 | q += ` AND (title LIKE ? OR body LIKE ?)` | ||
| 145 | q += ` AND (i.title LIKE ? OR i.body LIKE ?)` | ||
| 119 | 146 | like := "%" + term + "%" | |
| 120 | 147 | args = append(args, like, like) | |
| 121 | 148 | } | |
| 122 | q += ` ORDER BY id DESC` | ||
| 149 | q += ` ORDER BY i.id DESC` | ||
| 123 | 150 | ||
| 124 | 151 | rows, err := s.db.Query(q, args...) | |
| 125 | 152 | if err != nil { | |
| @@ -131,7 +158,7 @@ func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) { | |||
| 131 | 158 | var r IssueRow | |
| 132 | 159 | var created int64 | |
| 133 | 160 | var labels, assignees string | |
| 134 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &labels, &assignees, &r.Comments); err != nil { | ||
| 161 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &labels, &assignees, &r.Comments, &r.Number); err != nil { | ||
| 135 | 162 | return nil, err | |
| 136 | 163 | } | |
| 137 | 164 | r.Created = unixTime(created) | |