feat(app): wire the auto-merge evaluator
- commit
- ba47d48f493de1e968c8d4f999ef80ada1df264f
- author
- brice · · 2026-10-20 16:04 UTC
- parent
- 1314a0d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 143 | 143 | SigningKey *usecase.SigningKey | |
| 144 | 144 | // Mergeability keeps the mergeability of the open merge requests up to date. | |
| 145 | 145 | Mergeability *usecase.Mergeability | |
| 146 | + | AutoMerge *usecase.AutoMerge | |
| 146 | 147 | // Signatures are the trusted keys and the commit signature checks | |
| 147 | 148 | // (ADR 0030). | |
| 148 | 149 | Signatures *usecase.Signatures | |
| @@ -368,6 +369,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 368 | 369 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 369 | 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 | 380 | f.Mergeability.Start() | |
| 372 | 381 | f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors) | |
| 373 | 382 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | |
| @@ -391,6 +400,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 391 | 400 | Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(), | |
| 392 | 401 | }) | |
| 393 | 402 | f.CI.SetPullGate(f.MergeRequests) | |
| 403 | + | f.AutoMerge.Start() | |
| 394 | 404 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 395 | 405 | ||
| 396 | 406 | f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) | |
| @@ -543,6 +553,9 @@ func (f *forge) Close(ctx context.Context) { | |||
| 543 | 553 | if f.hooks != nil { | |
| 544 | 554 | f.hooks.Close(ctx) | |
| 545 | 555 | } | |
| 556 | + | if f.AutoMerge != nil { | |
| 557 | + | f.AutoMerge.Close(ctx) | |
| 558 | + | } | |
| 546 | 559 | if f.MergeRequests != nil { | |
| 547 | 560 | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | |
| 548 | 561 | } | |
| @@ -143,6 +143,7 @@ type forge struct { | |||||
| 143 | SigningKey *usecase.SigningKey | 143 | 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.Mergeability | 145 | Mergeability *usecase.Mergeability | ||
| 146 | + | AutoMerge *usecase.AutoMerge | |||
| 146 | // Signatures are the trusted keys and the commit signature checks | 147 | // Signatures are the trusted keys and the commit signature checks | ||
| 147 | // (ADR 0030). | 148 | // (ADR 0030). | ||
| 148 | Signatures *usecase.Signatures | 149 | 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 Mergeability | 560 | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | ||
| 548 | } | 561 | } | ||