detached fdd6d2b

Merge 6135dd51e565e014733437cd74457f6dfef45a53 into 4bec9a08f1d16de05320cc93a6cc759e32faa7a4

commit
fdd6d2b27397b8551c31f2567049c6a916bae6b3
author
ermite · · 2026-10-21 04:26 UTC
merge of
4bec9a0 + 6135dd5 · diff against the first parent
signature
unsigned
70 files · +3994 -492
M docs/cli.md +3 -2
@@ -48,7 +48,7 @@ It does not run:
4848
4949 ```sh
5050 ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51-ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--issues off|owner|public]
51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--allow-auto-merge] [--issues off|owner|public]
5252 ermite repo delete <name> [--yes]
5353
5454 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -57,7 +57,7 @@ ermite token list | revoke <id>
5757 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict
5858 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
5959 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
60-ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n>
60+ermite mr merge <repo> <n> --head <id> [--mode fast-forward|squash|merge] [--delete-branch] | merge <repo> <n> --head <id> --auto [--mode …] | merge <repo> <n> --cancel-auto | close <repo> <n>
6161
6262 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
6363 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
@@ -100,6 +100,7 @@ Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror]
100100 - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
101101 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
102102 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
103+- `mr merge --auto` makes the server merge once CI passed on the head (`repo edit --allow-auto-merge` first); `--cancel-auto` cancels it; both are exclusive with each other and `--auto` with `--delete-branch`. `watch --json` adds `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` (with `reason`), and `auto` (true) on `merge_request_merged` after an auto-merge. See [Merge requests](merge-requests.md#auto-merge).
103104 - Errors are one line on stderr (`ermite: …`) with exit status 1.
104105 - Shell completion completes repository names from the server.
105106
M docs/merge-requests.md +14 -0
@@ -41,6 +41,20 @@ git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commi
4141
4242 The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint.
4343
44+## Auto-merge
45+
46+A merge request can merge by itself once its checks passed.
47+
48+- The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
49+- Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
50+- The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.
51+- The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
52+- A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
53+- A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).
54+- A manual merge supersedes auto-merge and publishes `merge_request_merged` with `auto=false`, without a cancelled event. A manual close clears it and publishes `merge_request_closed` followed by `merge_request_auto_merge_cancelled` with reason `closed`.
55+- Cancellation reasons are `cancelled by <author>`, `closed`, `auto-merge was turned off in the repository settings`, `the <mode> merge mode is no longer allowed`, `the source branch was deleted`, `the branch moved to <short-head>`, and `the principal that enabled auto-merge can no longer merge <target>`. If a change wins the merge lock after the evaluator's first read, the merge-time reason is instead `auto-merge is off in <repo>`, `the <mode> merge mode is not allowed in <repo>`, `the source branch "<source>" no longer exists`, or `branch moved, read again: "<source>" is at <head>, not <recorded-head>`. Other permanent merge-time preconditions report `!<n> is <state>`, `!<n> is a draft: mark it ready for review first`, `the target branch "<target>" no longer exists`, `nothing to merge, "<target>" already is at <head>`, or `nothing to merge, "<target>" already contains <head>`.
56+- Cancel it with `ermite mr merge <repo> <n> --cancel-auto` or **Cancel auto-merge** in the web merge box.
57+
4458 ## Mergeability
4559
4660 The server checks in the background whether each open request merges with its target, and again whenever the source or the target moves. `mr show` prints it; the web merge box shows it as a live badge; `ermite watch` reports each change.
M gen/ermite/storage/merge_request.pb.go +163 -35 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.13.0
16+ bnema.dev/ermite-api v0.14.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client_test.go +3 -3
@@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147- client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
147+ client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, client.FeatureAutoMerge,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,
151- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures, client.FeatureAutoMerge,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154154 }
M internal/adapters/client/features.go +5 -1
@@ -57,11 +57,15 @@ const (
5757 // FeatureMergeability is MergeRequest.mergeability and the
5858 // merge_request_mergeability event.
5959 FeatureMergeability = "merge_requests.mergeability"
60+ // FeatureAutoMerge is MergeRequestService.EnableAutoMerge and
61+ // CancelAutoMerge, RepoSettings.allow_auto_merge and the auto-merge events
62+ // (`ermite mr merge --auto`).
63+ FeatureAutoMerge = "merge_requests.auto_merge"
6064 )
6165
6266 // UsedFeatures lists the features the CLI uses, in the order they are reported.
6367 func UsedFeatures() []string {
64- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
68+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures, FeatureAutoMerge}
6569 }
6670
6771 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/kvstore/mergerequests.go +32 -2
@@ -728,6 +728,18 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
728728 if err != nil {
729729 return nil, err
730730 }
731+ var autoMerge *storagepb.AutoMerge
732+ if am := mr.AutoMerge; am != nil {
733+ amMode, err := encodeMode(am.Mode)
734+ if err != nil || amMode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
735+ return nil, fmt.Errorf("%w: merge mode %q in the auto-merge", domain.ErrInvalid, am.Mode)
736+ }
737+ autoMerge = &storagepb.AutoMerge{
738+ Mode: amMode, HeadId: am.HeadID.String(),
739+ EnablerKind: string(am.EnabledBy.Kind), EnablerTokenId: am.EnabledBy.TokenID,
740+ EnablerLabel: am.EnablerLabel, EnabledAt: encodeTime(am.EnabledAt),
741+ }
742+ }
731743 return mustMarshal(&storagepb.MergeRequest{
732744 Number: mr.Number,
733745 Title: mr.Title.String(),
@@ -743,6 +755,8 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
743755 MergedCommitId: mr.MergedCommitID.String(),
744756 MergeMode: mode,
745757 CommentSeq: mr.CommentSeq,
758+ AutoMerge: autoMerge,
759+ AutoMerged: mr.AutoMerged,
746760 }), nil
747761 }
748762
@@ -773,8 +787,24 @@ func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest
773787 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
774788 MergeMode: decodeMode(pb.GetMergeMode()),
775789 CommentSeq: pb.GetCommentSeq(),
790+ AutoMerged: pb.GetAutoMerged(),
776791 Version: item.Version,
777792 }
793+ if am := pb.GetAutoMerge(); am != nil {
794+ head, err := domain.ParseObjectID(am.GetHeadId())
795+ if err != nil {
796+ return fail(err)
797+ }
798+ mode := decodeMode(am.GetMode())
799+ if mode == "" {
800+ return fail(fmt.Errorf("unknown auto-merge mode %d", am.GetMode()))
801+ }
802+ mr.AutoMerge = &domain.AutoMerge{
803+ Mode: mode, HeadID: head,
804+ EnabledBy: domain.CIPusher{Kind: domain.CIPusherKind(am.GetEnablerKind()), TokenID: am.GetEnablerTokenId()},
805+ EnablerLabel: am.GetEnablerLabel(), EnabledAt: decodeTime(am.GetEnabledAt()),
806+ }
807+ }
778808 for _, f := range []struct {
779809 dst *domain.ObjectID
780810 src string
@@ -909,7 +939,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
909939 }
910940
911941 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
912- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true}
942+ pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true, AllowAutoMerge: s.AllowAutoMerge}
913943 for _, m := range s.AllowedMergeModes {
914944 mode, err := encodeMode(m)
915945 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -949,5 +979,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
949979 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
950980 normalized = domain.MergeModes()
951981 }
952- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
982+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues()), AllowAutoMerge: pb.GetAllowAutoMerge()}
953983 }
M internal/adapters/kvstore/mergerequests_test.go +47 -0
@@ -94,6 +94,35 @@ func TestMergeRequestRoundTrip(t *testing.T) {
9494 assert.ErrorIs(t, err, domain.ErrNotFound)
9595 }
9696
97+func TestMergeRequestAutoMergeRoundTrip(t *testing.T) {
98+ ctx := context.Background()
99+ _, mrs, n := repoWithMRs(t)
100+
101+ set := newMR(t, n, "Auto")
102+ set.AutoMerge = &domain.AutoMerge{
103+ Mode: domain.MergeModeSquash, HeadID: oid('d'),
104+ EnabledBy: domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "tok1"},
105+ EnablerLabel: "ci-bot", EnabledAt: created,
106+ }
107+ created1, err := mrs.Create(ctx, set)
108+ require.NoError(t, err)
109+ got, err := mrs.Get(ctx, n, created1.Number)
110+ require.NoError(t, err)
111+ require.NotNil(t, got.AutoMerge)
112+ assert.Equal(t, *set.AutoMerge, *got.AutoMerge)
113+ assert.False(t, got.AutoMerged)
114+
115+ unset := newMR(t, n, "Merged by auto")
116+ unset.State = domain.MRStateMerged
117+ unset.AutoMerged = true
118+ created2, err := mrs.Create(ctx, unset)
119+ require.NoError(t, err)
120+ got, err = mrs.Get(ctx, n, created2.Number)
121+ require.NoError(t, err)
122+ assert.Nil(t, got.AutoMerge)
123+ assert.True(t, got.AutoMerged)
124+}
125+
97126 func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {
98127 ctx := context.Background()
99128 s, mrs, n := repoWithMRs(t)
@@ -453,6 +482,24 @@ func TestRepoSettings(t *testing.T) {
453482 assert.ErrorIs(t, err, domain.ErrInvalid)
454483 }
455484
485+func TestRepoSettingsKeepAllowAutoMerge(t *testing.T) {
486+ ctx := context.Background()
487+ s := openStore(t, t.TempDir())
488+ n := name(t, "proj")
489+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
490+ got, err := s.Get(ctx, n)
491+ require.NoError(t, err)
492+ assert.False(t, got.Settings.AllowAutoMerge)
493+
494+ settings := domain.DefaultRepoSettings()
495+ settings.AllowAutoMerge = true
496+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = settings; return true, nil })
497+ require.NoError(t, err)
498+ got, err = s.Get(ctx, n)
499+ require.NoError(t, err)
500+ assert.Equal(t, settings, got.Settings)
501+}
502+
456503 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
457504 // merge requests existed has no settings field.
458505 func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/convert.go +8 -2
@@ -72,7 +72,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
7272 }
7373
7474 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
75- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)}
75+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, AllowAutoMerge: s.AllowAutoMerge, Issues: issuesModeToPB(s.Issues)}
7676 for _, m := range s.AllowedMergeModes {
7777 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
7878 }
@@ -483,7 +483,13 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
483483 out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
484484 case domain.MergeRequestMerged:
485485 out.Time = timestamp(ev.At)
486- out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
486+ out.Kind = &pb.Event_MergeRequestMerged{MergeRequestMerged: &pb.MergeRequestMerged{MergeRequest: mergeRequestToPB(ev.MergeRequest), Auto: ev.Auto}}
487+ case domain.MergeRequestAutoMergeEnabled:
488+ out.Time = timestamp(ev.At)
489+ out.Kind = &pb.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &pb.MergeRequestAutoMergeEnabled{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
490+ case domain.MergeRequestAutoMergeCancelled:
491+ out.Time = timestamp(ev.At)
492+ out.Kind = &pb.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &pb.MergeRequestAutoMergeCancelled{MergeRequest: mergeRequestToPB(ev.MergeRequest), Reason: ev.Reason}}
487493 case domain.MergeRequestClosed:
488494 out.Time = timestamp(ev.At)
489495 out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
M internal/adapters/rpc/mergerequest.go +39 -0
@@ -19,6 +19,38 @@ type mergeRequestService struct {
1919 mrs ports.MergeRequestService
2020 }
2121
22+func (s *mergeRequestService) EnableAutoMerge(ctx context.Context, req *connect.Request[pb.EnableAutoMergeRequest]) (*connect.Response[pb.EnableAutoMergeResponse], error) {
23+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
24+ if err != nil {
25+ return nil, connectError(ctx, err)
26+ }
27+ mode, err := mergeModeFromPB(req.Msg.GetMode())
28+ if err != nil {
29+ return nil, connectError(ctx, err)
30+ }
31+ head, err := headID(req.Msg.GetHeadId())
32+ if err != nil {
33+ return nil, connectError(ctx, err)
34+ }
35+ mr, err := s.mrs.EnableAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
36+ if err != nil {
37+ return nil, connectError(ctx, err)
38+ }
39+ return connect.NewResponse(&pb.EnableAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil
40+}
41+
42+func (s *mergeRequestService) CancelAutoMerge(ctx context.Context, req *connect.Request[pb.CancelAutoMergeRequest]) (*connect.Response[pb.CancelAutoMergeResponse], error) {
43+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
44+ if err != nil {
45+ return nil, connectError(ctx, err)
46+ }
47+ mr, err := s.mrs.CancelAutoMerge(ctx, caller(ctx), name, req.Msg.GetNumber())
48+ if err != nil {
49+ return nil, connectError(ctx, err)
50+ }
51+ return connect.NewResponse(&pb.CancelAutoMergeResponse{MergeRequest: mergeRequestToPB(mr)}), nil
52+}
53+
2254 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
2355 name, err := domain.ParseRepoName(req.Msg.GetRepo())
2456 if err != nil {
@@ -133,6 +165,13 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
133165 HeadId: mr.HeadID.String(),
134166 MergedCommitId: mr.MergedCommitID.String(),
135167 MergeMode: mergeModeToPB(mr.MergeMode),
168+ AutoMerged: mr.AutoMerged,
169+ }
170+ if am := mr.AutoMerge; am != nil {
171+ out.AutoMerge = &pb.AutoMerge{
172+ Mode: mergeModeToPB(am.Mode), HeadId: am.HeadID.String(),
173+ EnabledBy: am.EnablerLabel, EnabledAt: timestamp(am.EnabledAt),
174+ }
136175 }
137176 if mr.IsOpen() {
138177 out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
M internal/adapters/rpc/mr_api_test.go +80 -0
@@ -3,6 +3,7 @@
33 package rpc
44
55 import (
6+ "strings"
67 "testing"
78 "time"
89
@@ -122,3 +123,82 @@ func TestNoteVerdictsDetailAndScope(t *testing.T) {
122123 _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))
123124 requireCode(t, connect.CodePermissionDenied, err)
124125 }
126+
127+func TestAutoMergeRPCCodesAndFields(t *testing.T) {
128+ now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
129+ head, err := domain.ParseObjectID(strings.Repeat("a", 40))
130+ require.NoError(t, err)
131+ armed := domain.MergeRequest{
132+ Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main", HeadID: head,
133+ AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: head, EnablerLabel: "owner", EnabledAt: now},
134+ }
135+ idle := armed
136+ idle.AutoMerge = nil
137+
138+ pbArmed := mergeRequestToPB(armed)
139+ require.NotNil(t, pbArmed.GetAutoMerge())
140+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, pbArmed.GetAutoMerge().GetMode())
141+ assert.Equal(t, head.String(), pbArmed.GetAutoMerge().GetHeadId())
142+ assert.Equal(t, "owner", pbArmed.GetAutoMerge().GetEnabledBy())
143+ assert.Equal(t, now, pbArmed.GetAutoMerge().GetEnabledAt().AsTime())
144+ assert.False(t, pbArmed.GetAutoMerged())
145+ assert.Nil(t, mergeRequestToPB(idle).GetAutoMerge())
146+ done := idle
147+ done.State, done.AutoMerged = domain.MRStateMerged, true
148+ assert.True(t, mergeRequestToPB(done).GetAutoMerged())
149+
150+ // Success: the mode and the request reach the service, the fields come back.
151+ m := mocks.NewMergeRequestService(t)
152+ m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: mustObjectID(t, strings.Repeat("a", domain.ObjectIDLen))}).Return(armed, nil).Once()
153+ res, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
154+ require.NoError(t, err)
155+ assert.Equal(t, pbArmed, res.Msg.GetMergeRequest())
156+ m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(idle, nil).Once()
157+ cres, err := (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
158+ require.NoError(t, err)
159+ assert.Nil(t, cres.Msg.GetMergeRequest().GetAutoMerge())
160+
161+ // Bad input never reaches the service.
162+ _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode(99), HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
163+ requireCode(t, connect.CodeInvalidArgument, err)
164+ _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
165+ requireCode(t, connect.CodeInvalidArgument, err)
166+ _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "", Number: 1}))
167+ requireCode(t, connect.CodeInvalidArgument, err)
168+
169+ // Domain errors map like the other merge request calls.
170+ for _, tc := range []struct {
171+ err error
172+ code connect.Code
173+ }{
174+ {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition},
175+ {domain.ErrForbidden, connect.CodePermissionDenied},
176+ {domain.ErrNotFound, connect.CodeNotFound},
177+ } {
178+ m := mocks.NewMergeRequestService(t)
179+ m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.Anything).Return(domain.MergeRequest{}, tc.err).Once()
180+ _, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
181+ requireCode(t, tc.code, err)
182+ m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{}, tc.err).Once()
183+ _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
184+ requireCode(t, tc.code, err)
185+ }
186+}
187+
188+func TestAutoMergeWatchEventsAndSettingsToPB(t *testing.T) {
189+ armed := domain.MergeRequest{Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen,
190+ AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeMerge, EnablerLabel: "owner"}}
191+ got := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.MergeRequestAutoMergeEnabled{MergeRequest: armed}})
192+ require.NotNil(t, got.GetMergeRequestAutoMergeEnabled())
193+ assert.Equal(t, pb.MergeMode_MERGE_MODE_MERGE_COMMIT, got.GetMergeRequestAutoMergeEnabled().GetMergeRequest().GetAutoMerge().GetMode())
194+ got = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.MergeRequestAutoMergeCancelled{MergeRequest: armed, Reason: "the branch moved"}})
195+ require.NotNil(t, got.GetMergeRequestAutoMergeCancelled())
196+ assert.Equal(t, "the branch moved", got.GetMergeRequestAutoMergeCancelled().GetReason())
197+ got = watchEventToPB(ports.WatchEvent{ID: "3", Event: domain.MergeRequestMerged{MergeRequest: armed, Auto: true}})
198+ assert.True(t, got.GetMergeRequestMerged().GetAuto())
199+ got = watchEventToPB(ports.WatchEvent{ID: "4", Event: domain.MergeRequestMerged{MergeRequest: armed}})
200+ assert.False(t, got.GetMergeRequestMerged().GetAuto())
201+
202+ assert.True(t, settingsToPB(domain.RepoSettings{AllowAutoMerge: true}).GetAllowAutoMerge())
203+ assert.False(t, settingsToPB(domain.RepoSettings{}).GetAllowAutoMerge())
204+}
M internal/adapters/rpc/repo.go +9 -5
@@ -80,8 +80,8 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
8080
8181 // updateParams maps the field mask of an update. The mask must name at least
8282 // one of "description", "topics", "visibility", "default_branch", "settings",
83-// "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84-// nothing else; the optional issues field stands outside the mask, and alone
83+// "settings.allowed_merge_modes", "settings.failing_checks_block" and
84+// "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone
8585 // it is a valid update.
8686 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
8787 var params ports.UpdateRepoParams
@@ -113,17 +113,21 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
113113 params.Visibility = &vis
114114 case "default_branch":
115115 params.DefaultBranch = new(msg.GetDefaultBranch())
116- case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block":
117- if path != "settings.failing_checks_block" {
116+ case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block", "settings.allow_auto_merge":
117+ all := path == "settings"
118+ if all || path == "settings.allowed_merge_modes" {
118119 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())
119120 if err != nil {
120121 return params, err
121122 }
122123 params.AllowedMergeModes = &modes
123124 }
124- if path != "settings.allowed_merge_modes" {
125+ if all || path == "settings.failing_checks_block" {
125126 params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())
126127 }
128+ if all || path == "settings.allow_auto_merge" {
129+ params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge())
130+ }
127131 default:
128132 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)
129133 }
M internal/adapters/rpc/repo_test.go +25 -0
@@ -280,3 +280,28 @@ func TestRepoToPBTopics(t *testing.T) {
280280 assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281281 assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282282 }
283+
284+func TestUpdateParamsAllowAutoMerge(t *testing.T) {
285+ t.Parallel()
286+ mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
287+ settings := &pb.RepoSettings{AllowAutoMerge: true, FailingChecksBlock: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}}
288+
289+ params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings})
290+ require.NoError(t, err)
291+ require.NotNil(t, params.AllowAutoMerge)
292+ assert.True(t, *params.AllowAutoMerge)
293+ assert.Nil(t, params.FailingChecksBlock, "only the masked setting is set")
294+ assert.Nil(t, params.AllowedMergeModes)
295+
296+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings})
297+ require.NoError(t, err)
298+ require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge")
299+ assert.True(t, *params.AllowAutoMerge)
300+ require.NotNil(t, params.FailingChecksBlock)
301+ require.NotNil(t, params.AllowedMergeModes)
302+
303+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.failing_checks_block"), Settings: settings})
304+ require.NoError(t, err)
305+ assert.Nil(t, params.AllowAutoMerge)
306+ require.NotNil(t, params.FailingChecksBlock)
307+}
M internal/adapters/web/fragments.go +5 -1
@@ -61,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool {
6161 }
6262 switch we.Event.(type) {
6363 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
64- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
64+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6565 domain.RunUpdated, domain.JobUpdated,
6666 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
6767 return true
@@ -189,6 +189,10 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
189189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
190190 case domain.MergeRequestReady:
191191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
192+ case domain.MergeRequestAutoMergeEnabled:
193+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was enabled — reload")
194+ case domain.MergeRequestAutoMergeCancelled:
195+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was cancelled — reload")
192196 case domain.MergeRequestMergeability:
193197 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
194198 return nil, nil
M internal/adapters/web/mr.go +26 -0
@@ -11,6 +11,7 @@ import (
1111 "net/url"
1212 "strconv"
1313 "strings"
14+ "time"
1415
1516 "github.com/a-h/templ"
1617
@@ -202,6 +203,18 @@ type MergeBox struct {
202203 Fingerprint string
203204 // Error is why the last merge was refused, shown in the box.
204205 Error string
206+ // AutoAction and CancelAutoAction post to the auto-merge routes.
207+ AutoAction, CancelAutoAction string
208+ // CanAutoMerge offers "Merge when ready": the repository allows
209+ // auto-merge and the server has CI to wait for.
210+ CanAutoMerge bool
211+ // AutoMerge is the pending auto-merge, nil when off.
212+ AutoMerge *AutoMergeView
213+}
214+
215+// AutoMergeView is a pending auto-merge, ready to show.
216+type AutoMergeView struct {
217+ Mode, EnabledBy, When, WhenAt, HeadShort string
205218 }
206219
207220 // ModeOption is a merge mode the repository allows. Disabled says that the
@@ -666,9 +679,20 @@ func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestD
666679 if m.IsDraft() {
667680 box.ReadyAction = mrHref(d.name(), m.Number, "ready")
668681 }
682+ box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge)
669683 return box
670684 }
671685
686+// autoMergeView is the pending auto-merge ready to show, nil when off.
687+func autoMergeView(now time.Time, am *domain.AutoMerge) *AutoMergeView {
688+ if am == nil {
689+ return nil
690+ }
691+ v := &AutoMergeView{Mode: am.Mode.String(), EnabledBy: am.EnablerLabel, HeadShort: short(am.HeadID.String())}
692+ v.When, v.WhenAt = whenOf(now, am.EnabledAt)
693+ return v
694+}
695+
672696 // mrFacts formats the description, metadata, branch links and checks.
673697 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
674698 m := res.MergeRequest
@@ -790,6 +814,8 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead
790814 CanMarkRead: canMarkRead,
791815 CanDeleteBranch: source != d.repo.DefaultBranch,
792816 Mergeability: mg,
817+ AutoAction: mrHref(d.name(), n, "auto-merge"), CancelAutoAction: mrHref(d.name(), n, "auto-merge/cancel"),
818+ CanAutoMerge: d.repo.Settings.AllowAutoMerge && s.cfg.Runs != nil,
793819 }
794820 var lines []string
795821 cmd := ""
M internal/adapters/web/mr.templ +28 -0
@@ -508,6 +508,20 @@ templ mergeMenu(p MRPage) {
508508 </form>
509509 } else if !p.Box.HeadGone {
510510 @mrMergeabilitySlot(p.Box.Mergeability)
511+ if p.Box.AutoMerge != nil {
512+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.CancelAutoAction) }>
513+ <p class="dim" role="status">
514+ Auto-merge: { p.Box.AutoMerge.Mode } when ready, enabled by { p.Box.AutoMerge.EnabledBy }
515+ if p.Box.AutoMerge.When != "" {
516+ { " " }<time datetime={ p.Box.AutoMerge.WhenAt }>{ p.Box.AutoMerge.When }</time>
517+ }
518+ { " " }(head { p.Box.AutoMerge.HeadShort })
519+ </p>
520+ <div class="d-btns">
521+ <button type="submit" class="btn">Cancel auto-merge</button>
522+ </div>
523+ </form>
524+ }
511525 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
512526 <input type="hidden" name="head_id" value={ p.Box.Head }/>
513527 <fieldset class="d-modes">
@@ -546,6 +560,20 @@ templ mergeMenu(p MRPage) {
546560 >Merge into { p.Target }</button>
547561 </div>
548562 </form>
563+ if p.Box.CanAutoMerge {
564+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.AutoAction) }>
565+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
566+ <fieldset class="d-modes">
567+ <legend>Auto-merge mode</legend>
568+ for i, m := range p.Box.Modes {
569+ <label><input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> { m.Label }</label>
570+ }
571+ </fieldset>
572+ <div class="d-btns">
573+ <button type="submit" class="btn" disabled?={ len(p.Box.Modes) == 0 } title="merge once the checks pass">Merge when ready</button>
574+ </div>
575+ </form>
576+ }
549577 }
550578 </div>
551579 </details>
M internal/adapters/web/mr_post.go +62 -0
@@ -391,6 +391,68 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
391391 return back, back, nil
392392 }
393393
394+// mrAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge: the form of the
395+// merge (mode, head_id) asks to merge once the checks pass. The server records
396+// refuses to enable when the source branch is no longer at head_id.
397+func (s *Server) mrAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
398+ repo := r.PathValue("repo")
399+ n, err := number(r)
400+ if err != nil {
401+ return "", "", err
402+ }
403+ back := mrHref(repo, n)
404+ var mode domain.MergeMode
405+ switch r.PostFormValue("mode") {
406+ case "fast-forward":
407+ mode = domain.MergeModeFastForward
408+ case "squash":
409+ mode = domain.MergeModeSquash
410+ case "merge":
411+ mode = domain.MergeModeMerge
412+ default:
413+ err := formError("mode must be fast-forward, squash or merge")
414+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
415+ }
416+ name, err := domain.ParseRepoName(repo)
417+ if err != nil {
418+ return "", back, err
419+ }
420+ head, err := headID(r.PostFormValue("head_id"))
421+ if err != nil {
422+ return "", back, err
423+ }
424+ _, err = s.cfg.MergeRequests.EnableAutoMerge(ctx, principal(ctx), name, n, ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
425+ switch {
426+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
427+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
428+ case err != nil:
429+ return "", back, err
430+ }
431+ return back, back, nil
432+}
433+
434+// mrCancelAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge/cancel.
435+func (s *Server) mrCancelAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
436+ repo := r.PathValue("repo")
437+ n, err := number(r)
438+ if err != nil {
439+ return "", "", err
440+ }
441+ back := mrHref(repo, n)
442+ name, err := domain.ParseRepoName(repo)
443+ if err != nil {
444+ return "", back, err
445+ }
446+ _, err = s.cfg.MergeRequests.CancelAutoMerge(ctx, principal(ctx), name, n)
447+ switch {
448+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
449+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
450+ case err != nil:
451+ return "", back, err
452+ }
453+ return back, back, nil
454+}
455+
394456 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
395457 // they were shown as read. Reading a page never does it (see the top of mr.go).
396458 func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
M internal/adapters/web/mr_templ.go +554 -350 generated file: collapsed
M internal/adapters/web/mr_test.go +103 -1
@@ -4,8 +4,11 @@ package web
44
55 import (
66 "context"
7- "time"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
810 "testing"
11+ "time"
912
1013 "github.com/stretchr/testify/assert"
1114 "github.com/stretchr/testify/mock"
@@ -195,3 +198,102 @@ func TestMRReadPresentationCommitCount(t *testing.T) {
195198 })
196199 }
197200 }
201+
202+func TestMergeBoxAutoMerge(t *testing.T) {
203+ t.Parallel()
204+ mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")
205+ mk := func(allow bool, runs ports.RunService) *MergeBox {
206+ v := repoWithModes(domain.MergeModes()...)
207+ v.Settings.AllowAutoMerge = allow
208+ return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg)
209+ }
210+ assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge)
211+ assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for")
212+ on := mk(true, mocks.NewRunService(t))
213+ assert.True(t, on.CanAutoMerge)
214+
215+ page := MRPage{Target: "main", Box: on}
216+ html := renderToString(t, mergeMenu(page))
217+ assert.Contains(t, html, "Merge when ready")
218+ assert.Contains(t, html, `action="`+on.AutoAction+`"`)
219+ assert.NotContains(t, html, "Cancel auto-merge")
220+ // Current mergeability must only gate the immediate form. Every allowed
221+ // mode remains selectable in the separate auto-merge form.
222+ for i := range on.Modes {
223+ on.Modes[i].Disabled = true
224+ }
225+ on.CanDeleteBranch = true
226+ page.Source = "feat"
227+ html = renderToString(t, mergeMenu(page))
228+ _, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`)
229+ require.True(t, found)
230+ autoForm, _, _ = strings.Cut(autoForm, "</form>")
231+ assert.NotContains(t, autoForm, "delete_branch")
232+ assert.NotContains(t, autoForm, "disabled")
233+ assert.Contains(t, autoForm, `name="head_id"`)
234+ for _, mode := range on.Modes {
235+ assert.Contains(t, autoForm, `value="`+mode.Value+`"`)
236+ }
237+ page.Box = mk(false, mocks.NewRunService(t))
238+ assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
239+
240+ on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"}
241+ html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on}))
242+ assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner")
243+ assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`)
244+ assert.Contains(t, html, "Cancel auto-merge")
245+}
246+
247+func autoMergeRequest(path, body string) *http.Request {
248+ r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
249+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
250+ r.SetPathValue("repo", "demo")
251+ r.SetPathValue("n", "3")
252+ return r
253+}
254+
255+func TestAutoMergeHandlers(t *testing.T) {
256+ t.Parallel()
257+ name, err := domain.ParseRepoName("demo")
258+ require.NoError(t, err)
259+ ctx := context.Background()
260+
261+ head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
262+ require.NoError(t, err)
263+ svc := mocks.NewMergeRequestService(t)
264+ svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: head}).
265+ Return(domain.MergeRequest{}, nil).Once()
266+ svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once()
267+ s := &Server{cfg: Config{MergeRequests: svc}}
268+ to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash&head_id="+head.String()))
269+ require.NoError(t, err)
270+ assert.Equal(t, mrHref("demo", 3), to)
271+ to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", ""))
272+ require.NoError(t, err)
273+ assert.Equal(t, mrHref("demo", 3), to)
274+
275+ // A bad mode is refused before the service.
276+ _, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase"))
277+ var re *rerender
278+ require.ErrorAs(t, err, &re)
279+ assert.Equal(t, http.StatusBadRequest, re.status)
280+
281+ // A refusal of the use case is shown in the box, like a refused merge.
282+ svc2 := mocks.NewMergeRequestService(t)
283+ svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything).
284+ Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once()
285+ svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).
286+ Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
287+ s2 := &Server{cfg: Config{MergeRequests: svc2}}
288+ _, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash&head_id="+head.String()))
289+ require.ErrorAs(t, err, &re)
290+ assert.Equal(t, http.StatusConflict, re.status)
291+ _, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", ""))
292+ require.ErrorIs(t, err, domain.ErrNotFound)
293+}
294+
295+func TestAutoMergeLiveEvents(t *testing.T) {
296+ t.Parallel()
297+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
298+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
299+}
M internal/adapters/web/web.go +2 -0
@@ -231,6 +231,8 @@ func (s *Server) routes() *http.ServeMux {
231231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
232232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
233233 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
234+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge", s.mrAction(s.mrAutoMerge))
235+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge/cancel", s.mrAction(s.mrCancelAutoMerge))
234236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
235237 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
236238 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
A internal/app/automerge_e2e_test.go +163 -0
@@ -0,0 +1,163 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+ "google.golang.org/protobuf/types/known/fieldmaskpb"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// This test exercises the composition root through use cases and domain
20+// events; TestAutoMergeEndToEndAPI does the same through the RPC API.
21+func TestAutoMergeEndToEnd(t *testing.T) {
22+ c := newCIHarness(t, nil)
23+ runner := c.registerRunner("alpha", "ubuntu-latest")
24+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
25+on:
26+ push:
27+ branches: [feat]
28+ pull_request:
29+jobs:
30+ build:
31+ runs-on: ubuntu-latest
32+ steps:
33+ - run: echo hello
34+`}, "")
35+ head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
36+ number := c.openMR(c.root, "feat")
37+ repo := c.name("proj")
38+ allow := true
39+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), repo, ports.UpdateRepoParams{AllowAutoMerge: &allow})
40+ require.NoError(t, err)
41+ headID, err := domain.ParseObjectID(head)
42+ require.NoError(t, err)
43+ events := c.forge.Bus.Subscribe(c.ctx)
44+ enabled, err := c.forge.MergeRequests.EnableAutoMerge(c.ctx, domain.Owner(), repo, number, ports.EnableAutoMergeParams{HeadID: headID})
45+ require.NoError(t, err)
46+ require.NotNil(t, enabled.AutoMerge)
47+ assert.Equal(t, head, enabled.AutoMerge.HeadID.String())
48+ enabledDeadline := time.After(5 * time.Second)
49+waitEnabled:
50+ for {
51+ select {
52+ case ev := <-events:
53+ if enabled, ok := ev.(domain.MergeRequestAutoMergeEnabled); ok && enabled.MergeRequest.Repo == repo && enabled.MergeRequest.Number == number {
54+ break waitEnabled
55+ }
56+ case <-enabledDeadline:
57+ t.Fatal("no enabled event")
58+ }
59+ }
60+ c.finish(runner)
61+ // A successful push check alone must not merge while the pull run is queued.
62+ got, err := c.forge.MergeRequests.Get(c.ctx, domain.Root(), repo, number, ports.GetMergeRequestOptions{})
63+ require.NoError(t, err)
64+ assert.True(t, got.MergeRequest.IsOpen())
65+ c.finish(runner)
66+ deadline := time.After(10 * time.Second)
67+ for {
68+ select {
69+ case ev := <-events:
70+ if merged, ok := ev.(domain.MergeRequestMerged); ok && merged.MergeRequest.Number == number {
71+ assert.True(t, merged.Auto)
72+ assert.True(t, merged.MergeRequest.AutoMerged)
73+ assert.Nil(t, merged.MergeRequest.AutoMerge)
74+ return
75+ }
76+ case <-deadline:
77+ t.Fatal("no automatic merged event")
78+ }
79+ }
80+}
81+
82+// TestAutoMergeEndToEndAPI enables auto-merge through the API client, lets CI
83+// succeed, and reads the Watch stream: enabled, then merged with auto=true.
84+func TestAutoMergeEndToEndAPI(t *testing.T) {
85+ c := newCIHarness(t, nil)
86+ runner := c.registerRunner("alpha", "ubuntu-latest")
87+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
88+on:
89+ push:
90+ branches: [feat]
91+ pull_request:
92+jobs:
93+ build:
94+ runs-on: ubuntu-latest
95+ steps:
96+ - run: echo hello
97+`}, "")
98+ head := c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
99+ number := c.openMR(c.root, "feat")
100+
101+ // Off by default: the server refuses.
102+ _, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: c.repo, Number: number, HeadId: head}))
103+ require.Error(t, err)
104+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
105+
106+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
107+ require.NoError(t, err)
108+ assert.Contains(t, ver.Msg.GetFeatures(), "merge_requests.auto_merge")
109+
110+ up, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
111+ Repo: c.repo,
112+ Settings: &pb.RepoSettings{AllowAutoMerge: true},
113+ UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allow_auto_merge"}},
114+ }))
115+ require.NoError(t, err)
116+ assert.True(t, up.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
117+
118+ w := c.watch(c.root, "", "home")
119+ res, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{
120+ Repo: c.repo, Number: number, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: head,
121+ }))
122+ require.NoError(t, err)
123+ am := res.Msg.GetMergeRequest().GetAutoMerge()
124+ require.NotNil(t, am)
125+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, am.GetMode())
126+ assert.Equal(t, res.Msg.GetMergeRequest().GetHeadId(), am.GetHeadId())
127+ assert.NotEmpty(t, am.GetEnabledBy())
128+ assert.NotNil(t, am.GetEnabledAt())
129+
130+ got, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
131+ require.NoError(t, err)
132+ assert.NotNil(t, got.Msg.GetMergeRequest().GetAutoMerge(), "Get reports the pending auto-merge")
133+
134+ c.finish(runner)
135+ c.finish(runner)
136+
137+ var sawEnabled bool
138+ deadline := time.After(15 * time.Second)
139+ for {
140+ select {
141+ case ev, ok := <-w.events:
142+ require.True(t, ok, "the stream ended: %v", w.err)
143+ if e := ev.GetMergeRequestAutoMergeEnabled(); e != nil {
144+ assert.EqualValues(t, number, e.GetMergeRequest().GetNumber())
145+ assert.NotNil(t, e.GetMergeRequest().GetAutoMerge())
146+ sawEnabled = true
147+ }
148+ if m := ev.GetMergeRequestMerged(); m != nil && m.GetMergeRequest().GetNumber() == number {
149+ assert.True(t, sawEnabled, "enabled comes before merged")
150+ assert.True(t, m.GetAuto())
151+ assert.True(t, m.GetMergeRequest().GetAutoMerged())
152+ assert.Nil(t, m.GetMergeRequest().GetAutoMerge())
153+ final, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
154+ require.NoError(t, err)
155+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, final.Msg.GetMergeRequest().GetState())
156+ assert.True(t, final.Msg.GetMergeRequest().GetAutoMerged())
157+ return
158+ }
159+ case <-deadline:
160+ t.Fatal("no automatic merged event")
161+ }
162+ }
163+}
M internal/app/cli_e2e_test.go +2 -2
@@ -203,7 +203,7 @@ type eventOut struct {
203203 MergeRequest *mrOut `json:"merge_request"`
204204 }
205205
206-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
206+var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207207
208208 // AC6: the CLI end to end against a real forge.
209209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
M internal/app/cli_fake_test.go +8 -0
@@ -62,6 +62,8 @@ type fakeAPI struct {
6262 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
6363 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
6464 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
65+ enableAuto func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error)
66+ cancelAuto func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error)
6567 createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error)
6668 listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)
6769 getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)
@@ -152,6 +154,12 @@ func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite
152154 func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) {
153155 return respond(f, "MergeMergeRequest", r, f.mergeMR)
154156 }
157+func (f *fakeAPI) EnableAutoMerge(_ context.Context, r *connect.Request[ermite.EnableAutoMergeRequest]) (*connect.Response[ermite.EnableAutoMergeResponse], error) {
158+ return respond(f, "EnableAutoMerge", r, f.enableAuto)
159+}
160+func (f *fakeAPI) CancelAutoMerge(_ context.Context, r *connect.Request[ermite.CancelAutoMergeRequest]) (*connect.Response[ermite.CancelAutoMergeResponse], error) {
161+ return respond(f, "CancelAutoMerge", r, f.cancelAuto)
162+}
155163 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
156164 return respond(f, "MarkRead", r, f.markRead)
157165 }
M internal/app/cli_mr.go +95 -3
@@ -121,6 +121,26 @@ type jsonMergeRequest struct {
121121 HeadID string `json:"head_id"`
122122 MergedCommitID string `json:"merged_commit_id"`
123123 MergeMode string `json:"merge_mode"`
124+ // AutoMerge is the pending auto-merge, null when there is none.
125+ AutoMerge *jsonAutoMerge `json:"auto_merge"`
126+ // AutoMerged is true when the server merged the request by auto-merge.
127+ AutoMerged bool `json:"auto_merged"`
128+}
129+
130+// jsonAutoMerge is the stable JSON shape of a pending auto-merge.
131+type jsonAutoMerge struct {
132+ Mode string `json:"mode"`
133+ HeadID string `json:"head_id"`
134+ EnabledBy string `json:"enabled_by"`
135+ EnabledAt *string `json:"enabled_at"`
136+}
137+
138+// toJSONAutoMerge maps an auto-merge; nil when there is none.
139+func toJSONAutoMerge(a *ermite.AutoMerge) *jsonAutoMerge {
140+ if a == nil {
141+ return nil
142+ }
143+ return &jsonAutoMerge{Mode: modeName(a.GetMode()), HeadID: a.GetHeadId(), EnabledBy: a.GetEnabledBy(), EnabledAt: jsonTime(a.GetEnabledAt())}
124144 }
125145
126146 // jsonMergeability is the stable JSON shape of the mergeability of an open
@@ -201,6 +221,7 @@ func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest
201221 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
202222 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
203223 HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()),
224+ AutoMerge: toJSONAutoMerge(m.GetAutoMerge()), AutoMerged: m.GetAutoMerged(),
204225 }
205226 }
206227
@@ -245,7 +266,8 @@ JSON fields of a merge request (--json): repo, url (full web URL), number, title
245266 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
246267 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
247268 branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty
248-unless merged).`
269+unless merged), auto_merge (null unless auto-merge is pending: mode, head_id, enabled_by,
270+enabled_at) and auto_merged (true when the server merged the request by auto-merge).`
249271
250272 const jsonMergeabilityHelp = `
251273 mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date",
@@ -660,7 +682,14 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
660682 {"head", orDash(m.GetHeadId())},
661683 }
662684 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
663- pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
685+ merged := orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"
686+ pairs = append(pairs, [2]string{"merged", merged})
687+ if m.GetAutoMerged() {
688+ pairs = append(pairs, [2]string{"auto-merged", "yes, by the server once the checks passed"})
689+ }
690+ }
691+ if am := m.GetAutoMerge(); am != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
692+ pairs = append(pairs, [2]string{"auto-merge", modeName(am.GetMode()) + " when ready (enabled by " + am.GetEnabledBy() + ")"})
664693 }
665694 if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
666695 pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
@@ -1056,6 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10561085 var mode string
10571086 var deleteBranch bool
10581087 var head string
1088+ var auto, cancelAuto bool
10591089 cmd := &cobra.Command{
10601090 Use: "merge <repo> <number>",
10611091 Short: "Merge a merge request",
@@ -1078,7 +1108,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10781108 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
10791109 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
10801110 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
1081- "moved, say), the merge stands and the branch is kept." + jsonMRHelp +
1111+ "moved, say), the merge stands and the branch is kept.\n\n" +
1112+ "--auto does not merge now: it asks the server to merge by itself, with --mode, once CI has settled\n" +
1113+ "with at least one successful check on the head and every check allows it. Needs the mr:merge scope,\n" +
1114+ "a server with CI, and `ermite repo edit --allow-auto-merge` on the repository. A new push to the\n" +
1115+ "source branch, a change of the settings or of your permissions cancels it. --auto takes --head\n" +
1116+ "like a plain merge: it is enabled only if the source branch still points to it. --cancel-auto cancels a pending auto-merge." + jsonMRHelp +
10821117 "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
10831118 Args: cobra.ExactArgs(2),
10841119 ValidArgsFunction: completeMRRepoThenNumber(env),
@@ -1086,6 +1121,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10861121 cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
10871122 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
10881123 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1124+ cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)")
1125+ cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")
1126+ cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")
1127+ cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")
1128+ cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")
1129+ cmd.MarkFlagsMutuallyExclusive("auto", "delete-branch")
10891130 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
10901131 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
10911132 })
@@ -1095,6 +1136,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10951136 if err != nil {
10961137 return err
10971138 }
1139+ if cancelAuto {
1140+ return runMRCancelAuto(env, cmd, args[0], n, *asJSON)
1141+ }
10981142 pbMode, err := parseMode(mode)
10991143 if err != nil {
11001144 return err
@@ -1104,6 +1148,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11041148 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
11051149 "so that only the commit you reviewed is merged", args[0], n)
11061150 }
1151+ if auto {
1152+ return runMREnableAuto(env, cmd, args[0], n, pbMode, head, *asJSON)
1153+ }
11071154 c, err := env.client(cmd)
11081155 if err != nil {
11091156 return err
@@ -1139,6 +1186,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11391186 return cmd
11401187 }
11411188
1189+// runMREnableAuto is `mr merge --auto`: it asks the server to merge once ready.
1190+func runMREnableAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, mode ermite.MergeMode, head string, asJSON bool) error {
1191+ c, err := env.client(cmd)
1192+ if err != nil {
1193+ return err
1194+ }
1195+ ctx := env.ctx(cmd)
1196+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "cannot merge by itself (--auto)"); err != nil {
1197+ return err
1198+ }
1199+ res, err := c.MergeRequests().EnableAutoMerge(ctx, connect.NewRequest(&ermite.EnableAutoMergeRequest{Repo: repo, Number: n, Mode: mode, HeadId: head}))
1200+ if err != nil {
1201+ return c.Describe(err)
1202+ }
1203+ m := res.Msg.GetMergeRequest()
1204+ if asJSON {
1205+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1206+ }
1207+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge enabled on %s!%d: %s into %s when ready (enabled by %s)\n", clean(m.GetRepo()), m.GetNumber(),
1208+ modeName(m.GetAutoMerge().GetMode()), clean(m.GetTargetBranch()), clean(m.GetAutoMerge().GetEnabledBy()))
1209+ return err
1210+}
1211+
1212+// runMRCancelAuto is `mr merge --cancel-auto`.
1213+func runMRCancelAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, asJSON bool) error {
1214+ c, err := env.client(cmd)
1215+ if err != nil {
1216+ return err
1217+ }
1218+ ctx := env.ctx(cmd)
1219+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "has no auto-merge to cancel (--cancel-auto)"); err != nil {
1220+ return err
1221+ }
1222+ res, err := c.MergeRequests().CancelAutoMerge(ctx, connect.NewRequest(&ermite.CancelAutoMergeRequest{Repo: repo, Number: n}))
1223+ if err != nil {
1224+ return c.Describe(err)
1225+ }
1226+ m := res.Msg.GetMergeRequest()
1227+ if asJSON {
1228+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1229+ }
1230+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge cancelled on %s!%d\n", clean(m.GetRepo()), m.GetNumber())
1231+ return err
1232+}
1233+
11421234 // signedNote says that a squash or merge commit is signed by the server's
11431235 // key, with its fingerprint when the server tells it.
11441236 func signedNote(ctx context.Context, c *client.Client) string {
M internal/app/cli_mr_e2e_test.go +1 -1
@@ -33,7 +33,7 @@ type mrOut struct {
3333 MergeMode string `json:"merge_mode"`
3434 }
3535
36-var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
36+var mrKeys = []string{"author", "auto_merge", "auto_merged", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
3737
3838 type commentOut struct {
3939 ID string `json:"id"`
M internal/app/cli_mr_test.go +112 -1
@@ -51,7 +51,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
5151 _ = res
5252
5353 res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)
54- assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
54+ assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":"","auto_merge":null,"auto_merged":false}`, res.stdout)
5555
5656 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
5757 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
@@ -578,3 +578,114 @@ func TestJSONFileOmitted(t *testing.T) {
578578 assert.Contains(t, string(raw), `"truncated":true`)
579579 assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580580 }
581+
582+func autoMergeFake(t *testing.T, features ...string) (*fakeAPI, string) {
583+ t.Helper()
584+ auto := proto.Clone(mrOne).(*ermite.MergeRequest)
585+ auto.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")}
586+ f := &fakeAPI{
587+ enableAuto: func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error) {
588+ return &ermite.EnableAutoMergeResponse{MergeRequest: auto}, nil
589+ },
590+ cancelAuto: func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error) {
591+ return &ermite.CancelAutoMergeResponse{MergeRequest: mrOne}, nil
592+ },
593+ getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
594+ return &ermite.GetVersionResponse{Version: "test", Features: features}, nil
595+ },
596+ }
597+ return f, f.serve(t)
598+}
599+
600+func TestMRMergeAuto(t *testing.T) {
601+ f, url := autoMergeFake(t, "merge_requests.auto_merge")
602+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--mode", "squash", "--head", headA).ok(t)
603+ assert.Equal(t, "auto-merge enabled on alpha!1: squash into main when ready (enabled by owner)\n", res.stdout)
604+ calls, reqs := f.snapshot()
605+ assert.Equal(t, []string{"GetVersion", "EnableAutoMerge"}, calls)
606+ got := reqs[1].(*ermite.EnableAutoMergeRequest)
607+ assert.Equal(t, "alpha", got.GetRepo())
608+ assert.EqualValues(t, 1, got.GetNumber())
609+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, got.GetMode())
610+ assert.Equal(t, headA, got.GetHeadId())
611+
612+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--head", headA, "--json").ok(t)
613+ var out struct {
614+ AutoMerge struct {
615+ Mode string `json:"mode"`
616+ HeadID string `json:"head_id"`
617+ EnabledBy string `json:"enabled_by"`
618+ EnabledAt string `json:"enabled_at"`
619+ } `json:"auto_merge"`
620+ }
621+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &out))
622+ assert.Equal(t, "squash", out.AutoMerge.Mode)
623+ assert.Equal(t, headA, out.AutoMerge.HeadID)
624+ assert.Equal(t, "owner", out.AutoMerge.EnabledBy)
625+ assert.Equal(t, "2026-10-03T08:00:00Z", out.AutoMerge.EnabledAt)
626+ _, reqs = f.snapshot()
627+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, reqs[len(reqs)-1].(*ermite.EnableAutoMergeRequest).GetMode(), "fast-forward by default")
628+
629+ for _, args := range [][]string{
630+ {"--auto", "--head", headA, "--cancel-auto"}, {"--auto", "--head", headA, "--delete-branch"}, {"--cancel-auto", "--head", headA}, {"--auto"},
631+ } {
632+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "merge", "alpha", "1"}, args...)...).err, "%v", args)
633+ }
634+}
635+
636+func TestMRMergeAutoNeedsTheFeature(t *testing.T) {
637+ f, url := autoMergeFake(t, "merge_requests")
638+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--head", headA)
639+ require.Error(t, res.err)
640+ assert.Contains(t, res.err.Error(), "merge_requests.auto_merge")
641+ calls, _ := f.snapshot()
642+ assert.NotContains(t, calls, "EnableAutoMerge")
643+}
644+
645+func TestMRMergeCancelAuto(t *testing.T) {
646+ f, url := autoMergeFake(t, "merge_requests.auto_merge")
647+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto").ok(t)
648+ assert.Equal(t, "auto-merge cancelled on alpha!1\n", res.stdout)
649+ calls, reqs := f.snapshot()
650+ assert.Equal(t, []string{"GetVersion", "CancelAutoMerge"}, calls)
651+ assert.EqualValues(t, 1, reqs[1].(*ermite.CancelAutoMergeRequest).GetNumber())
652+
653+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", "--json").ok(t)
654+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
655+
656+ for _, extra := range []string{"--delete-branch", "--head=" + headA} {
657+ require.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", extra).err, extra)
658+ }
659+}
660+
661+func TestMRShowAutoMerge(t *testing.T) {
662+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
663+ m.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")}
664+ cur := m
665+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
666+ return &ermite.GetMergeRequestResponse{MergeRequest: cur}, nil
667+ }}
668+ url := f.serve(t)
669+
670+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
671+ assert.Regexp(t, `(?m)^auto-merge\s+squash when ready \(enabled by owner\)$`, res.stdout)
672+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
673+ assert.Contains(t, res.stdout, `"mode": "squash"`)
674+ assert.Contains(t, res.stdout, `"enabled_by": "owner"`)
675+ assert.Contains(t, res.stdout, `"auto_merged": false`)
676+
677+ merged := proto.Clone(mrOne).(*ermite.MergeRequest)
678+ merged.State, merged.AutoMerged = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, true
679+ merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH
680+ cur = merged
681+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
682+ assert.Regexp(t, `(?m)^auto-merged\s+yes`, res.stdout)
683+ assert.NotContains(t, res.stdout, "when ready")
684+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
685+ assert.Contains(t, res.stdout, `"auto_merged": true`)
686+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
687+
688+ cur = mrOne
689+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
690+ assert.NotContains(t, res.stdout, "auto-merge")
691+}
M internal/app/cli_repo.go +17 -6
@@ -36,6 +36,7 @@ type jsonRepo struct {
3636 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
39+ AllowAutoMerge bool `json:"allow_auto_merge"`
3940 // Issues is the issue mode ("off", "owner" or "public").
4041 Issues string `json:"issues"`
4142 }
@@ -48,6 +49,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4849 return jsonRepo{
4950 MergeModes: modes,
5051 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
52+ AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
5153 Issues: issuesModeName(r.GetSettings().GetIssues()),
5254 Name: r.GetName(),
5355 Description: r.GetDescription(),
@@ -115,8 +117,8 @@ const jsonRepoHelp = `
115117 JSON fields of a repository (--json): name, description, visibility ("public" or
116118 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
117119 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
118-"merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119-mode: "off", "owner" or "public").`
120+"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether
121+auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
120122
121123 func newRepoCmd(env *cliEnv) *cobra.Command {
122124 repo := &cobra.Command{
@@ -229,6 +231,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
229231 {"last push", shortTime(r.GetPushedAt(), "never")},
230232 {"merge modes", orDash(strings.Join(modes, ", "))},
231233 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234+ {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
232235 {"issues", issuesModeName(r.GetSettings().GetIssues())},
233236 })
234237 }
@@ -308,7 +311,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin
308311 func newRepoEditCmd(env *cliEnv) *cobra.Command {
309312 var description, visibility, defaultBranch string
310313 var topics, mergeModes []string
311- var failingChecksBlock bool
314+ var failingChecksBlock, allowAutoMerge bool
312315 var issues string
313316 cmd := &cobra.Command{
314317 Use: "edit <name>",
@@ -331,6 +334,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
331334 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
332335 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
333336 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
337+ cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")
334338 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
335339 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
336340 return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp
@@ -341,7 +345,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
341345 })
342346 asJSON := addJSONFlag(cmd)
343347 cmd.RunE = func(cmd *cobra.Command, args []string) error {
344- req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues)
348+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues)
345349 if err != nil {
346350 return err
347351 }
@@ -365,7 +369,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
365369
366370 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
367371 // the user passed go into the update mask.
368-func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) {
372+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
369373 req := &ermite.UpdateRepoRequest{Repo: name}
370374 var paths []string
371375 if cmd.Flags().Changed("description") {
@@ -414,6 +418,13 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
414418 req.Settings.FailingChecksBlock = failingChecksBlock
415419 paths = append(paths, "settings.failing_checks_block")
416420 }
421+ if cmd.Flags().Changed("allow-auto-merge") {
422+ if req.Settings == nil {
423+ req.Settings = &ermite.RepoSettings{}
424+ }
425+ req.Settings.AllowAutoMerge = allowAutoMerge
426+ paths = append(paths, "settings.allow_auto_merge")
427+ }
417428 if cmd.Flags().Changed("issues") {
418429 mode, err := parseIssuesMode(issues)
419430 if err != nil {
@@ -423,7 +434,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
423434 req.Issues = &mode
424435 }
425436 if len(paths) == 0 && req.Issues == nil {
426- return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues")
437+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --allow-auto-merge, --issues")
427438 }
428439 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
429440 return req, nil
M internal/app/cli_repo_test.go +34 -4
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
5454 {
5555 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5656 wantStdout: `[
57- {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"},
58- {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}
57+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"},
58+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}
5959 ]`,
6060 },
6161 {
@@ -116,12 +116,13 @@ func TestRepoShow(t *testing.T) {
116116 "last push 2026-03-04 05:06\n"+
117117 "merge modes fast-forward, squash\n"+
118118 "failing checks block true\n"+
119+ "allow auto-merge false\n"+
119120 "issues off\n", res.stdout)
120121 _, reqs := f.snapshot()
121122 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
122123
123124 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
124- assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}`, res.stdout)
125+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
125126 }
126127
127128 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -177,7 +178,7 @@ func TestRepoCreateJSON(t *testing.T) {
177178 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
178179 }}
179180 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
180- assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}`, res.stdout)
181+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
181182 }
182183
183184 func TestRepoEditMask(t *testing.T) {
@@ -215,6 +216,13 @@ func TestRepoEditMask(t *testing.T) {
215216 {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
216217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
217218 }},
219+ {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
220+ assert.True(t, r.GetSettings().GetAllowAutoMerge())
221+ assert.False(t, r.GetSettings().GetFailingChecksBlock())
222+ }},
223+ {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
224+ assert.False(t, r.GetSettings().GetAllowAutoMerge())
225+ }},
218226 {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
219227 []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
220228 }
@@ -436,3 +444,25 @@ func TestToJSONRepoFromProto(t *testing.T) {
436444 assert.Equal(t, "private", r.Visibility)
437445 assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)
438446 }
447+
448+func TestRepoEditAllowAutoMerge(t *testing.T) {
449+ updated := &ermite.Repo{Name: "alpha", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, DefaultBranch: "main",
450+ Settings: &ermite.RepoSettings{AllowAutoMerge: true}}
451+ f := &fakeAPI{updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
452+ return &ermite.UpdateRepoResponse{Repo: updated}, nil
453+ }}
454+ url := f.serve(t)
455+
456+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge").ok(t)
457+ assert.Regexp(t, `(?m)^allow auto-merge\s+true$`, res.stdout)
458+ _, reqs := f.snapshot()
459+ got := reqs[0].(*ermite.UpdateRepoRequest)
460+ assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
461+ assert.True(t, got.GetSettings().GetAllowAutoMerge())
462+
463+ res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t)
464+ assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
465+ _, reqs = f.snapshot()
466+ got = reqs[1].(*ermite.UpdateRepoRequest)
467+ assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths())
468+}
M internal/app/cli_version.go +1 -0
@@ -126,4 +126,5 @@ var featureUse = map[string]string{
126126 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",
127127 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
128128 client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server",
129+ client.FeatureAutoMerge: "`ermite mr merge --auto` and `repo edit --allow-auto-merge` need: the commands are refused",
129130 }
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
M internal/app/cli_watch.go +25 -3
@@ -34,6 +34,10 @@ type jsonEvent struct {
3434 // State and FailureClass are set for a mirror_failed event.
3535 State string `json:"state,omitempty"`
3636 FailureClass string `json:"failure_class,omitempty"`
37+ // Auto is set for merge_request_merged: auto-merge made the merge.
38+ Auto bool `json:"auto,omitempty"`
39+ // Reason is set for merge_request_auto_merge_cancelled.
40+ Reason string `json:"reason,omitempty"`
3741 }
3842
3943 type jsonRefUpdate struct {
@@ -53,6 +57,8 @@ const (
5357 eventMROpened = "merge_request_opened"
5458 eventMRMerged = "merge_request_merged"
5559 eventMRClosed = "merge_request_closed"
60+ eventMRAutoEnabled = "merge_request_auto_merge_enabled"
61+ eventMRAutoCancelled = "merge_request_auto_merge_cancelled"
5662 eventCommentAdded = "comment_added"
5763 eventCommentEdited = "comment_edited"
5864 eventCommentDeleted = "comment_deleted"
@@ -87,6 +93,14 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
8793 case *ermite.Event_MergeRequestMerged:
8894 m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
8995 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
96+ out.Auto = k.MergeRequestMerged.GetAuto()
97+ case *ermite.Event_MergeRequestAutoMergeEnabled:
98+ m := toJSONMergeRequest(k.MergeRequestAutoMergeEnabled.GetMergeRequest(), baseURL)
99+ out.Type, out.Repo, out.MergeRequest = eventMRAutoEnabled, m.Repo, &m
100+ case *ermite.Event_MergeRequestAutoMergeCancelled:
101+ m := toJSONMergeRequest(k.MergeRequestAutoMergeCancelled.GetMergeRequest(), baseURL)
102+ out.Type, out.Repo, out.MergeRequest = eventMRAutoCancelled, m.Repo, &m
103+ out.Reason = k.MergeRequestAutoMergeCancelled.GetReason()
90104 case *ermite.Event_MergeRequestClosed:
91105 m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
92106 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
@@ -140,6 +154,13 @@ func eventLine(e jsonEvent) string {
140154 detail = e.Repo + " " + strings.Join(parts, ", ")
141155 case eventMROpened, eventMRMerged, eventMRClosed:
142156 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
157+ if e.Auto {
158+ detail += " (auto-merge)"
159+ }
160+ case eventMRAutoEnabled:
161+ detail = fmt.Sprintf("%s!%d %s when ready", e.Repo, e.MergeRequest.Number, e.MergeRequest.AutoMerge.Mode)
162+ case eventMRAutoCancelled:
163+ detail = fmt.Sprintf("%s!%d: %s", e.Repo, e.MergeRequest.Number, e.Reason)
143164 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
144165 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
145166 case eventMRMergeability:
@@ -198,18 +219,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
198219 Use: "watch [repo]",
199220 Short: "Print repository events as they happen",
200221 Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" +
201- "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" +
222+ "opened, merged (by auto-merge or not) or closed, auto-merge enabled or cancelled, notes added, edited, deleted or resolved) for every\n" +
202223 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
203224 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
204225 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
205226 "error.\n\n" +
206227 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
207228 "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" +
208- "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
229+ "merge_request_closed, merge_request_auto_merge_enabled, merge_request_auto_merge_cancelled\n" +
230+ "(with reason), comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
209231 "and, depending on the type, repo (name),\n" +
210232 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
211233 "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" +
212- "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" +
234+ "`mr show --json`; merge_request_merged also has auto, true for an auto-merge) and, for comment events, number (of the merge request) and comment. Unknown\n" +
213235 "event types are skipped.",
214236 Args: cobra.MaximumNArgs(1),
215237 ValidArgsFunction: completeRepoNames(env),
M internal/app/cli_watch_test.go +55 -1
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}}`, lines[0])
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}}`, lines[0])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
@@ -283,3 +283,57 @@ func TestMirrorWatchEvents(t *testing.T) {
283283 assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284284 assert.NotContains(t, eventLine(recovered), " \n")
285285 }
286+
287+func TestWatchAutoMergeEvents(t *testing.T) {
288+ events := []*ermite.WatchResponse{
289+ evt("1", func(e *ermite.Event) {
290+ e.Kind = &ermite.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &ermite.MergeRequestAutoMergeEnabled{MergeRequest: &ermite.MergeRequest{
291+ Repo: "alpha", Number: 1, SourceBranch: "feat/x", TargetBranch: "main",
292+ AutoMerge: &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner"},
293+ }}}
294+ }),
295+ evt("2", func(e *ermite.Event) {
296+ e.Kind = &ermite.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &ermite.MergeRequestAutoMergeCancelled{MergeRequest: mrOne, Reason: "the branch moved"}}
297+ }),
298+ evt("3", func(e *ermite.Event) {
299+ e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne, Auto: true}}
300+ }),
301+ evt("4", func(e *ermite.Event) {
302+ e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne}}
303+ }),
304+ }
305+ f := &fakeAPI{watch: finishing(events)}
306+ url := f.serve(t)
307+
308+ res := runCLI(t, envFor(url), "", "watch")
309+ require.Error(t, res.err)
310+ assert.Equal(t, ""+
311+ "2026-10-10T12:00:00Z merge_request_auto_merge_enabled alpha!1 squash when ready\n"+
312+ "2026-10-10T12:00:00Z merge_request_auto_merge_cancelled alpha!1: the branch moved\n"+
313+ "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main (auto-merge)\n"+
314+ "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main\n", res.stdout)
315+
316+ res = runCLI(t, envFor(url), "", "watch", "--json")
317+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
318+ require.Len(t, lines, 4)
319+ var enabled, cancelled, auto, manual struct {
320+ Type string `json:"type"`
321+ Reason string `json:"reason"`
322+ Auto bool `json:"auto"`
323+ MergeRequest *struct {
324+ AutoMerge *struct {
325+ Mode string `json:"mode"`
326+ } `json:"auto_merge"`
327+ } `json:"merge_request"`
328+ }
329+ for i, dst := range []any{&enabled, &cancelled, &auto, &manual} {
330+ require.NoError(t, json.Unmarshal([]byte(lines[i]), dst))
331+ }
332+ assert.Equal(t, "merge_request_auto_merge_enabled", enabled.Type)
333+ require.NotNil(t, enabled.MergeRequest.AutoMerge)
334+ assert.Equal(t, "squash", enabled.MergeRequest.AutoMerge.Mode)
335+ assert.Equal(t, "merge_request_auto_merge_cancelled", cancelled.Type)
336+ assert.Equal(t, "the branch moved", cancelled.Reason)
337+ assert.True(t, auto.Auto)
338+ assert.False(t, manual.Auto)
339+}
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
@@ -370,6 +371,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
370371 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
371372 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
372373 })
374+ f.AutoMerge = usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{
375+ MRs: f.MergeRequests, Store: store.MergeRequests(), Meta: store, Git: git,
376+ Checks: f.CI, Activity: f.CI, Tokens: store.Tokens(), Clock: clock,
377+ })
378+ f.MergeRequests.WithAutoMerge(f.AutoMerge)
379+ f.CI.SetAutoMergeTrigger(f.AutoMerge)
380+ f.Repos.WithAutoMerge(f.AutoMerge).WithMergeGate(f.MergeRequests)
381+ f.Mergeability.WithAutoMerge(f.AutoMerge)
373382 f.Mergeability.Start()
374383 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
375384 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
@@ -393,6 +402,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
393402 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
394403 })
395404 f.CI.SetPullGate(f.MergeRequests)
405+ f.AutoMerge.Start()
396406 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
397407
398408 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
@@ -545,6 +555,9 @@ func (f *forge) Close(ctx context.Context) {
545555 if f.hooks != nil {
546556 f.hooks.Close(ctx)
547557 }
558+ if f.AutoMerge != nil {
559+ f.AutoMerge.Close(ctx)
560+ }
548561 if f.MergeRequests != nil {
549562 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
550563 }
M internal/domain/event.go +28 -0
@@ -131,11 +131,39 @@ type MergeRequestMerged struct {
131131 At time.Time
132132 Visibility Visibility
133133 CreatedAt time.Time
134+ // Auto says that auto-merge made the merge.
135+ Auto bool
134136 }
135137
136138 // EventKind implements Event.
137139 func (MergeRequestMerged) EventKind() string { return "mr.merged" }
138140
141+// MergeRequestAutoMergeEnabled is published when auto-merge was enabled on a
142+// merge request.
143+type MergeRequestAutoMergeEnabled struct {
144+ MergeRequest MergeRequest
145+ At time.Time
146+ Visibility Visibility
147+ CreatedAt time.Time
148+}
149+
150+// EventKind implements Event.
151+func (MergeRequestAutoMergeEnabled) EventKind() string { return "mr.auto_merge_enabled" }
152+
153+// MergeRequestAutoMergeCancelled is published when auto-merge was cancelled
154+// on a merge request, by hand or by the server.
155+type MergeRequestAutoMergeCancelled struct {
156+ MergeRequest MergeRequest
157+ // Reason says why, in words.
158+ Reason string
159+ At time.Time
160+ Visibility Visibility
161+ CreatedAt time.Time
162+}
163+
164+// EventKind implements Event.
165+func (MergeRequestAutoMergeCancelled) EventKind() string { return "mr.auto_merge_cancelled" }
166+
139167 // MergeRequestClosed is published when a merge request was closed without
140168 // merging.
141169 type MergeRequestClosed struct {
M internal/domain/event_test.go +7 -0
@@ -52,3 +52,10 @@ func TestEventKinds(t *testing.T) {
5252 assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind())
5353 assert.Equal(t, "gap", domain.EventGap{}.EventKind())
5454 }
55+
56+func TestAutoMergeEventKinds(t *testing.T) {
57+ t.Parallel()
58+ assert.Equal(t, "mr.auto_merge_enabled", domain.MergeRequestAutoMergeEnabled{}.EventKind())
59+ assert.Equal(t, "mr.auto_merge_cancelled", domain.MergeRequestAutoMergeCancelled{}.EventKind())
60+ assert.Equal(t, "mr.merged", domain.MergeRequestMerged{Auto: true}.EventKind())
61+}
M internal/domain/mergerequest.go +21 -0
@@ -50,6 +50,9 @@ type RepoSettings struct {
5050 FailingChecksBlock bool
5151 // Issues says who may write issues; the empty mode means IssuesOff.
5252 Issues IssuesMode
53+ // AllowAutoMerge says that merge requests may be set to merge themselves
54+ // once their checks pass. Off by default.
55+ AllowAutoMerge bool
5356 }
5457
5558 // DefaultRepoSettings are the settings of a new repository: every mode, and
@@ -139,11 +142,29 @@ type MergeRequest struct {
139142 MergeMode MergeMode
140143 // CommentSeq is the id of the last comment of the request.
141144 CommentSeq int64
145+ // AutoMerge is the pending auto-merge; nil when off.
146+ AutoMerge *AutoMerge
147+ // AutoMerged says that the request was merged by auto-merge.
148+ AutoMerged bool
142149 // Version is set by the store on read: Update fails with ErrConflict when
143150 // the stored request changed since. Opaque to everyone else.
144151 Version uint64
145152 }
146153
154+// AutoMerge is the pending auto-merge of an open merge request.
155+type AutoMerge struct {
156+ // Mode is the merge mode chosen when auto-merge was enabled.
157+ Mode MergeMode
158+ // HeadID is the head it was enabled for; a new head cancels it.
159+ HeadID ObjectID
160+ // EnabledBy is who enabled it; the merge is made on their behalf.
161+ EnabledBy CIPusher
162+ // EnablerLabel is the display name of the enabler.
163+ EnablerLabel string
164+ // EnabledAt is when auto-merge was enabled.
165+ EnabledAt time.Time
166+}
167+
147168 // draftPrefixes mark a draft at the start of a title, compared without regard
148169 // to case, as Forgejo and Gitea do.
149170 var draftPrefixes = []string{"wip:", "[wip]"}
M internal/domain/mergerequest_test.go +6 -0
@@ -330,3 +330,9 @@ func TestMergeabilityAllowsAndIsFor(t *testing.T) {
330330 assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331331 assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332332 }
333+
334+func TestRepoSettingsAllowAutoMergeDefaultOff(t *testing.T) {
335+ t.Parallel()
336+ assert.False(t, domain.DefaultRepoSettings().AllowAutoMerge)
337+ assert.False(t, domain.RepoSettings{}.AllowAutoMerge)
338+}
M internal/ports/ci.go +20 -0
@@ -367,6 +367,18 @@ type PullGate interface {
367367 WhileOpen(ctx context.Context, repo domain.RepoName, number int64, fn func() error) error
368368 }
369369
370+// MergeGate serializes a change with the merges of a repository.
371+// *usecase.MergeRequests implements it.
372+// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
373+// store transaction or a CI lock may take the merge lock. MergeRequests never
374+// calls Repos.Update.
375+type MergeGate interface {
376+ // WithMergeLock runs fn holding the repository merge lock (also taken by
377+ // Merge, Close and auto-merge). fn must be short and must not call the
378+ // merge request use cases.
379+ WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error
380+}
381+
370382 // PullGit is what the refs/pull/<n>/{head,merge} of the merge requests need
371383 // from Git. Every method returns domain.ErrNotFound when the repository has no
372384 // directory.
@@ -896,3 +908,11 @@ type JobSettings struct {
896908 type CIJobSettings interface {
897909 Resolve(ctx context.Context, req JobSettingsRequest) (JobSettings, error)
898910 }
911+
912+// CIActivity tells whether CI still has queued work that may create runs for a
913+// head. *usecase.CI implements it.
914+type CIActivity interface {
915+ // Settled reports that no push trigger of the repository and no pull event
916+ // of the merge request is queued or being planned.
917+ Settled(ctx context.Context, repo domain.RepoName, number int64) bool
918+}
M internal/ports/dto.go +11 -0
@@ -51,6 +51,17 @@ type UpdateRepoParams struct {
5151 FailingChecksBlock *bool
5252 // Issues replaces the issues mode.
5353 Issues *domain.IssuesMode
54+ // AllowAutoMerge replaces the setting of that name.
55+ AllowAutoMerge *bool
56+}
57+
58+// EnableAutoMergeParams are the settings of EnableAutoMerge.
59+type EnableAutoMergeParams struct {
60+ // Mode is the merge mode to use; empty means fast-forward.
61+ Mode domain.MergeMode
62+ // HeadID is the head the caller saw; required. Auto-merge is enabled only
63+ // if the source branch still points to it.
64+ HeadID domain.ObjectID
5465 }
5566
5667 // TokenPage is a page of stored tokens, newest first.
A internal/ports/mocks/AutoMergeTrigger.go +140 -0
@@ -0,0 +1,140 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewAutoMergeTrigger creates a new instance of AutoMergeTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewAutoMergeTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *AutoMergeTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &AutoMergeTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// AutoMergeTrigger is an autogenerated mock type for the AutoMergeTrigger type
38+type AutoMergeTrigger struct {
39+ mock.Mock
40+}
41+
42+type AutoMergeTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *AutoMergeTrigger) EXPECT() *AutoMergeTrigger_Expecter {
47+ return &AutoMergeTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnAutoMergeEnabled provides a mock function for the type AutoMergeTrigger
51+func (_mock *AutoMergeTrigger) OnAutoMergeEnabled(ctx context.Context, repo domain.RepoName) {
52+ _mock.Called(ctx, repo)
53+ return
54+}
55+
56+// AutoMergeTrigger_OnAutoMergeEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnAutoMergeEnabled'
57+type AutoMergeTrigger_OnAutoMergeEnabled_Call struct {
58+ *mock.Call
59+}
60+
61+// OnAutoMergeEnabled is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+func (_e *AutoMergeTrigger_Expecter) OnAutoMergeEnabled(ctx any, repo any) *AutoMergeTrigger_OnAutoMergeEnabled_Call {
65+ return &AutoMergeTrigger_OnAutoMergeEnabled_Call{Call: _e.mock.On("OnAutoMergeEnabled", ctx, repo)}
66+}
67+
68+func (_c *AutoMergeTrigger_OnAutoMergeEnabled_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *AutoMergeTrigger_OnAutoMergeEnabled_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ var arg0 context.Context
71+ if args[0] != nil {
72+ arg0 = args[0].(context.Context)
73+ }
74+ var arg1 domain.RepoName
75+ if args[1] != nil {
76+ arg1 = args[1].(domain.RepoName)
77+ }
78+ run(
79+ arg0,
80+ arg1,
81+ )
82+ })
83+ return _c
84+}
85+
86+func (_c *AutoMergeTrigger_OnAutoMergeEnabled_Call) Return() *AutoMergeTrigger_OnAutoMergeEnabled_Call {
87+ _c.Call.Return()
88+ return _c
89+}
90+
91+func (_c *AutoMergeTrigger_OnAutoMergeEnabled_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName)) *AutoMergeTrigger_OnAutoMergeEnabled_Call {
92+ _c.Run(run)
93+ return _c
94+}
95+
96+// OnRepoChanged provides a mock function for the type AutoMergeTrigger
97+func (_mock *AutoMergeTrigger) OnRepoChanged(ctx context.Context, repo domain.RepoName) {
98+ _mock.Called(ctx, repo)
99+ return
100+}
101+
102+// AutoMergeTrigger_OnRepoChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnRepoChanged'
103+type AutoMergeTrigger_OnRepoChanged_Call struct {
104+ *mock.Call
105+}
106+
107+// OnRepoChanged is a helper method to define mock.On call
108+// - ctx context.Context
109+// - repo domain.RepoName
110+func (_e *AutoMergeTrigger_Expecter) OnRepoChanged(ctx any, repo any) *AutoMergeTrigger_OnRepoChanged_Call {
111+ return &AutoMergeTrigger_OnRepoChanged_Call{Call: _e.mock.On("OnRepoChanged", ctx, repo)}
112+}
113+
114+func (_c *AutoMergeTrigger_OnRepoChanged_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *AutoMergeTrigger_OnRepoChanged_Call {
115+ _c.Call.Run(func(args mock.Arguments) {
116+ var arg0 context.Context
117+ if args[0] != nil {
118+ arg0 = args[0].(context.Context)
119+ }
120+ var arg1 domain.RepoName
121+ if args[1] != nil {
122+ arg1 = args[1].(domain.RepoName)
123+ }
124+ run(
125+ arg0,
126+ arg1,
127+ )
128+ })
129+ return _c
130+}
131+
132+func (_c *AutoMergeTrigger_OnRepoChanged_Call) Return() *AutoMergeTrigger_OnRepoChanged_Call {
133+ _c.Call.Return()
134+ return _c
135+}
136+
137+func (_c *AutoMergeTrigger_OnRepoChanged_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName)) *AutoMergeTrigger_OnRepoChanged_Call {
138+ _c.Run(run)
139+ return _c
140+}
A internal/ports/mocks/CIActivity.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCIActivity creates a new instance of CIActivity. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCIActivity(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CIActivity {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CIActivity{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CIActivity is an autogenerated mock type for the CIActivity type
38+type CIActivity struct {
39+ mock.Mock
40+}
41+
42+type CIActivity_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CIActivity) EXPECT() *CIActivity_Expecter {
47+ return &CIActivity_Expecter{mock: &_m.Mock}
48+}
49+
50+// Settled provides a mock function for the type CIActivity
51+func (_mock *CIActivity) Settled(ctx context.Context, repo domain.RepoName, number int64) bool {
52+ ret := _mock.Called(ctx, repo, number)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Settled")
56+ }
57+
58+ var r0 bool
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) bool); ok {
60+ r0 = returnFunc(ctx, repo, number)
61+ } else {
62+ r0 = ret.Get(0).(bool)
63+ }
64+ return r0
65+}
66+
67+// CIActivity_Settled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Settled'
68+type CIActivity_Settled_Call struct {
69+ *mock.Call
70+}
71+
72+// Settled is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - number int64
76+func (_e *CIActivity_Expecter) Settled(ctx any, repo any, number any) *CIActivity_Settled_Call {
77+ return &CIActivity_Settled_Call{Call: _e.mock.On("Settled", ctx, repo, number)}
78+}
79+
80+func (_c *CIActivity_Settled_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *CIActivity_Settled_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.RepoName
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.RepoName)
89+ }
90+ var arg2 int64
91+ if args[2] != nil {
92+ arg2 = args[2].(int64)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *CIActivity_Settled_Call) Return(b bool) *CIActivity_Settled_Call {
104+ _c.Call.Return(b)
105+ return _c
106+}
107+
108+func (_c *CIActivity_Settled_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) bool) *CIActivity_Settled_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
A internal/ports/mocks/MergeGate.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeGate creates a new instance of MergeGate. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeGate(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeGate {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeGate{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeGate is an autogenerated mock type for the MergeGate type
38+type MergeGate struct {
39+ mock.Mock
40+}
41+
42+type MergeGate_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeGate) EXPECT() *MergeGate_Expecter {
47+ return &MergeGate_Expecter{mock: &_m.Mock}
48+}
49+
50+// WithMergeLock provides a mock function for the type MergeGate
51+func (_mock *MergeGate) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
52+ ret := _mock.Called(ctx, repo, fn)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for WithMergeLock")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func() error) error); ok {
60+ r0 = returnFunc(ctx, repo, fn)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// MergeGate_WithMergeLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithMergeLock'
68+type MergeGate_WithMergeLock_Call struct {
69+ *mock.Call
70+}
71+
72+// WithMergeLock is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - fn func() error
76+func (_e *MergeGate_Expecter) WithMergeLock(ctx any, repo any, fn any) *MergeGate_WithMergeLock_Call {
77+ return &MergeGate_WithMergeLock_Call{Call: _e.mock.On("WithMergeLock", ctx, repo, fn)}
78+}
79+
80+func (_c *MergeGate_WithMergeLock_Call) Run(run func(ctx context.Context, repo domain.RepoName, fn func() error)) *MergeGate_WithMergeLock_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.RepoName
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.RepoName)
89+ }
90+ var arg2 func() error
91+ if args[2] != nil {
92+ arg2 = args[2].(func() error)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeGate_WithMergeLock_Call) Return(err error) *MergeGate_WithMergeLock_Call {
104+ _c.Call.Return(err)
105+ return _c
106+}
107+
108+func (_c *MergeGate_WithMergeLock_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, fn func() error) error) *MergeGate_WithMergeLock_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
M internal/ports/mocks/MergeRequestService.go +162 -0
@@ -132,6 +132,84 @@ func (_c *MergeRequestService_AddComment_Call) RunAndReturn(run func(ctx context
132132 return _c
133133 }
134134
135+// CancelAutoMerge provides a mock function for the type MergeRequestService
136+func (_mock *MergeRequestService) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
137+ ret := _mock.Called(ctx, p, name, number)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for CancelAutoMerge")
141+ }
142+
143+ var r0 domain.MergeRequest
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.MergeRequest, error)); ok {
146+ return returnFunc(ctx, p, name, number)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.MergeRequest); ok {
149+ r0 = returnFunc(ctx, p, name, number)
150+ } else {
151+ r0 = ret.Get(0).(domain.MergeRequest)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
154+ r1 = returnFunc(ctx, p, name, number)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// MergeRequestService_CancelAutoMerge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelAutoMerge'
162+type MergeRequestService_CancelAutoMerge_Call struct {
163+ *mock.Call
164+}
165+
166+// CancelAutoMerge is a helper method to define mock.On call
167+// - ctx context.Context
168+// - p domain.Principal
169+// - name domain.RepoName
170+// - number int64
171+func (_e *MergeRequestService_Expecter) CancelAutoMerge(ctx any, p any, name any, number any) *MergeRequestService_CancelAutoMerge_Call {
172+ return &MergeRequestService_CancelAutoMerge_Call{Call: _e.mock.On("CancelAutoMerge", ctx, p, name, number)}
173+}
174+
175+func (_c *MergeRequestService_CancelAutoMerge_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_CancelAutoMerge_Call {
176+ _c.Call.Run(func(args mock.Arguments) {
177+ var arg0 context.Context
178+ if args[0] != nil {
179+ arg0 = args[0].(context.Context)
180+ }
181+ var arg1 domain.Principal
182+ if args[1] != nil {
183+ arg1 = args[1].(domain.Principal)
184+ }
185+ var arg2 domain.RepoName
186+ if args[2] != nil {
187+ arg2 = args[2].(domain.RepoName)
188+ }
189+ var arg3 int64
190+ if args[3] != nil {
191+ arg3 = args[3].(int64)
192+ }
193+ run(
194+ arg0,
195+ arg1,
196+ arg2,
197+ arg3,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *MergeRequestService_CancelAutoMerge_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_CancelAutoMerge_Call {
204+ _c.Call.Return(mergeRequest, err)
205+ return _c
206+}
207+
208+func (_c *MergeRequestService_CancelAutoMerge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)) *MergeRequestService_CancelAutoMerge_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
135213 // Close provides a mock function for the type MergeRequestService
136214 func (_mock *MergeRequestService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
137215 ret := _mock.Called(ctx, p, name, number)
@@ -618,6 +696,90 @@ func (_c *MergeRequestService_EditComment_Call) RunAndReturn(run func(ctx contex
618696 return _c
619697 }
620698
699+// EnableAutoMerge provides a mock function for the type MergeRequestService
700+func (_mock *MergeRequestService) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
701+ ret := _mock.Called(ctx, p, name, number, params)
702+
703+ if len(ret) == 0 {
704+ panic("no return value specified for EnableAutoMerge")
705+ }
706+
707+ var r0 domain.MergeRequest
708+ var r1 error
709+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) (domain.MergeRequest, error)); ok {
710+ return returnFunc(ctx, p, name, number, params)
711+ }
712+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) domain.MergeRequest); ok {
713+ r0 = returnFunc(ctx, p, name, number, params)
714+ } else {
715+ r0 = ret.Get(0).(domain.MergeRequest)
716+ }
717+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) error); ok {
718+ r1 = returnFunc(ctx, p, name, number, params)
719+ } else {
720+ r1 = ret.Error(1)
721+ }
722+ return r0, r1
723+}
724+
725+// MergeRequestService_EnableAutoMerge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableAutoMerge'
726+type MergeRequestService_EnableAutoMerge_Call struct {
727+ *mock.Call
728+}
729+
730+// EnableAutoMerge is a helper method to define mock.On call
731+// - ctx context.Context
732+// - p domain.Principal
733+// - name domain.RepoName
734+// - number int64
735+// - params ports.EnableAutoMergeParams
736+func (_e *MergeRequestService_Expecter) EnableAutoMerge(ctx any, p any, name any, number any, params any) *MergeRequestService_EnableAutoMerge_Call {
737+ return &MergeRequestService_EnableAutoMerge_Call{Call: _e.mock.On("EnableAutoMerge", ctx, p, name, number, params)}
738+}
739+
740+func (_c *MergeRequestService_EnableAutoMerge_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams)) *MergeRequestService_EnableAutoMerge_Call {
741+ _c.Call.Run(func(args mock.Arguments) {
742+ var arg0 context.Context
743+ if args[0] != nil {
744+ arg0 = args[0].(context.Context)
745+ }
746+ var arg1 domain.Principal
747+ if args[1] != nil {
748+ arg1 = args[1].(domain.Principal)
749+ }
750+ var arg2 domain.RepoName
751+ if args[2] != nil {
752+ arg2 = args[2].(domain.RepoName)
753+ }
754+ var arg3 int64
755+ if args[3] != nil {
756+ arg3 = args[3].(int64)
757+ }
758+ var arg4 ports.EnableAutoMergeParams
759+ if args[4] != nil {
760+ arg4 = args[4].(ports.EnableAutoMergeParams)
761+ }
762+ run(
763+ arg0,
764+ arg1,
765+ arg2,
766+ arg3,
767+ arg4,
768+ )
769+ })
770+ return _c
771+}
772+
773+func (_c *MergeRequestService_EnableAutoMerge_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_EnableAutoMerge_Call {
774+ _c.Call.Return(mergeRequest, err)
775+ return _c
776+}
777+
778+func (_c *MergeRequestService_EnableAutoMerge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error)) *MergeRequestService_EnableAutoMerge_Call {
779+ _c.Call.Return(run)
780+ return _c
781+}
782+
621783 // Get provides a mock function for the type MergeRequestService
622784 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
623785 ret := _mock.Called(ctx, p, name, number, opts)
M internal/ports/ports.go +7 -0
@@ -437,6 +437,13 @@ type MergeGit interface {
437437 CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
438438 }
439439
440+// AutoMergeTrigger asks the auto-merge evaluator to look at a repository
441+// again. It only marks the repository and returns.
442+type AutoMergeTrigger interface {
443+ OnRepoChanged(ctx context.Context, repo domain.RepoName)
444+ OnAutoMergeEnabled(ctx context.Context, repo domain.RepoName)
445+}
446+
440447 // MergeabilityTrigger queues mergeability checks. It only queues the work and
441448 // returns.
442449 type MergeabilityTrigger interface {
M internal/ports/services.go +5 -0
@@ -125,6 +125,11 @@ type MergeRequestService interface {
125125 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)
126126 // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix.
127127 MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
128+ // EnableAutoMerge sets an open merge request to merge itself once its
129+ // checks pass; enabling again replaces the mode and the head.
130+ EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params EnableAutoMergeParams) (domain.MergeRequest, error)
131+ // CancelAutoMerge turns auto-merge off on an open merge request.
132+ CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
128133 // CountOpen is the number of open merge requests of a repository the
129134 // principal can read. It reads no Git: the tab of every page shows it.
130135 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
A internal/usecase/automerge.go +413 -0
@@ -0,0 +1,413 @@
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+ Clock ports.Clock
28+ After func(time.Duration) <-chan time.Time
29+}
30+
31+// AutoMerge serializes evaluation within each repository. Triggers only enqueue
32+// work, including when invoked while CI or merge-request locks are held.
33+type AutoMerge struct {
34+ cfg AutoMergeConfig
35+ ctx context.Context
36+ cancel context.CancelFunc
37+ wg sync.WaitGroup
38+ mu sync.Mutex
39+ pending map[domain.RepoName]bool
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+ pending: map[domain.RepoName]bool{}, 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.OnAutoMergeEnabled(s.ctx, 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.pending[repo] {
118+ s.mu.Unlock()
119+ return
120+ }
121+ if s.running[repo] {
122+ s.again[repo] = true
123+ } else if !s.dirty[repo] {
124+ s.dirty[repo] = true
125+ s.order = append(s.order, repo)
126+ }
127+ s.mu.Unlock()
128+ s.signal()
129+}
130+
131+func (s *AutoMerge) next() (domain.RepoName, bool) {
132+ s.mu.Lock()
133+ defer s.mu.Unlock()
134+ if len(s.order) == 0 {
135+ return domain.RepoName{}, false
136+ }
137+ repo := s.order[0]
138+ s.order = s.order[1:]
139+ delete(s.dirty, repo)
140+ s.running[repo] = true
141+ if len(s.order) > 0 {
142+ s.signal()
143+ }
144+ return repo, true
145+}
146+
147+func (s *AutoMerge) loop() {
148+ defer s.wg.Done()
149+ for s.ctx.Err() == nil {
150+ repo, found := s.next()
151+ if !found {
152+ select {
153+ case <-s.ctx.Done():
154+ return
155+ case <-s.wake:
156+ }
157+ continue
158+ }
159+ if s.evaluate(repo) {
160+ s.retryDone(repo)
161+ } else {
162+ s.retry(repo)
163+ }
164+ s.mu.Lock()
165+ delete(s.running, repo)
166+ if s.again[repo] {
167+ delete(s.again, repo)
168+ s.dirty[repo] = true
169+ s.order = append(s.order, repo)
170+ s.signal()
171+ }
172+ s.mu.Unlock()
173+ }
174+}
175+
176+// OnRepoChanged marks and wakes only; it never merges on the caller's stack.
177+func (s *AutoMerge) OnRepoChanged(_ context.Context, repo domain.RepoName) { s.markDirty(repo) }
178+
179+// OnAutoMergeEnabled remembers an intent before queuing evaluation.
180+func (s *AutoMerge) OnAutoMergeEnabled(_ context.Context, repo domain.RepoName) {
181+ s.mu.Lock()
182+ s.pending[repo] = true
183+ if s.running[repo] {
184+ s.again[repo] = true
185+ }
186+ s.mu.Unlock()
187+ s.markDirty(repo)
188+}
189+
190+func (s *AutoMerge) evaluate(repo domain.RepoName) bool {
191+ err := s.evaluateRepo(repo)
192+ if err != nil && s.ctx.Err() == nil {
193+ ctxLog(s.ctx).Error().Err(err).Str("repo", repo.String()).Msg("auto-merge: evaluate repository")
194+ }
195+ return err == nil || s.ctx.Err() != nil
196+}
197+
198+func (s *AutoMerge) evaluateRepo(repo domain.RepoName) error {
199+ ctx := s.ctx
200+ meta, err := s.cfg.Meta.Get(ctx, repo)
201+ if errors.Is(err, domain.ErrNotFound) {
202+ return nil
203+ }
204+ if err != nil {
205+ return err
206+ }
207+ mrs, err := s.cfg.Store.ListOpen(ctx, repo)
208+ if err != nil {
209+ return err
210+ }
211+ hasIntent := slices.ContainsFunc(mrs, func(mr domain.MergeRequest) bool { return mr.AutoMerge != nil })
212+ if !hasIntent {
213+ s.mu.Lock()
214+ // An enable arriving during this scan requests another pass. Do not
215+ // erase its mark based on the older store snapshot.
216+ if !s.again[repo] {
217+ delete(s.pending, repo)
218+ }
219+ s.mu.Unlock()
220+ }
221+ var failures []error
222+ for _, mr := range mrs {
223+ if ctx.Err() != nil {
224+ return nil
225+ }
226+ if mr.AutoMerge == nil {
227+ continue
228+ }
229+ if err := s.evaluateMR(ctx, meta, mr); err != nil {
230+ failures = append(failures, err)
231+ }
232+ }
233+ return errors.Join(failures...)
234+}
235+
236+func (s *AutoMerge) evaluateMR(ctx context.Context, repo domain.Repo, mr domain.MergeRequest) error {
237+ intent := mr.AutoMerge
238+ settings := repo.EffectiveSettings()
239+ cancel := func(reason string) error { return s.cfg.MRs.cancelAuto(ctx, mr, reason) }
240+ if !settings.AllowAutoMerge {
241+ return cancel("auto-merge was turned off in the repository settings")
242+ }
243+ if !settings.Allows(intent.Mode) {
244+ return cancel(fmt.Sprintf("the %s merge mode is no longer allowed", intent.Mode))
245+ }
246+ head, err := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
247+ if errors.Is(err, domain.ErrNotFound) || (err == nil && head.IsZero()) {
248+ return cancel("the source branch was deleted")
249+ }
250+ if err != nil {
251+ return err
252+ }
253+ if head != intent.HeadID {
254+ return cancel("the branch moved to " + head.String()[:7])
255+ }
256+ // An enabler that can no longer merge cancels at once, even while CI is
257+ // pending: the intent must not stay shown as enabled. This check only
258+ // cancels; the merge itself resolves the enabler again under the merge lock.
259+ if _, err := enablerCanMerge(ctx, s.cfg.Tokens, intent, mr, s.cfg.Clock.Now()); err != nil {
260+ if errors.Is(err, domain.ErrForbidden) {
261+ return cancel(autoMergeReason(err))
262+ }
263+ return err
264+ }
265+ if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number) {
266+ return nil
267+ }
268+ checks, err := s.cfg.Checks.CommitChecks(ctx, mr.Repo, head.String())
269+ if err != nil {
270+ return err
271+ }
272+ if !hasSuccessfulCheck(checks) {
273+ return nil
274+ }
275+ _, err = s.cfg.MRs.mergeAs(ctx, domain.Principal{}, mr.Repo, mr.Number, ports.MergeParams{Mode: intent.Mode, HeadID: intent.HeadID}, mergeOpts{auto: &autoMergeOpts{intent: intent, tokens: s.cfg.Tokens, activity: s.cfg.Activity}})
276+ switch {
277+ case err == nil, errors.Is(err, errAutoMergeWait):
278+ return nil
279+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated):
280+ return cancel(autoMergeReason(err))
281+ default:
282+ return err
283+ }
284+}
285+
286+// Removing a known sentinel's presentation prefix is display formatting, not
287+// error classification (which always uses errors.Is).
288+func autoMergeReason(err error) string {
289+ text := err.Error()
290+ for _, sentinel := range []error{domain.ErrPreconditionFailed, domain.ErrForbidden, domain.ErrUnauthenticated} {
291+ text = strings.TrimPrefix(text, sentinel.Error()+": ")
292+ }
293+ return text
294+}
295+
296+func (s *AutoMerge) retry(key domain.RepoName) {
297+ s.retryMu.Lock()
298+ defer s.retryMu.Unlock()
299+ if s.closed || s.scheduled[key] {
300+ return
301+ }
302+ s.retries[key]++
303+ n := s.retries[key]
304+ if n > mergeabilityRetries {
305+ delete(s.retries, key)
306+ return
307+ }
308+ s.scheduled[key] = true
309+ it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key}
310+ i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int {
311+ if a.due.After(b.due) {
312+ return 1
313+ }
314+ return -1 // equal dues keep their insertion order
315+ })
316+ s.heap = slices.Insert(s.heap, i, it)
317+ if i == 0 {
318+ select {
319+ case s.retryKick <- struct{}{}:
320+ default:
321+ }
322+ }
323+}
324+
325+// scheduler is the only goroutine that waits for retries: it sleeps until the
326+// earliest is due and puts it back in the normal queue.
327+func (s *AutoMerge) scheduler() {
328+ defer s.wg.Done()
329+ // The timer armed for waited; a kick re-arms it only when the earliest
330+ // retry changed, so a stale kick never starts a second timer.
331+ var (
332+ after <-chan time.Time
333+ waited retryItem
334+ )
335+ for {
336+ s.retryMu.Lock()
337+ var head retryItem
338+ has := len(s.heap) > 0
339+ if has {
340+ head = s.heap[0]
341+ }
342+ s.retryMu.Unlock()
343+ if !has {
344+ after = nil
345+ select {
346+ case <-s.ctx.Done():
347+ return
348+ case <-s.retryKick:
349+ continue
350+ }
351+ }
352+ if after == nil || head.key != waited.key || !head.due.Equal(waited.due) {
353+ after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head
354+ }
355+ select {
356+ case <-s.ctx.Done():
357+ return
358+ case <-s.retryKick:
359+ continue // an earlier retry may have arrived
360+ case <-after:
361+ after = nil
362+ }
363+ for _, k := range s.popDue(waited) {
364+ if s.ctx.Err() != nil {
365+ return
366+ }
367+ s.markDirty(k)
368+ }
369+ }
370+}
371+
372+// popDue removes the waited item and the ones due as well. The waited item is
373+// removed regardless of the clock only when both its key and its deadline
374+// match: a replacement retry of the same repository is kept until it is due.
375+func (s *AutoMerge) popDue(waited retryItem) []domain.RepoName {
376+ s.retryMu.Lock()
377+ defer s.retryMu.Unlock()
378+ now := s.cfg.Clock.Now()
379+ var out []domain.RepoName
380+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
381+ if (it.key != waited.key || !it.due.Equal(waited.due)) && it.due.After(now) {
382+ return false
383+ }
384+ out = append(out, it.key)
385+ delete(s.scheduled, it.key)
386+ return true
387+ })
388+ return out
389+}
390+
391+// retryDone forgets the failures of key after a success.
392+func (s *AutoMerge) retryDone(key domain.RepoName) {
393+ s.retryMu.Lock()
394+ delete(s.retries, key)
395+ delete(s.scheduled, key)
396+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key })
397+ select {
398+ case s.retryKick <- struct{}{}:
399+ default:
400+ }
401+ s.retryMu.Unlock()
402+}
403+
404+func enablerCanMerge(ctx context.Context, tokens ports.TokenStore, intent *domain.AutoMerge, mr domain.MergeRequest, now time.Time) (domain.Principal, error) {
405+ fresh, valid, err := principalOfPusher(ctx, tokens, intent.EnabledBy, now)
406+ if err != nil {
407+ return domain.Principal{}, err
408+ }
409+ if !valid || !fresh.Allows(domain.ScopeMRMerge, mr.Repo) || !fresh.AllowsRef("refs/heads/"+mr.Target) {
410+ return domain.Principal{}, fmt.Errorf("%w: the principal that enabled auto-merge can no longer merge %s", domain.ErrForbidden, mr.Target)
411+ }
412+ return fresh, nil
413+}
A internal/usecase/automerge_index_test.go +97 -0
@@ -0,0 +1,97 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports/mocks"
15+)
16+
17+func TestAutoMergePendingIndex(t *testing.T) {
18+ ctx := context.Background()
19+ repo, err := domain.ParseRepoName("proj")
20+ require.NoError(t, err)
21+ store := mocks.NewMergeRequestStore(t)
22+ meta := mocks.NewRepoMetaStore(t)
23+ a := NewAutoMerge(ctx, AutoMergeConfig{Store: store, Meta: meta})
24+ defer a.Close(ctx)
25+ // No expectations: an unindexed kick must not read either store.
26+ a.OnRepoChanged(ctx, repo)
27+ _, found := a.next()
28+ require.False(t, found)
29+ a.OnAutoMergeEnabled(ctx, repo)
30+ a.OnRepoChanged(ctx, repo)
31+ got, found := a.next()
32+ require.True(t, found)
33+ require.Equal(t, repo, got)
34+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
35+ store.EXPECT().ListOpen(mock.Anything, repo).Return(nil, nil).Once()
36+ require.True(t, a.evaluate(repo))
37+ require.NotContains(t, a.pending, repo)
38+ a.mu.Lock()
39+ delete(a.running, repo)
40+ a.mu.Unlock()
41+ a.OnRepoChanged(ctx, repo)
42+ _, found = a.next()
43+ require.False(t, found)
44+}
45+
46+func TestAutoMergeEnableDuringEmptyScanKeepsPending(t *testing.T) {
47+ ctx := context.Background()
48+ repo, err := domain.ParseRepoName("proj")
49+ require.NoError(t, err)
50+ store := mocks.NewMergeRequestStore(t)
51+ meta := mocks.NewRepoMetaStore(t)
52+ a := NewAutoMerge(ctx, AutoMergeConfig{Store: store, Meta: meta})
53+ defer a.Close(ctx)
54+ a.OnAutoMergeEnabled(ctx, repo)
55+ _, found := a.next()
56+ require.True(t, found)
57+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
58+ store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) {
59+ // The returned empty snapshot predates this newly enabled intent.
60+ a.OnAutoMergeEnabled(ctx, repo)
61+ }).Return(nil, nil).Once()
62+ require.True(t, a.evaluate(repo))
63+ require.True(t, a.pending[repo])
64+ require.True(t, a.again[repo])
65+}
66+
67+func TestAutoMergeStartupFillsPendingIndex(t *testing.T) {
68+ ctx := context.Background()
69+ repo, err := domain.ParseRepoName("proj")
70+ require.NoError(t, err)
71+ store := mocks.NewMergeRequestStore(t)
72+ meta := mocks.NewRepoMetaStore(t)
73+ store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil).Once()
74+ scanned := make(chan struct{})
75+ meta.EXPECT().Get(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { close(scanned) }).Return(domain.Repo{}, domain.ErrNotFound).Once()
76+ a := NewAutoMerge(ctx, AutoMergeConfig{Store: store, Meta: meta})
77+ a.Start()
78+ defer a.Close(ctx)
79+ select {
80+ case <-scanned:
81+ case <-time.After(5 * time.Second):
82+ t.Fatal("startup did not scan the pending repository")
83+ }
84+ a.mu.Lock()
85+ pending := a.pending[repo]
86+ a.mu.Unlock()
87+ require.True(t, pending)
88+}
89+
90+func TestManualPendingChecksMessage(t *testing.T) {
91+ err := checksPolicy([]domain.CICheck{{Name: "build", Status: domain.CIQueued}})
92+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
93+ require.EqualError(t, err, "failed precondition: checks have not finished: build")
94+ require.NotContains(t, err.Error(), "auto-merge")
95+ require.NotErrorIs(t, err, errAutoMergeWait)
96+ require.NoError(t, checksPolicy(nil))
97+}
A internal/usecase/automerge_test.go +453 -0
@@ -0,0 +1,453 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+ "bnema.dev/ermite/internal/ports/mocks"
18+ "bnema.dev/ermite/internal/usecase"
19+)
20+
21+func autoRig(t *testing.T) (*mrRig, domain.MergeRequest, *mocks.CIChecksReader) {
22+ t.Helper()
23+ r := newMRRig(t)
24+ r.seed()
25+ r.feature("feat")
26+ mr := r.open("feat")
27+ checks := mocks.NewCIChecksReader(t)
28+ r.mrs.SetChecks(checks)
29+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
30+ require.NoError(t, err)
31+ return r, mr, checks
32+}
33+
34+func autoEvaluator(r *mrRig, checks ports.CIChecksReader, activity ports.CIActivity, tokens ports.TokenStore) *usecase.AutoMerge {
35+ return usecase.NewAutoMerge(r.ctx, usecase.AutoMergeConfig{MRs: r.mrs, Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Checks: checks, Activity: activity, Tokens: tokens, Clock: fixedClock{}})
36+}
37+
38+func enableAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest {
39+ t.Helper()
40+ enabled, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: r.head()})
41+ require.NoError(t, err)
42+ require.NotNil(t, enabled.AutoMerge)
43+ return enabled
44+}
45+
46+func storedAuto(t *testing.T, r *mrRig, mr domain.MergeRequest) domain.MergeRequest {
47+ t.Helper()
48+ got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
49+ require.NoError(t, err)
50+ return got
51+}
52+
53+func TestEnableAutoMergeNeedsSettingScopeAndRef(t *testing.T) {
54+ for _, tc := range []string{"setting", "scope", "ref", "draft", "no CI", "closed", "mode"} {
55+ t.Run(tc, func(t *testing.T) {
56+ r, mr, _ := autoRig(t)
57+ p := root
58+ want := domain.ErrPreconditionFailed
59+ switch tc {
60+ case "setting":
61+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
62+ require.NoError(t, err)
63+ case "scope":
64+ p = tokenPrincipal(domain.ScopeMRWrite, r.repo.String())
65+ want = domain.ErrNotFound
66+ case "ref":
67+ p = domain.TokenPrincipal(domain.Token{ID: "limited", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}, BranchPrefixes: []string{"feat"}})
68+ want = domain.ErrForbidden
69+ case "draft":
70+ mr.Title, _ = domain.ParseMRTitle("WIP: work")
71+ require.NoError(t, r.store.MergeRequests().Update(r.ctx, mr))
72+ case "no CI":
73+ r.mrs.SetChecks(nil)
74+ case "closed":
75+ _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
76+ require.NoError(t, err)
77+ case "mode":
78+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
79+ require.NoError(t, err)
80+ }
81+ _, err := r.mrs.EnableAutoMerge(r.ctx, p, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: r.head()})
82+ require.ErrorIs(t, err, want)
83+ })
84+ }
85+}
86+
87+func TestEnableAutoMergeRecordsEnablerModeHeadAndPublishes(t *testing.T) {
88+ r, mr, _ := autoRig(t)
89+ trigger := mocks.NewAutoMergeTrigger(t)
90+ trigger.EXPECT().OnAutoMergeEnabled(mock.Anything, r.repo).Return().Twice()
91+ r.mrs.WithAutoMerge(trigger)
92+ r.drain()
93+ got, err := r.mrs.EnableAutoMerge(r.ctx, domain.Owner(), r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: r.head()})
94+ require.NoError(t, err)
95+ assert.Equal(t, &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: r.head(), EnabledBy: domain.PusherOf(domain.Owner()), EnablerLabel: domain.AuthorName(domain.Owner()), EnabledAt: now}, got.AutoMerge)
96+ events := r.drain()
97+ require.Len(t, events, 1)
98+ assert.IsType(t, domain.MergeRequestAutoMergeEnabled{}, events[0])
99+ got = enableAuto(t, r, mr)
100+ assert.Equal(t, domain.PusherOf(root), got.AutoMerge.EnabledBy)
101+ assert.Equal(t, domain.MergeModeFastForward, got.AutoMerge.Mode)
102+}
103+
104+func TestCancelAutoMergePublishesReason(t *testing.T) {
105+ r, mr, _ := autoRig(t)
106+ enableAuto(t, r, mr)
107+ r.drain()
108+ got, err := r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number)
109+ require.NoError(t, err)
110+ assert.Nil(t, got.AutoMerge)
111+ events := r.drain()
112+ require.Len(t, events, 1)
113+ ev, ok := events[0].(domain.MergeRequestAutoMergeCancelled)
114+ require.True(t, ok)
115+ assert.NotEmpty(t, ev.Reason)
116+ _, err = r.mrs.CancelAutoMerge(r.ctx, root, r.repo, mr.Number)
117+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
118+}
119+
120+func TestAutoMergeWaitsWhileCINotSettledOrChecksPending(t *testing.T) {
121+ r, mr, checks := autoRig(t)
122+ enableAuto(t, r, mr)
123+ activity := mocks.NewCIActivity(t)
124+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
125+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Twice()
126+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, {Status: domain.CIQueued, Name: "pending"}}, nil)
127+ a := autoEvaluator(r, checks, activity, nil)
128+ defer a.Close(r.ctx)
129+ require.True(t, a.Evaluate(r.repo))
130+ require.True(t, a.Evaluate(r.repo))
131+ assert.True(t, storedAuto(t, r, mr).IsOpen())
132+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
133+}
134+
135+func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) {
136+ r, mr, checks := autoRig(t)
137+ trigger := mocks.NewWorkflowTrigger(t)
138+ trigger.EXPECT().OnMerge(mock.Anything, r.repo, mock.Anything, root).Return().Once()
139+ r.mrs.SetWorkflowTrigger(trigger)
140+ enableAuto(t, r, mr)
141+ // One pre-lock fast-path read and exactly one under-lock snapshot.
142+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
143+ a := autoEvaluator(r, checks, nil, nil)
144+ defer a.Close(r.ctx)
145+ r.drain()
146+ require.True(t, a.Evaluate(r.repo))
147+ got := storedAuto(t, r, mr)
148+ assert.Equal(t, domain.MRStateMerged, got.State)
149+ assert.True(t, got.AutoMerged)
150+ assert.Nil(t, got.AutoMerge)
151+ events := r.drain()
152+ found := false
153+ for _, ev := range events {
154+ if merged, ok := ev.(domain.MergeRequestMerged); ok {
155+ assert.True(t, merged.Auto)
156+ found = true
157+ }
158+ }
159+ require.True(t, found)
160+}
161+
162+func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
163+ for _, tc := range []struct {
164+ name string
165+ check domain.CICheck
166+ }{
167+ {"failure", domain.CICheck{Status: domain.CICompleted, Conclusion: domain.CIFailure}},
168+ {"pending", domain.CICheck{Status: domain.CIQueued}},
169+ } {
170+ t.Run(tc.name, func(t *testing.T) {
171+ r, mr, checks := autoRig(t)
172+ enableAuto(t, r, mr)
173+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
174+ require.NoError(t, err)
175+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
176+ a := autoEvaluator(r, checks, nil, nil)
177+ defer a.Close(r.ctx)
178+ require.True(t, a.Evaluate(r.repo))
179+ assert.True(t, storedAuto(t, r, mr).IsOpen())
180+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
181+ })
182+ }
183+}
184+
185+func TestAutoMergeCancelsOnNewHead(t *testing.T) { testAutoCancellation(t, "head") }
186+func TestAutoMergeCancelsWhenModeNoLongerAllowed(t *testing.T) { testAutoCancellation(t, "mode") }
187+func TestAutoMergeCancelsWhenSettingTurnedOff(t *testing.T) { testAutoCancellation(t, "setting") }
188+func testAutoCancellation(t *testing.T, change string) {
189+ r, mr, checks := autoRig(t)
190+ enableAuto(t, r, mr)
191+ switch change {
192+ case "head":
193+ r.commit(author, "new", "new", "new head")
194+ r.push("feat")
195+ case "mode":
196+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})})
197+ require.NoError(t, err)
198+ case "setting":
199+ _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(false)})
200+ require.NoError(t, err)
201+ }
202+ a := autoEvaluator(r, checks, nil, nil)
203+ defer a.Close(r.ctx)
204+ r.drain()
205+ require.True(t, a.Evaluate(r.repo))
206+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
207+ events := r.drain()
208+ require.Len(t, events, 1)
209+ assert.IsType(t, domain.MergeRequestAutoMergeCancelled{}, events[0])
210+}
211+
212+func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
213+ for _, expired := range []bool{false, true} {
214+ t.Run(map[bool]string{false: "revoked", true: "expired"}[expired], func(t *testing.T) {
215+ r, mr, checks := autoRig(t)
216+ enableAuto(t, r, mr)
217+ enabled := storedAuto(t, r, mr)
218+ enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
219+ require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
220+ tokens := mocks.NewTokenStore(t)
221+ // The enabler is checked before CI: no check is read.
222+ if expired {
223+ tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
224+ } else {
225+ tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{}, domain.ErrNotFound)
226+ }
227+ a := autoEvaluator(r, checks, nil, tokens)
228+ defer a.Close(r.ctx)
229+ require.True(t, a.Evaluate(r.repo))
230+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
231+ assert.True(t, storedAuto(t, r, mr).IsOpen())
232+ })
233+ }
234+}
235+
236+func TestAutoMergeWaitsOnConflictingOrBehindHead(t *testing.T) {
237+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
238+ t.Run(mode.String(), func(t *testing.T) {
239+ r, mr, checks := autoRig(t)
240+ _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{Mode: mode, HeadID: r.head()})
241+ require.NoError(t, err)
242+ head := r.head()
243+ r.sh("checkout", "main")
244+ r.commit(author, "a.txt", "conflicting target\n", "move target")
245+ r.push("main")
246+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, head.String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
247+ a := autoEvaluator(r, checks, nil, nil)
248+ defer a.Close(r.ctx)
249+ require.True(t, a.Evaluate(r.repo))
250+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
251+ assert.True(t, storedAuto(t, r, mr).IsOpen())
252+ })
253+ }
254+}
255+
256+func TestManualMergeOrCloseClearsAutoMerge(t *testing.T) {
257+ for _, closeMR := range []bool{false, true} {
258+ t.Run(map[bool]string{false: "merge", true: "close"}[closeMR], func(t *testing.T) {
259+ r, mr, checks := autoRig(t)
260+ enabled := enableAuto(t, r, mr)
261+ if closeMR {
262+ _, err := r.mrs.Close(r.ctx, root, r.repo, mr.Number)
263+ require.NoError(t, err)
264+ } else {
265+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(nil, nil)
266+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: enabled.AutoMerge.HeadID})
267+ require.NoError(t, err)
268+ }
269+ got := storedAuto(t, r, mr)
270+ assert.Nil(t, got.AutoMerge)
271+ assert.False(t, got.AutoMerged)
272+ })
273+ }
274+}
275+
276+func TestAutoMergeStartupRescan(t *testing.T) {
277+ r, mr, checks := autoRig(t)
278+ enableAuto(t, r, mr)
279+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
280+ a := autoEvaluator(r, checks, nil, nil)
281+ a.Start()
282+ defer a.Close(r.ctx)
283+ require.Eventually(t, func() bool {
284+ got, err := r.store.MergeRequests().Get(r.ctx, r.repo, mr.Number)
285+ return err == nil && got.AutoMerged
286+ }, 5*time.Second, time.Millisecond)
287+ a.Close(r.ctx)
288+ r.mrs.Wait(r.ctx)
289+}
290+
291+func TestAutoMergeRetriesTransientErrors(t *testing.T) {
292+ ctx := context.Background()
293+ store := mocks.NewMergeRequestStore(t)
294+ meta := mocks.NewRepoMetaStore(t)
295+ clock := mocks.NewClock(t)
296+ clock.EXPECT().Now().Return(now).Maybe()
297+ repo := name(t, "proj")
298+ store.EXPECT().ListOpenAll(mock.Anything).Return([]domain.MergeRequest{{Repo: repo, AutoMerge: &domain.AutoMerge{}}}, nil)
299+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{}, errors.New("temporary read error")).Once()
300+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil).Once()
301+ done := make(chan struct{}, 1)
302+ store.EXPECT().ListOpen(mock.Anything, repo).Run(func(context.Context, domain.RepoName) { done <- struct{}{} }).Return(nil, nil).Once()
303+ timer := make(chan time.Time, 1)
304+ armed := make(chan time.Duration, 1)
305+ a := usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{Store: store, Meta: meta, Clock: clock, After: func(d time.Duration) <-chan time.Time { armed <- d; return timer }})
306+ a.Start()
307+ defer a.Close(ctx)
308+ select {
309+ case d := <-armed:
310+ assert.Equal(t, time.Second, d)
311+ case <-time.After(5 * time.Second):
312+ t.Fatal("no retry scheduled")
313+ }
314+ timer <- now.Add(time.Second)
315+ select {
316+ case <-done:
317+ case <-time.After(5 * time.Second):
318+ t.Fatal("retry did not evaluate")
319+ }
320+}
321+
322+func TestAutoMergeKickFromUnderRunLockDoesNotDeadlock(t *testing.T) {
323+ r, mr, checks := autoRig(t)
324+ enableAuto(t, r, mr)
325+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
326+ a := autoEvaluator(r, checks, nil, nil)
327+ ci := newCIUnit(t, nil).ci
328+ ci.SetAutoMergeTrigger(a)
329+ gate := make(chan struct{})
330+ entered := make(chan struct{})
331+ r.mrs.SetAfterMergeGate(func() { close(entered); <-gate })
332+ defer func() { close(gate); a.Close(r.ctx); r.mrs.Wait(r.ctx) }()
333+ require.NoError(t, r.mrs.UnderRepoLock(r.ctx, r.repo, func() {
334+ a.Start()
335+ done := make(chan struct{})
336+ go func() {
337+ ci.UnderRunLock(1, func() { a.OnRepoChanged(r.ctx, r.repo) })
338+ close(done)
339+ }()
340+ select {
341+ case <-done:
342+ case <-time.After(5 * time.Second):
343+ t.Fatal("kick blocked behind merge lock")
344+ }
345+ }))
346+ select {
347+ case <-entered:
348+ case <-time.After(5 * time.Second):
349+ t.Fatal("merge never reached post-merge gate")
350+ }
351+}
352+
353+func TestAutoMergeRequiresSuccessfulCheck(t *testing.T) {
354+ for _, checksOnHead := range [][]domain.CICheck{nil, {{Status: domain.CICompleted, Conclusion: domain.CISkipped}}, {{Status: domain.CICompleted, Conclusion: domain.CINeutral}}} {
355+ r, mr, checks := autoRig(t)
356+ enableAuto(t, r, mr)
357+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(checksOnHead, nil)
358+ a := autoEvaluator(r, checks, nil, nil)
359+ require.True(t, a.Evaluate(r.repo))
360+ a.Close(r.ctx)
361+ assert.True(t, storedAuto(t, r, mr).IsOpen())
362+ assert.NotNil(t, storedAuto(t, r, mr).AutoMerge)
363+ }
364+}
365+
366+func TestEnableAutoMergeNeedsTheHeadTheCallerSaw(t *testing.T) {
367+ t.Run("stale head", func(t *testing.T) {
368+ r, mr, _ := autoRig(t)
369+ stale := r.head()
370+ r.commit(author, "later.txt", "later\n", "push after review")
371+ r.push("feat")
372+ r.drain()
373+ _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{HeadID: stale})
374+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
375+ assert.Contains(t, err.Error(), "branch moved, read again")
376+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
377+ assert.Empty(t, r.drain(), "nothing is published")
378+ })
379+ t.Run("missing head", func(t *testing.T) {
380+ r, mr, _ := autoRig(t)
381+ _, err := r.mrs.EnableAutoMerge(r.ctx, root, r.repo, mr.Number, ports.EnableAutoMergeParams{})
382+ require.ErrorIs(t, err, domain.ErrInvalid)
383+ assert.Nil(t, storedAuto(t, r, mr).AutoMerge)
384+ })
385+}
386+
387+func TestMergeGateSettingsUpdateWaits(t *testing.T) {
388+ for _, params := range []ports.UpdateRepoParams{
389+ {AllowAutoMerge: ptr(false)},
390+ {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
391+ {FailingChecksBlock: ptr(false)},
392+ } {
393+ r, _, _ := autoRig(t)
394+ r.repos.WithMergeGate(r.mrs)
395+ entered, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
396+ go func() {
397+ done <- r.mrs.WithMergeLock(r.ctx, r.repo, func() error { close(entered); <-release; return nil })
398+ }()
399+ <-entered
400+ ctx, cancel := context.WithTimeout(r.ctx, 20*time.Millisecond)
401+ _, err := r.repos.Update(ctx, root, r.repo, params)
402+ cancel()
403+ require.ErrorIs(t, err, context.DeadlineExceeded)
404+ close(release)
405+ require.NoError(t, <-done)
406+ _, err = r.repos.Update(r.ctx, root, r.repo, params)
407+ require.NoError(t, err)
408+ }
409+}
410+
411+func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
412+ r, mr, checks := autoRig(t)
413+ enableAuto(t, r, mr)
414+ enabled := storedAuto(t, r, mr)
415+ enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "revoked"}
416+ require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
417+ tokens := mocks.NewTokenStore(t)
418+ // Valid when the evaluator looks before CI, revoked by the time it holds the
419+ // merge lock: the merge must use the fresh answer.
420+ validTok := domain.Token{ID: "revoked", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}}
421+ tokens.EXPECT().Get(mock.Anything, "revoked").Return(validTok, nil).Once()
422+ tokens.EXPECT().Get(mock.Anything, "revoked").Return(domain.Token{}, domain.ErrNotFound).Once()
423+ reached := make(chan struct{})
424+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Run(func(context.Context, domain.RepoName, string) { close(reached) }).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
425+ a := autoEvaluator(r, checks, nil, tokens)
426+ defer a.Close(r.ctx)
427+ done := make(chan bool, 1)
428+ require.NoError(t, r.mrs.WithMergeLock(r.ctx, r.repo, func() error {
429+ go func() { done <- a.Evaluate(r.repo) }()
430+ <-reached
431+ // Evaluation has passed its fast-path checks with the then-valid token
432+ // and waits for this lock. The token is revoked meanwhile (second Get).
433+ return nil
434+ }))
435+ require.True(t, <-done)
436+ got := storedAuto(t, r, mr)
437+ require.Nil(t, got.AutoMerge)
438+ require.True(t, got.IsOpen())
439+}
440+
441+func TestAutoMergeRechecksSettledUnderMergeGate(t *testing.T) {
442+ r, mr, checks := autoRig(t)
443+ enableAuto(t, r, mr)
444+ activity := mocks.NewCIActivity(t)
445+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(true).Once()
446+ activity.EXPECT().Settled(mock.Anything, r.repo, mr.Number).Return(false).Once()
447+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
448+ a := autoEvaluator(r, checks, activity, nil)
449+ defer a.Close(r.ctx)
450+ require.True(t, a.Evaluate(r.repo))
451+ require.True(t, storedAuto(t, r, mr).IsOpen())
452+ require.NotNil(t, storedAuto(t, r, mr).AutoMerge)
453+}
M internal/usecase/ci.go +61 -2
@@ -53,8 +53,9 @@ const (
5353 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies
5454 // the defaults of Owner and Retention).
5555 type CIConfig struct {
56- Store ports.CIStore
57- Logs ports.CILogStore
56+ AutoMerge ports.AutoMergeTrigger
57+ Store ports.CIStore
58+ Logs ports.CILogStore
5859 // Summaries keeps the job summaries, deleted with the logs of their run.
5960 Summaries ports.CISummaryStore
6061 Meta ports.RepoMetaStore
@@ -266,6 +267,9 @@ type CI struct {
266267 pending notifier // woken when a job may be ready or a runner was removed
267268 flows *wfCache
268269
270+ pushes map[domain.RepoName]int
271+ pulls map[pullKey]int
272+ autoMerge ports.AutoMergeTrigger
269273 mu sync.Mutex
270274 heartbeats map[string]time.Time // job reference -> last heartbeat
271275 seen map[string]time.Time // runner id -> last seen
@@ -311,6 +315,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
311315 cfg = cfg.withDefaults()
312316 run, stop := context.WithCancel(context.WithoutCancel(ctx))
313317 return &CI{
318+ pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge,
314319 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
315320 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),
316321 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
@@ -343,6 +348,15 @@ func (c *CI) Close(_ context.Context) {
343348 c.mu.Unlock()
344349 c.stop()
345350 c.wg.Wait()
351+ // Workers stop on cancellation; release the planning parts they did not take.
352+ for {
353+ select {
354+ case t := <-c.queue:
355+ t.done()
356+ default:
357+ return
358+ }
359+ }
346360 }
347361
348362 func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() }
@@ -560,6 +574,7 @@ func (rs *runState) flush(ctx context.Context) error {
560574 }
561575 if completing {
562576 c.onRunEvent(ctx, rs.run, runCompleted)
577+ c.kickAutoMerge(ctx, rs.run.Repo)
563578 }
564579 return nil
565580 }
@@ -740,3 +755,47 @@ func isHex40(s string) bool {
740755 }
741756 return strings.Trim(s, "0123456789abcdef") == ""
742757 }
758+
759+// SetAutoMergeTrigger installs the evaluator after constructing the merge use cases.
760+func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) {
761+ c.mu.Lock()
762+ c.autoMerge = t
763+ c.mu.Unlock()
764+}
765+
766+func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) {
767+ c.mu.Lock()
768+ t := c.autoMerge
769+ c.mu.Unlock()
770+ if t != nil {
771+ t.OnRepoChanged(ctx, repo)
772+ }
773+}
774+
775+// Settled reports whether all push planning and pull activities have finished.
776+// Workflow-run events, schedules and dispatches are intentionally not counted.
777+// Repo-wide pushes conservatively delay requests even when an unrelated
778+// branch is updated.
779+func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64) bool {
780+ c.mu.Lock()
781+ defer c.mu.Unlock()
782+ return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0
783+}
784+
785+func (c *CI) finishPush(repo domain.RepoName) {
786+ c.mu.Lock()
787+ c.pushes[repo]--
788+ if c.pushes[repo] == 0 {
789+ delete(c.pushes, repo)
790+ }
791+ c.mu.Unlock()
792+}
793+
794+func (c *CI) finishPull(key pullKey) {
795+ c.mu.Lock()
796+ c.pulls[key]--
797+ if c.pulls[key] == 0 {
798+ delete(c.pulls, key)
799+ }
800+ c.mu.Unlock()
801+}
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.
A internal/usecase/ci_activity_test.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+func TestSettledCountsQueuedPushesAndPullEvents(t *testing.T) {
18+ trigger := mocks.NewAutoMergeTrigger(t)
19+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.TriggerQueue = 1; cfg.AutoMerge = trigger })
20+ repo := name(t, "proj")
21+ trigger.EXPECT().OnRepoChanged(mock.Anything, repo).Return().Times(3)
22+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
23+ ctx := context.Background()
24+ settled := func(n int64) bool { return u.ci.Settled(ctx, repo, n) }
25+ assert.True(t, settled(1))
26+ u.ci.QueuePush(ctx, repo)
27+ u.ci.QueuePush(ctx, repo) // dropped: queue capacity is one
28+ assert.False(t, settled(1))
29+ assert.False(t, settled(2)) // pushes conservatively cover the whole repo
30+ u.ci.QueuePull(repo, 1)
31+ u.ci.QueuePull(repo, 1) // dropped: no counter leak
32+ u.ci.PlanQueuedPush()
33+ assert.False(t, settled(1))
34+ assert.True(t, settled(2))
35+ u.ci.FinishQueuedPull()
36+ assert.True(t, settled(1))
37+}
38+
39+func TestSettledWaitsForPushDiscoveryAndReleasesOnError(t *testing.T) {
40+ mrs := mocks.NewMergeRequestStore(t)
41+ entered, release := make(chan struct{}), make(chan struct{})
42+ u := newCIUnit(t, func(cfg *usecase.CIConfig) {
43+ cfg.MRs = mrs
44+ cfg.Pull = mocks.NewPullGit(t)
45+ cfg.PullStage = func(string) { close(entered); <-release }
46+ })
47+ repo := name(t, "proj")
48+ mrs.EXPECT().OpenByTarget(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, nil)
49+ mrs.EXPECT().OpenBySource(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, domain.ErrUnavailable)
50+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
51+ done := make(chan struct{})
52+ go func() {
53+ u.ci.OnPush(context.Background(), repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: "1111111111111111111111111111111111111111"}}, domain.Root())
54+ close(done)
55+ }()
56+ <-entered
57+ u.ci.PlanQueuedPush()
58+ assert.False(t, u.ci.Settled(context.Background(), repo, 1))
59+ close(release)
60+ <-done
61+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
62+}
63+
64+func TestSettledReleasesQueuedPushOnShutdown(t *testing.T) {
65+ u := newCIUnit(t, nil)
66+ repo := name(t, "proj")
67+ u.ci.QueuePush(context.Background(), repo)
68+ u.ci.Close(context.Background())
69+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
70+ u.ci.QueuePush(context.Background(), repo)
71+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
72+}
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+}
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
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 }
M internal/usecase/ci_trigger.go +41 -6
@@ -12,6 +12,7 @@ import (
1212 "slices"
1313 "strconv"
1414 "strings"
15+ "sync/atomic"
1516 "time"
1617
1718 "github.com/bnema/zerowrap"
@@ -33,6 +34,7 @@ const (
3334
3435 // ciTrigger is a push waiting to be planned.
3536 type ciTrigger struct {
37+ done func()
3638 repo domain.RepoName
3739 updates []domain.RefUpdate
3840 actor string
@@ -54,6 +56,9 @@ type ciTrigger struct {
5456 // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
5557 // from running itself again.
5658 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
59+ done := c.pushLifecycle(ctx, repo, 2)
60+ defer done()
61+
5762 // Whoever pushed, the workflow files of the default branch may have changed.
5863 // (Who pushed was recorded before the refs moved: Pushes.Check.)
5964 c.markSchedulesDirty(repo, updates)
@@ -63,9 +68,10 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
6368 if pusher.ViaJobToken() {
6469 log := zerowrap.FromCtx(ctx)
6570 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
71+ done()
6672 return
6773 }
68- c.enqueue(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
74+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), done)
6975 }
7076
7177 // OnMerge queues the move of a target branch by a merge. The actor of the run is
@@ -75,6 +81,9 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
7581 // merging. For the same reason the run is trusted (gets secrets) only when the
7682 // merger is: the displayed actor is not the one that decides.
7783 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
84+ done := c.pushLifecycle(ctx, repo, 2)
85+ defer done()
86+
7887 c.markSchedulesDirty(repo, updates)
7988 // The merge moved a target branch: the test merges of the other requests
8089 // into it are computed again (a merge starts no synchronize run), after the
@@ -83,12 +92,32 @@ func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain
8392 if merger.ViaJobToken() {
8493 log := zerowrap.FromCtx(ctx)
8594 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
95+ done()
8696 return
8797 }
88- c.enqueue(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
98+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), done)
8999 }
90100
91101 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
102+ c.enqueuePush(ctx, repo, updates, actor, pusher, trusted, c.pushLifecycle(ctx, repo, 1))
103+}
104+
105+// pushLifecycle holds one repository activity count until all parts finish.
106+func (c *CI) pushLifecycle(ctx context.Context, repo domain.RepoName, parts int32) func() {
107+ c.mu.Lock()
108+ c.pushes[repo]++
109+ c.mu.Unlock()
110+ var remaining atomic.Int32
111+ remaining.Store(parts)
112+ return func() {
113+ if remaining.Add(-1) == 0 {
114+ c.finishPush(repo)
115+ c.kickAutoMerge(ctx, repo)
116+ }
117+ }
118+}
119+
120+func (c *CI) enqueuePush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool, done func()) {
92121 var kept []domain.RefUpdate
93122 for _, u := range updates {
94123 if strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/") {
@@ -96,20 +125,25 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
96125 }
97126 }
98127 if len(kept) == 0 {
128+ done()
99129 return
100130 }
101- t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
131+ t := ciTrigger{done: done, repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
102132 c.mu.Lock()
103- closed := c.closed
104- c.mu.Unlock()
105- if closed {
133+ if c.closed {
134+ c.mu.Unlock()
135+ done()
106136 return
107137 }
108138 select {
109139 case c.queue <- t:
140+ c.mu.Unlock()
110141 default:
142+ c.mu.Unlock()
143+ done()
111144 t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")
112145 }
146+
113147 }
114148
115149 func (c *CI) triggerLoop() {
@@ -127,6 +161,7 @@ func (c *CI) triggerLoop() {
127161 // planPush plans one push; a panic or an error is logged and never escapes.
128162 func (c *CI) planPush(t ciTrigger) {
129163 ctx := zerowrap.WithCtx(c.ctx, t.log)
164+ defer t.done()
130165 defer func() {
131166 if r := recover(); r != nil {
132167 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")
M internal/usecase/events.go +9 -0
@@ -182,6 +182,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
182182 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183183 case domain.MergeRequestClosed:
184184 return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.MergeRequestAutoMergeEnabled:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.MergeRequestAutoMergeCancelled:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185189 case domain.MergeRequestReady:
186190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187191 case domain.CommentAdded:
@@ -535,6 +539,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
535539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
536540 case domain.MergeRequestClosed:
537541 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
542+ case domain.MergeRequestAutoMergeEnabled:
543+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
544+ case domain.MergeRequestAutoMergeCancelled:
545+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538546 case domain.MergeRequestReady:
539547 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
540548 case domain.CommentAdded:
@@ -663,6 +671,7 @@ func isItemEvent(ev domain.Event) bool {
663671 case domain.IssueReferenced:
664672 return false // back-reference refreshes do not change the inbox
665673 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
674+ domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
666675 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
667676 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
668677 return true
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+}
M internal/usecase/issue_links_test.go +27 -0
@@ -11,8 +11,10 @@ import (
1111
1212 "bnema.dev/ermite/internal/domain"
1313 "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
1415 "bnema.dev/ermite/internal/usecase"
1516 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
1618 "github.com/stretchr/testify/require"
1719 )
1820
@@ -233,3 +235,28 @@ func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
233235 require.Len(t, after, 1)
234236 assert.Equal(t, issue.Number, after[0].Issue)
235237 }
238+
239+func TestAutoMergeClosesLinkedIssue(t *testing.T) {
240+ r, _, issues := linkedRig(t)
241+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
242+ require.NoError(t, err)
243+ r.feature("feat")
244+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "fix", Source: "feat", Description: fmt.Sprintf("closes #%d", issue.Number)})
245+ require.NoError(t, err)
246+ _, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
247+ require.NoError(t, err)
248+ checks := mocks.NewCIChecksReader(t)
249+ r.mrs.SetChecks(checks)
250+ enableAuto(t, r, mr)
251+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
252+ a := autoEvaluator(r, checks, nil, nil)
253+ defer a.Close(r.ctx)
254+ require.True(t, a.Evaluate(r.repo))
255+ r.mrs.Wait(r.ctx)
256+ merged := storedAuto(t, r, mr)
257+ require.Equal(t, domain.MRStateMerged, merged.State)
258+ require.True(t, merged.AutoMerged)
259+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
260+ require.NoError(t, err)
261+ assert.False(t, got.Issue.IsOpen())
262+}
M internal/usecase/mergeability.go +18 -6
@@ -35,12 +35,13 @@ func mergeBackoff(attempt int) time.Duration {
3535
3636 // MergeabilityConfig holds the dependencies of the repository reconciler.
3737 type MergeabilityConfig struct {
38- Store ports.MergeRequestStore
39- Meta ports.RepoMetaStore
40- Git ports.MergeGit
41- Bus ports.EventBus
42- Clock ports.Clock
43- Workers int // defaults to 2
38+ AutoMerge ports.AutoMergeTrigger
39+ Store ports.MergeRequestStore
40+ Meta ports.RepoMetaStore
41+ Git ports.MergeGit
42+ Bus ports.EventBus
43+ Clock ports.Clock
44+ Workers int // defaults to 2
4445 // After is injectable to drive backoff without wall-clock waits in tests.
4546 After func(time.Duration) <-chan time.Time
4647 }
@@ -235,6 +236,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
235236 return false
236237 }
237238 ok := true
239+ changed := false
238240 for _, mr := range mrs {
239241 if ctx.Err() != nil {
240242 return true
@@ -275,6 +277,10 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
275277 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276278 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277279 })
280+ changed = true
281+ }
282+ if changed && s.cfg.AutoMerge != nil {
283+ s.cfg.AutoMerge.OnRepoChanged(ctx, repo)
278284 }
279285 return ok
280286 }
@@ -465,3 +471,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number
465471 m.Behind, m.BehindCapped = domain.BoundBehind(n)
466472 return m, nil
467473 }
474+
475+// WithAutoMerge installs the evaluator before Start.
476+func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability {
477+ s.cfg.AutoMerge = t
478+ return s
479+}
M internal/usecase/mergerequests.go +93 -18
@@ -59,7 +59,8 @@ type MergeRequests struct {
5959 tips ports.CIRefTipStore // nil: who merges is not recorded
6060 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
6161 mergeab MergeabilityHook // nil: no mergeability
62- mirror ports.MirrorTrigger // nil: no outbound mirror
62+ autoMerge ports.AutoMergeTrigger
63+ mirror ports.MirrorTrigger // nil: no outbound mirror
6364
6465 mu sync.Mutex
6566 locks map[domain.RepoName]chan struct{}
@@ -150,6 +151,9 @@ func (m *MergeRequests) Wait(ctx context.Context) {
150151 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
151152 // merges of one repository, and the creation of its merge requests, run one at
152153 // a time. The returned function releases the lock.
154+// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
155+// store transaction or a CI lock may take this lock. MergeRequests never
156+// calls Repos.Update.
153157 func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
154158 m.mu.Lock()
155159 ch, ok := m.locks[name]
@@ -917,6 +921,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
917921 if err != nil {
918922 return domain.MergeRequest{}, err
919923 }
924+ hadAutoMerge := mr.AutoMerge != nil
925+ mr.AutoMerge = nil
920926 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
921927 err = m.store.Update(ctx, mr)
922928 if errors.Is(err, domain.ErrConflict) {
@@ -930,6 +936,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
930936 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
931937 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
932938 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
939+ if hadAutoMerge {
940+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
941+ }
933942 if m.links != nil {
934943 m.links.OnMergeRequestSettled(ctx, repo, mr)
935944 }
@@ -1010,7 +1019,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
10101019 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
10111020 // only says that the deletion was requested.
10121021 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
1013- mr, err := m.merge(ctx, p, name, number, params)
1022+ mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
10141023 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
10151024 }
10161025
@@ -1052,9 +1061,23 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
10521061 return res.Tree, nil
10531062 }
10541063
1055-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
1056- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1057- return domain.MergeRequest{}, err
1064+type mergeOpts struct {
1065+ auto *autoMergeOpts
1066+}
1067+
1068+type autoMergeOpts struct {
1069+ intent *domain.AutoMerge
1070+ tokens ports.TokenStore
1071+ activity ports.CIActivity
1072+}
1073+
1074+var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1075+
1076+func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1077+ if opts.auto == nil {
1078+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1079+ return domain.MergeRequest{}, err
1080+ }
10581081 }
10591082 mode := params.Mode
10601083 if mode == "" {
@@ -1083,6 +1106,15 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10831106 if err != nil {
10841107 return domain.MergeRequest{}, err
10851108 }
1109+ if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) {
1110+ return domain.MergeRequest{}, errAutoMergeWait
1111+ }
1112+ if opts.auto != nil {
1113+ p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now())
1114+ if err != nil {
1115+ return domain.MergeRequest{}, err
1116+ }
1117+ }
10861118 // A merge moves the target and may delete the source: a token limited to
10871119 // branch prefixes may do so only within them.
10881120 if !p.AllowsRef("refs/heads/" + mr.Target) {
@@ -1134,14 +1166,33 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11341166 } else if behind {
11351167 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
11361168 }
1169+ if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) {
1170+ return domain.MergeRequest{}, errAutoMergeWait
1171+ }
11371172 tree, err := m.checkMode(ctx, mr, mode, tip, head)
11381173 if err != nil {
1174+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1175+ return domain.MergeRequest{}, errAutoMergeWait
1176+ }
11391177 return domain.MergeRequest{}, err
11401178 }
1141- if settings.FailingChecksBlock {
1142- if err := m.checksAllow(ctx, name, head); err != nil {
1179+ if opts.auto != nil && !settings.AllowAutoMerge {
1180+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1181+ }
1182+ if settings.FailingChecksBlock || opts.auto != nil {
1183+ checks, err := m.readMergeChecks(ctx, name, head)
1184+ if err == nil {
1185+ err = checksPolicy(checks)
1186+ }
1187+ if err != nil {
1188+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1189+ return domain.MergeRequest{}, errAutoMergeWait
1190+ }
11431191 return domain.MergeRequest{}, err
11441192 }
1193+ if opts.auto != nil && !hasSuccessfulCheck(checks) {
1194+ return domain.MergeRequest{}, errAutoMergeWait
1195+ }
11451196 }
11461197 if params.DeleteSource {
11471198 if err := m.checkDeletable(ctx, name, mr); err != nil {
@@ -1154,7 +1205,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
11541205 return domain.MergeRequest{}, err
11551206 }
11561207 m.recordPush(ctx, name) // the target moved, whatever happens next
1157- mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
1208+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil)
11581209 if err != nil {
11591210 return domain.MergeRequest{}, err
11601211 }
@@ -1199,7 +1250,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
11991250 }
12001251 at := mr.UpdatedAt
12011252 recordActivity(ctx, m.meta, repo.Name, at)
1202- m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1253+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
12031254 m.bus.Publish(ctx, domain.PushEvent{
12041255 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
12051256 })
@@ -1272,21 +1323,34 @@ func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, num
12721323 return fn()
12731324 }
12741325
1275-// checksAllow is the merge rule of FailingChecksBlock: every check of the
1276-// head must have ended as success, skipped or neutral. A check still queued or
1277-// running blocks too (the merge could land code that then fails), and a head
1278-// without any check merges.
1279-func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error {
1326+// readMergeChecks reads one snapshot, classifying an incomplete result as a
1327+// failed precondition rather than treating it as a passing set of checks.
1328+func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) {
12801329 if m.checks == nil {
1281- return nil
1330+ return nil, nil
12821331 }
12831332 checks, err := m.checks.CommitChecks(ctx, name, head.String())
12841333 if errors.Is(err, domain.ErrResourceExhausted) {
1285- return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
1334+ return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
12861335 }
12871336 if err != nil {
1288- return fmt.Errorf("read the checks of %s: %w", head, err)
1337+ return nil, fmt.Errorf("read the checks of %s: %w", head, err)
12891338 }
1339+ return checks, nil
1340+}
1341+
1342+func hasSuccessfulCheck(checks []domain.CICheck) bool {
1343+ for _, check := range checks {
1344+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1345+ return true
1346+ }
1347+ }
1348+ return false
1349+}
1350+
1351+// checksPolicy requires every check to have ended as success, skipped or
1352+// neutral. Queued and running checks block too; an empty set is allowed.
1353+func checksPolicy(checks []domain.CICheck) error {
12901354 var failing, pending []string
12911355 for _, c := range checks {
12921356 switch {
@@ -1392,7 +1456,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
13921456
13931457 // markMerged records the merge. The target already moved, so a write that
13941458 // loses a race (a comment added meanwhile) is repeated on the fresh request.
1395-func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {
1459+func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) {
13961460 // The merge must be recorded even when the caller gives up.
13971461 ctx = context.WithoutCancel(ctx)
13981462 for range maxWriteRetries {
@@ -1400,6 +1464,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
14001464 if err != nil {
14011465 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
14021466 }
1467+ mr.AutoMerge, mr.AutoMerged = nil, auto
14031468 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
14041469 mr.UpdatedAt = m.clock.Now().UTC()
14051470 err = m.store.Update(ctx, mr)
@@ -1550,3 +1615,13 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep
15501615 }
15511616 return ports.PushSummary{Count: len(commits), More: more}, nil
15521617 }
1618+
1619+// WithMergeLock serializes fn with repository merges and closes.
1620+func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
1621+ unlock, err := m.lockRepo(ctx, repo)
1622+ if err != nil {
1623+ return err
1624+ }
1625+ defer unlock()
1626+ return fn()
1627+}
A internal/usecase/mergerequests_auto.go +168 -0
@@ -0,0 +1,168 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// WithAutoMerge installs the asynchronous evaluator before serving requests.
15+func (m *MergeRequests) WithAutoMerge(t ports.AutoMergeTrigger) *MergeRequests {
16+ m.autoMerge = t
17+ return m
18+}
19+
20+// EnableAutoMerge records the actor and the head the caller saw, which must be
21+// the current head of the source branch; re-enabling replaces the
22+// previous intent rather than preserving an older actor's authority.
23+func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
24+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
25+ return domain.MergeRequest{}, err
26+ }
27+ if m.checks == nil {
28+ return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed)
29+ }
30+ mode := params.Mode
31+ if mode == "" {
32+ mode = domain.MergeModeFastForward
33+ }
34+ var v domain.Violations
35+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
36+ v.Add("mode", "is not a merge mode")
37+ }
38+ if params.HeadID.IsZero() {
39+ v.Add("head_id", "is required")
40+ }
41+ if err := v.Err(); err != nil {
42+ return domain.MergeRequest{}, err
43+ }
44+ unlock, err := m.lockRepo(ctx, name)
45+ if err != nil {
46+ return domain.MergeRequest{}, err
47+ }
48+ defer unlock()
49+ for range maxWriteRetries {
50+ mr, err := m.store.Get(ctx, name, number)
51+ if err != nil {
52+ return domain.MergeRequest{}, err
53+ }
54+ if !mr.IsOpen() {
55+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
56+ }
57+ if mr.IsDraft() {
58+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
59+ }
60+ if !p.AllowsRef("refs/heads/" + mr.Target) {
61+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
62+ }
63+ repo, err := m.meta.Get(ctx, name)
64+ if err != nil {
65+ return domain.MergeRequest{}, err
66+ }
67+ settings := repo.EffectiveSettings()
68+ if !settings.AllowAutoMerge {
69+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s: turn it on with `ermite repo edit --allow-auto-merge`", domain.ErrPreconditionFailed, name)
70+ }
71+ if !settings.Allows(mode) {
72+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
73+ }
74+ head, err := m.branchOrZero(ctx, name, mr.Source)
75+ if err != nil {
76+ return domain.MergeRequest{}, err
77+ }
78+ if head.IsZero() {
79+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
80+ }
81+ if head != params.HeadID {
82+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
83+ }
84+ mr.UpdatedAt = m.clock.Now().UTC()
85+ mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt}
86+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
87+ continue
88+ } else if err != nil {
89+ return domain.MergeRequest{}, err
90+ }
91+ recordActivity(ctx, m.meta, name, mr.UpdatedAt)
92+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
93+ if m.autoMerge != nil {
94+ m.autoMerge.OnAutoMergeEnabled(ctx, name)
95+ }
96+ return m.withHead(ctx, mr)
97+ }
98+ return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict)
99+}
100+
101+// CancelAutoMerge clears an open request's intent with the same target access
102+// required to enable it.
103+func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
104+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
105+ return domain.MergeRequest{}, err
106+ }
107+ unlock, err := m.lockRepo(ctx, name)
108+ if err != nil {
109+ return domain.MergeRequest{}, err
110+ }
111+ defer unlock()
112+ mr, err := m.store.Get(ctx, name, number)
113+ if err != nil {
114+ return domain.MergeRequest{}, err
115+ }
116+ if !mr.IsOpen() {
117+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
118+ }
119+ if !p.AllowsRef("refs/heads/" + mr.Target) {
120+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
121+ }
122+ if mr.AutoMerge == nil {
123+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed)
124+ }
125+ mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p))
126+ if err != nil {
127+ return domain.MergeRequest{}, err
128+ }
129+ return m.withHead(ctx, mr)
130+}
131+
132+// cancelAuto takes the merge lock and only cancels the intent evaluated by the
133+// caller. A concurrent re-enable, close or merge must not be undone.
134+func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error {
135+ unlock, err := m.lockRepo(ctx, mr.Repo)
136+ if err != nil {
137+ return err
138+ }
139+ defer unlock()
140+ _, err = m.cancelAutoLocked(ctx, mr, reason)
141+ return err
142+}
143+
144+func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) {
145+ for range maxWriteRetries {
146+ mr, err := m.store.Get(ctx, expected.Repo, expected.Number)
147+ if err != nil {
148+ return domain.MergeRequest{}, err
149+ }
150+ if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge {
151+ return mr, nil
152+ }
153+ repo, err := m.meta.Get(ctx, mr.Repo)
154+ if err != nil {
155+ return domain.MergeRequest{}, err
156+ }
157+ mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC()
158+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
159+ continue
160+ } else if err != nil {
161+ return domain.MergeRequest{}, err
162+ }
163+ recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt)
164+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
165+ return mr, nil
166+ }
167+ return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict)
168+}
M internal/usecase/repos.go +14 -6
@@ -17,12 +17,14 @@ import (
1717
1818 // Repos are the repository use cases.
1919 type Repos struct {
20- meta ports.RepoMetaStore
21- git ports.GitRepos
22- bus ports.EventBus
23- clock ports.Clock
24- ci ports.CIRunCleaner // nil: no CI logs to remove
25- mirror ports.MirrorTrigger // nil: no outbound mirror
20+ meta ports.RepoMetaStore
21+ git ports.GitRepos
22+ bus ports.EventBus
23+ clock ports.Clock
24+ ci ports.CIRunCleaner // nil: no CI logs to remove
25+ autoMerge ports.AutoMergeTrigger
26+ mergeGate ports.MergeGate
27+ mirror ports.MirrorTrigger // nil: no outbound mirror
2628 }
2729
2830 var _ ports.RepoService = (*Repos)(nil)
@@ -233,3 +235,9 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
233235 }
234236 return rep, nil
235237 }
238+
239+// WithAutoMerge installs the settings-change trigger before serving requests.
240+func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r }
241+
242+// WithMergeGate installs the settings-write gate before serving requests.
243+func (r *Repos) WithMergeGate(g ports.MergeGate) *Repos { r.mergeGate = g; return r }
M internal/usecase/repos_write.go +19 -4
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6262 }
6363 before := repo
6464 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {
65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
6868 if params.AllowedMergeModes != nil {
@@ -116,11 +116,23 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116116 // branch was checked above, so the remaining causes are a vanished
117117 // repository or an I/O failure.
118118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {
120- if repo, err = r.updateMeta(ctx, name, params); err != nil {
119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120+ update := func() error {
121+ repo, err = r.updateMeta(ctx, name, params)
122+ return err
123+ }
124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {
125+ err = r.mergeGate.WithMergeLock(ctx, name, update)
126+ } else {
127+ err = update()
128+ }
129+ if err != nil {
121130 return domain.Repo{}, err
122131 }
123132 metaDone = true
133+ if r.autoMerge != nil {
134+ r.autoMerge.OnRepoChanged(ctx, name)
135+ }
124136 }
125137 var headErr error
126138 if params.DefaultBranch != nil {
@@ -142,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
142154 Bool("topics", params.Topics != nil).
143155 Bool("visibility", params.Visibility != nil).
144156 Bool("default_branch", params.DefaultBranch != nil).
145- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).
157+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
146158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
147159 Msg("repository updated")
148160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -175,6 +187,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
175187 if params.FailingChecksBlock != nil {
176188 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
177189 }
190+ if params.AllowAutoMerge != nil {
191+ repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192+ }
178193 if params.Issues != nil {
179194 repo.Settings.Issues = *params.Issues
180195 }
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)
M proto/ermite/storage/merge_request.proto +15 -0
@@ -30,6 +30,7 @@ message RepoSettings {
3030 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
3131 // read.
3232 bool merge_modes_v2 = 4;
33+ bool allow_auto_merge = 5;
3334 }
3435
3536 // IssuesMode says who may write issues in a repository.
@@ -78,6 +79,20 @@ message MergeRequest {
7879 MergeMode merge_mode = 13;
7980 // Last comment id handed out for this request.
8081 int64 comment_seq = 14;
82+ // Pending auto-merge; unset when off.
83+ AutoMerge auto_merge = 15;
84+ bool auto_merged = 16;
85+}
86+
87+// AutoMerge is the pending auto-merge of an open merge request.
88+message AutoMerge {
89+ MergeMode mode = 1;
90+ string head_id = 2;
91+ // Kind and token id of the CIPusher that enabled it.
92+ string enabler_kind = 3;
93+ string enabler_token_id = 4;
94+ string enabler_label = 5;
95+ google.protobuf.Timestamp enabled_at = 6;
8196 }
8297
8398 // Verdict is the immutable review decision of a note.
M skills/ermite/CLI.md +4 -2
@@ -11,10 +11,10 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
1111 | `repo list [--json]` | none | repositories you can see |
1212 | `repo show <name> [--json]` | none | |
1313 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
1515 | `repo delete <name> [--yes]` | `repo:admin` | |
1616
17-Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`.
17+Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`, `allow_auto_merge`.
1818
1919 ## Tokens
2020
@@ -62,6 +62,7 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
6262 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
6363 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
6464 | `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
65+| `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
6566 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6667
6768 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +107,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106107 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107108 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108109 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
110+| `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` | `repo`, `merge_request`; cancelled also has `reason`. `merge_request_merged` has `auto` true after an auto-merge |
109111 | `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes |
110112 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
111113 | `gap` | none: events were lost, reload the state |
M skills/ermite/SKILL.md +2 -0
@@ -39,6 +39,8 @@ git push https://git.example.com/<repo>.git main # password = token,
3939 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
4040 5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
4141
42+To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
4244 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
4345
4446 ### Act on a review

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 42

↑↓ moveenter openesc close