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.
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 ./...).
docs/roadmap.md +20−4
| @@ -170,17 +170,30 @@ Shipped: | |||
| 170 | 170 | - **Filtering and search**: state, label, and a LIKE search over title and body, | |
| 171 | 171 | backed by an `issue_labels` table and an indexed `body` column. | |
| 172 | 172 | ||
| 173 | Three follow-ups, none of them blocking: | ||
| 173 | Follow-ups: | ||
| 174 | 174 | ||
| 175 | 175 | - **Notifications are deliberately not built.** There is no user identity on a | |
| 176 | 176 | read-only web, so "in-app" has nobody to notify, and email needs subscriptions | |
| 177 | 177 | that would themselves need a store. The natural fit for this architecture is a | |
| 178 | 178 | per-repo Atom feed of issue and commit activity, which needs no auth — worth | |
| 179 | 179 | doing when someone asks, instead of building a notification system nobody can | |
| 180 | log into. | ||
| 181 | - Assignees are in the frontmatter but not rendered or filterable | ||
| 180 | log into. The shape it should take when it is wanted: one feed per repo over | ||
| 181 | tags *and* issues/comments, because the audience with no alternative is the | ||
| 182 | person consuming artifacts, not the person who already has a clone. | ||
| 183 | - ✅ **Assignees are first-class.** `assignees:` in the frontmatter was parsed and | ||
| 184 | written but went nowhere; it is now validated, filterable and clickable. An | ||
| 185 | assignee is held to the roster's account-name rule rather than the label rule — | ||
| 186 | it names a person, and a value that could never be an account is a typo, not a | ||
| 187 | new kind of label — but it is **not** roster-checked at push time: the hook has | ||
| 188 | no business failing a push because an assignment names somebody who has not been | ||
| 189 | created yet, and a name in frontmatter is a claim in the same way `author` is. | ||
| 190 | It is stored as written rather than resolved to an account id, so the file stays | ||
| 191 | readable in a plain clone and an assignment keeps meaning the same person after | ||
| 192 | a rename, since names are never reissued. `vole issue new -a`, `assign`, | ||
| 193 | `unassign`, `list --assignee`, `%(assignees)`, the index's `issue_assignees` | ||
| 194 | table and the web's filter links all read that one field. | ||
| 182 | 195 | - Comment editing and deletion (today: append another comment, or rewrite history | |
| 183 | and force-push) | ||
| 196 | and force-push). | ||
| 184 | 197 | ||
| 185 | 198 | Exit criteria: a user with an editor and a key files, comments on and closes an | |
| 186 | 199 | issue without installing Vole; an invalid tree is rejected with the offending path | |
| @@ -744,6 +757,9 @@ Filled in as phases land. Append; don't rewrite. | |||
| 744 | 757 | | 2026-10-23 | The roster is `data/users.db`; clients read it with the `vole-roster` SSH verb | Accounts are rows, not commits: SQLite enforces "a name is issued once" and "a key belongs to one account" as table keys, and a backup is one file. The client still needs an id/name map, so it asks over the key it already pushes with instead of cloning a repository that held nothing else. Supersedes both roster rows above | | |
| 745 | 758 | | 2026-10-23 | Metadata on `refs/heads/vole` is frontmatter markdown, not JSON | [Spike](spikes/issue-frontmatter/README.md): the same six edits cost 25 changed lines as `meta.json` and 10 as frontmatter. A JSON array rewrites its neighbour to add a comma; one label per line is one added line, and one file per entity means one path to diff, blame and log. Always quoting free text turns the YAML edge cases into a writer concern instead of a title-restriction list, and the validator only has to refuse an unquoted title the parser read differently. The `updated` field was dropped: it was 11 of the 21 frontmatter lines, and the commit already carries the time | | |
| 746 | 759 | | 2026-10-22 | A published release is immutable, admin-overridable and audited | A link whose bytes can change is worse than no link; but refusing outright leaves a mistyped tag unrepairable, so the override follows the force-push precedent: admin only, always audited | | |
| 760 | | 2026-10-23 | An assignee is a name held to the account-name rule, not roster-checked at push | It names a person, so the label rule (which allows dots) would accept a value no account can have — but resolving it against the roster at the gate would fail a push written offline, or for someone added after the assignment. A name in frontmatter is a claim on the same footing as `author`; the web shows it and the filter links by it. Stored as written, so a rename never rewrites history (names are never reissued) and the file stays readable in a clone | | ||
| 761 | | 2026-10-23 | The index carries a schema version; a bump empties the derived rows | A new table and column would leave existing instances serving rows derived from the old shape until the next push touched each repo. The index is disposable, so the honest migration is "delete and let the next read rebuild from git" — a stale row is worse than a rebuild, and the version stops a half-applied schema from being read as if it were complete | | ||
| 762 | | 2026-10-23 | An edit that changes nothing is not a commit | `vole issue assign` on an already-assigned person would otherwise add an empty commit to a linear branch, and a script that reruns has to be idempotent without growing the log. `Update` reports `ErrNoChange` and the branch does not move | | ||
| 747 | 763 | ||
| 748 | 764 | ## Open questions | |
| 749 | 765 | ||