detached 70c00ad

#7 feat(mr): live mergeability, merge commit mode and inline merge errors

state
merged · 70c00ad · fast-forward
branch
feat/mr-mergeability → main
opened
bnema ·
updated

Merge requests show live mergeability and gain a signed merge-commit mode.

  • Mergeability: a background check per repository (merge-tree) reports checking, up to date, behind N or conflict with paths. It is stored, exposed in the API (feature merge_requests.mergeability), published as a Watch event for every settled result on new branch tips, and shown live in the CLI and the web merge box. Failed checks retry per repository with backoff (1 s to 1 min, 5 attempts).
  • Merge modes: fast-forward (requires up to date), squash (uses the merge-tree tree, so a behind but clean branch can be squashed) and merge commit (two parents, signed by the instance key, see 'ermite signing-key show'). Every mode is allowed by default; existing repos are upgraded once. Restrict with 'ermite repo edit --merge-modes'.
  • Merge answer: a refused merge re-renders the page with an inline error (409), never an error page. Post-merge side effects (source branch deletion, events) run after the response.
  • Requires bnema.dev/ermite-api v0.10.0.

Checks: build, vet, hexcheck -fail-on warn, go test -count=1 ./..., race on usecase and app, JS tests.

CommitSubjectAuthorDateSignatureStatus
70c00adtest: use Store.Mutate for the merge-mode upgrade test and skip id-only events in the inbox stream testbriceGPG Verified
bda2a5efix(mr): wait for the async source deletion before pruning in the squash test; simplify the retry pop conditionbriceGPG Verified
1475ed7build(deps): require ermite-api v0.10.0briceGPG Verified
fb5ff06test(mr): cover stale retry timers, branch disappearance and CAS conflict; drop unused clockbriceGPG Verified
c22da12fix(mr): keep replacement retries from stale timers and log reconcile failuresbriceGPG Verified
1036507refactor(mr): reconcile mergeability once per repositorybriceGPG Verified
0019f14refactor(mr): retry failed mergeability work per repository onlybriceGPG Verified
99b00c3fix(mr): count retry attempts only for the keys that wait in the schedulerbriceGPG Verified
33a90cdfix(mr): arm one retry timer per earliest retry in the mergeability schedulerbriceGPG Verified
41023defix(mr): bound mergeability retries with one scheduler and retry failed publishesbriceGPG Verified
0e2e418feat(cli): report the mergeability featurebriceGPG Verified
bfa2dcdfix(rpc): report checking mergeability on open merge requestsbriceGPG Verified
5ad3fbefix(events): deliver mergeability events on owner topicsbriceGPG Verified
f09b4fbfix(mr): bound conflict paths with exact-sized copiesbriceGPG Verified
f8b1d20fix(mr): retry failed mergeability checks with backoffbriceGPG Verified
f8bb86dfix(mr): alternate pending and dirty work in the mergeability queuebriceGPG Verified
a79212efix(mr): refresh live-event goldens and guard shared issue numbersbriceGPG Verified
5d95127fix(web): enable the merge button after choosing a mode by keyboardbriceGPG Verified
1b45752fix(web): keep the merge box inside its popover and the MR URL after a refused mergebriceGPG Verified
b5b4bf9feat(web,cli): live mergeability in the merge box, merge modes and inline merge errorsbriceGPG Verified
fc9623bfix(mr): safe async source deletion, single-run mergeability queue and domain-side merge-tree boundsbriceGPG Verified
bf27c54feat(mr): per-mode merge checks, signed merge commits and side effects after the responsebriceGPG Verified
b707720refactor(kvstore): decode the mergeability record without mutating itbriceGPG Verified
23c66c0feat(mr): background mergeability checks, their Watch event and API fieldbriceGPG Verified
8f36a2ffeat(mr): mergeability types, merge-tree checks, storage record and merge commit modebriceGPG Verified

Checks 8

