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