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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 25m ago 4b8c9e1d
history blame raw 8.0 KiB
1package 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
18import (
19 "net/http"
20 "net/url"
21 "sort"
22 "strconv"
23 "strings"
24 "time"
25
26 "rodent.software/vole/forge/index"
27)
28
29// profilePage is how many events one page of a profile shows.
30const 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.
38type profileFilter struct {
39 kind string
40 typ string
41 label string
42 query string
43}
44
45func 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.
61type 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.
70type profileRepoRow struct {
71 Name string
72 Issues int
73 Changes int
74 Reviews int
75 Comments int
76}
77
78type profileView struct {
79 Page
80 Name string
81 Bot bool
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}.
95func (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{
141 Page: page, Name: acct.Name, Bot: acct.Bot(), Active: acct.Active(), Joined: acct.Created,
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.
159func (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.
181func (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").
207func 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 }
216 base := "/-/u/" + url.PathEscape(name)
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.
227func 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}
263