vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
7f547ca3
Keenan Nemetz
10d ago
|
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 | ||
|
d37d301f
Keenan Nemetz
17h ago
|
12 | "rodent.software/vole/core/git" |
| 13 | "rodent.software/vole/core/lfs" | |
| 14 | "rodent.software/vole/metadata" | |
|
7f547ca3
Keenan Nemetz
10d ago
|
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 | } | |
|
fd9aa8ed
Keenan Nemetz
1d ago
|
66 | byID, err := s.RequestsByID(m.ID, closes) |
| 67 | if err != nil { | |
| 68 | t.Fatal(err) | |
| 69 | } | |
| 70 | if row, ok := byID[req.ID]; !ok || row.Title != "Land the retry" || row.State != "open" || row.Number != 2 { | |
| 71 | t.Fatalf("RequestsByID(%v) = %+v", closes, byID) | |
| 72 | } | |
|
7f547ca3
Keenan Nemetz
10d ago
|
73 | |
| 74 | // A comment and an approval move the branch forward, so the next read is the | |
| 75 | // incremental path rather than the full rebuild. | |
| 76 | comment, err := metadata.NewRequestComment("looks good", "wilbur", now.Add(2*time.Minute)) | |
| 77 | if err != nil { | |
| 78 | t.Fatal(err) | |
| 79 | } | |
| 80 | if err := metadata.AddRequestComment(g, req.ID, comment, "wilbur"); err != nil { | |
| 81 | t.Fatal(err) | |
| 82 | } | |
| 83 | if _, err := metadata.ApproveRequest(g, req.ID, "wilbur", "main", requestHead, now.Add(3*time.Minute), "wilbur"); err != nil { | |
| 84 | t.Fatal(err) | |
| 85 | } | |
| 86 | if err := s.Ensure(m); err != nil { | |
| 87 | t.Fatal(err) | |
| 88 | } | |
| 89 | ||
| 90 | rows, err = s.Requests(m.ID, RequestFilter{}) | |
| 91 | if err != nil { | |
| 92 | t.Fatal(err) | |
| 93 | } | |
| 94 | if len(rows) != 1 || rows[0].CommentCount != 1 || rows[0].Number != 2 { | |
| 95 | t.Fatalf("after a comment: rows = %+v, want one row with 1 comment at #2", rows) | |
| 96 | } | |
| 97 | comments, err := s.Comments(m.ID, "request", req.ID) | |
| 98 | if err != nil { | |
| 99 | t.Fatal(err) | |
| 100 | } | |
| 101 | if len(comments) != 1 || strings.TrimSpace(comments[0].Body) != "looks good" || comments[0].Author != "wilbur" { | |
| 102 | t.Fatalf("comments = %+v", comments) | |
| 103 | } | |
| 104 | approvals, err := s.Approvals(m.ID, []string{req.ID}) | |
| 105 | if err != nil { | |
| 106 | t.Fatal(err) | |
| 107 | } | |
| 108 | if len(approvals[req.ID]) != 1 { | |
| 109 | t.Fatalf("approvals = %+v, want one", approvals) | |
| 110 | } | |
| 111 | if a := approvals[req.ID][0]; a.Head != requestHead || a.Base != "main" || a.Principal != "wilbur" { | |
| 112 | t.Fatalf("approval = %+v", a) | |
| 113 | } | |
| 114 | ||
| 115 | // Closing is an edit: the row changes state, and the number stays. | |
| 116 | if _, err := metadata.SetRequestState(g, req.ID, "closed", "keenan"); err != nil { | |
| 117 | t.Fatal(err) | |
| 118 | } | |
| 119 | if err := s.Ensure(m); err != nil { | |
| 120 | t.Fatal(err) | |
| 121 | } | |
| 122 | rows, err = s.Requests(m.ID, RequestFilter{}) | |
| 123 | if err != nil { | |
| 124 | t.Fatal(err) | |
| 125 | } | |
| 126 | if len(rows) != 1 || rows[0].State != "closed" || rows[0].Number != 2 { | |
| 127 | t.Fatalf("after closing: rows = %+v", rows) | |
| 128 | } | |
| 129 | if n, err := s.RequestCount(m.ID, "open"); err != nil || n != 0 { | |
| 130 | t.Fatalf("open count = %d, %v; want 0", n, err) | |
| 131 | } | |
| 132 | if n, err := s.RequestCount(m.ID, "closed"); err != nil || n != 1 { | |
| 133 | t.Fatalf("closed count = %d, %v; want 1", n, err) | |
| 134 | } | |
| 135 | open, merged, closed, total, err := s.RequestCounts(m.ID) | |
| 136 | if err != nil || open != 0 || merged != 0 || closed != 1 || total != 1 { | |
| 137 | t.Fatalf("counts = %d/%d/%d/%d, %v; want 0/0/1/1", open, merged, closed, total, err) | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | func TestEnsureUpdatesOneRequestWithoutTouchingTheOther(t *testing.T) { | |
| 142 | t.Parallel() | |
| 143 | s, m, g := incrementalRepo(t) | |
| 144 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | |
| 145 | first := createIndexedRequest(t, g, "First change", now, nil) | |
| 146 | second := createIndexedRequest(t, g, "Second change", now.Add(time.Minute), nil) | |
| 147 | if err := s.Ensure(m); err != nil { | |
| 148 | t.Fatal(err) | |
| 149 | } | |
| 150 | ||
| 151 | comment, err := metadata.NewRequestComment("a note", "keenan", now.Add(2*time.Minute)) | |
| 152 | if err != nil { | |
| 153 | t.Fatal(err) | |
| 154 | } | |
| 155 | if err := metadata.AddRequestComment(g, first.ID, comment, "keenan"); err != nil { | |
| 156 | t.Fatal(err) | |
| 157 | } | |
| 158 | if err := s.Ensure(m); err != nil { | |
| 159 | t.Fatal(err) | |
| 160 | } | |
| 161 | ||
| 162 | rows, err := s.Requests(m.ID, RequestFilter{}) | |
| 163 | if err != nil { | |
| 164 | t.Fatal(err) | |
| 165 | } | |
| 166 | if len(rows) != 2 { | |
| 167 | t.Fatalf("requests = %d, want 2", len(rows)) | |
| 168 | } | |
| 169 | numbers := map[string]int{} | |
| 170 | for _, row := range rows { | |
| 171 | numbers[row.ID] = row.Number | |
| 172 | } | |
| 173 | if numbers[first.ID] != 1 || numbers[second.ID] != 2 { | |
| 174 | t.Fatalf("numbers = %v, want first #1 and second #2", numbers) | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | func TestEnsureRemovesADeletedRequestAndKeepsItsNumber(t *testing.T) { | |
| 179 | t.Parallel() | |
| 180 | s, m, g := incrementalRepo(t) | |
| 181 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | |
| 182 | gone := createIndexedRequest(t, g, "Will be deleted", now, nil) | |
| 183 | kept := createIndexedRequest(t, g, "Stays", now.Add(time.Minute), nil) | |
| 184 | if err := s.Ensure(m); err != nil { | |
| 185 | t.Fatal(err) | |
| 186 | } | |
| 187 | ||
|
b492b557
Keenan Nemetz
3d ago
|
188 | prefix := metadata.ChangesDir + "/" + gone.ID + "/" |
|
7f547ca3
Keenan Nemetz
10d ago
|
189 | if _, err := git.CommitFiles(g, metadata.Ref, func(files map[string][]byte) error { |
| 190 | for path := range files { | |
| 191 | if strings.HasPrefix(path, prefix) { | |
| 192 | delete(files, path) | |
| 193 | } | |
| 194 | } | |
| 195 | return nil | |
| 196 | }, "delete the request", "keenan"); err != nil { | |
| 197 | t.Fatal(err) | |
| 198 | } | |
| 199 | if err := s.Ensure(m); err != nil { | |
| 200 | t.Fatal(err) | |
| 201 | } | |
| 202 | ||
| 203 | rows, err := s.Requests(m.ID, RequestFilter{}) | |
| 204 | if err != nil { | |
| 205 | t.Fatal(err) | |
| 206 | } | |
| 207 | if len(rows) != 1 || rows[0].ID != kept.ID || rows[0].Number != 2 { | |
| 208 | t.Fatalf("rows = %+v, want only %s at #2", rows, kept.ID) | |
| 209 | } | |
| 210 | approvals, err := s.Approvals(m.ID, []string{gone.ID}) | |
| 211 | if err != nil { | |
| 212 | t.Fatal(err) | |
| 213 | } | |
| 214 | if len(approvals[gone.ID]) != 0 { | |
| 215 | t.Fatalf("deleted request left approvals: %+v", approvals) | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | // TestIndexRepoMatchesTheIncrementalRows is the contract between the two paths: | |
| 220 | // a rebuild from the branch and a series of deltas describe the same repository. | |
| 221 | func TestIndexRepoMatchesTheIncrementalRows(t *testing.T) { | |
| 222 | t.Parallel() | |
| 223 | s, m, g := incrementalRepo(t) | |
| 224 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | |
| 225 | iss := createIndexedIssue(t, g, "An issue", now, nil, nil) | |
| 226 | req := createIndexedRequest(t, g, "A change", now.Add(time.Minute), []string{iss.ID}) | |
| 227 | comment, err := metadata.NewRequestComment("a note", "wilbur", now.Add(2*time.Minute)) | |
| 228 | if err != nil { | |
| 229 | t.Fatal(err) | |
| 230 | } | |
| 231 | if err := metadata.AddRequestComment(g, req.ID, comment, "wilbur"); err != nil { | |
| 232 | t.Fatal(err) | |
| 233 | } | |
| 234 | if err := s.Ensure(m); err != nil { | |
| 235 | t.Fatal(err) | |
| 236 | } | |
| 237 | before, err := s.Requests(m.ID, RequestFilter{}) | |
| 238 | if err != nil { | |
| 239 | t.Fatal(err) | |
| 240 | } | |
| 241 | ||
| 242 | if err := s.IndexRepo(m); err != nil { | |
| 243 | t.Fatal(err) | |
| 244 | } | |
| 245 | after, err := s.Requests(m.ID, RequestFilter{}) | |
| 246 | if err != nil { | |
| 247 | t.Fatal(err) | |
| 248 | } | |
| 249 | if len(before) != len(after) { | |
| 250 | t.Fatalf("incremental rows = %d, rebuilt rows = %d", len(before), len(after)) | |
| 251 | } | |
| 252 | for i := range before { | |
| 253 | if before[i] != after[i] { | |
| 254 | t.Fatalf("row %d differs: incremental %+v, rebuilt %+v", i, before[i], after[i]) | |
| 255 | } | |
| 256 | } | |
| 257 | comments, err := s.Comments(m.ID, "request", req.ID) | |
| 258 | if err != nil { | |
| 259 | t.Fatal(err) | |
| 260 | } | |
| 261 | if len(comments) != 1 || strings.TrimSpace(comments[0].Body) != "a note" { | |
| 262 | t.Fatalf("rebuilt comments = %+v", comments) | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | // TestEnsureIndexesReleaseArtifacts covers the other thing the metadata branch | |
| 267 | // holds: the release pointers. The index answers a release page's artifact list | |
| 268 | // from the rows, so the tag's files must be complete and sorted. | |
| 269 | func TestEnsureIndexesReleaseArtifacts(t *testing.T) { | |
| 270 | t.Parallel() | |
| 271 | s, m, g := incrementalRepo(t) | |
| 272 | oid := strings.Repeat("ab", 32) | |
| 273 | if _, err := git.CommitFiles(g, metadata.Ref, func(files map[string][]byte) error { | |
| 274 | files[metadata.ReleasePath("v1.0.0", "demo-linux-amd64")] = lfs.Pointer{OID: oid, Size: 1234}.Encode() | |
| 275 | files[metadata.ReleasePath("v1.0.0", "demo-darwin-arm64")] = lfs.Pointer{OID: oid, Size: 5678}.Encode() | |
| 276 | return nil | |
| 277 | }, "release: v1.0.0", "keenan"); err != nil { | |
| 278 | t.Fatal(err) | |
| 279 | } | |
| 280 | if err := s.Ensure(m); err != nil { | |
| 281 | t.Fatal(err) | |
| 282 | } | |
| 283 | list, err := s.Artifacts(m.ID, "v1.0.0") | |
| 284 | if err != nil { | |
| 285 | t.Fatal(err) | |
| 286 | } | |
| 287 | if len(list) != 2 { | |
| 288 | t.Fatalf("artifacts = %+v, want 2", list) | |
| 289 | } | |
| 290 | if list[0].Name != "demo-darwin-arm64" || list[0].Size != 5678 || list[0].OID != oid { | |
| 291 | t.Fatalf("first artifact = %+v", list[0]) | |
| 292 | } | |
| 293 | if list[1].Name != "demo-linux-amd64" || list[1].Size != 1234 { | |
| 294 | t.Fatalf("second artifact = %+v", list[1]) | |
| 295 | } | |
| 296 | // A tag with nothing attached is empty, not an error and not another tag's | |
| 297 | // files. | |
| 298 | if list, err := s.Artifacts(m.ID, "v2.0.0"); err != nil || len(list) != 0 { | |
| 299 | t.Fatalf("artifacts(v2.0.0) = %+v, %v", list, err) | |
| 300 | } | |
| 301 | } | |
|
122cbe4f
Keenan Nemetz
7d ago
|
302 | |
| 303 | // A push asks whether any open request follows the branch it moved. The answer has | |
| 304 | // to be trustworthy or absent: a stale index must say "ask the branch" rather than | |
| 305 | // "nobody follows this", because a skipped sync is not repaired afterwards. | |
| 306 | func TestRequestsFollowingOnlyAnswersForTheBranchItIndexed(t *testing.T) { | |
| 307 | t.Parallel() | |
| 308 | s, m, g := incrementalRepo(t) | |
| 309 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | |
| 310 | followed := createIndexedRequest(t, g, "Follows feature", now, nil) | |
| 311 | if _, err := metadata.SetRequestHead(g, followed.ID, requestHead, "feature", "keenan"); err != nil { | |
| 312 | t.Fatal(err) | |
| 313 | } | |
| 314 | createIndexedRequest(t, g, "Pinned to a commit", now.Add(time.Minute), nil) | |
| 315 | if err := s.Ensure(m); err != nil { | |
| 316 | t.Fatal(err) | |
| 317 | } | |
| 318 | ||
| 319 | follows, ok, err := s.RequestsFollowing(m, "feature") | |
| 320 | if err != nil { | |
| 321 | t.Fatal(err) | |
| 322 | } | |
| 323 | if !follows || !ok { | |
| 324 | t.Fatalf("feature = follows %v, known %v; want both true", follows, ok) | |
| 325 | } | |
| 326 | follows, ok, err = s.RequestsFollowing(m, "other") | |
| 327 | if err != nil { | |
| 328 | t.Fatal(err) | |
| 329 | } | |
| 330 | if follows || !ok { | |
| 331 | t.Fatalf("other = follows %v, known %v; want a known no", follows, ok) | |
| 332 | } | |
| 333 | ||
| 334 | // A closed request stops following its branch. | |
| 335 | if _, err := metadata.SetRequestState(g, followed.ID, "closed", "keenan"); err != nil { | |
| 336 | t.Fatal(err) | |
| 337 | } | |
| 338 | if err := s.Ensure(m); err != nil { | |
| 339 | t.Fatal(err) | |
| 340 | } | |
| 341 | if follows, ok, err = s.RequestsFollowing(m, "feature"); err != nil || follows || !ok { | |
| 342 | t.Fatalf("closed request: follows %v, known %v, err %v", follows, ok, err) | |
| 343 | } | |
| 344 | ||
| 345 | // The branch moves and the index is behind: the question is no longer answerable, | |
| 346 | // so the caller has to look. | |
| 347 | createIndexedIssue(t, g, "Moved on", now.Add(2*time.Minute), nil, nil) | |
| 348 | if follows, ok, err = s.RequestsFollowing(m, "feature"); err != nil || ok { | |
| 349 | t.Fatalf("stale index answered: follows %v, known %v, err %v", follows, ok, err) | |
| 350 | } | |
| 351 | } |