detached 6135dd5

#19 feat(mr): merge request auto-merge

state
merged · 6135dd5 · fast-forward
branch
feat/auto-merge → main
opened
bnema ·
updated

Merge request auto-merge, step 1 of ADR 0033.

  • Repository setting allow_auto_merge (default off): ermite repo edit --allow-auto-merge.
  • Enable with ermite mr merge --auto --head <sha> --mode <mode> or Merge when ready on the web; cancel with --cancel-auto.
  • Merges once CI has settled on the recorded head, every run is complete, at least one check passed and none fails (whatever failing_checks_block says), as the principal who enabled it. That principal is re-checked at every evaluation and under the merge lock.
  • Failing or conflicting heads wait. A new head, close, setting off, mode no longer allowed or a principal that can no longer merge cancels it.
  • Events mr.auto_merge_enabled, mr.auto_merge_cancelled, merged.auto; feature merge_requests.auto_merge.
  • Settings changes that affect merges take the merge lock (MergeGate). CI keeps a pushed head unsettled until its pull events are discovered.

Requires ermite-api v0.14.0.

Checks run locally: build, vet, hexcheck -fail-on warn, mocks/templ drift, race tests (all packages). The two golden failures (TestWebFilesGolden/blob-markdown, TestWebGolden/summary) and three errcheck lint findings also fail on main.

CommitSubjectAuthorDateSignatureStatus
6135dd5test(mr): close linked issues when a merge request auto-mergesbriceGPG Verified
4b10ba7merge: main with issue and merge request links into feat/auto-mergebriceGPG Verified
b2cf6d4build(deps): require ermite-api v0.14.0 for merge request auto-mergebriceGPG Verified
be277d4test(mr): cover failing checks with failing_checks_block off, the pending auto-merge index, and stop relying on event orderbriceGPG Verified
84db480perf(mr): skip auto-merge scans of repositories without a pending auto-merge, read checks once under the merge lock and keep the manual pending-checks errorbriceGPG Verified
b6e9374fix(web): give merge when ready its own mode choice and drop branch deletion from itbriceGPG Verified
355f5f4fix(mr): cancel auto-merge when the enabler can no longer merge, even while checks are pendingbriceGPG Verified
cb71bdddocs(mr): describe when auto-merge waits and when it is clearedbriceGPG Verified
22ff0d6fix(ci): keep a pushed head unsettled until its pull events are discoveredbriceGPG Verified
6609752fix(mr): resolve the auto-merge enabler under the merge lockbriceGPG Verified
8539d6ffix(repo): serialize merge settings updates with mergesbriceGPG Verified
63e55ecfeat(mr): require the head the caller saw to enable auto-mergebriceGPG Verified
b5db7e3docs(mr): document merge request auto-mergebriceGPG Verified
718df65merge: web auto-merge controls into feat/auto-mergebriceGPG Verified
b271fd8feat(cli): add mr merge --auto/--cancel-auto and repo edit --allow-auto-mergebriceGPG Verified
d1f49e5feat(rpc): serve merge request auto-merge and its eventsbriceGPG Verified
5dfb71bfeat(web): add merge when ready and auto-merge state to the merge boxbriceGPG Verified
31adb55style(web): gofmt mr_test.gobriceGPG Verified
ba47d48feat(app): wire the auto-merge evaluatorbriceGPG Verified
1314a0dfeat(mr): enable, cancel and evaluate merge request auto-mergebriceGPG Verified
9795e63feat(ports): add auto-merge service methods, CIActivity and AutoMergeTrigger portsbriceGPG Verified
4bb6db7feat(mr): store auto-merge state and allow_auto_merge settingbriceGPG Verified

Checks 8

