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/write.go +19−2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package issues | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | "errors" | ||
| 4 | 5 | "fmt" | |
| 5 | 6 | "sort" | |
| 6 | 7 | "strings" | |
| @@ -10,8 +11,13 @@ import ( | |||
| 10 | 11 | "github.com/rodent-software/vole/internal/gitx" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 14 | // ErrNoChange is returned by Update when a mutator decides the entity already | ||
| 15 | // says what it would write. The branch does not move: an edit that changes | ||
| 16 | // nothing is not a commit. | ||
| 17 | var ErrNoChange = errors.New("no change") | ||
| 18 | |||
| 13 | 19 | // New fills in the fields a caller should not have to get right. | |
| 14 | func New(title, body, author string, labels []string, now time.Time) (*Issue, error) { | ||
| 20 | func New(title, body, author string, labels, assignees []string, now time.Time) (*Issue, error) { | ||
| 15 | 21 | id, err := NewULID(now) | |
| 16 | 22 | if err != nil { | |
| 17 | 23 | return nil, err | |
| @@ -23,6 +29,7 @@ func New(title, body, author string, labels []string, now time.Time) (*Issue, er | |||
| 23 | 29 | Author: strings.TrimSpace(author), | |
| 24 | 30 | Created: now.UTC(), | |
| 25 | 31 | Labels: labels, | |
| 32 | Assignees: assignees, | ||
| 26 | 33 | Description: body, | |
| 27 | 34 | }, nil | |
| 28 | 35 | } | |
| @@ -73,6 +80,9 @@ func Update(g *gitx.Repo, id string, mutate func(*Issue) error, actor string) (* | |||
| 73 | 80 | return nil, fmt.Errorf("issues: no issue %s", id) | |
| 74 | 81 | } | |
| 75 | 82 | if err := mutate(target); err != nil { | |
| 83 | if errors.Is(err, ErrNoChange) { | ||
| 84 | return nil, ErrNoChange | ||
| 85 | } | ||
| 76 | 86 | return nil, err | |
| 77 | 87 | } | |
| 78 | 88 | if err := validateForWrite(target); err != nil { | |
| @@ -152,7 +162,9 @@ func renderIssue(iss *Issue) ([]byte, error) { | |||
| 152 | 162 | labels := append([]string(nil), iss.Labels...) | |
| 153 | 163 | sort.Strings(labels) | |
| 154 | 164 | d.List("labels", labels) | |
| 155 | d.List("assignees", iss.Assignees) | ||
| 165 | assignees := append([]string(nil), iss.Assignees...) | ||
| 166 | sort.Strings(assignees) | ||
| 167 | d.List("assignees", assignees) | ||
| 156 | 168 | return d.Bytes(strings.TrimRight(iss.Description, "\n")) | |
| 157 | 169 | } | |
| 158 | 170 | ||
| @@ -177,6 +189,11 @@ func validateForWrite(iss *Issue) error { | |||
| 177 | 189 | problems = append(problems, fmt.Sprintf("invalid label %q", l)) | |
| 178 | 190 | } | |
| 179 | 191 | } | |
| 192 | for _, a := range iss.Assignees { | ||
| 193 | if !assigneeRE.MatchString(a) { | ||
| 194 | problems = append(problems, fmt.Sprintf("invalid assignee %q (an account name: lowercase letters, digits, dash, underscore)", a)) | ||
| 195 | } | ||
| 196 | } | ||
| 180 | 197 | if len(problems) > 0 { | |
| 181 | 198 | return &ValidationError{Problems: problems} | |
| 182 | 199 | } | |