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/index/issues.go +47−10

@@ -50,13 +50,16 @@ func (s *Store) IndexIssues(m *repo.Meta) error {
50 50 if _, err := tx.Exec(`DELETE FROM issue_labels WHERE repo_id=?`, m.ID); err != nil {
51 51 return err
52 52 }
53 if _, err := tx.Exec(`DELETE FROM issue_assignees WHERE repo_id=?`, m.ID); err != nil {
54 return err
55 }
53 56 for _, iss := range list {
54 57 if _, err := tx.Exec(
55 `INSERT INTO issues(repo_id, id, title, state, author, created, labels, comments, body)
56 VALUES(?,?,?,?,?,?,?,?,?)`,
58 `INSERT INTO issues(repo_id, id, title, state, author, created, labels, assignees, comments, body)
59 VALUES(?,?,?,?,?,?,?,?,?,?)`,
57 60 m.ID, iss.ID, iss.Title, iss.State, iss.Author,
58 iss.Created.Unix(), strings.Join(iss.Labels, "\x1f"), len(iss.Comments),
59 iss.Description,
61 iss.Created.Unix(), strings.Join(iss.Labels, "\x1f"), strings.Join(iss.Assignees, "\x1f"),
62 len(iss.Comments), iss.Description,
60 63 ); err != nil {
61 64 return err
62 65 }
@@ -67,6 +70,13 @@ func (s *Store) IndexIssues(m *repo.Meta) error {
67 70 return err
68 71 }
69 72 }
73 for _, a := range iss.Assignees {
74 if _, err := tx.Exec(
75 `INSERT OR IGNORE INTO issue_assignees(repo_id, issue_id, assignee) VALUES(?,?,?)`,
76 m.ID, iss.ID, a); err != nil {
77 return err
78 }
79 }
70 80 }
71 81 if _, err := tx.Exec(`UPDATE repos SET issues_sha=? WHERE id=?`, sha, m.ID); err != nil {
72 82 return err
@@ -84,13 +94,14 @@ func unixTime(sec int64) time.Time {
84 94
85 95 // Filter narrows an issue list. Empty fields are ignored.
86 96 type Filter struct {
87 State string
88 Label string
89 Query string
97 State string
98 Label string
99 Assignee string
100 Query string
90 101 }
91 102
92 103 func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) {
93 q := `SELECT id, title, state, author, created, labels, comments
104 q := `SELECT id, title, state, author, created, labels, assignees, comments
94 105 FROM issues WHERE repo_id=?`
95 106 args := []any{repoID}
96 107 if f.State != "" && f.State != "all" {
@@ -101,6 +112,10 @@ func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) {
101 112 q += ` AND id IN (SELECT issue_id FROM issue_labels WHERE repo_id=? AND label=?)`
102 113 args = append(args, repoID, f.Label)
103 114 }
115 if f.Assignee != "" {
116 q += ` AND id IN (SELECT issue_id FROM issue_assignees WHERE repo_id=? AND assignee=?)`
117 args = append(args, repoID, f.Assignee)
118 }
104 119 if term := strings.TrimSpace(f.Query); term != "" {
105 120 q += ` AND (title LIKE ? OR body LIKE ?)`
106 121 like := "%" + term + "%"
@@ -117,14 +132,17 @@ func (s *Store) Issues(repoID string, f Filter) ([]IssueRow, error) {
117 132 for rows.Next() {
118 133 var r IssueRow
119 134 var created int64
120 var labels string
121 if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &labels, &r.Comments); err != nil {
135 var labels, assignees string
136 if err := rows.Scan(&r.ID, &r.Title, &r.State, &r.Author, &created, &labels, &assignees, &r.Comments); err != nil {
122 137 return nil, err
123 138 }
124 139 r.Created = unixTime(created)
125 140 if labels != "" {
126 141 r.Labels = strings.Split(labels, "\x1f")
127 142 }
143 if assignees != "" {
144 r.Assignees = strings.Split(assignees, "\x1f")
145 }
128 146 out = append(out, r)
129 147 }
130 148 return out, rows.Err()
@@ -170,6 +188,25 @@ func (s *Store) Labels(repoID string) ([]string, error) {
170 188 return out, rows.Err()
171 189 }
172 190
191 // Assignees returns the assignees in use for a repo, for the filter UI.
192 func (s *Store) Assignees(repoID string) ([]string, error) {
193 rows, err := s.db.Query(
194 `SELECT DISTINCT assignee FROM issue_assignees WHERE repo_id=? ORDER BY assignee`, repoID)
195 if err != nil {
196 return nil, err
197 }
198 defer rows.Close()
199 var out []string
200 for rows.Next() {
201 var a string
202 if err := rows.Scan(&a); err != nil {
203 return nil, err
204 }
205 out = append(out, a)
206 }
207 return out, rows.Err()
208 }
209
173 210 // IssueCount is used by the repo page.
174 211 func (s *Store) IssueCount(repoID string) (int, error) {
175 212 var n int