Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }