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.

server/web/profile.go +261

@@ -0,0 +1,261 @@
1 package web
2
3 // One account's profile: what a person has opened, reviewed and commented on,
4 // and the repositories that work is in.
5 //
6 // The numbers and the feed come from the same rows. forge/index keeps one event
7 // per opening, comment, approval and state change, so a profile is a grouped read
8 // of the events table and the activity feed is a page of it: "twelve issues
9 // opened" cannot disagree with the twelve rows the feed shows, because they are
10 // the same rows.
11 //
12 // Every read is scoped to the repositories the viewer may open. Two people can
13 // therefore see different totals for the same account, which is the honest
14 // answer: a private repository's work is not a number an outsider gets to learn.
15 // Account names themselves are public on this instance, so an unknown name is the
16 // only 404.
17
18 import (
19 "net/http"
20 "net/url"
21 "sort"
22 "strconv"
23 "strings"
24 "time"
25
26 "github.com/rodent-software/vole/forge/index"
27 )
28
29 // profilePage is how many events one page of a profile shows.
30 const profilePage = 25
31
32 // profileFilter is how a ?type= narrows the feed. The page offers the four
33 // things a person does that the forge records: open issues, open changes,
34 // review, and comment. A release is deliberately absent: its event names a Git
35 // tag's tagger, which Vole cannot verify is the account, so it is not counted as
36 // somebody's work. query is the recognised spelling, so a page link and a chip
37 // keep the filter the reader chose rather than the one they typed.
38 type profileFilter struct {
39 kind string
40 typ string
41 label string
42 query string
43 }
44
45 func parseProfileFilter(v string) profileFilter {
46 switch v = strings.ToLower(strings.TrimSpace(v)); v {
47 case "issues":
48 return profileFilter{kind: "issue", label: "Issues", query: v}
49 case "changes":
50 return profileFilter{kind: "change", label: "Changes", query: v}
51 case "reviews":
52 return profileFilter{typ: "approval", label: "Reviews", query: v}
53 case "comments":
54 return profileFilter{typ: "comment", label: "Comments", query: v}
55 }
56 return profileFilter{}
57 }
58
59 // profileStats are the four numbers a profile leads with. Each is a link to the
60 // feed narrowed to the events it counts, so a number is never a dead end.
61 type profileStats struct {
62 Issues int
63 Changes int
64 Reviews int
65 Comments int
66 }
67
68 // profileRepoRow is one repository's share of the work, for the cards beside the
69 // feed. A repository the viewer cannot read never reaches this list.
70 type profileRepoRow struct {
71 Name string
72 Issues int
73 Changes int
74 Reviews int
75 Comments int
76 }
77
78 type profileView struct {
79 Page
80 Name string
81 Active bool
82 Joined time.Time
83 ShowRole bool
84 Role string
85 Stats profileStats
86 Repos []profileRepoRow
87 // Type is the raw ?type= a chip keeps, empty for the whole feed.
88 Type string
89 Events []activityEvent
90 Pager pager
91 }
92
93 // ProfilePage serves GET /u/{name}.
94 func (w *Web) ProfilePage(rw http.ResponseWriter, r *http.Request) {
95 if w.Ident == nil {
96 w.notFound(rw, r)
97 return
98 }
99 acct, err := w.Ident.Find(strings.TrimSpace(r.PathValue("name")))
100 if err != nil {
101 w.notFound(rw, r)
102 return
103 }
104 if w.Index == nil {
105 w.serverError(rw, r, "the index is unavailable, so a profile cannot be counted")
106 return
107 }
108
109 typ := r.URL.Query().Get("type")
110 filter := parseProfileFilter(typ)
111 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
112
113 entries, err := w.visibleRepos(r, "")
114 if err != nil {
115 w.serverError(rw, r, "index unavailable: "+err.Error())
116 return
117 }
118 memo := memoOf(r)
119 repoIDs, names := w.visibleIndexedRepos(memo, entries)
120
121 counts, err := w.Index.ActorActivity(repoIDs, acct.Name)
122 if err != nil {
123 w.serverError(rw, r, "cannot read profile: "+err.Error())
124 return
125 }
126 stats, repos := profileTotals(counts, names)
127
128 events, pages, err := w.profileEvents(repoIDs, names, acct.Name, filter, number)
129 if err != nil {
130 w.serverError(rw, r, "cannot read profile activity: "+err.Error())
131 return
132 }
133
134 page := w.page(r, acct.Name)
135 if filter.label != "" {
136 page.Title = acct.Name + " · " + filter.label
137 }
138 page.NoIndex = true
139 view := profileView{
140 Page: page, Name: acct.Name, Active: acct.Active(), Joined: acct.Created,
141 Stats: stats, Repos: repos, Type: filter.query, Events: events, Pager: pages,
142 }
143 // The instance role is a targeting list rather than a description of
144 // someone's work, so only an instance admin is told who administers the
145 // instance.
146 if page.InstanceAdmin {
147 view.ShowRole = true
148 view.Role = acct.Role.String()
149 }
150 w.render(rw, "profile", view, 0)
151 }
152
153 // visibleIndexedRepos returns the readable repositories' ids and names, with each
154 // repository's derived rows brought up to date. It is the set every instance-wide
155 // count is allowed to include; a repository the viewer cannot read is absent, not
156 // zero. The refresh is the same one /activity and the landing page pay for, so a
157 // browser write is visible without waiting for a push.
158 func (w *Web) visibleIndexedRepos(memo *requestMemo, entries []index.Entry) ([]string, map[string]string) {
159 repoIDs := make([]string, 0, len(entries))
160 names := make(map[string]string, len(entries))
161 for _, e := range entries {
162 m, err := w.Repos.Meta(e.Name)
163 if err != nil {
164 continue
165 }
166 if err := w.ensureIndex(memo, m); err != nil {
167 if w.Logger != nil {
168 w.Logger("web: index for %s: %v", e.Name, err)
169 }
170 continue
171 }
172 repoIDs = append(repoIDs, e.ID)
173 names[e.ID] = e.Name
174 }
175 return repoIDs, names
176 }
177
178 // profileEvents is one page of an account's feed. The count and the page are the
179 // same filter, so the pager's total and the rows cannot describe different sets.
180 func (w *Web) profileEvents(repoIDs []string, names map[string]string, actor string,
181 f profileFilter, number int) ([]activityEvent, pager, error) {
182 count, err := w.Index.ActorEventCount(repoIDs, f.kind, f.typ, actor)
183 if err != nil {
184 return nil, pager{}, err
185 }
186 p := newPager(count, profilePage, number, profileURL(actor, f.query))
187 p.Label = "Profile pages"
188 if p.Total == 0 {
189 return nil, p, nil
190 }
191 from, _ := p.rows()
192 rows, err := w.Index.ActorEvents(repoIDs, f.kind, f.typ, actor, profilePage, from)
193 if err != nil {
194 return nil, p, err
195 }
196 events := make([]activityEvent, 0, len(rows))
197 for _, row := range rows {
198 events = append(events, w.activityEventFromRow(row, names[row.RepoID], true))
199 }
200 return events, p, nil
201 }
202
203 // profileURL builds a page link that keeps the filter, so paging never silently
204 // widens what the reader asked for. typ is the page's own spelling of the filter
205 // ("reviews"), not the event type it maps to ("approval").
206 func profileURL(name, typ string) func(int) string {
207 return func(n int) string {
208 v := url.Values{}
209 if typ != "" {
210 v.Set("type", typ)
211 }
212 if n > 1 {
213 v.Set("page", strconv.Itoa(n))
214 }
215 base := "/u/" + url.PathEscape(name)
216 if q := v.Encode(); q != "" {
217 return base + "?" + q
218 }
219 return base
220 }
221 }
222
223 // profileTotals folds the grouped event rows into the stat panel and the
224 // repository cards. It is a pure function of the rows so the counting rules can be
225 // tested without a database, and so the panel and the cards cannot disagree.
226 func profileTotals(counts []index.ActorCount, names map[string]string) (profileStats, []profileRepoRow) {
227 var stats profileStats
228 byRepo := map[string]*profileRepoRow{}
229 for _, c := range counts {
230 row := byRepo[c.RepoID]
231 if row == nil {
232 row = &profileRepoRow{Name: names[c.RepoID]}
233 byRepo[c.RepoID] = row
234 }
235 switch {
236 case c.Kind == "issue" && c.Type == "open":
237 stats.Issues += c.N
238 row.Issues += c.N
239 case c.Kind == "change" && c.Type == "open":
240 stats.Changes += c.N
241 row.Changes += c.N
242 case c.Type == "approval":
243 stats.Reviews += c.N
244 row.Reviews += c.N
245 case c.Type == "comment":
246 stats.Comments += c.N
247 row.Comments += c.N
248 }
249 }
250 repos := make([]profileRepoRow, 0, len(byRepo))
251 for _, row := range byRepo {
252 // A repository where the only events are state changes has nothing to put
253 // on a card; the feed still shows those events.
254 if row.Issues+row.Changes+row.Reviews+row.Comments == 0 {
255 continue
256 }
257 repos = append(repos, *row)
258 }
259 sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name })
260 return stats, repos
261 }