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/templates.go +8−1
| @@ -271,6 +271,7 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde | |||
| 271 | 271 | <option value="closed"{{if eq .Filter.State "closed"}} selected{{end}}>closed</option> | |
| 272 | 272 | </select> | |
| 273 | 273 | <input type="hidden" name="label" value="{{.Filter.Label}}"> | |
| 274 | <input type="hidden" name="assignee" value="{{.Filter.Assignee}}"> | ||
| 274 | 275 | <button type="submit">Filter</button> | |
| 275 | 276 | {{if .Filtered}}<a href="/r/{{.Name}}/issues">clear</a>{{end}} | |
| 276 | 277 | </form> | |
| @@ -280,13 +281,18 @@ select{background:var(--card);color:var(--fg);border:1px solid var(--line);borde | |||
| 280 | 281 | {{range .Labels}}<a class="label" href="/r/{{$.Name}}/issues?label={{.}}">{{.}}</a>{{end}} | |
| 281 | 282 | </p> | |
| 282 | 283 | {{end}} | |
| 284 | {{if .Assignees}} | ||
| 285 | <p class="muted">Assignees: | ||
| 286 | {{range .Assignees}}<a class="label" href="/r/{{$.Name}}/issues?assignee={{.}}">@{{.}}</a>{{end}} | ||
| 287 | </p> | ||
| 288 | {{end}} | ||
| 283 | 289 | {{if .Issues}} | |
| 284 | 290 | <table> | |
| 285 | 291 | <tr><th>ID</th><th>Title</th><th>State</th><th>Author</th><th>Replies</th><th>Age</th></tr> | |
| 286 | 292 | {{range .Issues}} | |
| 287 | 293 | <tr> | |
| 288 | 294 | <td class="sha">{{short .ID}}</td> | |
| 289 | <td><a href="/r/{{$.Name}}/issues/{{.ID}}">{{.Title}}</a>{{range .Labels}}<span class="label">{{.}}</span>{{end}}</td> | ||
| 295 | <td><a href="/r/{{$.Name}}/issues/{{.ID}}">{{.Title}}</a>{{range .Labels}}<span class="label">{{.}}</span>{{end}}{{range .Assignees}} <a class="label" href="/r/{{$.Name}}/issues?assignee={{.}}">@{{.}}</a>{{end}}</td> | ||
| 290 | 296 | <td class="muted">{{.State}}</td> | |
| 291 | 297 | <td class="muted">{{.Author}}</td> | |
| 292 | 298 | <td class="muted">{{.Comments}}</td> | |
| @@ -310,6 +316,7 @@ labels, then the body as markdown.</p> | |||
| 310 | 316 | <p class="crumbs"> | |
| 311 | 317 | <span class="sha">{{.Issue.ID}}</span> · {{.Issue.State}} · opened by {{.Issue.Author}} {{since .Issue.Created.Unix}} | |
| 312 | 318 | {{if .Labels}}<span class="label">{{.Labels}}</span>{{end}} | |
| 319 | {{range .Issue.Assignees}}<a class="label" href="/r/{{$.Name}}/issues?assignee={{.}}">@{{.}}</a>{{end}} | ||
| 313 | 320 | </p> | |
| 314 | 321 | <article class="md">{{.Description}}</article> | |
| 315 | 322 | <h2>{{len .Comments}} comment(s)</h2> | |