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