detached 1314a0d

feat(mr): enable, cancel and evaluate merge request auto-merge

commit
1314a0d9ddd6a1adedb5a4c6be23eb30027db978
author
brice · · 2026-10-20 16:04 UTC
parent
9795e63
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +1189 -50
A internal/usecase/automerge.go +382 -0
@@ -0,0 +1,382 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// AutoMergeConfig holds dependencies of the asynchronous merge evaluator.
19+type AutoMergeConfig struct {
20+ MRs *MergeRequests
21+ Store ports.MergeRequestStore
22+ Meta ports.RepoMetaStore
23+ Git ports.MergeGit
24+ Checks ports.CIChecksReader
25+ Activity ports.CIActivity
26+ Tokens ports.TokenStore
27+ Bus ports.EventBus
28+ Clock ports.Clock
29+ After func(time.Duration) <-chan time.Time
30+}
31+
32+// AutoMerge serializes evaluation within each repository. Triggers only enqueue
33+// work, including when invoked while CI or merge-request locks are held.
34+type AutoMerge struct {
35+ cfg AutoMergeConfig
36+ ctx context.Context
37+ cancel context.CancelFunc
38+ wg sync.WaitGroup
39+ mu sync.Mutex
40+ dirty map[domain.RepoName]bool
41+ order []domain.RepoName
42+ running, again map[domain.RepoName]bool
43+ wake chan struct{}
44+ retryMu sync.Mutex
45+ retries map[domain.RepoName]int
46+ scheduled map[domain.RepoName]bool
47+ heap []retryItem
48+ retryKick chan struct{}
49+ closed bool
50+}
51+
52+var _ ports.AutoMergeTrigger = (*AutoMerge)(nil)
53+
54+// NewAutoMerge builds an evaluator; call Start once, and Close before MRs.Wait.
55+func NewAutoMerge(ctx context.Context, cfg AutoMergeConfig) *AutoMerge {
56+ if cfg.After == nil {
57+ cfg.After = time.After
58+ }
59+ ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
60+ return &AutoMerge{cfg: cfg, ctx: ctx, cancel: cancel,
61+ dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1),
62+ retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)}
63+}
64+
65+// Start starts workers and discovers repositories, retrying failed discovery.
66+func (s *AutoMerge) Start() {
67+ s.wg.Add(2 + defaultMergeabilityWorkers)
68+ go s.scheduler()
69+ go func() {
70+ defer s.wg.Done()
71+ for attempt := 0; attempt <= mergeabilityRetries; attempt++ {
72+ repos, err := s.cfg.Store.ListOpenAll(s.ctx)
73+ if err != nil && s.ctx.Err() == nil {
74+ ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("auto-merge: list the repositories at startup")
75+ }
76+ if err == nil {
77+ for _, repo := range repos {
78+ if repo.AutoMerge != nil {
79+ s.markDirty(repo.Repo)
80+ }
81+ }
82+ return
83+ }
84+ if attempt == mergeabilityRetries {
85+ return
86+ }
87+ select {
88+ case <-s.ctx.Done():
89+ return
90+ case <-s.cfg.After(mergeBackoff(attempt + 1)):
91+ }
92+ }
93+ }()
94+ for range defaultMergeabilityWorkers {
95+ go s.loop()
96+ }
97+}
98+
99+// Close cancels workers and pending retry waits, then waits for them.
100+func (s *AutoMerge) Close(_ context.Context) {
101+ s.retryMu.Lock()
102+ s.closed = true
103+ s.retryMu.Unlock()
104+ s.cancel()
105+ s.wg.Wait()
106+}
107+
108+func (s *AutoMerge) signal() {
109+ select {
110+ case s.wake <- struct{}{}:
111+ default:
112+ }
113+}
114+
115+func (s *AutoMerge) markDirty(repo domain.RepoName) {
116+ s.mu.Lock()
117+ if s.running[repo] {
118+ s.again[repo] = true
119+ } else if !s.dirty[repo] {
120+ s.dirty[repo] = true
121+ s.order = append(s.order, repo)
122+ }
123+ s.mu.Unlock()
124+ s.signal()
125+}
126+
127+func (s *AutoMerge) next() (domain.RepoName, bool) {
128+ s.mu.Lock()
129+ defer s.mu.Unlock()
130+ if len(s.order) == 0 {
131+ return domain.RepoName{}, false
132+ }
133+ repo := s.order[0]
134+ s.order = s.order[1:]
135+ delete(s.dirty, repo)
136+ s.running[repo] = true
137+ if len(s.order) > 0 {
138+ s.signal()
139+ }
140+ return repo, true
141+}
142+
143+func (s *AutoMerge) loop() {
144+ defer s.wg.Done()
145+ for s.ctx.Err() == nil {
146+ repo, found := s.next()
147+ if !found {
148+ select {
149+ case <-s.ctx.Done():
150+ return
151+ case <-s.wake:
152+ }
153+ continue
154+ }
155+ if s.evaluate(repo) {
156+ s.retryDone(repo)
157+ } else {
158+ s.retry(repo)
159+ }
160+ s.mu.Lock()
161+ delete(s.running, repo)
162+ if s.again[repo] {
163+ delete(s.again, repo)
164+ s.dirty[repo] = true
165+ s.order = append(s.order, repo)
166+ s.signal()
167+ }
168+ s.mu.Unlock()
169+ }
170+}
171+
172+// OnRepoChanged marks and wakes only; it never merges on the caller's stack.
173+func (s *AutoMerge) OnRepoChanged(_ context.Context, repo domain.RepoName) { s.markDirty(repo) }
174+
175+func (s *AutoMerge) evaluate(repo domain.RepoName) bool {
176+ err := s.evaluateRepo(repo)
177+ if err != nil && s.ctx.Err() == nil {
178+ ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("auto-merge: evaluate repository")
179+ }
180+ return err == nil || s.ctx.Err() != nil
181+}
182+
183+func (s *AutoMerge) evaluateRepo(repo domain.RepoName) error {
184+ ctx := s.ctx
185+ meta, err := s.cfg.Meta.Get(ctx, repo)
186+ if errors.Is(err, domain.ErrNotFound) {
187+ return nil
188+ }
189+ if err != nil {
190+ return err
191+ }
192+ mrs, err := s.cfg.Store.ListOpen(ctx, repo)
193+ if err != nil {
194+ return err
195+ }
196+ var failures []error
197+ for _, mr := range mrs {
198+ if ctx.Err() != nil {
199+ return nil
200+ }
201+ if mr.AutoMerge == nil {
202+ continue
203+ }
204+ if err := s.evaluateMR(ctx, meta, mr); err != nil {
205+ failures = append(failures, err)
206+ }
207+ }
208+ return errors.Join(failures...)
209+}
210+
211+func (s *AutoMerge) evaluateMR(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) error {
212+ intent := mr.AutoMerge
213+ settings := repo.EffectiveSettings()
214+ cancel := func(reason string) error { return s.cfg.MRs.cancelAuto(ctx, mr, reason) }
215+ if !settings.AllowAutoMerge {
216+ return cancel("auto-merge was turned off in the repository settings")
217+ }
218+ if !settings.Allows(intent.Mode) {
219+ return cancel(fmt.Sprintf("the %s merge mode is no longer allowed", intent.Mode))
220+ }
221+ head, err := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
222+ if errors.Is(err, domain.ErrNotFound) || (err == nil && head.IsZero()) {
223+ return cancel("the source branch was deleted")
224+ }
225+ if err != nil {
226+ return err
227+ }
228+ if head != intent.HeadID {
229+ return cancel("the branch moved to " + head.String()[:7])
230+ }
231+ p, valid, err := principalOfPusher(ctx, s.cfg.Tokens, intent.EnabledBy, s.cfg.Clock.Now())
232+ if err != nil {
233+ return err
234+ }
235+ if !valid || !p.Allows(domain.ScopeMRMerge, mr.Repo) || !p.AllowsRef("refs/heads/"+mr.Target) {
236+ return cancel("the principal that enabled auto-merge can no longer merge " + mr.Target)
237+ }
238+ if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number, mr.Source, head) {
239+ return nil
240+ }
241+ checks, err := s.cfg.Checks.CommitChecks(ctx, mr.Repo, head.String())
242+ if err != nil {
243+ return err
244+ }
245+ passed := false
246+ for _, check := range checks {
247+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
248+ passed = true
249+ break
250+ }
251+ }
252+ if !passed {
253+ return nil
254+ }
255+ _, err = s.cfg.MRs.mergeAs(ctx, p, mr.Repo, mr.Number, ports.MergeParams{Mode: intent.Mode, HeadID: intent.HeadID}, mergeOpts{auto: true, intent: intent})
256+ switch {
257+ case err == nil, errors.Is(err, errAutoMergeWait):
258+ return nil
259+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated):
260+ return cancel(autoMergeReason(err))
261+ default:
262+ return err
263+ }
264+}
265+
266+// Removing a known sentinel's presentation prefix is display formatting, not
267+// error classification (which always uses errors.Is).
268+func autoMergeReason(err error) string {
269+ text := err.Error()
270+ for _, sentinel := range []error{domain.ErrPreconditionFailed, domain.ErrForbidden, domain.ErrUnauthenticated} {
271+ text = strings.TrimPrefix(text, sentinel.Error()+": ")
272+ }
273+ return text
274+}
275+
276+func (s *AutoMerge) retry(key domain.RepoName) {
277+ s.retryMu.Lock()
278+ defer s.retryMu.Unlock()
279+ if s.closed || s.scheduled[key] {
280+ return
281+ }
282+ s.retries[key]++
283+ n := s.retries[key]
284+ if n > mergeabilityRetries {
285+ delete(s.retries, key)
286+ return
287+ }
288+ s.scheduled[key] = true
289+ it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key}
290+ i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int {
291+ if a.due.After(b.due) {
292+ return 1
293+ }
294+ return -1 // equal dues keep their insertion order
295+ })
296+ s.heap = slices.Insert(s.heap, i, it)
297+ if i == 0 {
298+ select {
299+ case s.retryKick <- struct{}{}:
300+ default:
301+ }
302+ }
303+}
304+
305+// scheduler is the only goroutine that waits for retries: it sleeps until the
306+// earliest is due and puts it back in the normal queue.
307+func (s *AutoMerge) scheduler() {
308+ defer s.wg.Done()
309+ // The timer armed for waited; a kick re-arms it only when the earliest
310+ // retry changed, so a stale kick never starts a second timer.
311+ var (
312+ after <-chan time.Time
313+ waited retryItem
314+ )
315+ for {
316+ s.retryMu.Lock()
317+ var head retryItem
318+ has := len(s.heap) > 0
319+ if has {
320+ head = s.heap[0]
321+ }
322+ s.retryMu.Unlock()
323+ if !has {
324+ after = nil
325+ select {
326+ case <-s.ctx.Done():
327+ return
328+ case <-s.retryKick:
329+ continue
330+ }
331+ }
332+ if after == nil || head.key != waited.key || !head.due.Equal(waited.due) {
333+ after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head
334+ }
335+ select {
336+ case <-s.ctx.Done():
337+ return
338+ case <-s.retryKick:
339+ continue // an earlier retry may have arrived
340+ case <-after:
341+ after = nil
342+ }
343+ for _, k := range s.popDue(waited) {
344+ if s.ctx.Err() != nil {
345+ return
346+ }
347+ s.markDirty(k)
348+ }
349+ }
350+}
351+
352+// popDue removes the waited item and the ones due as well. The waited item is
353+// removed regardless of the clock only when both its key and its deadline
354+// match: a replacement retry of the same repository is kept until it is due.
355+func (s *AutoMerge) popDue(waited retryItem) []domain.RepoName {
356+ s.retryMu.Lock()
357+ defer s.retryMu.Unlock()
358+ now := s.cfg.Clock.Now()
359+ var out []domain.RepoName
360+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
361+ if (it.key != waited.key || !it.due.Equal(waited.due)) && it.due.After(now) {
362+ return false
363+ }
364+ out = append(out, it.key)
365+ delete(s.scheduled, it.key)
366+ return true
367+ })
368+ return out
369+}
370+
371+// retryDone forgets the failures of key after a success.
372+func (s *AutoMerge) retryDone(key domain.RepoName) {
373+ s.retryMu.Lock()
374+ delete(s.retries, key)
375+ delete(s.scheduled, key)
376+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key })
377+ select {
378+ case s.retryKick <- struct{}{}:
379+ default:
380+ }
381+ s.retryMu.Unlock()
382+}
@@ -0,0 +1,382 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "sync"
12+ "time"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// AutoMergeConfig holds dependencies of the asynchronous merge evaluator.
19+type AutoMergeConfig struct {
20+ MRs *MergeRequests
21+ Store ports.MergeRequestStore
22+ Meta ports.RepoMetaStore
23+ Git ports.MergeGit
24+ Checks ports.CIChecksReader
25+ Activity ports.CIActivity
26+ Tokens ports.TokenStore
27+ Bus ports.EventBus
28+ Clock ports.Clock
29+ After func(time.Duration) <-chan time.Time
30+}
31+
32+// AutoMerge serializes evaluation within each repository. Triggers only enqueue
33+// work, including when invoked while CI or merge-request locks are held.
34+type AutoMerge struct {
35+ cfg AutoMergeConfig
36+ ctx context.Context
37+ cancel context.CancelFunc
38+ wg sync.WaitGroup
39+ mu sync.Mutex
40+ dirty map[domain.RepoName]bool
41+ order []domain.RepoName
42+ running, again map[domain.RepoName]bool
43+ wake chan struct{}
44+ retryMu sync.Mutex
45+ retries map[domain.RepoName]int
46+ scheduled map[domain.RepoName]bool
47+ heap []retryItem
48+ retryKick chan struct{}
49+ closed bool
50+}
51+
52+var _ ports.AutoMergeTrigger = (*AutoMerge)(nil)
53+
54+// NewAutoMerge builds an evaluator; call Start once, and Close before MRs.Wait.
55+func NewAutoMerge(ctx context.Context, cfg AutoMergeConfig) *AutoMerge {
56+ if cfg.After == nil {
57+ cfg.After = time.After
58+ }
59+ ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
60+ return &AutoMerge{cfg: cfg, ctx: ctx, cancel: cancel,
61+ dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1),
62+ retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)}
63+}
64+
65+// Start starts workers and discovers repositories, retrying failed discovery.
66+func (s *AutoMerge) Start() {
67+ s.wg.Add(2 + defaultMergeabilityWorkers)
68+ go s.scheduler()
69+ go func() {
70+ defer s.wg.Done()
71+ for attempt := 0; attempt <= mergeabilityRetries; attempt++ {
72+ repos, err := s.cfg.Store.ListOpenAll(s.ctx)
73+ if err != nil && s.ctx.Err() == nil {
74+ ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("auto-merge: list the repositories at startup")
75+ }
76+ if err == nil {
77+ for _, repo := range repos {
78+ if repo.AutoMerge != nil {
79+ s.markDirty(repo.Repo)
80+ }
81+ }
82+ return
83+ }
84+ if attempt == mergeabilityRetries {
85+ return
86+ }
87+ select {
88+ case <-s.ctx.Done():
89+ return
90+ case <-s.cfg.After(mergeBackoff(attempt + 1)):
91+ }
92+ }
93+ }()
94+ for range defaultMergeabilityWorkers {
95+ go s.loop()
96+ }
97+}
98+
99+// Close cancels workers and pending retry waits, then waits for them.
100+func (s *AutoMerge) Close(_ context.Context) {
101+ s.retryMu.Lock()
102+ s.closed = true
103+ s.retryMu.Unlock()
104+ s.cancel()
105+ s.wg.Wait()
106+}
107+
108+func (s *AutoMerge) signal() {
109+ select {
110+ case s.wake <- struct{}{}:
111+ default:
112+ }
113+}
114+
115+func (s *AutoMerge) markDirty(repo domain.RepoName) {
116+ s.mu.Lock()
117+ if s.running[repo] {
118+ s.again[repo] = true
119+ } else if !s.dirty[repo] {
120+ s.dirty[repo] = true
121+ s.order = append(s.order, repo)
122+ }
123+ s.mu.Unlock()
124+ s.signal()
125+}
126+
127+func (s *AutoMerge) next() (domain.RepoName, bool) {
128+ s.mu.Lock()
129+ defer s.mu.Unlock()
130+ if len(s.order) == 0 {
131+ return domain.RepoName{}, false
132+ }
133+ repo := s.order[0]
134+ s.order = s.order[1:]
135+ delete(s.dirty, repo)
136+ s.running[repo] = true
137+ if len(s.order) > 0 {
138+ s.signal()
139+ }
140+ return repo, true
141+}
142+
143+func (s *AutoMerge) loop() {
144+ defer s.wg.Done()
145+ for s.ctx.Err() == nil {
146+ repo, found := s.next()
147+ if !found {
148+ select {
149+ case <-s.ctx.Done():
150+ return
151+ case <-s.wake:
152+ }
153+ continue
154+ }
155+ if s.evaluate(repo) {
156+ s.retryDone(repo)
157+ } else {
158+ s.retry(repo)
159+ }
160+ s.mu.Lock()
161+ delete(s.running, repo)
162+ if s.again[repo] {
163+ delete(s.again, repo)
164+ s.dirty[repo] = true
165+ s.order = append(s.order, repo)
166+ s.signal()
167+ }
168+ s.mu.Unlock()
169+ }
170+}
171+
172+// OnRepoChanged marks and wakes only; it never merges on the caller's stack.
173+func (s *AutoMerge) OnRepoChanged(_ context.Context, repo domain.RepoName) { s.markDirty(repo) }
174+
175+func (s *AutoMerge) evaluate(repo domain.RepoName) bool {
176+ err := s.evaluateRepo(repo)
177+ if err != nil && s.ctx.Err() == nil {
178+ ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("auto-merge: evaluate repository")
179+ }
180+ return err == nil || s.ctx.Err() != nil
181+}
182+
183+func (s *AutoMerge) evaluateRepo(repo domain.RepoName) error {
184+ ctx := s.ctx
185+ meta, err := s.cfg.Meta.Get(ctx, repo)
186+ if errors.Is(err, domain.ErrNotFound) {
187+ return nil
188+ }
189+ if err != nil {
190+ return err
191+ }
192+ mrs, err := s.cfg.Store.ListOpen(ctx, repo)
193+ if err != nil {
194+ return err
195+ }
196+ var failures []error
197+ for _, mr := range mrs {
198+ if ctx.Err() != nil {
199+ return nil
200+ }
201+ if mr.AutoMerge == nil {
202+ continue
203+ }
204+ if err := s.evaluateMR(ctx, meta, mr); err != nil {
205+ failures = append(failures, err)
206+ }
207+ }
208+ return errors.Join(failures...)
209+}
210+
211+func (s *AutoMerge) evaluateMR(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) error {
212+ intent := mr.AutoMerge
213+ settings := repo.EffectiveSettings()
214+ cancel := func(reason string) error { return s.cfg.MRs.cancelAuto(ctx, mr, reason) }
215+ if !settings.AllowAutoMerge {
216+ return cancel("auto-merge was turned off in the repository settings")
217+ }
218+ if !settings.Allows(intent.Mode) {
219+ return cancel(fmt.Sprintf("the %s merge mode is no longer allowed", intent.Mode))
220+ }
221+ head, err := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
222+ if errors.Is(err, domain.ErrNotFound) || (err == nil && head.IsZero()) {
223+ return cancel("the source branch was deleted")
224+ }
225+ if err != nil {
226+ return err
227+ }
228+ if head != intent.HeadID {
229+ return cancel("the branch moved to " + head.String()[:7])
230+ }
231+ p, valid, err := principalOfPusher(ctx, s.cfg.Tokens, intent.EnabledBy, s.cfg.Clock.Now())
232+ if err != nil {
233+ return err
234+ }
235+ if !valid || !p.Allows(domain.ScopeMRMerge, mr.Repo) || !p.AllowsRef("refs/heads/"+mr.Target) {
236+ return cancel("the principal that enabled auto-merge can no longer merge " + mr.Target)
237+ }
238+ if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number, mr.Source, head) {
239+ return nil
240+ }
241+ checks, err := s.cfg.Checks.CommitChecks(ctx, mr.Repo, head.String())
242+ if err != nil {
243+ return err
244+ }
245+ passed := false
246+ for _, check := range checks {
247+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
248+ passed = true
249+ break
250+ }
251+ }
252+ if !passed {
253+ return nil
254+ }
255+ _, err = s.cfg.MRs.mergeAs(ctx, p, mr.Repo, mr.Number, ports.MergeParams{Mode: intent.Mode, HeadID: intent.HeadID}, mergeOpts{auto: true, intent: intent})
256+ switch {
257+ case err == nil, errors.Is(err, errAutoMergeWait):
258+ return nil
259+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated):
260+ return cancel(autoMergeReason(err))
261+ default:
262+ return err
263+ }
264+}
265+
266+// Removing a known sentinel's presentation prefix is display formatting, not
267+// error classification (which always uses errors.Is).
268+func autoMergeReason(err error) string {
269+ text := err.Error()
270+ for _, sentinel := range []error{domain.ErrPreconditionFailed, domain.ErrForbidden, domain.ErrUnauthenticated} {
271+ text = strings.TrimPrefix(text, sentinel.Error()+": ")
272+ }
273+ return text
274+}
275+
276+func (s *AutoMerge) retry(key domain.RepoName) {
277+ s.retryMu.Lock()
278+ defer s.retryMu.Unlock()
279+ if s.closed || s.scheduled[key] {
280+ return
281+ }
282+ s.retries[key]++
283+ n := s.retries[key]
284+ if n > mergeabilityRetries {
285+ delete(s.retries, key)
286+ return
287+ }
288+ s.scheduled[key] = true
289+ it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key}
290+ i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int {
291+ if a.due.After(b.due) {
292+ return 1
293+ }
294+ return -1 // equal dues keep their insertion order
295+ })
296+ s.heap = slices.Insert(s.heap, i, it)
297+ if i == 0 {
298+ select {
299+ case s.retryKick <- struct{}{}:
300+ default:
301+ }
302+ }
303+}
304+
305+// scheduler is the only goroutine that waits for retries: it sleeps until the
306+// earliest is due and puts it back in the normal queue.
307+func (s *AutoMerge) scheduler() {
308+ defer s.wg.Done()
309+ // The timer armed for waited; a kick re-arms it only when the earliest
310+ // retry changed, so a stale kick never starts a second timer.
311+ var (
312+ after <-chan time.Time
313+ waited retryItem
314+ )
315+ for {
316+ s.retryMu.Lock()
317+ var head retryItem
318+ has := len(s.heap) > 0
319+ if has {
320+ head = s.heap[0]
321+ }
322+ s.retryMu.Unlock()
323+ if !has {
324+ after = nil
325+ select {
326+ case <-s.ctx.Done():
327+ return
328+ case <-s.retryKick:
329+ continue
330+ }
331+ }
332+ if after == nil || head.key != waited.key || !head.due.Equal(waited.due) {
333+ after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head
334+ }
335+ select {
336+ case <-s.ctx.Done():
337+ return
338+ case <-s.retryKick:
339+ continue // an earlier retry may have arrived
340+ case <-after:
341+ after = nil
342+ }
343+ for _, k := range s.popDue(waited) {
344+ if s.ctx.Err() != nil {
345+ return
346+ }
347+ s.markDirty(k)
348+ }
349+ }
350+}
351+
352+// popDue removes the waited item and the ones due as well. The waited item is
353+// removed regardless of the clock only when both its key and its deadline
354+// match: a replacement retry of the same repository is kept until it is due.
355+func (s *AutoMerge) popDue(waited retryItem) []domain.RepoName {
356+ s.retryMu.Lock()
357+ defer s.retryMu.Unlock()
358+ now := s.cfg.Clock.Now()
359+ var out []domain.RepoName
360+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
361+ if (it.key != waited.key || !it.due.Equal(waited.due)) && it.due.After(now) {
362+ return false
363+ }
364+ out = append(out, it.key)
365+ delete(s.scheduled, it.key)
366+ return true
367+ })
368+ return out
369+}
370+
371+// retryDone forgets the failures of key after a success.
372+func (s *AutoMerge) retryDone(key domain.RepoName) {
373+ s.retryMu.Lock()
374+ delete(s.retries, key)
375+ delete(s.scheduled, key)
376+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key })
377+ select {
378+ case s.retryKick <- struct{}{}:
379+ default:
380+ }
381+ s.retryMu.Unlock()
382+}
A internal/usecase/automerge_test.go +353 -0
@@ -0,0 +1,353 @@
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, Bus: r.bus, 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{})
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{})
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().OnRepoChanged(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})
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, mr.Source, r.head()).Return(false).Once()
125+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number, mr.Source, r.head()).Return(true).Once()
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+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
142+ a := autoEvaluator(r, checks, nil, nil)
143+ defer a.Close(r.ctx)
144+ r.drain()
145+ require.True(t, a.Evaluate(r.repo))
146+ got := storedAuto(t, r, mr)
147+ assert.Equal(t, domain.MRStateMerged, got.State)
148+ assert.True(t, got.AutoMerged)
149+ assert.Nil(t, got.AutoMerge)
150+ events := r.drain()
151+ found := false
152+ for _, ev := range events {
153+ if merged, ok := ev.(domain.MergeRequestMerged); ok {
154+ assert.True(t, merged.Auto)
155+ found = true
156+ }
157+ }
158+ require.True(t, found)
159+}
160+
161+func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
162+ r, mr, checks := autoRig(t)
163+ enableAuto(t, r, mr)
164+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
165+ require.NoError(t, err)
166+ // No successful check: an unsuccessful head waits rather than merging.
167+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CIFailure}}, nil)
168+ a := autoEvaluator(r, checks, nil, nil)
169+ defer a.Close(r.ctx)
170+ require.True(t, a.Evaluate(r.repo))
171+ assert.True(t, storedAuto(t, r, mr).IsOpen())
172+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
173+}
174+
175+func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") }
176+func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
177+func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") }
178+func testAutoCancellation(t *testing.T, change string) {
179+ r, mr, checks := autoRig(t)
180+ enableAuto(t, r, mr)
181+ switch change {
182+ case "head":
183+ r.commit(author, "new", "new", "new head")
184+ r.push("feat")
185+ case "mode":
186+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
187+ require.NoError(t, err)
188+ case "setting":
189+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
190+ require.NoError(t, err)
191+ }
192+ a := autoEvaluator(r, checks, nil, nil)
193+ defer a.Close(r.ctx)
194+ r.drain()
195+ require.True(t, a.Evaluate(r.repo))
196+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
197+ events := r.drain()
198+ require.Len(t, events, 1)
199+ assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0])
200+}
201+
202+func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
203+ for _, expired := range []bool{false, true} {
204+ t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) {
205+ r, mr, checks := autoRig(t)
206+ enableAuto(t, r, mr)
207+ enabled := storedAuto(t, r, mr)
208+ enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
209+ require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
210+ tokens := mocks.NewTokenStore(t)
211+ if expired {
212+ tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
213+ } else {
214+ tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound)
215+ }
216+ a := autoEvaluator(r, checks, nil, tokens)
217+ defer a.Close(r.ctx)
218+ require.True(t, a.Evaluate(r.repo))
219+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
220+ assert.True(t, storedAuto(t, r, mr).IsOpen())
221+ })
222+ }
223+}
224+
225+func TestAutoMergeCancelsOnPermanentMergeFailure(t *testing.T) {
226+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
227+ t.Run(mode.String(), func(t *testing.T) {
228+ r, mr, checks := autoRig(t)
229+ _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode})
230+ require.NoError(t, err)
231+ head := r.head()
232+ r.sh("checkout", "main")
233+ r.commit(author, "a.txt", "conflicting target\n", "move target")
234+ r.push("main")
235+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
236+ a := autoEvaluator(r, checks, nil, nil)
237+ defer a.Close(r.ctx)
238+ require.True(t, a.Evaluate(r.repo))
239+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
240+ assert.True(t, storedAuto(t, r, mr).IsOpen())
241+ })
242+ }
243+}
244+
245+func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) {
246+ for _, closeMR := range []bool{false, true} {
247+ t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) {
248+ r, mr, checks := autoRig(t)
249+ enabled := enableAuto(t, r, mr)
250+ if closeMR {
251+ _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
252+ require.NoError(t, err)
253+ } else {
254+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil)
255+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID})
256+ require.NoError(t, err)
257+ }
258+ got := storedAuto(t, r, mr)
259+ assert.Nil(t, got.AutoMerge)
260+ assert.False(t, got.AutoMerged)
261+ })
262+ }
263+}
264+
265+func TestAutoMergeStartupRescan(t *testing.T) {
266+ r, mr, checks := autoRig(t)
267+ enableAuto(t, r, mr)
268+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
269+ a := autoEvaluator(r, checks, nil, nil)
270+ a.Start()
271+ defer a.Close(r.ctx)
272+ require.Eventually(t, func() bool {
273+ got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
274+ return err == nil && got.AutoMerged
275+ }, 5*time.Second, time.Millisecond)
276+ a.Close(r.ctx)
277+ r.mrs.Wait(r.ctx)
278+}
279+
280+func TestAutoMergeRetriesTransientErrors(t *testing.T) {
281+ ctx := context.Background()
282+ store := mocks.NewMergeRequestStore(t)
283+ meta := mocks.NewRepoMetaStore(t)
284+ clock := mocks.NewClock(t)
285+ clock.EXPECT().Now().Return(now).Maybe()
286+ repo := name(t, "proj")
287+ store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil)
288+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once()
289+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
290+ done := make(chan struct{}, 1)
291+ store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once()
292+ timer := make(chan time.Time, 1)
293+ armed := make(chan time.Duration, 1)
294+ a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }})
295+ a.Start()
296+ defer a.Close(ctx)
297+ select {
298+ case d := <-armed:
299+ assert.Equal(t, time.Second, d)
300+ case <-time.After(5 * time.Second):
301+ t.Fatal("no retry scheduled")
302+ }
303+ timer <- now.Add(time.Second)
304+ select {
305+ case <-done:
306+ case <-time.After(5 * time.Second):
307+ t.Fatal("retry did not evaluate")
308+ }
309+}
310+
311+func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) {
312+ r, mr, checks := autoRig(t)
313+ enableAuto(t, r, mr)
314+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
315+ a := autoEvaluator(r, checks, nil, nil)
316+ ci := newCIUnit(t, nil).ci
317+ ci.SetAutoMergeTrigger(a)
318+ gate := make(chan struct{})
319+ entered := make(chan struct{})
320+ r.mrs.SetAfterMergeGate(func() { close(entered); <-gate })
321+ defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }()
322+ require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() {
323+ a.Start()
324+ done := make(chan struct{})
325+ go func() {
326+ ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) })
327+ close(done)
328+ }()
329+ select {
330+ case <-done:
331+ case <-time.After(5 * time.Second):
332+ t.Fatal("kick blocked behind merge lock")
333+ }
334+ }))
335+ select {
336+ case <-entered:
337+ case <-time.After(5 * time.Second):
338+ t.Fatal("merge never reached post-merge gate")
339+ }
340+}
341+
342+func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) {
343+ for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} {
344+ r, mr, checks := autoRig(t)
345+ enableAuto(t, r, mr)
346+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil)
347+ a := autoEvaluator(r, checks, nil, nil)
348+ require.True(t, a.Evaluate(r.repo))
349+ a.Close(r.ctx)
350+ assert.True(t, storedAuto(t, r, mr).IsOpen())
351+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
352+ }
353+}
@@ -0,0 +1,353 @@
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, Bus: r.bus, 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{})
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{})
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().OnRepoChanged(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})
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, mr.Source, r.head()).Return(false).Once()
125+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number, mr.Source, r.head()).Return(true).Once()
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+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
142+ a := autoEvaluator(r, checks, nil, nil)
143+ defer a.Close(r.ctx)
144+ r.drain()
145+ require.True(t, a.Evaluate(r.repo))
146+ got := storedAuto(t, r, mr)
147+ assert.Equal(t, domain.MRStateMerged, got.State)
148+ assert.True(t, got.AutoMerged)
149+ assert.Nil(t, got.AutoMerge)
150+ events := r.drain()
151+ found := false
152+ for _, ev := range events {
153+ if merged, ok := ev.(domain.MergeRequestMerged); ok {
154+ assert.True(t, merged.Auto)
155+ found = true
156+ }
157+ }
158+ require.True(t, found)
159+}
160+
161+func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
162+ r, mr, checks := autoRig(t)
163+ enableAuto(t, r, mr)
164+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
165+ require.NoError(t, err)
166+ // No successful check: an unsuccessful head waits rather than merging.
167+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CIFailure}}, nil)
168+ a := autoEvaluator(r, checks, nil, nil)
169+ defer a.Close(r.ctx)
170+ require.True(t, a.Evaluate(r.repo))
171+ assert.True(t, storedAuto(t, r, mr).IsOpen())
172+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
173+}
174+
175+func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") }
176+func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
177+func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") }
178+func testAutoCancellation(t *testing.T, change string) {
179+ r, mr, checks := autoRig(t)
180+ enableAuto(t, r, mr)
181+ switch change {
182+ case "head":
183+ r.commit(author, "new", "new", "new head")
184+ r.push("feat")
185+ case "mode":
186+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
187+ require.NoError(t, err)
188+ case "setting":
189+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
190+ require.NoError(t, err)
191+ }
192+ a := autoEvaluator(r, checks, nil, nil)
193+ defer a.Close(r.ctx)
194+ r.drain()
195+ require.True(t, a.Evaluate(r.repo))
196+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
197+ events := r.drain()
198+ require.Len(t, events, 1)
199+ assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0])
200+}
201+
202+func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
203+ for _, expired := range []bool{false, true} {
204+ t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) {
205+ r, mr, checks := autoRig(t)
206+ enableAuto(t, r, mr)
207+ enabled := storedAuto(t, r, mr)
208+ enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
209+ require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
210+ tokens := mocks.NewTokenStore(t)
211+ if expired {
212+ tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
213+ } else {
214+ tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound)
215+ }
216+ a := autoEvaluator(r, checks, nil, tokens)
217+ defer a.Close(r.ctx)
218+ require.True(t, a.Evaluate(r.repo))
219+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
220+ assert.True(t, storedAuto(t, r, mr).IsOpen())
221+ })
222+ }
223+}
224+
225+func TestAutoMergeCancelsOnPermanentMergeFailure(t *testing.T) {
226+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
227+ t.Run(mode.String(), func(t *testing.T) {
228+ r, mr, checks := autoRig(t)
229+ _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode})
230+ require.NoError(t, err)
231+ head := r.head()
232+ r.sh("checkout", "main")
233+ r.commit(author, "a.txt", "conflicting target\n", "move target")
234+ r.push("main")
235+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
236+ a := autoEvaluator(r, checks, nil, nil)
237+ defer a.Close(r.ctx)
238+ require.True(t, a.Evaluate(r.repo))
239+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
240+ assert.True(t, storedAuto(t, r, mr).IsOpen())
241+ })
242+ }
243+}
244+
245+func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) {
246+ for _, closeMR := range []bool{false, true} {
247+ t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) {
248+ r, mr, checks := autoRig(t)
249+ enabled := enableAuto(t, r, mr)
250+ if closeMR {
251+ _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
252+ require.NoError(t, err)
253+ } else {
254+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil)
255+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID})
256+ require.NoError(t, err)
257+ }
258+ got := storedAuto(t, r, mr)
259+ assert.Nil(t, got.AutoMerge)
260+ assert.False(t, got.AutoMerged)
261+ })
262+ }
263+}
264+
265+func TestAutoMergeStartupRescan(t *testing.T) {
266+ r, mr, checks := autoRig(t)
267+ enableAuto(t, r, mr)
268+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
269+ a := autoEvaluator(r, checks, nil, nil)
270+ a.Start()
271+ defer a.Close(r.ctx)
272+ require.Eventually(t, func() bool {
273+ got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
274+ return err == nil && got.AutoMerged
275+ }, 5*time.Second, time.Millisecond)
276+ a.Close(r.ctx)
277+ r.mrs.Wait(r.ctx)
278+}
279+
280+func TestAutoMergeRetriesTransientErrors(t *testing.T) {
281+ ctx := context.Background()
282+ store := mocks.NewMergeRequestStore(t)
283+ meta := mocks.NewRepoMetaStore(t)
284+ clock := mocks.NewClock(t)
285+ clock.EXPECT().Now().Return(now).Maybe()
286+ repo := name(t, "proj")
287+ store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil)
288+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once()
289+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
290+ done := make(chan struct{}, 1)
291+ store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once()
292+ timer := make(chan time.Time, 1)
293+ armed := make(chan time.Duration, 1)
294+ a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }})
295+ a.Start()
296+ defer a.Close(ctx)
297+ select {
298+ case d := <-armed:
299+ assert.Equal(t, time.Second, d)
300+ case <-time.After(5 * time.Second):
301+ t.Fatal("no retry scheduled")
302+ }
303+ timer <- now.Add(time.Second)
304+ select {
305+ case <-done:
306+ case <-time.After(5 * time.Second):
307+ t.Fatal("retry did not evaluate")
308+ }
309+}
310+
311+func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) {
312+ r, mr, checks := autoRig(t)
313+ enableAuto(t, r, mr)
314+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
315+ a := autoEvaluator(r, checks, nil, nil)
316+ ci := newCIUnit(t, nil).ci
317+ ci.SetAutoMergeTrigger(a)
318+ gate := make(chan struct{})
319+ entered := make(chan struct{})
320+ r.mrs.SetAfterMergeGate(func() { close(entered); <-gate })
321+ defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }()
322+ require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() {
323+ a.Start()
324+ done := make(chan struct{})
325+ go func() {
326+ ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) })
327+ close(done)
328+ }()
329+ select {
330+ case <-done:
331+ case <-time.After(5 * time.Second):
332+ t.Fatal("kick blocked behind merge lock")
333+ }
334+ }))
335+ select {
336+ case <-entered:
337+ case <-time.After(5 * time.Second):
338+ t.Fatal("merge never reached post-merge gate")
339+ }
340+}
341+
342+func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) {
343+ for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} {
344+ r, mr, checks := autoRig(t)
345+ enableAuto(t, r, mr)
346+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil)
347+ a := autoEvaluator(r, checks, nil, nil)
348+ require.True(t, a.Evaluate(r.repo))
349+ a.Close(r.ctx)
350+ assert.True(t, storedAuto(t, r, mr).IsOpen())
351+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
352+ }
353+}
M internal/usecase/ci.go +52 -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{},
@@ -560,6 +565,7 @@ func (rs *runState) flush(ctx context.Context) error {
560565 }
561566 if completing {
562567 c.onRunEvent(ctx, rs.run, runCompleted)
568+ c.kickAutoMerge(ctx, rs.run.Repo)
563569 }
564570 return nil
565571 }
@@ -740,3 +746,47 @@ func isHex40(s string) bool {
740746 }
741747 return strings.Trim(s, "0123456789abcdef") == ""
742748 }
749+
750+// SetAutoMergeTrigger installs the evaluator after constructing the merge use cases.
751+func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) {
752+ c.mu.Lock()
753+ c.autoMerge = t
754+ c.mu.Unlock()
755+}
756+
757+func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) {
758+ c.mu.Lock()
759+ t := c.autoMerge
760+ c.mu.Unlock()
761+ if t != nil {
762+ t.OnRepoChanged(ctx, repo)
763+ }
764+}
765+
766+// Settled reports whether all push planning and pull activities have finished.
767+// Workflow-run events, schedules and dispatches are intentionally not counted.
768+// Source and head are reserved for finer-grained accounting; repo-wide pushes
769+// conservatively delay requests even when an unrelated branch is updated.
770+func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64, _ string, _ domain.ObjectID) bool {
771+ c.mu.Lock()
772+ defer c.mu.Unlock()
773+ return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0
774+}
775+
776+func (c *CI) finishPush(repo domain.RepoName) {
777+ c.mu.Lock()
778+ c.pushes[repo]--
779+ if c.pushes[repo] == 0 {
780+ delete(c.pushes, repo)
781+ }
782+ c.mu.Unlock()
783+}
784+
785+func (c *CI) finishPull(key pullKey) {
786+ c.mu.Lock()
787+ c.pulls[key]--
788+ if c.pulls[key] == 0 {
789+ delete(c.pulls, key)
790+ }
791+ c.mu.Unlock()
792+}
@@ -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{},
@@ -560,6 +565,7 @@ func (rs *runState) flush(ctx context.Context) error {
560 }565 }
561 if completing {566 if completing {
562 c.onRunEvent(ctx, rs.run, runCompleted)567 c.onRunEvent(ctx, rs.run, runCompleted)
568+ c.kickAutoMerge(ctx, rs.run.Repo)
563 }569 }
564 return nil570 return nil
565 }571 }
@@ -740,3 +746,47 @@ func isHex40(s string) bool {
740 }746 }
741 return strings.Trim(s, "0123456789abcdef") == ""747 return strings.Trim(s, "0123456789abcdef") == ""
742 }748 }
749+
750+// SetAutoMergeTrigger installs the evaluator after constructing the merge use cases.
751+func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) {
752+ c.mu.Lock()
753+ c.autoMerge = t
754+ c.mu.Unlock()
755+}
756+
757+func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) {
758+ c.mu.Lock()
759+ t := c.autoMerge
760+ c.mu.Unlock()
761+ if t != nil {
762+ t.OnRepoChanged(ctx, repo)
763+ }
764+}
765+
766+// Settled reports whether all push planning and pull activities have finished.
767+// Workflow-run events, schedules and dispatches are intentionally not counted.
768+// Source and head are reserved for finer-grained accounting; repo-wide pushes
769+// conservatively delay requests even when an unrelated branch is updated.
770+func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64, _ string, _ domain.ObjectID) bool {
771+ c.mu.Lock()
772+ defer c.mu.Unlock()
773+ return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0
774+}
775+
776+func (c *CI) finishPush(repo domain.RepoName) {
777+ c.mu.Lock()
778+ c.pushes[repo]--
779+ if c.pushes[repo] == 0 {
780+ delete(c.pushes, repo)
781+ }
782+ c.mu.Unlock()
783+}
784+
785+func (c *CI) finishPull(key pullKey) {
786+ c.mu.Lock()
787+ c.pulls[key]--
788+ if c.pulls[key] == 0 {
789+ delete(c.pulls, key)
790+ }
791+ c.mu.Unlock()
792+}
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 +37 -0
@@ -0,0 +1,37 @@
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().Twice()
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, "feat", domain.ObjectID{}) }
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+}
@@ -0,0 +1,37 @@
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().Twice()
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, "feat", domain.ObjectID{}) }
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+}
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 +5 -0
@@ -101,6 +101,9 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
101101 t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
102102 c.mu.Lock()
103103 closed := c.closed
104+ if !closed {
105+ c.pushes[repo]++
106+ }
104107 c.mu.Unlock()
105108 if closed {
106109 return
@@ -108,6 +111,7 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
108111 select {
109112 case c.queue <- t:
110113 default:
114+ c.finishPush(repo)
111115 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")
112116 }
113117 }
@@ -127,6 +131,7 @@ func (c *CI) triggerLoop() {
127131 // planPush plans one push; a panic or an error is logged and never escapes.
128132 func (c *CI) planPush(t ciTrigger) {
129133 ctx := zerowrap.WithCtx(c.ctx, t.log)
134+ defer func() { c.finishPush(t.repo); c.kickAutoMerge(ctx, t.repo) }()
130135 defer func() {
131136 if r := recover(); r != nil {
132137 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")
@@ -101,6 +101,9 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
101 t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}101 t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
102 c.mu.Lock()102 c.mu.Lock()
103 closed := c.closed103 closed := c.closed
104+ if !closed {
105+ c.pushes[repo]++
106+ }
104 c.mu.Unlock()107 c.mu.Unlock()
105 if closed {108 if closed {
106 return109 return
@@ -108,6 +111,7 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
108 select {111 select {
109 case c.queue <- t:112 case c.queue <- t:
110 default:113 default:
114+ c.finishPush(repo)
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")115 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 }116 }
113 }117 }
@@ -127,6 +131,7 @@ func (c *CI) triggerLoop() {
127 // planPush plans one push; a panic or an error is logged and never escapes.131 // planPush plans one push; a panic or an error is logged and never escapes.
128 func (c *CI) planPush(t ciTrigger) {132 func (c *CI) planPush(t ciTrigger) {
129 ctx := zerowrap.WithCtx(c.ctx, t.log)133 ctx := zerowrap.WithCtx(c.ctx, t.log)
134+ defer func() { c.finishPush(t.repo); c.kickAutoMerge(ctx, t.repo) }()
130 defer func() {135 defer func() {
131 if r := recover(); r != nil {136 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")137 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:
@@ -533,6 +537,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
533537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
534538 case domain.MergeRequestClosed:
535539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
540+ case domain.MergeRequestAutoMergeEnabled:
541+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
542+ case domain.MergeRequestAutoMergeCancelled:
543+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
536544 case domain.MergeRequestReady:
537545 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538546 case domain.CommentAdded:
@@ -657,6 +665,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
657665 func isItemEvent(ev domain.Event) bool {
658666 switch ev.(type) {
659667 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
668+ domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
660669 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661670 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
662671 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:
@@ -533,6 +537,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
533 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
534 case domain.MergeRequestClosed:538 case domain.MergeRequestClosed:
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)
540+ case domain.MergeRequestAutoMergeEnabled:
541+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
542+ case domain.MergeRequestAutoMergeCancelled:
543+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
536 case domain.MergeRequestReady:544 case domain.MergeRequestReady:
537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)545 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538 case domain.CommentAdded:546 case domain.CommentAdded:
@@ -657,6 +665,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
657 func isItemEvent(ev domain.Event) bool {665 func isItemEvent(ev domain.Event) bool {
658 switch ev.(type) {666 switch ev.(type) {
659 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,667 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
668+ domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
660 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,669 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
661 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:670 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
662 return true671 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/mergeability.go +16 -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 }
@@ -275,6 +276,9 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
275276 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276277 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277278 })
279+ if s.cfg.AutoMerge != nil {
280+ s.cfg.AutoMerge.OnRepoChanged(ctx, repo)
281+ }
278282 }
279283 return ok
280284 }
@@ -465,3 +469,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number
465469 m.Behind, m.BehindCapped = domain.BoundBehind(n)
466470 return m, nil
467471 }
472+
473+// WithAutoMerge installs the evaluator before Start.
474+func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability {
475+ s.cfg.AutoMerge = t
476+ return s
477+}
@@ -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 }
@@ -275,6 +276,9 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
275 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,276 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,277 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277 })278 })
279+ if s.cfg.AutoMerge != nil {
280+ s.cfg.AutoMerge.OnRepoChanged(ctx, repo)
281+ }
278 }282 }
279 return ok283 return ok
280 }284 }
@@ -465,3 +469,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number
465 m.Behind, m.BehindCapped = domain.BoundBehind(n)469 m.Behind, m.BehindCapped = domain.BoundBehind(n)
466 return m, nil470 return m, nil
467 }471 }
472+
473+// WithAutoMerge installs the evaluator before Start.
474+func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability {
475+ s.cfg.AutoMerge = t
476+ return s
477+}
M internal/usecase/mergerequests.go +44 -18
@@ -58,7 +58,8 @@ type MergeRequests struct {
5858 tips ports.CIRefTipStore // nil: who merges is not recorded
5959 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
6060 mergeab MergeabilityHook // nil: no mergeability
61- mirror ports.MirrorTrigger // nil: no outbound mirror
61+ autoMerge ports.AutoMergeTrigger
62+ mirror ports.MirrorTrigger // nil: no outbound mirror
6263
6364 mu sync.Mutex
6465 locks map[domain.RepoName]chan struct{}
@@ -899,6 +900,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
899900 if err != nil {
900901 return domain.MergeRequest{}, err
901902 }
903+ hadAutoMerge := mr.AutoMerge != nil
904+ mr.AutoMerge = nil
902905 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
903906 err = m.store.Update(ctx, mr)
904907 if errors.Is(err, domain.ErrConflict) {
@@ -912,6 +915,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912915 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913916 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914917 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
918+ if hadAutoMerge {
919+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
920+ }
915921 if m.pulls != nil {
916922 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917923 }
@@ -989,7 +995,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
989995 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
990996 // only says that the deletion was requested.
991997 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
992- mr, err := m.merge(ctx, p, name, number, params)
998+ mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
993999 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
9941000 }
9951001
@@ -1031,7 +1037,14 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
10311037 return res.Tree, nil
10321038 }
10331039
1034-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
1040+type mergeOpts struct {
1041+ auto bool
1042+ intent *domain.AutoMerge
1043+}
1044+
1045+var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1046+
1047+func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
10351048 if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
10361049 return domain.MergeRequest{}, err
10371050 }
@@ -1062,6 +1075,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10621075 if err != nil {
10631076 return domain.MergeRequest{}, err
10641077 }
1078+ if opts.auto && (mr.AutoMerge == nil || opts.intent == nil || *mr.AutoMerge != *opts.intent) {
1079+ return domain.MergeRequest{}, errAutoMergeWait
1080+ }
10651081 // A merge moves the target and may delete the source: a token limited to
10661082 // branch prefixes may do so only within them.
10671083 if !p.AllowsRef("refs/heads/" + mr.Target) {
@@ -1117,11 +1133,30 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11171133 if err != nil {
11181134 return domain.MergeRequest{}, err
11191135 }
1120- if settings.FailingChecksBlock {
1136+ if opts.auto && !settings.AllowAutoMerge {
1137+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1138+ }
1139+ if settings.FailingChecksBlock || opts.auto {
11211140 if err := m.checksAllow(ctx, name, head); err != nil {
11221141 return domain.MergeRequest{}, err
11231142 }
11241143 }
1144+ if opts.auto {
1145+ checks, err := m.checks.CommitChecks(ctx, name, head.String())
1146+ if err != nil {
1147+ return domain.MergeRequest{}, err
1148+ }
1149+ passed := false
1150+ for _, check := range checks {
1151+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1152+ passed = true
1153+ break
1154+ }
1155+ }
1156+ if !passed {
1157+ return domain.MergeRequest{}, errAutoMergeWait
1158+ }
1159+ }
11251160 if params.DeleteSource {
11261161 if err := m.checkDeletable(ctx, name, mr); err != nil {
11271162 return domain.MergeRequest{}, err
@@ -1133,7 +1168,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11331168 return domain.MergeRequest{}, err
11341169 }
11351170 m.recordPush(ctx, name) // the target moved, whatever happens next
1136- mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
1171+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto)
11371172 if err != nil {
11381173 return domain.MergeRequest{}, err
11391174 }
@@ -1175,7 +1210,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
11751210 }
11761211 at := mr.UpdatedAt
11771212 recordActivity(ctx, m.meta, repo.Name, at)
1178- m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1213+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
11791214 m.bus.Publish(ctx, domain.PushEvent{
11801215 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
11811216 })
@@ -1273,7 +1308,7 @@ func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, h
12731308 case len(failing) > 0:
12741309 return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
12751310 case len(pending) > 0:
1276- return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
1311+ return fmt.Errorf("%w: %w: checks have not finished: %s", domain.ErrPreconditionFailed, errAutoMergeWait, strings.Join(pending, ", "))
12771312 }
12781313 return nil
12791314 }
@@ -1365,7 +1400,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
13651400
13661401 // markMerged records the merge. The target already moved, so a write that
13671402 // loses a race (a comment added meanwhile) is repeated on the fresh request.
1368-func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {
1403+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) {
13691404 // The merge must be recorded even when the caller gives up.
13701405 ctx = context.WithoutCancel(ctx)
13711406 for range maxWriteRetries {
@@ -1373,6 +1408,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
13731408 if err != nil {
13741409 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
13751410 }
1411+ mr.AutoMerge, mr.AutoMerged = nil, auto
13761412 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
13771413 mr.UpdatedAt = m.clock.Now().UTC()
13781414 err = m.store.Update(ctx, mr)
@@ -1520,13 +1556,3 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep
15201556 }
15211557 return ports.PushSummary{Count: len(commits), More: more}, nil
15221558 }
1523-
1524-// EnableAutoMerge is not implemented yet.
1525-func (m *MergeRequests) EnableAutoMerge(_ context.Context, _ domain.Principal, _ domain.RepoName, _ int64, _ ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
1526- return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not implemented yet", domain.ErrPreconditionFailed)
1527-}
1528-
1529-// CancelAutoMerge is not implemented yet.
1530-func (m *MergeRequests) CancelAutoMerge(_ context.Context, _ domain.Principal, _ domain.RepoName, _ int64) (domain.MergeRequest, error) {
1531- return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not implemented yet", domain.ErrPreconditionFailed)
1532-}
@@ -58,7 +58,8 @@ type MergeRequests struct {
58 tips ports.CIRefTipStore // nil: who merges is not recorded58 tips ports.CIRefTipStore // nil: who merges is not recorded
59 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)59 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
60 mergeab MergeabilityHook // nil: no mergeability60 mergeab MergeabilityHook // nil: no mergeability
61- mirror ports.MirrorTrigger // nil: no outbound mirror61+ autoMerge ports.AutoMergeTrigger
62+ mirror ports.MirrorTrigger // nil: no outbound mirror
62 63
63 mu sync.Mutex64 mu sync.Mutex
64 locks map[domain.RepoName]chan struct{}65 locks map[domain.RepoName]chan struct{}
@@ -899,6 +900,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
899 if err != nil {900 if err != nil {
900 return domain.MergeRequest{}, err901 return domain.MergeRequest{}, err
901 }902 }
903+ hadAutoMerge := mr.AutoMerge != nil
904+ mr.AutoMerge = nil
902 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()905 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
903 err = m.store.Update(ctx, mr)906 err = m.store.Update(ctx, mr)
904 if errors.Is(err, domain.ErrConflict) {907 if errors.Is(err, domain.ErrConflict) {
@@ -912,6 +915,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
912 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")915 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
913 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)916 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
914 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})917 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
918+ if hadAutoMerge {
919+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
920+ }
915 if m.pulls != nil {921 if m.pulls != nil {
916 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)922 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
917 }923 }
@@ -989,7 +995,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
989 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore995 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
990 // only says that the deletion was requested.996 // only says that the deletion was requested.
991 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {997 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
992- mr, err := m.merge(ctx, p, name, number, params)998+ mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
993 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err999 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
994 }1000 }
995 1001
@@ -1031,7 +1037,14 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
1031 return res.Tree, nil1037 return res.Tree, nil
1032 }1038 }
1033 1039
1034-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {1040+type mergeOpts struct {
1041+ auto bool
1042+ intent *domain.AutoMerge
1043+}
1044+
1045+var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1046+
1047+func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1035 if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {1048 if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1036 return domain.MergeRequest{}, err1049 return domain.MergeRequest{}, err
1037 }1050 }
@@ -1062,6 +1075,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1062 if err != nil {1075 if err != nil {
1063 return domain.MergeRequest{}, err1076 return domain.MergeRequest{}, err
1064 }1077 }
1078+ if opts.auto && (mr.AutoMerge == nil || opts.intent == nil || *mr.AutoMerge != *opts.intent) {
1079+ return domain.MergeRequest{}, errAutoMergeWait
1080+ }
1065 // A merge moves the target and may delete the source: a token limited to1081 // A merge moves the target and may delete the source: a token limited to
1066 // branch prefixes may do so only within them.1082 // branch prefixes may do so only within them.
1067 if !p.AllowsRef("refs/heads/" + mr.Target) {1083 if !p.AllowsRef("refs/heads/" + mr.Target) {
@@ -1117,11 +1133,30 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1117 if err != nil {1133 if err != nil {
1118 return domain.MergeRequest{}, err1134 return domain.MergeRequest{}, err
1119 }1135 }
1120- if settings.FailingChecksBlock {1136+ if opts.auto && !settings.AllowAutoMerge {
1137+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1138+ }
1139+ if settings.FailingChecksBlock || opts.auto {
1121 if err := m.checksAllow(ctx, name, head); err != nil {1140 if err := m.checksAllow(ctx, name, head); err != nil {
1122 return domain.MergeRequest{}, err1141 return domain.MergeRequest{}, err
1123 }1142 }
1124 }1143 }
1144+ if opts.auto {
1145+ checks, err := m.checks.CommitChecks(ctx, name, head.String())
1146+ if err != nil {
1147+ return domain.MergeRequest{}, err
1148+ }
1149+ passed := false
1150+ for _, check := range checks {
1151+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1152+ passed = true
1153+ break
1154+ }
1155+ }
1156+ if !passed {
1157+ return domain.MergeRequest{}, errAutoMergeWait
1158+ }
1159+ }
1125 if params.DeleteSource {1160 if params.DeleteSource {
1126 if err := m.checkDeletable(ctx, name, mr); err != nil {1161 if err := m.checkDeletable(ctx, name, mr); err != nil {
1127 return domain.MergeRequest{}, err1162 return domain.MergeRequest{}, err
@@ -1133,7 +1168,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1133 return domain.MergeRequest{}, err1168 return domain.MergeRequest{}, err
1134 }1169 }
1135 m.recordPush(ctx, name) // the target moved, whatever happens next1170 m.recordPush(ctx, name) // the target moved, whatever happens next
1136- mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)1171+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto)
1137 if err != nil {1172 if err != nil {
1138 return domain.MergeRequest{}, err1173 return domain.MergeRequest{}, err
1139 }1174 }
@@ -1175,7 +1210,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
1175 }1210 }
1176 at := mr.UpdatedAt1211 at := mr.UpdatedAt
1177 recordActivity(ctx, m.meta, repo.Name, at)1212 recordActivity(ctx, m.meta, repo.Name, at)
1178- m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1213+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1179 m.bus.Publish(ctx, domain.PushEvent{1214 m.bus.Publish(ctx, domain.PushEvent{
1180 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1215 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1181 })1216 })
@@ -1273,7 +1308,7 @@ func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, h
1273 case len(failing) > 0:1308 case len(failing) > 0:
1274 return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))1309 return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1275 case len(pending) > 0:1310 case len(pending) > 0:
1276- return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))1311+ return fmt.Errorf("%w: %w: checks have not finished: %s", domain.ErrPreconditionFailed, errAutoMergeWait, strings.Join(pending, ", "))
1277 }1312 }
1278 return nil1313 return nil
1279 }1314 }
@@ -1365,7 +1400,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
1365 1400
1366 // markMerged records the merge. The target already moved, so a write that1401 // markMerged records the merge. The target already moved, so a write that
1367 // loses a race (a comment added meanwhile) is repeated on the fresh request.1402 // loses a race (a comment added meanwhile) is repeated on the fresh request.
1368-func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {1403+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) {
1369 // The merge must be recorded even when the caller gives up.1404 // The merge must be recorded even when the caller gives up.
1370 ctx = context.WithoutCancel(ctx)1405 ctx = context.WithoutCancel(ctx)
1371 for range maxWriteRetries {1406 for range maxWriteRetries {
@@ -1373,6 +1408,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
1373 if err != nil {1408 if err != nil {
1374 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)1409 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1375 }1410 }
1411+ mr.AutoMerge, mr.AutoMerged = nil, auto
1376 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode1412 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
1377 mr.UpdatedAt = m.clock.Now().UTC()1413 mr.UpdatedAt = m.clock.Now().UTC()
1378 err = m.store.Update(ctx, mr)1414 err = m.store.Update(ctx, mr)
@@ -1520,13 +1556,3 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep
1520 }1556 }
1521 return ports.PushSummary{Count: len(commits), More: more}, nil1557 return ports.PushSummary{Count: len(commits), More: more}, nil
1522 }1558 }
1523-
1524-// EnableAutoMerge is not implemented yet.
1525-func (m *MergeRequests) EnableAutoMerge(_ context.Context, _ domain.Principal, _ domain.RepoName, _ int64, _ ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
1526- return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not implemented yet", domain.ErrPreconditionFailed)
1527-}
1528-
1529-// CancelAutoMerge is not implemented yet.
1530-func (m *MergeRequests) CancelAutoMerge(_ context.Context, _ domain.Principal, _ domain.RepoName, _ int64) (domain.MergeRequest, error) {
1531- return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not implemented yet", domain.ErrPreconditionFailed)
1532-}
A internal/usecase/mergerequests_auto.go +157 -0
@@ -0,0 +1,157 @@
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 current head; re-enabling replaces the
21+// previous intent rather than preserving an older actor's authority.
22+func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
23+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
24+ return domain.MergeRequest{}, err
25+ }
26+ if m.checks == nil {
27+ return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed)
28+ }
29+ mode := params.Mode
30+ if mode == "" {
31+ mode = domain.MergeModeFastForward
32+ }
33+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
34+ return domain.MergeRequest{}, err
35+ }
36+ unlock, err := m.lockRepo(ctx, name)
37+ if err != nil {
38+ return domain.MergeRequest{}, err
39+ }
40+ defer unlock()
41+ for range maxWriteRetries {
42+ mr, err := m.store.Get(ctx, name, number)
43+ if err != nil {
44+ return domain.MergeRequest{}, err
45+ }
46+ if !mr.IsOpen() {
47+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
48+ }
49+ if mr.IsDraft() {
50+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
51+ }
52+ if !p.AllowsRef("refs/heads/" + mr.Target) {
53+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
54+ }
55+ repo, err := m.meta.Get(ctx, name)
56+ if err != nil {
57+ return domain.MergeRequest{}, err
58+ }
59+ settings := repo.EffectiveSettings()
60+ if !settings.AllowAutoMerge {
61+ 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)
62+ }
63+ if !settings.Allows(mode) {
64+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
65+ }
66+ head, err := m.branchOrZero(ctx, name, mr.Source)
67+ if err != nil {
68+ return domain.MergeRequest{}, err
69+ }
70+ if head.IsZero() {
71+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
72+ }
73+ mr.UpdatedAt = m.clock.Now().UTC()
74+ mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt}
75+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
76+ continue
77+ } else if err != nil {
78+ return domain.MergeRequest{}, err
79+ }
80+ recordActivity(ctx, m.meta, name, mr.UpdatedAt)
81+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
82+ if m.autoMerge != nil {
83+ m.autoMerge.OnRepoChanged(ctx, name)
84+ }
85+ return m.withHead(ctx, mr)
86+ }
87+ return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict)
88+}
89+
90+// CancelAutoMerge clears an open request's intent with the same target access
91+// required to enable it.
92+func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
93+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
94+ return domain.MergeRequest{}, err
95+ }
96+ unlock, err := m.lockRepo(ctx, name)
97+ if err != nil {
98+ return domain.MergeRequest{}, err
99+ }
100+ defer unlock()
101+ mr, err := m.store.Get(ctx, name, number)
102+ if err != nil {
103+ return domain.MergeRequest{}, err
104+ }
105+ if !mr.IsOpen() {
106+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
107+ }
108+ if !p.AllowsRef("refs/heads/" + mr.Target) {
109+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
110+ }
111+ if mr.AutoMerge == nil {
112+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed)
113+ }
114+ mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p))
115+ if err != nil {
116+ return domain.MergeRequest{}, err
117+ }
118+ return m.withHead(ctx, mr)
119+}
120+
121+// cancelAuto takes the merge lock and only cancels the intent evaluated by the
122+// caller. A concurrent re-enable, close or merge must not be undone.
123+func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error {
124+ unlock, err := m.lockRepo(ctx, mr.Repo)
125+ if err != nil {
126+ return err
127+ }
128+ defer unlock()
129+ _, err = m.cancelAutoLocked(ctx, mr, reason)
130+ return err
131+}
132+
133+func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) {
134+ for range maxWriteRetries {
135+ mr, err := m.store.Get(ctx, expected.Repo, expected.Number)
136+ if err != nil {
137+ return domain.MergeRequest{}, err
138+ }
139+ if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge {
140+ return mr, nil
141+ }
142+ repo, err := m.meta.Get(ctx, mr.Repo)
143+ if err != nil {
144+ return domain.MergeRequest{}, err
145+ }
146+ mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC()
147+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
148+ continue
149+ } else if err != nil {
150+ return domain.MergeRequest{}, err
151+ }
152+ recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt)
153+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
154+ return mr, nil
155+ }
156+ return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict)
157+}
@@ -0,0 +1,157 @@
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 current head; re-enabling replaces the
21+// previous intent rather than preserving an older actor's authority.
22+func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
23+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
24+ return domain.MergeRequest{}, err
25+ }
26+ if m.checks == nil {
27+ return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed)
28+ }
29+ mode := params.Mode
30+ if mode == "" {
31+ mode = domain.MergeModeFastForward
32+ }
33+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
34+ return domain.MergeRequest{}, err
35+ }
36+ unlock, err := m.lockRepo(ctx, name)
37+ if err != nil {
38+ return domain.MergeRequest{}, err
39+ }
40+ defer unlock()
41+ for range maxWriteRetries {
42+ mr, err := m.store.Get(ctx, name, number)
43+ if err != nil {
44+ return domain.MergeRequest{}, err
45+ }
46+ if !mr.IsOpen() {
47+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
48+ }
49+ if mr.IsDraft() {
50+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
51+ }
52+ if !p.AllowsRef("refs/heads/" + mr.Target) {
53+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
54+ }
55+ repo, err := m.meta.Get(ctx, name)
56+ if err != nil {
57+ return domain.MergeRequest{}, err
58+ }
59+ settings := repo.EffectiveSettings()
60+ if !settings.AllowAutoMerge {
61+ 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)
62+ }
63+ if !settings.Allows(mode) {
64+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
65+ }
66+ head, err := m.branchOrZero(ctx, name, mr.Source)
67+ if err != nil {
68+ return domain.MergeRequest{}, err
69+ }
70+ if head.IsZero() {
71+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
72+ }
73+ mr.UpdatedAt = m.clock.Now().UTC()
74+ mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt}
75+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
76+ continue
77+ } else if err != nil {
78+ return domain.MergeRequest{}, err
79+ }
80+ recordActivity(ctx, m.meta, name, mr.UpdatedAt)
81+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
82+ if m.autoMerge != nil {
83+ m.autoMerge.OnRepoChanged(ctx, name)
84+ }
85+ return m.withHead(ctx, mr)
86+ }
87+ return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict)
88+}
89+
90+// CancelAutoMerge clears an open request's intent with the same target access
91+// required to enable it.
92+func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
93+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
94+ return domain.MergeRequest{}, err
95+ }
96+ unlock, err := m.lockRepo(ctx, name)
97+ if err != nil {
98+ return domain.MergeRequest{}, err
99+ }
100+ defer unlock()
101+ mr, err := m.store.Get(ctx, name, number)
102+ if err != nil {
103+ return domain.MergeRequest{}, err
104+ }
105+ if !mr.IsOpen() {
106+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
107+ }
108+ if !p.AllowsRef("refs/heads/" + mr.Target) {
109+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
110+ }
111+ if mr.AutoMerge == nil {
112+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed)
113+ }
114+ mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p))
115+ if err != nil {
116+ return domain.MergeRequest{}, err
117+ }
118+ return m.withHead(ctx, mr)
119+}
120+
121+// cancelAuto takes the merge lock and only cancels the intent evaluated by the
122+// caller. A concurrent re-enable, close or merge must not be undone.
123+func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error {
124+ unlock, err := m.lockRepo(ctx, mr.Repo)
125+ if err != nil {
126+ return err
127+ }
128+ defer unlock()
129+ _, err = m.cancelAutoLocked(ctx, mr, reason)
130+ return err
131+}
132+
133+func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) {
134+ for range maxWriteRetries {
135+ mr, err := m.store.Get(ctx, expected.Repo, expected.Number)
136+ if err != nil {
137+ return domain.MergeRequest{}, err
138+ }
139+ if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge {
140+ return mr, nil
141+ }
142+ repo, err := m.meta.Get(ctx, mr.Repo)
143+ if err != nil {
144+ return domain.MergeRequest{}, err
145+ }
146+ mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC()
147+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
148+ continue
149+ } else if err != nil {
150+ return domain.MergeRequest{}, err
151+ }
152+ recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt)
153+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
154+ return mr, nil
155+ }
156+ return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict)
157+}
M internal/usecase/repos.go +10 -6
@@ -17,12 +17,13 @@ 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+ mirror ports.MirrorTrigger // nil: no outbound mirror
2627 }
2728
2829 var _ ports.RepoService = (*Repos)(nil)
@@ -233,3 +234,6 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
233234 }
234235 return rep, nil
235236 }
237+
238+// WithAutoMerge installs the settings-change trigger before serving requests.
239+func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r }
@@ -17,12 +17,13 @@ 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+ mirror ports.MirrorTrigger // nil: no outbound mirror
26 }27 }
27 28
28 var _ ports.RepoService = (*Repos)(nil)29 var _ ports.RepoService = (*Repos)(nil)
@@ -233,3 +234,6 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
233 }234 }
234 return rep, nil235 return rep, nil
235 }236 }
237+
238+// WithAutoMerge installs the settings-change trigger before serving requests.
239+func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r }
M internal/usecase/repos_write.go +9 -3
@@ -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,14 @@ 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 {
119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120120 if repo, err = r.updateMeta(ctx, name, params); err != nil {
121121 return domain.Repo{}, err
122122 }
123123 metaDone = true
124+ if r.autoMerge != nil {
125+ r.autoMerge.OnRepoChanged(ctx, name)
126+ }
124127 }
125128 var headErr error
126129 if params.DefaultBranch != nil {
@@ -142,7 +145,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
142145 Bool("topics", params.Topics != nil).
143146 Bool("visibility", params.Visibility != nil).
144147 Bool("default_branch", params.DefaultBranch != nil).
145- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
148+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
146149 Str("principal", p.Label()).Str("token_id", p.TokenID()).
147150 Msg("repository updated")
148151 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -175,6 +178,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
175178 if params.FailingChecksBlock != nil {
176179 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
177180 }
181+ if params.AllowAutoMerge != nil {
182+ repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
183+ }
178184 if params.Issues != nil {
179185 repo.Settings.Issues = *params.Issues
180186 }
@@ -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,14 @@ 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 if repo, err = r.updateMeta(ctx, name, params); err != nil {
121 return domain.Repo{}, err121 return domain.Repo{}, err
122 }122 }
123 metaDone = true123 metaDone = true
124+ if r.autoMerge != nil {
125+ r.autoMerge.OnRepoChanged(ctx, name)
126+ }
124 }127 }
125 var headErr error128 var headErr error
126 if params.DefaultBranch != nil {129 if params.DefaultBranch != nil {
@@ -142,7 +145,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
142 Bool("topics", params.Topics != nil).145 Bool("topics", params.Topics != nil).
143 Bool("visibility", params.Visibility != nil).146 Bool("visibility", params.Visibility != nil).
144 Bool("default_branch", params.DefaultBranch != nil).147 Bool("default_branch", params.DefaultBranch != nil).
145- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).148+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
146 Str("principal", p.Label()).Str("token_id", p.TokenID()).149 Str("principal", p.Label()).Str("token_id", p.TokenID()).
147 Msg("repository updated")150 Msg("repository updated")
148 r.bus.Publish(ctx, domain.RepoUpdated{151 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -175,6 +178,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
175 if params.FailingChecksBlock != nil {178 if params.FailingChecksBlock != nil {
176 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock179 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
177 }180 }
181+ if params.AllowAutoMerge != nil {
182+ repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
183+ }
178 if params.Issues != nil {184 if params.Issues != nil {
179 repo.Settings.Issues = *params.Issues185 repo.Settings.Issues = *params.Issues
180 }186 }
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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close