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.

Paginate the whole activity feed

c8cdb6accc47af06a3df2b8b5023c21134fdb812 · Keenan Nemetz · 10d ago · parent 4d938fe8

Paginate the whole activity feed

Both activity pages stopped at a fixed number of events: the repository
feed was truncated at 100, and the instance feed merged 100 per
repository and then took 200 before its pager. Anything older was
unreachable.

Now that issues, changes, comments and state changes are index rows, the
whole feed is assembled from the database and paginated:

- the repository feed and the instance feed keep the whole sorted,
  filtered list, and both show 50 events per page;
- the repository feed gains a pager (it had none), and the instance
  pager now reaches all history;
- the kind/actor/assignee filters travel through the page links, so
  paging never widens the feed;
- the repository pager swaps the `#activity-feed` fragment over HTMX like
  the chips and the filter form, and the page links are plain hrefs when
  JavaScript is not there;
- the feed reads comment summaries (parent, id, author, created) rather
  than every comment body it will not render.

The home rail still asks for a bounded feed, so it keeps its handful of
events. The feed is assembled in memory per request, so a page costs
O(history) instead of O(page); that is the accepted trade for now, and a
SQL event stream is the follow-up if a large history makes it visible.

server/activity_pages_test.go +75

@@ -0,0 +1,75 @@
1 package server_test
2
3 // The activity feed is complete and paginated: the pages show the whole history,
4 // one page at a time, with the filters travelling through the page links.
5
6 import (
7 "fmt"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/rodent-software/vole/metadata"
13 )
14
15 func TestActivityFeedIsPaginatedAcrossItsWholeHistory(t *testing.T) {
16 t.Parallel()
17 f := newFixture(t)
18 f.publish(f.repo)
19 m := mustRepo(t, f)
20 base := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC)
21 const total = 55
22 for i := 0; i < total; i++ {
23 iss, err := metadata.NewIssue(fmt.Sprintf("issue %02d", i), "body\n", "keenan", nil, nil, base.Add(time.Duration(i)*time.Minute))
24 if err != nil {
25 t.Fatal(err)
26 }
27 if err := metadata.CreateIssue(m.Git(), iss, "keenan"); err != nil {
28 t.Fatal(err)
29 }
30 }
31
32 count := func(body string) int { return strings.Count(body, `class="activity-event"`) }
33
34 // Repository feed: page one is the newest 50 of 55, page two is the rest.
35 code, body := f.get("/r/" + f.repo + "/activity")
36 if code != 200 {
37 t.Fatalf("activity page = %d", code)
38 }
39 if n := count(body); n != 50 {
40 t.Fatalf("page one shows %d events, want 50", n)
41 }
42 if !strings.Contains(body, "issue 54") || strings.Contains(body, "issue 00") {
43 t.Fatalf("page one is not the newest 50:\n%s", body)
44 }
45 if !strings.Contains(body, "Showing 1–50 of 55") || !strings.Contains(body, "page=2") {
46 t.Fatalf("page one has no pager:\n%s", body)
47 }
48
49 _, body = f.get("/r/" + f.repo + "/activity?page=2")
50 if n := count(body); n != 5 {
51 t.Fatalf("page two shows %d events, want 5", n)
52 }
53 if !strings.Contains(body, "issue 00") || strings.Contains(body, "issue 54") {
54 t.Fatalf("page two is not the oldest 5:\n%s", body)
55 }
56 if !strings.Contains(body, "Showing 51–55 of 55") {
57 t.Fatalf("page two does not describe itself:\n%s", body)
58 }
59
60 // The instance feed carries the same history.
61 _, body = f.get("/activity")
62 if n := count(body); n != 50 {
63 t.Fatalf("instance page one shows %d events, want 50", n)
64 }
65 _, body = f.get("/activity?page=2")
66 if n := count(body); n != 5 || !strings.Contains(body, "issue 00") {
67 t.Fatalf("instance page two shows %d events:\n%s", n, body)
68 }
69
70 // A filter travels with the page links, so paging never widens the feed.
71 _, body = f.get("/r/" + f.repo + "/activity?kind=issues&actor=keenan")
72 if !strings.Contains(body, "kind=issue") || !strings.Contains(body, "actor=keenan") || !strings.Contains(body, "page=2") {
73 t.Fatalf("filtered pager lost a filter:\n%s", body)
74 }
75 }