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.
Phase 2: an assignee is a person you can click
67dd8854450865e9c5d2a8643b99c9ef7f71c284 · Keenan Nemetz · 17d ago · parent 307cbc2a
Phase 2: an assignee is a person you can click `assignees:` was in the frontmatter, written by the tool and read by nothing: not validated, not set by any command, not in the index, not on a page. It now goes the whole way — the field is checked at the push boundary, the index holds it in an `issue_assignees` table mirroring `issue_labels`, and the web renders it as a filter link on both the list and the detail page. The field is held to the *roster's* account-name rule rather than the label rule. It names a person, so the label rule (which allows dots) would accept a value no account could ever have; a capital or a `@` prefix is a typo that would render as a link to nobody. It is deliberately NOT resolved against the roster at push time: a clone may be offline, an assignment may name someone who has not been created yet, and a name in an issue file is a claim on the same footing as `author` — the hook's job is the format, not the org chart. Stored as written rather than as an account id, for the same reason `author` is: the file stays readable in a plain clone, and because names are never reissued an assignment keeps meaning the same person after a rename. The index needed a schema version to go with the new table, because `EnsureIssues` only re-reads when the ref moves: an existing instance would have served rows derived from the old shape until someone happened to push. A bump now empties the derived issue rows and leaves every repo's `issues_sha` empty, so the next read rebuilds from git. The index is disposable; a stale row is worse than a rebuild. `vole issue new -a`, `assign`, `unassign`, `list --assignee` and `%(assignees)` are the CLI half. An assignment that changes nothing is not a commit — `Update` reports `ErrNoChange` and the branch does not move — so a script that reruns stays idempotent without growing a linear log. Verified: TestAssigneeRuleMatchesAccountNames compares this package's regex with identity.ValidateName over the edge cases (a name the roster accepts and the format rejects would only ever show up as a rejected push); TestAssigneesAreAccountNames drives valid, capitalised, dotted, `@`-prefixed, too-short, too-long and space-bearing values through the reader; TestAssigneeRoundTrip proves the writer emits a sorted one-per-line list that the reader reads back; TestSchemaVersionDropsRowsFromAnOlderShape writes rows an older build would have left, reopens, and checks they are gone, `issues_sha` is empty and a second open does not re-wipe; and TestIssueAssigneesRenderedFilteredAndValidated plus the CLI end-to-end test push an assignment over SSH, click it, filter by it and get a push rejected for `Wilbur` with the path and value named and the branch unmoved. scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
internal/index/issues.go +47−10
| @@ -50,13 +50,16 @@ func (s *Store) IndexIssues(m *repo.Meta) error { | |||
| 50 | 50 | if _, err := tx.Exec(`DELETE FROM issue_labels WHERE repo_id=?`, m.ID); err != nil { | |
| 51 | 51 | return err | |
| 52 | 52 | } | |
| 53 | if _, err := tx.Exec(`DELETE FROM issue_assignees WHERE repo_id=?`, m.ID); err != nil { | ||
| 54 | return err | ||
| 55 | } | ||
| 53 | 56 | for _, iss := range list { | |
| 54 | 57 | if _, err := tx.Exec( | |
| 55 | `INSERT INTO issues(repo_id, id, title, state, author, created, labels, comments, body) | ||
| 56 | VALUES(?,?,?,?,?,?,?,?,?)`, | ||
| 58 | `INSERT INTO issues(repo_id, id, title, state, author, created, labels, assignees, comments, body) | ||
| 59 | VALUES(?,?,?,?,?,?,?,?,?,?)`, | ||
| 57 | 60 | m.ID, iss.ID, iss.Title, iss.State, iss.Author, | |
| 58 | iss.Created.Unix(), strings.Join(iss.Labels, "\x1f"), len(iss.Comments), | ||
| 59 | iss.Description, | ||
| 61 | iss.Created.Unix(), strings.Join(iss.Labels, "\x1f"), strings.Join(iss.Assignees, "\x1f"), | ||
| 62 | len(iss.Comments), iss.Description, | ||
| 60 | 63 | ); err != nil { | |
| 61 | 64 | return err | |
| 62 | 65 | } | |
| @@ -67,6 +70,13 @@ func (s *Store) IndexIssues(m *repo.Meta) error { | |||
| 67 | 70 | return err | |
| 68 | 71 | } | |
| 69 | 72 | } | |
| 73 | for _, a := range iss.Assignees { | ||
| 74 | if _, err := tx.Exec( | ||
| 75 | `INSERT OR IGNORE INTO issue_assignees(repo_id, issue_id, assignee) VALUES(?,?,?)`, | ||
| 76 | m.ID, iss.ID, a); err != nil { | ||
| 77 | return err | ||
| 78 | } | ||
| 79 | } | ||
| 70 | 80 | } | |
| 71 | 81 | if _, err := tx.Exec(`UPDATE repos SET issues_sha=? WHERE id=?`, sha, m.ID); err != nil { | |
| 72 | 82 | return err | |
| @@ -84,13 +94,14 @@ func unixTime(sec int64) time.Time { | |||
| 84 | 94 | ||
| 85 | 95 | // Filter narrows an issue list. Empty fields are ignored. | |
| 86 | 96 | type Filter struct { | |
| 87 | State string | ||
| 88 | Label string | ||
| 89 | Query string | ||
| 97 | State string | ||
| 98 | Label string | ||
| 99 | Assignee string | ||
| 100 | Query string | ||
| 90 | 101 | } | |
| 91 | 102 | ||
| 92 | 103 | func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) { | |
| 93 | q := `SELECT id, title, state, author, created, labels, comments | ||
| 104 | q := `SELECT id, title, state, author, created, labels, assignees, comments | ||
| 94 | 105 | FROM issues WHERE repo_id=?` | |
| 95 | 106 | args := []any{repoID} | |
| 96 | 107 | if f.State != "" && f.State != "all" { | |
| @@ -101,6 +112,10 @@ func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) { | |||
| 101 | 112 | q += ` AND id IN (SELECT issue_id FROM issue_labels WHERE repo_id=? AND label=?)` | |
| 102 | 113 | args = append(args, repoID, f.Label) | |
| 103 | 114 | } | |
| 115 | if f.Assignee != "" { | ||
| 116 | q += ` AND id IN (SELECT issue_id FROM issue_assignees WHERE repo_id=? AND assignee=?)` | ||
| 117 | args = append(args, repoID, f.Assignee) | ||
| 118 | } | ||
| 104 | 119 | if term := strings.TrimSpace(f.Query); term != "" { | |
| 105 | 120 | q += ` AND (title LIKE ? OR body LIKE ?)` | |
| 106 | 121 | like := "%" + term + "%" | |
| @@ -117,14 +132,17 @@ func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) { | |||
| 117 | 132 | for rows.Next() { | |
| 118 | 133 | var r IssueRow | |
| 119 | 134 | var created int64 | |
| 120 | var labels string | ||
| 121 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &labels, &r.Comments); err != nil { | ||
| 135 | var labels, assignees string | ||
| 136 | if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &labels, &assignees, &r.Comments); err != nil { | ||
| 122 | 137 | return nil, err | |
| 123 | 138 | } | |
| 124 | 139 | r.Created = unixTime(created) | |
| 125 | 140 | if labels != "" { | |
| 126 | 141 | r.Labels = strings.Split(labels, "\x1f") | |
| 127 | 142 | } | |
| 143 | if assignees != "" { | ||
| 144 | r.Assignees = strings.Split(assignees, "\x1f") | ||
| 145 | } | ||
| 128 | 146 | out = append(out, r) | |
| 129 | 147 | } | |
| 130 | 148 | return out, rows.Err() | |
| @@ -170,6 +188,25 @@ func (s *Store) Labels(repoID string) ([]string, error) { | |||
| 170 | 188 | return out, rows.Err() | |
| 171 | 189 | } | |
| 172 | 190 | ||
| 191 | // Assignees returns the assignees in use for a repo, for the filter UI. | ||
| 192 | func (s *Store) Assignees(repoID string) ([]string, error) { | ||
| 193 | rows, err := s.db.Query( | ||
| 194 | `SELECT DISTINCT assignee FROM issue_assignees WHERE repo_id=? ORDER BY assignee`, repoID) | ||
| 195 | if err != nil { | ||
| 196 | return nil, err | ||
| 197 | } | ||
| 198 | defer rows.Close() | ||
| 199 | var out []string | ||
| 200 | for rows.Next() { | ||
| 201 | var a string | ||
| 202 | if err := rows.Scan(&a); err != nil { | ||
| 203 | return nil, err | ||
| 204 | } | ||
| 205 | out = append(out, a) | ||
| 206 | } | ||
| 207 | return out, rows.Err() | ||
| 208 | } | ||
| 209 | |||
| 173 | 210 | // IssueCount is used by the repo page. | |
| 174 | 211 | func (s *Store) IssueCount(repoID string) (int, error) { | |
| 175 | 212 | var n int | |