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