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.go +17−1
| @@ -57,6 +57,11 @@ var ( | |||
| 57 | 57 | ulidRE = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`) | |
| 58 | 58 | pathRE = regexp.MustCompile(`^issues/([0-9A-HJKMNP-TV-Z]{26})(?:/(issue\.md)|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`) | |
| 59 | 59 | labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) | |
| 60 | // assigneeRE is the roster's account-name rule, not the label rule: an | ||
| 61 | // assignee is a person, and a value that could never be an account name is a | ||
| 62 | // typo rather than a new kind of label. Kept in step with | ||
| 63 | // identity.ValidateName by TestAssigneeRuleMatchesAccountNames. | ||
| 64 | assigneeRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`) | ||
| 60 | 65 | ) | |
| 61 | 66 | ||
| 62 | 67 | type Issue struct { | |
| @@ -237,9 +242,20 @@ func applyIssue(iss *Issue, raw, path string, problems *[]string) { | |||
| 237 | 242 | } | |
| 238 | 243 | iss.Labels = append(iss.Labels, l) | |
| 239 | 244 | } | |
| 240 | iss.Assignees = m.Assignees | ||
| 245 | // An assignee names an account, so a value that could never be one is | ||
| 246 | // reported here rather than rendering as a filter link to nothing. Stored as | ||
| 247 | // written: names are never reissued, so an assignment keeps resolving to the | ||
| 248 | // same person after a rename, exactly as the author field does. | ||
| 249 | for _, a := range m.Assignees { | ||
| 250 | if !assigneeRE.MatchString(a) { | ||
| 251 | *problems = append(*problems, fmt.Sprintf("%s: invalid assignee %q (an account name: lowercase letters, digits, dash, underscore)", path, a)) | ||
| 252 | continue | ||
| 253 | } | ||
| 254 | iss.Assignees = append(iss.Assignees, a) | ||
| 255 | } | ||
| 241 | 256 | iss.Description = body | |
| 242 | 257 | sort.Strings(iss.Labels) | |
| 258 | sort.Strings(iss.Assignees) | ||
| 243 | 259 | } | |
| 244 | 260 | ||
| 245 | 261 | // parseComment reads the frontmatter block a comment carries. Comments share the | |