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/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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 11.0 KiB
1package 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
7import (
8 "strings"
9 "testing"
10 "time"
11
12 "rodent.software/vole/core/git"
13 "rodent.software/vole/core/lfs"
14 "rodent.software/vole/metadata"
15)
16
17const requestHead = "0123456789abcdef0123456789abcdef01234567"
18
19func 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
32func 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 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 }
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
141func 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
178func 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
188 prefix := metadata.ChangesDir + "/" + gone.ID + "/"
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.
221func 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.
269func 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}
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.
306func 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}
352