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/events.go +74−23
| @@ -289,41 +289,72 @@ func replaceTagRows(tx execer, repoID string, tags []git.Tag) error { | |||
| 289 | 289 | return nil | |
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | // eventFilter is the WHERE clause the count and the page share. Kind is the | ||
| 293 | // event kind (issue, change, release); the empty kind is the whole feed. | ||
| 294 | func eventFilter(repoIDs []string, kind, actor, assignee string) (string, []any) { | ||
| 292 | // eventQuery is the set of narrowings the feed, a profile and the directory | ||
| 293 | // share. kind is the event kind (issue, change, release); typ is what happened | ||
| 294 | // (open, comment, approval, state, release). Either may be empty. | ||
| 295 | type eventQuery struct { | ||
| 296 | kind string | ||
| 297 | typ string | ||
| 298 | actor string | ||
| 299 | assignee string | ||
| 300 | // notKind excludes one kind. A profile uses it to leave releases out: a | ||
| 301 | // release event names the tag's tagger, which is a Git identity and not the | ||
| 302 | // credential that proves an account, so it is not somebody's work here. | ||
| 303 | notKind string | ||
| 304 | } | ||
| 305 | |||
| 306 | // where is the WHERE clause the count and the page share. | ||
| 307 | func (q eventQuery) where(repoIDs []string) (string, []any) { | ||
| 295 | 308 | conds := []string{`repo_id IN (` + questionMarks(len(repoIDs)) + `)`} | |
| 296 | args := make([]any, 0, len(repoIDs)+2) | ||
| 309 | args := make([]any, 0, len(repoIDs)+3) | ||
| 297 | 310 | for _, id := range repoIDs { | |
| 298 | 311 | args = append(args, id) | |
| 299 | 312 | } | |
| 300 | if kind != "" { | ||
| 313 | if q.kind != "" { | ||
| 301 | 314 | conds = append(conds, `kind = ?`) | |
| 302 | args = append(args, kind) | ||
| 315 | args = append(args, q.kind) | ||
| 316 | } | ||
| 317 | if q.typ != "" { | ||
| 318 | conds = append(conds, `type = ?`) | ||
| 319 | args = append(args, q.typ) | ||
| 320 | } | ||
| 321 | if q.notKind != "" { | ||
| 322 | conds = append(conds, `kind != ?`) | ||
| 323 | args = append(args, q.notKind) | ||
| 303 | 324 | } | |
| 304 | if actor != "" { | ||
| 325 | if q.actor != "" { | ||
| 305 | 326 | conds = append(conds, `actor = ?`) | |
| 306 | args = append(args, actor) | ||
| 327 | args = append(args, q.actor) | ||
| 307 | 328 | } | |
| 308 | if assignee != "" { | ||
| 329 | if q.assignee != "" { | ||
| 309 | 330 | // Only issue events carry assignees. The column is a \x1f-joined list; | |
| 310 | 331 | // char(31) keeps the delimiter out of the query text. | |
| 311 | 332 | conds = append(conds, | |
| 312 | 333 | `kind = 'issue'`, | |
| 313 | 334 | `instr(char(31)||assignees||char(31), char(31)||?||char(31)) > 0`) | |
| 314 | args = append(args, assignee) | ||
| 335 | args = append(args, q.assignee) | ||
| 315 | 336 | } | |
| 316 | 337 | return strings.Join(conds, " AND "), args | |
| 317 | 338 | } | |
| 318 | 339 | ||
| 340 | // unfiltered reports whether the query is the whole feed, which is the one case | ||
| 341 | // a stored per-repository count can answer. | ||
| 342 | func (q eventQuery) unfiltered() bool { | ||
| 343 | return q.kind == "" && q.typ == "" && q.actor == "" && q.assignee == "" && q.notKind == "" | ||
| 344 | } | ||
| 345 | |||
| 319 | 346 | // EventCount is how many events the filters select. The unfiltered total is the | |
| 320 | 347 | // stored per-repository count, so a pager does not count a large repository's | |
| 321 | 348 | // history on every page; a filtered total is an index scan without the sort. | |
| 322 | 349 | func (s *Store) EventCount(repoIDs []string, kind, actor, assignee string) (int, error) { | |
| 350 | return s.countEvents(repoIDs, eventQuery{kind: kind, actor: actor, assignee: assignee}) | ||
| 351 | } | ||
| 352 | |||
| 353 | func (s *Store) countEvents(repoIDs []string, q eventQuery) (int, error) { | ||
| 323 | 354 | if len(repoIDs) == 0 { | |
| 324 | 355 | return 0, nil | |
| 325 | 356 | } | |
| 326 | if kind == "" && actor == "" && assignee == "" { | ||
| 357 | if q.unfiltered() { | ||
| 327 | 358 | args := make([]any, 0, len(repoIDs)) | |
| 328 | 359 | for _, id := range repoIDs { | |
| 329 | 360 | args = append(args, id) | |
| @@ -334,7 +365,7 @@ func (s *Store) EventCount(repoIDs []string, kind, actor, assignee string) (int, | |||
| 334 | 365 | args...).Scan(&n) | |
| 335 | 366 | return n, err | |
| 336 | 367 | } | |
| 337 | where, args := eventFilter(repoIDs, kind, actor, assignee) | ||
| 368 | where, args := q.where(repoIDs) | ||
| 338 | 369 | var n int | |
| 339 | 370 | err := s.db.QueryRow(`SELECT COUNT(*) FROM events WHERE `+where, args...).Scan(&n) | |
| 340 | 371 | return n, err | |
| @@ -346,18 +377,21 @@ const eventColumns = `repo_id, id, kind, type, at, actor, title, assignees, | |||
| 346 | 377 | ||
| 347 | 378 | // eventPageQuery builds the query for one page. | |
| 348 | 379 | // | |
| 349 | // One repository is a single range of the (repository, time) index. Several | ||
| 350 | // repositories walk the global time index in order and filter by repository: the | ||
| 351 | // page stops after offset+limit matches, and no sort is involved. Sorting every | ||
| 352 | // visible repository's history to find the page is exactly what this table | ||
| 353 | // exists to avoid; a narrow visible set scans proportionally more index rows, | ||
| 354 | // which is the bounded alternative. | ||
| 355 | func eventPageQuery(repoIDs []string, kind, actor, assignee string, limit, offset int) (string, []any) { | ||
| 356 | where, args := eventFilter(repoIDs, kind, actor, assignee) | ||
| 380 | // The index narrows first. One repository is a range of the (repository, time) | ||
| 381 | // index; one account is a range of the actor index, which is what makes a profile | ||
| 382 | // a read of that person's rows rather than a scan of everything they can see; a | ||
| 383 | // whole-instance feed walks the global time index. In every case the page stops | ||
| 384 | // after offset+limit matches, so no query sorts a repository's history. | ||
| 385 | func eventPageQuery(repoIDs []string, q eventQuery, limit, offset int) (string, []any) { | ||
| 386 | where, args := q.where(repoIDs) | ||
| 357 | 387 | args = append(args, limit, offset) | |
| 358 | if len(repoIDs) == 1 { | ||
| 388 | switch { | ||
| 389 | case len(repoIDs) == 1 && q.actor == "": | ||
| 359 | 390 | return `SELECT ` + eventColumns + ` FROM events WHERE ` + where + | |
| 360 | 391 | ` ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`, args | |
| 392 | case q.actor != "": | ||
| 393 | return `SELECT ` + eventColumns + ` FROM events INDEXED BY events_actor WHERE ` + where + | ||
| 394 | ` ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`, args | ||
| 361 | 395 | } | |
| 362 | 396 | return `SELECT ` + eventColumns + ` FROM events INDEXED BY events_time WHERE ` + where + | |
| 363 | 397 | ` ORDER BY at DESC, id DESC LIMIT ? OFFSET ?`, args | |
| @@ -365,11 +399,28 @@ func eventPageQuery(repoIDs []string, kind, actor, assignee string, limit, offse | |||
| 365 | 399 | ||
| 366 | 400 | // Events reads one page of the feed, newest first. | |
| 367 | 401 | func (s *Store) Events(repoIDs []string, kind, actor, assignee string, limit, offset int) ([]EventRow, error) { | |
| 402 | return s.eventPage(repoIDs, eventQuery{kind: kind, actor: actor, assignee: assignee}, limit, offset) | ||
| 403 | } | ||
| 404 | |||
| 405 | // ActorEvents reads one page of one account's events, optionally narrowed to a | ||
| 406 | // kind and a type. A profile asks for "alice's reviews" by naming the approval | ||
| 407 | // type; the repositories are the ones the viewer can read, so the page never | ||
| 408 | // counts a repository they cannot open. Releases are left out: see eventQuery. | ||
| 409 | func (s *Store) ActorEvents(repoIDs []string, kind, typ, actor string, limit, offset int) ([]EventRow, error) { | ||
| 410 | return s.eventPage(repoIDs, eventQuery{kind: kind, typ: typ, actor: actor, notKind: "release"}, limit, offset) | ||
| 411 | } | ||
| 412 | |||
| 413 | // ActorEventCount is the total ActorEvents pages through. | ||
| 414 | func (s *Store) ActorEventCount(repoIDs []string, kind, typ, actor string) (int, error) { | ||
| 415 | return s.countEvents(repoIDs, eventQuery{kind: kind, typ: typ, actor: actor, notKind: "release"}) | ||
| 416 | } | ||
| 417 | |||
| 418 | func (s *Store) eventPage(repoIDs []string, q eventQuery, limit, offset int) ([]EventRow, error) { | ||
| 368 | 419 | if len(repoIDs) == 0 || limit <= 0 { | |
| 369 | 420 | return nil, nil | |
| 370 | 421 | } | |
| 371 | q, args := eventPageQuery(repoIDs, kind, actor, assignee, limit, offset) | ||
| 372 | rows, err := s.db.Query(q, args...) | ||
| 422 | query, args := eventPageQuery(repoIDs, q, limit, offset) | ||
| 423 | rows, err := s.db.Query(query, args...) | ||
| 373 | 424 | if err != nil { | |
| 374 | 425 | return nil, err | |
| 375 | 426 | } | |