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/index.go +49−8

@@ -69,6 +69,9 @@ CREATE TABLE IF NOT EXISTS issues (
69 69 }
70 70 // Body text is indexed so search does not have to touch git per query.
71 71 _, _ = db.Exec(`ALTER TABLE issues ADD COLUMN body TEXT NOT NULL DEFAULT ''`)
72 // Assignees are denormalised onto the row the way labels are: the list page
73 // shows them without a join, and the filter table below is what queries use.
74 _, _ = db.Exec(`ALTER TABLE issues ADD COLUMN assignees TEXT NOT NULL DEFAULT ''`)
72 75 // The metadata format dropped its updated field (the commit carries the
73 76 // time), so the index has no column for it either. The index is derived: if
74 77 // this fails on some older file the table is rebuilt from the repos.
@@ -80,23 +83,61 @@ CREATE TABLE IF NOT EXISTS issue_labels (
80 83 label TEXT NOT NULL,
81 84 PRIMARY KEY (repo_id, issue_id, label)
82 85 );
83 CREATE INDEX IF NOT EXISTS issue_labels_lookup ON issue_labels(repo_id, label);`); err != nil {
86 CREATE INDEX IF NOT EXISTS issue_labels_lookup ON issue_labels(repo_id, label);
87 CREATE TABLE IF NOT EXISTS issue_assignees (
88 repo_id TEXT NOT NULL,
89 issue_id TEXT NOT NULL,
90 assignee TEXT NOT NULL,
91 PRIMARY KEY (repo_id, issue_id, assignee)
92 );
93 CREATE INDEX IF NOT EXISTS issue_assignees_lookup ON issue_assignees(repo_id, assignee);`); err != nil {
94 db.Close()
95 return nil, err
96 }
97 if err := s.migrate(); err != nil {
84 98 db.Close()
85 99 return nil, err
86 100 }
87 101 return s, nil
88 102 }
89 103
104 // schemaVersion is the shape the rows in this file were derived from. It is not
105 // a migration chain: the index is disposable, so a bump drops the issue rows and
106 // leaves every repo with an empty issues_sha, which makes the next read rebuild
107 // that repo from git. A stale row is worse than a rebuild, and a rebuild is the
108 // operation the index already has to survive.
109 const schemaVersion = 2
110
111 func (s *Store) migrate() error {
112 var v int
113 if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
114 return err
115 }
116 if v == schemaVersion {
117 return nil
118 }
119 if _, err := s.db.Exec(`
120 DELETE FROM issues;
121 DELETE FROM issue_labels;
122 DELETE FROM issue_assignees;
123 UPDATE repos SET issues_sha = '';`); err != nil {
124 return err
125 }
126 _, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
127 return err
128 }
129
90 130 // IssueRow is the indexed view of an issue: enough to list and link, not enough
91 131 // to render. The detail page reads the issue from git so bodies are never stale.
92 132 type IssueRow struct {
93 ID string
94 Title string
95 State string
96 Author string
97 Created time.Time
98 Labels []string
99 Comments int
133 ID string
134 Title string
135 State string
136 Author string
137 Created time.Time
138 Labels []string
139 Assignees []string
140 Comments int
100 141 }
101 142
102 143 func (s *Store) Close() error { return s.db.Close() }