v0.33.0 cf26365

events.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"strconv"
10	"strings"
11	"sync"
12	"time"
13
14	"github.com/bnema/zerowrap"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// DefaultEventBuffer is the number of events the Events use case remembers.
21const DefaultEventBuffer = 1024
22
23// Limits of the watchers.
24const (
25	// MaxWatchTopics is the most topics one watch may follow.
26	MaxWatchTopics = 64
27	// MaxWatchers is the most watches in progress at once.
28	MaxWatchers = 256
29	// MaxWatchersPerClient is the most watches in progress for one client
30	// address (see ports.WatchParams.Client).
31	MaxWatchersPerClient = 16
32	// DefaultRevalidateEvery is how long a watcher with a stored token goes
33	// without asking the token store whether the token still exists.
34	DefaultRevalidateEvery = 5 * time.Second
35)
36
37// Topics a watcher may follow.
38const (
39	// TopicHome follows every repository the caller may see.
40	TopicHome = "home"
41	// TopicOwner follows the merge requests and issues of every repository,
42	// for the owner only (the inbox). Anyone else is told it is an unknown
43	// topic.
44	TopicOwner = "owner"
45	// topicRepoPrefix starts the topic of one repository: "repo:<name>".
46	topicRepoPrefix = "repo:"
47)
48
49// bufferedEvent is an event with its sequence number in the buffer, and what
50// was true of its repository when it was buffered. Access to an event is
51// decided from that, and from the repository as it is when the event is
52// delivered: a repository made public later does not reveal what happened
53// while it was private, and a repository deleted and created again under the
54// same name does not inherit the events of the old one.
55type bufferedEvent struct {
56	seq uint64
57	ev  domain.Event
58
59	// known is false when the repository did not exist any more when the
60	// event was buffered: nobody is told about it.
61	known bool
62	// vis is the visibility of the repository at that time.
63	vis domain.Visibility
64	// created is the creation time of that repository.
65	created time.Time
66}
67
68// Events keeps the last events of the bus in a ring buffer, numbers them, and
69// lets callers watch them: live, or replayed from the id of the last event
70// they saw. Events of a private repository only reach callers that may read
71// it, checked again for every event.
72//
73// Event ids are opaque to clients: "<boot>-<seq>", where boot identifies this
74// run of the server, so that an id from before a restart is unknown rather
75// than taken for a newer event.
76//
77// The goroutine that feeds the buffer uses the logger of the context given to
78// NewEvents and lives until Close.
79type Events struct {
80	repos *Repos
81	auth  ports.Revalidator
82	clock ports.Clock
83	boot  string
84	size  int
85	every time.Duration // see EventsConfig.RevalidateEvery
86
87	stop context.CancelFunc
88	done chan struct{}
89
90	mu      sync.Mutex
91	ring    []bufferedEvent // capacity size, oldest first
92	nextSeq uint64          // sequence number of the next event, from 1
93	changed chan struct{}   // closed and replaced on every append and on close
94	closed  bool
95	watches int
96	clients map[string]int // watches in progress by client
97}
98
99var _ ports.EventService = (*Events)(nil)
100
101// EventsConfig configures Events.
102type EventsConfig struct {
103	// Size is the number of events remembered; DefaultEventBuffer when not
104	// positive.
105	Size int
106	// Auth confirms that the token of a watcher still exists. Required.
107	Auth ports.Revalidator
108	// RevalidateEvery is how long the answer of Auth is kept for a watcher;
109	// DefaultRevalidateEvery when zero, and negative to ask for every event.
110	RevalidateEvery time.Duration
111}
112
113// NewEvents subscribes to the bus and starts remembering its events. ctx
114// carries the logger; its cancellation is not followed, call Close.
115func NewEvents(ctx context.Context, repos *Repos, bus ports.EventBus, clock ports.Clock, cfg EventsConfig) *Events {
116	size := cfg.Size
117	if size <= 0 {
118		size = DefaultEventBuffer
119	}
120	every := cfg.RevalidateEvery
121	if every == 0 {
122		every = DefaultRevalidateEvery
123	}
124	run, stop := context.WithCancel(context.WithoutCancel(ctx))
125	e := &Events{
126		repos:   repos,
127		auth:    cfg.Auth,
128		clock:   clock,
129		every:   every,
130		clients: make(map[string]int),
131		boot:    strconv.FormatInt(clock.Now().UnixMilli(), 36),
132		size:    size,
133		stop:    stop,
134		done:    make(chan struct{}),
135		nextSeq: 1,
136		changed: make(chan struct{}),
137	}
138	sub := bus.Subscribe(run) // before returning: nothing published after is missed
139	go e.feed(run, sub)
140	return e
141}
142
143// feed appends the events of the bus until it stops.
144func (e *Events) feed(ctx context.Context, sub <-chan domain.Event) {
145	defer close(e.done)
146	defer e.shutdown()
147	for {
148		select {
149		case ev, ok := <-sub:
150			if !ok {
151				return
152			}
153			e.append(ctx, ev)
154		case <-ctx.Done():
155			return
156		}
157	}
158}
159
160// snapshot records the visibility and the generation of the repository an
161// event is about. Every event of the repository carries them.
162func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
163	switch ev := ev.(type) {
164	case domain.RepoCreated:
165		return bufferedEvent{known: true, vis: ev.Visibility, created: ev.At}
166	case domain.RepoUpdated:
167		return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
168	case domain.RepoDeleted:
169		return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
170	case domain.MirrorFailed:
171		return repoSnapshot(ev.Visibility, ev.CreatedAt)
172	case domain.MirrorRecovered:
173		return repoSnapshot(ev.Visibility, ev.CreatedAt)
174	case domain.ReleasePublished:
175		return repoSnapshot(ev.Visibility, ev.CreatedAt)
176	case domain.ReleaseUpdated:
177		return repoSnapshot(ev.Visibility, ev.CreatedAt)
178	case domain.ReleaseDeleted:
179		return repoSnapshot(ev.Visibility, ev.CreatedAt)
180	case domain.ReleaseUnpublished:
181		return repoSnapshot(ev.Visibility, ev.CreatedAt)
182	case domain.PushEvent:
183		if ev.CreatedAt.IsZero() {
184			return bufferedEvent{} // repository unknown at publish time: nobody is told
185		}
186		return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
187	case domain.MergeRequestOpened:
188		return repoSnapshot(ev.Visibility, ev.CreatedAt)
189	case domain.MergeRequestMerged:
190		return repoSnapshot(ev.Visibility, ev.CreatedAt)
191	case domain.MergeRequestClosed:
192		return repoSnapshot(ev.Visibility, ev.CreatedAt)
193	case domain.MergeRequestAutoMergeEnabled:
194		return repoSnapshot(ev.Visibility, ev.CreatedAt)
195	case domain.MergeRequestAutoMergeCancelled:
196		return repoSnapshot(ev.Visibility, ev.CreatedAt)
197	case domain.MergeRequestReady:
198		return repoSnapshot(ev.Visibility, ev.CreatedAt)
199	case domain.CommentAdded:
200		return repoSnapshot(ev.Visibility, ev.CreatedAt)
201	case domain.IssueReferenced:
202		return repoSnapshot(ev.Visibility, ev.CreatedAt)
203	case domain.IssueOpened:
204		return repoSnapshot(ev.Visibility, ev.CreatedAt)
205	case domain.IssueClosed:
206		return repoSnapshot(ev.Visibility, ev.CreatedAt)
207	case domain.IssueReopened:
208		return repoSnapshot(ev.Visibility, ev.CreatedAt)
209	case domain.IssueCommented:
210		return repoSnapshot(ev.Visibility, ev.CreatedAt)
211	case domain.IssueSubmitted, domain.IssuePendingRemoved:
212		return bufferedEvent{known: true} // owner topic only: no repository snapshot
213	case domain.CommentEdited:
214		return repoSnapshot(ev.Visibility, ev.CreatedAt)
215	case domain.CommentDeleted:
216		return repoSnapshot(ev.Visibility, ev.CreatedAt)
217	case domain.CommentResolved:
218		return repoSnapshot(ev.Visibility, ev.CreatedAt)
219	case domain.MergeRequestMergeability:
220		return repoSnapshot(ev.Visibility, ev.CreatedAt)
221	case domain.RunUpdated:
222		return repoSnapshot(ev.Visibility, ev.CreatedAt)
223	case domain.JobUpdated:
224		return repoSnapshot(ev.Visibility, ev.CreatedAt)
225	}
226	return bufferedEvent{known: true}
227}
228
229// repoSnapshot is the snapshot of an event that carries the visibility and the
230// creation time of its repository; a zero creation time means the repository
231// was unknown when it was published, and nobody is told.
232func repoSnapshot(vis domain.Visibility, created time.Time) bufferedEvent {
233	if created.IsZero() {
234		return bufferedEvent{}
235	}
236	return bufferedEvent{known: true, vis: vis, created: created}
237}
238
239func (e *Events) append(ctx context.Context, ev domain.Event) {
240	be := e.snapshot(ctx, ev) // outside the lock: it reads the store
241	e.mu.Lock()
242	defer e.mu.Unlock()
243	if len(e.ring) == e.size {
244		copy(e.ring, e.ring[1:])
245		e.ring = e.ring[:e.size-1]
246	}
247	be.seq, be.ev = e.nextSeq, ev
248	e.ring = append(e.ring, be)
249	e.nextSeq++
250	e.wake()
251}
252
253// wake releases the watchers waiting for news. The caller holds mu.
254func (e *Events) wake() {
255	close(e.changed)
256	e.changed = make(chan struct{})
257}
258
259func (e *Events) shutdown() {
260	e.mu.Lock()
261	defer e.mu.Unlock()
262	if !e.closed {
263		e.closed = true
264		e.wake()
265	}
266}
267
268// Close ends every watch cleanly, stops feeding and waits for the feeder,
269// until ctx ends. It is safe to call more than once.
270func (e *Events) Close(ctx context.Context) {
271	e.stop()
272	select {
273	case <-e.done:
274	case <-ctx.Done():
275	}
276}
277
278// Watchers returns the number of watches in progress.
279func (e *Events) Watchers() int {
280	e.mu.Lock()
281	defer e.mu.Unlock()
282	return e.watches
283}
284
285func (e *Events) id(seq uint64) string { return e.boot + "-" + strconv.FormatUint(seq, 10) }
286
287// parseID returns the sequence number of an id of this run.
288func (e *Events) parseID(id string) (uint64, bool) {
289	boot, seq, ok := strings.Cut(id, "-")
290	if !ok || boot != e.boot {
291		return 0, false
292	}
293	n, err := strconv.ParseUint(seq, 10, 64)
294	return n, err == nil && n > 0
295}
296
297// watch is the state of one Watch call.
298type watch struct {
299	p     domain.Principal
300	home  bool
301	owner bool // the owner topic: merge request, issue, repo update and deletion events of every repository
302	repos map[domain.RepoName]bool
303
304	checked time.Time // when the token store last confirmed the principal
305}
306
307func (w *watch) wants(name domain.RepoName) bool { return w.home || w.repos[name] }
308
309// wantsEvent is wants for an event: the owner topic also follows the events
310// about merge requests and issues of every repository.
311func (w *watch) wantsEvent(name domain.RepoName, ev domain.Event) bool {
312	return w.wants(name) || (w.owner && (isItemEvent(ev) || isRepoLifecycleEvent(ev)))
313}
314
315// parseTopics validates the topics of a watch. A repository topic needs a
316// repository the principal may read (else domain.ErrNotFound); an unknown
317// topic, or more than MaxWatchTopics, is domain.ErrInvalid.
318func (e *Events) parseTopics(ctx context.Context, p domain.Principal, topics []string) (*watch, error) {
319	if len(topics) > MaxWatchTopics {
320		return nil, fmt.Errorf("%w: %d topics, at most %d", domain.ErrInvalid, len(topics), MaxWatchTopics)
321	}
322	w := &watch{p: p, repos: make(map[domain.RepoName]bool), checked: time.Now()}
323	if len(topics) == 0 {
324		w.home = true
325	}
326	for _, t := range topics {
327		if t == TopicHome {
328			w.home = true
329			continue
330		}
331		if t == TopicOwner && p.HasOwnerRights() {
332			w.owner = true
333			continue
334		}
335		raw, ok := strings.CutPrefix(t, topicRepoPrefix)
336		if !ok {
337			// Includes "owner" for anyone but the owner: the same error as
338			// any unknown topic.
339			return nil, fmt.Errorf("%w: unknown topic %q (want %q or \"repo:<name>\")", domain.ErrInvalid, t, TopicHome)
340		}
341		name, err := domain.ParseRepoName(raw)
342		if err != nil {
343			return nil, err
344		}
345		if _, err := e.repos.Get(ctx, p, name); err != nil {
346			return nil, err
347		}
348		w.repos[name] = true
349	}
350	return w, nil
351}
352
353// Watch calls send for the events the principal may see until ctx ends, the
354// buffer closes (both return nil) or send fails (its error is returned).
355//
356// A principal with a stored token is checked again for every event, and every
357// few seconds when idle: when the token expired or was revoked the watch ends
358// with domain.ErrUnauthenticated and nothing more is sent. More watches than
359// MaxWatchers, or than MaxWatchersPerClient for one Client, give
360// domain.ErrResourceExhausted.
361//
362// Without ResumeAfter only new events are sent. With it, the buffered events
363// after that id are sent first, then new ones. When the id is no longer
364// buffered, or was not issued by this run, a gap comes first and the oldest
365// buffered event follows. A watcher that falls more than the buffer behind
366// gets a gap too, and carries on with the oldest buffered event.
367func (e *Events) Watch(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error {
368	w, err := e.parseTopics(ctx, p, params.Topics)
369	if err != nil {
370		return err
371	}
372
373	e.mu.Lock()
374	next := e.nextSeq // only new events
375	gap := false
376	if params.ResumeAfter != "" {
377		seq, ok := e.parseID(params.ResumeAfter)
378		oldest := e.nextSeq - uint64(len(e.ring)) // first buffered sequence, nextSeq when empty
379		switch {
380		case ok && seq >= oldest-1 && seq < e.nextSeq:
381			next = seq + 1
382		default:
383			next, gap = oldest, true
384		}
385	}
386	if e.watches >= MaxWatchers || (params.Client != "" && e.clients[params.Client] >= MaxWatchersPerClient) {
387		e.mu.Unlock()
388		return fmt.Errorf("%w: too many watchers", domain.ErrResourceExhausted)
389	}
390	e.watches++
391	if params.Client != "" {
392		e.clients[params.Client]++
393	}
394	e.mu.Unlock()
395	defer func() {
396		e.mu.Lock()
397		e.watches--
398		if params.Client != "" {
399			if e.clients[params.Client]--; e.clients[params.Client] == 0 {
400				delete(e.clients, params.Client)
401			}
402		}
403		e.mu.Unlock()
404	}()
405
406	if params.Ready != nil {
407		params.Ready()
408	}
409	log := zerowrap.FromCtx(ctx)
410	log.Debug().Strs("topics", params.Topics).Bool("resume", params.ResumeAfter != "").Bool("gap", gap).Msg("watch started")
411	if gap {
412		if err := send(ports.WatchEvent{Gap: true}); err != nil {
413			return err
414		}
415	}
416	for {
417		if err := e.stillAllowed(ctx, w); err != nil {
418			return err
419		}
420		batch, changed, oldest, lost, closed := e.since(next)
421		if lost {
422			if err := send(ports.WatchEvent{Gap: true}); err != nil {
423				return err
424			}
425			next = oldest
426			continue
427		}
428		for _, be := range batch {
429			if err := e.deliver(ctx, w, be, send); err != nil {
430				return err
431			}
432			next = be.seq + 1
433		}
434		if len(batch) > 0 {
435			continue
436		}
437		if closed {
438			return nil
439		}
440		e.wait(ctx, w, changed)
441		if ctx.Err() != nil {
442			return nil
443		}
444	}
445}
446
447// wait blocks until more events arrive or ctx ends. A watcher with a stored
448// token also wakes up every few seconds, so that a revocation ends the watch
449// of a quiet forge too (the caller checks the token on the next turn).
450func (e *Events) wait(ctx context.Context, w *watch, changed <-chan struct{}) {
451	var idle <-chan time.Time
452	if w.p.TokenID() != "" {
453		t := time.NewTimer(e.idleCheck())
454		defer t.Stop()
455		idle = t.C
456	}
457	select {
458	case <-changed:
459	case <-idle:
460	case <-ctx.Done():
461	}
462}
463
464// idleCheck is how long a watcher with a stored token waits for news before
465// checking its token anyway.
466func (e *Events) idleCheck() time.Duration {
467	if e.every <= 0 {
468		return DefaultRevalidateEvery
469	}
470	return min(e.every, DefaultRevalidateEvery)
471}
472
473// stillAllowed checks that the holder of a stored token may still watch: the
474// token has not expired and, at most every few seconds, still exists. Root and
475// anonymous callers have nothing to lose. The owner topic needs nothing more:
476// a stored token with owner rights loses them only by ceasing to exist, which
477// is checked here, and the owner's web session, which this use case cannot see,
478// is checked by the web adapter before it sends anything.
479func (e *Events) stillAllowed(ctx context.Context, w *watch) error {
480	if w.p.TokenID() == "" {
481		return nil
482	}
483	if exp := w.p.ExpiresAt(); !exp.IsZero() && !e.clock.Now().Before(exp) {
484		return domain.ErrUnauthenticated
485	}
486	if e.every > 0 && time.Since(w.checked) < e.every {
487		return nil
488	}
489	if err := e.auth.Revalidate(ctx, w.p); err != nil {
490		if ctx.Err() != nil {
491			return nil
492		}
493		return err
494	}
495	w.checked = time.Now()
496	return nil
497}
498
499// since returns the buffered events from sequence next on, and the channel
500// that closes when more arrive. lost is true when next fell out of the buffer;
501// the caller then restarts from oldest, the first buffered sequence. The
502// returned slice is a copy.
503func (e *Events) since(next uint64) (batch []bufferedEvent, changed <-chan struct{}, oldest uint64, lost, closed bool) {
504	e.mu.Lock()
505	defer e.mu.Unlock()
506	oldest = e.nextSeq - uint64(len(e.ring))
507	if next < oldest {
508		return nil, e.changed, oldest, true, e.closed
509	}
510	batch = append(batch, e.ring[next-oldest:]...)
511	return batch, e.changed, oldest, false, e.closed
512}
513
514// deliver sends the event if the watcher follows its topic and may see it.
515func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send func(ports.WatchEvent) error) error {
516	if err := e.stillAllowed(ctx, w); err != nil {
517		return err
518	}
519	out := ports.WatchEvent{ID: e.id(be.seq), Event: be.ev}
520	switch ev := be.ev.(type) {
521	case domain.EventGap:
522		// The feeder lost events: tell every watcher.
523		return send(ports.WatchEvent{Gap: true})
524	case domain.RepoDeleted:
525		// The repository is gone: use the visibility it had.
526		if !w.wantsEvent(ev.Repo, ev) || !canReadAs(w.p, ev.Repo, ev.Visibility) {
527			return nil
528		}
529		return send(out)
530	case domain.IssueSubmitted:
531		return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send)
532	case domain.IssuePendingRemoved:
533		return e.deliverOwnerOnly(ctx, w, ev.Repo, out, send)
534	case domain.RepoCreated:
535		return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
536	case domain.RepoUpdated:
537		return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
538	case domain.MirrorFailed:
539		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
540	case domain.MirrorRecovered:
541		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542	case domain.ReleasePublished:
543		return e.deliverRelease(ctx, w, ev.Release, be, out, send)
544	case domain.ReleaseUpdated:
545		return e.deliverRelease(ctx, w, ev.Release, be, out, send)
546	case domain.ReleaseDeleted:
547		return e.deliverRelease(ctx, w, ev.Release, be, out, send)
548	case domain.ReleaseUnpublished:
549		return e.deliverRelease(ctx, w, ev.Release, be, out, send)
550	case domain.PushEvent:
551		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
552	case domain.MergeRequestOpened:
553		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
554	case domain.MergeRequestMerged:
555		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
556	case domain.MergeRequestClosed:
557		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
558	case domain.MergeRequestAutoMergeEnabled:
559		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
560	case domain.MergeRequestAutoMergeCancelled:
561		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
562	case domain.MergeRequestReady:
563		return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
564	case domain.CommentAdded:
565		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
566	case domain.IssueReferenced:
567		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
568	case domain.IssueOpened:
569		return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
570	case domain.IssueClosed:
571		return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
572	case domain.IssueReopened:
573		return e.deliverRepo(ctx, w, ev.Issue.Repo, false, be, out, send)
574	case domain.IssueCommented:
575		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
576	case domain.CommentEdited:
577		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
578	case domain.CommentDeleted:
579		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
580	case domain.CommentResolved:
581		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
582	case domain.MergeRequestMergeability:
583		return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
584	case domain.RunUpdated:
585		// Home shows the CI state of each repository.
586		return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
587	case domain.JobUpdated:
588		// Home gets a job when it ends only: a failed job turns the state to
589		// failing before its run ends. Its progress stays on the topic of its
590		// repository.
591		if !w.repos[ev.Job.Repo] && ev.Job.Phase != domain.CIPhaseEnded {
592			return nil
593		}
594		return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
595	}
596	return nil // an event this version does not stream
597}
598
599// deliverOwnerOnly sends an owner-topic event: never home, never a repository
600// topic, not even to an owner who watches the repository. No content travels.
601func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.RepoName, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
602	if !w.owner {
603		return nil
604	}
605	repo, err := lookupRepo(ctx, e.repos.meta, name)
606	switch {
607	case errors.Is(err, domain.ErrNotFound), ctx.Err() != nil:
608		return nil
609	case err != nil:
610		return fmt.Errorf("check access to %q: %w", name, err)
611	}
612	if !repo.Settings.IssuesEnabled() {
613		return nil
614	}
615	return send(out)
616}
617
618// deliverRepo sends an event about a repository if the watcher could read the
619// repository when the event happened and can read it now, and the repository
620// is still the one the event is about. A repository that no longer exists is
621// skipped: its RepoDeleted follows or came before.
622//
623// The exception is a repository that became private: the watchers who could
624// read it before are told once, with its name and its new visibility only,
625// so that what they show can be removed.
626func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName, withRepo bool, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
627	if !w.wantsEvent(name, be.ev) || !be.known {
628		return nil
629	}
630	repo, err := lookupRepo(ctx, e.repos.meta, name)
631	switch {
632	case errors.Is(err, domain.ErrNotFound):
633		return nil
634	case ctx.Err() != nil:
635		return nil
636	case err != nil:
637		return fmt.Errorf("check access to %q: %w", name, err)
638	}
639	if repo.CreatedAt.After(be.created) {
640		return nil // the event is about a repository of this name that was deleted
641	}
642	if !canReadAs(w.p, name, be.vis) {
643		if up, ok := be.ev.(domain.RepoUpdated); ok && canReadAs(w.p, name, up.Previous) {
644			out.Repo = &ports.RepoView{Repo: domain.Repo{Name: name, Visibility: up.Visibility}}
645			return send(out)
646		}
647		return nil
648	}
649	if !canRead(w.p, repo) {
650		return nil
651	}
652	if isIssueEvent(be.ev) && !repo.Settings.IssuesEnabled() {
653		return nil // issues were turned off since: a replay must not show them
654	}
655	if withRepo {
656		view, err := e.repos.View(ctx, repo)
657		if err != nil {
658			return err
659		}
660		out.Repo = &view
661	}
662	return send(out)
663}
664
665// deliverRelease sends a release event. The event of a draft goes only to
666// release writers: a draft is invisible to every other reader.
667func (e *Events) deliverRelease(ctx context.Context, w *watch, rel domain.Release, be bufferedEvent, out ports.WatchEvent, send func(ports.WatchEvent) error) error {
668	if rel.Draft && !releaseWriter(w.p, rel.Repo) {
669		return nil
670	}
671	return e.deliverRepo(ctx, w, rel.Repo, false, be, out, send)
672}
673
674// isIssueEvent reports whether ev is about an issue.
675func isIssueEvent(ev domain.Event) bool {
676	switch ev.(type) {
677	case domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueReferenced:
678		return true
679	}
680	return false
681}
682
683// isRepoLifecycleEvent reports whether ev is a repository update or deletion:
684// the owner topic follows them, for they change what the inbox lists.
685func isRepoLifecycleEvent(ev domain.Event) bool {
686	switch ev.(type) {
687	case domain.RepoUpdated, domain.RepoDeleted:
688		return true
689	}
690	return false
691}
692
693// isItemEvent reports whether ev changes an inbox item, including CI runs.
694func isItemEvent(ev domain.Event) bool {
695	switch ev.(type) {
696	case domain.IssueReferenced:
697		return false // back-reference refreshes do not change the inbox
698	case domain.RunUpdated, domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
699		domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
700		domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
701		domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
702		return true
703	}
704	return isIssueEvent(ev)
705}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close