detached 5278df2

mergeability.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"slices"
9	"strings"
10	"sync"
11	"time"
12
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15)
16
17const (
18	defaultMergeabilityWorkers = 2
19	mergeabilityRetries        = 5
20	mergeabilityRetryInitial   = time.Second
21	mergeabilityRetryMax       = time.Minute
22)
23
24// mergeBackoff is the delay before retry number attempt (1-based).
25func mergeBackoff(attempt int) time.Duration {
26	d := mergeabilityRetryInitial
27	for i := 1; i < attempt; i++ {
28		d *= 2
29		if d >= mergeabilityRetryMax {
30			return mergeabilityRetryMax
31		}
32	}
33	return d
34}
35
36// MergeabilityConfig holds the dependencies of the repository reconciler.
37type MergeabilityConfig struct {
38	Store   ports.MergeRequestStore
39	Meta    ports.RepoMetaStore
40	Git     ports.MergeGit
41	Bus     ports.EventBus
42	Clock   ports.Clock
43	Workers int // defaults to 2
44	// After is injectable to drive backoff without wall-clock waits in tests.
45	After func(time.Duration) <-chan time.Time
46}
47
48// Mergeability reconciles derived records, with one writer per repository.
49// Its work and retry state are bounded by the number of repositories.
50type Mergeability struct {
51	cfg            MergeabilityConfig
52	ctx            context.Context
53	cancel         context.CancelFunc
54	wg             sync.WaitGroup
55	mu             sync.Mutex
56	dirty          map[domain.RepoName]bool
57	order          []domain.RepoName
58	running, again map[domain.RepoName]bool
59	wake           chan struct{}
60	retryMu        sync.Mutex
61	retries        map[domain.RepoName]int
62	scheduled      map[domain.RepoName]bool
63	heap           []retryItem
64	retryKick      chan struct{}
65	closed         bool
66}
67
68// MergeabilityHook is the unchanged trigger and reader contract.
69type MergeabilityHook interface {
70	ports.MergeabilityTrigger
71	ports.MergeabilityReader
72}
73
74var _ MergeabilityHook = (*Mergeability)(nil)
75
76// NewMergeability builds a reconciler whose lifetime is controlled by Close.
77func NewMergeability(ctx context.Context, cfg MergeabilityConfig) *Mergeability {
78	if cfg.Workers <= 0 {
79		cfg.Workers = defaultMergeabilityWorkers
80	}
81	if cfg.After == nil {
82		cfg.After = time.After
83	}
84	ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
85	return &Mergeability{cfg: cfg, ctx: ctx, cancel: cancel,
86		dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1),
87		retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)}
88}
89
90// Start starts workers and discovers repositories, retrying failed discovery.
91func (s *Mergeability) Start() {
92	s.wg.Add(2 + s.cfg.Workers)
93	go s.scheduler()
94	go func() {
95		defer s.wg.Done()
96		for attempt := 0; attempt <= mergeabilityRetries; attempt++ {
97			repos, err := s.cfg.Meta.List(s.ctx)
98			if err != nil && s.ctx.Err() == nil {
99				ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("mergeability: list the repositories at startup")
100			}
101			if err == nil {
102				for _, repo := range repos {
103					s.markDirty(repo.Name)
104				}
105				return
106			}
107			if attempt == mergeabilityRetries {
108				return
109			}
110			select {
111			case <-s.ctx.Done():
112				return
113			case <-s.cfg.After(mergeBackoff(attempt + 1)):
114			}
115		}
116	}()
117	for range s.cfg.Workers {
118		go s.loop()
119	}
120}
121
122// Close cancels workers and pending retry waits, then waits for them.
123func (s *Mergeability) Close(_ context.Context) {
124	s.retryMu.Lock()
125	s.closed = true
126	s.retryMu.Unlock()
127	s.cancel()
128	s.wg.Wait()
129}
130
131func (s *Mergeability) signal() {
132	select {
133	case s.wake <- struct{}{}:
134	default:
135	}
136}
137
138func (s *Mergeability) markDirty(repo domain.RepoName) {
139	s.mu.Lock()
140	if s.running[repo] {
141		s.again[repo] = true
142	} else if !s.dirty[repo] {
143		s.dirty[repo] = true
144		s.order = append(s.order, repo)
145	}
146	s.mu.Unlock()
147	s.signal()
148}
149
150func (s *Mergeability) next() (domain.RepoName, bool) {
151	s.mu.Lock()
152	defer s.mu.Unlock()
153	if len(s.order) == 0 {
154		return domain.RepoName{}, false
155	}
156	repo := s.order[0]
157	s.order = s.order[1:]
158	delete(s.dirty, repo)
159	s.running[repo] = true
160	if len(s.order) > 0 {
161		s.signal()
162	}
163	return repo, true
164}
165
166func (s *Mergeability) loop() {
167	defer s.wg.Done()
168	for s.ctx.Err() == nil {
169		repo, found := s.next()
170		if !found {
171			select {
172			case <-s.ctx.Done():
173				return
174			case <-s.wake:
175			}
176			continue
177		}
178		if s.reconcile(repo) {
179			s.retryDone(repo)
180		} else {
181			s.retry(repo)
182		}
183		s.mu.Lock()
184		delete(s.running, repo)
185		if s.again[repo] {
186			delete(s.again, repo)
187			s.dirty[repo] = true
188			s.order = append(s.order, repo)
189			s.signal()
190		}
191		s.mu.Unlock()
192	}
193}
194
195// OnOpened implements ports.MergeabilityTrigger.
196func (s *Mergeability) OnOpened(_ context.Context, mr domain.MergeRequest) { s.markDirty(mr.Repo) }
197
198// OnBranchesMoved dirties the repository for any branch update.
199func (s *Mergeability) OnBranchesMoved(_ context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
200	for _, update := range updates {
201		if strings.HasPrefix(update.Ref, "refs/heads/") {
202			s.markDirty(repo)
203			return
204		}
205	}
206}
207
208// Read returns a current record, or checking and requests reconciliation.
209func (s *Mergeability) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
210	if !mr.HeadID.IsZero() && !targetTip.IsZero() {
211		rec, err := s.cfg.Store.GetMergeability(ctx, mr.Repo, mr.Number)
212		if err == nil && rec.IsFor(targetTip, mr.HeadID) {
213			return rec
214		}
215	}
216	s.markDirty(mr.Repo)
217	return domain.Mergeability{Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityChecking}
218}
219
220// reconcile settles each open request for the tips read in this pass. A push
221// during computation requests another pass; old-tip records are harmless.
222func (s *Mergeability) reconcile(repo domain.RepoName) bool {
223	ctx := s.ctx
224	meta, err := s.cfg.Meta.Get(ctx, repo)
225	if errors.Is(err, domain.ErrNotFound) {
226		return true
227	}
228	if err != nil {
229		s.logFailure(ctx, err, "get the repository", repo, 0)
230		return false
231	}
232	mrs, err := s.cfg.Store.ListOpen(ctx, repo)
233	if err != nil {
234		s.logFailure(ctx, err, "list the open merge requests", repo, 0)
235		return false
236	}
237	ok := true
238	for _, mr := range mrs {
239		if ctx.Err() != nil {
240			return true
241		}
242		head, tip, found, transient := s.tips(ctx, mr)
243		if !found {
244			ok = ok && !transient
245			continue
246		}
247		prev, err := s.cfg.Store.GetMergeability(ctx, repo, mr.Number)
248		if err != nil && !errors.Is(err, domain.ErrNotFound) {
249			s.logFailure(ctx, err, "get the mergeability", repo, mr.Number)
250			ok = false
251			continue
252		}
253		if prev.IsFor(tip, head) {
254			continue
255		}
256		res, err := s.compute(ctx, repo, mr.Number, tip, head)
257		if err != nil {
258			s.logFailure(ctx, err, "compute the mergeability", repo, mr.Number)
259			ok = false
260			continue
261		}
262		res.Version = prev.Version
263		err = s.cfg.Store.PutMergeability(ctx, res)
264		if errors.Is(err, domain.ErrNotFound) {
265			continue
266		}
267		if err != nil {
268			if !errors.Is(err, domain.ErrConflict) { // retried by the next pass
269				s.logFailure(ctx, err, "store the mergeability", repo, mr.Number)
270			}
271			ok = false
272			continue
273		}
274		s.cfg.Bus.Publish(ctx, domain.MergeRequestMergeability{
275			Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276			Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277		})
278	}
279	return ok
280}
281
282// logFailure logs an unexpected failure; cancellation is expected on Close.
283func (s *Mergeability) logFailure(ctx context.Context, err error, op string, repo domain.RepoName, number int64) {
284	if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, domain.ErrNotFound) {
285		return
286	}
287	e := ctxLog(ctx).Error().Err(err).Str("repo", repo.String())
288	if number != 0 {
289		e = e.Int64("number", number)
290	}
291	e.Msg("mergeability: " + op)
292}
293
294// retryItem is one scheduled retry.
295type retryItem struct {
296	due time.Time
297	key domain.RepoName
298}
299
300// retry schedules a retry of key after a bounded exponential backoff. It is
301// deduplicated per key, gives up after mergeabilityRetries attempts and stops
302// on Close. All retries share one scheduler goroutine.
303func (s *Mergeability) retry(key domain.RepoName) {
304	s.retryMu.Lock()
305	defer s.retryMu.Unlock()
306	if s.closed || s.scheduled[key] {
307		return
308	}
309	s.retries[key]++
310	n := s.retries[key]
311	if n > mergeabilityRetries {
312		delete(s.retries, key)
313		return
314	}
315	s.scheduled[key] = true
316	it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key}
317	i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int {
318		if a.due.After(b.due) {
319			return 1
320		}
321		return -1 // equal dues keep their insertion order
322	})
323	s.heap = slices.Insert(s.heap, i, it)
324	if i == 0 {
325		select {
326		case s.retryKick <- struct{}{}:
327		default:
328		}
329	}
330}
331
332// scheduler is the only goroutine that waits for retries: it sleeps until the
333// earliest is due and puts it back in the normal queue.
334func (s *Mergeability) scheduler() {
335	defer s.wg.Done()
336	// The timer armed for waited; a kick re-arms it only when the earliest
337	// retry changed, so a stale kick never starts a second timer.
338	var (
339		after  <-chan time.Time
340		waited retryItem
341	)
342	for {
343		s.retryMu.Lock()
344		var head retryItem
345		has := len(s.heap) > 0
346		if has {
347			head = s.heap[0]
348		}
349		s.retryMu.Unlock()
350		if !has {
351			after = nil
352			select {
353			case <-s.ctx.Done():
354				return
355			case <-s.retryKick:
356				continue
357			}
358		}
359		if after == nil || head.key != waited.key || !head.due.Equal(waited.due) {
360			after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head
361		}
362		select {
363		case <-s.ctx.Done():
364			return
365		case <-s.retryKick:
366			continue // an earlier retry may have arrived
367		case <-after:
368			after = nil
369		}
370		for _, k := range s.popDue(waited) {
371			if s.ctx.Err() != nil {
372				return
373			}
374			s.markDirty(k)
375		}
376	}
377}
378
379// popDue removes the waited item and the ones due as well. The waited item is
380// removed regardless of the clock only when both its key and its deadline
381// match: a replacement retry of the same repository is kept until it is due.
382func (s *Mergeability) popDue(waited retryItem) []domain.RepoName {
383	s.retryMu.Lock()
384	defer s.retryMu.Unlock()
385	now := s.cfg.Clock.Now()
386	var out []domain.RepoName
387	s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
388		if (it.key != waited.key || !it.due.Equal(waited.due)) && it.due.After(now) {
389			return false
390		}
391		out = append(out, it.key)
392		delete(s.scheduled, it.key)
393		return true
394	})
395	return out
396}
397
398// retryDone forgets the failures of key after a success.
399func (s *Mergeability) retryDone(key domain.RepoName) {
400	s.retryMu.Lock()
401	delete(s.retries, key)
402	delete(s.scheduled, key)
403	s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key })
404	select {
405	case s.retryKick <- struct{}{}:
406	default:
407	}
408	s.retryMu.Unlock()
409}
410
411// tips reads the heads of the source and the target of mr. ok is false when
412// the check is over: a branch is gone (the record is deleted) or Git failed,
413// which is transient.
414func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok, transient bool) {
415	head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
416	tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target)
417	switch {
418	case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound):
419		if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil {
420			if ctx.Err() == nil {
421				ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")
422			}
423			transient = ctx.Err() == nil
424		}
425		return domain.ObjectID{}, domain.ObjectID{}, false, transient
426	case herr != nil || terr != nil:
427		if ctx.Err() != nil {
428			return domain.ObjectID{}, domain.ObjectID{}, false, false
429		}
430		ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")
431		return domain.ObjectID{}, domain.ObjectID{}, false, true
432	}
433	return head, tip, true, false
434}
435
436// compute runs the Git reads that decide the state of head against tip.
437func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number int64, tip, head domain.ObjectID) (domain.Mergeability, error) {
438	m := domain.Mergeability{Repo: repo, Number: number, TargetID: tip, HeadID: head, CheckedAt: s.cfg.Clock.Now().UTC()}
439	ancestor := tip == head
440	if !ancestor {
441		var err error
442		if ancestor, err = s.cfg.Git.IsAncestor(ctx, repo, tip, head); err != nil {
443			return m, err
444		}
445	}
446	if ancestor {
447		m.State = domain.MergeabilityUpToDate
448		return m, nil
449	}
450	tree, err := s.cfg.Git.MergeTree(ctx, repo, tip, head)
451	if err != nil {
452		return m, err
453	}
454	if !tree.Clean {
455		m.State = domain.MergeabilityConflict
456		tree = tree.Bounded()
457		m.ConflictPaths, m.ConflictPathsTruncated = tree.ConflictPaths, tree.Truncated
458		return m, nil
459	}
460	m.State = domain.MergeabilityBehind
461	n, err := s.cfg.Git.CountBehind(ctx, repo, head, tip, domain.BehindLimit)
462	if err != nil {
463		return m, err
464	}
465	m.Behind, m.BehindCapped = domain.BoundBehind(n)
466	return m, nil
467}

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