Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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) {