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.

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 }