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