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.

forge/index/profile.go +108

@@ -0,0 +1,108 @@
1 package index
2
3 // One account's numbers, read from the feed's own rows.
4 //
5 // A profile could count issues, changes, approvals and comments from the
6 // per-entity tables, but then "12 issues opened" and the activity feed would be
7 // two statements about the same history that can disagree. The events table
8 // already holds exactly what the feed shows — one row per opening, comment,
9 // approval, state change and release — so the numbers are a grouped read of those
10 // rows. An account's profile and the instance feed cannot drift apart, because
11 // they are the same rows.
12 //
13 // Whether a row is visible is the caller's question, not this layer's: every
14 // function here takes the repository ids the viewer may read.
15
16 // ActorCount is one cell of a profile's numbers: how many events of one kind and
17 // type an account produced in one repository. State changes keep their state, so
18 // a closed issue and a reopened one are not mistaken for each other.
19 type ActorCount struct {
20 RepoID string
21 Kind string // issue | change | release
22 Type string // open | comment | approval | state | release
23 State string // closed | merged | open, for state events
24 N int
25 }
26
27 // ActorActivity returns an account's events grouped by repository, kind, type and
28 // state. It is the profile's whole read: the stat panel and the per-repository
29 // cards are this one answer arranged two ways.
30 func (s *Store) ActorActivity(repoIDs []string, actor string) ([]ActorCount, error) {
31 if len(repoIDs) == 0 || actor == "" {
32 return nil, nil
33 }
34 args := make([]any, 0, len(repoIDs)+1)
35 for _, id := range repoIDs {
36 args = append(args, id)
37 }
38 args = append(args, actor)
39 rows, err := s.db.Query(
40 `SELECT repo_id, kind, type, state, COUNT(*) FROM events
41 WHERE repo_id IN (`+questionMarks(len(repoIDs))+`) AND actor=?
42 GROUP BY repo_id, kind, type, state`, args...)
43 if err != nil {
44 return nil, err
45 }
46 defer rows.Close()
47 var out []ActorCount
48 for rows.Next() {
49 var c ActorCount
50 if err := rows.Scan(&c.RepoID, &c.Kind, &c.Type, &c.State, &c.N); err != nil {
51 return nil, err
52 }
53 out = append(out, c)
54 }
55 return out, rows.Err()
56 }
57
58 // ActorSummary is what a directory row says about one account: how many
59 // repositories they touched, and the three counts worth putting beside a name.
60 type ActorSummary struct {
61 Repos int
62 Issues int
63 Changes int
64 Reviews int
65 }
66
67 // ActorSummary answers for a whole page of names in one grouped query rather
68 // than one query per row. Names with nothing visible are absent from the map,
69 // which the directory renders as an account with no visible work, not as a zero
70 // somebody should be embarrassed by.
71 func (s *Store) ActorSummary(repoIDs, actors []string) (map[string]ActorSummary, error) {
72 out := map[string]ActorSummary{}
73 if len(repoIDs) == 0 || len(actors) == 0 {
74 return out, nil
75 }
76 args := make([]any, 0, len(repoIDs)+len(actors))
77 for _, id := range repoIDs {
78 args = append(args, id)
79 }
80 for _, name := range actors {
81 args = append(args, name)
82 }
83 rows, err := s.db.Query(
84 `SELECT actor,
85 COUNT(DISTINCT repo_id),
86 COALESCE(SUM(CASE WHEN kind='issue' AND type='open' THEN 1 ELSE 0 END),0),
87 COALESCE(SUM(CASE WHEN kind='change' AND type='open' THEN 1 ELSE 0 END),0),
88 COALESCE(SUM(CASE WHEN type='approval' THEN 1 ELSE 0 END),0)
89 FROM events
90 WHERE repo_id IN (`+questionMarks(len(repoIDs))+`)
91 AND actor IN (`+questionMarks(len(actors))+`)
92 GROUP BY actor`, args...)
93 if err != nil {
94 return nil, err
95 }
96 defer rows.Close()
97 for rows.Next() {
98 var (
99 name string
100 sum ActorSummary
101 )
102 if err := rows.Scan(&name, &sum.Repos, &sum.Issues, &sum.Changes, &sum.Reviews); err != nil {
103 return nil, err
104 }
105 out[name] = sum
106 }
107 return out, rows.Err()
108 }