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