70 files · +3994 -492
7/8
M docs/cli.md +3 -2
M docs/merge-requests.md +14 -0
M gen/ermite/storage/merge_request.pb.go +163 -35
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 +5 -1
M internal/adapters/kvstore/mergerequests.go +32 -2
M internal/adapters/kvstore/mergerequests_test.go +47 -0
M internal/adapters/rpc/api.go +1 -1
M internal/adapters/rpc/convert.go +8 -2
M internal/adapters/rpc/mergerequest.go +39 -0
M internal/adapters/rpc/mr_api_test.go +80 -0
M internal/adapters/rpc/repo.go +9 -5
M internal/adapters/rpc/repo_test.go +25 -0
M internal/adapters/web/fragments.go +5 -1
M internal/adapters/web/mr.go +26 -0
M internal/adapters/web/mr.templ +28 -0
M internal/adapters/web/mr_post.go +62 -0
M internal/adapters/web/mr_templ.go +554 -350
M internal/adapters/web/mr_test.go +103 -1
M internal/adapters/web/web.go +2 -0
M internal/app/api_test.go +1 -1
A internal/app/automerge_e2e_test.go +163 -0
M internal/app/cli_e2e_test.go +2 -2
M internal/app/cli_fake_test.go +8 -0
M internal/app/cli_mr.go +95 -3
M internal/app/cli_mr_e2e_test.go +1 -1
M internal/app/cli_mr_test.go +112 -1
M internal/app/cli_repo.go +17 -6
M internal/app/cli_repo_test.go +34 -4
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch.go +25 -3
M internal/app/cli_watch_test.go +55 -1
M internal/app/forge.go +13 -0
M internal/domain/event.go +28 -0
M internal/domain/event_test.go +7 -0
M internal/domain/mergerequest.go +21 -0
M internal/domain/mergerequest_test.go +6 -0
M internal/ports/ci.go +20 -0
M internal/ports/dto.go +11 -0
A internal/ports/mocks/AutoMergeTrigger.go +140 -0
A internal/ports/mocks/CIActivity.go +111 -0
A internal/ports/mocks/MergeGate.go +111 -0
M internal/ports/mocks/MergeRequestService.go +162 -0
M internal/ports/ports.go +7 -0
M internal/ports/services.go +5 -0
A internal/usecase/automerge.go +413 -0
A internal/usecase/automerge_index_test.go +97 -0
A internal/usecase/automerge_test.go +453 -0
@@ -0,0 +1,453 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
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+
21+func 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+
34+func 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+
38+func 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+
46+func 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+
53+func 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+
87+func 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+
104+func 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+
120+func 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+
135+func 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+
162+func 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+
185+func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") }
186+func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
187+func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") }
188+func 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+
212+func 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+
236+func 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+
256+func 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+
276+func 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+
291+func 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+
322+func 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+
353+func 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+
366+func 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+
387+func 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+
411+func 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+
441+func 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+}
@@ -0,0 +1,453 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
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+
21+func 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+
34+func 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+
38+func 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+
46+func 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+
53+func 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+
87+func 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+
104+func 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+
120+func 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+
135+func 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+
162+func 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+
185+func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") }
186+func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
187+func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") }
188+func 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+
212+func 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+
236+func 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+
256+func 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+
276+func 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+
291+func 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+
322+func 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+
353+func 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+
366+func 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+
387+func 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+
411+func 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+
441+func 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+}
M internal/usecase/ci.go +61 -2
@@ -53,8 +53,9 @@ const (
5353 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies
5454 // the defaults of Owner and Retention).
5555 type CIConfig struct {
56- Store ports.CIStore
57- Logs ports.CILogStore
56+ AutoMerge ports.AutoMergeTrigger
57+ Store ports.CIStore
58+ Logs ports.CILogStore
5859 // Summaries keeps the job summaries, deleted with the logs of their run.
5960 Summaries ports.CISummaryStore
6061 Meta ports.RepoMetaStore
@@ -266,6 +267,9 @@ type CI struct {
266267 pending notifier // woken when a job may be ready or a runner was removed
267268 flows *wfCache
268269
270+ pushes map[domain.RepoName]int
271+ pulls map[pullKey]int
272+ autoMerge ports.AutoMergeTrigger
269273 mu sync.Mutex
270274 heartbeats map[string]time.Time // job reference -> last heartbeat
271275 seen map[string]time.Time // runner id -> last seen
@@ -311,6 +315,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
311315 cfg = cfg.withDefaults()
312316 run, stop := context.WithCancel(context.WithoutCancel(ctx))
313317 return &CI{
318+ pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge,
314319 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
315320 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),
316321 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
@@ -343,6 +348,15 @@ func (c *CI) Close(_ context.Context) {
343348 c.mu.Unlock()
344349 c.stop()
345350 c.wg.Wait()
351+ // Workers stop on cancellation; release the planning parts they did not take.
352+ for {
353+ select {
354+ case t := <-c.queue:
355+ t.done()
356+ default:
357+ return
358+ }
359+ }
346360 }
347361
348362 func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() }
@@ -560,6 +574,7 @@ func (rs *runState) flush(ctx context.Context) error {
560574 }
561575 if completing {
562576 c.onRunEvent(ctx, rs.run, runCompleted)
577+ c.kickAutoMerge(ctx, rs.run.Repo)
563578 }
564579 return nil
565580 }
@@ -740,3 +755,47 @@ func isHex40(s string) bool {
740755 }
741756 return strings.Trim(s, "0123456789abcdef") == ""
742757 }
758+
759+// SetAutoMergeTrigger installs the evaluator after constructing the merge use cases.
760+func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) {
761+ c.mu.Lock()
762+ c.autoMerge = t
763+ c.mu.Unlock()
764+}
765+
766+func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) {
767+ c.mu.Lock()
768+ t := c.autoMerge
769+ c.mu.Unlock()
770+ if t != nil {
771+ t.OnRepoChanged(ctx, repo)
772+ }
773+}
774+
775+// Settled reports whether all push planning and pull activities have finished.
776+// Workflow-run events, schedules and dispatches are intentionally not counted.
777+// Repo-wide pushes conservatively delay requests even when an unrelated
778+// branch is updated.
779+func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64) bool {
780+ c.mu.Lock()
781+ defer c.mu.Unlock()
782+ return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0
783+}
784+
785+func (c *CI) finishPush(repo domain.RepoName) {
786+ c.mu.Lock()
787+ c.pushes[repo]--
788+ if c.pushes[repo] == 0 {
789+ delete(c.pushes, repo)
790+ }
791+ c.mu.Unlock()
792+}
793+
794+func (c *CI) finishPull(key pullKey) {
795+ c.mu.Lock()
796+ c.pulls[key]--
797+ if c.pulls[key] == 0 {
798+ delete(c.pulls, key)
799+ }
800+ c.mu.Unlock()
801+}
@@ -53,8 +53,9 @@ const (
53 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies53 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies
54 // the defaults of Owner and Retention).54 // the defaults of Owner and Retention).
55 type CIConfig struct {55 type CIConfig struct {
56- Store ports.CIStore56+ AutoMerge ports.AutoMergeTrigger
57- Logs ports.CILogStore57+ Store ports.CIStore
58+ Logs ports.CILogStore
58 // Summaries keeps the job summaries, deleted with the logs of their run.59 // Summaries keeps the job summaries, deleted with the logs of their run.
59 Summaries ports.CISummaryStore60 Summaries ports.CISummaryStore
60 Meta ports.RepoMetaStore61 Meta ports.RepoMetaStore
@@ -266,6 +267,9 @@ type CI struct {
266 pending notifier // woken when a job may be ready or a runner was removed267 pending notifier // woken when a job may be ready or a runner was removed
267 flows *wfCache268 flows *wfCache
268 269
270+ pushes map[domain.RepoName]int
271+ pulls map[pullKey]int
272+ autoMerge ports.AutoMergeTrigger
269 mu sync.Mutex273 mu sync.Mutex
270 heartbeats map[string]time.Time // job reference -> last heartbeat274 heartbeats map[string]time.Time // job reference -> last heartbeat
271 seen map[string]time.Time // runner id -> last seen275 seen map[string]time.Time // runner id -> last seen
@@ -311,6 +315,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
311 cfg = cfg.withDefaults()315 cfg = cfg.withDefaults()
312 run, stop := context.WithCancel(context.WithoutCancel(ctx))316 run, stop := context.WithCancel(context.WithoutCancel(ctx))
313 return &CI{317 return &CI{
318+ pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge,
314 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),319 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
315 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),320 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),
316 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},321 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
@@ -343,6 +348,15 @@ func (c *CI) Close(_ context.Context) {
343 c.mu.Unlock()348 c.mu.Unlock()
344 c.stop()349 c.stop()
345 c.wg.Wait()350 c.wg.Wait()
351+ // Workers stop on cancellation; release the planning parts they did not take.
352+ for {
353+ select {
354+ case t := <-c.queue:
355+ t.done()
356+ default:
357+ return
358+ }
359+ }
346 }360 }
347 361
348 func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() }362 func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() }
@@ -560,6 +574,7 @@ func (rs *runState) flush(ctx context.Context) error {
560 }574 }
561 if completing {575 if completing {
562 c.onRunEvent(ctx, rs.run, runCompleted)576 c.onRunEvent(ctx, rs.run, runCompleted)
577+ c.kickAutoMerge(ctx, rs.run.Repo)
563 }578 }
564 return nil579 return nil
565 }580 }
@@ -740,3 +755,47 @@ func isHex40(s string) bool {
740 }755 }
741 return strings.Trim(s, "0123456789abcdef") == ""756 return strings.Trim(s, "0123456789abcdef") == ""
742 }757 }
758+
759+// SetAutoMergeTrigger installs the evaluator after constructing the merge use cases.
760+func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) {
761+ c.mu.Lock()
762+ c.autoMerge = t
763+ c.mu.Unlock()
764+}
765+
766+func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) {
767+ c.mu.Lock()
768+ t := c.autoMerge
769+ c.mu.Unlock()
770+ if t != nil {
771+ t.OnRepoChanged(ctx, repo)
772+ }
773+}
774+
775+// Settled reports whether all push planning and pull activities have finished.
776+// Workflow-run events, schedules and dispatches are intentionally not counted.
777+// Repo-wide pushes conservatively delay requests even when an unrelated
778+// branch is updated.
779+func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64) bool {
780+ c.mu.Lock()
781+ defer c.mu.Unlock()
782+ return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0
783+}
784+
785+func (c *CI) finishPush(repo domain.RepoName) {
786+ c.mu.Lock()
787+ c.pushes[repo]--
788+ if c.pushes[repo] == 0 {
789+ delete(c.pushes, repo)
790+ }
791+ c.mu.Unlock()
792+}
793+
794+func (c *CI) finishPull(key pullKey) {
795+ c.mu.Lock()
796+ c.pulls[key]--
797+ if c.pulls[key] == 0 {
798+ delete(c.pulls, key)
799+ }
800+ c.mu.Unlock()
801+}
M internal/usecase/ci_actions.go +22 -14
@@ -271,31 +271,39 @@ func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.
271271 if repo.Name == runRepo {
272272 return true, nil
273273 }
274- var p domain.Principal
275- switch by.pusher.Kind {
274+ p, _, err := principalOfPusher(ctx, c.cfg.Tokens, by.pusher, c.now())
275+ if err != nil {
276+ return false, err
277+ }
278+ return canRead(p, repo), nil
279+}
280+
281+// principalOfPusher re-resolves a recorded actor without substituting a more
282+// privileged identity when its token no longer exists or has expired.
283+func principalOfPusher(ctx context.Context, tokens ports.TokenStore, by domain.CIPusher, now time.Time) (domain.Principal, bool, error) {
284+ switch by.Kind {
276285 case domain.CIPusherRoot:
277- p = domain.Root()
286+ return domain.Root(), true, nil
278287 case domain.CIPusherOwner:
279- p = domain.Owner()
288+ return domain.Owner(), true, nil
280289 case domain.CIPusherToken:
281- if c.cfg.Tokens == nil {
282- return false, nil
290+ if tokens == nil {
291+ return domain.Anonymous(), false, nil
283292 }
284- tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID)
293+ tok, err := tokens.Get(ctx, by.TokenID)
285294 if errors.Is(err, domain.ErrNotFound) {
286- return false, nil
295+ return domain.Anonymous(), false, nil
287296 }
288297 if err != nil {
289- return false, err
298+ return domain.Anonymous(), false, err
290299 }
291- if tok.Expired(c.now()) {
292- return false, nil
300+ if tok.Expired(now) {
301+ return domain.Anonymous(), false, nil
293302 }
294- p = domain.TokenPrincipal(tok)
303+ return domain.TokenPrincipal(tok), true, nil
295304 default:
296- p = domain.Anonymous()
305+ return domain.Anonymous(), false, nil
297306 }
298- return canRead(p, repo), nil
299307 }
300308
301309 // actionRef is a parsed "uses": owner/repo[/path]@ref.
@@ -271,31 +271,39 @@ func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.
271 if repo.Name == runRepo {271 if repo.Name == runRepo {
272 return true, nil272 return true, nil
273 }273 }
274- var p domain.Principal274+ p, _, err := principalOfPusher(ctx, c.cfg.Tokens, by.pusher, c.now())
275- switch by.pusher.Kind {275+ if err != nil {
276+ return false, err
277+ }
278+ return canRead(p, repo), nil
279+}
280+
281+// principalOfPusher re-resolves a recorded actor without substituting a more
282+// privileged identity when its token no longer exists or has expired.
283+func principalOfPusher(ctx context.Context, tokens ports.TokenStore, by domain.CIPusher, now time.Time) (domain.Principal, bool, error) {
284+ switch by.Kind {
276 case domain.CIPusherRoot:285 case domain.CIPusherRoot:
277- p = domain.Root()286+ return domain.Root(), true, nil
278 case domain.CIPusherOwner:287 case domain.CIPusherOwner:
279- p = domain.Owner()288+ return domain.Owner(), true, nil
280 case domain.CIPusherToken:289 case domain.CIPusherToken:
281- if c.cfg.Tokens == nil {290+ if tokens == nil {
282- return false, nil291+ return domain.Anonymous(), false, nil
283 }292 }
284- tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID)293+ tok, err := tokens.Get(ctx, by.TokenID)
285 if errors.Is(err, domain.ErrNotFound) {294 if errors.Is(err, domain.ErrNotFound) {
286- return false, nil295+ return domain.Anonymous(), false, nil
287 }296 }
288 if err != nil {297 if err != nil {
289- return false, err298+ return domain.Anonymous(), false, err
290 }299 }
291- if tok.Expired(c.now()) {300+ if tok.Expired(now) {
292- return false, nil301+ return domain.Anonymous(), false, nil
293 }302 }
294- p = domain.TokenPrincipal(tok)303+ return domain.TokenPrincipal(tok), true, nil
295 default:304 default:
296- p = domain.Anonymous()305+ return domain.Anonymous(), false, nil
297 }306 }
298- return canRead(p, repo), nil
299 }307 }
300 308
301 // actionRef is a parsed "uses": owner/repo[/path]@ref.309 // actionRef is a parsed "uses": owner/repo[/path]@ref.
A internal/usecase/ci_activity_test.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+func TestSettledCountsQueuedPushesAndPullEvents(t *testing.T) {
18+ trigger := mocks.NewAutoMergeTrigger(t)
19+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.TriggerQueue = 1; cfg.AutoMerge = trigger })
20+ repo := name(t, "proj")
21+ trigger.EXPECT().OnRepoChanged(mock.Anything, repo).Return().Times(3)
22+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
23+ ctx := context.Background()
24+ settled := func(n int64) bool { return u.ci.Settled(ctx, repo, n) }
25+ assert.True(t, settled(1))
26+ u.ci.QueuePush(ctx, repo)
27+ u.ci.QueuePush(ctx, repo) // dropped: queue capacity is one
28+ assert.False(t, settled(1))
29+ assert.False(t, settled(2)) // pushes conservatively cover the whole repo
30+ u.ci.QueuePull(repo, 1)
31+ u.ci.QueuePull(repo, 1) // dropped: no counter leak
32+ u.ci.PlanQueuedPush()
33+ assert.False(t, settled(1))
34+ assert.True(t, settled(2))
35+ u.ci.FinishQueuedPull()
36+ assert.True(t, settled(1))
37+}
38+
39+func TestSettledWaitsForPushDiscoveryAndReleasesOnError(t *testing.T) {
40+ mrs := mocks.NewMergeRequestStore(t)
41+ entered, release := make(chan struct{}), make(chan struct{})
42+ u := newCIUnit(t, func(cfg *usecase.CIConfig) {
43+ cfg.MRs = mrs
44+ cfg.Pull = mocks.NewPullGit(t)
45+ cfg.PullStage = func(string) { close(entered); <-release }
46+ })
47+ repo := name(t, "proj")
48+ mrs.EXPECT().OpenByTarget(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, nil)
49+ mrs.EXPECT().OpenBySource(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, domain.ErrUnavailable)
50+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
51+ done := make(chan struct{})
52+ go func() {
53+ u.ci.OnPush(context.Background(), repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: "1111111111111111111111111111111111111111"}}, domain.Root())
54+ close(done)
55+ }()
56+ <-entered
57+ u.ci.PlanQueuedPush()
58+ assert.False(t, u.ci.Settled(context.Background(), repo, 1))
59+ close(release)
60+ <-done
61+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
62+}
63+
64+func TestSettledReleasesQueuedPushOnShutdown(t *testing.T) {
65+ u := newCIUnit(t, nil)
66+ repo := name(t, "proj")
67+ u.ci.QueuePush(context.Background(), repo)
68+ u.ci.Close(context.Background())
69+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
70+ u.ci.QueuePush(context.Background(), repo)
71+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
72+}
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+func TestSettledCountsQueuedPushesAndPullEvents(t *testing.T) {
18+ trigger := mocks.NewAutoMergeTrigger(t)
19+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.TriggerQueue = 1; cfg.AutoMerge = trigger })
20+ repo := name(t, "proj")
21+ trigger.EXPECT().OnRepoChanged(mock.Anything, repo).Return().Times(3)
22+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
23+ ctx := context.Background()
24+ settled := func(n int64) bool { return u.ci.Settled(ctx, repo, n) }
25+ assert.True(t, settled(1))
26+ u.ci.QueuePush(ctx, repo)
27+ u.ci.QueuePush(ctx, repo) // dropped: queue capacity is one
28+ assert.False(t, settled(1))
29+ assert.False(t, settled(2)) // pushes conservatively cover the whole repo
30+ u.ci.QueuePull(repo, 1)
31+ u.ci.QueuePull(repo, 1) // dropped: no counter leak
32+ u.ci.PlanQueuedPush()
33+ assert.False(t, settled(1))
34+ assert.True(t, settled(2))
35+ u.ci.FinishQueuedPull()
36+ assert.True(t, settled(1))
37+}
38+
39+func TestSettledWaitsForPushDiscoveryAndReleasesOnError(t *testing.T) {
40+ mrs := mocks.NewMergeRequestStore(t)
41+ entered, release := make(chan struct{}), make(chan struct{})
42+ u := newCIUnit(t, func(cfg *usecase.CIConfig) {
43+ cfg.MRs = mrs
44+ cfg.Pull = mocks.NewPullGit(t)
45+ cfg.PullStage = func(string) { close(entered); <-release }
46+ })
47+ repo := name(t, "proj")
48+ mrs.EXPECT().OpenByTarget(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, nil)
49+ mrs.EXPECT().OpenBySource(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, domain.ErrUnavailable)
50+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
51+ done := make(chan struct{})
52+ go func() {
53+ u.ci.OnPush(context.Background(), repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: "1111111111111111111111111111111111111111"}}, domain.Root())
54+ close(done)
55+ }()
56+ <-entered
57+ u.ci.PlanQueuedPush()
58+ assert.False(t, u.ci.Settled(context.Background(), repo, 1))
59+ close(release)
60+ <-done
61+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
62+}
63+
64+func TestSettledReleasesQueuedPushOnShutdown(t *testing.T) {
65+ u := newCIUnit(t, nil)
66+ repo := name(t, "proj")
67+ u.ci.QueuePush(context.Background(), repo)
68+ u.ci.Close(context.Background())
69+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
70+ u.ci.QueuePush(context.Background(), repo)
71+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
72+}
M internal/usecase/ci_github_test.go +21 -0
@@ -5,14 +5,17 @@ package usecase_test
55 import (
66 "bytes"
77 "context"
8+ "encoding/json"
89 "fmt"
910 "math"
1011 "strconv"
1112 "strings"
1213 "testing"
1314
15+ "bnema.dev/ermite/internal/ports/mocks"
1416 "github.com/bnema/zerowrap"
1517 "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
1619 "github.com/stretchr/testify/require"
1720
1821 "bnema.dev/ermite-actions/workflow"
@@ -109,3 +112,21 @@ func TestCheckRunID(t *testing.T) {
109112 assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)
110113 }
111114 }
115+
116+func TestGitHubRepoReportsAllowAutoMerge(t *testing.T) {
117+ for _, allow := range []bool{false, true} {
118+ t.Run(strconv.FormatBool(allow), func(t *testing.T) {
119+ repos := mocks.NewGitRepos(t)
120+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Repos = repos })
121+ repo := domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()}
122+ repo.Settings.AllowAutoMerge = allow
123+ u.meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
124+ repos.EXPECT().HeadBranch(mock.Anything, repo.Name).Return("main", nil)
125+ data, err := u.ci.GetRepo(context.Background(), domain.Root(), "owner", "proj")
126+ require.NoError(t, err)
127+ var obj map[string]any
128+ require.NoError(t, json.Unmarshal(data, &obj))
129+ assert.Equal(t, allow, obj["allow_auto_merge"])
130+ })
131+ }
132+}
@@ -5,14 +5,17 @@ package usecase_test
5 import (5 import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8+ "encoding/json"
8 "fmt"9 "fmt"
9 "math"10 "math"
10 "strconv"11 "strconv"
11 "strings"12 "strings"
12 "testing"13 "testing"
13 14
15+ "bnema.dev/ermite/internal/ports/mocks"
14 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
15 "github.com/stretchr/testify/assert"17 "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"19 "github.com/stretchr/testify/require"
17 20
18 "bnema.dev/ermite-actions/workflow"21 "bnema.dev/ermite-actions/workflow"
@@ -109,3 +112,21 @@ func TestCheckRunID(t *testing.T) {
109 assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)112 assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)
110 }113 }
111 }114 }
115+
116+func TestGitHubRepoReportsAllowAutoMerge(t *testing.T) {
117+ for _, allow := range []bool{false, true} {
118+ t.Run(strconv.FormatBool(allow), func(t *testing.T) {
119+ repos := mocks.NewGitRepos(t)
120+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Repos = repos })
121+ repo := domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()}
122+ repo.Settings.AllowAutoMerge = allow
123+ u.meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
124+ repos.EXPECT().HeadBranch(mock.Anything, repo.Name).Return("main", nil)
125+ data, err := u.ci.GetRepo(context.Background(), domain.Root(), "owner", "proj")
126+ require.NoError(t, err)
127+ var obj map[string]any
128+ require.NoError(t, json.Unmarshal(data, &obj))
129+ assert.Equal(t, allow, obj["allow_auto_merge"])
130+ })
131+ }
132+}
M internal/usecase/ci_githubapi.go +1 -1
@@ -74,7 +74,7 @@ func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string
7474 for k, v := range map[string]any{
7575 "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),
7676 "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),
77- "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false,
77+ "allow_auto_merge": repo.EffectiveSettings().AllowAutoMerge, "delete_branch_on_merge": false, "allow_update_branch": false,
7878 "use_squash_pr_title_as_default": false, "has_pull_requests": true,
7979 } {
8080 obj[k] = v
@@ -74,7 +74,7 @@ func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string
74 for k, v := range map[string]any{74 for k, v := range map[string]any{
75 "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),75 "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),
76 "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),76 "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),
77- "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false,77+ "allow_auto_merge": repo.EffectiveSettings().AllowAutoMerge, "delete_branch_on_merge": false, "allow_update_branch": false,
78 "use_squash_pr_title_as_default": false, "has_pull_requests": true,78 "use_squash_pr_title_as_default": false, "has_pull_requests": true,
79 } {79 } {
80 obj[k] = v80 obj[k] = v
M internal/usecase/ci_pull.go +6 -0
@@ -482,6 +482,9 @@ func (c *CI) wakePull() {
482482 func (c *CI) queuePullEvent(e pullEvent) {
483483 c.mu.Lock()
484484 closed := c.closed
485+ if !closed {
486+ c.pulls[pullKey{repo: e.repo, number: e.number}]++
487+ }
485488 c.mu.Unlock()
486489 if closed {
487490 return
@@ -489,6 +492,7 @@ func (c *CI) queuePullEvent(e pullEvent) {
489492 select {
490493 case c.pull.events <- e:
491494 default:
495+ c.finishPull(pullKey{repo: e.repo, number: e.number})
492496 e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run")
493497 }
494498 }
@@ -614,6 +618,8 @@ func (c *CI) pullLoop() {
614618 return
615619 case e := <-c.pull.events:
616620 c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) })
621+ c.finishPull(pullKey{repo: e.repo, number: e.number})
622+ c.kickAutoMerge(c.ctx, e.repo)
617623 }
618624 }
619625 }
@@ -482,6 +482,9 @@ func (c *CI) wakePull() {
482 func (c *CI) queuePullEvent(e pullEvent) {482 func (c *CI) queuePullEvent(e pullEvent) {
483 c.mu.Lock()483 c.mu.Lock()
484 closed := c.closed484 closed := c.closed
485+ if !closed {
486+ c.pulls[pullKey{repo: e.repo, number: e.number}]++
487+ }
485 c.mu.Unlock()488 c.mu.Unlock()
486 if closed {489 if closed {
487 return490 return
@@ -489,6 +492,7 @@ func (c *CI) queuePullEvent(e pullEvent) {
489 select {492 select {
490 case c.pull.events <- e:493 case c.pull.events <- e:
491 default:494 default:
495+ c.finishPull(pullKey{repo: e.repo, number: e.number})
492 e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run")496 e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run")
493 }497 }
494 }498 }
@@ -614,6 +618,8 @@ func (c *CI) pullLoop() {
614 return618 return
615 case e := <-c.pull.events:619 case e := <-c.pull.events:
616 c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) })620 c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) })
621+ c.finishPull(pullKey{repo: e.repo, number: e.number})
622+ c.kickAutoMerge(c.ctx, e.repo)
617 }623 }
618 }624 }
619 }625 }
M internal/usecase/ci_trigger.go +41 -6
@@ -12,6 +12,7 @@ import (
1212 "slices"
1313 "strconv"
1414 "strings"
15+ "sync/atomic"
1516 "time"
1617
1718 "github.com/bnema/zerowrap"
@@ -33,6 +34,7 @@ const (
3334
3435 // ciTrigger is a push waiting to be planned.
3536 type ciTrigger struct {
37+ done func()
3638 repo domain.RepoName
3739 updates []domain.RefUpdate
3840 actor string
@@ -54,6 +56,9 @@ type ciTrigger struct {
5456 // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
5557 // from running itself again.
5658 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
59+ done := c.pushLifecycle(ctx, repo, 2)
60+ defer done()
61+
5762 // Whoever pushed, the workflow files of the default branch may have changed.
5863 // (Who pushed was recorded before the refs moved: Pushes.Check.)
5964 c.markSchedulesDirty(repo, updates)
@@ -63,9 +68,10 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
6368 if pusher.ViaJobToken() {
6469 log := zerowrap.FromCtx(ctx)
6570 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
71+ done()
6672 return
6773 }
68- c.enqueue(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
74+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), done)
6975 }
7076
7177 // OnMerge queues the move of a target branch by a merge. The actor of the run is
@@ -75,6 +81,9 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
7581 // merging. For the same reason the run is trusted (gets secrets) only when the
7682 // merger is: the displayed actor is not the one that decides.
7783 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
84+ done := c.pushLifecycle(ctx, repo, 2)
85+ defer done()
86+
7887 c.markSchedulesDirty(repo, updates)
7988 // The merge moved a target branch: the test merges of the other requests
8089 // into it are computed again (a merge starts no synchronize run), after the
@@ -83,12 +92,32 @@ func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain
8392 if merger.ViaJobToken() {
8493 log := zerowrap.FromCtx(ctx)
8594 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
95+ done()
8696 return
8797 }
88- c.enqueue(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
98+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), done)
8999 }
90100
91101 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
102+ c.enqueuePush(ctx, repo, updates, actor, pusher, trusted, c.pushLifecycle(ctx, repo, 1))
103+}
104+
105+// pushLifecycle holds one repository activity count until all parts finish.
106+func (c *CI) pushLifecycle(ctx context.Context, repo domain.RepoName, parts int32) func() {
107+ c.mu.Lock()
108+ c.pushes[repo]++
109+ c.mu.Unlock()
110+ var remaining atomic.Int32
111+ remaining.Store(parts)
112+ return func() {
113+ if remaining.Add(-1) == 0 {
114+ c.finishPush(repo)
115+ c.kickAutoMerge(ctx, repo)
116+ }
117+ }
118+}
119+
120+func (c *CI) enqueuePush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool, done func()) {
92121 var kept []domain.RefUpdate
93122 for _, u := range updates {
94123 if strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/") {
@@ -96,20 +125,25 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
96125 }
97126 }
98127 if len(kept) == 0 {
128+ done()
99129 return
100130 }
101- t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
131+ t := ciTrigger{done: done, repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
102132 c.mu.Lock()
103- closed := c.closed
104- c.mu.Unlock()
105- if closed {
133+ if c.closed {
134+ c.mu.Unlock()
135+ done()
106136 return
107137 }
108138 select {
109139 case c.queue <- t:
140+ c.mu.Unlock()
110141 default:
142+ c.mu.Unlock()
143+ done()
111144 t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")
112145 }
146+
113147 }
114148
115149 func (c *CI) triggerLoop() {
@@ -127,6 +161,7 @@ func (c *CI) triggerLoop() {
127161 // planPush plans one push; a panic or an error is logged and never escapes.
128162 func (c *CI) planPush(t ciTrigger) {
129163 ctx := zerowrap.WithCtx(c.ctx, t.log)
164+ defer t.done()
130165 defer func() {
131166 if r := recover(); r != nil {
132167 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")
@@ -12,6 +12,7 @@ import (
12 "slices"12 "slices"
13 "strconv"13 "strconv"
14 "strings"14 "strings"
15+ "sync/atomic"
15 "time"16 "time"
16 17
17 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
@@ -33,6 +34,7 @@ const (
33 34
34 // ciTrigger is a push waiting to be planned.35 // ciTrigger is a push waiting to be planned.
35 type ciTrigger struct {36 type ciTrigger struct {
37+ done func()
36 repo domain.RepoName38 repo domain.RepoName
37 updates []domain.RefUpdate39 updates []domain.RefUpdate
38 actor string40 actor string
@@ -54,6 +56,9 @@ type ciTrigger struct {
54 // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes56 // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
55 // from running itself again.57 // from running itself again.
56 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {58 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
59+ done := c.pushLifecycle(ctx, repo, 2)
60+ defer done()
61+
57 // Whoever pushed, the workflow files of the default branch may have changed.62 // Whoever pushed, the workflow files of the default branch may have changed.
58 // (Who pushed was recorded before the refs moved: Pushes.Check.)63 // (Who pushed was recorded before the refs moved: Pushes.Check.)
59 c.markSchedulesDirty(repo, updates)64 c.markSchedulesDirty(repo, updates)
@@ -63,9 +68,10 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
63 if pusher.ViaJobToken() {68 if pusher.ViaJobToken() {
64 log := zerowrap.FromCtx(ctx)69 log := zerowrap.FromCtx(ctx)
65 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")70 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
71+ done()
66 return72 return
67 }73 }
68- c.enqueue(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))74+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), done)
69 }75 }
70 76
71 // OnMerge queues the move of a target branch by a merge. The actor of the run is77 // OnMerge queues the move of a target branch by a merge. The actor of the run is
@@ -75,6 +81,9 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
75 // merging. For the same reason the run is trusted (gets secrets) only when the81 // merging. For the same reason the run is trusted (gets secrets) only when the
76 // merger is: the displayed actor is not the one that decides.82 // merger is: the displayed actor is not the one that decides.
77 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {83 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
84+ done := c.pushLifecycle(ctx, repo, 2)
85+ defer done()
86+
78 c.markSchedulesDirty(repo, updates)87 c.markSchedulesDirty(repo, updates)
79 // The merge moved a target branch: the test merges of the other requests88 // The merge moved a target branch: the test merges of the other requests
80 // into it are computed again (a merge starts no synchronize run), after the89 // into it are computed again (a merge starts no synchronize run), after the
@@ -83,12 +92,32 @@ func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain
83 if merger.ViaJobToken() {92 if merger.ViaJobToken() {
84 log := zerowrap.FromCtx(ctx)93 log := zerowrap.FromCtx(ctx)
85 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")94 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
95+ done()
86 return96 return
87 }97 }
88- c.enqueue(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))98+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), done)
89 }99 }
90 100
91 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {101 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
102+ c.enqueuePush(ctx, repo, updates, actor, pusher, trusted, c.pushLifecycle(ctx, repo, 1))
103+}
104+
105+// pushLifecycle holds one repository activity count until all parts finish.
106+func (c *CI) pushLifecycle(ctx context.Context, repo domain.RepoName, parts int32) func() {
107+ c.mu.Lock()
108+ c.pushes[repo]++
109+ c.mu.Unlock()
110+ var remaining atomic.Int32
111+ remaining.Store(parts)
112+ return func() {
113+ if remaining.Add(-1) == 0 {
114+ c.finishPush(repo)
115+ c.kickAutoMerge(ctx, repo)
116+ }
117+ }
118+}
119+
120+func (c *CI) enqueuePush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool, done func()) {
92 var kept []domain.RefUpdate121 var kept []domain.RefUpdate
93 for _, u := range updates {122 for _, u := range updates {
94 if strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/") {123 if strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/") {
@@ -96,20 +125,25 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
96 }125 }
97 }126 }
98 if len(kept) == 0 {127 if len(kept) == 0 {
128+ done()
99 return129 return
100 }130 }
101- t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}131+ t := ciTrigger{done: done, repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
102 c.mu.Lock()132 c.mu.Lock()
103- closed := c.closed133+ if c.closed {
104- c.mu.Unlock()134+ c.mu.Unlock()
105- if closed {135+ done()
106 return136 return
107 }137 }
108 select {138 select {
109 case c.queue <- t:139 case c.queue <- t:
140+ c.mu.Unlock()
110 default:141 default:
142+ c.mu.Unlock()
143+ done()
111 t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")144 t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")
112 }145 }
146+
113 }147 }
114 148
115 func (c *CI) triggerLoop() {149 func (c *CI) triggerLoop() {
@@ -127,6 +161,7 @@ func (c *CI) triggerLoop() {
127 // planPush plans one push; a panic or an error is logged and never escapes.161 // planPush plans one push; a panic or an error is logged and never escapes.
128 func (c *CI) planPush(t ciTrigger) {162 func (c *CI) planPush(t ciTrigger) {
129 ctx := zerowrap.WithCtx(c.ctx, t.log)163 ctx := zerowrap.WithCtx(c.ctx, t.log)
164+ defer t.done()
130 defer func() {165 defer func() {
131 if r := recover(); r != nil {166 if r := recover(); r != nil {
132 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")167 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")
M internal/usecase/events.go +9 -0
@@ -182,6 +182,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
182182 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183183 case domain.MergeRequestClosed:
184184 return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.MergeRequestAutoMergeEnabled:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.MergeRequestAutoMergeCancelled:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185189 case domain.MergeRequestReady:
186190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187191 case domain.CommentAdded:
@@ -535,6 +539,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
535539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
536540 case domain.MergeRequestClosed:
537541 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
542+ case domain.MergeRequestAutoMergeEnabled:
543+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
544+ case domain.MergeRequestAutoMergeCancelled:
545+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538546 case domain.MergeRequestReady:
539547 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
540548 case domain.CommentAdded:
@@ -663,6 +671,7 @@ func isItemEvent(ev domain.Event) bool {
663671 case domain.IssueReferenced:
664672 return false // back-reference refreshes do not change the inbox
665673 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
674+ domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
666675 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
667676 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
668677 return true
@@ -182,6 +182,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
182 return repoSnapshot(ev.Visibility, ev.CreatedAt)182 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183 case domain.MergeRequestClosed:183 case domain.MergeRequestClosed:
184 return repoSnapshot(ev.Visibility, ev.CreatedAt)184 return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.MergeRequestAutoMergeEnabled:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.MergeRequestAutoMergeCancelled:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185 case domain.MergeRequestReady:189 case domain.MergeRequestReady:
186 return repoSnapshot(ev.Visibility, ev.CreatedAt)190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187 case domain.CommentAdded:191 case domain.CommentAdded:
@@ -535,6 +539,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
535 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
536 case domain.MergeRequestClosed:540 case domain.MergeRequestClosed:
537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)541 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
542+ case domain.MergeRequestAutoMergeEnabled:
543+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
544+ case domain.MergeRequestAutoMergeCancelled:
545+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538 case domain.MergeRequestReady:546 case domain.MergeRequestReady:
539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)547 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
540 case domain.CommentAdded:548 case domain.CommentAdded:
@@ -663,6 +671,7 @@ func isItemEvent(ev domain.Event) bool {
663 case domain.IssueReferenced:671 case domain.IssueReferenced:
664 return false // back-reference refreshes do not change the inbox672 return false // back-reference refreshes do not change the inbox
665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,673 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
674+ domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,675 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
667 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:676 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
668 return true677 return true
M internal/usecase/export_test.go +48 -0
@@ -220,3 +220,51 @@ func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []doma
220220
221221 // WindowLimits exposes windowLimits to the tests.
222222 var WindowLimits = windowLimits
223+
224+// SetChecks installs a checks reader before starting a test evaluator.
225+func (m *MergeRequests) SetChecks(checks ports.CIChecksReader) { m.checks = checks }
226+
227+// Evaluate exposes a single synchronous pass without starting workers.
228+func (s *AutoMerge) Evaluate(repo domain.RepoName) bool { return s.evaluate(repo) }
229+
230+// QueuePush exposes the normal trigger queue for activity accounting tests.
231+func (c *CI) QueuePush(ctx context.Context, repo domain.RepoName) {
232+ c.enqueue(ctx, repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}, "owner", domain.PusherOf(domain.Root()), true)
233+}
234+
235+// PlanQueuedPush handles the next queued trigger without starting workers.
236+func (c *CI) PlanQueuedPush() { c.planPush(<-c.queue) }
237+
238+// QueuePull exposes the pull activity queue for accounting tests.
239+func (c *CI) QueuePull(repo domain.RepoName, number int64) {
240+ c.queuePullEvent(pullEvent{repo: repo, number: number})
241+}
242+
243+// FinishQueuedPull completes one test activity without planning workflows.
244+func (c *CI) FinishQueuedPull() {
245+ e := <-c.pull.events
246+ c.finishPull(pullKey{repo: e.repo, number: e.number})
247+ c.kickAutoMerge(c.ctx, e.repo)
248+}
249+
250+// SetWorkflowTrigger installs the merge hook before evaluation.
251+func (m *MergeRequests) SetWorkflowTrigger(t ports.WorkflowTrigger) { m.ci = t }
252+
253+// UnderRunLock exposes the production run lock to test nonblocking triggers.
254+func (c *CI) UnderRunLock(id int64, fn func()) {
255+ lock := c.runLock(id)
256+ lock.Lock()
257+ defer lock.Unlock()
258+ fn()
259+}
260+
261+// UnderRepoLock holds the production merge lock while a test interleaves a kick.
262+func (m *MergeRequests) UnderRepoLock(ctx context.Context, repo domain.RepoName, fn func()) error {
263+ unlock, err := m.lockRepo(ctx, repo)
264+ if err != nil {
265+ return err
266+ }
267+ defer unlock()
268+ fn()
269+ return nil
270+}
@@ -220,3 +220,51 @@ func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []doma
220 220
221 // WindowLimits exposes windowLimits to the tests.221 // WindowLimits exposes windowLimits to the tests.
222 var WindowLimits = windowLimits222 var WindowLimits = windowLimits
223+
224+// SetChecks installs a checks reader before starting a test evaluator.
225+func (m *MergeRequests) SetChecks(checks ports.CIChecksReader) { m.checks = checks }
226+
227+// Evaluate exposes a single synchronous pass without starting workers.
228+func (s *AutoMerge) Evaluate(repo domain.RepoName) bool { return s.evaluate(repo) }
229+
230+// QueuePush exposes the normal trigger queue for activity accounting tests.
231+func (c *CI) QueuePush(ctx context.Context, repo domain.RepoName) {
232+ c.enqueue(ctx, repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}, "owner", domain.PusherOf(domain.Root()), true)
233+}
234+
235+// PlanQueuedPush handles the next queued trigger without starting workers.
236+func (c *CI) PlanQueuedPush() { c.planPush(<-c.queue) }
237+
238+// QueuePull exposes the pull activity queue for accounting tests.
239+func (c *CI) QueuePull(repo domain.RepoName, number int64) {
240+ c.queuePullEvent(pullEvent{repo: repo, number: number})
241+}
242+
243+// FinishQueuedPull completes one test activity without planning workflows.
244+func (c *CI) FinishQueuedPull() {
245+ e := <-c.pull.events
246+ c.finishPull(pullKey{repo: e.repo, number: e.number})
247+ c.kickAutoMerge(c.ctx, e.repo)
248+}
249+
250+// SetWorkflowTrigger installs the merge hook before evaluation.
251+func (m *MergeRequests) SetWorkflowTrigger(t ports.WorkflowTrigger) { m.ci = t }
252+
253+// UnderRunLock exposes the production run lock to test nonblocking triggers.
254+func (c *CI) UnderRunLock(id int64, fn func()) {
255+ lock := c.runLock(id)
256+ lock.Lock()
257+ defer lock.Unlock()
258+ fn()
259+}
260+
261+// UnderRepoLock holds the production merge lock while a test interleaves a kick.
262+func (m *MergeRequests) UnderRepoLock(ctx context.Context, repo domain.RepoName, fn func()) error {
263+ unlock, err := m.lockRepo(ctx, repo)
264+ if err != nil {
265+ return err
266+ }
267+ defer unlock()
268+ fn()
269+ return nil
270+}
M internal/usecase/issue_links_test.go +27 -0
@@ -11,8 +11,10 @@ import (
1111
1212 "bnema.dev/ermite/internal/domain"
1313 "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
1415 "bnema.dev/ermite/internal/usecase"
1516 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
1618 "github.com/stretchr/testify/require"
1719 )
1820
@@ -233,3 +235,28 @@ func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
233235 require.Len(t, after, 1)
234236 assert.Equal(t, issue.Number, after[0].Issue)
235237 }
238+
239+func TestAutoMergeClosesLinkedIssue(t *testing.T) {
240+ r, _, issues := linkedRig(t)
241+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
242+ require.NoError(t, err)
243+ r.feature("feat")
244+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "fix", Source: "feat", Description: fmt.Sprintf("closes #%d", issue.Number)})
245+ require.NoError(t, err)
246+ _, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
247+ require.NoError(t, err)
248+ checks := mocks.NewCIChecksReader(t)
249+ r.mrs.SetChecks(checks)
250+ enableAuto(t, r, mr)
251+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
252+ a := autoEvaluator(r, checks, nil, nil)
253+ defer a.Close(r.ctx)
254+ require.True(t, a.Evaluate(r.repo))
255+ r.mrs.Wait(r.ctx)
256+ merged := storedAuto(t, r, mr)
257+ require.Equal(t, domain.MRStateMerged, merged.State)
258+ require.True(t, merged.AutoMerged)
259+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
260+ require.NoError(t, err)
261+ assert.False(t, got.Issue.IsOpen())
262+}
@@ -11,8 +11,10 @@ import (
11 11
12 "bnema.dev/ermite/internal/domain"12 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"13 "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
14 "bnema.dev/ermite/internal/usecase"15 "bnema.dev/ermite/internal/usecase"
15 "github.com/stretchr/testify/assert"16 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
17 )19 )
18 20
@@ -233,3 +235,28 @@ func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
233 require.Len(t, after, 1)235 require.Len(t, after, 1)
234 assert.Equal(t, issue.Number, after[0].Issue)236 assert.Equal(t, issue.Number, after[0].Issue)
235 }237 }
238+
239+func TestAutoMergeClosesLinkedIssue(t *testing.T) {
240+ r, _, issues := linkedRig(t)
241+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
242+ require.NoError(t, err)
243+ r.feature("feat")
244+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "fix", Source: "feat", Description: fmt.Sprintf("closes #%d", issue.Number)})
245+ require.NoError(t, err)
246+ _, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
247+ require.NoError(t, err)
248+ checks := mocks.NewCIChecksReader(t)
249+ r.mrs.SetChecks(checks)
250+ enableAuto(t, r, mr)
251+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
252+ a := autoEvaluator(r, checks, nil, nil)
253+ defer a.Close(r.ctx)
254+ require.True(t, a.Evaluate(r.repo))
255+ r.mrs.Wait(r.ctx)
256+ merged := storedAuto(t, r, mr)
257+ require.Equal(t, domain.MRStateMerged, merged.State)
258+ require.True(t, merged.AutoMerged)
259+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
260+ require.NoError(t, err)
261+ assert.False(t, got.Issue.IsOpen())
262+}
M internal/usecase/mergeability.go +18 -6
@@ -35,12 +35,13 @@ func mergeBackoff(attempt int) time.Duration {
3535
3636 // MergeabilityConfig holds the dependencies of the repository reconciler.
3737 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
38+ AutoMerge ports.AutoMergeTrigger
39+ Store ports.MergeRequestStore
40+ Meta ports.RepoMetaStore
41+ Git ports.MergeGit
42+ Bus ports.EventBus
43+ Clock ports.Clock
44+ Workers int // defaults to 2
4445 // After is injectable to drive backoff without wall-clock waits in tests.
4546 After func(time.Duration) <-chan time.Time
4647 }
@@ -235,6 +236,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
235236 return false
236237 }
237238 ok := true
239+ changed := false
238240 for _, mr := range mrs {
239241 if ctx.Err() != nil {
240242 return true
@@ -275,6 +277,10 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
275277 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276278 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277279 })
280+ changed = true
281+ }
282+ if changed && s.cfg.AutoMerge != nil {
283+ s.cfg.AutoMerge.OnRepoChanged(ctx, repo)
278284 }
279285 return ok
280286 }
@@ -465,3 +471,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number
465471 m.Behind, m.BehindCapped = domain.BoundBehind(n)
466472 return m, nil
467473 }
474+
475+// WithAutoMerge installs the evaluator before Start.
476+func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability {
477+ s.cfg.AutoMerge = t
478+ return s
479+}
@@ -35,12 +35,13 @@ func mergeBackoff(attempt int) time.Duration {
35 35
36 // MergeabilityConfig holds the dependencies of the repository reconciler.36 // MergeabilityConfig holds the dependencies of the repository reconciler.
37 type MergeabilityConfig struct {37 type MergeabilityConfig struct {
38- Store ports.MergeRequestStore38+ AutoMerge ports.AutoMergeTrigger
39- Meta ports.RepoMetaStore39+ Store ports.MergeRequestStore
40- Git ports.MergeGit40+ Meta ports.RepoMetaStore
41- Bus ports.EventBus41+ Git ports.MergeGit
42- Clock ports.Clock42+ Bus ports.EventBus
43- Workers int // defaults to 243+ Clock ports.Clock
44+ Workers int // defaults to 2
44 // After is injectable to drive backoff without wall-clock waits in tests.45 // After is injectable to drive backoff without wall-clock waits in tests.
45 After func(time.Duration) <-chan time.Time46 After func(time.Duration) <-chan time.Time
46 }47 }
@@ -235,6 +236,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
235 return false236 return false
236 }237 }
237 ok := true238 ok := true
239+ changed := false
238 for _, mr := range mrs {240 for _, mr := range mrs {
239 if ctx.Err() != nil {241 if ctx.Err() != nil {
240 return true242 return true
@@ -275,6 +277,10 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
275 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,277 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,278 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277 })279 })
280+ changed = true
281+ }
282+ if changed && s.cfg.AutoMerge != nil {
283+ s.cfg.AutoMerge.OnRepoChanged(ctx, repo)
278 }284 }
279 return ok285 return ok
280 }286 }
@@ -465,3 +471,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number
465 m.Behind, m.BehindCapped = domain.BoundBehind(n)471 m.Behind, m.BehindCapped = domain.BoundBehind(n)
466 return m, nil472 return m, nil
467 }473 }
474+
475+// WithAutoMerge installs the evaluator before Start.
476+func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability {
477+ s.cfg.AutoMerge = t
478+ return s
479+}
M internal/usecase/mergerequests.go +93 -18
@@ -59,7 +59,8 @@ type MergeRequests struct {
5959 tips ports.CIRefTipStore // nil: who merges is not recorded
6060 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
6161 mergeab MergeabilityHook // nil: no mergeability
62- mirror ports.MirrorTrigger // nil: no outbound mirror
62+ autoMerge ports.AutoMergeTrigger
63+ mirror ports.MirrorTrigger // nil: no outbound mirror
6364
6465 mu sync.Mutex
6566 locks map[domain.RepoName]chan struct{}
@@ -150,6 +151,9 @@ func (m *MergeRequests) Wait(ctx context.Context) {
150151 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
151152 // merges of one repository, and the creation of its merge requests, run one at
152153 // a time. The returned function releases the lock.
154+// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
155+// store transaction or a CI lock may take this lock. MergeRequests never
156+// calls Repos.Update.
153157 func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
154158 m.mu.Lock()
155159 ch, ok := m.locks[name]
@@ -917,6 +921,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
917921 if err != nil {
918922 return domain.MergeRequest{}, err
919923 }
924+ hadAutoMerge := mr.AutoMerge != nil
925+ mr.AutoMerge = nil
920926 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
921927 err = m.store.Update(ctx, mr)
922928 if errors.Is(err, domain.ErrConflict) {
@@ -930,6 +936,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
930936 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
931937 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
932938 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
939+ if hadAutoMerge {
940+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
941+ }
933942 if m.links != nil {
934943 m.links.OnMergeRequestSettled(ctx, repo, mr)
935944 }
@@ -1010,7 +1019,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
10101019 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
10111020 // only says that the deletion was requested.
10121021 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
1013- mr, err := m.merge(ctx, p, name, number, params)
1022+ mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
10141023 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
10151024 }
10161025
@@ -1052,9 +1061,23 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
10521061 return res.Tree, nil
10531062 }
10541063
1055-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
1056- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1057- return domain.MergeRequest{}, err
1064+type mergeOpts struct {
1065+ auto *autoMergeOpts
1066+}
1067+
1068+type autoMergeOpts struct {
1069+ intent *domain.AutoMerge
1070+ tokens ports.TokenStore
1071+ activity ports.CIActivity
1072+}
1073+
1074+var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1075+
1076+func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1077+ if opts.auto == nil {
1078+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1079+ return domain.MergeRequest{}, err
1080+ }
10581081 }
10591082 mode := params.Mode
10601083 if mode == "" {
@@ -1083,6 +1106,15 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10831106 if err != nil {
10841107 return domain.MergeRequest{}, err
10851108 }
1109+ if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) {
1110+ return domain.MergeRequest{}, errAutoMergeWait
1111+ }
1112+ if opts.auto != nil {
1113+ p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now())
1114+ if err != nil {
1115+ return domain.MergeRequest{}, err
1116+ }
1117+ }
10861118 // A merge moves the target and may delete the source: a token limited to
10871119 // branch prefixes may do so only within them.
10881120 if !p.AllowsRef("refs/heads/" + mr.Target) {
@@ -1134,14 +1166,33 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11341166 } else if behind {
11351167 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
11361168 }
1169+ if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) {
1170+ return domain.MergeRequest{}, errAutoMergeWait
1171+ }
11371172 tree, err := m.checkMode(ctx, mr, mode, tip, head)
11381173 if err != nil {
1174+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1175+ return domain.MergeRequest{}, errAutoMergeWait
1176+ }
11391177 return domain.MergeRequest{}, err
11401178 }
1141- if settings.FailingChecksBlock {
1142- if err := m.checksAllow(ctx, name, head); err != nil {
1179+ if opts.auto != nil && !settings.AllowAutoMerge {
1180+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1181+ }
1182+ if settings.FailingChecksBlock || opts.auto != nil {
1183+ checks, err := m.readMergeChecks(ctx, name, head)
1184+ if err == nil {
1185+ err = checksPolicy(checks)
1186+ }
1187+ if err != nil {
1188+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1189+ return domain.MergeRequest{}, errAutoMergeWait
1190+ }
11431191 return domain.MergeRequest{}, err
11441192 }
1193+ if opts.auto != nil && !hasSuccessfulCheck(checks) {
1194+ return domain.MergeRequest{}, errAutoMergeWait
1195+ }
11451196 }
11461197 if params.DeleteSource {
11471198 if err := m.checkDeletable(ctx, name, mr); err != nil {
@@ -1154,7 +1205,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11541205 return domain.MergeRequest{}, err
11551206 }
11561207 m.recordPush(ctx, name) // the target moved, whatever happens next
1157- mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
1208+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil)
11581209 if err != nil {
11591210 return domain.MergeRequest{}, err
11601211 }
@@ -1199,7 +1250,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
11991250 }
12001251 at := mr.UpdatedAt
12011252 recordActivity(ctx, m.meta, repo.Name, at)
1202- m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1253+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
12031254 m.bus.Publish(ctx, domain.PushEvent{
12041255 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
12051256 })
@@ -1272,21 +1323,34 @@ func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, num
12721323 return fn()
12731324 }
12741325
1275-// checksAllow is the merge rule of FailingChecksBlock: every check of the
1276-// head must have ended as success, skipped or neutral. A check still queued or
1277-// running blocks too (the merge could land code that then fails), and a head
1278-// without any check merges.
1279-func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error {
1326+// readMergeChecks reads one snapshot, classifying an incomplete result as a
1327+// failed precondition rather than treating it as a passing set of checks.
1328+func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) {
12801329 if m.checks == nil {
1281- return nil
1330+ return nil, nil
12821331 }
12831332 checks, err := m.checks.CommitChecks(ctx, name, head.String())
12841333 if errors.Is(err, domain.ErrResourceExhausted) {
1285- return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
1334+ return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
12861335 }
12871336 if err != nil {
1288- return fmt.Errorf("read the checks of %s: %w", head, err)
1337+ return nil, fmt.Errorf("read the checks of %s: %w", head, err)
12891338 }
1339+ return checks, nil
1340+}
1341+
1342+func hasSuccessfulCheck(checks []domain.CICheck) bool {
1343+ for _, check := range checks {
1344+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1345+ return true
1346+ }
1347+ }
1348+ return false
1349+}
1350+
1351+// checksPolicy requires every check to have ended as success, skipped or
1352+// neutral. Queued and running checks block too; an empty set is allowed.
1353+func checksPolicy(checks []domain.CICheck) error {
12901354 var failing, pending []string
12911355 for _, c := range checks {
12921356 switch {
@@ -1392,7 +1456,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
13921456
13931457 // markMerged records the merge. The target already moved, so a write that
13941458 // loses a race (a comment added meanwhile) is repeated on the fresh request.
1395-func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {
1459+func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) {
13961460 // The merge must be recorded even when the caller gives up.
13971461 ctx = context.WithoutCancel(ctx)
13981462 for range maxWriteRetries {
@@ -1400,6 +1464,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
14001464 if err != nil {
14011465 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
14021466 }
1467+ mr.AutoMerge, mr.AutoMerged = nil, auto
14031468 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
14041469 mr.UpdatedAt = m.clock.Now().UTC()
14051470 err = m.store.Update(ctx, mr)
@@ -1550,3 +1615,13 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep
15501615 }
15511616 return ports.PushSummary{Count: len(commits), More: more}, nil
15521617 }
1618+
1619+// WithMergeLock serializes fn with repository merges and closes.
1620+func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
1621+ unlock, err := m.lockRepo(ctx, repo)
1622+ if err != nil {
1623+ return err
1624+ }
1625+ defer unlock()
1626+ return fn()
1627+}
@@ -59,7 +59,8 @@ type MergeRequests struct {
59 tips ports.CIRefTipStore // nil: who merges is not recorded59 tips ports.CIRefTipStore // nil: who merges is not recorded
60 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)60 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
61 mergeab MergeabilityHook // nil: no mergeability61 mergeab MergeabilityHook // nil: no mergeability
62- mirror ports.MirrorTrigger // nil: no outbound mirror62+ autoMerge ports.AutoMergeTrigger
63+ mirror ports.MirrorTrigger // nil: no outbound mirror
63 64
64 mu sync.Mutex65 mu sync.Mutex
65 locks map[domain.RepoName]chan struct{}66 locks map[domain.RepoName]chan struct{}
@@ -150,6 +151,9 @@ func (m *MergeRequests) Wait(ctx context.Context) {
150 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The151 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
151 // merges of one repository, and the creation of its merge requests, run one at152 // merges of one repository, and the creation of its merge requests, run one at
152 // a time. The returned function releases the lock.153 // a time. The returned function releases the lock.
154+// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
155+// store transaction or a CI lock may take this lock. MergeRequests never
156+// calls Repos.Update.
153 func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {157 func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
154 m.mu.Lock()158 m.mu.Lock()
155 ch, ok := m.locks[name]159 ch, ok := m.locks[name]
@@ -917,6 +921,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
917 if err != nil {921 if err != nil {
918 return domain.MergeRequest{}, err922 return domain.MergeRequest{}, err
919 }923 }
924+ hadAutoMerge := mr.AutoMerge != nil
925+ mr.AutoMerge = nil
920 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()926 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
921 err = m.store.Update(ctx, mr)927 err = m.store.Update(ctx, mr)
922 if errors.Is(err, domain.ErrConflict) {928 if errors.Is(err, domain.ErrConflict) {
@@ -930,6 +936,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
930 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")936 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
931 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)937 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
932 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})938 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
939+ if hadAutoMerge {
940+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
941+ }
933 if m.links != nil {942 if m.links != nil {
934 m.links.OnMergeRequestSettled(ctx, repo, mr)943 m.links.OnMergeRequestSettled(ctx, repo, mr)
935 }944 }
@@ -1010,7 +1019,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
1010 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore1019 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
1011 // only says that the deletion was requested.1020 // only says that the deletion was requested.
1012 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {1021 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
1013- mr, err := m.merge(ctx, p, name, number, params)1022+ mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
1014 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err1023 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
1015 }1024 }
1016 1025
@@ -1052,9 +1061,23 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
1052 return res.Tree, nil1061 return res.Tree, nil
1053 }1062 }
1054 1063
1055-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {1064+type mergeOpts struct {
1056- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {1065+ auto *autoMergeOpts
1057- return domain.MergeRequest{}, err1066+}
1067+
1068+type autoMergeOpts struct {
1069+ intent *domain.AutoMerge
1070+ tokens ports.TokenStore
1071+ activity ports.CIActivity
1072+}
1073+
1074+var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1075+
1076+func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1077+ if opts.auto == nil {
1078+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1079+ return domain.MergeRequest{}, err
1080+ }
1058 }1081 }
1059 mode := params.Mode1082 mode := params.Mode
1060 if mode == "" {1083 if mode == "" {
@@ -1083,6 +1106,15 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1083 if err != nil {1106 if err != nil {
1084 return domain.MergeRequest{}, err1107 return domain.MergeRequest{}, err
1085 }1108 }
1109+ if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) {
1110+ return domain.MergeRequest{}, errAutoMergeWait
1111+ }
1112+ if opts.auto != nil {
1113+ p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now())
1114+ if err != nil {
1115+ return domain.MergeRequest{}, err
1116+ }
1117+ }
1086 // A merge moves the target and may delete the source: a token limited to1118 // A merge moves the target and may delete the source: a token limited to
1087 // branch prefixes may do so only within them.1119 // branch prefixes may do so only within them.
1088 if !p.AllowsRef("refs/heads/" + mr.Target) {1120 if !p.AllowsRef("refs/heads/" + mr.Target) {
@@ -1134,14 +1166,33 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1134 } else if behind {1166 } else if behind {
1135 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)1167 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1136 }1168 }
1169+ if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) {
1170+ return domain.MergeRequest{}, errAutoMergeWait
1171+ }
1137 tree, err := m.checkMode(ctx, mr, mode, tip, head)1172 tree, err := m.checkMode(ctx, mr, mode, tip, head)
1138 if err != nil {1173 if err != nil {
1174+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1175+ return domain.MergeRequest{}, errAutoMergeWait
1176+ }
1139 return domain.MergeRequest{}, err1177 return domain.MergeRequest{}, err
1140 }1178 }
1141- if settings.FailingChecksBlock {1179+ if opts.auto != nil && !settings.AllowAutoMerge {
1142- if err := m.checksAllow(ctx, name, head); err != nil {1180+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1181+ }
1182+ if settings.FailingChecksBlock || opts.auto != nil {
1183+ checks, err := m.readMergeChecks(ctx, name, head)
1184+ if err == nil {
1185+ err = checksPolicy(checks)
1186+ }
1187+ if err != nil {
1188+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1189+ return domain.MergeRequest{}, errAutoMergeWait
1190+ }
1143 return domain.MergeRequest{}, err1191 return domain.MergeRequest{}, err
1144 }1192 }
1193+ if opts.auto != nil && !hasSuccessfulCheck(checks) {
1194+ return domain.MergeRequest{}, errAutoMergeWait
1195+ }
1145 }1196 }
1146 if params.DeleteSource {1197 if params.DeleteSource {
1147 if err := m.checkDeletable(ctx, name, mr); err != nil {1198 if err := m.checkDeletable(ctx, name, mr); err != nil {
@@ -1154,7 +1205,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1154 return domain.MergeRequest{}, err1205 return domain.MergeRequest{}, err
1155 }1206 }
1156 m.recordPush(ctx, name) // the target moved, whatever happens next1207 m.recordPush(ctx, name) // the target moved, whatever happens next
1157- mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)1208+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil)
1158 if err != nil {1209 if err != nil {
1159 return domain.MergeRequest{}, err1210 return domain.MergeRequest{}, err
1160 }1211 }
@@ -1199,7 +1250,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
1199 }1250 }
1200 at := mr.UpdatedAt1251 at := mr.UpdatedAt
1201 recordActivity(ctx, m.meta, repo.Name, at)1252 recordActivity(ctx, m.meta, repo.Name, at)
1202- m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1253+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1203 m.bus.Publish(ctx, domain.PushEvent{1254 m.bus.Publish(ctx, domain.PushEvent{
1204 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1255 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1205 })1256 })
@@ -1272,21 +1323,34 @@ func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, num
1272 return fn()1323 return fn()
1273 }1324 }
1274 1325
1275-// checksAllow is the merge rule of FailingChecksBlock: every check of the1326+// readMergeChecks reads one snapshot, classifying an incomplete result as a
1276-// head must have ended as success, skipped or neutral. A check still queued or1327+// failed precondition rather than treating it as a passing set of checks.
1277-// running blocks too (the merge could land code that then fails), and a head1328+func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) {
1278-// without any check merges.
1279-func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error {
1280 if m.checks == nil {1329 if m.checks == nil {
1281- return nil1330+ return nil, nil
1282 }1331 }
1283 checks, err := m.checks.CommitChecks(ctx, name, head.String())1332 checks, err := m.checks.CommitChecks(ctx, name, head.String())
1284 if errors.Is(err, domain.ErrResourceExhausted) {1333 if errors.Is(err, domain.ErrResourceExhausted) {
1285- return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)1334+ return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
1286 }1335 }
1287 if err != nil {1336 if err != nil {
1288- return fmt.Errorf("read the checks of %s: %w", head, err)1337+ return nil, fmt.Errorf("read the checks of %s: %w", head, err)
1289 }1338 }
1339+ return checks, nil
1340+}
1341+
1342+func hasSuccessfulCheck(checks []domain.CICheck) bool {
1343+ for _, check := range checks {
1344+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1345+ return true
1346+ }
1347+ }
1348+ return false
1349+}
1350+
1351+// checksPolicy requires every check to have ended as success, skipped or
1352+// neutral. Queued and running checks block too; an empty set is allowed.
1353+func checksPolicy(checks []domain.CICheck) error {
1290 var failing, pending []string1354 var failing, pending []string
1291 for _, c := range checks {1355 for _, c := range checks {
1292 switch {1356 switch {
@@ -1392,7 +1456,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
1392 1456
1393 // markMerged records the merge. The target already moved, so a write that1457 // markMerged records the merge. The target already moved, so a write that
1394 // loses a race (a comment added meanwhile) is repeated on the fresh request.1458 // loses a race (a comment added meanwhile) is repeated on the fresh request.
1395-func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {1459+func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) {
1396 // The merge must be recorded even when the caller gives up.1460 // The merge must be recorded even when the caller gives up.
1397 ctx = context.WithoutCancel(ctx)1461 ctx = context.WithoutCancel(ctx)
1398 for range maxWriteRetries {1462 for range maxWriteRetries {
@@ -1400,6 +1464,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
1400 if err != nil {1464 if err != nil {
1401 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)1465 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1402 }1466 }
1467+ mr.AutoMerge, mr.AutoMerged = nil, auto
1403 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode1468 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
1404 mr.UpdatedAt = m.clock.Now().UTC()1469 mr.UpdatedAt = m.clock.Now().UTC()
1405 err = m.store.Update(ctx, mr)1470 err = m.store.Update(ctx, mr)
@@ -1550,3 +1615,13 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep
1550 }1615 }
1551 return ports.PushSummary{Count: len(commits), More: more}, nil1616 return ports.PushSummary{Count: len(commits), More: more}, nil
1552 }1617 }
1618+
1619+// WithMergeLock serializes fn with repository merges and closes.
1620+func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
1621+ unlock, err := m.lockRepo(ctx, repo)
1622+ if err != nil {
1623+ return err
1624+ }
1625+ defer unlock()
1626+ return fn()
1627+}
A internal/usecase/mergerequests_auto.go +168 -0
@@ -0,0 +1,168 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// WithAutoMerge installs the asynchronous evaluator before serving requests.
15+func (m *MergeRequests) WithAutoMerge(t ports.AutoMergeTrigger) *MergeRequests {
16+ m.autoMerge = t
17+ return m
18+}
19+
20+// EnableAutoMerge records the actor and the head the caller saw, which must be
21+// the current head of the source branch; re-enabling replaces the
22+// previous intent rather than preserving an older actor's authority.
23+func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
24+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
25+ return domain.MergeRequest{}, err
26+ }
27+ if m.checks == nil {
28+ return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed)
29+ }
30+ mode := params.Mode
31+ if mode == "" {
32+ mode = domain.MergeModeFastForward
33+ }
34+ var v domain.Violations
35+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
36+ v.Add("mode", "is not a merge mode")
37+ }
38+ if params.HeadID.IsZero() {
39+ v.Add("head_id", "is required")
40+ }
41+ if err := v.Err(); err != nil {
42+ return domain.MergeRequest{}, err
43+ }
44+ unlock, err := m.lockRepo(ctx, name)
45+ if err != nil {
46+ return domain.MergeRequest{}, err
47+ }
48+ defer unlock()
49+ for range maxWriteRetries {
50+ mr, err := m.store.Get(ctx, name, number)
51+ if err != nil {
52+ return domain.MergeRequest{}, err
53+ }
54+ if !mr.IsOpen() {
55+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
56+ }
57+ if mr.IsDraft() {
58+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
59+ }
60+ if !p.AllowsRef("refs/heads/" + mr.Target) {
61+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
62+ }
63+ repo, err := m.meta.Get(ctx, name)
64+ if err != nil {
65+ return domain.MergeRequest{}, err
66+ }
67+ settings := repo.EffectiveSettings()
68+ if !settings.AllowAutoMerge {
69+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s: turn it on with `ermite repo edit --allow-auto-merge`", domain.ErrPreconditionFailed, name)
70+ }
71+ if !settings.Allows(mode) {
72+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
73+ }
74+ head, err := m.branchOrZero(ctx, name, mr.Source)
75+ if err != nil {
76+ return domain.MergeRequest{}, err
77+ }
78+ if head.IsZero() {
79+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
80+ }
81+ if head != params.HeadID {
82+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
83+ }
84+ mr.UpdatedAt = m.clock.Now().UTC()
85+ mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt}
86+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
87+ continue
88+ } else if err != nil {
89+ return domain.MergeRequest{}, err
90+ }
91+ recordActivity(ctx, m.meta, name, mr.UpdatedAt)
92+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
93+ if m.autoMerge != nil {
94+ m.autoMerge.OnAutoMergeEnabled(ctx, name)
95+ }
96+ return m.withHead(ctx, mr)
97+ }
98+ return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict)
99+}
100+
101+// CancelAutoMerge clears an open request's intent with the same target access
102+// required to enable it.
103+func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
104+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
105+ return domain.MergeRequest{}, err
106+ }
107+ unlock, err := m.lockRepo(ctx, name)
108+ if err != nil {
109+ return domain.MergeRequest{}, err
110+ }
111+ defer unlock()
112+ mr, err := m.store.Get(ctx, name, number)
113+ if err != nil {
114+ return domain.MergeRequest{}, err
115+ }
116+ if !mr.IsOpen() {
117+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
118+ }
119+ if !p.AllowsRef("refs/heads/" + mr.Target) {
120+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
121+ }
122+ if mr.AutoMerge == nil {
123+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed)
124+ }
125+ mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p))
126+ if err != nil {
127+ return domain.MergeRequest{}, err
128+ }
129+ return m.withHead(ctx, mr)
130+}
131+
132+// cancelAuto takes the merge lock and only cancels the intent evaluated by the
133+// caller. A concurrent re-enable, close or merge must not be undone.
134+func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error {
135+ unlock, err := m.lockRepo(ctx, mr.Repo)
136+ if err != nil {
137+ return err
138+ }
139+ defer unlock()
140+ _, err = m.cancelAutoLocked(ctx, mr, reason)
141+ return err
142+}
143+
144+func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) {
145+ for range maxWriteRetries {
146+ mr, err := m.store.Get(ctx, expected.Repo, expected.Number)
147+ if err != nil {
148+ return domain.MergeRequest{}, err
149+ }
150+ if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge {
151+ return mr, nil
152+ }
153+ repo, err := m.meta.Get(ctx, mr.Repo)
154+ if err != nil {
155+ return domain.MergeRequest{}, err
156+ }
157+ mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC()
158+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
159+ continue
160+ } else if err != nil {
161+ return domain.MergeRequest{}, err
162+ }
163+ recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt)
164+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
165+ return mr, nil
166+ }
167+ return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict)
168+}
@@ -0,0 +1,168 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// WithAutoMerge installs the asynchronous evaluator before serving requests.
15+func (m *MergeRequests) WithAutoMerge(t ports.AutoMergeTrigger) *MergeRequests {
16+ m.autoMerge = t
17+ return m
18+}
19+
20+// EnableAutoMerge records the actor and the head the caller saw, which must be
21+// the current head of the source branch; re-enabling replaces the
22+// previous intent rather than preserving an older actor's authority.
23+func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
24+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
25+ return domain.MergeRequest{}, err
26+ }
27+ if m.checks == nil {
28+ return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed)
29+ }
30+ mode := params.Mode
31+ if mode == "" {
32+ mode = domain.MergeModeFastForward
33+ }
34+ var v domain.Violations
35+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
36+ v.Add("mode", "is not a merge mode")
37+ }
38+ if params.HeadID.IsZero() {
39+ v.Add("head_id", "is required")
40+ }
41+ if err := v.Err(); err != nil {
42+ return domain.MergeRequest{}, err
43+ }
44+ unlock, err := m.lockRepo(ctx, name)
45+ if err != nil {
46+ return domain.MergeRequest{}, err
47+ }
48+ defer unlock()
49+ for range maxWriteRetries {
50+ mr, err := m.store.Get(ctx, name, number)
51+ if err != nil {
52+ return domain.MergeRequest{}, err
53+ }
54+ if !mr.IsOpen() {
55+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
56+ }
57+ if mr.IsDraft() {
58+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
59+ }
60+ if !p.AllowsRef("refs/heads/" + mr.Target) {
61+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
62+ }
63+ repo, err := m.meta.Get(ctx, name)
64+ if err != nil {
65+ return domain.MergeRequest{}, err
66+ }
67+ settings := repo.EffectiveSettings()
68+ if !settings.AllowAutoMerge {
69+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s: turn it on with `ermite repo edit --allow-auto-merge`", domain.ErrPreconditionFailed, name)
70+ }
71+ if !settings.Allows(mode) {
72+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
73+ }
74+ head, err := m.branchOrZero(ctx, name, mr.Source)
75+ if err != nil {
76+ return domain.MergeRequest{}, err
77+ }
78+ if head.IsZero() {
79+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
80+ }
81+ if head != params.HeadID {
82+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
83+ }
84+ mr.UpdatedAt = m.clock.Now().UTC()
85+ mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt}
86+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
87+ continue
88+ } else if err != nil {
89+ return domain.MergeRequest{}, err
90+ }
91+ recordActivity(ctx, m.meta, name, mr.UpdatedAt)
92+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
93+ if m.autoMerge != nil {
94+ m.autoMerge.OnAutoMergeEnabled(ctx, name)
95+ }
96+ return m.withHead(ctx, mr)
97+ }
98+ return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict)
99+}
100+
101+// CancelAutoMerge clears an open request's intent with the same target access
102+// required to enable it.
103+func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
104+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
105+ return domain.MergeRequest{}, err
106+ }
107+ unlock, err := m.lockRepo(ctx, name)
108+ if err != nil {
109+ return domain.MergeRequest{}, err
110+ }
111+ defer unlock()
112+ mr, err := m.store.Get(ctx, name, number)
113+ if err != nil {
114+ return domain.MergeRequest{}, err
115+ }
116+ if !mr.IsOpen() {
117+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
118+ }
119+ if !p.AllowsRef("refs/heads/" + mr.Target) {
120+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
121+ }
122+ if mr.AutoMerge == nil {
123+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed)
124+ }
125+ mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p))
126+ if err != nil {
127+ return domain.MergeRequest{}, err
128+ }
129+ return m.withHead(ctx, mr)
130+}
131+
132+// cancelAuto takes the merge lock and only cancels the intent evaluated by the
133+// caller. A concurrent re-enable, close or merge must not be undone.
134+func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error {
135+ unlock, err := m.lockRepo(ctx, mr.Repo)
136+ if err != nil {
137+ return err
138+ }
139+ defer unlock()
140+ _, err = m.cancelAutoLocked(ctx, mr, reason)
141+ return err
142+}
143+
144+func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) {
145+ for range maxWriteRetries {
146+ mr, err := m.store.Get(ctx, expected.Repo, expected.Number)
147+ if err != nil {
148+ return domain.MergeRequest{}, err
149+ }
150+ if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge {
151+ return mr, nil
152+ }
153+ repo, err := m.meta.Get(ctx, mr.Repo)
154+ if err != nil {
155+ return domain.MergeRequest{}, err
156+ }
157+ mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC()
158+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
159+ continue
160+ } else if err != nil {
161+ return domain.MergeRequest{}, err
162+ }
163+ recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt)
164+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
165+ return mr, nil
166+ }
167+ return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict)
168+}
M internal/usecase/repos.go +14 -6
@@ -17,12 +17,14 @@ import (
1717
1818 // Repos are the repository use cases.
1919 type Repos struct {
20- meta ports.RepoMetaStore
21- git ports.GitRepos
22- bus ports.EventBus
23- clock ports.Clock
24- ci ports.CIRunCleaner // nil: no CI logs to remove
25- mirror ports.MirrorTrigger // nil: no outbound mirror
20+ meta ports.RepoMetaStore
21+ git ports.GitRepos
22+ bus ports.EventBus
23+ clock ports.Clock
24+ ci ports.CIRunCleaner // nil: no CI logs to remove
25+ autoMerge ports.AutoMergeTrigger
26+ mergeGate ports.MergeGate
27+ mirror ports.MirrorTrigger // nil: no outbound mirror
2628 }
2729
2830 var _ ports.RepoService = (*Repos)(nil)
@@ -233,3 +235,9 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
233235 }
234236 return rep, nil
235237 }
238+
239+// WithAutoMerge installs the settings-change trigger before serving requests.
240+func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r }
241+
242+// WithMergeGate installs the settings-write gate before serving requests.
243+func (r *Repos) WithMergeGate(g ports.MergeGate) *Repos { r.mergeGate = g; return r }
@@ -17,12 +17,14 @@ import (
17 17
18 // Repos are the repository use cases.18 // Repos are the repository use cases.
19 type Repos struct {19 type Repos struct {
20- meta ports.RepoMetaStore20+ meta ports.RepoMetaStore
21- git ports.GitRepos21+ git ports.GitRepos
22- bus ports.EventBus22+ bus ports.EventBus
23- clock ports.Clock23+ clock ports.Clock
24- ci ports.CIRunCleaner // nil: no CI logs to remove24+ ci ports.CIRunCleaner // nil: no CI logs to remove
25- mirror ports.MirrorTrigger // nil: no outbound mirror25+ autoMerge ports.AutoMergeTrigger
26+ mergeGate ports.MergeGate
27+ mirror ports.MirrorTrigger // nil: no outbound mirror
26 }28 }
27 29
28 var _ ports.RepoService = (*Repos)(nil)30 var _ ports.RepoService = (*Repos)(nil)
@@ -233,3 +235,9 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
233 }235 }
234 return rep, nil236 return rep, nil
235 }237 }
238+
239+// WithAutoMerge installs the settings-change trigger before serving requests.
240+func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r }
241+
242+// WithMergeGate installs the settings-write gate before serving requests.
243+func (r *Repos) WithMergeGate(g ports.MergeGate) *Repos { r.mergeGate = g; return r }
M internal/usecase/repos_write.go +19 -4
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6262 }
6363 before := repo
6464 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {
65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
6868 if params.AllowedMergeModes != nil {
@@ -116,11 +116,23 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116116 // branch was checked above, so the remaining causes are a vanished
117117 // repository or an I/O failure.
118118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {
120- if repo, err = r.updateMeta(ctx, name, params); err != nil {
119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120+ update := func() error {
121+ repo, err = r.updateMeta(ctx, name, params)
122+ return err
123+ }
124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {
125+ err = r.mergeGate.WithMergeLock(ctx, name, update)
126+ } else {
127+ err = update()
128+ }
129+ if err != nil {
121130 return domain.Repo{}, err
122131 }
123132 metaDone = true
133+ if r.autoMerge != nil {
134+ r.autoMerge.OnRepoChanged(ctx, name)
135+ }
124136 }
125137 var headErr error
126138 if params.DefaultBranch != nil {
@@ -142,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
142154 Bool("topics", params.Topics != nil).
143155 Bool("visibility", params.Visibility != nil).
144156 Bool("default_branch", params.DefaultBranch != nil).
145- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
157+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
146158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
147159 Msg("repository updated")
148160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -175,6 +187,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
175187 if params.FailingChecksBlock != nil {
176188 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
177189 }
190+ if params.AllowAutoMerge != nil {
191+ repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192+ }
178193 if params.Issues != nil {
179194 repo.Settings.Issues = *params.Issues
180195 }
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
62 }62 }
63 before := repo63 before := repo
64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
68 if params.AllowedMergeModes != nil {68 if params.AllowedMergeModes != nil {
@@ -116,11 +116,23 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116 // branch was checked above, so the remaining causes are a vanished116 // branch was checked above, so the remaining causes are a vanished
117 // repository or an I/O failure.117 // repository or an I/O failure.
118 metaDone := false118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120- if repo, err = r.updateMeta(ctx, name, params); err != nil {120+ update := func() error {
121+ repo, err = r.updateMeta(ctx, name, params)
122+ return err
123+ }
124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {
125+ err = r.mergeGate.WithMergeLock(ctx, name, update)
126+ } else {
127+ err = update()
128+ }
129+ if err != nil {
121 return domain.Repo{}, err130 return domain.Repo{}, err
122 }131 }
123 metaDone = true132 metaDone = true
133+ if r.autoMerge != nil {
134+ r.autoMerge.OnRepoChanged(ctx, name)
135+ }
124 }136 }
125 var headErr error137 var headErr error
126 if params.DefaultBranch != nil {138 if params.DefaultBranch != nil {
@@ -142,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
142 Bool("topics", params.Topics != nil).154 Bool("topics", params.Topics != nil).
143 Bool("visibility", params.Visibility != nil).155 Bool("visibility", params.Visibility != nil).
144 Bool("default_branch", params.DefaultBranch != nil).156 Bool("default_branch", params.DefaultBranch != nil).
145- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).157+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
146 Str("principal", p.Label()).Str("token_id", p.TokenID()).158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
147 Msg("repository updated")159 Msg("repository updated")
148 r.bus.Publish(ctx, domain.RepoUpdated{160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -175,6 +187,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
175 if params.FailingChecksBlock != nil {187 if params.FailingChecksBlock != nil {
176 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock188 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
177 }189 }
190+ if params.AllowAutoMerge != nil {
191+ repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192+ }
178 if params.Issues != nil {193 if params.Issues != nil {
179 repo.Settings.Issues = *params.Issues194 repo.Settings.Issues = *params.Issues
180 }195 }
M internal/usecase/repos_write_test.go +17 -0
@@ -15,6 +15,7 @@ import (
1515
1616 "bnema.dev/ermite/internal/domain"
1717 "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
1819 "bnema.dev/ermite/internal/usecase"
1920 )
2021
@@ -27,6 +28,22 @@ func TestUpdate(t *testing.T) {
2728 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
2829 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
2930
31+ t.Run("auto-merge setting is stored and kicks evaluator", func(t *testing.T) {
32+ f := newFixture(t)
33+ trigger := mocks.NewAutoMergeTrigger(t)
34+ f.repos.WithAutoMerge(trigger)
35+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
36+ want := priv
37+ want.Settings = domain.DefaultRepoSettings()
38+ want.Settings.AllowAutoMerge = true
39+ expectRepoMutation(t, f.meta, priv, want)
40+ trigger.EXPECT().OnRepoChanged(ctx, n).Return().Once()
41+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
42+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
43+ require.NoError(t, err)
44+ assert.True(t, got.EffectiveSettings().AllowAutoMerge)
45+ })
46+
3047 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
3148 t.Parallel()
3249 f := newFixture(t)
@@ -15,6 +15,7 @@ import (
15 15
16 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"17 "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
18 "bnema.dev/ermite/internal/usecase"19 "bnema.dev/ermite/internal/usecase"
19 )20 )
20 21
@@ -27,6 +28,22 @@ func TestUpdate(t *testing.T) {
27 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}28 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
28 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")29 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
29 30
31+ t.Run("auto-merge setting is stored and kicks evaluator", func(t *testing.T) {
32+ f := newFixture(t)
33+ trigger := mocks.NewAutoMergeTrigger(t)
34+ f.repos.WithAutoMerge(trigger)
35+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
36+ want := priv
37+ want.Settings = domain.DefaultRepoSettings()
38+ want.Settings.AllowAutoMerge = true
39+ expectRepoMutation(t, f.meta, priv, want)
40+ trigger.EXPECT().OnRepoChanged(ctx, n).Return().Once()
41+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
42+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
43+ require.NoError(t, err)
44+ assert.True(t, got.EffectiveSettings().AllowAutoMerge)
45+ })
46+
30 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {47 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
31 t.Parallel()48 t.Parallel()
32 f := newFixture(t)49 f := newFixture(t)
M proto/ermite/storage/merge_request.proto +15 -0
@@ -30,6 +30,7 @@ message RepoSettings {
3030 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
3131 // read.
3232 bool merge_modes_v2 = 4;
33+ bool allow_auto_merge = 5;
3334 }
3435
3536 // IssuesMode says who may write issues in a repository.
@@ -78,6 +79,20 @@ message MergeRequest {
7879 MergeMode merge_mode = 13;
7980 // Last comment id handed out for this request.
8081 int64 comment_seq = 14;
82+ // Pending auto-merge; unset when off.
83+ AutoMerge auto_merge = 15;
84+ bool auto_merged = 16;
85+}
86+
87+// AutoMerge is the pending auto-merge of an open merge request.
88+message AutoMerge {
89+ MergeMode mode = 1;
90+ string head_id = 2;
91+ // Kind and token id of the CIPusher that enabled it.
92+ string enabler_kind = 3;
93+ string enabler_token_id = 4;
94+ string enabler_label = 5;
95+ google.protobuf.Timestamp enabled_at = 6;
8196 }
8297
8398 // Verdict is the immutable review decision of a note.
@@ -30,6 +30,7 @@ message RepoSettings {
30 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on30 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
31 // read.31 // read.
32 bool merge_modes_v2 = 4;32 bool merge_modes_v2 = 4;
33+ bool allow_auto_merge = 5;
33 }34 }
34 35
35 // IssuesMode says who may write issues in a repository.36 // IssuesMode says who may write issues in a repository.
@@ -78,6 +79,20 @@ message MergeRequest {
78 MergeMode merge_mode = 13;79 MergeMode merge_mode = 13;
79 // Last comment id handed out for this request.80 // Last comment id handed out for this request.
80 int64 comment_seq = 14;81 int64 comment_seq = 14;
82+ // Pending auto-merge; unset when off.
83+ AutoMerge auto_merge = 15;
84+ bool auto_merged = 16;
85+}
86+
87+// AutoMerge is the pending auto-merge of an open merge request.
88+message AutoMerge {
89+ MergeMode mode = 1;
90+ string head_id = 2;
91+ // Kind and token id of the CIPusher that enabled it.
92+ string enabler_kind = 3;
93+ string enabler_token_id = 4;
94+ string enabler_label = 5;
95+ google.protobuf.Timestamp enabled_at = 6;
81 }96 }
82 97
83 // Verdict is the immutable review decision of a note.98 // Verdict is the immutable review decision of a note.
M skills/ermite/CLI.md +4 -2
@@ -11,10 +11,10 @@ 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,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 |
14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `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
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`, `allow_auto_merge`.
1818
1919 ## Tokens
2020
@@ -62,6 +62,7 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
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 |
6464 | `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 merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
6566 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6667
6768 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +107,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106107 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107108 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108109 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
110+| `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` | `repo`, `merge_request`; cancelled also has `reason`. `merge_request_merged` has `auto` true after an auto-merge |
109111 | `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 |
110112 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
111113 | `gap` | none: events were lost, reload the state |
@@ -11,10 +11,10 @@ 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,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 |14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `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`, `allow_auto_merge`.
18 18
19 ## Tokens19 ## Tokens
20 20
@@ -62,6 +62,7 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
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> [--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`) |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 merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
65 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |66 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
66 67
67 `ermite mr create` and `ermite mr close` print a confirmation followed by the full68 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +107,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |107 | `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) |108 | `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` |109 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
110+| `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` | `repo`, `merge_request`; cancelled also has `reason`. `merge_request_merged` has `auto` true after an auto-merge |
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 |111 | `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 |
110 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |112 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
111 | `gap` | none: events were lost, reload the state |113 | `gap` | none: events were lost, reload the state |
M skills/ermite/SKILL.md +2 -0
@@ -39,6 +39,8 @@ git push https://git.example.com/<repo>.git main # password = token,
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.
4040 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+To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
4244 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.
4345
4446 ### Act on a review
@@ -39,6 +39,8 @@ git push https://git.example.com/<repo>.git main # password = token,
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 `--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.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+To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
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.44 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 45
44 ### Act on a review46 ### Act on a review

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 3

Tags 44

↑↓ moveenter openesc close