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/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.

Blame · server/web/profile.go

Line attribution on main · 262 line(s)

a6b42433
Keenan Nemetz
7d ago
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
d37d301f
Keenan Nemetz
15h ago
26 "rodent.software/vole/forge/index"
a6b42433
Keenan Nemetz
7d ago
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
29a795b9
Keenan Nemetz
6d ago
81 Bot bool
a6b42433
Keenan Nemetz
7d ago
82 Active bool
83 Joined time.Time
84 ShowRole bool
85 Role string
86 Stats profileStats
87 Repos []profileRepoRow
88 // Type is the raw ?type= a chip keeps, empty for the whole feed.
89 Type string
90 Events []activityEvent
91 Pager pager
92 }
93
94 // ProfilePage serves GET /u/{name}.
95 func (w *Web) ProfilePage(rw http.ResponseWriter, r *http.Request) {
96 if w.Ident == nil {
97 w.notFound(rw, r)
98 return
99 }
100 acct, err := w.Ident.Find(strings.TrimSpace(r.PathValue("name")))
101 if err != nil {
102 w.notFound(rw, r)
103 return
104 }
105 if w.Index == nil {
106 w.serverError(rw, r, "the index is unavailable, so a profile cannot be counted")
107 return
108 }
109
110 typ := r.URL.Query().Get("type")
111 filter := parseProfileFilter(typ)
112 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
113
114 entries, err := w.visibleRepos(r, "")
115 if err != nil {
116 w.serverError(rw, r, "index unavailable: "+err.Error())
117 return
118 }
119 memo := memoOf(r)
120 repoIDs, names := w.visibleIndexedRepos(memo, entries)
121
122 counts, err := w.Index.ActorActivity(repoIDs, acct.Name)
123 if err != nil {
124 w.serverError(rw, r, "cannot read profile: "+err.Error())
125 return
126 }
127 stats, repos := profileTotals(counts, names)
128
129 events, pages, err := w.profileEvents(repoIDs, names, acct.Name, filter, number)
130 if err != nil {
131 w.serverError(rw, r, "cannot read profile activity: "+err.Error())
132 return
133 }
134
135 page := w.page(r, acct.Name)
136 if filter.label != "" {
137 page.Title = acct.Name + " · " + filter.label
138 }
139 page.NoIndex = true
140 view := profileView{
29a795b9
Keenan Nemetz
6d ago
141 Page: page, Name: acct.Name, Bot: acct.Bot(), Active: acct.Active(), Joined: acct.Created,
a6b42433
Keenan Nemetz
7d ago
142 Stats: stats, Repos: repos, Type: filter.query, Events: events, Pager: pages,
143 }
144 // The instance role is a targeting list rather than a description of
145 // someone's work, so only an instance admin is told who administers the
146 // instance.
147 if page.InstanceAdmin {
148 view.ShowRole = true
149 view.Role = acct.Role.String()
150 }
151 w.render(rw, "profile", view, 0)
152 }
153
154 // visibleIndexedRepos returns the readable repositories' ids and names, with each
155 // repository's derived rows brought up to date. It is the set every instance-wide
156 // count is allowed to include; a repository the viewer cannot read is absent, not
157 // zero. The refresh is the same one /activity and the landing page pay for, so a
158 // browser write is visible without waiting for a push.
159 func (w *Web) visibleIndexedRepos(memo *requestMemo, entries []index.Entry) ([]string, map[string]string) {
160 repoIDs := make([]string, 0, len(entries))
161 names := make(map[string]string, len(entries))
162 for _, e := range entries {
163 m, err := w.Repos.Meta(e.Name)
164 if err != nil {
165 continue
166 }
167 if err := w.ensureIndex(memo, m); err != nil {
168 if w.Logger != nil {
169 w.Logger("web: index for %s: %v", e.Name, err)
170 }
171 continue
172 }
173 repoIDs = append(repoIDs, e.ID)
174 names[e.ID] = e.Name
175 }
176 return repoIDs, names
177 }
178
179 // profileEvents is one page of an account's feed. The count and the page are the
180 // same filter, so the pager's total and the rows cannot describe different sets.
181 func (w *Web) profileEvents(repoIDs []string, names map[string]string, actor string,
182 f profileFilter, number int) ([]activityEvent, pager, error) {
183 count, err := w.Index.ActorEventCount(repoIDs, f.kind, f.typ, actor)
184 if err != nil {
185 return nil, pager{}, err
186 }
187 p := newPager(count, profilePage, number, profileURL(actor, f.query))
188 p.Label = "Profile pages"
189 if p.Total == 0 {
190 return nil, p, nil
191 }
192 from, _ := p.rows()
193 rows, err := w.Index.ActorEvents(repoIDs, f.kind, f.typ, actor, profilePage, from)
194 if err != nil {
195 return nil, p, err
196 }
197 events := make([]activityEvent, 0, len(rows))
198 for _, row := range rows {
199 events = append(events, w.activityEventFromRow(row, names[row.RepoID], true))
200 }
201 return events, p, nil
202 }
203
204 // profileURL builds a page link that keeps the filter, so paging never silently
205 // widens what the reader asked for. typ is the page's own spelling of the filter
206 // ("reviews"), not the event type it maps to ("approval").
207 func profileURL(name, typ string) func(int) string {
208 return func(n int) string {
209 v := url.Values{}
210 if typ != "" {
211 v.Set("type", typ)
212 }
213 if n > 1 {
214 v.Set("page", strconv.Itoa(n))
215 }
ed03b3d1
Keenan Nemetz
3h ago
216 base := "/-/u/" + url.PathEscape(name)
a6b42433
Keenan Nemetz
7d ago
217 if q := v.Encode(); q != "" {
218 return base + "?" + q
219 }
220 return base
221 }
222 }
223
224 // profileTotals folds the grouped event rows into the stat panel and the
225 // repository cards. It is a pure function of the rows so the counting rules can be
226 // tested without a database, and so the panel and the cards cannot disagree.
227 func profileTotals(counts []index.ActorCount, names map[string]string) (profileStats, []profileRepoRow) {
228 var stats profileStats
229 byRepo := map[string]*profileRepoRow{}
230 for _, c := range counts {
231 row := byRepo[c.RepoID]
232 if row == nil {
233 row = &profileRepoRow{Name: names[c.RepoID]}
234 byRepo[c.RepoID] = row
235 }
236 switch {
237 case c.Kind == "issue" && c.Type == "open":
238 stats.Issues += c.N
239 row.Issues += c.N
240 case c.Kind == "change" && c.Type == "open":
241 stats.Changes += c.N
242 row.Changes += c.N
243 case c.Type == "approval":
244 stats.Reviews += c.N
245 row.Reviews += c.N
246 case c.Type == "comment":
247 stats.Comments += c.N
248 row.Comments += c.N
249 }
250 }
251 repos := make([]profileRepoRow, 0, len(byRepo))
252 for _, row := range byRepo {
253 // A repository where the only events are state changes has nothing to put
254 // on a card; the feed still shows those events.
255 if row.Issues+row.Changes+row.Reviews+row.Comments == 0 {
256 continue
257 }
258 repos = append(repos, *row)
259 }
260 sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name })
261 return stats, repos
262 }