detached ba47d48

feat(app): wire the auto-merge evaluator

commit
ba47d48f493de1e968c8d4f999ef80ada1df264f
author
brice · · 2026-10-20 16:04 UTC
parent
1314a0d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +82 -0
A internal/app/automerge_e2e_test.go +69 -0
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// The RPC enable/cancel and Watch conversions belong to Batch D. This test
17+// exercises the composition root through use cases and domain events instead.
18+func TestAutoMergeEndToEnd(t *testing.T) {
19+ c := newCIHarness(t, nil)
20+ runner := c.registerRunner("alpha", "ubuntu-latest")
21+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
22+on:
23+ push:
24+ branches: [feat]
25+ pull_request:
26+jobs:
27+ build:
28+ runs-on: ubuntu-latest
29+ steps:
30+ - run: echo hello
31+`}, "")
32+ head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
33+ number := c.openMR(c.root, "feat")
34+ repo := c.name("proj")
35+ allow := true
36+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), repo, ports.UpdateRepoParams{AllowAutoMerge: &allow})
37+ require.NoError(t, err)
38+ events := c.forge.Bus.Subscribe(c.ctx)
39+ enabled, err := c.forge.MergeRequests.EnableAutoMerge(c.ctx, domain.Owner(), repo, number, ports.EnableAutoMergeParams{})
40+ require.NoError(t, err)
41+ require.NotNil(t, enabled.AutoMerge)
42+ assert.Equal(t, head, enabled.AutoMerge.HeadID.String())
43+ select {
44+ case ev := <-events:
45+ require.IsType(t, domain.MergeRequestAutoMergeEnabled{}, ev)
46+ case <-time.After(5 * time.Second):
47+ t.Fatal("no enabled event")
48+ }
49+ c.finish(runner)
50+ // A successful push check alone must not merge while the pull run is queued.
51+ got, err := c.forge.MergeRequests.Get(c.ctx, domain.Root(), repo, number, ports.GetMergeRequestOptions{})
52+ require.NoError(t, err)
53+ assert.True(t, got.MergeRequest.IsOpen())
54+ c.finish(runner)
55+ deadline := time.After(10 * time.Second)
56+ for {
57+ select {
58+ case ev := <-events:
59+ if merged, ok := ev.(domain.MergeRequestMerged); ok && merged.MergeRequest.Number == number {
60+ assert.True(t, merged.Auto)
61+ assert.True(t, merged.MergeRequest.AutoMerged)
62+ assert.Nil(t, merged.MergeRequest.AutoMerge)
63+ return
64+ }
65+ case <-deadline:
66+ t.Fatal("no automatic merged event")
67+ }
68+ }
69+}
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// The RPC enable/cancel and Watch conversions belong to Batch D. This test
17+// exercises the composition root through use cases and domain events instead.
18+func TestAutoMergeEndToEnd(t *testing.T) {
19+ c := newCIHarness(t, nil)
20+ runner := c.registerRunner("alpha", "ubuntu-latest")
21+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
22+on:
23+ push:
24+ branches: [feat]
25+ pull_request:
26+jobs:
27+ build:
28+ runs-on: ubuntu-latest
29+ steps:
30+ - run: echo hello
31+`}, "")
32+ head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
33+ number := c.openMR(c.root, "feat")
34+ repo := c.name("proj")
35+ allow := true
36+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), repo, ports.UpdateRepoParams{AllowAutoMerge: &allow})
37+ require.NoError(t, err)
38+ events := c.forge.Bus.Subscribe(c.ctx)
39+ enabled, err := c.forge.MergeRequests.EnableAutoMerge(c.ctx, domain.Owner(), repo, number, ports.EnableAutoMergeParams{})
40+ require.NoError(t, err)
41+ require.NotNil(t, enabled.AutoMerge)
42+ assert.Equal(t, head, enabled.AutoMerge.HeadID.String())
43+ select {
44+ case ev := <-events:
45+ require.IsType(t, domain.MergeRequestAutoMergeEnabled{}, ev)
46+ case <-time.After(5 * time.Second):
47+ t.Fatal("no enabled event")
48+ }
49+ c.finish(runner)
50+ // A successful push check alone must not merge while the pull run is queued.
51+ got, err := c.forge.MergeRequests.Get(c.ctx, domain.Root(), repo, number, ports.GetMergeRequestOptions{})
52+ require.NoError(t, err)
53+ assert.True(t, got.MergeRequest.IsOpen())
54+ c.finish(runner)
55+ deadline := time.After(10 * time.Second)
56+ for {
57+ select {
58+ case ev := <-events:
59+ if merged, ok := ev.(domain.MergeRequestMerged); ok && merged.MergeRequest.Number == number {
60+ assert.True(t, merged.Auto)
61+ assert.True(t, merged.MergeRequest.AutoMerged)
62+ assert.Nil(t, merged.MergeRequest.AutoMerge)
63+ return
64+ }
65+ case <-deadline:
66+ t.Fatal("no automatic merged event")
67+ }
68+ }
69+}
M internal/app/forge.go +13 -0
@@ -143,6 +143,7 @@ type forge struct {
143143 SigningKey *usecase.SigningKey
144144 // Mergeability keeps the mergeability of the open merge requests up to date.
145145 Mergeability *usecase.Mergeability
146+ AutoMerge *usecase.AutoMerge
146147 // Signatures are the trusted keys and the commit signature checks
147148 // (ADR 0030).
148149 Signatures *usecase.Signatures
@@ -368,6 +369,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
368369 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369370 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370371 })
372+ f.AutoMerge = usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{
373+ MRs: f.MergeRequests, Store: store.MergeRequests(), Meta: store, Git: git,
374+ Checks: f.CI, Activity: f.CI, Tokens: store.Tokens(), Bus: f.Bus, Clock: clock,
375+ })
376+ f.MergeRequests.WithAutoMerge(f.AutoMerge)
377+ f.CI.SetAutoMergeTrigger(f.AutoMerge)
378+ f.Repos.WithAutoMerge(f.AutoMerge)
379+ f.Mergeability.WithAutoMerge(f.AutoMerge)
371380 f.Mergeability.Start()
372381 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373382 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
@@ -391,6 +400,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
391400 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
392401 })
393402 f.CI.SetPullGate(f.MergeRequests)
403+ f.AutoMerge.Start()
394404 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
395405
396406 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
@@ -543,6 +553,9 @@ func (f *forge) Close(ctx context.Context) {
543553 if f.hooks != nil {
544554 f.hooks.Close(ctx)
545555 }
556+ if f.AutoMerge != nil {
557+ f.AutoMerge.Close(ctx)
558+ }
546559 if f.MergeRequests != nil {
547560 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
548561 }
@@ -143,6 +143,7 @@ type forge struct {
143 SigningKey *usecase.SigningKey143 SigningKey *usecase.SigningKey
144 // Mergeability keeps the mergeability of the open merge requests up to date.144 // Mergeability keeps the mergeability of the open merge requests up to date.
145 Mergeability *usecase.Mergeability145 Mergeability *usecase.Mergeability
146+ AutoMerge *usecase.AutoMerge
146 // Signatures are the trusted keys and the commit signature checks147 // Signatures are the trusted keys and the commit signature checks
147 // (ADR 0030).148 // (ADR 0030).
148 Signatures *usecase.Signatures149 Signatures *usecase.Signatures
@@ -368,6 +369,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
368 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,369 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,370 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
370 })371 })
372+ f.AutoMerge = usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{
373+ MRs: f.MergeRequests, Store: store.MergeRequests(), Meta: store, Git: git,
374+ Checks: f.CI, Activity: f.CI, Tokens: store.Tokens(), Bus: f.Bus, Clock: clock,
375+ })
376+ f.MergeRequests.WithAutoMerge(f.AutoMerge)
377+ f.CI.SetAutoMergeTrigger(f.AutoMerge)
378+ f.Repos.WithAutoMerge(f.AutoMerge)
379+ f.Mergeability.WithAutoMerge(f.AutoMerge)
371 f.Mergeability.Start()380 f.Mergeability.Start()
372 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)381 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{382 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
@@ -391,6 +400,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
391 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),400 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
392 })401 })
393 f.CI.SetPullGate(f.MergeRequests)402 f.CI.SetPullGate(f.MergeRequests)
403+ f.AutoMerge.Start()
394 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")404 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
395 405
396 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)406 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
@@ -543,6 +553,9 @@ func (f *forge) Close(ctx context.Context) {
543 if f.hooks != nil {553 if f.hooks != nil {
544 f.hooks.Close(ctx)554 f.hooks.Close(ctx)
545 }555 }
556+ if f.AutoMerge != nil {
557+ f.AutoMerge.Close(ctx)
558+ }
546 if f.MergeRequests != nil {559 if f.MergeRequests != nil {
547 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability560 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
548 }561 }

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 43

↑↓ moveenter openesc close