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_test.go +89

@@ -0,0 +1,89 @@
1 package index
2
3 // The index is derived, which is what lets a schema change be a rebuild instead
4 // of a migration chain. These tests hold that promise to its two halves: a file
5 // written by an older build is emptied rather than misread, and the version it
6 // reports is the one this build writes.
7
8 import (
9 "path/filepath"
10 "testing"
11 )
12
13 func TestSchemaVersionDropsRowsFromAnOlderShape(t *testing.T) {
14 path := filepath.Join(t.TempDir(), "index.db")
15 s, err := Open(path)
16 if err != nil {
17 t.Fatal(err)
18 }
19 // Rows as an older build would have left them, with the file claiming the
20 // previous version — the state every existing instance is in on upgrade.
21 if _, err := s.db.Exec(`
22 INSERT INTO repos(id, name, created, issues_sha) VALUES('r1','demo',0,'deadbeef');
23 INSERT INTO issues(repo_id, id, title, state, author, created) VALUES('r1','i1','stale','open','keenan',0);
24 INSERT INTO issue_labels(repo_id, issue_id, label) VALUES('r1','i1','old');
25 INSERT INTO issue_assignees(repo_id, issue_id, assignee) VALUES('r1','i1','wilbur');
26 PRAGMA user_version = 1;`); err != nil {
27 t.Fatal(err)
28 }
29 if err := s.Close(); err != nil {
30 t.Fatal(err)
31 }
32
33 // Opening with this build clears the derived rows and leaves the repo with no
34 // indexed issues, so the next read rebuilds it from git instead of serving
35 // rows derived from a shape that no longer exists.
36 s2, err := Open(path)
37 if err != nil {
38 t.Fatal(err)
39 }
40 defer s2.Close()
41
42 for _, q := range []string{
43 `SELECT COUNT(*) FROM issues`,
44 `SELECT COUNT(*) FROM issue_labels`,
45 `SELECT COUNT(*) FROM issue_assignees`,
46 } {
47 var n int
48 if err := s2.db.QueryRow(q).Scan(&n); err != nil {
49 t.Fatalf("%s: %v", q, err)
50 }
51 if n != 0 {
52 t.Fatalf("%s = %d, want 0 after a version bump", q, n)
53 }
54 }
55 var sha string
56 if err := s2.db.QueryRow(`SELECT issues_sha FROM repos WHERE id='r1'`).Scan(&sha); err != nil {
57 t.Fatal(err)
58 }
59 if sha != "" {
60 t.Fatalf("issues_sha = %q, want empty so the repo is re-read from git", sha)
61 }
62 var v int
63 if err := s2.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
64 t.Fatal(err)
65 }
66 if v != schemaVersion {
67 t.Fatalf("user_version = %d, want %d", v, schemaVersion)
68 }
69
70 // A second open is a no-op: the fix is not re-applied every time.
71 if _, err := s2.db.Exec(`INSERT INTO issues(repo_id, id, title, state, author, created) VALUES('r1','i2','fresh','open','keenan',0)`); err != nil {
72 t.Fatal(err)
73 }
74 if err := s2.Close(); err != nil {
75 t.Fatal(err)
76 }
77 s3, err := Open(path)
78 if err != nil {
79 t.Fatal(err)
80 }
81 defer s3.Close()
82 var n int
83 if err := s3.db.QueryRow(`SELECT COUNT(*) FROM issues`).Scan(&n); err != nil {
84 t.Fatal(err)
85 }
86 if n != 1 {
87 t.Fatalf("reopening at the current version dropped rows: %d", n)
88 }
89 }