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.
Give every account a profile page and a directory of accounts
a6b42433c087ab0c936e34a1d05cf488db7a92d6 · Keenan Nemetz · 8d ago · parent 727df386
Give every account a profile page and a directory of accounts Clicking a name now opens /u/<name>: the issues and changes somebody opened, the reviews they gave, the comments they left, and the repositories that work is in. The numbers and the feed are grouped reads of the same event rows the activity feed pages through, so a profile cannot disagree with the feed, and every read is limited to the repositories the reader may open. A private repository's work is not a number an outsider gets to learn, so two readers can see different totals and the page says what its numbers cover. /u lists the roster. Account names are public on this instance; their activity is not. Both pages ask crawlers to leave them out of an index. @name in an issue, a comment, a change request, or a release note links to the account it addresses, using the mention scanner CI already evaluates: what reads as an address on the page is what a workflow sees. A name that is not an account stays text, and code samples are skipped. Git author names are not linked and commit counts are not shown. A commit author is a name somebody typed, not the credential that proves an account, and the index only keeps the top eight authors of each default branch anyway.
forge/index/profile_test.go +241
| @@ -0,0 +1,241 @@ | |||
| 1 | package index | ||
| 2 | |||
| 3 | // A profile's numbers are a grouped read of the same event rows the feed shows. | ||
| 4 | // These tests hold that: the count a profile displays is the number of rows the | ||
| 5 | // feed would page through, the counts follow the repository set the caller | ||
| 6 | // passes, and a release stays out because its actor is a Git tagger rather than | ||
| 7 | // a credential. | ||
| 8 | |||
| 9 | import ( | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "github.com/rodent-software/vole/core/git" | ||
| 14 | "github.com/rodent-software/vole/metadata" | ||
| 15 | ) | ||
| 16 | |||
| 17 | func TestActorActivityCountsTheFeedRows(t *testing.T) { | ||
| 18 | t.Parallel() | ||
| 19 | s, m, g := incrementalRepo(t) | ||
| 20 | other, og := newSecondRepo(t, s, "other") | ||
| 21 | now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) | ||
| 22 | |||
| 23 | // alice opens two issues in demo and one in other; adam opens one of demo's. | ||
| 24 | for _, spec := range []struct { | ||
| 25 | g *git.Repo | ||
| 26 | title string | ||
| 27 | author string | ||
| 28 | at time.Time | ||
| 29 | }{ | ||
| 30 | {g, "first", "alice", now}, | ||
| 31 | {g, "second", "alice", now.Add(time.Minute)}, | ||
| 32 | {og, "elsewhere", "alice", now.Add(2 * time.Minute)}, | ||
| 33 | {g, "by adam", "adam", now.Add(3 * time.Minute)}, | ||
| 34 | } { | ||
| 35 | iss, err := metadata.NewIssue(spec.title, "body", spec.author, nil, nil, spec.at) | ||
| 36 | if err != nil { | ||
| 37 | t.Fatal(err) | ||
| 38 | } | ||
| 39 | if err := metadata.CreateIssue(spec.g, iss, spec.author); err != nil { | ||
| 40 | t.Fatal(err) | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | // A comment from alice, a change from alice, and an approval by alice. | ||
| 45 | iss, err := metadata.NewIssue("commented", "body", "adam", nil, nil, now.Add(4*time.Minute)) | ||
| 46 | if err != nil { | ||
| 47 | t.Fatal(err) | ||
| 48 | } | ||
| 49 | if err := metadata.CreateIssue(g, iss, "adam"); err != nil { | ||
| 50 | t.Fatal(err) | ||
| 51 | } | ||
| 52 | comment, err := metadata.NewIssueComment("a note", "alice", now.Add(5*time.Minute)) | ||
| 53 | if err != nil { | ||
| 54 | t.Fatal(err) | ||
| 55 | } | ||
| 56 | if err := metadata.AddIssueComment(g, iss.ID, comment, "alice"); err != nil { | ||
| 57 | t.Fatal(err) | ||
| 58 | } | ||
| 59 | req, err := metadata.NewRequest("a change", "why", "adam", "main", requestHead, now.Add(6*time.Minute)) | ||
| 60 | if err != nil { | ||
| 61 | t.Fatal(err) | ||
| 62 | } | ||
| 63 | if err := metadata.CreateRequest(g, req, "adam"); err != nil { | ||
| 64 | t.Fatal(err) | ||
| 65 | } | ||
| 66 | // An account cannot approve its own change, so a review is always of somebody | ||
| 67 | // else's work. | ||
| 68 | if _, err := metadata.ApproveRequest(g, req.ID, "alice", "main", requestHead, now.Add(7*time.Minute), "alice"); err != nil { | ||
| 69 | t.Fatal(err) | ||
| 70 | } | ||
| 71 | |||
| 72 | if err := s.Ensure(m); err != nil { | ||
| 73 | t.Fatal(err) | ||
| 74 | } | ||
| 75 | if err := s.Ensure(other); err != nil { | ||
| 76 | t.Fatal(err) | ||
| 77 | } | ||
| 78 | |||
| 79 | counts, err := s.ActorActivity([]string{m.ID, other.ID}, "alice") | ||
| 80 | if err != nil { | ||
| 81 | t.Fatal(err) | ||
| 82 | } | ||
| 83 | got := map[string]int{} | ||
| 84 | for _, c := range counts { | ||
| 85 | got[c.RepoID+"/"+c.Kind+"/"+c.Type+"/"+c.State] += c.N | ||
| 86 | } | ||
| 87 | for key, want := range map[string]int{ | ||
| 88 | m.ID + "/issue/open/": 2, | ||
| 89 | other.ID + "/issue/open/": 1, | ||
| 90 | m.ID + "/issue/comment/": 1, | ||
| 91 | m.ID + "/change/approval/": 1, | ||
| 92 | } { | ||
| 93 | if got[key] != want { | ||
| 94 | t.Errorf("%s = %d, want %d (all counts: %v)", key, got[key], want, got) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | // adam opened the change; alice only reviewed it, so the profile must not | ||
| 98 | // credit her with it. | ||
| 99 | if n := got[m.ID+"/change/open/"]; n != 0 { | ||
| 100 | t.Errorf("alice is credited with adam's change: %d", n) | ||
| 101 | } | ||
| 102 | |||
| 103 | // The caller decides visibility: a profile counted over one readable | ||
| 104 | // repository cannot include the other. | ||
| 105 | counts, err = s.ActorActivity([]string{m.ID}, "alice") | ||
| 106 | if err != nil { | ||
| 107 | t.Fatal(err) | ||
| 108 | } | ||
| 109 | for _, c := range counts { | ||
| 110 | if c.RepoID != m.ID { | ||
| 111 | t.Fatalf("a one-repository read returned counts for %s", c.RepoID) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | func TestActorEventsFollowTheKindTypeAndVisibility(t *testing.T) { | ||
| 117 | t.Parallel() | ||
| 118 | s, m, g := incrementalRepo(t) | ||
| 119 | now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) | ||
| 120 | iss, err := metadata.NewIssue("an issue", "body", "alice", nil, nil, now) | ||
| 121 | if err != nil { | ||
| 122 | t.Fatal(err) | ||
| 123 | } | ||
| 124 | if err := metadata.CreateIssue(g, iss, "alice"); err != nil { | ||
| 125 | t.Fatal(err) | ||
| 126 | } | ||
| 127 | req, err := metadata.NewRequest("a change", "why", "adam", "main", requestHead, now.Add(time.Minute)) | ||
| 128 | if err != nil { | ||
| 129 | t.Fatal(err) | ||
| 130 | } | ||
| 131 | if err := metadata.CreateRequest(g, req, "adam"); err != nil { | ||
| 132 | t.Fatal(err) | ||
| 133 | } | ||
| 134 | if _, err := metadata.ApproveRequest(g, req.ID, "alice", "main", requestHead, now.Add(2*time.Minute), "alice"); err != nil { | ||
| 135 | t.Fatal(err) | ||
| 136 | } | ||
| 137 | // A tag whose tagger is alice: a Git identity, not a credential. | ||
| 138 | tip, err := g.RevParse(metadata.Ref) | ||
| 139 | if err != nil || tip == "" { | ||
| 140 | t.Fatalf("resolve metadata tip: %q, %v", tip, err) | ||
| 141 | } | ||
| 142 | if err := g.WriteAnnotatedTag("v1", tip, "notes\n", "alice", false); err != nil { | ||
| 143 | t.Fatal(err) | ||
| 144 | } | ||
| 145 | if err := s.Ensure(m); err != nil { | ||
| 146 | t.Fatal(err) | ||
| 147 | } | ||
| 148 | if err := s.RefreshTags(m); err != nil { | ||
| 149 | t.Fatal(err) | ||
| 150 | } | ||
| 151 | |||
| 152 | // The instance feed does show the release. | ||
| 153 | all, err := s.Events([]string{m.ID}, "", "", "", 100, 0) | ||
| 154 | if err != nil { | ||
| 155 | t.Fatal(err) | ||
| 156 | } | ||
| 157 | var releases int | ||
| 158 | for _, row := range all { | ||
| 159 | if row.Kind == "release" { | ||
| 160 | releases++ | ||
| 161 | } | ||
| 162 | } | ||
| 163 | if releases == 0 { | ||
| 164 | t.Fatal("the feed is missing the release event") | ||
| 165 | } | ||
| 166 | |||
| 167 | // A profile does not: a tagger is a name somebody typed into a tag. | ||
| 168 | events, err := s.ActorEvents([]string{m.ID}, "", "", "alice", 100, 0) | ||
| 169 | if err != nil { | ||
| 170 | t.Fatal(err) | ||
| 171 | } | ||
| 172 | if len(events) != 2 { | ||
| 173 | t.Fatalf("profile feed = %d events, want 2: %+v", len(events), events) | ||
| 174 | } | ||
| 175 | for _, row := range events { | ||
| 176 | if row.Kind == "release" { | ||
| 177 | t.Fatalf("profile feed includes a release: %+v", row) | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | reviews, err := s.ActorEvents([]string{m.ID}, "", "approval", "alice", 100, 0) | ||
| 182 | if err != nil { | ||
| 183 | t.Fatal(err) | ||
| 184 | } | ||
| 185 | if len(reviews) != 1 || reviews[0].Type != "approval" { | ||
| 186 | t.Fatalf("reviews = %+v, want one approval", reviews) | ||
| 187 | } | ||
| 188 | if n, err := s.ActorEventCount([]string{m.ID}, "", "approval", "alice"); err != nil || n != 1 { | ||
| 189 | t.Fatalf("review count = %d, %v", n, err) | ||
| 190 | } | ||
| 191 | |||
| 192 | // A repository the caller does not pass contributes nothing. | ||
| 193 | events, err = s.ActorEvents([]string{"somewhere-else"}, "", "", "alice", 100, 0) | ||
| 194 | if err != nil { | ||
| 195 | t.Fatal(err) | ||
| 196 | } | ||
| 197 | if len(events) != 0 { | ||
| 198 | t.Fatalf("an unreadable repository returned %d events", len(events)) | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | func TestActorSummaryAnswersForAPageOfNames(t *testing.T) { | ||
| 203 | t.Parallel() | ||
| 204 | s, m, g := incrementalRepo(t) | ||
| 205 | now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) | ||
| 206 | for _, spec := range []struct{ title, author string }{ | ||
| 207 | {"one", "alice"}, {"two", "alice"}, {"three", "adam"}, | ||
| 208 | } { | ||
| 209 | iss, err := metadata.NewIssue(spec.title, "body", spec.author, nil, nil, now) | ||
| 210 | if err != nil { | ||
| 211 | t.Fatal(err) | ||
| 212 | } | ||
| 213 | if err := metadata.CreateIssue(g, iss, spec.author); err != nil { | ||
| 214 | t.Fatal(err) | ||
| 215 | } | ||
| 216 | now = now.Add(time.Minute) | ||
| 217 | } | ||
| 218 | if err := s.Ensure(m); err != nil { | ||
| 219 | t.Fatal(err) | ||
| 220 | } | ||
| 221 | |||
| 222 | sums, err := s.ActorSummary([]string{m.ID}, []string{"alice", "adam", "nobody"}) | ||
| 223 | if err != nil { | ||
| 224 | t.Fatal(err) | ||
| 225 | } | ||
| 226 | if got := sums["alice"]; got.Repos != 1 || got.Issues != 2 || got.Changes != 0 || got.Reviews != 0 { | ||
| 227 | t.Fatalf("alice = %+v, want 1 repository and 2 issues", got) | ||
| 228 | } | ||
| 229 | if got := sums["adam"]; got.Issues != 1 { | ||
| 230 | t.Fatalf("adam = %+v, want 1 issue", got) | ||
| 231 | } | ||
| 232 | // A name with nothing visible is absent rather than zero, so the directory can | ||
| 233 | // say "nothing you can see yet" instead of "0". | ||
| 234 | if _, ok := sums["nobody"]; ok { | ||
| 235 | t.Fatalf("a name with no visible work was reported: %+v", sums["nobody"]) | ||
| 236 | } | ||
| 237 | // An empty repository set is not an error, it is an empty answer. | ||
| 238 | if sums, err = s.ActorSummary(nil, []string{"alice"}); err != nil || len(sums) != 0 { | ||
| 239 | t.Fatalf("empty repository set = %v, %v", sums, err) | ||
| 240 | } | ||
| 241 | } | ||