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.
Materialise the activity feed in an events table
bca9ec24cbbb938f2d349d5f06e2c1f4fca06fcb · Keenan Nemetz · 9d ago · parent 73a732b1
Materialise the activity feed in an events table The previous commit read the feed as a UNION over the source tables. It stopped materialising events in Go, but SQLite still scanned and sorted the union to find a page, so a page cost the history. This replaces it with the design the feed actually wants: an `events` table with one row per event — issue and change opened, comment, approval, state change, release — carrying the time, actor, title and assignees the feed renders. Rows are indexed by (repository, time, id), so a repository page is an index range read; the instance page walks the global time index and filters by repository, and neither sorts history. An unfiltered page total is a stored per-repository count. Maintenance is part of the refresh that already exists: - an entity's events are replaced when the entity is reloaded, which is how a title or assignee edit reaches old events; - state changes are rebuilt into events for the entities the commits name, which also catches a close-and-reopen that leaves the tree where it started and an endpoint diff cannot see; - releases are refreshed from the tags after every push (post-receive) and after a release written in the browser, because a tag is a ref and no metadata commit announces it; - the stored count moves with the rows a refresh adds or removes. Measured with 100,000 events across 50 repositories: a repository page takes 0.3-0.5 ms (page 1 or page 20), an instance page 0.5-0.9 ms, and an unfiltered total 0.1 ms. A filtered total is the one query that scans, at 12 ms for 100,000 events. On the load harness, instance activity rises from 222 to 482 req/s and p50 from 18 ms to 8 ms; activity from 577 to 762 req/s. The repository feed no longer reads tags per request, and nothing reads the metadata branch to render a page.
server/activity_release_test.go +54
| @@ -0,0 +1,54 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // A release is a tag, so its feed event is not announced by a metadata commit. | ||
| 4 | // Both paths that create a tag must refresh the feed: a push (post-receive) and | ||
| 5 | // the browser's release form (which writes the tag on the server directly). | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "net/url" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func TestActivityShowsAReleasePushedAsATag(t *testing.T) { | ||
| 14 | t.Parallel() | ||
| 15 | f := newFixture(t) | ||
| 16 | f.publish(f.repo) | ||
| 17 | work := workRepo(t, map[string]string{"README.md": "# release\n"}) | ||
| 18 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 19 | t.Fatalf("push main: %s", errs) | ||
| 20 | } | ||
| 21 | runGit(t, work, "tag", "-a", "v1.0.0", "-m", "first release\n") | ||
| 22 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "v1.0.0"); err != nil { | ||
| 23 | t.Fatalf("push tag: %s", errs) | ||
| 24 | } | ||
| 25 | |||
| 26 | _, body := f.get("/r/" + f.repo + "/activity") | ||
| 27 | if !strings.Contains(body, "released") || !strings.Contains(body, "v1.0.0") { | ||
| 28 | t.Fatalf("the pushed tag is not in the activity feed:\n%s", body) | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | func TestActivityShowsAReleaseCreatedInTheBrowser(t *testing.T) { | ||
| 33 | t.Parallel() | ||
| 34 | f := newFixture(t) | ||
| 35 | f.publish(f.repo) | ||
| 36 | work := workRepo(t, map[string]string{"README.md": "# release\n"}) | ||
| 37 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 38 | t.Fatalf("push main: %s", errs) | ||
| 39 | } | ||
| 40 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 41 | resp, body := f.browserPost(t, "/r/"+f.repo+"/releases", cookie, url.Values{ | ||
| 42 | "csrf": {sess.CSRF}, "tag": {"v2.0.0"}, "target": {"main"}, | ||
| 43 | "notes": {"second release\n"}, | ||
| 44 | }) | ||
| 45 | if resp.StatusCode != 303 { | ||
| 46 | t.Fatalf("create release: %d\n%s", resp.StatusCode, body) | ||
| 47 | } | ||
| 48 | // The write refreshes the release events before it answers, so the feed is | ||
| 49 | // current on the next read. | ||
| 50 | _, body = f.get("/r/" + f.repo + "/activity") | ||
| 51 | if !strings.Contains(body, "released") || !strings.Contains(body, "v2.0.0") { | ||
| 52 | t.Fatalf("the browser release is not in the activity feed:\n%s", body) | ||
| 53 | } | ||
| 54 | } | ||