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/web/issues.go +25−18
| @@ -73,9 +73,10 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) { | |||
| 73 | 73 | return | |
| 74 | 74 | } | |
| 75 | 75 | filter := index.Filter{ | |
| 76 | State: strings.TrimSpace(r.URL.Query().Get("state")), | ||
| 77 | Label: strings.TrimSpace(r.URL.Query().Get("label")), | ||
| 78 | Query: strings.TrimSpace(r.URL.Query().Get("q")), | ||
| 76 | State: strings.TrimSpace(r.URL.Query().Get("state")), | ||
| 77 | Label: strings.TrimSpace(r.URL.Query().Get("label")), | ||
| 78 | Assignee: strings.TrimSpace(r.URL.Query().Get("assignee")), | ||
| 79 | Query: strings.TrimSpace(r.URL.Query().Get("q")), | ||
| 79 | 80 | } | |
| 80 | 81 | rows, err := w.Index.Issues(m.ID, filter) | |
| 81 | 82 | if err != nil { | |
| @@ -93,6 +94,10 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) { | |||
| 93 | 94 | if err != nil { | |
| 94 | 95 | labels = nil | |
| 95 | 96 | } | |
| 97 | assignees, err := w.Index.Assignees(m.ID) | ||
| 98 | if err != nil { | ||
| 99 | assignees = nil | ||
| 100 | } | ||
| 96 | 101 | total, _ := w.Index.IssueCount(m.ID) | |
| 97 | 102 | open := 0 | |
| 98 | 103 | for _, row := range rows { | |
| @@ -102,22 +107,24 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) { | |||
| 102 | 107 | } | |
| 103 | 108 | w.render(rw, "issues", struct { | |
| 104 | 109 | Page | |
| 105 | Name string | ||
| 106 | Issues []index.IssueRow | ||
| 107 | Labels []string | ||
| 108 | Filter index.Filter | ||
| 109 | Total int | ||
| 110 | Open int | ||
| 111 | Filtered bool | ||
| 110 | Name string | ||
| 111 | Issues []index.IssueRow | ||
| 112 | Labels []string | ||
| 113 | Assignees []string | ||
| 114 | Filter index.Filter | ||
| 115 | Total int | ||
| 116 | Open int | ||
| 117 | Filtered bool | ||
| 112 | 118 | }{ | |
| 113 | Page: w.base(m.Name + " · issues"), | ||
| 114 | Name: m.Name, | ||
| 115 | Issues: rows, | ||
| 116 | Labels: labels, | ||
| 117 | Filter: filter, | ||
| 118 | Total: total, | ||
| 119 | Open: open, | ||
| 120 | Filtered: filter.State != "" || filter.Label != "" || filter.Query != "", | ||
| 119 | Page: w.base(m.Name + " · issues"), | ||
| 120 | Name: m.Name, | ||
| 121 | Issues: rows, | ||
| 122 | Labels: labels, | ||
| 123 | Assignees: assignees, | ||
| 124 | Filter: filter, | ||
| 125 | Total: total, | ||
| 126 | Open: open, | ||
| 127 | Filtered: filter.State != "" || filter.Label != "" || filter.Assignee != "" || filter.Query != "", | ||
| 121 | 128 | }, 0) | |
| 122 | 129 | } | |
| 123 | 130 | ||