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 | } | ||