1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
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"
17 "bnema.dev/ermite/internal/ports/mocks"
18 "bnema.dev/ermite/internal/usecase"
19)
20
21func autoRig(t *testing.T) (*mrRig, domain.MergeRequest, *mocks.CIChecksReader) {
22 t.Helper()
23 r := newMRRig(t)
24 r.seed()
25 r.feature("feat")
26 mr := r.open("feat")
27 checks := mocks.NewCIChecksReader(t)
28 r.mrs.SetChecks(checks)
29 _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
30 require.NoError(t, err)
31 return r, mr, checks
32}
33
34func autoEvaluator(r *mrRig, checks ports.CIChecksReader, activity ports.CIActivity, tokens ports.TokenStore) *usecase.AutoMerge {
35 return usecase.NewAutoMerge(r.ctx, usecase.AutoMergeConfig{MRs: r.mrs, Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Checks: checks, Activity: activity, Tokens: tokens, Clock: fixedClock{}})
36}
37
38func enableAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest {
39 t.Helper()
40 enabled, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: r.head()})
41 require.NoError(t, err)
42 require.NotNil(t, enabled.AutoMerge)
43 return enabled
44}
45
46func storedAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest {
47 t.Helper()
48 got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
49 require.NoError(t, err)
50 return got
51}
52
53func TestEnableAutoMergeNeedsSettingScopeAndRef(t *testing.T) {
54 for _, tc := range []string{"setting", "scope", "ref", "draft", "no CI", "closed", "mode"} {
55 t.Run(tc, func(t *testing.T) {
56 r, mr, _ := autoRig(t)
57 p := root
58 want := domain.ErrPreconditionFailed
59 switch tc {
60 case "setting":
61 _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
62 require.NoError(t, err)
63 case "scope":
64 p = tokenPrincipal(domain.ScopeMRWrite, r.repo.String())
65 want = domain.ErrNotFound
66 case "ref":
67 p = domain.TokenPrincipal(domain.Token{ID: "limited", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}, BranchPrefixes: []string{"feat"}})
68 want = domain.ErrForbidden
69 case "draft":
70 mr.Title, _ = domain.ParseMRTitle("WIP: work")
71 require.NoError(t, r.store.MergeRequests().Update(r.ctx, mr))
72 case "no CI":
73 r.mrs.SetChecks(nil)
74 case "closed":
75 _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
76 require.NoError(t, err)
77 case "mode":
78 _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
79 require.NoError(t, err)
80 }
81 _, err := r.mrs.EnableAutoMerge(r.ctx, p, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: r.head()})
82 require.ErrorIs(t, err, want)
83 })
84 }
85}
86
87func TestEnableAutoMergeRecordsEnablerModeHeadAndPublishes(t *testing.T) {
88 r, mr, _ := autoRig(t)
89 trigger := mocks.NewAutoMergeTrigger(t)
90 trigger.EXPECT().OnAutoMergeEnabled(mock.Anything, r.repo).Return().Twice()
91 r.mrs.WithAutoMerge(trigger)
92 r.drain()
93 got, err := r.mrs.EnableAutoMerge(r.ctx, domain.Owner(), r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: r.head()})
94 require.NoError(t, err)
95 assert.Equal(t, &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: r.head(), EnabledBy: domain.PusherOf(domain.Owner()), EnablerLabel: domain.AuthorName(domain.Owner()), EnabledAt: now}, got.AutoMerge)
96 events := r.drain()
97 require.Len(t, events, 1)
98 assert.IsType(t, domain.MergeRequestAutoMergeEnabled{}, events[0])
99 got = enableAuto(t, r, mr)
100 assert.Equal(t, domain.PusherOf(root), got.AutoMerge.EnabledBy)
101 assert.Equal(t, domain.MergeModeFastForward, got.AutoMerge.Mode)
102}
103
104func TestCancelAutoMergePublishesReason(t *testing.T) {
105 r, mr, _ := autoRig(t)
106 enableAuto(t, r, mr)
107 r.drain()
108 got, err := r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number)
109 require.NoError(t, err)
110 assert.Nil(t, got.AutoMerge)
111 events := r.drain()
112 require.Len(t, events, 1)
113 ev, ok := events[0].(domain.MergeRequestAutoMergeCancelled)
114 require.True(t, ok)
115 assert.NotEmpty(t, ev.Reason)
116 _, err = r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number)
117 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
118}
119
120func TestAutoMergeWaitsWhileCINotSettledOrChecksPending(t *testing.T) {
121 r, mr, checks := autoRig(t)
122 enableAuto(t, r, mr)
123 activity := mocks.NewCIActivity(t)
124 activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
125 activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Twice()
126 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, {Status: domain.CIQueued, Name: "pending"}}, nil)
127 a := autoEvaluator(r, checks, activity, nil)
128 defer a.Close(r.ctx)
129 require.True(t, a.Evaluate(r.repo))
130 require.True(t, a.Evaluate(r.repo))
131 assert.True(t, storedAuto(t, r, mr).IsOpen())
132 assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
133}
134
135func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) {
136 r, mr, checks := autoRig(t)
137 trigger := mocks.NewWorkflowTrigger(t)
138 trigger.EXPECT().OnMerge(mock.Anything, r.repo, mock.Anything, root).Return().Once()
139 r.mrs.SetWorkflowTrigger(trigger)
140 enableAuto(t, r, mr)
141 // One pre-lock fast-path read and exactly one under-lock snapshot.
142 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
143 a := autoEvaluator(r, checks, nil, nil)
144 defer a.Close(r.ctx)
145 r.drain()
146 require.True(t, a.Evaluate(r.repo))
147 got := storedAuto(t, r, mr)
148 assert.Equal(t, domain.MRStateMerged, got.State)
149 assert.True(t, got.AutoMerged)
150 assert.Nil(t, got.AutoMerge)
151 events := r.drain()
152 found := false
153 for _, ev := range events {
154 if merged, ok := ev.(domain.MergeRequestMerged); ok {
155 assert.True(t, merged.Auto)
156 found = true
157 }
158 }
159 require.True(t, found)
160}
161
162func TestAutoMergeRequiresPassingChecks(t *testing.T) {
163 for _, tc := range []struct {
164 name string
165 check domain.CICheck
166 }{
167 {"failure", domain.CICheck{Status: domain.CICompleted, Conclusion: domain.CIFailure}},
168 {"pending", domain.CICheck{Status: domain.CIQueued}},
169 } {
170 t.Run(tc.name, func(t *testing.T) {
171 r, mr, checks := autoRig(t)
172 enableAuto(t, r, mr)
173 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
174 a := autoEvaluator(r, checks, nil, nil)
175 defer a.Close(r.ctx)
176 require.True(t, a.Evaluate(r.repo))
177 assert.True(t, storedAuto(t, r, mr).IsOpen())
178 assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
179 })
180 }
181}
182
183func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") }
184func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
185func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") }
186func testAutoCancellation(t *testing.T, change string) {
187 r, mr, checks := autoRig(t)
188 enableAuto(t, r, mr)
189 switch change {
190 case "head":
191 r.commit(author, "new", "new", "new head")
192 r.push("feat")
193 case "mode":
194 _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
195 require.NoError(t, err)
196 case "setting":
197 _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
198 require.NoError(t, err)
199 }
200 a := autoEvaluator(r, checks, nil, nil)
201 defer a.Close(r.ctx)
202 r.drain()
203 require.True(t, a.Evaluate(r.repo))
204 assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
205 events := r.drain()
206 require.Len(t, events, 1)
207 assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0])
208}
209
210func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
211 for _, expired := range []bool{false, true} {
212 t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) {
213 r, mr, checks := autoRig(t)
214 enableAuto(t, r, mr)
215 enabled := storedAuto(t, r, mr)
216 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
217 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
218 tokens := mocks.NewTokenStore(t)
219 // The enabler is checked before CI: no check is read.
220 if expired {
221 tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
222 } else {
223 tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound)
224 }
225 a := autoEvaluator(r, checks, nil, tokens)
226 defer a.Close(r.ctx)
227 require.True(t, a.Evaluate(r.repo))
228 assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
229 assert.True(t, storedAuto(t, r, mr).IsOpen())
230 })
231 }
232}
233
234func TestAutoMergeWaitsOnConflictingOrBehindHead(t *testing.T) {
235 for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
236 t.Run(mode.String(), func(t *testing.T) {
237 r, mr, checks := autoRig(t)
238 _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode, HeadID: r.head()})
239 require.NoError(t, err)
240 head := r.head()
241 r.sh("checkout", "main")
242 r.commit(author, "a.txt", "conflicting target\n", "move target")
243 r.push("main")
244 checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
245 a := autoEvaluator(r, checks, nil, nil)
246 defer a.Close(r.ctx)
247 require.True(t, a.Evaluate(r.repo))
248 assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
249 assert.True(t, storedAuto(t, r, mr).IsOpen())
250 })
251 }
252}
253
254func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) {
255 for _, closeMR := range []bool{false, true} {
256 t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) {
257 r, mr, checks := autoRig(t)
258 enabled := enableAuto(t, r, mr)
259 if closeMR {
260 _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
261 require.NoError(t, err)
262 } else {
263 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil)
264 _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID})
265 require.NoError(t, err)
266 }
267 got := storedAuto(t, r, mr)
268 assert.Nil(t, got.AutoMerge)
269 assert.False(t, got.AutoMerged)
270 })
271 }
272}
273
274func TestAutoMergeStartupRescan(t *testing.T) {
275 r, mr, checks := autoRig(t)
276 enableAuto(t, r, mr)
277 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
278 a := autoEvaluator(r, checks, nil, nil)
279 a.Start()
280 defer a.Close(r.ctx)
281 require.Eventually(t, func() bool {
282 got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
283 return err == nil && got.AutoMerged
284 }, 5*time.Second, time.Millisecond)
285 a.Close(r.ctx)
286 r.mrs.Wait(r.ctx)
287}
288
289func TestAutoMergeRetriesTransientErrors(t *testing.T) {
290 ctx := context.Background()
291 store := mocks.NewMergeRequestStore(t)
292 meta := mocks.NewRepoMetaStore(t)
293 clock := mocks.NewClock(t)
294 clock.EXPECT().Now().Return(now).Maybe()
295 repo := name(t, "proj")
296 store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil)
297 meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once()
298 meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
299 done := make(chan struct{}, 1)
300 store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once()
301 timer := make(chan time.Time, 1)
302 armed := make(chan time.Duration, 1)
303 a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }})
304 a.Start()
305 defer a.Close(ctx)
306 select {
307 case d := <-armed:
308 assert.Equal(t, time.Second, d)
309 case <-time.After(5 * time.Second):
310 t.Fatal("no retry scheduled")
311 }
312 timer <- now.Add(time.Second)
313 select {
314 case <-done:
315 case <-time.After(5 * time.Second):
316 t.Fatal("retry did not evaluate")
317 }
318}
319
320func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) {
321 r, mr, checks := autoRig(t)
322 enableAuto(t, r, mr)
323 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
324 a := autoEvaluator(r, checks, nil, nil)
325 ci := newCIUnit(t, nil).ci
326 ci.SetAutoMergeTrigger(a)
327 gate := make(chan struct{})
328 entered := make(chan struct{})
329 r.mrs.SetAfterMergeGate(func() { close(entered); <-gate })
330 defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }()
331 require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() {
332 a.Start()
333 done := make(chan struct{})
334 go func() {
335 ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) })
336 close(done)
337 }()
338 select {
339 case <-done:
340 case <-time.After(5 * time.Second):
341 t.Fatal("kick blocked behind merge lock")
342 }
343 }))
344 select {
345 case <-entered:
346 case <-time.After(5 * time.Second):
347 t.Fatal("merge never reached post-merge gate")
348 }
349}
350
351func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) {
352 for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} {
353 r, mr, checks := autoRig(t)
354 enableAuto(t, r, mr)
355 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil)
356 a := autoEvaluator(r, checks, nil, nil)
357 require.True(t, a.Evaluate(r.repo))
358 a.Close(r.ctx)
359 assert.True(t, storedAuto(t, r, mr).IsOpen())
360 assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
361 }
362}
363
364func TestEnableAutoMergeNeedsTheHeadTheCallerSaw(t *testing.T) {
365 t.Run("stale head", func(t *testing.T) {
366 r, mr, _ := autoRig(t)
367 stale := r.head()
368 r.commit(author, "later.txt", "later\n", "push after review")
369 r.push("feat")
370 r.drain()
371 _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: stale})
372 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
373 assert.Contains(t, err.Error(), "branch moved, read again")
374 assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
375 assert.Empty(t, r.drain(), "nothing is published")
376 })
377 t.Run("missing head", func(t *testing.T) {
378 r, mr, _ := autoRig(t)
379 _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{})
380 require.ErrorIs(t, err, domain.ErrInvalid)
381 assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
382 })
383}
384
385func TestMergeGateSettingsUpdateWaits(t *testing.T) {
386 for _, params := range []ports.UpdateRepoParams{
387 {AllowAutoMerge: ptr(false)},
388 {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
389 } {
390 r, _, _ := autoRig(t)
391 r.repos.WithMergeGate(r.mrs)
392 entered, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
393 go func() {
394 done <- r.mrs.WithMergeLock(r.ctx, r.repo, func() error { close(entered); <-release; return nil })
395 }()
396 <-entered
397 ctx, cancel := context.WithTimeout(r.ctx, 20*time.Millisecond)
398 _, err := r.repos.Update(ctx, root, r.repo, params)
399 cancel()
400 require.ErrorIs(t, err, context.DeadlineExceeded)
401 close(release)
402 require.NoError(t, <-done)
403 _, err = r.repos.Update(r.ctx, root, r.repo, params)
404 require.NoError(t, err)
405 }
406}
407
408func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
409 r, mr, checks := autoRig(t)
410 enableAuto(t, r, mr)
411 enabled := storedAuto(t, r, mr)
412 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "revoked"}
413 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
414 tokens := mocks.NewTokenStore(t)
415 // Valid when the evaluator looks before CI, revoked by the time it holds the
416 // merge lock: the merge must use the fresh answer.
417 validTok := domain.Token{ID: "revoked", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}}
418 tokens.EXPECT().Get(mock.Anything, "revoked").Return(validTok, nil).Once()
419 tokens.EXPECT().Get(mock.Anything, "revoked").Return(domain.Token{}, domain.ErrNotFound).Once()
420 reached := make(chan struct{})
421 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Run(func(context.Context, domain.RepoName, string) { close(reached) }).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
422 a := autoEvaluator(r, checks, nil, tokens)
423 defer a.Close(r.ctx)
424 done := make(chan bool, 1)
425 require.NoError(t, r.mrs.WithMergeLock(r.ctx, r.repo, func() error {
426 go func() { done <- a.Evaluate(r.repo) }()
427 <-reached
428 // Evaluation has passed its fast-path checks with the then-valid token
429 // and waits for this lock. The token is revoked meanwhile (second Get).
430 return nil
431 }))
432 require.True(t, <-done)
433 got := storedAuto(t, r, mr)
434 require.Nil(t, got.AutoMerge)
435 require.True(t, got.IsOpen())
436}
437
438func TestAutoMergeRechecksSettledUnderMergeGate(t *testing.T) {
439 r, mr, checks := autoRig(t)
440 enableAuto(t, r, mr)
441 activity := mocks.NewCIActivity(t)
442 activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Once()
443 activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
444 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
445 a := autoEvaluator(r, checks, activity, nil)
446 defer a.Close(r.ctx)
447 require.True(t, a.Evaluate(r.repo))
448 require.True(t, storedAuto(t, r, mr).IsOpen())
449 require.NotNil(t, storedAuto(t, r, mr).AutoMerge)
450}