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 TestAutoMergeIgnoresFailingChecksBlockOff(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 _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
174 require.NoError(t, err)
175 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
176 a := autoEvaluator(r, checks, nil, nil)
177 defer a.Close(r.ctx)
178 require.True(t, a.Evaluate(r.repo))
179 assert.True(t, storedAuto(t, r, mr).IsOpen())
180 assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
181 })
182 }
183}
184
185func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") }
186func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
187func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") }
188func testAutoCancellation(t *testing.T, change string) {
189 r, mr, checks := autoRig(t)
190 enableAuto(t, r, mr)
191 switch change {
192 case "head":
193 r.commit(author, "new", "new", "new head")
194 r.push("feat")
195 case "mode":
196 _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
197 require.NoError(t, err)
198 case "setting":
199 _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
200 require.NoError(t, err)
201 }
202 a := autoEvaluator(r, checks, nil, nil)
203 defer a.Close(r.ctx)
204 r.drain()
205 require.True(t, a.Evaluate(r.repo))
206 assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
207 events := r.drain()
208 require.Len(t, events, 1)
209 assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0])
210}
211
212func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
213 for _, expired := range []bool{false, true} {
214 t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) {
215 r, mr, checks := autoRig(t)
216 enableAuto(t, r, mr)
217 enabled := storedAuto(t, r, mr)
218 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
219 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
220 tokens := mocks.NewTokenStore(t)
221 // The enabler is checked before CI: no check is read.
222 if expired {
223 tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
224 } else {
225 tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound)
226 }
227 a := autoEvaluator(r, checks, nil, tokens)
228 defer a.Close(r.ctx)
229 require.True(t, a.Evaluate(r.repo))
230 assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
231 assert.True(t, storedAuto(t, r, mr).IsOpen())
232 })
233 }
234}
235
236func TestAutoMergeWaitsOnConflictingOrBehindHead(t *testing.T) {
237 for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
238 t.Run(mode.String(), func(t *testing.T) {
239 r, mr, checks := autoRig(t)
240 _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode, HeadID: r.head()})
241 require.NoError(t, err)
242 head := r.head()
243 r.sh("checkout", "main")
244 r.commit(author, "a.txt", "conflicting target\n", "move target")
245 r.push("main")
246 checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
247 a := autoEvaluator(r, checks, nil, nil)
248 defer a.Close(r.ctx)
249 require.True(t, a.Evaluate(r.repo))
250 assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
251 assert.True(t, storedAuto(t, r, mr).IsOpen())
252 })
253 }
254}
255
256func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) {
257 for _, closeMR := range []bool{false, true} {
258 t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) {
259 r, mr, checks := autoRig(t)
260 enabled := enableAuto(t, r, mr)
261 if closeMR {
262 _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
263 require.NoError(t, err)
264 } else {
265 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil)
266 _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID})
267 require.NoError(t, err)
268 }
269 got := storedAuto(t, r, mr)
270 assert.Nil(t, got.AutoMerge)
271 assert.False(t, got.AutoMerged)
272 })
273 }
274}
275
276func TestAutoMergeStartupRescan(t *testing.T) {
277 r, mr, checks := autoRig(t)
278 enableAuto(t, r, mr)
279 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
280 a := autoEvaluator(r, checks, nil, nil)
281 a.Start()
282 defer a.Close(r.ctx)
283 require.Eventually(t, func() bool {
284 got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
285 return err == nil && got.AutoMerged
286 }, 5*time.Second, time.Millisecond)
287 a.Close(r.ctx)
288 r.mrs.Wait(r.ctx)
289}
290
291func TestAutoMergeRetriesTransientErrors(t *testing.T) {
292 ctx := context.Background()
293 store := mocks.NewMergeRequestStore(t)
294 meta := mocks.NewRepoMetaStore(t)
295 clock := mocks.NewClock(t)
296 clock.EXPECT().Now().Return(now).Maybe()
297 repo := name(t, "proj")
298 store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil)
299 meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once()
300 meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
301 done := make(chan struct{}, 1)
302 store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once()
303 timer := make(chan time.Time, 1)
304 armed := make(chan time.Duration, 1)
305 a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }})
306 a.Start()
307 defer a.Close(ctx)
308 select {
309 case d := <-armed:
310 assert.Equal(t, time.Second, d)
311 case <-time.After(5 * time.Second):
312 t.Fatal("no retry scheduled")
313 }
314 timer <- now.Add(time.Second)
315 select {
316 case <-done:
317 case <-time.After(5 * time.Second):
318 t.Fatal("retry did not evaluate")
319 }
320}
321
322func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) {
323 r, mr, checks := autoRig(t)
324 enableAuto(t, r, mr)
325 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
326 a := autoEvaluator(r, checks, nil, nil)
327 ci := newCIUnit(t, nil).ci
328 ci.SetAutoMergeTrigger(a)
329 gate := make(chan struct{})
330 entered := make(chan struct{})
331 r.mrs.SetAfterMergeGate(func() { close(entered); <-gate })
332 defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }()
333 require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() {
334 a.Start()
335 done := make(chan struct{})
336 go func() {
337 ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) })
338 close(done)
339 }()
340 select {
341 case <-done:
342 case <-time.After(5 * time.Second):
343 t.Fatal("kick blocked behind merge lock")
344 }
345 }))
346 select {
347 case <-entered:
348 case <-time.After(5 * time.Second):
349 t.Fatal("merge never reached post-merge gate")
350 }
351}
352
353func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) {
354 for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} {
355 r, mr, checks := autoRig(t)
356 enableAuto(t, r, mr)
357 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil)
358 a := autoEvaluator(r, checks, nil, nil)
359 require.True(t, a.Evaluate(r.repo))
360 a.Close(r.ctx)
361 assert.True(t, storedAuto(t, r, mr).IsOpen())
362 assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
363 }
364}
365
366func TestEnableAutoMergeNeedsTheHeadTheCallerSaw(t *testing.T) {
367 t.Run("stale head", func(t *testing.T) {
368 r, mr, _ := autoRig(t)
369 stale := r.head()
370 r.commit(author, "later.txt", "later\n", "push after review")
371 r.push("feat")
372 r.drain()
373 _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: stale})
374 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
375 assert.Contains(t, err.Error(), "branch moved, read again")
376 assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
377 assert.Empty(t, r.drain(), "nothing is published")
378 })
379 t.Run("missing head", func(t *testing.T) {
380 r, mr, _ := autoRig(t)
381 _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{})
382 require.ErrorIs(t, err, domain.ErrInvalid)
383 assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
384 })
385}
386
387func TestMergeGateSettingsUpdateWaits(t *testing.T) {
388 for _, params := range []ports.UpdateRepoParams{
389 {AllowAutoMerge: ptr(false)},
390 {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
391 {FailingChecksBlock: ptr(false)},
392 } {
393 r, _, _ := autoRig(t)
394 r.repos.WithMergeGate(r.mrs)
395 entered, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
396 go func() {
397 done <- r.mrs.WithMergeLock(r.ctx, r.repo, func() error { close(entered); <-release; return nil })
398 }()
399 <-entered
400 ctx, cancel := context.WithTimeout(r.ctx, 20*time.Millisecond)
401 _, err := r.repos.Update(ctx, root, r.repo, params)
402 cancel()
403 require.ErrorIs(t, err, context.DeadlineExceeded)
404 close(release)
405 require.NoError(t, <-done)
406 _, err = r.repos.Update(r.ctx, root, r.repo, params)
407 require.NoError(t, err)
408 }
409}
410
411func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
412 r, mr, checks := autoRig(t)
413 enableAuto(t, r, mr)
414 enabled := storedAuto(t, r, mr)
415 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "revoked"}
416 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
417 tokens := mocks.NewTokenStore(t)
418 // Valid when the evaluator looks before CI, revoked by the time it holds the
419 // merge lock: the merge must use the fresh answer.
420 validTok := domain.Token{ID: "revoked", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}}
421 tokens.EXPECT().Get(mock.Anything, "revoked").Return(validTok, nil).Once()
422 tokens.EXPECT().Get(mock.Anything, "revoked").Return(domain.Token{}, domain.ErrNotFound).Once()
423 reached := make(chan struct{})
424 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()
425 a := autoEvaluator(r, checks, nil, tokens)
426 defer a.Close(r.ctx)
427 done := make(chan bool, 1)
428 require.NoError(t, r.mrs.WithMergeLock(r.ctx, r.repo, func() error {
429 go func() { done <- a.Evaluate(r.repo) }()
430 <-reached
431 // Evaluation has passed its fast-path checks with the then-valid token
432 // and waits for this lock. The token is revoked meanwhile (second Get).
433 return nil
434 }))
435 require.True(t, <-done)
436 got := storedAuto(t, r, mr)
437 require.Nil(t, got.AutoMerge)
438 require.True(t, got.IsOpen())
439}
440
441func TestAutoMergeRechecksSettledUnderMergeGate(t *testing.T) {
442 r, mr, checks := autoRig(t)
443 enableAuto(t, r, mr)
444 activity := mocks.NewCIActivity(t)
445 activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Once()
446 activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
447 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
448 a := autoEvaluator(r, checks, activity, nil)
449 defer a.Close(r.ctx)
450 require.True(t, a.Evaluate(r.repo))
451 require.True(t, storedAuto(t, r, mr).IsOpen())
452 require.NotNil(t, storedAuto(t, r, mr).AutoMerge)
453}