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.
Assemble an issue or change from the indexed rows
9d8dd1bc7f52d0fcdfcf286734a2c7735c1d051d · Keenan Nemetz · 10d ago · parent 6b8f84c4
Assemble an issue or change from the indexed rows The detail pages can read the same rows the lists do, as long as the refresh runs first (it does) and the assembled record is exactly what the branch says. Add Store.Issue and Store.Request, which put one entity back together from its row, its comments, and — for a change — its closes and approvals. Two orderings had to be stored rather than inferred: - request_closes keeps the file's position, because the branch lists the issues a change closes in the order the author wrote them, not in id order. - comments and approvals are read in the same id/principal order git's path listing gives, so a rebuilt thread matches the one read from git. The tests compare both readers field by field (reflect.DeepEqual) on an issue with labels, assignees, a reply and a comment, and on a change with two closes, an anchored comment and an approval. The schema version is bumped so existing rows are rebuilt with the new column.
forge/index/detail_test.go +118
| @@ -0,0 +1,118 @@ | |||
| 1 | package index | ||
| 2 | |||
| 3 | // The detail pages read one issue or change from the index instead of git. These | ||
| 4 | // tests hold the two readers to the same answer: the rows must assemble into | ||
| 5 | // exactly what metadata returns from the branch. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "reflect" | ||
| 9 | "testing" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "github.com/rodent-software/vole/metadata" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func TestIndexIssueMatchesGit(t *testing.T) { | ||
| 16 | t.Parallel() | ||
| 17 | s, m, g := incrementalRepo(t) | ||
| 18 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 19 | iss := createIndexedIssue(t, g, "Rendered from the index", now, | ||
| 20 | []string{"bug", "web"}, []string{"keenan", "wilbur"}) | ||
| 21 | first := addIssueComment(t, g, iss.ID, "first note", now.Add(time.Minute)) | ||
| 22 | reply, err := metadata.NewIssueComment("a reply", "wilbur", now.Add(2*time.Minute)) | ||
| 23 | if err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | reply.ReplyTo = first.ID | ||
| 27 | if err := metadata.AddIssueComment(g, iss.ID, reply, "wilbur"); err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | if err := s.Ensure(m); err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | |||
| 34 | fromIndex, err := s.Issue(m.ID, iss.ID) | ||
| 35 | if err != nil { | ||
| 36 | t.Fatal(err) | ||
| 37 | } | ||
| 38 | fromGit, found, err := metadata.LoadIssueAt(g, metadata.Ref, iss.ID) | ||
| 39 | if err != nil || !found { | ||
| 40 | t.Fatalf("git load: %v, found=%t", err, found) | ||
| 41 | } | ||
| 42 | if !reflect.DeepEqual(fromIndex, fromGit) { | ||
| 43 | t.Fatalf("index issue differs from git\nindex: %+v\ngit: %+v", fromIndex, fromGit) | ||
| 44 | } | ||
| 45 | if _, err := s.Issue(m.ID, "01M5JR2JK29KKMAA1T9MDNTPBZ"); err != ErrNotFound { | ||
| 46 | t.Fatalf("missing issue error = %v, want ErrNotFound", err) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | func TestIndexRequestMatchesGit(t *testing.T) { | ||
| 51 | t.Parallel() | ||
| 52 | s, m, g := incrementalRepo(t) | ||
| 53 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 54 | first := createIndexedIssue(t, g, "First issue", now, nil, nil) | ||
| 55 | second := createIndexedIssue(t, g, "Second issue", now.Add(time.Second), nil, nil) | ||
| 56 | req := createIndexedRequest(t, g, "A change", now.Add(time.Minute), []string{second.ID, first.ID}) | ||
| 57 | |||
| 58 | comment, err := metadata.NewRequestComment("review note", "wilbur", now.Add(2*time.Minute)) | ||
| 59 | if err != nil { | ||
| 60 | t.Fatal(err) | ||
| 61 | } | ||
| 62 | comment.Anchor = &metadata.Anchor{ | ||
| 63 | Path: "readme.md", Side: "new", Line: 3, Blob: requestHead, | ||
| 64 | } | ||
| 65 | if err := metadata.AddRequestComment(g, req.ID, comment, "wilbur"); err != nil { | ||
| 66 | t.Fatal(err) | ||
| 67 | } | ||
| 68 | if _, err := metadata.ApproveRequest(g, req.ID, "wilbur", "main", requestHead, now.Add(3*time.Minute), "wilbur"); err != nil { | ||
| 69 | t.Fatal(err) | ||
| 70 | } | ||
| 71 | if err := s.Ensure(m); err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | |||
| 75 | fromIndex, err := s.Request(m.ID, req.ID) | ||
| 76 | if err != nil { | ||
| 77 | t.Fatal(err) | ||
| 78 | } | ||
| 79 | fromGit, err := metadata.GetRequest(g, req.ID) | ||
| 80 | if err != nil { | ||
| 81 | t.Fatal(err) | ||
| 82 | } | ||
| 83 | if !reflect.DeepEqual(fromIndex, fromGit) { | ||
| 84 | t.Fatalf("index request differs from git\nindex: %+v\ngit: %+v", fromIndex, fromGit) | ||
| 85 | } | ||
| 86 | if _, err := s.Request(m.ID, "01M5JR2JK29KKMAA1T9MDNTPBZ"); err != ErrNotFound { | ||
| 87 | t.Fatalf("missing request error = %v, want ErrNotFound", err) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | // TestDetailRowsFollowTheBranch pins the freshness rule the pages rely on: a | ||
| 92 | // push that edits a body is visible in the assembled record after Ensure. | ||
| 93 | func TestDetailRowsFollowTheBranch(t *testing.T) { | ||
| 94 | t.Parallel() | ||
| 95 | s, m, g := incrementalRepo(t) | ||
| 96 | now := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | ||
| 97 | iss := createIndexedIssue(t, g, "Before the edit", now, nil, nil) | ||
| 98 | if err := s.Ensure(m); err != nil { | ||
| 99 | t.Fatal(err) | ||
| 100 | } | ||
| 101 | if _, err := metadata.UpdateIssue(g, iss.ID, func(iss *metadata.Issue) error { | ||
| 102 | iss.Title = "After the edit" | ||
| 103 | iss.Description = "a body written by a second push\n" | ||
| 104 | return nil | ||
| 105 | }, "keenan"); err != nil { | ||
| 106 | t.Fatal(err) | ||
| 107 | } | ||
| 108 | if err := s.Ensure(m); err != nil { | ||
| 109 | t.Fatal(err) | ||
| 110 | } | ||
| 111 | fromIndex, err := s.Issue(m.ID, iss.ID) | ||
| 112 | if err != nil { | ||
| 113 | t.Fatal(err) | ||
| 114 | } | ||
| 115 | if fromIndex.Title != "After the edit" || fromIndex.Description != "a body written by a second push\n" { | ||
| 116 | t.Fatalf("index issue is stale: %+v", fromIndex) | ||
| 117 | } | ||
| 118 | } | ||