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/people.go +131

@@ -0,0 +1,131 @@
1 package web
2
3 // The account directory: everyone who has an account here, and what they have
4 // been working on.
5 //
6 // Account names on this instance are public, which is why this page lists them
7 // and why /u/{name} answers for anybody in the roster. Activity is not public:
8 // every count beside a name is limited to the repositories the reader may open,
9 // so a private repository's work contributes nothing to a stranger's view. The
10 // page says so, once, rather than repeating a caveat on every row.
11
12 import (
13 "net/http"
14 "net/url"
15 "strconv"
16 "strings"
17 "time"
18
19 "github.com/rodent-software/vole/forge/identity"
20 "github.com/rodent-software/vole/forge/index"
21 )
22
23 // peoplePage is how many accounts one page of the directory shows.
24 const peoplePage = 50
25
26 // peopleRow is one account in the directory. The counts are a summary of the same
27 // events a profile reads, so a row and the page it opens tell the same story.
28 type peopleRow struct {
29 Name string
30 Active bool
31 Joined time.Time
32 Role string // rendered only for an instance admin
33 Summary index.ActorSummary
34 }
35
36 type peopleView struct {
37 Page
38 Query string
39 Rows []peopleRow
40 Retired []peopleRow
41 Pager pager
42 // Total is every account on the instance, or the number that matched a
43 // search, so the subtitle can say which question was answered.
44 Total int
45 Filtered bool
46 }
47
48 // PeoplePage serves GET /u, the directory of accounts.
49 func (w *Web) PeoplePage(rw http.ResponseWriter, r *http.Request) {
50 if w.Ident == nil {
51 w.notFound(rw, r)
52 return
53 }
54 query := strings.TrimSpace(r.URL.Query().Get("q"))
55 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
56
57 // All reads the roster once and already orders it active first, then by name:
58 // people who are here are found by eye, and a returning reader finds a name
59 // where it was last time.
60 accounts := w.Ident.All()
61 matched := make([]*identity.Account, 0, len(accounts))
62 needle := strings.ToLower(query)
63 for _, acct := range accounts {
64 if needle != "" && !strings.Contains(strings.ToLower(acct.Name), needle) {
65 continue
66 }
67 matched = append(matched, acct)
68 }
69
70 p := newPager(len(matched), peoplePage, number, peopleURL(query))
71 p.Label = "People pages"
72 from, to := p.rows()
73 visible := matched[from:to]
74
75 summaries := map[string]index.ActorSummary{}
76 if w.Index != nil && len(visible) > 0 {
77 names := make([]string, 0, len(visible))
78 for _, acct := range visible {
79 names = append(names, acct.Name)
80 }
81 entries, err := w.visibleRepos(r, "")
82 if err != nil {
83 w.serverError(rw, r, "index unavailable: "+err.Error())
84 return
85 }
86 repoIDs, _ := w.visibleIndexedRepos(memoOf(r), entries)
87 if summaries, err = w.Index.ActorSummary(repoIDs, names); err != nil {
88 w.serverError(rw, r, "cannot read the directory: "+err.Error())
89 return
90 }
91 }
92
93 page := w.page(r, "People")
94 page.NoIndex = true
95 view := peopleView{
96 Page: page, Query: query, Total: len(matched), Filtered: query != "", Pager: p,
97 }
98 for _, acct := range visible {
99 row := peopleRow{
100 Name: acct.Name, Active: acct.Active(), Joined: acct.Created,
101 Summary: summaries[acct.Name],
102 }
103 if page.InstanceAdmin {
104 row.Role = acct.Role.String()
105 }
106 if row.Active {
107 view.Rows = append(view.Rows, row)
108 } else {
109 view.Retired = append(view.Retired, row)
110 }
111 }
112 w.render(rw, "people", view, 0)
113 }
114
115 // peopleURL builds a page link that keeps the search, so paging never silently
116 // widens what the reader asked for.
117 func peopleURL(query string) func(int) string {
118 return func(n int) string {
119 v := url.Values{}
120 if query != "" {
121 v.Set("q", query)
122 }
123 if n > 1 {
124 v.Set("page", strconv.Itoa(n))
125 }
126 if q := v.Encode(); q != "" {
127 return "/u?" + q
128 }
129 return "/u"
130 }
131 }