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.
Index the rest of the metadata branch
7f547ca3b8da218249120d3c910c73c9a1a1e52d · Keenan Nemetz · 10d ago · parent c9202f7f
Index the rest of the metadata branch The index held issues and their numbers. Everything else on the branch — change requests, their comments, approvals and closes, and release artifacts — was read from git on every page that needed it, and the repository header read the whole requests subtree just to print a count. Add the tables and derive them from the same commit: - requests, with the body preserved for search; - request_closes and request_approvals, each in its own table so a backlink and a current-approval count are queries rather than a walk of every request; - comments for both kinds of entity, with the anchor a review comment carries; - artifacts, the release pointers, with the size from the pointer. The refresh is still driven by the path delta between two commits: a changed issue or request replaces its own rows, its comments and its sets, and a deleted one removes them while keeping its `#42` number. Rebuild and the first read go through IndexRepo, which is the definition the incremental path has to reproduce; a rebuild now clears the derived tables first so a deleted repository leaves nothing behind. Schema version 4 drops the old rows on first open, which is the existing recovery path: the index is disposable and built from the branch.
forge/index/requests_test.go +294
| @@ -0,0 +1,294 @@ | |||
| 1 | package index | ||
| 2 | |||
| 3 | // The request half of the derived index, and the rule that the incremental path | ||
| 4 | // and the full rebuild agree: they are two ways of computing the same rows from | ||
| 5 | // the same branch. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "github.com/rodent-software/vole/core/git" | ||
| 13 | "github.com/rodent-software/vole/core/lfs" | ||
| 14 | "github.com/rodent-software/vole/metadata" | ||
| 15 | ) | ||
| 16 | |||
| 17 | const requestHead = "0123456789abcdef0123456789abcdef01234567" | ||
| 18 | |||
| 19 | func createIndexedRequest(t *testing.T, g *git.Repo, title string, at time.Time, closes []string) *metadata.Request { | ||
| 20 | t.Helper() | ||
| 21 | req, err := metadata.NewRequest(title, "why "+title, "keenan", "main", requestHead, at) | ||
| 22 | if err != nil { | ||
| 23 | t.Fatal(err) | ||
| 24 | } | ||
| 25 | req.Closes = closes | ||
| 26 | if err := metadata.CreateRequest(g, req, "keenan"); err != nil { | ||
| 27 | t.Fatal(err) | ||
| 28 | } | ||
| 29 | return req | ||
| 30 | } | ||
| 31 | |||
| 32 | func TestEnsureIndexesRequestsCommentsApprovalsAndCloses(t *testing.T) { | ||
| 33 | t.Parallel() | ||
| 34 | s, m, g := incrementalRepo(t) | ||
| 35 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 36 | iss := createIndexedIssue(t, g, "Fixed by the change", now, nil, nil) | ||
| 37 | req := createIndexedRequest(t, g, "Land the retry", now.Add(time.Minute), []string{iss.ID}) | ||
| 38 | if err := s.Ensure(m); err != nil { | ||
| 39 | t.Fatal(err) | ||
| 40 | } | ||
| 41 | |||
| 42 | rows, err := s.Requests(m.ID, RequestFilter{}) | ||
| 43 | if err != nil { | ||
| 44 | t.Fatal(err) | ||
| 45 | } | ||
| 46 | if len(rows) != 1 { | ||
| 47 | t.Fatalf("requests = %d, want 1", len(rows)) | ||
| 48 | } | ||
| 49 | if rows[0].ID != req.ID || rows[0].Title != "Land the retry" || rows[0].State != "open" { | ||
| 50 | t.Fatalf("row = %+v", rows[0]) | ||
| 51 | } | ||
| 52 | if rows[0].Number != 2 { | ||
| 53 | t.Fatalf("request number = %d, want 2 (the issue was #1)", rows[0].Number) | ||
| 54 | } | ||
| 55 | if rows[0].CommentCount != 0 { | ||
| 56 | t.Fatalf("comment count = %d, want 0", rows[0].CommentCount) | ||
| 57 | } | ||
| 58 | |||
| 59 | closes, err := s.Closes(m.ID, iss.ID) | ||
| 60 | if err != nil { | ||
| 61 | t.Fatal(err) | ||
| 62 | } | ||
| 63 | if len(closes) != 1 || closes[0] != req.ID { | ||
| 64 | t.Fatalf("closes(%s) = %v, want %s", iss.ID, closes, req.ID) | ||
| 65 | } | ||
| 66 | |||
| 67 | // A comment and an approval move the branch forward, so the next read is the | ||
| 68 | // incremental path rather than the full rebuild. | ||
| 69 | comment, err := metadata.NewRequestComment("looks good", "wilbur", now.Add(2*time.Minute)) | ||
| 70 | if err != nil { | ||
| 71 | t.Fatal(err) | ||
| 72 | } | ||
| 73 | if err := metadata.AddRequestComment(g, req.ID, comment, "wilbur"); err != nil { | ||
| 74 | t.Fatal(err) | ||
| 75 | } | ||
| 76 | if _, err := metadata.ApproveRequest(g, req.ID, "wilbur", "main", requestHead, now.Add(3*time.Minute), "wilbur"); err != nil { | ||
| 77 | t.Fatal(err) | ||
| 78 | } | ||
| 79 | if err := s.Ensure(m); err != nil { | ||
| 80 | t.Fatal(err) | ||
| 81 | } | ||
| 82 | |||
| 83 | rows, err = s.Requests(m.ID, RequestFilter{}) | ||
| 84 | if err != nil { | ||
| 85 | t.Fatal(err) | ||
| 86 | } | ||
| 87 | if len(rows) != 1 || rows[0].CommentCount != 1 || rows[0].Number != 2 { | ||
| 88 | t.Fatalf("after a comment: rows = %+v, want one row with 1 comment at #2", rows) | ||
| 89 | } | ||
| 90 | comments, err := s.Comments(m.ID, "request", req.ID) | ||
| 91 | if err != nil { | ||
| 92 | t.Fatal(err) | ||
| 93 | } | ||
| 94 | if len(comments) != 1 || strings.TrimSpace(comments[0].Body) != "looks good" || comments[0].Author != "wilbur" { | ||
| 95 | t.Fatalf("comments = %+v", comments) | ||
| 96 | } | ||
| 97 | approvals, err := s.Approvals(m.ID, []string{req.ID}) | ||
| 98 | if err != nil { | ||
| 99 | t.Fatal(err) | ||
| 100 | } | ||
| 101 | if len(approvals[req.ID]) != 1 { | ||
| 102 | t.Fatalf("approvals = %+v, want one", approvals) | ||
| 103 | } | ||
| 104 | if a := approvals[req.ID][0]; a.Head != requestHead || a.Base != "main" || a.Principal != "wilbur" { | ||
| 105 | t.Fatalf("approval = %+v", a) | ||
| 106 | } | ||
| 107 | |||
| 108 | // Closing is an edit: the row changes state, and the number stays. | ||
| 109 | if _, err := metadata.SetRequestState(g, req.ID, "closed", "keenan"); err != nil { | ||
| 110 | t.Fatal(err) | ||
| 111 | } | ||
| 112 | if err := s.Ensure(m); err != nil { | ||
| 113 | t.Fatal(err) | ||
| 114 | } | ||
| 115 | rows, err = s.Requests(m.ID, RequestFilter{}) | ||
| 116 | if err != nil { | ||
| 117 | t.Fatal(err) | ||
| 118 | } | ||
| 119 | if len(rows) != 1 || rows[0].State != "closed" || rows[0].Number != 2 { | ||
| 120 | t.Fatalf("after closing: rows = %+v", rows) | ||
| 121 | } | ||
| 122 | if n, err := s.RequestCount(m.ID, "open"); err != nil || n != 0 { | ||
| 123 | t.Fatalf("open count = %d, %v; want 0", n, err) | ||
| 124 | } | ||
| 125 | if n, err := s.RequestCount(m.ID, "closed"); err != nil || n != 1 { | ||
| 126 | t.Fatalf("closed count = %d, %v; want 1", n, err) | ||
| 127 | } | ||
| 128 | open, merged, closed, total, err := s.RequestCounts(m.ID) | ||
| 129 | if err != nil || open != 0 || merged != 0 || closed != 1 || total != 1 { | ||
| 130 | t.Fatalf("counts = %d/%d/%d/%d, %v; want 0/0/1/1", open, merged, closed, total, err) | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | func TestEnsureUpdatesOneRequestWithoutTouchingTheOther(t *testing.T) { | ||
| 135 | t.Parallel() | ||
| 136 | s, m, g := incrementalRepo(t) | ||
| 137 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 138 | first := createIndexedRequest(t, g, "First change", now, nil) | ||
| 139 | second := createIndexedRequest(t, g, "Second change", now.Add(time.Minute), nil) | ||
| 140 | if err := s.Ensure(m); err != nil { | ||
| 141 | t.Fatal(err) | ||
| 142 | } | ||
| 143 | |||
| 144 | comment, err := metadata.NewRequestComment("a note", "keenan", now.Add(2*time.Minute)) | ||
| 145 | if err != nil { | ||
| 146 | t.Fatal(err) | ||
| 147 | } | ||
| 148 | if err := metadata.AddRequestComment(g, first.ID, comment, "keenan"); err != nil { | ||
| 149 | t.Fatal(err) | ||
| 150 | } | ||
| 151 | if err := s.Ensure(m); err != nil { | ||
| 152 | t.Fatal(err) | ||
| 153 | } | ||
| 154 | |||
| 155 | rows, err := s.Requests(m.ID, RequestFilter{}) | ||
| 156 | if err != nil { | ||
| 157 | t.Fatal(err) | ||
| 158 | } | ||
| 159 | if len(rows) != 2 { | ||
| 160 | t.Fatalf("requests = %d, want 2", len(rows)) | ||
| 161 | } | ||
| 162 | numbers := map[string]int{} | ||
| 163 | for _, row := range rows { | ||
| 164 | numbers[row.ID] = row.Number | ||
| 165 | } | ||
| 166 | if numbers[first.ID] != 1 || numbers[second.ID] != 2 { | ||
| 167 | t.Fatalf("numbers = %v, want first #1 and second #2", numbers) | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | func TestEnsureRemovesADeletedRequestAndKeepsItsNumber(t *testing.T) { | ||
| 172 | t.Parallel() | ||
| 173 | s, m, g := incrementalRepo(t) | ||
| 174 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 175 | gone := createIndexedRequest(t, g, "Will be deleted", now, nil) | ||
| 176 | kept := createIndexedRequest(t, g, "Stays", now.Add(time.Minute), nil) | ||
| 177 | if err := s.Ensure(m); err != nil { | ||
| 178 | t.Fatal(err) | ||
| 179 | } | ||
| 180 | |||
| 181 | prefix := metadata.RequestsDir + "/" + gone.ID + "/" | ||
| 182 | if _, err := git.CommitFiles(g, metadata.Ref, func(files map[string][]byte) error { | ||
| 183 | for path := range files { | ||
| 184 | if strings.HasPrefix(path, prefix) { | ||
| 185 | delete(files, path) | ||
| 186 | } | ||
| 187 | } | ||
| 188 | return nil | ||
| 189 | }, "delete the request", "keenan"); err != nil { | ||
| 190 | t.Fatal(err) | ||
| 191 | } | ||
| 192 | if err := s.Ensure(m); err != nil { | ||
| 193 | t.Fatal(err) | ||
| 194 | } | ||
| 195 | |||
| 196 | rows, err := s.Requests(m.ID, RequestFilter{}) | ||
| 197 | if err != nil { | ||
| 198 | t.Fatal(err) | ||
| 199 | } | ||
| 200 | if len(rows) != 1 || rows[0].ID != kept.ID || rows[0].Number != 2 { | ||
| 201 | t.Fatalf("rows = %+v, want only %s at #2", rows, kept.ID) | ||
| 202 | } | ||
| 203 | approvals, err := s.Approvals(m.ID, []string{gone.ID}) | ||
| 204 | if err != nil { | ||
| 205 | t.Fatal(err) | ||
| 206 | } | ||
| 207 | if len(approvals[gone.ID]) != 0 { | ||
| 208 | t.Fatalf("deleted request left approvals: %+v", approvals) | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | // TestIndexRepoMatchesTheIncrementalRows is the contract between the two paths: | ||
| 213 | // a rebuild from the branch and a series of deltas describe the same repository. | ||
| 214 | func TestIndexRepoMatchesTheIncrementalRows(t *testing.T) { | ||
| 215 | t.Parallel() | ||
| 216 | s, m, g := incrementalRepo(t) | ||
| 217 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 218 | iss := createIndexedIssue(t, g, "An issue", now, nil, nil) | ||
| 219 | req := createIndexedRequest(t, g, "A change", now.Add(time.Minute), []string{iss.ID}) | ||
| 220 | comment, err := metadata.NewRequestComment("a note", "wilbur", now.Add(2*time.Minute)) | ||
| 221 | if err != nil { | ||
| 222 | t.Fatal(err) | ||
| 223 | } | ||
| 224 | if err := metadata.AddRequestComment(g, req.ID, comment, "wilbur"); err != nil { | ||
| 225 | t.Fatal(err) | ||
| 226 | } | ||
| 227 | if err := s.Ensure(m); err != nil { | ||
| 228 | t.Fatal(err) | ||
| 229 | } | ||
| 230 | before, err := s.Requests(m.ID, RequestFilter{}) | ||
| 231 | if err != nil { | ||
| 232 | t.Fatal(err) | ||
| 233 | } | ||
| 234 | |||
| 235 | if err := s.IndexRepo(m); err != nil { | ||
| 236 | t.Fatal(err) | ||
| 237 | } | ||
| 238 | after, err := s.Requests(m.ID, RequestFilter{}) | ||
| 239 | if err != nil { | ||
| 240 | t.Fatal(err) | ||
| 241 | } | ||
| 242 | if len(before) != len(after) { | ||
| 243 | t.Fatalf("incremental rows = %d, rebuilt rows = %d", len(before), len(after)) | ||
| 244 | } | ||
| 245 | for i := range before { | ||
| 246 | if before[i] != after[i] { | ||
| 247 | t.Fatalf("row %d differs: incremental %+v, rebuilt %+v", i, before[i], after[i]) | ||
| 248 | } | ||
| 249 | } | ||
| 250 | comments, err := s.Comments(m.ID, "request", req.ID) | ||
| 251 | if err != nil { | ||
| 252 | t.Fatal(err) | ||
| 253 | } | ||
| 254 | if len(comments) != 1 || strings.TrimSpace(comments[0].Body) != "a note" { | ||
| 255 | t.Fatalf("rebuilt comments = %+v", comments) | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | // TestEnsureIndexesReleaseArtifacts covers the other thing the metadata branch | ||
| 260 | // holds: the release pointers. The index answers a release page's artifact list | ||
| 261 | // from the rows, so the tag's files must be complete and sorted. | ||
| 262 | func TestEnsureIndexesReleaseArtifacts(t *testing.T) { | ||
| 263 | t.Parallel() | ||
| 264 | s, m, g := incrementalRepo(t) | ||
| 265 | oid := strings.Repeat("ab", 32) | ||
| 266 | if _, err := git.CommitFiles(g, metadata.Ref, func(files map[string][]byte) error { | ||
| 267 | files[metadata.ReleasePath("v1.0.0", "demo-linux-amd64")] = lfs.Pointer{OID: oid, Size: 1234}.Encode() | ||
| 268 | files[metadata.ReleasePath("v1.0.0", "demo-darwin-arm64")] = lfs.Pointer{OID: oid, Size: 5678}.Encode() | ||
| 269 | return nil | ||
| 270 | }, "release: v1.0.0", "keenan"); err != nil { | ||
| 271 | t.Fatal(err) | ||
| 272 | } | ||
| 273 | if err := s.Ensure(m); err != nil { | ||
| 274 | t.Fatal(err) | ||
| 275 | } | ||
| 276 | list, err := s.Artifacts(m.ID, "v1.0.0") | ||
| 277 | if err != nil { | ||
| 278 | t.Fatal(err) | ||
| 279 | } | ||
| 280 | if len(list) != 2 { | ||
| 281 | t.Fatalf("artifacts = %+v, want 2", list) | ||
| 282 | } | ||
| 283 | if list[0].Name != "demo-darwin-arm64" || list[0].Size != 5678 || list[0].OID != oid { | ||
| 284 | t.Fatalf("first artifact = %+v", list[0]) | ||
| 285 | } | ||
| 286 | if list[1].Name != "demo-linux-amd64" || list[1].Size != 1234 { | ||
| 287 | t.Fatalf("second artifact = %+v", list[1]) | ||
| 288 | } | ||
| 289 | // A tag with nothing attached is empty, not an error and not another tag's | ||
| 290 | // files. | ||
| 291 | if list, err := s.Artifacts(m.ID, "v2.0.0"); err != nil || len(list) != 0 { | ||
| 292 | t.Fatalf("artifacts(v2.0.0) = %+v, %v", list, err) | ||
| 293 | } | ||
| 294 | } | ||