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 | } | ||