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/server/integration_test.go +109−2

@@ -687,7 +687,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
687 687 return out.String(), errb.String(), err
688 688 }
689 689
690 out, errs, err := run("issue", "new", "--title", "Filed from the CLI", "-m", "Body from the CLI.", "-l", "bug")
690 out, errs, err := run("issue", "new", "--title", "Filed from the CLI", "-m", "Body from the CLI.", "-l", "bug", "-a", "keenan")
691 691 if err != nil {
692 692 t.Fatalf("issue new: %v\n%s", err, errs)
693 693 }
@@ -697,6 +697,41 @@ func TestIssueCLIEndToEnd(t *testing.T) {
697 697 }
698 698 id := fields[0]
699 699
700 // The assignment is in the file the server holds, not only in the local
701 // commit: this is the format the web reads.
702 serverFile := func(path string) string {
703 out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), "show", "refs/heads/vole:"+path)
704 return out
705 }
706 if got := serverFile("issues/" + id + "/issue.md"); !strings.Contains(got, "assignees:\n - keenan\n") {
707 t.Fatalf("assignment did not reach the branch:\n%s", got)
708 }
709 // Assign and unassign are normal edits of the same file. An assignment that
710 // changes nothing is not a commit: a rerun must leave the tip alone.
711 tip := f.serverIssues(t)
712 if _, errs, err := run("issue", "assign", id, "keenan"); err != nil {
713 t.Fatalf("assign (already assigned): %v\n%s", err, errs)
714 }
715 if f.serverIssues(t) != tip {
716 t.Fatal("re-assigning the same person moved the branch (empty commit)")
717 }
718 if _, errs, err := run("issue", "unassign", id, "keenan"); err != nil {
719 t.Fatalf("unassign: %v\n%s", err, errs)
720 }
721 if got := serverFile("issues/" + id + "/issue.md"); strings.Contains(got, "assignees:") {
722 t.Fatalf("unassign left the assignee behind:\n%s", got)
723 }
724 // A typed @ is accepted the way people write it.
725 if _, errs, err := run("issue", "assign", id, "@wilbur"); err != nil {
726 t.Fatalf("assign @wilbur: %v\n%s", err, errs)
727 }
728 if got := serverFile("issues/" + id + "/issue.md"); !strings.Contains(got, " - wilbur") {
729 t.Fatalf("@ was not stripped:\n%s", got)
730 }
731 if _, body := f.get("/r/" + f.repo + "/issues?assignee=wilbur"); !strings.Contains(body, "Filed from the CLI") {
732 t.Fatal("the assignment is not filterable on the web")
733 }
734
700 735 if !strings.HasPrefix(f.serverIssues(t), "") {
701 736 t.Fatal("issues branch missing on the server")
702 737 }
@@ -865,6 +900,77 @@ func TestIssueFiltersAndSearch(t *testing.T) {
865 900 }
866 901 }
867 902
903 // TestIssueAssigneesRenderedFilteredAndValidated: an assignee is a name you can
904 // click, a filter that narrows the list, and — because it names a person — a
905 // value the push boundary refuses when it could never be an account.
906 func TestIssueAssigneesRenderedFilteredAndValidated(t *testing.T) {
907 f := newFixture(t)
908 f.publish(f.repo)
909 const (
910 assigned = "01M5JR2JK29KKMAA1T9MDNTPC1"
911 unassigned = "01M5JR2JK29KKMAA1T9MDNTPC2"
912 )
913 files := map[string]string{
914 "issues/" + assigned + "/issue.md": issueFile("assigned bug", "keenan", "open",
915 "2026-10-22T21:00:00Z", []string{"bug"}, "body\n", "wilbur"),
916 "issues/" + unassigned + "/issue.md": issueFile("unassigned bug", "keenan", "open",
917 "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
918 }
919 work := f.issuesRepo(t, files)
920 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
921 t.Fatalf("push: %s", errs)
922 }
923
924 // The detail page shows the assignee as a link that filters.
925 code, body := f.get("/r/" + f.repo + "/issues/" + assigned)
926 if code != 200 {
927 t.Fatalf("issue page: %d", code)
928 }
929 if !strings.Contains(body, "@wilbur") || !strings.Contains(body, "issues?assignee=wilbur") {
930 t.Fatalf("assignee is not rendered as a filter link:\n%s", body)
931 }
932 // The list page offers the assignee as a filter and shows it on the row.
933 if _, body := f.get("/r/" + f.repo + "/issues"); !strings.Contains(body, "Assignees:") {
934 t.Fatalf("list has no assignee filters:\n%s", body)
935 }
936 // And the filter excludes issues with a different assignee.
937 _, body = f.get("/r/" + f.repo + "/issues?assignee=wilbur")
938 if !strings.Contains(body, "assigned bug") {
939 t.Fatalf("assignee filter dropped the assigned issue:\n%s", body)
940 }
941 if strings.Contains(body, "unassigned bug") {
942 t.Fatal("assignee filter did not exclude other issues")
943 }
944
945 // A value that could never be an account name is refused at the boundary,
946 // and the refusal names the path and the value.
947 before := f.serverIssues(t)
948 badPath := filepath.Join(work, "issues", "01M5JR2JK29KKMAA1T9MDNTPC3")
949 if err := os.MkdirAll(badPath, 0o755); err != nil {
950 t.Fatal(err)
951 }
952 bad := issueFile("typo", "keenan", "open", "2026-10-22T21:00:00Z", nil, "body\n", "Wilbur")
953 if err := os.WriteFile(filepath.Join(badPath, "issue.md"), []byte(bad), 0o644); err != nil {
954 t.Fatal(err)
955 }
956 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
957 t.Fatalf("add: %s", errs)
958 }
959 if _, errs, err := runGit(t, work, "commit", "-qm", "typo"); err != nil {
960 t.Fatalf("commit: %s", errs)
961 }
962 _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole")
963 if err == nil {
964 t.Fatalf("push with an impossible assignee was accepted")
965 }
966 if !strings.Contains(errs, "invalid assignee") || !strings.Contains(errs, "01M5JR2JK29KKMAA1T9MDNTPC3") {
967 t.Fatalf("refusal did not name the path and the value:\n%s", errs)
968 }
969 if after := f.serverIssues(t); after != before {
970 t.Fatalf("issues branch moved on a rejected push: %s -> %s", before, after)
971 }
972 }
973
868 974 // TestCrossReferencesLinksIssues: a #<ulid> in a body becomes a link, and a
869 975 // commit mentioning an issue shows as a backlink.
870 976 func TestCrossReferencesLinksIssues(t *testing.T) {
@@ -1245,13 +1351,14 @@ func approvalFile(base, head string) string {
1245 1351 }
1246 1352
1247 1353 // issueFile renders issues/<id>/issue.md for fixtures.
1248 func issueFile(title, author, state, created string, labels []string, body string) string {
1354 func issueFile(title, author, state, created string, labels []string, body string, assignees ...string) string {
1249 1355 d := frontmatter.New()
1250 1356 d.Text("title", title)
1251 1357 d.Word("state", state)
1252 1358 d.Word("author", author)
1253 1359 d.Word("created", created)
1254 1360 d.List("labels", labels)
1361 d.List("assignees", assignees)
1255 1362 out, err := d.Bytes(body)
1256 1363 if err != nil {
1257 1364 panic(err)