89 files · +6371 -1025
8/8
M README.md +1 -1
M docs/cli.md +5 -4
M docs/commit-signatures.md +1 -1
M docs/configuration.md +2 -2
M docs/merge-requests.md +45 -7
M gen/ermite/storage/merge_request.pb.go +216 -24
M go.mod +1 -1
M go.sum +2 -2
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +4 -1
M internal/adapters/gitcli/git.go +12 -2
M internal/adapters/gitcli/merge_test.go +83 -0
A internal/adapters/gitcli/mergeability.go +87 -0
M internal/adapters/kvstore/ci_tip.go +29 -0
A internal/adapters/kvstore/ci_tip_test.go +34 -0
M internal/adapters/kvstore/mergerequests.go +150 -5
M internal/adapters/kvstore/mergerequests_open_test.go +25 -0
M internal/adapters/kvstore/mergerequests_test.go +120 -0
M internal/adapters/rpc/api.go +1 -1
M internal/adapters/rpc/convert.go +7 -0
M internal/adapters/rpc/convert_test.go +23 -1
M internal/adapters/rpc/mergerequest.go +47 -3
M internal/adapters/rpc/mergerequest_test.go +1 -0
M internal/adapters/web/events.go +6 -2
M internal/adapters/web/fragments.go +58 -8
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/fragments_test.go +1 -0
M internal/adapters/web/mr.go +156 -18
M internal/adapters/web/mr.templ +92 -4
M internal/adapters/web/mr_post.go +55 -3
M internal/adapters/web/mr_templ.go +1185 -737
A internal/adapters/web/mr_test.go +130 -0
M internal/adapters/web/static/css/60-mr.css +21 -0
M internal/adapters/web/static/js/mr.js +10 -1
M internal/adapters/web/web.go +4 -0
M internal/app/api_helpers_test.go +14 -1
M internal/app/api_test.go +1 -1
M internal/app/ci_schedule_trust_test.go +6 -2
M internal/app/cli_e2e_test.go +14 -2
M internal/app/cli_mr.go +150 -24
M internal/app/cli_mr_e2e_test.go +6 -6
M internal/app/cli_mr_test.go +83 -7
M internal/app/cli_repo.go +9 -7
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch.go +30 -0
M internal/app/cli_watch_test.go +43 -0
M internal/app/forge.go +14 -1
M internal/app/mr_api_test.go +3 -2
M internal/app/testdata/web/commits-root.html +1 -1
M internal/app/testdata/web/commits-small.html +1 -1
M internal/app/testdata/web/home.html +1 -1
M internal/app/testdata/web/refs.html +1 -1
M internal/app/testdata/web/summary.html +1 -1
M internal/app/web.go +5 -0
M internal/app/web_events_test.go +9 -0
M internal/app/web_inbox_test.go +15 -3
M internal/app/web_mr_test.go +202 -10
M internal/domain/event.go +14 -0
M internal/domain/mergerequest.go +155 -16
M internal/domain/mergerequest_test.go +50 -3
A internal/domain/mergetree_bound_test.go +53 -0
M internal/ports/ci.go +3 -0
M internal/ports/dto.go +8 -2
M internal/ports/mocks/CIRefTipStore.go +69 -0
M internal/ports/mocks/MergeGit.go +162 -0
M internal/ports/mocks/MergeRequestService.go +78 -0
M internal/ports/mocks/MergeRequestStore.go +192 -0
A internal/ports/mocks/MergeabilityReader.go +111 -0
A internal/ports/mocks/MergeabilityTrigger.go +146 -0
M internal/ports/ports.go +37 -0
M internal/ports/services.go +4 -0
M internal/usecase/ci_unit_test.go +1 -0
M internal/usecase/events.go +5 -1
M internal/usecase/events_test.go +13 -0
@@ -997,3 +997,16 @@ func TestNoteMutationEventVisibility(t *testing.T) {
997997 }
998998 })
999999 }
1000+
1001+func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
1002+ synctest.Test(t, func(t *testing.T) {
1003+ r := newEventRig(t, 0)
1004+ priv := r.repo("priv", domain.VisibilityPrivate)
1005+ ctx, cancel := context.WithCancel(context.Background())
1006+ defer cancel()
1007+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
1008+ synctest.Wait()
1009+ r.feed <- domain.MergeRequestMergeability{Repo: priv, Number: 1, Visibility: domain.VisibilityPrivate, CreatedAt: now, At: now}
1010+ assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
1011+ })
1012+}
@@ -997,3 +997,16 @@ func TestNoteMutationEventVisibility(t *testing.T) {
997 }997 }
998 })998 })
999 }999 }
1000+
1001+func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
1002+ synctest.Test(t, func(t *testing.T) {
1003+ r := newEventRig(t, 0)
1004+ priv := r.repo("priv", domain.VisibilityPrivate)
1005+ ctx, cancel := context.WithCancel(context.Background())
1006+ defer cancel()
1007+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
1008+ synctest.Wait()
1009+ r.feed <- domain.MergeRequestMergeability{Repo: priv, Number: 1, Visibility: domain.VisibilityPrivate, CreatedAt: now, At: now}
1010+ assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
1011+ })
1012+}
M internal/usecase/export_test.go +46 -0
@@ -171,3 +171,49 @@ func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
171171
172172 // MaxSpent is the bound of the spent set.
173173 const MaxSpent = maxSpent
174+
175+// SetAfterMergeGate makes the post-merge goroutine of m run f before anything
176+// else, to place a test between a merge and its side effects.
177+func (m *MergeRequests) SetAfterMergeGate(f func()) { m.afterGate = f }
178+
179+// ScheduledRetries gives the number of retries waiting in the scheduler.
180+func (s *Mergeability) ScheduledRetries() int {
181+ s.retryMu.Lock()
182+ defer s.retryMu.Unlock()
183+ return len(s.heap)
184+}
185+
186+// RetryAttempts gives the number of repositories whose
187+// failed attempts are counted.
188+func (s *Mergeability) RetryAttempts() int {
189+ s.retryMu.Lock()
190+ defer s.retryMu.Unlock()
191+ return len(s.retries)
192+}
193+
194+// ReconcileForTest runs a repository pass synchronously without workers.
195+func (s *Mergeability) ReconcileForTest(repo domain.RepoName) bool { return s.reconcile(repo) }
196+
197+// RetryForTest schedules a retry of repo as a failed pass does.
198+func (s *Mergeability) RetryForTest(repo domain.RepoName) { s.retry(repo) }
199+
200+// RetryDoneForTest forgets the retries of repo as a successful pass does.
201+func (s *Mergeability) RetryDoneForTest(repo domain.RepoName) { s.retryDone(repo) }
202+
203+// RetryDueForTest gives the deadline of the retry scheduled for repo.
204+func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
205+ s.retryMu.Lock()
206+ defer s.retryMu.Unlock()
207+ for _, it := range s.heap {
208+ if it.key == repo {
209+ return it.due, true
210+ }
211+ }
212+ return time.Time{}, false
213+}
214+
215+// PopDueForTest is the scheduler step taken when the timer armed for the
216+// retry (repo, due) fires. It gives the repositories it released.
217+func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218+ return s.popDue(retryItem{key: repo, due: due})
219+}
@@ -171,3 +171,49 @@ func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
171 171
172 // MaxSpent is the bound of the spent set.172 // MaxSpent is the bound of the spent set.
173 const MaxSpent = maxSpent173 const MaxSpent = maxSpent
174+
175+// SetAfterMergeGate makes the post-merge goroutine of m run f before anything
176+// else, to place a test between a merge and its side effects.
177+func (m *MergeRequests) SetAfterMergeGate(f func()) { m.afterGate = f }
178+
179+// ScheduledRetries gives the number of retries waiting in the scheduler.
180+func (s *Mergeability) ScheduledRetries() int {
181+ s.retryMu.Lock()
182+ defer s.retryMu.Unlock()
183+ return len(s.heap)
184+}
185+
186+// RetryAttempts gives the number of repositories whose
187+// failed attempts are counted.
188+func (s *Mergeability) RetryAttempts() int {
189+ s.retryMu.Lock()
190+ defer s.retryMu.Unlock()
191+ return len(s.retries)
192+}
193+
194+// ReconcileForTest runs a repository pass synchronously without workers.
195+func (s *Mergeability) ReconcileForTest(repo domain.RepoName) bool { return s.reconcile(repo) }
196+
197+// RetryForTest schedules a retry of repo as a failed pass does.
198+func (s *Mergeability) RetryForTest(repo domain.RepoName) { s.retry(repo) }
199+
200+// RetryDoneForTest forgets the retries of repo as a successful pass does.
201+func (s *Mergeability) RetryDoneForTest(repo domain.RepoName) { s.retryDone(repo) }
202+
203+// RetryDueForTest gives the deadline of the retry scheduled for repo.
204+func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
205+ s.retryMu.Lock()
206+ defer s.retryMu.Unlock()
207+ for _, it := range s.heap {
208+ if it.key == repo {
209+ return it.due, true
210+ }
211+ }
212+ return time.Time{}, false
213+}
214+
215+// PopDueForTest is the scheduler step taken when the timer armed for the
216+// retry (repo, due) fires. It gives the repositories it released.
217+func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
218+ return s.popDue(retryItem{key: repo, due: due})
219+}
A internal/usecase/mergeability.go +467 -0
@@ -0,0 +1,467 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
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+
17+const (
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).
25+func 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.
37+type 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.
50+type 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.
69+type MergeabilityHook interface {
70+ ports.MergeabilityTrigger
71+ ports.MergeabilityReader
72+}
73+
74+var _ MergeabilityHook = (*Mergeability)(nil)
75+
76+// NewMergeability builds a reconciler whose lifetime is controlled by Close.
77+func 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.
91+func (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.
123+func (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+
131+func (s *Mergeability) signal() {
132+ select {
133+ case s.wake <- struct{}{}:
134+ default:
135+ }
136+}
137+
138+func (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+
150+func (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+
166+func (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.
196+func (s *Mergeability) OnOpened(_ context.Context, mr domain.MergeRequest) { s.markDirty(mr.Repo) }
197+
198+// OnBranchesMoved dirties the repository for any branch update.
199+func (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.
209+func (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.
222+func (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.
283+func (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.
295+type 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.
303+func (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.
334+func (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.
382+func (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.
399+func (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.
414+func (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.
437+func (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+}
@@ -0,0 +1,467 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
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+
17+const (
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).
25+func 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.
37+type 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.
50+type 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.
69+type MergeabilityHook interface {
70+ ports.MergeabilityTrigger
71+ ports.MergeabilityReader
72+}
73+
74+var _ MergeabilityHook = (*Mergeability)(nil)
75+
76+// NewMergeability builds a reconciler whose lifetime is controlled by Close.
77+func 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.
91+func (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.
123+func (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+
131+func (s *Mergeability) signal() {
132+ select {
133+ case s.wake <- struct{}{}:
134+ default:
135+ }
136+}
137+
138+func (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+
150+func (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+
166+func (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.
196+func (s *Mergeability) OnOpened(_ context.Context, mr domain.MergeRequest) { s.markDirty(mr.Repo) }
197+
198+// OnBranchesMoved dirties the repository for any branch update.
199+func (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.
209+func (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.
222+func (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.
283+func (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.
295+type 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.
303+func (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.
334+func (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.
382+func (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.
399+func (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.
414+func (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.
437+func (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+}
A internal/usecase/mergeability_reconcile_test.go +91 -0
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestMergeabilityBackoffIsBounded(t *testing.T) {
20+ assert.Equal(t, time.Second, mergeBackoff(1))
21+ assert.Equal(t, 2*time.Second, mergeBackoff(2))
22+ assert.Equal(t, 4*time.Second, mergeBackoff(3))
23+ assert.Equal(t, time.Minute, mergeBackoff(20))
24+}
25+
26+func TestMergeabilityReadMarksTheRepoDirty(t *testing.T) {
27+ repo, err := domain.ParseRepoName("proj")
28+ require.NoError(t, err)
29+ oid, err := domain.ParseObjectID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
30+ require.NoError(t, err)
31+ store := mocks.NewMergeRequestStore(t)
32+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
33+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store})
34+ defer svc.Close(context.Background())
35+ mr := domain.MergeRequest{Repo: repo, Number: 7, HeadID: oid}
36+ assert.Equal(t, domain.MergeabilityChecking, svc.Read(context.Background(), mr, oid).State)
37+ // Another MR in the same repository is the same unit of work.
38+ mr.Number = 8
39+ mr.HeadID = domain.ObjectID{}
40+ svc.Read(context.Background(), mr, oid)
41+ got, found := svc.next()
42+ require.True(t, found)
43+ assert.Equal(t, repo, got)
44+ _, found = svc.next()
45+ assert.False(t, found)
46+}
47+
48+func TestMergeabilityOnePassPerRepoAtATimeAndAgain(t *testing.T) {
49+ repo, err := domain.ParseRepoName("proj")
50+ require.NoError(t, err)
51+ other, err := domain.ParseRepoName("other")
52+ require.NoError(t, err)
53+ meta, store := mocks.NewRepoMetaStore(t), mocks.NewMergeRequestStore(t)
54+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
55+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
56+ meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other}, nil)
57+ entered, release, otherPassed := make(chan struct{}, 4), make(chan struct{}), make(chan struct{})
58+ var passes, inflight, peak atomic.Int32
59+ store.EXPECT().ListOpen(mock.Anything, repo).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
60+ n := inflight.Add(1)
61+ if n > peak.Load() {
62+ peak.Store(n)
63+ }
64+ pass := passes.Add(1)
65+ entered <- struct{}{}
66+ if pass == 1 {
67+ <-release
68+ }
69+ inflight.Add(-1)
70+ return nil, nil
71+ })
72+ store.EXPECT().ListOpen(mock.Anything, other).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
73+ close(otherPassed)
74+ return nil, nil
75+ })
76+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store, Meta: meta, Workers: 2})
77+ svc.Start()
78+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
79+ <-entered
80+ for range 50 {
81+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
82+ }
83+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: other})
84+ <-otherPassed // the other worker has serviced all work eligible meanwhile
85+ assert.EqualValues(t, 1, passes.Load())
86+ close(release)
87+ <-entered
88+ svc.Close(context.Background())
89+ assert.EqualValues(t, 2, passes.Load(), "all triggers during a pass coalesce into exactly one more pass")
90+ assert.EqualValues(t, 1, peak.Load())
91+}
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestMergeabilityBackoffIsBounded(t *testing.T) {
20+ assert.Equal(t, time.Second, mergeBackoff(1))
21+ assert.Equal(t, 2*time.Second, mergeBackoff(2))
22+ assert.Equal(t, 4*time.Second, mergeBackoff(3))
23+ assert.Equal(t, time.Minute, mergeBackoff(20))
24+}
25+
26+func TestMergeabilityReadMarksTheRepoDirty(t *testing.T) {
27+ repo, err := domain.ParseRepoName("proj")
28+ require.NoError(t, err)
29+ oid, err := domain.ParseObjectID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
30+ require.NoError(t, err)
31+ store := mocks.NewMergeRequestStore(t)
32+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
33+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store})
34+ defer svc.Close(context.Background())
35+ mr := domain.MergeRequest{Repo: repo, Number: 7, HeadID: oid}
36+ assert.Equal(t, domain.MergeabilityChecking, svc.Read(context.Background(), mr, oid).State)
37+ // Another MR in the same repository is the same unit of work.
38+ mr.Number = 8
39+ mr.HeadID = domain.ObjectID{}
40+ svc.Read(context.Background(), mr, oid)
41+ got, found := svc.next()
42+ require.True(t, found)
43+ assert.Equal(t, repo, got)
44+ _, found = svc.next()
45+ assert.False(t, found)
46+}
47+
48+func TestMergeabilityOnePassPerRepoAtATimeAndAgain(t *testing.T) {
49+ repo, err := domain.ParseRepoName("proj")
50+ require.NoError(t, err)
51+ other, err := domain.ParseRepoName("other")
52+ require.NoError(t, err)
53+ meta, store := mocks.NewRepoMetaStore(t), mocks.NewMergeRequestStore(t)
54+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
55+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
56+ meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other}, nil)
57+ entered, release, otherPassed := make(chan struct{}, 4), make(chan struct{}), make(chan struct{})
58+ var passes, inflight, peak atomic.Int32
59+ store.EXPECT().ListOpen(mock.Anything, repo).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
60+ n := inflight.Add(1)
61+ if n > peak.Load() {
62+ peak.Store(n)
63+ }
64+ pass := passes.Add(1)
65+ entered <- struct{}{}
66+ if pass == 1 {
67+ <-release
68+ }
69+ inflight.Add(-1)
70+ return nil, nil
71+ })
72+ store.EXPECT().ListOpen(mock.Anything, other).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
73+ close(otherPassed)
74+ return nil, nil
75+ })
76+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store, Meta: meta, Workers: 2})
77+ svc.Start()
78+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
79+ <-entered
80+ for range 50 {
81+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
82+ }
83+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: other})
84+ <-otherPassed // the other worker has serviced all work eligible meanwhile
85+ assert.EqualValues(t, 1, passes.Load())
86+ close(release)
87+ <-entered
88+ svc.Close(context.Background())
89+ assert.EqualValues(t, 2, passes.Load(), "all triggers during a pass coalesce into exactly one more pass")
90+ assert.EqualValues(t, 1, peak.Load())
91+}
A internal/usecase/mergeability_test.go +673 -0
@@ -0,0 +1,673 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "strconv"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+ "bnema.dev/ermite/internal/usecase"
22+)
23+
24+func fakeOID(t *testing.T, c string) domain.ObjectID {
25+ t.Helper()
26+ return oidFrom(t, strings.Repeat(c, 40))
27+}
28+
29+// mergeRig runs the mergeability service over the real adapters of mrRig.
30+type mergeRig struct {
31+ *mrRig
32+ svc *usecase.Mergeability
33+ mev <-chan domain.Event
34+ history []domain.MergeRequestMergeability
35+}
36+
37+func newMergeRig(t *testing.T) *mergeRig {
38+ t.Helper()
39+ r := newMRRig(t)
40+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
41+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Workers: 2,
42+ })
43+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
45+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
46+ Mergeability: svc,
47+ })
48+ t.Cleanup(func() { svc.Close(context.Background()) })
49+ return &mergeRig{mrRig: r, svc: svc, mev: r.bus.Subscribe(r.ctx)}
50+}
51+
52+func (r *mergeRig) record(n int64) (domain.Mergeability, error) {
53+ return r.store.MergeRequests().GetMergeability(r.ctx, r.repo, n)
54+}
55+
56+func (r *mergeRig) waitState(n int64, want domain.MergeabilityState, target, head domain.ObjectID) domain.Mergeability {
57+ r.t.Helper()
58+ var m domain.Mergeability
59+ deadline := time.After(10 * time.Second)
60+ for {
61+ var err error
62+ m, err = r.record(n)
63+ if err == nil && m.State == want && m.IsFor(target, head) {
64+ break
65+ }
66+ select {
67+ case ev := <-r.mev:
68+ if e, ok := ev.(domain.MergeRequestMergeability); ok {
69+ r.history = append(r.history, e)
70+ }
71+ case <-deadline:
72+ r.t.Fatalf("mergeability of !%d did not become %s", n, want)
73+ }
74+ }
75+
76+ return m
77+}
78+
79+// events returns the mergeability events published so far.
80+func (r *mergeRig) events() []domain.MergeRequestMergeability {
81+ out := r.history
82+ r.history = nil
83+ for {
84+ select {
85+ case ev := <-r.mev:
86+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
87+ out = append(out, m)
88+ }
89+ default:
90+ return out
91+ }
92+ }
93+}
94+
95+// awaitEvents receives mergeability events until n arrived, or fails after a
96+// bound: the workers publish after they stored the record the tests wait for.
97+func (r *mergeRig) awaitEvents(n int) []domain.MergeRequestMergeability {
98+ r.t.Helper()
99+ out := r.history
100+ r.history = nil
101+ deadline := time.After(10 * time.Second)
102+ for len(out) < n {
103+ select {
104+ case ev := <-r.mev:
105+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
106+ out = append(out, m)
107+ }
108+ case <-deadline:
109+ r.t.Fatalf("got %d mergeability events, want %d", len(out), n)
110+ }
111+ }
112+ return out
113+}
114+
115+func TestMergeabilityStatesEndToEnd(t *testing.T) {
116+ r := newMergeRig(t)
117+ r.svc.Start()
118+ main := r.seed()
119+ head := r.feature("feat")
120+ mr := r.open("feat")
121+
122+ // up to date: the target is an ancestor of the head.
123+ m := r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
124+ assert.False(t, m.CheckedAt.IsZero())
125+
126+ // behind: the target moved with a change that merges cleanly.
127+ r.sh("checkout", "-q", "main")
128+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
129+ r.push("main")
130+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
131+ m = r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
132+ assert.Equal(t, 1, m.Behind)
133+ assert.False(t, m.BehindCapped)
134+
135+ // conflict: the target changes a line the head changed.
136+ main3 := r.commit(author, "a.txt", "one\nTWO\nthree\n", "conflict a")
137+ r.push("main")
138+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main2.String(), New: main3.String(), Ref: "refs/heads/main"}})
139+ m = r.waitState(mr.Number, domain.MergeabilityConflict, main3, head)
140+ assert.Equal(t, []string{"a.txt"}, m.ConflictPaths)
141+
142+ got := r.awaitEvents(3)
143+ for i, s := range []domain.MergeabilityState{domain.MergeabilityUpToDate, domain.MergeabilityBehind, domain.MergeabilityConflict} {
144+ assert.Equal(t, s, got[i].Mergeability.State)
145+ assert.Equal(t, mr.Number, got[i].Number)
146+ }
147+}
148+
149+func TestMergeabilityReaderIsLazy(t *testing.T) {
150+ r := newMergeRig(t)
151+ main := r.seed()
152+ head := r.feature("feat")
153+ mr := r.open("feat") // queued, but the workers are not started
154+
155+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
156+ require.NoError(t, err)
157+ assert.Equal(t, domain.MergeabilityChecking, d.Mergeability.State)
158+
159+ r.svc.Start()
160+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
161+ d, err = r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
162+ require.NoError(t, err)
163+ assert.Equal(t, domain.MergeabilityUpToDate, d.Mergeability.State)
164+
165+ page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
166+ require.NoError(t, err)
167+ assert.Equal(t, domain.MergeabilityUpToDate, page.Mergeability[mr.Number].State)
168+}
169+
170+func TestMergeabilityPublishesEachSettledResultOnce(t *testing.T) {
171+ r := newMergeRig(t)
172+ r.seed()
173+ r.feature("feat")
174+ mr := r.open("feat")
175+ r.svc.ReconcileForTest(r.repo)
176+ require.Len(t, r.events(), 1)
177+
178+ // Same tips: nothing is computed, nothing is published.
179+ r.svc.ReconcileForTest(r.repo)
180+ assert.Empty(t, r.events())
181+
182+ // New tips publish even when the state and counts stay the same.
183+ r.sh("checkout", "-q", "feat")
184+ r.commit(author, "e.txt", "e\n", "more")
185+ r.push("feat")
186+ r.svc.ReconcileForTest(r.repo)
187+ assert.Len(t, r.events(), 1)
188+ m, err := r.record(mr.Number)
189+ require.NoError(t, err)
190+ assert.Equal(t, domain.MergeabilityUpToDate, m.State)
191+}
192+
193+func TestMergeabilityMergeReevaluatesOtherRequests(t *testing.T) {
194+ r := newMergeRig(t)
195+ r.svc.Start()
196+ main := r.seed()
197+ r.sh("checkout", "-q", "-B", "one", "main")
198+ oneHead := r.commit(author, "one.txt", "1\n", "one")
199+ r.push("one")
200+ r.sh("checkout", "-q", "-B", "two", "main")
201+ twoHead := r.commit(author, "two.txt", "2\n", "two")
202+ r.push("two")
203+ one, two := r.open("one"), r.open("two")
204+ r.waitState(one.Number, domain.MergeabilityUpToDate, main, oneHead)
205+ r.waitState(two.Number, domain.MergeabilityUpToDate, main, twoHead)
206+
207+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, one.Number, ports.MergeParams{HeadID: oneHead})
208+ require.NoError(t, err)
209+ m := r.waitState(two.Number, domain.MergeabilityBehind, oneHead, twoHead)
210+ assert.Equal(t, 1, m.Behind)
211+ _, err = r.record(one.Number)
212+ assert.ErrorIs(t, err, domain.ErrNotFound, "the merged request keeps no record")
213+}
214+
215+func TestMergeabilityClosedRequestRecordDeleted(t *testing.T) {
216+ r := newMergeRig(t)
217+ r.seed()
218+ r.feature("feat")
219+ mr := r.open("feat")
220+ r.svc.ReconcileForTest(r.repo)
221+ _, err := r.record(mr.Number)
222+ require.NoError(t, err)
223+
224+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
225+ require.NoError(t, err)
226+ _, err = r.record(mr.Number)
227+ assert.ErrorIs(t, err, domain.ErrNotFound)
228+ r.svc.ReconcileForTest(r.repo) // a late check writes nothing
229+ _, err = r.record(mr.Number)
230+ assert.ErrorIs(t, err, domain.ErrNotFound)
231+}
232+
233+func TestMergeabilityStartupRescanComputesMissingRecords(t *testing.T) {
234+ r := newMergeRig(t)
235+ main := r.seed()
236+ head := r.feature("feat")
237+ mr := r.open("feat")
238+ _, err := r.record(mr.Number)
239+ require.ErrorIs(t, err, domain.ErrNotFound)
240+
241+ // A fresh service has received no OnOpened or Read trigger.
242+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
243+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{},
244+ })
245+ defer svc.Close(context.Background())
246+ svc.Start()
247+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
248+}
249+
250+func TestMergeabilityNeedsNoCI(t *testing.T) {
251+ // The service is built without any CI: a target move still updates the
252+ // record, and the repository gets no pull ref (the service has no CI to create a run with).
253+ r := newMergeRig(t)
254+ r.svc.Start()
255+ main := r.seed()
256+ head := r.feature("feat")
257+ mr := r.open("feat")
258+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
259+ r.sh("checkout", "-q", "main")
260+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
261+ r.push("main")
262+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
263+ r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
264+
265+ assert.Empty(t, r.run(r.bare(), r.env(author), "for-each-ref", "refs/pull"))
266+}
267+
268+// --- unit tests over mocks ---
269+
270+type mockRig struct {
271+ store *mocks.MergeRequestStore
272+ meta *mocks.RepoMetaStore
273+ git *mocks.MergeGit
274+ bus *mocks.EventBus
275+ svc *usecase.Mergeability
276+ mr domain.MergeRequest
277+}
278+
279+func newMockRig(t *testing.T) *mockRig {
280+ t.Helper()
281+ repo := name(t, "proj")
282+ r := &mockRig{
283+ store: mocks.NewMergeRequestStore(t), meta: mocks.NewRepoMetaStore(t), git: mocks.NewMergeGit(t), bus: mocks.NewEventBus(t),
284+ mr: domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen},
285+ }
286+ r.store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{r.mr}, nil).Maybe()
287+ r.store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound).Maybe()
288+ r.meta.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
289+ r.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil).Maybe()
290+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
291+ Store: r.store, Meta: r.meta, Git: r.git, Bus: r.bus, Clock: fixedClock{},
292+ })
293+ t.Cleanup(func() { r.svc.Close(context.Background()) })
294+ return r
295+}
296+
297+func TestMergeabilityDedupsTriggers(t *testing.T) {
298+ r := newMockRig(t)
299+ repo := r.mr.Repo
300+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
301+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
302+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
303+ computed := make(chan struct{}, 1)
304+ var computes atomic.Int32
305+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).RunAndReturn(
306+ func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) {
307+ computes.Add(1)
308+ computed <- struct{}{}
309+ return true, nil
310+ })
311+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
312+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
313+
314+ for range 50 {
315+ r.svc.OnOpened(context.Background(), r.mr) // queued before any worker runs
316+ }
317+ r.svc.Start()
318+ <-computed
319+ r.svc.Close(context.Background()) // the workers have each looked at the queue once more
320+ assert.EqualValues(t, 1, computes.Load())
321+}
322+
323+func TestMergeabilityNoWriteForClosedRequest(t *testing.T) {
324+ r := newMockRig(t)
325+ repo := r.mr.Repo
326+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
327+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
328+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
329+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
330+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once() // closed meanwhile
331+ r.svc.ReconcileForTest(repo) // no event, no further write
332+}
333+
334+func TestMergeabilityBoundsConflictPathsAndBehind(t *testing.T) {
335+ t.Run("conflict paths", func(t *testing.T) {
336+ r := newMockRig(t)
337+ repo := r.mr.Repo
338+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
339+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
340+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
341+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil)
342+ var paths []string
343+ for i := 0; i < domain.MaxConflictPaths+3; i++ {
344+ paths = append(paths, strings.Repeat("p", i+1))
345+ }
346+ r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{ConflictPaths: paths}, nil)
347+ var put domain.Mergeability
348+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
349+ func(_ context.Context, m domain.Mergeability) error { put = m; return nil })
350+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
351+ r.svc.ReconcileForTest(repo)
352+ assert.Equal(t, domain.MergeabilityConflict, put.State)
353+ assert.Len(t, put.ConflictPaths, domain.MaxConflictPaths)
354+ assert.True(t, put.ConflictPathsTruncated)
355+ })
356+ t.Run("behind", func(t *testing.T) {
357+ r := newMockRig(t)
358+ repo := r.mr.Repo
359+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
360+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
361+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
362+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil)
363+ r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{Clean: true}, nil)
364+ r.git.EXPECT().CountBehind(mock.Anything, repo, head, tip, domain.MaxBehindCount+1).Return(domain.MaxBehindCount+1, nil)
365+ var put domain.Mergeability
366+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
367+ func(_ context.Context, m domain.Mergeability) error { put = m; return nil })
368+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
369+ r.svc.ReconcileForTest(repo)
370+ assert.Equal(t, domain.MaxBehindCount, put.Behind)
371+ assert.True(t, put.BehindCapped)
372+ })
373+}
374+
375+// A transient Git failure is retried once after the backoff, by rescanning
376+// the repository through the queue.
377+func TestMergeabilityRetriesATransientFailureWithBackoff(t *testing.T) {
378+ repo := name(t, "proj")
379+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
380+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
381+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
382+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
383+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
384+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
385+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
386+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
387+ var fails atomic.Int32
388+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").RunAndReturn(
389+ func(context.Context, domain.RepoName, string) (domain.ObjectID, error) {
390+ if fails.Add(1) == 1 {
391+ return domain.ObjectID{}, errors.New("transient")
392+ }
393+ return head, nil
394+ })
395+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
396+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
397+ published := make(chan struct{})
398+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
399+
400+ asked, fire := make(chan time.Duration, 4), make(chan time.Time)
401+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
402+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
403+ After: func(d time.Duration) <-chan time.Time { asked <- d; return fire },
404+ })
405+ t.Cleanup(func() { svc.Close(context.Background()) })
406+ svc.Start()
407+ svc.OnOpened(context.Background(), mr)
408+
409+ assert.Equal(t, time.Second, <-asked)
410+ select {
411+ case <-published:
412+ t.Fatal("published before the backoff elapsed")
413+ default:
414+ }
415+ fire <- time.Time{}
416+ <-published
417+ assert.Empty(t, asked, "one retry was enough and only one was scheduled")
418+}
419+
420+// Close stops a pending retry timer's goroutine.
421+func TestMergeabilityCloseStopsPendingRetries(t *testing.T) {
422+ repo := name(t, "proj")
423+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
424+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
425+ store.EXPECT().ListOpen(mock.Anything, repo).Return(nil, errors.New("db down"))
426+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
427+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
428+ asked := make(chan time.Duration, 4)
429+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
430+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
431+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
432+ })
433+ svc.Start()
434+ svc.OnOpened(context.Background(), mr)
435+ <-asked
436+ done := make(chan struct{})
437+ go func() { svc.Close(context.Background()); close(done) }()
438+ <-done // Close waits for the timer goroutine: it returns only if it stopped
439+}
440+
441+// Workers are not stopped by the cancellation of the context given to the
442+// constructor; Close stops them.
443+func TestMergeabilityWorkersOutliveTheParentContext(t *testing.T) {
444+ repo := name(t, "proj")
445+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
446+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
447+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
448+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
449+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
450+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
451+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
452+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
453+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
454+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
455+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
456+ published := make(chan struct{})
457+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
458+
459+ parent, cancel := context.WithCancel(context.Background())
460+ svc := usecase.NewMergeability(parent, usecase.MergeabilityConfig{Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}})
461+ svc.Start()
462+ cancel()
463+ svc.OnOpened(context.Background(), mr)
464+ <-published // computed although the parent is cancelled
465+ svc.Close(context.Background())
466+}
467+
468+// Failures of many requests of 3 repositories schedule at most one retry per
469+// repository on a single scheduler goroutine.
470+func TestMergeabilityRetriesAreBounded(t *testing.T) {
471+ const repos = 3
472+ store, meta := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t)
473+ barrier := name(t, "barrier")
474+ done := make(chan struct{})
475+ store.EXPECT().ListOpen(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) { close(done); return nil, nil }).Once()
476+ store.EXPECT().ListOpen(mock.Anything, mock.Anything).Return(nil, errors.New("db down"))
477+ meta.EXPECT().Get(mock.Anything, mock.Anything).Return(domain.Repo{}, nil)
478+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
479+ asked := make(chan time.Duration, repos)
480+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
481+ Store: store, Meta: meta, Clock: fixedClock{}, Workers: 1,
482+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
483+ })
484+ svc.Start()
485+ for r := range repos {
486+ repo := name(t, "proj"+strconv.Itoa(r))
487+ for range 20 {
488+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
489+ }
490+ }
491+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: barrier})
492+ <-done
493+ // The shared scheduler only arms the earliest pending retry.
494+ <-asked
495+ svc.Close(context.Background())
496+ assert.Equal(t, repos, svc.ScheduledRetries())
497+ assert.Equal(t, repos, svc.RetryAttempts())
498+}
499+
500+// retryRig is a service over mocks whose only repository holds request !7,
501+// whose read fails for its first failFor calls. Each repository rescan signals passes.
502+type retryRig struct {
503+ svc *usecase.Mergeability
504+ repo domain.RepoName
505+ reads atomic.Int32
506+ failFor atomic.Int32
507+ passes chan struct{}
508+ afters chan chan time.Time
509+ barrier chan struct{}
510+}
511+
512+func newRetryRig(t *testing.T) *retryRig {
513+ t.Helper()
514+ r := &retryRig{repo: name(t, "proj"), passes: make(chan struct{}, 32), afters: make(chan chan time.Time, 32), barrier: make(chan struct{})}
515+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
516+ r.failFor.Store(1 << 30)
517+ meta.EXPECT().Get(mock.Anything, r.repo).Return(domain.Repo{Name: r.repo}, nil)
518+ store.EXPECT().ListOpen(mock.Anything, r.repo).RunAndReturn(
519+ func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
520+ defer func() { r.passes <- struct{}{} }()
521+ if r.reads.Add(1) <= r.failFor.Load() {
522+ return nil, errors.New("db down")
523+ }
524+ return nil, nil
525+ })
526+ barrier := name(t, "barrier")
527+ meta.EXPECT().Get(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
528+ r.barrier <- struct{}{}
529+ return domain.Repo{}, domain.ErrNotFound
530+ })
531+ meta.EXPECT().List(mock.Anything).Return([]domain.Repo{{Name: r.repo}}, nil)
532+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
533+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}, Workers: 1,
534+ After: func(time.Duration) <-chan time.Time { ch := make(chan time.Time, 1); r.afters <- ch; return ch },
535+ })
536+ t.Cleanup(func() { r.svc.Close(context.Background()) })
537+ return r
538+}
539+
540+// next fires the pending retry and waits for the rescan it queues.
541+func (r *retryRig) next() {
542+ ch := <-r.afters
543+ ch <- time.Time{}
544+ <-r.passes
545+}
546+
547+func (r *retryRig) settled(t *testing.T) {
548+ t.Helper()
549+ r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: name(t, "barrier")})
550+ <-r.barrier
551+ require.Zero(t, r.svc.RetryAttempts())
552+ require.Zero(t, r.svc.ScheduledRetries())
553+}
554+
555+// A persistent failure is retried 5 times, then given up with no state kept.
556+func TestMergeabilityGivesUpAfterFiveRetries(t *testing.T) {
557+ r := newRetryRig(t)
558+ r.svc.Start()
559+ <-r.passes // the startup rescan fails
560+ for range 5 {
561+ r.next()
562+ }
563+ r.settled(t)
564+ assert.Empty(t, r.afters, "no sixth retry")
565+ assert.Empty(t, r.passes)
566+}
567+
568+// A successful full rescan resets the budget: a later failure gets 5 again.
569+func TestMergeabilitySuccessfulPassForgetsTheRetryBudget(t *testing.T) {
570+ r := newRetryRig(t)
571+ r.failFor.Store(2) // the first two reads fail, the third succeeds
572+ r.svc.Start()
573+ <-r.passes
574+ r.next() // second failing pass
575+ r.next() // success
576+ r.settled(t)
577+ r.failFor.Store(1 << 30)
578+ r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: r.repo})
579+ <-r.passes
580+ for range 5 {
581+ r.next()
582+ }
583+ r.settled(t)
584+ assert.Empty(t, r.afters)
585+ assert.Empty(t, r.passes)
586+}
587+
588+// stepClock is a clock the test moves.
589+type stepClock struct{ at atomic.Int64 }
590+
591+func (c *stepClock) Now() time.Time { return time.Unix(0, c.at.Load()).UTC() }
592+func (c *stepClock) advance(d time.Duration) { c.at.Add(int64(d)) }
593+
594+// An expired timer must not consume a replacement retry of its repository.
595+func TestMergeabilityStaleTimerKeepsReplacementRetry(t *testing.T) {
596+ repo := name(t, "proj")
597+ meta := mocks.NewRepoMetaStore(t)
598+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
599+ clock := &stepClock{}
600+ clock.advance(time.Hour)
601+ armed := make(chan time.Duration, 8)
602+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
603+ Meta: meta, Clock: clock, Workers: 1,
604+ After: func(d time.Duration) <-chan time.Time { armed <- d; return make(chan time.Time) },
605+ })
606+ t.Cleanup(func() { svc.Close(context.Background()) })
607+ svc.Start()
608+
609+ svc.RetryForTest(repo)
610+ assert.Equal(t, time.Second, <-armed, "the scheduler armed the timer of retry A")
611+ oldDue, ok := svc.RetryDueForTest(repo)
612+ require.True(t, ok)
613+
614+ svc.RetryDoneForTest(repo)
615+ clock.advance(10 * time.Second)
616+ svc.RetryForTest(repo)
617+ newDue, ok := svc.RetryDueForTest(repo)
618+ require.True(t, ok)
619+ require.True(t, newDue.After(oldDue))
620+
621+ // The old timer fires while the replacement is not yet due.
622+ assert.Empty(t, svc.PopDueForTest(repo, oldDue))
623+ assert.Equal(t, 1, svc.ScheduledRetries())
624+ // The timer armed for the replacement itself releases it.
625+ assert.Equal(t, []domain.RepoName{repo}, svc.PopDueForTest(repo, newDue))
626+ assert.Zero(t, svc.ScheduledRetries())
627+}
628+
629+func reconcileMocks(t *testing.T, repo domain.RepoName) (*mocks.MergeRequestStore, *mocks.RepoMetaStore, *mocks.MergeGit, domain.MergeRequest) {
630+ t.Helper()
631+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
632+ store, meta, git := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t)
633+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
634+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
635+ return store, meta, git, mr
636+}
637+
638+func TestMergeabilityDisappearedBranchDeletesTheRecord(t *testing.T) {
639+ repo := name(t, "proj")
640+ store, meta, git, _ := reconcileMocks(t, repo)
641+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(domain.ObjectID{}, domain.ErrNotFound)
642+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(fakeOID(t, "b"), nil)
643+ store.EXPECT().DeleteMergeability(mock.Anything, repo, int64(7)).Return(nil).Once()
644+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
645+ Store: store, Meta: meta, Git: git, Clock: fixedClock{},
646+ })
647+ t.Cleanup(func() { svc.Close(context.Background()) })
648+ assert.True(t, svc.ReconcileForTest(repo))
649+}
650+
651+func TestMergeabilityConflictMakesThePassFailAndRetries(t *testing.T) {
652+ repo := name(t, "proj")
653+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
654+ store, meta, git, mr := reconcileMocks(t, repo)
655+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
656+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
657+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
658+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
659+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
660+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrConflict)
661+
662+ asked := make(chan time.Duration, 4)
663+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
664+ Store: store, Meta: meta, Git: git, Clock: fixedClock{}, Workers: 1,
665+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
666+ })
667+ t.Cleanup(func() { svc.Close(context.Background()) })
668+ assert.False(t, svc.ReconcileForTest(repo))
669+
670+ svc.Start()
671+ svc.OnOpened(context.Background(), mr)
672+ assert.Equal(t, time.Second, <-asked, "the conflict scheduled a retry")
673+}
@@ -0,0 +1,673 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "strconv"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+ "bnema.dev/ermite/internal/usecase"
22+)
23+
24+func fakeOID(t *testing.T, c string) domain.ObjectID {
25+ t.Helper()
26+ return oidFrom(t, strings.Repeat(c, 40))
27+}
28+
29+// mergeRig runs the mergeability service over the real adapters of mrRig.
30+type mergeRig struct {
31+ *mrRig
32+ svc *usecase.Mergeability
33+ mev <-chan domain.Event
34+ history []domain.MergeRequestMergeability
35+}
36+
37+func newMergeRig(t *testing.T) *mergeRig {
38+ t.Helper()
39+ r := newMRRig(t)
40+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
41+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Workers: 2,
42+ })
43+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
45+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
46+ Mergeability: svc,
47+ })
48+ t.Cleanup(func() { svc.Close(context.Background()) })
49+ return &mergeRig{mrRig: r, svc: svc, mev: r.bus.Subscribe(r.ctx)}
50+}
51+
52+func (r *mergeRig) record(n int64) (domain.Mergeability, error) {
53+ return r.store.MergeRequests().GetMergeability(r.ctx, r.repo, n)
54+}
55+
56+func (r *mergeRig) waitState(n int64, want domain.MergeabilityState, target, head domain.ObjectID) domain.Mergeability {
57+ r.t.Helper()
58+ var m domain.Mergeability
59+ deadline := time.After(10 * time.Second)
60+ for {
61+ var err error
62+ m, err = r.record(n)
63+ if err == nil && m.State == want && m.IsFor(target, head) {
64+ break
65+ }
66+ select {
67+ case ev := <-r.mev:
68+ if e, ok := ev.(domain.MergeRequestMergeability); ok {
69+ r.history = append(r.history, e)
70+ }
71+ case <-deadline:
72+ r.t.Fatalf("mergeability of !%d did not become %s", n, want)
73+ }
74+ }
75+
76+ return m
77+}
78+
79+// events returns the mergeability events published so far.
80+func (r *mergeRig) events() []domain.MergeRequestMergeability {
81+ out := r.history
82+ r.history = nil
83+ for {
84+ select {
85+ case ev := <-r.mev:
86+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
87+ out = append(out, m)
88+ }
89+ default:
90+ return out
91+ }
92+ }
93+}
94+
95+// awaitEvents receives mergeability events until n arrived, or fails after a
96+// bound: the workers publish after they stored the record the tests wait for.
97+func (r *mergeRig) awaitEvents(n int) []domain.MergeRequestMergeability {
98+ r.t.Helper()
99+ out := r.history
100+ r.history = nil
101+ deadline := time.After(10 * time.Second)
102+ for len(out) < n {
103+ select {
104+ case ev := <-r.mev:
105+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
106+ out = append(out, m)
107+ }
108+ case <-deadline:
109+ r.t.Fatalf("got %d mergeability events, want %d", len(out), n)
110+ }
111+ }
112+ return out
113+}
114+
115+func TestMergeabilityStatesEndToEnd(t *testing.T) {
116+ r := newMergeRig(t)
117+ r.svc.Start()
118+ main := r.seed()
119+ head := r.feature("feat")
120+ mr := r.open("feat")
121+
122+ // up to date: the target is an ancestor of the head.
123+ m := r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
124+ assert.False(t, m.CheckedAt.IsZero())
125+
126+ // behind: the target moved with a change that merges cleanly.
127+ r.sh("checkout", "-q", "main")
128+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
129+ r.push("main")
130+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
131+ m = r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
132+ assert.Equal(t, 1, m.Behind)
133+ assert.False(t, m.BehindCapped)
134+
135+ // conflict: the target changes a line the head changed.
136+ main3 := r.commit(author, "a.txt", "one\nTWO\nthree\n", "conflict a")
137+ r.push("main")
138+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main2.String(), New: main3.String(), Ref: "refs/heads/main"}})
139+ m = r.waitState(mr.Number, domain.MergeabilityConflict, main3, head)
140+ assert.Equal(t, []string{"a.txt"}, m.ConflictPaths)
141+
142+ got := r.awaitEvents(3)
143+ for i, s := range []domain.MergeabilityState{domain.MergeabilityUpToDate, domain.MergeabilityBehind, domain.MergeabilityConflict} {
144+ assert.Equal(t, s, got[i].Mergeability.State)
145+ assert.Equal(t, mr.Number, got[i].Number)
146+ }
147+}
148+
149+func TestMergeabilityReaderIsLazy(t *testing.T) {
150+ r := newMergeRig(t)
151+ main := r.seed()
152+ head := r.feature("feat")
153+ mr := r.open("feat") // queued, but the workers are not started
154+
155+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
156+ require.NoError(t, err)
157+ assert.Equal(t, domain.MergeabilityChecking, d.Mergeability.State)
158+
159+ r.svc.Start()
160+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
161+ d, err = r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
162+ require.NoError(t, err)
163+ assert.Equal(t, domain.MergeabilityUpToDate, d.Mergeability.State)
164+
165+ page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
166+ require.NoError(t, err)
167+ assert.Equal(t, domain.MergeabilityUpToDate, page.Mergeability[mr.Number].State)
168+}
169+
170+func TestMergeabilityPublishesEachSettledResultOnce(t *testing.T) {
171+ r := newMergeRig(t)
172+ r.seed()
173+ r.feature("feat")
174+ mr := r.open("feat")
175+ r.svc.ReconcileForTest(r.repo)
176+ require.Len(t, r.events(), 1)
177+
178+ // Same tips: nothing is computed, nothing is published.
179+ r.svc.ReconcileForTest(r.repo)
180+ assert.Empty(t, r.events())
181+
182+ // New tips publish even when the state and counts stay the same.
183+ r.sh("checkout", "-q", "feat")
184+ r.commit(author, "e.txt", "e\n", "more")
185+ r.push("feat")
186+ r.svc.ReconcileForTest(r.repo)
187+ assert.Len(t, r.events(), 1)
188+ m, err := r.record(mr.Number)
189+ require.NoError(t, err)
190+ assert.Equal(t, domain.MergeabilityUpToDate, m.State)
191+}
192+
193+func TestMergeabilityMergeReevaluatesOtherRequests(t *testing.T) {
194+ r := newMergeRig(t)
195+ r.svc.Start()
196+ main := r.seed()
197+ r.sh("checkout", "-q", "-B", "one", "main")
198+ oneHead := r.commit(author, "one.txt", "1\n", "one")
199+ r.push("one")
200+ r.sh("checkout", "-q", "-B", "two", "main")
201+ twoHead := r.commit(author, "two.txt", "2\n", "two")
202+ r.push("two")
203+ one, two := r.open("one"), r.open("two")
204+ r.waitState(one.Number, domain.MergeabilityUpToDate, main, oneHead)
205+ r.waitState(two.Number, domain.MergeabilityUpToDate, main, twoHead)
206+
207+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, one.Number, ports.MergeParams{HeadID: oneHead})
208+ require.NoError(t, err)
209+ m := r.waitState(two.Number, domain.MergeabilityBehind, oneHead, twoHead)
210+ assert.Equal(t, 1, m.Behind)
211+ _, err = r.record(one.Number)
212+ assert.ErrorIs(t, err, domain.ErrNotFound, "the merged request keeps no record")
213+}
214+
215+func TestMergeabilityClosedRequestRecordDeleted(t *testing.T) {
216+ r := newMergeRig(t)
217+ r.seed()
218+ r.feature("feat")
219+ mr := r.open("feat")
220+ r.svc.ReconcileForTest(r.repo)
221+ _, err := r.record(mr.Number)
222+ require.NoError(t, err)
223+
224+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
225+ require.NoError(t, err)
226+ _, err = r.record(mr.Number)
227+ assert.ErrorIs(t, err, domain.ErrNotFound)
228+ r.svc.ReconcileForTest(r.repo) // a late check writes nothing
229+ _, err = r.record(mr.Number)
230+ assert.ErrorIs(t, err, domain.ErrNotFound)
231+}
232+
233+func TestMergeabilityStartupRescanComputesMissingRecords(t *testing.T) {
234+ r := newMergeRig(t)
235+ main := r.seed()
236+ head := r.feature("feat")
237+ mr := r.open("feat")
238+ _, err := r.record(mr.Number)
239+ require.ErrorIs(t, err, domain.ErrNotFound)
240+
241+ // A fresh service has received no OnOpened or Read trigger.
242+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
243+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{},
244+ })
245+ defer svc.Close(context.Background())
246+ svc.Start()
247+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
248+}
249+
250+func TestMergeabilityNeedsNoCI(t *testing.T) {
251+ // The service is built without any CI: a target move still updates the
252+ // record, and the repository gets no pull ref (the service has no CI to create a run with).
253+ r := newMergeRig(t)
254+ r.svc.Start()
255+ main := r.seed()
256+ head := r.feature("feat")
257+ mr := r.open("feat")
258+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
259+ r.sh("checkout", "-q", "main")
260+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
261+ r.push("main")
262+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
263+ r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
264+
265+ assert.Empty(t, r.run(r.bare(), r.env(author), "for-each-ref", "refs/pull"))
266+}
267+
268+// --- unit tests over mocks ---
269+
270+type mockRig struct {
271+ store *mocks.MergeRequestStore
272+ meta *mocks.RepoMetaStore
273+ git *mocks.MergeGit
274+ bus *mocks.EventBus
275+ svc *usecase.Mergeability
276+ mr domain.MergeRequest
277+}
278+
279+func newMockRig(t *testing.T) *mockRig {
280+ t.Helper()
281+ repo := name(t, "proj")
282+ r := &mockRig{
283+ store: mocks.NewMergeRequestStore(t), meta: mocks.NewRepoMetaStore(t), git: mocks.NewMergeGit(t), bus: mocks.NewEventBus(t),
284+ mr: domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen},
285+ }
286+ r.store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{r.mr}, nil).Maybe()
287+ r.store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound).Maybe()
288+ r.meta.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
289+ r.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil).Maybe()
290+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
291+ Store: r.store, Meta: r.meta, Git: r.git, Bus: r.bus, Clock: fixedClock{},
292+ })
293+ t.Cleanup(func() { r.svc.Close(context.Background()) })
294+ return r
295+}
296+
297+func TestMergeabilityDedupsTriggers(t *testing.T) {
298+ r := newMockRig(t)
299+ repo := r.mr.Repo
300+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
301+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
302+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
303+ computed := make(chan struct{}, 1)
304+ var computes atomic.Int32
305+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).RunAndReturn(
306+ func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) {
307+ computes.Add(1)
308+ computed <- struct{}{}
309+ return true, nil
310+ })
311+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
312+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
313+
314+ for range 50 {
315+ r.svc.OnOpened(context.Background(), r.mr) // queued before any worker runs
316+ }
317+ r.svc.Start()
318+ <-computed
319+ r.svc.Close(context.Background()) // the workers have each looked at the queue once more
320+ assert.EqualValues(t, 1, computes.Load())
321+}
322+
323+func TestMergeabilityNoWriteForClosedRequest(t *testing.T) {
324+ r := newMockRig(t)
325+ repo := r.mr.Repo
326+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
327+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
328+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
329+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
330+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once() // closed meanwhile
331+ r.svc.ReconcileForTest(repo) // no event, no further write
332+}
333+
334+func TestMergeabilityBoundsConflictPathsAndBehind(t *testing.T) {
335+ t.Run("conflict paths", func(t *testing.T) {
336+ r := newMockRig(t)
337+ repo := r.mr.Repo
338+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
339+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
340+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
341+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil)
342+ var paths []string
343+ for i := 0; i < domain.MaxConflictPaths+3; i++ {
344+ paths = append(paths, strings.Repeat("p", i+1))
345+ }
346+ r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{ConflictPaths: paths}, nil)
347+ var put domain.Mergeability
348+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
349+ func(_ context.Context, m domain.Mergeability) error { put = m; return nil })
350+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
351+ r.svc.ReconcileForTest(repo)
352+ assert.Equal(t, domain.MergeabilityConflict, put.State)
353+ assert.Len(t, put.ConflictPaths, domain.MaxConflictPaths)
354+ assert.True(t, put.ConflictPathsTruncated)
355+ })
356+ t.Run("behind", func(t *testing.T) {
357+ r := newMockRig(t)
358+ repo := r.mr.Repo
359+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
360+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
361+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
362+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil)
363+ r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{Clean: true}, nil)
364+ r.git.EXPECT().CountBehind(mock.Anything, repo, head, tip, domain.MaxBehindCount+1).Return(domain.MaxBehindCount+1, nil)
365+ var put domain.Mergeability
366+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
367+ func(_ context.Context, m domain.Mergeability) error { put = m; return nil })
368+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
369+ r.svc.ReconcileForTest(repo)
370+ assert.Equal(t, domain.MaxBehindCount, put.Behind)
371+ assert.True(t, put.BehindCapped)
372+ })
373+}
374+
375+// A transient Git failure is retried once after the backoff, by rescanning
376+// the repository through the queue.
377+func TestMergeabilityRetriesATransientFailureWithBackoff(t *testing.T) {
378+ repo := name(t, "proj")
379+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
380+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
381+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
382+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
383+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
384+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
385+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
386+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
387+ var fails atomic.Int32
388+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").RunAndReturn(
389+ func(context.Context, domain.RepoName, string) (domain.ObjectID, error) {
390+ if fails.Add(1) == 1 {
391+ return domain.ObjectID{}, errors.New("transient")
392+ }
393+ return head, nil
394+ })
395+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
396+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
397+ published := make(chan struct{})
398+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
399+
400+ asked, fire := make(chan time.Duration, 4), make(chan time.Time)
401+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
402+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
403+ After: func(d time.Duration) <-chan time.Time { asked <- d; return fire },
404+ })
405+ t.Cleanup(func() { svc.Close(context.Background()) })
406+ svc.Start()
407+ svc.OnOpened(context.Background(), mr)
408+
409+ assert.Equal(t, time.Second, <-asked)
410+ select {
411+ case <-published:
412+ t.Fatal("published before the backoff elapsed")
413+ default:
414+ }
415+ fire <- time.Time{}
416+ <-published
417+ assert.Empty(t, asked, "one retry was enough and only one was scheduled")
418+}
419+
420+// Close stops a pending retry timer's goroutine.
421+func TestMergeabilityCloseStopsPendingRetries(t *testing.T) {
422+ repo := name(t, "proj")
423+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
424+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
425+ store.EXPECT().ListOpen(mock.Anything, repo).Return(nil, errors.New("db down"))
426+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
427+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
428+ asked := make(chan time.Duration, 4)
429+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
430+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
431+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
432+ })
433+ svc.Start()
434+ svc.OnOpened(context.Background(), mr)
435+ <-asked
436+ done := make(chan struct{})
437+ go func() { svc.Close(context.Background()); close(done) }()
438+ <-done // Close waits for the timer goroutine: it returns only if it stopped
439+}
440+
441+// Workers are not stopped by the cancellation of the context given to the
442+// constructor; Close stops them.
443+func TestMergeabilityWorkersOutliveTheParentContext(t *testing.T) {
444+ repo := name(t, "proj")
445+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
446+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
447+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
448+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
449+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
450+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
451+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
452+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
453+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
454+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
455+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
456+ published := make(chan struct{})
457+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
458+
459+ parent, cancel := context.WithCancel(context.Background())
460+ svc := usecase.NewMergeability(parent, usecase.MergeabilityConfig{Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}})
461+ svc.Start()
462+ cancel()
463+ svc.OnOpened(context.Background(), mr)
464+ <-published // computed although the parent is cancelled
465+ svc.Close(context.Background())
466+}
467+
468+// Failures of many requests of 3 repositories schedule at most one retry per
469+// repository on a single scheduler goroutine.
470+func TestMergeabilityRetriesAreBounded(t *testing.T) {
471+ const repos = 3
472+ store, meta := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t)
473+ barrier := name(t, "barrier")
474+ done := make(chan struct{})
475+ store.EXPECT().ListOpen(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) { close(done); return nil, nil }).Once()
476+ store.EXPECT().ListOpen(mock.Anything, mock.Anything).Return(nil, errors.New("db down"))
477+ meta.EXPECT().Get(mock.Anything, mock.Anything).Return(domain.Repo{}, nil)
478+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
479+ asked := make(chan time.Duration, repos)
480+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
481+ Store: store, Meta: meta, Clock: fixedClock{}, Workers: 1,
482+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
483+ })
484+ svc.Start()
485+ for r := range repos {
486+ repo := name(t, "proj"+strconv.Itoa(r))
487+ for range 20 {
488+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
489+ }
490+ }
491+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: barrier})
492+ <-done
493+ // The shared scheduler only arms the earliest pending retry.
494+ <-asked
495+ svc.Close(context.Background())
496+ assert.Equal(t, repos, svc.ScheduledRetries())
497+ assert.Equal(t, repos, svc.RetryAttempts())
498+}
499+
500+// retryRig is a service over mocks whose only repository holds request !7,
501+// whose read fails for its first failFor calls. Each repository rescan signals passes.
502+type retryRig struct {
503+ svc *usecase.Mergeability
504+ repo domain.RepoName
505+ reads atomic.Int32
506+ failFor atomic.Int32
507+ passes chan struct{}
508+ afters chan chan time.Time
509+ barrier chan struct{}
510+}
511+
512+func newRetryRig(t *testing.T) *retryRig {
513+ t.Helper()
514+ r := &retryRig{repo: name(t, "proj"), passes: make(chan struct{}, 32), afters: make(chan chan time.Time, 32), barrier: make(chan struct{})}
515+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
516+ r.failFor.Store(1 << 30)
517+ meta.EXPECT().Get(mock.Anything, r.repo).Return(domain.Repo{Name: r.repo}, nil)
518+ store.EXPECT().ListOpen(mock.Anything, r.repo).RunAndReturn(
519+ func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
520+ defer func() { r.passes <- struct{}{} }()
521+ if r.reads.Add(1) <= r.failFor.Load() {
522+ return nil, errors.New("db down")
523+ }
524+ return nil, nil
525+ })
526+ barrier := name(t, "barrier")
527+ meta.EXPECT().Get(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
528+ r.barrier <- struct{}{}
529+ return domain.Repo{}, domain.ErrNotFound
530+ })
531+ meta.EXPECT().List(mock.Anything).Return([]domain.Repo{{Name: r.repo}}, nil)
532+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
533+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}, Workers: 1,
534+ After: func(time.Duration) <-chan time.Time { ch := make(chan time.Time, 1); r.afters <- ch; return ch },
535+ })
536+ t.Cleanup(func() { r.svc.Close(context.Background()) })
537+ return r
538+}
539+
540+// next fires the pending retry and waits for the rescan it queues.
541+func (r *retryRig) next() {
542+ ch := <-r.afters
543+ ch <- time.Time{}
544+ <-r.passes
545+}
546+
547+func (r *retryRig) settled(t *testing.T) {
548+ t.Helper()
549+ r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: name(t, "barrier")})
550+ <-r.barrier
551+ require.Zero(t, r.svc.RetryAttempts())
552+ require.Zero(t, r.svc.ScheduledRetries())
553+}
554+
555+// A persistent failure is retried 5 times, then given up with no state kept.
556+func TestMergeabilityGivesUpAfterFiveRetries(t *testing.T) {
557+ r := newRetryRig(t)
558+ r.svc.Start()
559+ <-r.passes // the startup rescan fails
560+ for range 5 {
561+ r.next()
562+ }
563+ r.settled(t)
564+ assert.Empty(t, r.afters, "no sixth retry")
565+ assert.Empty(t, r.passes)
566+}
567+
568+// A successful full rescan resets the budget: a later failure gets 5 again.
569+func TestMergeabilitySuccessfulPassForgetsTheRetryBudget(t *testing.T) {
570+ r := newRetryRig(t)
571+ r.failFor.Store(2) // the first two reads fail, the third succeeds
572+ r.svc.Start()
573+ <-r.passes
574+ r.next() // second failing pass
575+ r.next() // success
576+ r.settled(t)
577+ r.failFor.Store(1 << 30)
578+ r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: r.repo})
579+ <-r.passes
580+ for range 5 {
581+ r.next()
582+ }
583+ r.settled(t)
584+ assert.Empty(t, r.afters)
585+ assert.Empty(t, r.passes)
586+}
587+
588+// stepClock is a clock the test moves.
589+type stepClock struct{ at atomic.Int64 }
590+
591+func (c *stepClock) Now() time.Time { return time.Unix(0, c.at.Load()).UTC() }
592+func (c *stepClock) advance(d time.Duration) { c.at.Add(int64(d)) }
593+
594+// An expired timer must not consume a replacement retry of its repository.
595+func TestMergeabilityStaleTimerKeepsReplacementRetry(t *testing.T) {
596+ repo := name(t, "proj")
597+ meta := mocks.NewRepoMetaStore(t)
598+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
599+ clock := &stepClock{}
600+ clock.advance(time.Hour)
601+ armed := make(chan time.Duration, 8)
602+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
603+ Meta: meta, Clock: clock, Workers: 1,
604+ After: func(d time.Duration) <-chan time.Time { armed <- d; return make(chan time.Time) },
605+ })
606+ t.Cleanup(func() { svc.Close(context.Background()) })
607+ svc.Start()
608+
609+ svc.RetryForTest(repo)
610+ assert.Equal(t, time.Second, <-armed, "the scheduler armed the timer of retry A")
611+ oldDue, ok := svc.RetryDueForTest(repo)
612+ require.True(t, ok)
613+
614+ svc.RetryDoneForTest(repo)
615+ clock.advance(10 * time.Second)
616+ svc.RetryForTest(repo)
617+ newDue, ok := svc.RetryDueForTest(repo)
618+ require.True(t, ok)
619+ require.True(t, newDue.After(oldDue))
620+
621+ // The old timer fires while the replacement is not yet due.
622+ assert.Empty(t, svc.PopDueForTest(repo, oldDue))
623+ assert.Equal(t, 1, svc.ScheduledRetries())
624+ // The timer armed for the replacement itself releases it.
625+ assert.Equal(t, []domain.RepoName{repo}, svc.PopDueForTest(repo, newDue))
626+ assert.Zero(t, svc.ScheduledRetries())
627+}
628+
629+func reconcileMocks(t *testing.T, repo domain.RepoName) (*mocks.MergeRequestStore, *mocks.RepoMetaStore, *mocks.MergeGit, domain.MergeRequest) {
630+ t.Helper()
631+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
632+ store, meta, git := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t)
633+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
634+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
635+ return store, meta, git, mr
636+}
637+
638+func TestMergeabilityDisappearedBranchDeletesTheRecord(t *testing.T) {
639+ repo := name(t, "proj")
640+ store, meta, git, _ := reconcileMocks(t, repo)
641+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(domain.ObjectID{}, domain.ErrNotFound)
642+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(fakeOID(t, "b"), nil)
643+ store.EXPECT().DeleteMergeability(mock.Anything, repo, int64(7)).Return(nil).Once()
644+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
645+ Store: store, Meta: meta, Git: git, Clock: fixedClock{},
646+ })
647+ t.Cleanup(func() { svc.Close(context.Background()) })
648+ assert.True(t, svc.ReconcileForTest(repo))
649+}
650+
651+func TestMergeabilityConflictMakesThePassFailAndRetries(t *testing.T) {
652+ repo := name(t, "proj")
653+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
654+ store, meta, git, mr := reconcileMocks(t, repo)
655+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
656+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
657+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
658+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
659+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
660+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrConflict)
661+
662+ asked := make(chan time.Duration, 4)
663+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
664+ Store: store, Meta: meta, Git: git, Clock: fixedClock{}, Workers: 1,
665+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
666+ })
667+ t.Cleanup(func() { svc.Close(context.Background()) })
668+ assert.False(t, svc.ReconcileForTest(repo))
669+
670+ svc.Start()
671+ svc.OnOpened(context.Background(), mr)
672+ assert.Equal(t, time.Second, <-asked, "the conflict scheduled a retry")
673+}
M internal/usecase/mergerequests.go +246 -70
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "math"
11+ "runtime/debug"
1112 "slices"
1213 "strconv"
1314 "strings"
@@ -56,9 +57,15 @@ type MergeRequests struct {
5657 pulls ports.PullRequestTrigger // nil: no pull_request workflows
5758 tips ports.CIRefTipStore // nil: who merges is not recorded
5859 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
60+ mergeab MergeabilityHook // nil: no mergeability
5961
6062 mu sync.Mutex
6163 locks map[domain.RepoName]chan struct{}
64+
65+ // after tracks the post-merge side effects running off the request path.
66+ after sync.WaitGroup
67+ // afterGate, when set by a test, runs first in the post-merge goroutine.
68+ afterGate func()
6269 }
6370
6471 var (
@@ -95,13 +102,16 @@ type MergeRequestsConfig struct {
95102 // deleted source branch) before the merge does, as a push does
96103 // (domain.CIRefTip). Optional.
97104 RefTips ports.CIRefTipStore
105+ // Mergeability is queued when a request is opened, closed or merged and
106+ // read by Get and List. Optional: without it Mergeability stays zero.
107+ Mergeability MergeabilityHook
98108 }
99109
100110 // NewMergeRequests builds the merge request use cases.
101111 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
102112 return &MergeRequests{
103113 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
104- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips,
114+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
105115 locks: make(map[domain.RepoName]chan struct{}),
106116 }
107117 }
@@ -113,6 +123,21 @@ func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
113123 return m
114124 }
115125
126+// Wait waits for the post-merge side effects still running (source branch
127+// deletion, events, CI and mergeability hooks), or for ctx to end. Call it
128+// before closing what they use.
129+func (m *MergeRequests) Wait(ctx context.Context) {
130+ done := make(chan struct{})
131+ go func() {
132+ m.after.Wait()
133+ close(done)
134+ }()
135+ select {
136+ case <-done:
137+ case <-ctx.Done():
138+ }
139+}
140+
116141 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
117142 // merges of one repository, and the creation of its merge requests, run one at
118143 // a time. The returned function releases the lock.
@@ -256,6 +281,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
256281 }
257282 recordActivity(ctx, m.meta, repo.Name, now)
258283 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
284+ if m.mergeab != nil {
285+ m.mergeab.OnOpened(ctx, mr)
286+ }
259287 if m.pulls != nil {
260288 // The commits the opening is for are the ones of now.
261289 base, _ := m.branchOrZero(ctx, name, target)
@@ -336,10 +364,25 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai
336364 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
337365 },
338366 encodeMRToken)
367+ tips := map[string]domain.ObjectID{} // one BranchHead per distinct target
339368 for i, mr := range page.MergeRequests {
340369 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
341370 return ports.MergeRequestPage{}, err
342371 }
372+ if m.mergeab == nil || !mr.IsOpen() {
373+ continue
374+ }
375+ tip, ok := tips[mr.Target]
376+ if !ok {
377+ if tip, err = m.branchOrZero(ctx, name, mr.Target); err != nil {
378+ return ports.MergeRequestPage{}, err
379+ }
380+ tips[mr.Target] = tip
381+ }
382+ if page.Mergeability == nil {
383+ page.Mergeability = map[int64]domain.Mergeability{}
384+ }
385+ page.Mergeability[mr.Number] = m.mergeab.Read(ctx, page.MergeRequests[i], tip)
343386 }
344387 return page, nil
345388 }
@@ -397,6 +440,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name
397440 return n, nil
398441 }
399442
443+// Mergeability implements ports.MergeRequestService: the mergeability of an
444+// open merge request for the current tips, queued when there is no up-to-date
445+// result. It is the zero value for a request that is not open, or without a
446+// mergeability service.
447+func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
448+ _, mr, err := m.get(ctx, p, name, number)
449+ if err != nil {
450+ return domain.Mergeability{}, err
451+ }
452+ if m.mergeab == nil || !mr.IsOpen() {
453+ return domain.Mergeability{}, nil
454+ }
455+ if mr, err = m.withHead(ctx, mr); err != nil {
456+ return domain.Mergeability{}, err
457+ }
458+ tip, err := m.branchOrZero(ctx, name, mr.Target)
459+ if err != nil {
460+ return domain.Mergeability{}, err
461+ }
462+ return m.mergeab.Read(ctx, mr, tip), nil
463+}
464+
400465 func encodeMRToken(last domain.MergeRequest) string {
401466 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
402467 }
@@ -467,6 +532,13 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
467532 return nil
468533 }
469534 d := ports.MergeRequestDetail{MergeRequest: mr}
535+ if m.mergeab != nil && mr.IsOpen() {
536+ tip, err := m.branchOrZero(ctx, name, mr.Target)
537+ if err != nil {
538+ return ports.MergeRequestDetail{}, err
539+ }
540+ d.Mergeability = m.mergeab.Read(ctx, mr, tip)
541+ }
470542 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
471543 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
472544 }
@@ -853,30 +925,77 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
853925 // domain.ErrPreconditionFailed when the request is not open, the mode is not
854926 // allowed by the settings of the repository, the source branch no longer
855927 // points to the head ("branch moved, read again") or is gone, the target is
856-// gone or is not an ancestor of the head (the author rebases and pushes
857-// again), or the branch brings nothing. Checks come with CI (CI-1): there are
858-// none yet, so the setting that lets failing checks block the merge has
859-// nothing to act on.
928+// gone, the mode's own condition does not hold (see below), or the branch
929+// brings nothing. Checks come with CI (CI-1): there are none yet, so the
930+// setting that lets failing checks block the merge has nothing to act on.
860931 //
861-// A fast-forward moves the target to the head, with the old value as a
862-// precondition. A squash writes one commit with the tree of the head, the
863-// target as parent and the author and message of domain.NewSquashCommit,
864-// signed by the instance key, and moves the target to it the same way.
932+// The conditions are always checked with Git, under the lock, never from the
933+// stored mergeability. A fast-forward needs the target to be an ancestor of
934+// the head (the author rebases and pushes again) and moves the target to the
935+// head, with the old value as a precondition. A squash and a merge commit need
936+// the head to merge cleanly into the target (a behind branch is fine). A squash
937+// writes one commit with the merged tree, the target as parent and the author
938+// and message of domain.NewSquashCommit; a merge commit has two parents (the
939+// target, then the head) and the message of domain.NewMergeCommit. Both are
940+// signed by the instance key and move the target the same way.
865941 //
866942 // With DeleteSource the merge is also refused, before anything changes, when
867943 // the source is the default branch or the source or target of another open
868-// request. After the merge the source is deleted if it still points to the
869-// head; the PushEvent then carries the deletion too. A failed deletion is
870-// logged and leaves the branch: the merge stands and SourceDeleted is false.
944+// request.
945+//
946+// Only the merge itself runs before Merge returns, under the lock. Afterwards,
947+// off the request path and without the lock: the deletion of the source branch
948+// (if it still points to the head; a failure is logged and leaves the branch),
949+// MergeRequestMerged then a PushEvent (which carries the deletion too), and
950+// the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
951+// only says that the deletion was requested.
871952 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
872- mr, deleted, err := m.merge(ctx, p, name, number, params)
873- return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err
953+ mr, err := m.merge(ctx, p, name, number, params)
954+ return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
874955 }
875956
876-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, bool, error) {
957+// maxConflictPathsShown is how many conflicting paths a refusal lists.
958+const maxConflictPathsShown = 5
959+
960+// checkMode is the authoritative per-mode check of a merge, done with Git. It
961+// returns the tree a squash or merge commit gets (zero for a fast-forward).
962+func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
963+ if mode == domain.MergeModeFastForward {
964+ ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head)
965+ if err != nil {
966+ return domain.ObjectID{}, err
967+ }
968+ if !ok {
969+ return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed)
970+ }
971+ return domain.ObjectID{}, nil
972+ }
973+ res, err := m.git.MergeTree(ctx, mr.Repo, tip, head)
974+ if err != nil {
975+ return domain.ObjectID{}, err
976+ }
977+ res = res.Bounded()
978+ if !res.Clean {
979+ what := "no common history"
980+ if len(res.ConflictPaths) > 0 {
981+ shown := res.ConflictPaths
982+ if len(shown) > maxConflictPathsShown {
983+ shown = shown[:maxConflictPathsShown]
984+ }
985+ what = strings.Join(shown, ", ")
986+ if len(shown) < len(res.ConflictPaths) || res.Truncated {
987+ what += ", ..."
988+ }
989+ }
990+ return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what)
991+ }
992+ return res.Tree, nil
993+}
994+
995+func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
877996 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
878997 if err != nil {
879- return domain.MergeRequest{}, false, err
998+ return domain.MergeRequest{}, err
880999 }
8811000 mode := params.Mode
8821001 if mode == "" {
@@ -890,116 +1009,134 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
8901009 v.Add("head_id", "is required")
8911010 }
8921011 if err := v.Err(); err != nil {
893- return domain.MergeRequest{}, false, err
1012+ return domain.MergeRequest{}, err
8941013 }
8951014
8961015 unlock, err := m.lockRepo(ctx, name)
8971016 if err != nil {
898- return domain.MergeRequest{}, false, err
1017+ return domain.MergeRequest{}, err
8991018 }
900- defer unlock()
1019+ var once sync.Once
1020+ release := func() { once.Do(unlock) }
1021+ defer release()
9011022
9021023 mr, err := m.store.Get(ctx, name, number)
9031024 if err != nil {
904- return domain.MergeRequest{}, false, err
1025+ return domain.MergeRequest{}, err
9051026 }
9061027 // A merge moves the target and may delete the source: a token limited to
9071028 // branch prefixes may do so only within them.
9081029 if !p.AllowsRef("refs/heads/" + mr.Target) {
909- return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
1030+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
9101031 }
9111032 if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
912- return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
1033+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
9131034 }
9141035 if !mr.IsOpen() {
915- return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1036+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
9161037 }
9171038 if mr.IsDraft() {
918- return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
1039+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
9191040 }
9201041 // The settings are read again under the lock: a change applies at once.
9211042 repo, err = m.meta.Get(ctx, name)
9221043 if err != nil {
9231044 if errors.Is(err, domain.ErrNotFound) {
924- return domain.MergeRequest{}, false, err // deleted meanwhile
1045+ return domain.MergeRequest{}, err // deleted meanwhile
9251046 }
926- return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err)
1047+ return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
9271048 }
9281049 settings := repo.Settings
9291050 if settings.IsZero() {
9301051 settings = domain.DefaultRepoSettings()
9311052 }
9321053 if !settings.Allows(mode) {
933- return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
1054+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
9341055 }
9351056
9361057 head, err := m.branchOrZero(ctx, name, mr.Source)
9371058 switch {
9381059 case err != nil:
939- return domain.MergeRequest{}, false, err
1060+ return domain.MergeRequest{}, err
9401061 case head.IsZero():
941- return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
1062+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
9421063 case head != params.HeadID:
943- return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
1064+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
9441065 }
9451066 tip, err := m.branchOrZero(ctx, name, mr.Target)
9461067 switch {
9471068 case err != nil:
948- return domain.MergeRequest{}, false, err
1069+ return domain.MergeRequest{}, err
9491070 case tip.IsZero():
950- return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
1071+ return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
9511072 case tip == head:
952- return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
1073+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
9531074 }
954- switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {
955- case err != nil:
956- return domain.MergeRequest{}, false, err
957- case !ok:
958- return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target)
1075+ // A head behind the target (or at it) has nothing to merge, in any mode.
1076+ if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil {
1077+ return domain.MergeRequest{}, err
1078+ } else if behind {
1079+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1080+ }
1081+ tree, err := m.checkMode(ctx, mr, mode, tip, head)
1082+ if err != nil {
1083+ return domain.MergeRequest{}, err
9591084 }
9601085 if settings.FailingChecksBlock {
9611086 if err := m.checksAllow(ctx, name, head); err != nil {
962- return domain.MergeRequest{}, false, err
1087+ return domain.MergeRequest{}, err
9631088 }
9641089 }
9651090 if params.DeleteSource {
9661091 if err := m.checkDeletable(ctx, name, mr); err != nil {
967- return domain.MergeRequest{}, false, err
1092+ return domain.MergeRequest{}, err
9681093 }
9691094 }
9701095
971- merged, err := m.moveTarget(ctx, mr, mode, tip, head, p)
1096+ merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p)
9721097 if err != nil {
973- return domain.MergeRequest{}, false, err
1098+ return domain.MergeRequest{}, err
9741099 }
9751100 m.recordPush(ctx, name) // the target moved, whatever happens next
9761101 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
9771102 if err != nil {
978- return domain.MergeRequest{}, false, err
1103+ return domain.MergeRequest{}, err
9791104 }
9801105 log := zerowrap.FromCtx(ctx)
9811106 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
9821107 Str("target", mr.Target).Str("commit", merged.String()).
9831108 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
984- updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
985- deleted := false
986- if params.DeleteSource {
987- // The merge is done and recorded: a failure here keeps the branch. Who
988- // pushed the source goes first (the branch is gone either way for a
989- // schedule; a kept branch merely has no record: not trusted).
990- if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil {
991- log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept")
992- }
993- switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); {
994- case err != nil:
995- log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
996- Msg("source branch kept after the merge")
997- default:
998- deleted = true
999- updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
1000- log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
1001- Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1109+
1110+ // The merge is done and recorded. The rest runs without the lock and after
1111+ // the caller got its answer.
1112+ release()
1113+ bg := context.WithoutCancel(ctx)
1114+ m.after.Add(1)
1115+ go func() {
1116+ defer m.after.Done()
1117+ defer func() {
1118+ if r := recover(); r != nil {
1119+ log := zerowrap.FromCtx(bg)
1120+ log.Error().Str("repo", name.String()).Int64("number", number).
1121+ Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked")
1122+ }
1123+ }()
1124+ if m.afterGate != nil {
1125+ m.afterGate()
10021126 }
1127+ m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource)
1128+ }()
1129+ return mr, nil
1130+}
1131+
1132+// afterMerge is what follows a merge off the request path: errors are logged,
1133+// never returned. mr is the merged request, tip the old target and merged the
1134+// new one.
1135+func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1136+ name := repo.Name
1137+ updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1138+ if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
1139+ updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
10031140 }
10041141 at := mr.UpdatedAt
10051142 recordActivity(ctx, m.meta, repo.Name, at)
@@ -1008,14 +1145,47 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10081145 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
10091146 })
10101147 if m.pulls != nil {
1011- m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1148+ m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
10121149 }
10131150 if m.ci != nil {
10141151 // GitHub runs the "push" workflows of a merge, with the owner as actor;
10151152 // the run is trusted with what the merging principal may read.
10161153 m.ci.OnMerge(ctx, name, updates, p)
10171154 }
1018- return mr, deleted, nil
1155+ if m.mergeab != nil {
1156+ m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1157+ }
1158+}
1159+
1160+// deleteSource deletes the source branch of a merged request if it is still
1161+// head and still unused, and reports whether it did. The eligibility is checked
1162+// again and the branch deleted in one critical section of the merge lock: a
1163+// request opened on the branch since the merge, or a push to it, keeps it. The
1164+// record of who pushed the branch goes only after a deletion, and only if it
1165+// still names head.
1166+func (m *MergeRequests) deleteSource(ctx context.Context, p domain.Principal, name domain.RepoName, mr domain.MergeRequest, head domain.ObjectID) bool {
1167+ log := zerowrap.FromCtx(ctx).With().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source).Logger()
1168+ unlock, err := m.lockRepo(ctx, name)
1169+ if err != nil {
1170+ log.Warn().Err(err).Msg("source branch kept after the merge")
1171+ return false
1172+ }
1173+ defer unlock()
1174+ if err := m.checkDeletable(ctx, name, mr); err != nil {
1175+ log.Warn().Err(err).Msg("source branch kept after the merge")
1176+ return false
1177+ }
1178+ if err := m.git.DeleteBranch(ctx, name, mr.Source, head); err != nil {
1179+ log.Warn().Err(err).Msg("source branch kept after the merge")
1180+ return false
1181+ }
1182+ if m.tips != nil {
1183+ if err := m.tips.DeleteRefTipIf(ctx, name, "refs/heads/"+mr.Source, head.String()); err != nil && !errors.Is(err, domain.ErrNotFound) {
1184+ log.Warn().Err(err).Msg("the record of the source branch was kept")
1185+ }
1186+ }
1187+ log.Info().Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1188+ return true
10191189 }
10201190
10211191 // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a
@@ -1108,10 +1278,20 @@ func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
11081278 }
11091279
11101280 // moveTarget moves the target branch from tip to the head (fast-forward) or to
1111-// a new signed squash commit, and returns the new tip.
1112-func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
1281+// a new signed squash or merge commit with the given tree, and returns the new tip.
1282+func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
11131283 newTip := head
1114- if mode == domain.MergeModeSquash {
1284+ switch mode {
1285+ case domain.MergeModeMerge:
1286+ commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now())
1287+ armored, err := m.signer.Sign(commit.Payload())
1288+ if err != nil {
1289+ return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1290+ }
1291+ if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1292+ return domain.ObjectID{}, err
1293+ }
1294+ case domain.MergeModeSquash:
11151295 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
11161296 if err != nil {
11171297 return domain.ObjectID{}, err
@@ -1119,10 +1299,6 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
11191299 if more {
11201300 return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
11211301 }
1122- tree, err := m.git.CommitTree(ctx, mr.Repo, head)
1123- if err != nil {
1124- return domain.ObjectID{}, err
1125- }
11261302 commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
11271303 armored, err := m.signer.Sign(commit.Payload())
11281304 if err != nil {
@@ -8,6 +8,7 @@ import (
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10 "math"10 "math"
11+ "runtime/debug"
11 "slices"12 "slices"
12 "strconv"13 "strconv"
13 "strings"14 "strings"
@@ -56,9 +57,15 @@ type MergeRequests struct {
56 pulls ports.PullRequestTrigger // nil: no pull_request workflows57 pulls ports.PullRequestTrigger // nil: no pull_request workflows
57 tips ports.CIRefTipStore // nil: who merges is not recorded58 tips ports.CIRefTipStore // nil: who merges is not recorded
58 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)59 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
60+ mergeab MergeabilityHook // nil: no mergeability
59 61
60 mu sync.Mutex62 mu sync.Mutex
61 locks map[domain.RepoName]chan struct{}63 locks map[domain.RepoName]chan struct{}
64+
65+ // after tracks the post-merge side effects running off the request path.
66+ after sync.WaitGroup
67+ // afterGate, when set by a test, runs first in the post-merge goroutine.
68+ afterGate func()
62 }69 }
63 70
64 var (71 var (
@@ -95,13 +102,16 @@ type MergeRequestsConfig struct {
95 // deleted source branch) before the merge does, as a push does102 // deleted source branch) before the merge does, as a push does
96 // (domain.CIRefTip). Optional.103 // (domain.CIRefTip). Optional.
97 RefTips ports.CIRefTipStore104 RefTips ports.CIRefTipStore
105+ // Mergeability is queued when a request is opened, closed or merged and
106+ // read by Get and List. Optional: without it Mergeability stays zero.
107+ Mergeability MergeabilityHook
98 }108 }
99 109
100 // NewMergeRequests builds the merge request use cases.110 // NewMergeRequests builds the merge request use cases.
101 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {111 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
102 return &MergeRequests{112 return &MergeRequests{
103 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,113 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
104- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips,114+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
105 locks: make(map[domain.RepoName]chan struct{}),115 locks: make(map[domain.RepoName]chan struct{}),
106 }116 }
107 }117 }
@@ -113,6 +123,21 @@ func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
113 return m123 return m
114 }124 }
115 125
126+// Wait waits for the post-merge side effects still running (source branch
127+// deletion, events, CI and mergeability hooks), or for ctx to end. Call it
128+// before closing what they use.
129+func (m *MergeRequests) Wait(ctx context.Context) {
130+ done := make(chan struct{})
131+ go func() {
132+ m.after.Wait()
133+ close(done)
134+ }()
135+ select {
136+ case <-done:
137+ case <-ctx.Done():
138+ }
139+}
140+
116 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The141 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
117 // merges of one repository, and the creation of its merge requests, run one at142 // merges of one repository, and the creation of its merge requests, run one at
118 // a time. The returned function releases the lock.143 // a time. The returned function releases the lock.
@@ -256,6 +281,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
256 }281 }
257 recordActivity(ctx, m.meta, repo.Name, now)282 recordActivity(ctx, m.meta, repo.Name, now)
258 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})283 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
284+ if m.mergeab != nil {
285+ m.mergeab.OnOpened(ctx, mr)
286+ }
259 if m.pulls != nil {287 if m.pulls != nil {
260 // The commits the opening is for are the ones of now.288 // The commits the opening is for are the ones of now.
261 base, _ := m.branchOrZero(ctx, name, target)289 base, _ := m.branchOrZero(ctx, name, target)
@@ -336,10 +364,25 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai
336 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)364 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
337 },365 },
338 encodeMRToken)366 encodeMRToken)
367+ tips := map[string]domain.ObjectID{} // one BranchHead per distinct target
339 for i, mr := range page.MergeRequests {368 for i, mr := range page.MergeRequests {
340 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {369 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
341 return ports.MergeRequestPage{}, err370 return ports.MergeRequestPage{}, err
342 }371 }
372+ if m.mergeab == nil || !mr.IsOpen() {
373+ continue
374+ }
375+ tip, ok := tips[mr.Target]
376+ if !ok {
377+ if tip, err = m.branchOrZero(ctx, name, mr.Target); err != nil {
378+ return ports.MergeRequestPage{}, err
379+ }
380+ tips[mr.Target] = tip
381+ }
382+ if page.Mergeability == nil {
383+ page.Mergeability = map[int64]domain.Mergeability{}
384+ }
385+ page.Mergeability[mr.Number] = m.mergeab.Read(ctx, page.MergeRequests[i], tip)
343 }386 }
344 return page, nil387 return page, nil
345 }388 }
@@ -397,6 +440,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name
397 return n, nil440 return n, nil
398 }441 }
399 442
443+// Mergeability implements ports.MergeRequestService: the mergeability of an
444+// open merge request for the current tips, queued when there is no up-to-date
445+// result. It is the zero value for a request that is not open, or without a
446+// mergeability service.
447+func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
448+ _, mr, err := m.get(ctx, p, name, number)
449+ if err != nil {
450+ return domain.Mergeability{}, err
451+ }
452+ if m.mergeab == nil || !mr.IsOpen() {
453+ return domain.Mergeability{}, nil
454+ }
455+ if mr, err = m.withHead(ctx, mr); err != nil {
456+ return domain.Mergeability{}, err
457+ }
458+ tip, err := m.branchOrZero(ctx, name, mr.Target)
459+ if err != nil {
460+ return domain.Mergeability{}, err
461+ }
462+ return m.mergeab.Read(ctx, mr, tip), nil
463+}
464+
400 func encodeMRToken(last domain.MergeRequest) string {465 func encodeMRToken(last domain.MergeRequest) string {
401 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))466 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
402 }467 }
@@ -467,6 +532,13 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
467 return nil532 return nil
468 }533 }
469 d := ports.MergeRequestDetail{MergeRequest: mr}534 d := ports.MergeRequestDetail{MergeRequest: mr}
535+ if m.mergeab != nil && mr.IsOpen() {
536+ tip, err := m.branchOrZero(ctx, name, mr.Target)
537+ if err != nil {
538+ return ports.MergeRequestDetail{}, err
539+ }
540+ d.Mergeability = m.mergeab.Read(ctx, mr, tip)
541+ }
470 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {542 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
471 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)543 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
472 }544 }
@@ -853,30 +925,77 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
853 // domain.ErrPreconditionFailed when the request is not open, the mode is not925 // domain.ErrPreconditionFailed when the request is not open, the mode is not
854 // allowed by the settings of the repository, the source branch no longer926 // allowed by the settings of the repository, the source branch no longer
855 // points to the head ("branch moved, read again") or is gone, the target is927 // points to the head ("branch moved, read again") or is gone, the target is
856-// gone or is not an ancestor of the head (the author rebases and pushes928+// gone, the mode's own condition does not hold (see below), or the branch
857-// again), or the branch brings nothing. Checks come with CI (CI-1): there are929+// brings nothing. Checks come with CI (CI-1): there are none yet, so the
858-// none yet, so the setting that lets failing checks block the merge has930+// setting that lets failing checks block the merge has nothing to act on.
859-// nothing to act on.
860 //931 //
861-// A fast-forward moves the target to the head, with the old value as a932+// The conditions are always checked with Git, under the lock, never from the
862-// precondition. A squash writes one commit with the tree of the head, the933+// stored mergeability. A fast-forward needs the target to be an ancestor of
863-// target as parent and the author and message of domain.NewSquashCommit,934+// the head (the author rebases and pushes again) and moves the target to the
864-// signed by the instance key, and moves the target to it the same way.935+// head, with the old value as a precondition. A squash and a merge commit need
936+// the head to merge cleanly into the target (a behind branch is fine). A squash
937+// writes one commit with the merged tree, the target as parent and the author
938+// and message of domain.NewSquashCommit; a merge commit has two parents (the
939+// target, then the head) and the message of domain.NewMergeCommit. Both are
940+// signed by the instance key and move the target the same way.
865 //941 //
866 // With DeleteSource the merge is also refused, before anything changes, when942 // With DeleteSource the merge is also refused, before anything changes, when
867 // the source is the default branch or the source or target of another open943 // the source is the default branch or the source or target of another open
868-// request. After the merge the source is deleted if it still points to the944+// request.
869-// head; the PushEvent then carries the deletion too. A failed deletion is945+//
870-// logged and leaves the branch: the merge stands and SourceDeleted is false.946+// Only the merge itself runs before Merge returns, under the lock. Afterwards,
947+// off the request path and without the lock: the deletion of the source branch
948+// (if it still points to the head; a failure is logged and leaves the branch),
949+// MergeRequestMerged then a PushEvent (which carries the deletion too), and
950+// the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
951+// only says that the deletion was requested.
871 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {952 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
872- mr, deleted, err := m.merge(ctx, p, name, number, params)953+ mr, err := m.merge(ctx, p, name, number, params)
873- return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err954+ return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
874 }955 }
875 956
876-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, bool, error) {957+// maxConflictPathsShown is how many conflicting paths a refusal lists.
958+const maxConflictPathsShown = 5
959+
960+// checkMode is the authoritative per-mode check of a merge, done with Git. It
961+// returns the tree a squash or merge commit gets (zero for a fast-forward).
962+func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
963+ if mode == domain.MergeModeFastForward {
964+ ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head)
965+ if err != nil {
966+ return domain.ObjectID{}, err
967+ }
968+ if !ok {
969+ return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed)
970+ }
971+ return domain.ObjectID{}, nil
972+ }
973+ res, err := m.git.MergeTree(ctx, mr.Repo, tip, head)
974+ if err != nil {
975+ return domain.ObjectID{}, err
976+ }
977+ res = res.Bounded()
978+ if !res.Clean {
979+ what := "no common history"
980+ if len(res.ConflictPaths) > 0 {
981+ shown := res.ConflictPaths
982+ if len(shown) > maxConflictPathsShown {
983+ shown = shown[:maxConflictPathsShown]
984+ }
985+ what = strings.Join(shown, ", ")
986+ if len(shown) < len(res.ConflictPaths) || res.Truncated {
987+ what += ", ..."
988+ }
989+ }
990+ return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what)
991+ }
992+ return res.Tree, nil
993+}
994+
995+func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
877 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)996 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
878 if err != nil {997 if err != nil {
879- return domain.MergeRequest{}, false, err998+ return domain.MergeRequest{}, err
880 }999 }
881 mode := params.Mode1000 mode := params.Mode
882 if mode == "" {1001 if mode == "" {
@@ -890,116 +1009,134 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
890 v.Add("head_id", "is required")1009 v.Add("head_id", "is required")
891 }1010 }
892 if err := v.Err(); err != nil {1011 if err := v.Err(); err != nil {
893- return domain.MergeRequest{}, false, err1012+ return domain.MergeRequest{}, err
894 }1013 }
895 1014
896 unlock, err := m.lockRepo(ctx, name)1015 unlock, err := m.lockRepo(ctx, name)
897 if err != nil {1016 if err != nil {
898- return domain.MergeRequest{}, false, err1017+ return domain.MergeRequest{}, err
899 }1018 }
900- defer unlock()1019+ var once sync.Once
1020+ release := func() { once.Do(unlock) }
1021+ defer release()
901 1022
902 mr, err := m.store.Get(ctx, name, number)1023 mr, err := m.store.Get(ctx, name, number)
903 if err != nil {1024 if err != nil {
904- return domain.MergeRequest{}, false, err1025+ return domain.MergeRequest{}, err
905 }1026 }
906 // A merge moves the target and may delete the source: a token limited to1027 // A merge moves the target and may delete the source: a token limited to
907 // branch prefixes may do so only within them.1028 // branch prefixes may do so only within them.
908 if !p.AllowsRef("refs/heads/" + mr.Target) {1029 if !p.AllowsRef("refs/heads/" + mr.Target) {
909- return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)1030+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
910 }1031 }
911 if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {1032 if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
912- return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)1033+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
913 }1034 }
914 if !mr.IsOpen() {1035 if !mr.IsOpen() {
915- return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)1036+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
916 }1037 }
917 if mr.IsDraft() {1038 if mr.IsDraft() {
918- return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)1039+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
919 }1040 }
920 // The settings are read again under the lock: a change applies at once.1041 // The settings are read again under the lock: a change applies at once.
921 repo, err = m.meta.Get(ctx, name)1042 repo, err = m.meta.Get(ctx, name)
922 if err != nil {1043 if err != nil {
923 if errors.Is(err, domain.ErrNotFound) {1044 if errors.Is(err, domain.ErrNotFound) {
924- return domain.MergeRequest{}, false, err // deleted meanwhile1045+ return domain.MergeRequest{}, err // deleted meanwhile
925 }1046 }
926- return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err)1047+ return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
927 }1048 }
928 settings := repo.Settings1049 settings := repo.Settings
929 if settings.IsZero() {1050 if settings.IsZero() {
930 settings = domain.DefaultRepoSettings()1051 settings = domain.DefaultRepoSettings()
931 }1052 }
932 if !settings.Allows(mode) {1053 if !settings.Allows(mode) {
933- return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)1054+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
934 }1055 }
935 1056
936 head, err := m.branchOrZero(ctx, name, mr.Source)1057 head, err := m.branchOrZero(ctx, name, mr.Source)
937 switch {1058 switch {
938 case err != nil:1059 case err != nil:
939- return domain.MergeRequest{}, false, err1060+ return domain.MergeRequest{}, err
940 case head.IsZero():1061 case head.IsZero():
941- return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)1062+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
942 case head != params.HeadID:1063 case head != params.HeadID:
943- return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)1064+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
944 }1065 }
945 tip, err := m.branchOrZero(ctx, name, mr.Target)1066 tip, err := m.branchOrZero(ctx, name, mr.Target)
946 switch {1067 switch {
947 case err != nil:1068 case err != nil:
948- return domain.MergeRequest{}, false, err1069+ return domain.MergeRequest{}, err
949 case tip.IsZero():1070 case tip.IsZero():
950- return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)1071+ return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
951 case tip == head:1072 case tip == head:
952- return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)1073+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
953 }1074 }
954- switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {1075+ // A head behind the target (or at it) has nothing to merge, in any mode.
955- case err != nil:1076+ if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil {
956- return domain.MergeRequest{}, false, err1077+ return domain.MergeRequest{}, err
957- case !ok:1078+ } else if behind {
958- return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target)1079+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1080+ }
1081+ tree, err := m.checkMode(ctx, mr, mode, tip, head)
1082+ if err != nil {
1083+ return domain.MergeRequest{}, err
959 }1084 }
960 if settings.FailingChecksBlock {1085 if settings.FailingChecksBlock {
961 if err := m.checksAllow(ctx, name, head); err != nil {1086 if err := m.checksAllow(ctx, name, head); err != nil {
962- return domain.MergeRequest{}, false, err1087+ return domain.MergeRequest{}, err
963 }1088 }
964 }1089 }
965 if params.DeleteSource {1090 if params.DeleteSource {
966 if err := m.checkDeletable(ctx, name, mr); err != nil {1091 if err := m.checkDeletable(ctx, name, mr); err != nil {
967- return domain.MergeRequest{}, false, err1092+ return domain.MergeRequest{}, err
968 }1093 }
969 }1094 }
970 1095
971- merged, err := m.moveTarget(ctx, mr, mode, tip, head, p)1096+ merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p)
972 if err != nil {1097 if err != nil {
973- return domain.MergeRequest{}, false, err1098+ return domain.MergeRequest{}, err
974 }1099 }
975 m.recordPush(ctx, name) // the target moved, whatever happens next1100 m.recordPush(ctx, name) // the target moved, whatever happens next
976 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)1101 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
977 if err != nil {1102 if err != nil {
978- return domain.MergeRequest{}, false, err1103+ return domain.MergeRequest{}, err
979 }1104 }
980 log := zerowrap.FromCtx(ctx)1105 log := zerowrap.FromCtx(ctx)
981 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).1106 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
982 Str("target", mr.Target).Str("commit", merged.String()).1107 Str("target", mr.Target).Str("commit", merged.String()).
983 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")1108 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
984- updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}1109+
985- deleted := false1110+ // The merge is done and recorded. The rest runs without the lock and after
986- if params.DeleteSource {1111+ // the caller got its answer.
987- // The merge is done and recorded: a failure here keeps the branch. Who1112+ release()
988- // pushed the source goes first (the branch is gone either way for a1113+ bg := context.WithoutCancel(ctx)
989- // schedule; a kept branch merely has no record: not trusted).1114+ m.after.Add(1)
990- if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil {1115+ go func() {
991- log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept")1116+ defer m.after.Done()
992- }1117+ defer func() {
993- switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); {1118+ if r := recover(); r != nil {
994- case err != nil:1119+ log := zerowrap.FromCtx(bg)
995- log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).1120+ log.Error().Str("repo", name.String()).Int64("number", number).
996- Msg("source branch kept after the merge")1121+ Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked")
997- default:1122+ }
998- deleted = true1123+ }()
999- updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})1124+ if m.afterGate != nil {
1000- log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).1125+ m.afterGate()
1001- Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1002 }1126 }
1127+ m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource)
1128+ }()
1129+ return mr, nil
1130+}
1131+
1132+// afterMerge is what follows a merge off the request path: errors are logged,
1133+// never returned. mr is the merged request, tip the old target and merged the
1134+// new one.
1135+func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1136+ name := repo.Name
1137+ updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1138+ if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
1139+ updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
1003 }1140 }
1004 at := mr.UpdatedAt1141 at := mr.UpdatedAt
1005 recordActivity(ctx, m.meta, repo.Name, at)1142 recordActivity(ctx, m.meta, repo.Name, at)
@@ -1008,14 +1145,47 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1008 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1145 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1009 })1146 })
1010 if m.pulls != nil {1147 if m.pulls != nil {
1011- m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)1148+ m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1012 }1149 }
1013 if m.ci != nil {1150 if m.ci != nil {
1014 // GitHub runs the "push" workflows of a merge, with the owner as actor;1151 // GitHub runs the "push" workflows of a merge, with the owner as actor;
1015 // the run is trusted with what the merging principal may read.1152 // the run is trusted with what the merging principal may read.
1016 m.ci.OnMerge(ctx, name, updates, p)1153 m.ci.OnMerge(ctx, name, updates, p)
1017 }1154 }
1018- return mr, deleted, nil1155+ if m.mergeab != nil {
1156+ m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1157+ }
1158+}
1159+
1160+// deleteSource deletes the source branch of a merged request if it is still
1161+// head and still unused, and reports whether it did. The eligibility is checked
1162+// again and the branch deleted in one critical section of the merge lock: a
1163+// request opened on the branch since the merge, or a push to it, keeps it. The
1164+// record of who pushed the branch goes only after a deletion, and only if it
1165+// still names head.
1166+func (m *MergeRequests) deleteSource(ctx context.Context, p domain.Principal, name domain.RepoName, mr domain.MergeRequest, head domain.ObjectID) bool {
1167+ log := zerowrap.FromCtx(ctx).With().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source).Logger()
1168+ unlock, err := m.lockRepo(ctx, name)
1169+ if err != nil {
1170+ log.Warn().Err(err).Msg("source branch kept after the merge")
1171+ return false
1172+ }
1173+ defer unlock()
1174+ if err := m.checkDeletable(ctx, name, mr); err != nil {
1175+ log.Warn().Err(err).Msg("source branch kept after the merge")
1176+ return false
1177+ }
1178+ if err := m.git.DeleteBranch(ctx, name, mr.Source, head); err != nil {
1179+ log.Warn().Err(err).Msg("source branch kept after the merge")
1180+ return false
1181+ }
1182+ if m.tips != nil {
1183+ if err := m.tips.DeleteRefTipIf(ctx, name, "refs/heads/"+mr.Source, head.String()); err != nil && !errors.Is(err, domain.ErrNotFound) {
1184+ log.Warn().Err(err).Msg("the record of the source branch was kept")
1185+ }
1186+ }
1187+ log.Info().Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1188+ return true
1019 }1189 }
1020 1190
1021 // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a1191 // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a
@@ -1108,10 +1278,20 @@ func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
1108 }1278 }
1109 1279
1110 // moveTarget moves the target branch from tip to the head (fast-forward) or to1280 // moveTarget moves the target branch from tip to the head (fast-forward) or to
1111-// a new signed squash commit, and returns the new tip.1281+// a new signed squash or merge commit with the given tree, and returns the new tip.
1112-func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {1282+func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
1113 newTip := head1283 newTip := head
1114- if mode == domain.MergeModeSquash {1284+ switch mode {
1285+ case domain.MergeModeMerge:
1286+ commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now())
1287+ armored, err := m.signer.Sign(commit.Payload())
1288+ if err != nil {
1289+ return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1290+ }
1291+ if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1292+ return domain.ObjectID{}, err
1293+ }
1294+ case domain.MergeModeSquash:
1115 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)1295 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
1116 if err != nil {1296 if err != nil {
1117 return domain.ObjectID{}, err1297 return domain.ObjectID{}, err
@@ -1119,10 +1299,6 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
1119 if more {1299 if more {
1120 return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)1300 return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
1121 }1301 }
1122- tree, err := m.git.CommitTree(ctx, mr.Repo, head)
1123- if err != nil {
1124- return domain.ObjectID{}, err
1125- }
1126 commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())1302 commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
1127 armored, err := m.signer.Sign(commit.Payload())1303 armored, err := m.signer.Sign(commit.Payload())
1128 if err != nil {1304 if err != nil {
M internal/usecase/mergerequests_branches_test.go +1 -0
@@ -67,6 +67,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) {
6767 res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
6868 require.NoError(t, err)
6969 assert.True(t, res.SourceDeleted)
70+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
7071 assert.Equal(t, head, r.branchHead("agent/claude/base"))
7172 _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
7273 assert.ErrorIs(t, err, domain.ErrNotFound)
@@ -67,6 +67,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) {
67 res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})67 res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
68 require.NoError(t, err)68 require.NoError(t, err)
69 assert.True(t, res.SourceDeleted)69 assert.True(t, res.SourceDeleted)
70+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
70 assert.Equal(t, head, r.branchHead("agent/claude/base"))71 assert.Equal(t, head, r.branchHead("agent/claude/base"))
71 _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")72 _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
72 assert.ErrorIs(t, err, domain.ErrNotFound)73 assert.ErrorIs(t, err, domain.ErrNotFound)
M internal/usecase/mergerequests_delete_test.go +150 -1
@@ -3,8 +3,13 @@
33 package usecase_test
44
55 import (
6+ "bytes"
67 "context"
8+ "sync"
79 "testing"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
813
914 "github.com/stretchr/testify/assert"
1015 "github.com/stretchr/testify/require"
@@ -31,6 +36,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) {
3136 require.NoError(t, err)
3237 assert.True(t, res.SourceDeleted)
3338 assert.Equal(t, domain.MRStateMerged, res.State)
39+ r.mrs.Wait(r.ctx) // the deletion happens after Merge returned
3440 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
3541 assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")
3642
@@ -131,6 +137,7 @@ func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) {
131137 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true})
132138 require.NoError(t, err)
133139 require.True(t, res.SourceDeleted)
140+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
134141
135142 r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all")
136143 r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
@@ -157,6 +164,7 @@ func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
157164 mr := r.open("feat")
158165 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
159166 require.NoError(t, err)
167+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
160168 _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"})
161169 require.ErrorIs(t, err, domain.ErrInvalid)
162170 assert.Contains(t, fieldsOf(t, err), "source_branch")
@@ -192,11 +200,152 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
192200
193201 res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
194202 require.NoError(t, err, "the merge stands")
195- assert.False(t, res.SourceDeleted)
203+ assert.True(t, res.SourceDeleted, "the deletion was requested")
196204 assert.Equal(t, domain.MRStateMerged, res.State)
205+ mrs.Wait(r.ctx)
197206 assert.Equal(t, head, r.branchHead("main"))
198207 assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
199208 evs := r.drain()
200209 require.Len(t, evs, 2)
201210 assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
202211 }
212+
213+// afterMergeRig builds merge request use cases over the rig with ref-tip
214+// records, whose post-merge goroutine waits for the returned release function.
215+func (r *mrRig) afterMergeRig(extra func(*usecase.MergeRequestsConfig)) (mrs *usecase.MergeRequests, release func()) {
216+ gate := make(chan struct{})
217+ cfg := usecase.MergeRequestsConfig{
218+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
219+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
220+ Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, RefTips: r.store.CI(),
221+ }
222+ if extra != nil {
223+ extra(&cfg)
224+ }
225+ mrs = usecase.NewMergeRequests(cfg)
226+ mrs.SetAfterMergeGate(func() { <-gate })
227+ var once sync.Once
228+ return mrs, func() { once.Do(func() { close(gate) }) }
229+}
230+
231+// A request opened on the source after the merge, before the deletion runs,
232+// keeps the branch (GO-002).
233+func TestMergeKeepsTheSourceWhenAnotherRequestOpensOnItMeanwhile(t *testing.T) {
234+ t.Parallel()
235+ r := newMRRig(t)
236+ r.seed()
237+ head := r.feature("feat")
238+ mr := r.open("feat")
239+ r.sh("checkout", "-q", "-B", "release", "main")
240+ r.push("release")
241+ mrs, release := r.afterMergeRig(nil)
242+ defer release()
243+
244+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
245+ require.NoError(t, err)
246+ _, err = mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
247+ require.NoError(t, err, "the merge released the lock; the deletion has not run")
248+ release()
249+ mrs.Wait(r.ctx)
250+ assert.Equal(t, head, r.branchHead("feat"), "the branch is kept")
251+}
252+
253+// A push that moves the source after the merge keeps both the branch and the
254+// record of who pushed it (GO-003).
255+func TestMergeKeepsTheSourceAndItsRecordWhenAPushMovesItMeanwhile(t *testing.T) {
256+ t.Parallel()
257+ r := newMRRig(t)
258+ r.seed()
259+ head := r.feature("feat")
260+ mr := r.open("feat")
261+ mrs, release := r.afterMergeRig(nil)
262+ defer release()
263+
264+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
265+ require.NoError(t, err)
266+ r.sh("checkout", "-q", "feat")
267+ moved := r.commit(author, "later.txt", "later\n", "pushed meanwhile")
268+ r.push("feat")
269+ require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: moved.String(), Actor: "ada"}))
270+ release()
271+ mrs.Wait(r.ctx)
272+
273+ assert.Equal(t, moved, r.branchHead("feat"), "the branch is kept")
274+ tip, err := r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
275+ require.NoError(t, err, "the record of the new push is kept")
276+ assert.Equal(t, moved.String(), tip.Sha)
277+}
278+
279+// The record of the source goes with the branch, once it is deleted.
280+func TestMergeRemovesTheSourceRecordAfterDeletingIt(t *testing.T) {
281+ t.Parallel()
282+ r := newMRRig(t)
283+ r.seed()
284+ head := r.feature("feat")
285+ mr := r.open("feat")
286+ mrs, release := r.afterMergeRig(nil)
287+ release()
288+ require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: head.String(), Actor: "ada"}))
289+
290+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
291+ require.NoError(t, err)
292+ mrs.Wait(r.ctx)
293+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
294+ require.ErrorIs(t, err, domain.ErrNotFound)
295+ _, err = r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
296+ assert.ErrorIs(t, err, domain.ErrNotFound)
297+}
298+
299+// panicHooks is a CI trigger whose merge hook panics.
300+type panicHooks struct{}
301+
302+func (panicHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
303+func (panicHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
304+ panic("boom from ci")
305+}
306+
307+func TestMergeLogsAPanickingSideEffectAndWaitReturns(t *testing.T) {
308+ t.Parallel()
309+ r := newMRRig(t)
310+ r.seed()
311+ head := r.feature("feat")
312+ mr := r.open("feat")
313+ mrs, release := r.afterMergeRig(func(c *usecase.MergeRequestsConfig) { c.CI = panicHooks{} })
314+ release()
315+ var logs bytes.Buffer
316+ ctx := zerowrap.WithCtx(r.ctx, zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs}))
317+
318+ _, err := mrs.Merge(ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
319+ require.NoError(t, err)
320+ waited := make(chan struct{})
321+ go func() { mrs.Wait(context.Background()); close(waited) }()
322+ select {
323+ case <-waited:
324+ case <-time.After(10 * time.Second):
325+ t.Fatal("Wait did not return after a panicking side effect")
326+ }
327+ assert.Contains(t, logs.String(), "post-merge side effects panicked")
328+ assert.Contains(t, logs.String(), "boom from ci")
329+}
330+
331+// A head behind the target has nothing to merge, whatever the mode (GO-007).
332+func TestMergeRefusesAHeadBehindTheTarget(t *testing.T) {
333+ t.Parallel()
334+ for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge, domain.MergeModeFastForward} {
335+ t.Run(string(mode), func(t *testing.T) {
336+ t.Parallel()
337+ r := newMRRig(t)
338+ main := r.seed()
339+ r.sh("checkout", "-q", "-B", "old", "main~1")
340+ r.push("old")
341+ old := r.branchHead("old")
342+ require.NotEqual(t, main, old)
343+ mr := r.open("old")
344+
345+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: old})
346+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
347+ assert.Contains(t, err.Error(), "nothing to merge")
348+ assert.Equal(t, main, r.branchHead("main"), "no commit was made")
349+ })
350+ }
351+}
@@ -3,8 +3,13 @@
3 package usecase_test3 package usecase_test
4 4
5 import (5 import (
6+ "bytes"
6 "context"7 "context"
8+ "sync"
7 "testing"9 "testing"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
8 13
9 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
@@ -31,6 +36,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) {
31 require.NoError(t, err)36 require.NoError(t, err)
32 assert.True(t, res.SourceDeleted)37 assert.True(t, res.SourceDeleted)
33 assert.Equal(t, domain.MRStateMerged, res.State)38 assert.Equal(t, domain.MRStateMerged, res.State)
39+ r.mrs.Wait(r.ctx) // the deletion happens after Merge returned
34 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")40 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
35 assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")41 assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")
36 42
@@ -131,6 +137,7 @@ func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) {
131 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true})137 res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, DeleteSource: true})
132 require.NoError(t, err)138 require.NoError(t, err)
133 require.True(t, res.SourceDeleted)139 require.True(t, res.SourceDeleted)
140+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
134 141
135 r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all")142 r.run(r.bare(), r.env(author), "reflog", "expire", "--expire=now", "--all")
136 r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")143 r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
@@ -157,6 +164,7 @@ func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
157 mr := r.open("feat")164 mr := r.open("feat")
158 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})165 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
159 require.NoError(t, err)166 require.NoError(t, err)
167+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
160 _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"})168 _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"})
161 require.ErrorIs(t, err, domain.ErrInvalid)169 require.ErrorIs(t, err, domain.ErrInvalid)
162 assert.Contains(t, fieldsOf(t, err), "source_branch")170 assert.Contains(t, fieldsOf(t, err), "source_branch")
@@ -192,11 +200,152 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
192 200
193 res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})201 res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
194 require.NoError(t, err, "the merge stands")202 require.NoError(t, err, "the merge stands")
195- assert.False(t, res.SourceDeleted)203+ assert.True(t, res.SourceDeleted, "the deletion was requested")
196 assert.Equal(t, domain.MRStateMerged, res.State)204 assert.Equal(t, domain.MRStateMerged, res.State)
205+ mrs.Wait(r.ctx)
197 assert.Equal(t, head, r.branchHead("main"))206 assert.Equal(t, head, r.branchHead("main"))
198 assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")207 assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
199 evs := r.drain()208 evs := r.drain()
200 require.Len(t, evs, 2)209 require.Len(t, evs, 2)
201 assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")210 assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
202 }211 }
212+
213+// afterMergeRig builds merge request use cases over the rig with ref-tip
214+// records, whose post-merge goroutine waits for the returned release function.
215+func (r *mrRig) afterMergeRig(extra func(*usecase.MergeRequestsConfig)) (mrs *usecase.MergeRequests, release func()) {
216+ gate := make(chan struct{})
217+ cfg := usecase.MergeRequestsConfig{
218+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
219+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
220+ Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, RefTips: r.store.CI(),
221+ }
222+ if extra != nil {
223+ extra(&cfg)
224+ }
225+ mrs = usecase.NewMergeRequests(cfg)
226+ mrs.SetAfterMergeGate(func() { <-gate })
227+ var once sync.Once
228+ return mrs, func() { once.Do(func() { close(gate) }) }
229+}
230+
231+// A request opened on the source after the merge, before the deletion runs,
232+// keeps the branch (GO-002).
233+func TestMergeKeepsTheSourceWhenAnotherRequestOpensOnItMeanwhile(t *testing.T) {
234+ t.Parallel()
235+ r := newMRRig(t)
236+ r.seed()
237+ head := r.feature("feat")
238+ mr := r.open("feat")
239+ r.sh("checkout", "-q", "-B", "release", "main")
240+ r.push("release")
241+ mrs, release := r.afterMergeRig(nil)
242+ defer release()
243+
244+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
245+ require.NoError(t, err)
246+ _, err = mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
247+ require.NoError(t, err, "the merge released the lock; the deletion has not run")
248+ release()
249+ mrs.Wait(r.ctx)
250+ assert.Equal(t, head, r.branchHead("feat"), "the branch is kept")
251+}
252+
253+// A push that moves the source after the merge keeps both the branch and the
254+// record of who pushed it (GO-003).
255+func TestMergeKeepsTheSourceAndItsRecordWhenAPushMovesItMeanwhile(t *testing.T) {
256+ t.Parallel()
257+ r := newMRRig(t)
258+ r.seed()
259+ head := r.feature("feat")
260+ mr := r.open("feat")
261+ mrs, release := r.afterMergeRig(nil)
262+ defer release()
263+
264+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
265+ require.NoError(t, err)
266+ r.sh("checkout", "-q", "feat")
267+ moved := r.commit(author, "later.txt", "later\n", "pushed meanwhile")
268+ r.push("feat")
269+ require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: moved.String(), Actor: "ada"}))
270+ release()
271+ mrs.Wait(r.ctx)
272+
273+ assert.Equal(t, moved, r.branchHead("feat"), "the branch is kept")
274+ tip, err := r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
275+ require.NoError(t, err, "the record of the new push is kept")
276+ assert.Equal(t, moved.String(), tip.Sha)
277+}
278+
279+// The record of the source goes with the branch, once it is deleted.
280+func TestMergeRemovesTheSourceRecordAfterDeletingIt(t *testing.T) {
281+ t.Parallel()
282+ r := newMRRig(t)
283+ r.seed()
284+ head := r.feature("feat")
285+ mr := r.open("feat")
286+ mrs, release := r.afterMergeRig(nil)
287+ release()
288+ require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: head.String(), Actor: "ada"}))
289+
290+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
291+ require.NoError(t, err)
292+ mrs.Wait(r.ctx)
293+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
294+ require.ErrorIs(t, err, domain.ErrNotFound)
295+ _, err = r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
296+ assert.ErrorIs(t, err, domain.ErrNotFound)
297+}
298+
299+// panicHooks is a CI trigger whose merge hook panics.
300+type panicHooks struct{}
301+
302+func (panicHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
303+func (panicHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
304+ panic("boom from ci")
305+}
306+
307+func TestMergeLogsAPanickingSideEffectAndWaitReturns(t *testing.T) {
308+ t.Parallel()
309+ r := newMRRig(t)
310+ r.seed()
311+ head := r.feature("feat")
312+ mr := r.open("feat")
313+ mrs, release := r.afterMergeRig(func(c *usecase.MergeRequestsConfig) { c.CI = panicHooks{} })
314+ release()
315+ var logs bytes.Buffer
316+ ctx := zerowrap.WithCtx(r.ctx, zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs}))
317+
318+ _, err := mrs.Merge(ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
319+ require.NoError(t, err)
320+ waited := make(chan struct{})
321+ go func() { mrs.Wait(context.Background()); close(waited) }()
322+ select {
323+ case <-waited:
324+ case <-time.After(10 * time.Second):
325+ t.Fatal("Wait did not return after a panicking side effect")
326+ }
327+ assert.Contains(t, logs.String(), "post-merge side effects panicked")
328+ assert.Contains(t, logs.String(), "boom from ci")
329+}
330+
331+// A head behind the target has nothing to merge, whatever the mode (GO-007).
332+func TestMergeRefusesAHeadBehindTheTarget(t *testing.T) {
333+ t.Parallel()
334+ for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge, domain.MergeModeFastForward} {
335+ t.Run(string(mode), func(t *testing.T) {
336+ t.Parallel()
337+ r := newMRRig(t)
338+ main := r.seed()
339+ r.sh("checkout", "-q", "-B", "old", "main~1")
340+ r.push("old")
341+ old := r.branchHead("old")
342+ require.NotEqual(t, main, old)
343+ mr := r.open("old")
344+
345+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: old})
346+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
347+ assert.Contains(t, err.Error(), "nothing to merge")
348+ assert.Equal(t, main, r.branchHead("main"), "no commit was made")
349+ })
350+ }
351+}
M internal/usecase/mergerequests_integration_test.go +11 -6
@@ -207,6 +207,7 @@ func (r *mrRig) open(branch string) domain.MergeRequest {
207207 // subscriber before Publish returns: whatever a returned call published is
208208 // already in the channel.
209209 func (r *mrRig) drain() []domain.Event {
210+ r.mrs.Wait(r.ctx) // the events of a merge are published after it returned
210211 var out []domain.Event
211212 for {
212213 select {
@@ -959,11 +960,10 @@ func TestMergeRefusals(t *testing.T) {
959960 r.push("main")
960961 tip := r.branchHead("main")
961962
962- for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
963- _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
964- require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)
965- assert.Contains(t, err.Error(), "rebase", mode)
966- }
963+ // Only a fast-forward needs the target to be an ancestor.
964+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
965+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
966+ assert.Contains(t, err.Error(), "the target has moved: rebase and push again")
967967 assert.Equal(t, tip, r.branchHead("main"))
968968 d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
969969 require.NoError(t, err)
@@ -1047,8 +1047,13 @@ func TestConcurrentMergesSerialize(t *testing.T) {
10471047 const n = 6
10481048 heads := map[int64]domain.ObjectID{}
10491049 for i := range n {
1050+ // Every branch writes the same file differently: once one is merged the
1051+ // others neither fast-forward nor merge cleanly.
10501052 b := fmt.Sprintf("f%d", i)
1051- heads[r.openFeature(b).Number] = r.branchHead(b)
1053+ r.sh("checkout", "-q", "-B", b, "main")
1054+ r.commit(author, "same.txt", b+"\n", "add "+b)
1055+ r.push(b)
1056+ heads[r.open(b).Number] = r.branchHead(b)
10521057 }
10531058
10541059 type outcome struct {
@@ -207,6 +207,7 @@ func (r *mrRig) open(branch string) domain.MergeRequest {
207 // subscriber before Publish returns: whatever a returned call published is207 // subscriber before Publish returns: whatever a returned call published is
208 // already in the channel.208 // already in the channel.
209 func (r *mrRig) drain() []domain.Event {209 func (r *mrRig) drain() []domain.Event {
210+ r.mrs.Wait(r.ctx) // the events of a merge are published after it returned
210 var out []domain.Event211 var out []domain.Event
211 for {212 for {
212 select {213 select {
@@ -959,11 +960,10 @@ func TestMergeRefusals(t *testing.T) {
959 r.push("main")960 r.push("main")
960 tip := r.branchHead("main")961 tip := r.branchHead("main")
961 962
962- for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {963+ // Only a fast-forward needs the target to be an ancestor.
963- _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})964+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
964- require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)965+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
965- assert.Contains(t, err.Error(), "rebase", mode)966+ assert.Contains(t, err.Error(), "the target has moved: rebase and push again")
966- }
967 assert.Equal(t, tip, r.branchHead("main"))967 assert.Equal(t, tip, r.branchHead("main"))
968 d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})968 d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
969 require.NoError(t, err)969 require.NoError(t, err)
@@ -1047,8 +1047,13 @@ func TestConcurrentMergesSerialize(t *testing.T) {
1047 const n = 61047 const n = 6
1048 heads := map[int64]domain.ObjectID{}1048 heads := map[int64]domain.ObjectID{}
1049 for i := range n {1049 for i := range n {
1050+ // Every branch writes the same file differently: once one is merged the
1051+ // others neither fast-forward nor merge cleanly.
1050 b := fmt.Sprintf("f%d", i)1052 b := fmt.Sprintf("f%d", i)
1051- heads[r.openFeature(b).Number] = r.branchHead(b)1053+ r.sh("checkout", "-q", "-B", b, "main")
1054+ r.commit(author, "same.txt", b+"\n", "add "+b)
1055+ r.push(b)
1056+ heads[r.open(b).Number] = r.branchHead(b)
1052 }1057 }
1053 1058
1054 type outcome struct {1059 type outcome struct {
A internal/usecase/mergerequests_merge_test.go +293 -0
@@ -0,0 +1,293 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/ed25519"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/sshsig"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// gateHooks is a CI and a pull_request trigger that block on a channel, to
24+// tell what runs before Merge returns.
25+type gateHooks struct {
26+ release chan struct{}
27+ mu sync.Mutex
28+ calls []string
29+}
30+
31+func newGateHooks() *gateHooks { return &gateHooks{release: make(chan struct{})} }
32+
33+func (g *gateHooks) note(s string) {
34+ <-g.release
35+ g.mu.Lock()
36+ defer g.mu.Unlock()
37+ g.calls = append(g.calls, s)
38+}
39+
40+func (g *gateHooks) ran() []string {
41+ g.mu.Lock()
42+ defer g.mu.Unlock()
43+ return append([]string(nil), g.calls...)
44+}
45+
46+func (g *gateHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
47+
48+func (g *gateHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
49+ g.note("ci")
50+}
51+
52+// OnPullRequest blocks for the closing of a request only: Create reports the
53+// opening on the request path.
54+func (g *gateHooks) OnPullRequest(_ context.Context, _ domain.RepoName, ev ports.PullRequestEvent, _ domain.Principal) {
55+ if ev.Action == domain.PullClosed {
56+ g.note("pulls")
57+ }
58+}
59+
60+// gated builds merge request use cases over the rig whose CI and pull hooks
61+// block until g.release is closed.
62+func (r *mrRig) gated(g *gateHooks) *usecase.MergeRequests {
63+ signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
64+ require.NoError(r.t, err)
65+ return usecase.NewMergeRequests(usecase.MergeRequestsConfig{
66+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: signer,
67+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
68+ Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, CI: g, PullRequests: g,
69+ })
70+}
71+
72+// behind moves main on with a commit of its own (a file the feature does not
73+// touch), and returns the new tip.
74+func (r *mrRig) behind() domain.ObjectID {
75+ r.sh("checkout", "-q", "main")
76+ r.commit(author, "news.txt", "n\n", "main moved")
77+ r.push("main")
78+ return r.branchHead("main")
79+}
80+
81+func TestMergeModesWhenBehind(t *testing.T) {
82+ t.Parallel()
83+ t.Run("fast-forward is refused", func(t *testing.T) {
84+ t.Parallel()
85+ r := newMRRig(t)
86+ r.seed()
87+ head := r.feature("feat")
88+ mr := r.open("feat")
89+ tip := r.behind()
90+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
91+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
92+ assert.Contains(t, err.Error(), "the target has moved: rebase and push again, or merge with squash or a merge commit")
93+ assert.Equal(t, tip, r.branchHead("main"))
94+ })
95+
96+ t.Run("squash merges the merged tree", func(t *testing.T) {
97+ t.Parallel()
98+ r := newMRRig(t)
99+ r.seed()
100+ head := r.feature("feat")
101+ mr := r.open("feat")
102+ tip := r.behind()
103+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
104+ require.NoError(t, err)
105+ sq := res.MergedCommitID.String()
106+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
107+ assert.Equal(t, tip.String(), g("rev-parse", sq+"^"), "the current tip is the parent")
108+ files := g("ls-tree", "-r", "--name-only", sq)
109+ assert.Contains(t, files, "news.txt", "the target's own change is kept")
110+ assert.Contains(t, files, "c.txt", "the branch's change is there")
111+ r.verifyCommit(sq)
112+ })
113+
114+ t.Run("merge commit has two parents, the merge tree and a good signature", func(t *testing.T) {
115+ t.Parallel()
116+ r := newMRRig(t)
117+ r.seed()
118+ head := r.feature("feat")
119+ mr := r.open("feat")
120+ tip := r.behind()
121+ want := r.run(r.bare(), r.env(author), "merge-tree", "--write-tree", tip.String(), head.String())
122+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
123+ require.NoError(t, err)
124+ assert.Equal(t, domain.MergeModeMerge, res.MergeMode)
125+ assert.Equal(t, tip, res.BaseID)
126+ id := res.MergedCommitID.String()
127+ assert.Equal(t, id, r.branchHead("main").String())
128+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
129+ assert.Equal(t, tip.String()+" "+head.String(), g("log", "-1", "--format=%P", id), "parents: target, head")
130+ assert.Equal(t, strings.TrimSpace(want), g("rev-parse", id+"^{tree}"))
131+ assert.Equal(t, "Merge branch 'feat' into main (!1)\n\nAdd feat", g("log", "-1", "--format=%B", id))
132+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%an <%ae>", id))
133+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", id))
134+ r.verifyCommit(id)
135+ })
136+
137+ t.Run("merge commit when up to date", func(t *testing.T) {
138+ t.Parallel()
139+ r := newMRRig(t)
140+ main := r.seed()
141+ head := r.feature("feat")
142+ mr := r.open("feat")
143+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
144+ require.NoError(t, err)
145+ id := res.MergedCommitID.String()
146+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
147+ assert.Equal(t, main.String()+" "+head.String(), g("log", "-1", "--format=%P", id))
148+ assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", id+"^{tree}"))
149+ })
150+}
151+
152+func TestMergeRefusedOnConflictAndNoCommonHistory(t *testing.T) {
153+ t.Parallel()
154+ conflict := func(t *testing.T) (*mrRig, domain.MergeRequest, domain.ObjectID, domain.ObjectID) {
155+ r := newMRRig(t)
156+ r.seed()
157+ head := r.feature("feat") // changes line 2 of a.txt
158+ mr := r.open("feat")
159+ r.sh("checkout", "-q", "main")
160+ r.commit(author, "a.txt", "one\nTWO\nthree\n", "main changes a too")
161+ r.push("main")
162+ return r, mr, head, r.branchHead("main")
163+ }
164+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
165+ t.Run("conflict "+string(mode), func(t *testing.T) {
166+ t.Parallel()
167+ r, mr, head, tip := conflict(t)
168+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
169+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
170+ if mode != domain.MergeModeFastForward {
171+ assert.Contains(t, err.Error(), "conflicts with main: a.txt: rebase and push again")
172+ }
173+ assert.Equal(t, tip, r.branchHead("main"))
174+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
175+ require.NoError(t, err)
176+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
177+ })
178+ }
179+
180+ for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge} {
181+ t.Run("no common history "+string(mode), func(t *testing.T) {
182+ t.Parallel()
183+ r := newMRRig(t)
184+ r.seed()
185+ r.sh("checkout", "-q", "--orphan", "island")
186+ r.commit(author, "island.txt", "i\n", "an unrelated root")
187+ r.push("island")
188+ head := r.branchHead("island")
189+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "island", Source: "island"})
190+ if err != nil {
191+ t.Skipf("a request without common history cannot be opened: %v", err)
192+ }
193+ tip := r.branchHead("main")
194+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
195+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
196+ assert.Contains(t, err.Error(), "no common history")
197+ assert.Equal(t, tip, r.branchHead("main"))
198+ })
199+ }
200+}
201+
202+func TestMergeReturnsBeforeTheSideEffects(t *testing.T) {
203+ t.Parallel()
204+ r := newMRRig(t)
205+ r.seed()
206+ head := r.feature("feat")
207+ mr := r.open("feat")
208+ r.drain()
209+ g := newGateHooks()
210+ mrs := r.gated(g)
211+
212+ done := make(chan error, 1)
213+ go func() {
214+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
215+ done <- err
216+ }()
217+ select {
218+ case err := <-done:
219+ require.NoError(t, err, "Merge does not wait for the blocked hooks")
220+ case <-time.After(10 * time.Second):
221+ t.Fatal("Merge waited for the side effects")
222+ }
223+ // The merge is complete and recorded though the hooks have not run.
224+ d, err := mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
225+ require.NoError(t, err)
226+ assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
227+ assert.Equal(t, d.MergeRequest.MergedCommitID, r.branchHead("main"))
228+ assert.Empty(t, g.ran())
229+
230+ // Wait waits for them: it times out while they are blocked, then returns.
231+ ctx, cancel := context.WithTimeout(r.ctx, 50*time.Millisecond)
232+ start := time.Now()
233+ mrs.Wait(ctx)
234+ cancel()
235+ assert.GreaterOrEqual(t, time.Since(start), 40*time.Millisecond, "Wait waits for the pending side effects")
236+
237+ waited := make(chan struct{})
238+ go func() { mrs.Wait(context.Background()); close(waited) }()
239+ select {
240+ case <-waited:
241+ t.Fatal("Wait returned with side effects pending")
242+ case <-time.After(50 * time.Millisecond):
243+ }
244+ close(g.release)
245+ select {
246+ case <-waited:
247+ case <-time.After(10 * time.Second):
248+ t.Fatal("Wait did not return after the side effects ended")
249+ }
250+ assert.ElementsMatch(t, []string{"ci", "pulls"}, g.ran())
251+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
252+ assert.ErrorIs(t, err, domain.ErrNotFound, "the source was deleted")
253+
254+ // Merged, then pushed, with the deletion in the push.
255+ evs := r.drain()
256+ require.Len(t, evs, 2)
257+ assert.IsType(t, domain.MergeRequestMerged{}, evs[0])
258+ push, ok := evs[1].(domain.PushEvent)
259+ require.True(t, ok)
260+ assert.Len(t, push.Updates, 2)
261+}
262+
263+func TestMergeReleasesTheLockBeforeTheSideEffects(t *testing.T) {
264+ t.Parallel()
265+ r := newMRRig(t)
266+ r.seed()
267+ head := r.feature("feat")
268+ mr := r.open("feat")
269+ r.sh("checkout", "-q", "main")
270+ other := r.openFeature("other")
271+ g := newGateHooks()
272+ defer func() {
273+ close(g.release)
274+ }()
275+ mrs := r.gated(g)
276+
277+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
278+ require.NoError(t, err)
279+ t.Cleanup(func() { mrs.Wait(context.Background()) })
280+ require.Empty(t, g.ran(), "the side effects are still blocked")
281+
282+ // Create and another Merge need the repository lock: they must finish.
283+ ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second)
284+ defer cancel()
285+ r.sh("checkout", "-q", "-B", "third", "main")
286+ r.commit(author, "third.txt", "t\n", "third")
287+ r.push("third")
288+ _, err = mrs.Create(ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "third", Source: "third"})
289+ require.NoError(t, err, "Create is not blocked by the side effects of a merge")
290+ _, err = mrs.Merge(ctx, mrM, r.repo, other.Number, ports.MergeParams{HeadID: r.branchHead("other"), Mode: domain.MergeModeMerge})
291+ require.NoError(t, err, "a second merge is not blocked either")
292+ assert.Empty(t, g.ran())
293+}
@@ -0,0 +1,293 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/ed25519"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/sshsig"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// gateHooks is a CI and a pull_request trigger that block on a channel, to
24+// tell what runs before Merge returns.
25+type gateHooks struct {
26+ release chan struct{}
27+ mu sync.Mutex
28+ calls []string
29+}
30+
31+func newGateHooks() *gateHooks { return &gateHooks{release: make(chan struct{})} }
32+
33+func (g *gateHooks) note(s string) {
34+ <-g.release
35+ g.mu.Lock()
36+ defer g.mu.Unlock()
37+ g.calls = append(g.calls, s)
38+}
39+
40+func (g *gateHooks) ran() []string {
41+ g.mu.Lock()
42+ defer g.mu.Unlock()
43+ return append([]string(nil), g.calls...)
44+}
45+
46+func (g *gateHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
47+
48+func (g *gateHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
49+ g.note("ci")
50+}
51+
52+// OnPullRequest blocks for the closing of a request only: Create reports the
53+// opening on the request path.
54+func (g *gateHooks) OnPullRequest(_ context.Context, _ domain.RepoName, ev ports.PullRequestEvent, _ domain.Principal) {
55+ if ev.Action == domain.PullClosed {
56+ g.note("pulls")
57+ }
58+}
59+
60+// gated builds merge request use cases over the rig whose CI and pull hooks
61+// block until g.release is closed.
62+func (r *mrRig) gated(g *gateHooks) *usecase.MergeRequests {
63+ signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
64+ require.NoError(r.t, err)
65+ return usecase.NewMergeRequests(usecase.MergeRequestsConfig{
66+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: signer,
67+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
68+ Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, CI: g, PullRequests: g,
69+ })
70+}
71+
72+// behind moves main on with a commit of its own (a file the feature does not
73+// touch), and returns the new tip.
74+func (r *mrRig) behind() domain.ObjectID {
75+ r.sh("checkout", "-q", "main")
76+ r.commit(author, "news.txt", "n\n", "main moved")
77+ r.push("main")
78+ return r.branchHead("main")
79+}
80+
81+func TestMergeModesWhenBehind(t *testing.T) {
82+ t.Parallel()
83+ t.Run("fast-forward is refused", func(t *testing.T) {
84+ t.Parallel()
85+ r := newMRRig(t)
86+ r.seed()
87+ head := r.feature("feat")
88+ mr := r.open("feat")
89+ tip := r.behind()
90+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
91+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
92+ assert.Contains(t, err.Error(), "the target has moved: rebase and push again, or merge with squash or a merge commit")
93+ assert.Equal(t, tip, r.branchHead("main"))
94+ })
95+
96+ t.Run("squash merges the merged tree", func(t *testing.T) {
97+ t.Parallel()
98+ r := newMRRig(t)
99+ r.seed()
100+ head := r.feature("feat")
101+ mr := r.open("feat")
102+ tip := r.behind()
103+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
104+ require.NoError(t, err)
105+ sq := res.MergedCommitID.String()
106+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
107+ assert.Equal(t, tip.String(), g("rev-parse", sq+"^"), "the current tip is the parent")
108+ files := g("ls-tree", "-r", "--name-only", sq)
109+ assert.Contains(t, files, "news.txt", "the target's own change is kept")
110+ assert.Contains(t, files, "c.txt", "the branch's change is there")
111+ r.verifyCommit(sq)
112+ })
113+
114+ t.Run("merge commit has two parents, the merge tree and a good signature", func(t *testing.T) {
115+ t.Parallel()
116+ r := newMRRig(t)
117+ r.seed()
118+ head := r.feature("feat")
119+ mr := r.open("feat")
120+ tip := r.behind()
121+ want := r.run(r.bare(), r.env(author), "merge-tree", "--write-tree", tip.String(), head.String())
122+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
123+ require.NoError(t, err)
124+ assert.Equal(t, domain.MergeModeMerge, res.MergeMode)
125+ assert.Equal(t, tip, res.BaseID)
126+ id := res.MergedCommitID.String()
127+ assert.Equal(t, id, r.branchHead("main").String())
128+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
129+ assert.Equal(t, tip.String()+" "+head.String(), g("log", "-1", "--format=%P", id), "parents: target, head")
130+ assert.Equal(t, strings.TrimSpace(want), g("rev-parse", id+"^{tree}"))
131+ assert.Equal(t, "Merge branch 'feat' into main (!1)\n\nAdd feat", g("log", "-1", "--format=%B", id))
132+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%an <%ae>", id))
133+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", id))
134+ r.verifyCommit(id)
135+ })
136+
137+ t.Run("merge commit when up to date", func(t *testing.T) {
138+ t.Parallel()
139+ r := newMRRig(t)
140+ main := r.seed()
141+ head := r.feature("feat")
142+ mr := r.open("feat")
143+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
144+ require.NoError(t, err)
145+ id := res.MergedCommitID.String()
146+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
147+ assert.Equal(t, main.String()+" "+head.String(), g("log", "-1", "--format=%P", id))
148+ assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", id+"^{tree}"))
149+ })
150+}
151+
152+func TestMergeRefusedOnConflictAndNoCommonHistory(t *testing.T) {
153+ t.Parallel()
154+ conflict := func(t *testing.T) (*mrRig, domain.MergeRequest, domain.ObjectID, domain.ObjectID) {
155+ r := newMRRig(t)
156+ r.seed()
157+ head := r.feature("feat") // changes line 2 of a.txt
158+ mr := r.open("feat")
159+ r.sh("checkout", "-q", "main")
160+ r.commit(author, "a.txt", "one\nTWO\nthree\n", "main changes a too")
161+ r.push("main")
162+ return r, mr, head, r.branchHead("main")
163+ }
164+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
165+ t.Run("conflict "+string(mode), func(t *testing.T) {
166+ t.Parallel()
167+ r, mr, head, tip := conflict(t)
168+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
169+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
170+ if mode != domain.MergeModeFastForward {
171+ assert.Contains(t, err.Error(), "conflicts with main: a.txt: rebase and push again")
172+ }
173+ assert.Equal(t, tip, r.branchHead("main"))
174+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
175+ require.NoError(t, err)
176+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
177+ })
178+ }
179+
180+ for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge} {
181+ t.Run("no common history "+string(mode), func(t *testing.T) {
182+ t.Parallel()
183+ r := newMRRig(t)
184+ r.seed()
185+ r.sh("checkout", "-q", "--orphan", "island")
186+ r.commit(author, "island.txt", "i\n", "an unrelated root")
187+ r.push("island")
188+ head := r.branchHead("island")
189+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "island", Source: "island"})
190+ if err != nil {
191+ t.Skipf("a request without common history cannot be opened: %v", err)
192+ }
193+ tip := r.branchHead("main")
194+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
195+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
196+ assert.Contains(t, err.Error(), "no common history")
197+ assert.Equal(t, tip, r.branchHead("main"))
198+ })
199+ }
200+}
201+
202+func TestMergeReturnsBeforeTheSideEffects(t *testing.T) {
203+ t.Parallel()
204+ r := newMRRig(t)
205+ r.seed()
206+ head := r.feature("feat")
207+ mr := r.open("feat")
208+ r.drain()
209+ g := newGateHooks()
210+ mrs := r.gated(g)
211+
212+ done := make(chan error, 1)
213+ go func() {
214+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
215+ done <- err
216+ }()
217+ select {
218+ case err := <-done:
219+ require.NoError(t, err, "Merge does not wait for the blocked hooks")
220+ case <-time.After(10 * time.Second):
221+ t.Fatal("Merge waited for the side effects")
222+ }
223+ // The merge is complete and recorded though the hooks have not run.
224+ d, err := mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
225+ require.NoError(t, err)
226+ assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
227+ assert.Equal(t, d.MergeRequest.MergedCommitID, r.branchHead("main"))
228+ assert.Empty(t, g.ran())
229+
230+ // Wait waits for them: it times out while they are blocked, then returns.
231+ ctx, cancel := context.WithTimeout(r.ctx, 50*time.Millisecond)
232+ start := time.Now()
233+ mrs.Wait(ctx)
234+ cancel()
235+ assert.GreaterOrEqual(t, time.Since(start), 40*time.Millisecond, "Wait waits for the pending side effects")
236+
237+ waited := make(chan struct{})
238+ go func() { mrs.Wait(context.Background()); close(waited) }()
239+ select {
240+ case <-waited:
241+ t.Fatal("Wait returned with side effects pending")
242+ case <-time.After(50 * time.Millisecond):
243+ }
244+ close(g.release)
245+ select {
246+ case <-waited:
247+ case <-time.After(10 * time.Second):
248+ t.Fatal("Wait did not return after the side effects ended")
249+ }
250+ assert.ElementsMatch(t, []string{"ci", "pulls"}, g.ran())
251+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
252+ assert.ErrorIs(t, err, domain.ErrNotFound, "the source was deleted")
253+
254+ // Merged, then pushed, with the deletion in the push.
255+ evs := r.drain()
256+ require.Len(t, evs, 2)
257+ assert.IsType(t, domain.MergeRequestMerged{}, evs[0])
258+ push, ok := evs[1].(domain.PushEvent)
259+ require.True(t, ok)
260+ assert.Len(t, push.Updates, 2)
261+}
262+
263+func TestMergeReleasesTheLockBeforeTheSideEffects(t *testing.T) {
264+ t.Parallel()
265+ r := newMRRig(t)
266+ r.seed()
267+ head := r.feature("feat")
268+ mr := r.open("feat")
269+ r.sh("checkout", "-q", "main")
270+ other := r.openFeature("other")
271+ g := newGateHooks()
272+ defer func() {
273+ close(g.release)
274+ }()
275+ mrs := r.gated(g)
276+
277+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
278+ require.NoError(t, err)
279+ t.Cleanup(func() { mrs.Wait(context.Background()) })
280+ require.Empty(t, g.ran(), "the side effects are still blocked")
281+
282+ // Create and another Merge need the repository lock: they must finish.
283+ ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second)
284+ defer cancel()
285+ r.sh("checkout", "-q", "-B", "third", "main")
286+ r.commit(author, "third.txt", "t\n", "third")
287+ r.push("third")
288+ _, err = mrs.Create(ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "third", Source: "third"})
289+ require.NoError(t, err, "Create is not blocked by the side effects of a merge")
290+ _, err = mrs.Merge(ctx, mrM, r.repo, other.Number, ports.MergeParams{HeadID: r.branchHead("other"), Mode: domain.MergeModeMerge})
291+ require.NoError(t, err, "a second merge is not blocked either")
292+ assert.Empty(t, g.ran())
293+}
M internal/usecase/pushes.go +23 -11
@@ -17,17 +17,18 @@ import (
1717
1818 // Pushes are the use cases around a Git push.
1919 type Pushes struct {
20- meta ports.RepoMetaStore
21- repos *Repos
22- git ports.GitRepos
23- bus ports.EventBus
24- clock ports.Clock
25- maint ports.MaintenanceScheduler
26- auth ports.Revalidator
27- fresh ports.RepoFreshener // nil: the reader needs no telling
28- ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused
30- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
20+ meta ports.RepoMetaStore
21+ repos *Repos
22+ git ports.GitRepos
23+ bus ports.EventBus
24+ clock ports.Clock
25+ maint ports.MaintenanceScheduler
26+ auth ports.Revalidator
27+ fresh ports.RepoFreshener // nil: the reader needs no telling
28+ ci ports.WorkflowTrigger // nil: no CI
29+ wf ports.WorkflowGuard // nil: pushes by job tokens are refused
30+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
31+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3132 }
3233
3334 var (
@@ -56,6 +57,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
5657 return u
5758 }
5859
60+// WithMergeability makes Record queue the mergeability checks of the merge
61+// requests whose branches the push moved. Call it before the first push; it
62+// returns u.
63+func (u *Pushes) WithMergeability(t ports.MergeabilityTrigger) *Pushes {
64+ u.mergeab = t
65+ return u
66+}
67+
5968 // WithRefTips makes Check record who is about to move each branch and tag
6069 // (domain.CIRefTip), before Git moves it. Call it before the first push; it
6170 // returns u.
@@ -230,6 +239,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
230239 if u.ci != nil {
231240 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
232241 }
242+ if u.mergeab != nil {
243+ u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
244+ }
233245 u.bus.Publish(ctx, domain.PushEvent{
234246 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
235247 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
@@ -17,17 +17,18 @@ import (
17 17
18 // Pushes are the use cases around a Git push.18 // Pushes are the use cases around a Git push.
19 type Pushes struct {19 type Pushes struct {
20- meta ports.RepoMetaStore20+ meta ports.RepoMetaStore
21- repos *Repos21+ repos *Repos
22- git ports.GitRepos22+ git ports.GitRepos
23- bus ports.EventBus23+ bus ports.EventBus
24- clock ports.Clock24+ clock ports.Clock
25- maint ports.MaintenanceScheduler25+ maint ports.MaintenanceScheduler
26- auth ports.Revalidator26+ auth ports.Revalidator
27- fresh ports.RepoFreshener // nil: the reader needs no telling27+ fresh ports.RepoFreshener // nil: the reader needs no telling
28- ci ports.WorkflowTrigger // nil: no CI28+ ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused29+ wf ports.WorkflowGuard // nil: pushes by job tokens are refused
30- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded30+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
31+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
31 }32 }
32 33
33 var (34 var (
@@ -56,6 +57,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
56 return u57 return u
57 }58 }
58 59
60+// WithMergeability makes Record queue the mergeability checks of the merge
61+// requests whose branches the push moved. Call it before the first push; it
62+// returns u.
63+func (u *Pushes) WithMergeability(t ports.MergeabilityTrigger) *Pushes {
64+ u.mergeab = t
65+ return u
66+}
67+
59 // WithRefTips makes Check record who is about to move each branch and tag68 // WithRefTips makes Check record who is about to move each branch and tag
60 // (domain.CIRefTip), before Git moves it. Call it before the first push; it69 // (domain.CIRefTip), before Git moves it. Call it before the first push; it
61 // returns u.70 // returns u.
@@ -230,6 +239,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
230 if u.ci != nil {239 if u.ci != nil {
231 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push240 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
232 }241 }
242+ if u.mergeab != nil {
243+ u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
244+ }
233 u.bus.Publish(ctx, domain.PushEvent{245 u.bus.Publish(ctx, domain.PushEvent{
234 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,246 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
235 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,247 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
M proto/ermite/storage/merge_request.proto +28 -0
@@ -15,6 +15,7 @@ enum MergeMode {
1515 MERGE_MODE_UNSPECIFIED = 0;
1616 MERGE_MODE_FAST_FORWARD = 1;
1717 MERGE_MODE_SQUASH = 2;
18+ MERGE_MODE_MERGE = 3;
1819 }
1920
2021 // RepoSettings are the merge settings of a repository, stored inside Repo.
@@ -24,6 +25,11 @@ message RepoSettings {
2425 bool failing_checks_block = 2;
2526 // Who may write issues; unspecified reads as off.
2627 IssuesMode issues = 3;
28+ // Set on every write since merge commits exist: the modes are the explicit
29+ // choice of the owner. When false (settings stored before), exactly
30+ // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
31+ // read.
32+ bool merge_modes_v2 = 4;
2733 }
2834
2935 // IssuesMode says who may write issues in a repository.
@@ -104,3 +110,25 @@ message Comment {
104110 message Sequence {
105111 int64 last = 1;
106112 }
113+
114+// MergeabilityState is the outcome of a mergeability check. "Checking" is
115+// never stored.
116+enum MergeabilityState {
117+ MERGEABILITY_STATE_UNSPECIFIED = 0;
118+ MERGEABILITY_STATE_UP_TO_DATE = 1;
119+ MERGEABILITY_STATE_BEHIND = 2;
120+ MERGEABILITY_STATE_CONFLICT = 3;
121+}
122+
123+// Mergeability is stored under the key "mrmerge/<repo>/<number:010d>": a
124+// derived, discardable record of an open merge request.
125+message Mergeability {
126+ MergeabilityState state = 1;
127+ string target_id = 2;
128+ string head_id = 3;
129+ int32 behind = 4;
130+ bool behind_capped = 5;
131+ repeated string conflict_paths = 6;
132+ bool conflict_paths_truncated = 7;
133+ google.protobuf.Timestamp checked_at = 8;
134+}
@@ -15,6 +15,7 @@ enum MergeMode {
15 MERGE_MODE_UNSPECIFIED = 0;15 MERGE_MODE_UNSPECIFIED = 0;
16 MERGE_MODE_FAST_FORWARD = 1;16 MERGE_MODE_FAST_FORWARD = 1;
17 MERGE_MODE_SQUASH = 2;17 MERGE_MODE_SQUASH = 2;
18+ MERGE_MODE_MERGE = 3;
18 }19 }
19 20
20 // RepoSettings are the merge settings of a repository, stored inside Repo.21 // RepoSettings are the merge settings of a repository, stored inside Repo.
@@ -24,6 +25,11 @@ message RepoSettings {
24 bool failing_checks_block = 2;25 bool failing_checks_block = 2;
25 // Who may write issues; unspecified reads as off.26 // Who may write issues; unspecified reads as off.
26 IssuesMode issues = 3;27 IssuesMode issues = 3;
28+ // Set on every write since merge commits exist: the modes are the explicit
29+ // choice of the owner. When false (settings stored before), exactly
30+ // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
31+ // read.
32+ bool merge_modes_v2 = 4;
27 }33 }
28 34
29 // IssuesMode says who may write issues in a repository.35 // IssuesMode says who may write issues in a repository.
@@ -104,3 +110,25 @@ message Comment {
104 message Sequence {110 message Sequence {
105 int64 last = 1;111 int64 last = 1;
106 }112 }
113+
114+// MergeabilityState is the outcome of a mergeability check. "Checking" is
115+// never stored.
116+enum MergeabilityState {
117+ MERGEABILITY_STATE_UNSPECIFIED = 0;
118+ MERGEABILITY_STATE_UP_TO_DATE = 1;
119+ MERGEABILITY_STATE_BEHIND = 2;
120+ MERGEABILITY_STATE_CONFLICT = 3;
121+}
122+
123+// Mergeability is stored under the key "mrmerge/<repo>/<number:010d>": a
124+// derived, discardable record of an open merge request.
125+message Mergeability {
126+ MergeabilityState state = 1;
127+ string target_id = 2;
128+ string head_id = 3;
129+ int32 behind = 4;
130+ bool behind_capped = 5;
131+ repeated string conflict_paths = 6;
132+ bool conflict_paths_truncated = 7;
133+ google.protobuf.Timestamp checked_at = 8;
134+}
M skills/ermite/CLI.md +5 -4
@@ -11,7 +11,7 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
1111 | `repo list [--json]` | none | repositories you can see |
1212 | `repo show <name> [--json]` | none | |
1313 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; `--description ""` clears it; the branch must exist |
14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
1515 | `repo delete <name> [--yes]` | `repo:admin` | |
1616
1717 Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`.
@@ -56,12 +56,12 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5656 |---|---|---|
5757 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |
5858 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
59-| `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
59+| `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers; the `mergeability` line (JSON `mergeability`, null unless open) is `up_to_date`, `behind` (N commits, merges cleanly), `conflict` (paths) or `checking` |
6060 | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |
6161 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
6262 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
6363 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64-| `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
64+| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
6565 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6666
6767 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
109+| `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes |
109110 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
110111 | `gap` | none: events were lost, reload the state |
111112
@@ -116,7 +117,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
116117 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |
117118 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
118119 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
119-| `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
120+| `signing-key show` | public key and `allowed_signers` line to check squash and merge commits with `git verify-commit` |
120121 | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) |
121122 | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key |
122123 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
@@ -11,7 +11,7 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
11 | `repo list [--json]` | none | repositories you can see |11 | `repo list [--json]` | none | repositories you can see |
12 | `repo show <name> [--json]` | none | |12 | `repo show <name> [--json]` | none | |
13 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |13 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; `--description ""` clears it; the branch must exist |14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
15 | `repo delete <name> [--yes]` | `repo:admin` | |15 | `repo delete <name> [--yes]` | `repo:admin` | |
16 16
17 Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`.17 Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`.
@@ -56,12 +56,12 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
56 |---|---|---|56 |---|---|---|
57 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |57 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |
58 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |58 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
59-| `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |59+| `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers; the `mergeability` line (JSON `mergeability`, null unless open) is `up_to_date`, `behind` (N commits, merges cleanly), `conflict` (paths) or `checking` |
60 | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |60 | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |
61 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |61 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
62 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |62 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
63 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |63 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64-| `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |64+| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
65 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |65 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
66 66
67 `ermite mr create` and `ermite mr close` print a confirmation followed by the full67 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
109+| `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes |
109 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |110 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
110 | `gap` | none: events were lost, reload the state |111 | `gap` | none: events were lost, reload the state |
111 112
@@ -116,7 +117,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
116 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |117 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |
117 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |118 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
118 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |119 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
119-| `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |120+| `signing-key show` | public key and `allowed_signers` line to check squash and merge commits with `git verify-commit` |
120 | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) |121 | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) |
121 | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key |122 | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key |
122 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |123 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
M skills/ermite/SKILL.md +2 -2
@@ -37,9 +37,9 @@ git push https://git.example.com/<repo>.git main # password = token,
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
3838 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
3939 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
40+5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
4141
42-The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
42+The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
4343
4444 ### Act on a review
4545
@@ -37,9 +37,9 @@ git push https://git.example.com/<repo>.git main # password = token,
37 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.37 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
38 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.38 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
39 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.39 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.40+5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
41 41
42-The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.42+The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
43 43
44 ### Act on a review44 ### Act on a review
45 45

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close