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/issues/issues_test.go +86
| @@ -4,6 +4,9 @@ import ( | |||
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | |||
| 8 | "github.com/rodent-software/vole/internal/frontmatter" | ||
| 9 | "github.com/rodent-software/vole/internal/identity" | ||
| 7 | 10 | ) | |
| 8 | 11 | ||
| 9 | 12 | const ( | |
| @@ -117,6 +120,89 @@ func TestRenderParseRoundTrip(t *testing.T) { | |||
| 117 | 120 | } | |
| 118 | 121 | } | |
| 119 | 122 | ||
| 123 | // TestAssigneesAreAccountNames: an assignee names a person, so the value has to | ||
| 124 | // be something the roster could issue. Anything else — a capital, a dot, a | ||
| 125 | // leading @ — is a typo that would render as a filter link to nobody. | ||
| 126 | func TestAssigneesAreAccountNames(t *testing.T) { | ||
| 127 | // Build the files the way a writer would, so the validator is what refuses | ||
| 128 | // rather than YAML refusing to parse the line first. | ||
| 129 | mk := func(assignees ...string) map[string]string { | ||
| 130 | d := frontmatter.New() | ||
| 131 | d.Text("title", "assigned") | ||
| 132 | d.Word("state", "open") | ||
| 133 | d.Word("author", "keenan") | ||
| 134 | d.Word("created", "2026-10-22T21:00:00Z") | ||
| 135 | d.List("assignees", assignees) | ||
| 136 | raw, err := d.Bytes("body\n") | ||
| 137 | if err != nil { | ||
| 138 | t.Fatal(err) | ||
| 139 | } | ||
| 140 | c := validContents() | ||
| 141 | c["issues/"+idA+"/issue.md"] = string(raw) | ||
| 142 | return c | ||
| 143 | } | ||
| 144 | |||
| 145 | list, err := Parse(mk("wilbur", "keenan")) | ||
| 146 | if err != nil { | ||
| 147 | t.Fatalf("valid assignees refused: %v", err) | ||
| 148 | } | ||
| 149 | if got := list[0].Assignees; len(got) != 2 || got[0] != "keenan" || got[1] != "wilbur" { | ||
| 150 | t.Fatalf("assignees = %v, want sorted [keenan wilbur]", got) | ||
| 151 | } | ||
| 152 | |||
| 153 | for _, bad := range []string{"Wilbur", "wil.bur", "@wilbur", "w", strings.Repeat("a", 33), "wil bur"} { | ||
| 154 | _, err := Parse(mk(bad)) | ||
| 155 | ve, ok := err.(*ValidationError) | ||
| 156 | if !ok { | ||
| 157 | t.Fatalf("%q was accepted: %v", bad, err) | ||
| 158 | } | ||
| 159 | if !strings.Contains(ve.Problems[0], "invalid assignee") || !strings.Contains(ve.Problems[0], bad) { | ||
| 160 | t.Fatalf("%q: unhelpful refusal: %v", bad, ve.Problems) | ||
| 161 | } | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | // TestAssigneeRuleMatchesAccountNames keeps the format's copy of the roster's | ||
| 166 | // name rule honest. A name the roster would accept but an issue would reject | ||
| 167 | // (or the reverse) is a bug that only shows up as a rejected push. | ||
| 168 | func TestAssigneeRuleMatchesAccountNames(t *testing.T) { | ||
| 169 | for _, s := range []string{"keenan", "wilbur-2", "a_b", "x1", "@keenan", "Keenan", "a.b", | ||
| 170 | "a", strings.Repeat("a", 32), strings.Repeat("a", 33), "with space"} { | ||
| 171 | want := identity.ValidateName(s) == nil | ||
| 172 | if got := assigneeRE.MatchString(s); got != want { | ||
| 173 | t.Fatalf("%q: issue format says %v, roster says %v", s, got, want) | ||
| 174 | } | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | // TestAssigneeRoundTrip: what the writer emits is what the reader reads, and the | ||
| 179 | // list is written sorted so an assignment is one added line. | ||
| 180 | func TestAssigneeRoundTrip(t *testing.T) { | ||
| 181 | iss := &Issue{ | ||
| 182 | ID: idA, | ||
| 183 | Title: "assigned", | ||
| 184 | State: "open", | ||
| 185 | Author: "keenan", | ||
| 186 | Created: time.Date(2026, 10, 22, 21, 0, 0, 0, time.UTC), | ||
| 187 | Assignees: []string{"wilbur", "keenan"}, | ||
| 188 | Description: "body\n", | ||
| 189 | } | ||
| 190 | raw, err := renderIssue(iss) | ||
| 191 | if err != nil { | ||
| 192 | t.Fatal(err) | ||
| 193 | } | ||
| 194 | if !strings.Contains(string(raw), "assignees:\n - keenan\n - wilbur\n") { | ||
| 195 | t.Fatalf("assignees not written sorted, one per line:\n%s", raw) | ||
| 196 | } | ||
| 197 | got, err := Parse(map[string]string{"issues/" + idA + "/issue.md": string(raw)}) | ||
| 198 | if err != nil { | ||
| 199 | t.Fatalf("parse: %v\n%s", raw, err) | ||
| 200 | } | ||
| 201 | if len(got[0].Assignees) != 2 || got[0].Assignees[0] != "keenan" || got[0].Assignees[1] != "wilbur" { | ||
| 202 | t.Fatalf("assignees came back as %v", got[0].Assignees) | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 120 | 206 | // TestUnquotedTitleIsRefused: a hand-edit that drops the quotes on a title with | |
| 121 | 207 | // " # " would otherwise store a shorter title and say nothing. | |
| 122 | 208 | func TestUnquotedTitleIsRefused(t *testing.T) { | |