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:
48 48
49 ```sh49 ```sh
50 ermite repo list | show <name> | create <name> [--description …] [--visibility public]50 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]
52 ermite repo delete <name> [--yes]52 ermite repo delete <name> [--yes]
53 53
54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -57,7 +57,7 @@ ermite token list | revoke <id>
57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict57 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict
58 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]58 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>59 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>
61 61
62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]62 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>63 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]
100 - `--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`.100 - `--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`.
101 - `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`.101 - `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`.
102 - `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`.102 - `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).
103 - Errors are one line on stderr (`ermite: …`) with exit status 1.104 - Errors are one line on stderr (`ermite: …`) with exit status 1.
104 - Shell completion completes repository names from the server.105 - Shell completion completes repository names from the server.
105 106
M docs/merge-requests.md +14 -0
@@ -41,6 +41,20 @@ git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commi
41 41
42 The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint.42 The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint.
43 43
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+
44 ## Mergeability58 ## Mergeability
45 59
46 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.60 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 (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.13.016+ bnema.dev/ermite-api v0.14.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 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) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 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},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSession,
146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,146 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,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, client.FeatureSigningKey, client.FeatureSession,150 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,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154 }154 }
M internal/adapters/client/features.go +5 -1
@@ -57,11 +57,15 @@ const (
57 // FeatureMergeability is MergeRequest.mergeability and the57 // FeatureMergeability is MergeRequest.mergeability and the
58 // merge_request_mergeability event.58 // merge_request_mergeability event.
59 FeatureMergeability = "merge_requests.mergeability"59 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"
60 )64 )
61 65
62 // UsedFeatures lists the features the CLI uses, in the order they are reported.66 // UsedFeatures lists the features the CLI uses, in the order they are reported.
63 func UsedFeatures() []string {67 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}
65 }69 }
66 70
67 // MissingFeatures returns the features the CLI uses that have is lacking.71 // 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) {
728 if err != nil {728 if err != nil {
729 return nil, err729 return nil, err
730 }730 }
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+ }
731 return mustMarshal(&storagepb.MergeRequest{743 return mustMarshal(&storagepb.MergeRequest{
732 Number: mr.Number,744 Number: mr.Number,
733 Title: mr.Title.String(),745 Title: mr.Title.String(),
@@ -743,6 +755,8 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
743 MergedCommitId: mr.MergedCommitID.String(),755 MergedCommitId: mr.MergedCommitID.String(),
744 MergeMode: mode,756 MergeMode: mode,
745 CommentSeq: mr.CommentSeq,757 CommentSeq: mr.CommentSeq,
758+ AutoMerge: autoMerge,
759+ AutoMerged: mr.AutoMerged,
746 }), nil760 }), nil
747 }761 }
748 762
@@ -773,8 +787,24 @@ func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest
773 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),787 CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
774 MergeMode: decodeMode(pb.GetMergeMode()),788 MergeMode: decodeMode(pb.GetMergeMode()),
775 CommentSeq: pb.GetCommentSeq(),789 CommentSeq: pb.GetCommentSeq(),
790+ AutoMerged: pb.GetAutoMerged(),
776 Version: item.Version,791 Version: item.Version,
777 }792 }
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+ }
778 for _, f := range []struct {808 for _, f := range []struct {
779 dst *domain.ObjectID809 dst *domain.ObjectID
780 src string810 src string
@@ -909,7 +939,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
909 }939 }
910 940
911 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {941 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}
913 for _, m := range s.AllowedMergeModes {943 for _, m := range s.AllowedMergeModes {
914 mode, err := encodeMode(m)944 mode, err := encodeMode(m)
915 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {945 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -949,5 +979,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
949 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {979 if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
950 normalized = domain.MergeModes()980 normalized = domain.MergeModes()
951 }981 }
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()}
953 }983 }
M internal/adapters/kvstore/mergerequests_test.go +47 -0
@@ -94,6 +94,35 @@ func TestMergeRequestRoundTrip(t *testing.T) {
94 assert.ErrorIs(t, err, domain.ErrNotFound)94 assert.ErrorIs(t, err, domain.ErrNotFound)
95 }95 }
96 96
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+
97 func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {126 func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {
98 ctx := context.Background()127 ctx := context.Background()
99 s, mrs, n := repoWithMRs(t)128 s, mrs, n := repoWithMRs(t)
@@ -453,6 +482,24 @@ func TestRepoSettings(t *testing.T) {
453 assert.ErrorIs(t, err, domain.ErrInvalid)482 assert.ErrorIs(t, err, domain.ErrInvalid)
454 }483 }
455 484
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+
456 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before503 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
457 // merge requests existed has no settings field.504 // merge requests existed has no settings field.
458 func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {505 func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // 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"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // 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 {
72 }72 }
73 73
74 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {74 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)}
76 for _, m := range s.AllowedMergeModes {76 for _, m := range s.AllowedMergeModes {
77 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))77 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
78 }78 }
@@ -483,7 +483,13 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
483 out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}483 out.Kind = &pb.Event_MergeRequestOpened{MergeRequestOpened: &pb.MergeRequestOpened{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}
484 case domain.MergeRequestMerged:484 case domain.MergeRequestMerged:
485 out.Time = timestamp(ev.At)485 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}}
487 case domain.MergeRequestClosed:493 case domain.MergeRequestClosed:
488 out.Time = timestamp(ev.At)494 out.Time = timestamp(ev.At)
489 out.Kind = &pb.Event_MergeRequestClosed{MergeRequestClosed: &pb.MergeRequestClosed{MergeRequest: mergeRequestToPB(ev.MergeRequest)}}495 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 {
19 mrs ports.MergeRequestService19 mrs ports.MergeRequestService
20 }20 }
21 21
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+
22 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {54 func (s *mergeRequestService) EditComment(ctx context.Context, req *connect.Request[pb.EditCommentRequest]) (*connect.Response[pb.EditCommentResponse], error) {
23 name, err := domain.ParseRepoName(req.Msg.GetRepo())55 name, err := domain.ParseRepoName(req.Msg.GetRepo())
24 if err != nil {56 if err != nil {
@@ -133,6 +165,13 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
133 HeadId: mr.HeadID.String(),165 HeadId: mr.HeadID.String(),
134 MergedCommitId: mr.MergedCommitID.String(),166 MergedCommitId: mr.MergedCommitID.String(),
135 MergeMode: mergeModeToPB(mr.MergeMode),167 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+ }
136 }175 }
137 if mr.IsOpen() {176 if mr.IsOpen() {
138 out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}177 out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
M internal/adapters/rpc/mr_api_test.go +80 -0
@@ -3,6 +3,7 @@
3 package rpc3 package rpc
4 4
5 import (5 import (
6+ "strings"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -122,3 +123,82 @@ func TestNoteVerdictsDetailAndScope(t *testing.T) {
122 _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))123 _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))
123 requireCode(t, connect.CodePermissionDenied, err)124 requireCode(t, connect.CodePermissionDenied, err)
124 }125 }
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
80 80
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82 // one of "description", "topics", "visibility", "default_branch", "settings",82 // one of "description", "topics", "visibility", "default_branch", "settings",
83-// "settings.allowed_merge_modes" and "settings.failing_checks_block", and83+// "settings.allowed_merge_modes", "settings.failing_checks_block" and
84-// nothing else; the optional issues field stands outside the mask, and alone84+// "settings.allow_auto_merge", and nothing else; the optional issues field stands outside the mask, and alone
85 // it is a valid update.85 // it is a valid update.
86 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {86 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
87 var params ports.UpdateRepoParams87 var params ports.UpdateRepoParams
@@ -113,17 +113,21 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
113 params.Visibility = &vis113 params.Visibility = &vis
114 case "default_branch":114 case "default_branch":
115 params.DefaultBranch = new(msg.GetDefaultBranch())115 params.DefaultBranch = new(msg.GetDefaultBranch())
116- case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block":116+ case "settings", "settings.allowed_merge_modes", "settings.failing_checks_block", "settings.allow_auto_merge":
117- if path != "settings.failing_checks_block" {117+ all := path == "settings"
118+ if all || path == "settings.allowed_merge_modes" {
118 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())119 modes, err := mergeModesFromPB(msg.GetSettings().GetAllowedMergeModes())
119 if err != nil {120 if err != nil {
120 return params, err121 return params, err
121 }122 }
122 params.AllowedMergeModes = &modes123 params.AllowedMergeModes = &modes
123 }124 }
124- if path != "settings.allowed_merge_modes" {125+ if all || path == "settings.failing_checks_block" {
125 params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())126 params.FailingChecksBlock = new(msg.GetSettings().GetFailingChecksBlock())
126 }127 }
128+ if all || path == "settings.allow_auto_merge" {
129+ params.AllowAutoMerge = new(msg.GetSettings().GetAllowAutoMerge())
130+ }
127 default:131 default:
128 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)132 return params, fmt.Errorf("%w: unknown path %q in update_mask", domain.ErrInvalid, path)
129 }133 }
M internal/adapters/rpc/repo_test.go +25 -0
@@ -280,3 +280,28 @@ func TestRepoToPBTopics(t *testing.T) {
280 assert.Equal(t, []string{"cli", "go"}, got.GetTopics())280 assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281 assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())281 assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282 }282 }
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 {
61 }61 }
62 switch we.Event.(type) {62 switch we.Event.(type) {
63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,63 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,
65 domain.RunUpdated, domain.JobUpdated,65 domain.RunUpdated, domain.JobUpdated,
66 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:66 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved, domain.IssueReferenced:
67 return true67 return true
@@ -189,6 +189,10 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
190 case domain.MergeRequestReady:190 case domain.MergeRequestReady:
191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")191 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")
192 case domain.MergeRequestMergeability:196 case domain.MergeRequestMergeability:
193 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {197 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
194 return nil, nil198 return nil, nil
M internal/adapters/web/mr.go +26 -0
@@ -11,6 +11,7 @@ import (
11 "net/url"11 "net/url"
12 "strconv"12 "strconv"
13 "strings"13 "strings"
14+ "time"
14 15
15 "github.com/a-h/templ"16 "github.com/a-h/templ"
16 17
@@ -202,6 +203,18 @@ type MergeBox struct {
202 Fingerprint string203 Fingerprint string
203 // Error is why the last merge was refused, shown in the box.204 // Error is why the last merge was refused, shown in the box.
204 Error string205 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
205 }218 }
206 219
207 // ModeOption is a merge mode the repository allows. Disabled says that the220 // 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
666 if m.IsDraft() {679 if m.IsDraft() {
667 box.ReadyAction = mrHref(d.name(), m.Number, "ready")680 box.ReadyAction = mrHref(d.name(), m.Number, "ready")
668 }681 }
682+ box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge)
669 return box683 return box
670 }684 }
671 685
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+
672 // mrFacts formats the description, metadata, branch links and checks.696 // mrFacts formats the description, metadata, branch links and checks.
673 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {697 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
674 m := res.MergeRequest698 m := res.MergeRequest
@@ -790,6 +814,8 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead
790 CanMarkRead: canMarkRead,814 CanMarkRead: canMarkRead,
791 CanDeleteBranch: source != d.repo.DefaultBranch,815 CanDeleteBranch: source != d.repo.DefaultBranch,
792 Mergeability: mg,816 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,
793 }819 }
794 var lines []string820 var lines []string
795 cmd := ""821 cmd := ""
M internal/adapters/web/mr.templ +28 -0
@@ -508,6 +508,20 @@ templ mergeMenu(p MRPage) {
508 </form>508 </form>
509 } else if !p.Box.HeadGone {509 } else if !p.Box.HeadGone {
510 @mrMergeabilitySlot(p.Box.Mergeability)510 @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+ }
511 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>525 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
512 <input type="hidden" name="head_id" value={ p.Box.Head }/>526 <input type="hidden" name="head_id" value={ p.Box.Head }/>
513 <fieldset class="d-modes">527 <fieldset class="d-modes">
@@ -546,6 +560,20 @@ templ mergeMenu(p MRPage) {
546 >Merge into { p.Target }</button>560 >Merge into { p.Target }</button>
547 </div>561 </div>
548 </form>562 </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+ }
549 }577 }
550 </div>578 </div>
551 </details>579 </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,
391 return back, back, nil391 return back, back, nil
392 }392 }
393 393
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+
394 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head456 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
395 // they were shown as read. Reading a page never does it (see the top of mr.go).457 // they were shown as read. Reading a page never does it (see the top of mr.go).
396 func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {458 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
4 4
5 import (5 import (
6 "context"6 "context"
7- "time"7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
8 "testing"10 "testing"
11+ "time"
9 12
10 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
@@ -195,3 +198,102 @@ func TestMRReadPresentationCommitCount(t *testing.T) {
195 })198 })
196 }199 }
197 }200 }
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 {
231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
233 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))233 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))
234 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
235 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))237 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))238 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) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 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)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
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 {
203 MergeRequest *mrOut `json:"merge_request"`203 MergeRequest *mrOut `json:"merge_request"`
204 }204 }
205 205
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"}
207 207
208 // AC6: the CLI end to end against a real forge.208 // AC6: the CLI end to end against a real forge.
209 func TestAC6CLIEndToEnd(t *testing.T) {209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 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)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_fake_test.go +8 -0
@@ -62,6 +62,8 @@ type fakeAPI struct {
62 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)62 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
63 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)63 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
64 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)64 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
65+ enableAuto func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error)
66+ cancelAuto func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error)
65 createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error)67 createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error)
66 listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)68 listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)
67 getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)69 getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)
@@ -152,6 +154,12 @@ func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite
152 func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) {154 func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) {
153 return respond(f, "MergeMergeRequest", r, f.mergeMR)155 return respond(f, "MergeMergeRequest", r, f.mergeMR)
154 }156 }
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+}
155 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {163 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
156 return respond(f, "MarkRead", r, f.markRead)164 return respond(f, "MarkRead", r, f.markRead)
157 }165 }
M internal/app/cli_mr.go +95 -3
@@ -121,6 +121,26 @@ type jsonMergeRequest struct {
121 HeadID string `json:"head_id"`121 HeadID string `json:"head_id"`
122 MergedCommitID string `json:"merged_commit_id"`122 MergedCommitID string `json:"merged_commit_id"`
123 MergeMode string `json:"merge_mode"`123 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())}
124 }144 }
125 145
126 // jsonMergeability is the stable JSON shape of the mergeability of an open146 // jsonMergeability is the stable JSON shape of the mergeability of an open
@@ -201,6 +221,7 @@ func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest
201 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),221 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
202 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),222 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
203 HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()),223 HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()),
224+ AutoMerge: toJSONAutoMerge(m.GetAutoMerge()), AutoMerged: m.GetAutoMerged(),
204 }225 }
205 }226 }
206 227
@@ -245,7 +266,8 @@ JSON fields of a merge request (--json): repo, url (full web URL), number, title
245 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and266 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
246 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the267 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
247 branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty268 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).`
249 271
250 const jsonMergeabilityHelp = `272 const jsonMergeabilityHelp = `
251 mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date",273 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
660 {"head", orDash(m.GetHeadId())},682 {"head", orDash(m.GetHeadId())},
661 }683 }
662 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {684 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() + ")"})
664 }693 }
665 if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {694 if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
666 pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})695 pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
@@ -1056,6 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1056 var mode string1085 var mode string
1057 var deleteBranch bool1086 var deleteBranch bool
1058 var head string1087 var head string
1088+ var auto, cancelAuto bool
1059 cmd := &cobra.Command{1089 cmd := &cobra.Command{
1060 Use: "merge <repo> <number>",1090 Use: "merge <repo> <number>",
1061 Short: "Merge a merge request",1091 Short: "Merge a merge request",
@@ -1078,7 +1108,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1078 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +1108 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
1079 "the source branch right after, in the background. The merge is refused when the branch is the\n" +1109 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
1080 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +1110 "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 +
1082 "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",1117 "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
1083 Args: cobra.ExactArgs(2),1118 Args: cobra.ExactArgs(2),
1084 ValidArgsFunction: completeMRRepoThenNumber(env),1119 ValidArgsFunction: completeMRRepoThenNumber(env),
@@ -1086,6 +1121,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1086 cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")1121 cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
1087 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")1122 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
1088 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")1123 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")
1089 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {1130 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
1090 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp1131 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
1091 })1132 })
@@ -1095,6 +1136,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1095 if err != nil {1136 if err != nil {
1096 return err1137 return err
1097 }1138 }
1139+ if cancelAuto {
1140+ return runMRCancelAuto(env, cmd, args[0], n, *asJSON)
1141+ }
1098 pbMode, err := parseMode(mode)1142 pbMode, err := parseMode(mode)
1099 if err != nil {1143 if err != nil {
1100 return err1144 return err
@@ -1104,6 +1148,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1104 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+1148 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
1105 "so that only the commit you reviewed is merged", args[0], n)1149 "so that only the commit you reviewed is merged", args[0], n)
1106 }1150 }
1151+ if auto {
1152+ return runMREnableAuto(env, cmd, args[0], n, pbMode, head, *asJSON)
1153+ }
1107 c, err := env.client(cmd)1154 c, err := env.client(cmd)
1108 if err != nil {1155 if err != nil {
1109 return err1156 return err
@@ -1139,6 +1186,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1139 return cmd1186 return cmd
1140 }1187 }
1141 1188
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+
1142 // signedNote says that a squash or merge commit is signed by the server's1234 // signedNote says that a squash or merge commit is signed by the server's
1143 // key, with its fingerprint when the server tells it.1235 // key, with its fingerprint when the server tells it.
1144 func signedNote(ctx context.Context, c *client.Client) string {1236 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 {
33 MergeMode string `json:"merge_mode"`33 MergeMode string `json:"merge_mode"`
34 }34 }
35 35
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"}
37 37
38 type commentOut struct {38 type commentOut struct {
39 ID string `json:"id"`39 ID string `json:"id"`
M internal/app/cli_mr_test.go +112 -1
@@ -51,7 +51,7 @@ func TestMRCreateMapsFlags(t *testing.T) {
51 _ = res51 _ = res
52 52
53 res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)53 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)
55 55
56 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {56 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
57 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)57 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
@@ -578,3 +578,114 @@ func TestJSONFileOmitted(t *testing.T) {
578 assert.Contains(t, string(raw), `"truncated":true`)578 assert.Contains(t, string(raw), `"truncated":true`)
579 assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))579 assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580 }580 }
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 {
36 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").36 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
37 MergeModes []string `json:"merge_modes"`37 MergeModes []string `json:"merge_modes"`
38 FailingChecksBlock bool `json:"failing_checks_block"`38 FailingChecksBlock bool `json:"failing_checks_block"`
39+ AllowAutoMerge bool `json:"allow_auto_merge"`
39 // Issues is the issue mode ("off", "owner" or "public").40 // Issues is the issue mode ("off", "owner" or "public").
40 Issues string `json:"issues"`41 Issues string `json:"issues"`
41 }42 }
@@ -48,6 +49,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
48 return jsonRepo{49 return jsonRepo{
49 MergeModes: modes,50 MergeModes: modes,
50 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),51 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
52+ AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
51 Issues: issuesModeName(r.GetSettings().GetIssues()),53 Issues: issuesModeName(r.GetSettings().GetIssues()),
52 Name: r.GetName(),54 Name: r.GetName(),
53 Description: r.GetDescription(),55 Description: r.GetDescription(),
@@ -115,8 +117,8 @@ const jsonRepoHelp = `
115 JSON fields of a repository (--json): name, description, visibility ("public" or117 JSON fields of a repository (--json): name, description, visibility ("public" or
116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is118 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
117 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or119 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 issue120+"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether
119-mode: "off", "owner" or "public").`121+auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
120 122
121 func newRepoCmd(env *cliEnv) *cobra.Command {123 func newRepoCmd(env *cliEnv) *cobra.Command {
122 repo := &cobra.Command{124 repo := &cobra.Command{
@@ -229,6 +231,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
229 {"last push", shortTime(r.GetPushedAt(), "never")},231 {"last push", shortTime(r.GetPushedAt(), "never")},
230 {"merge modes", orDash(strings.Join(modes, ", "))},232 {"merge modes", orDash(strings.Join(modes, ", "))},
231 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},233 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234+ {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
232 {"issues", issuesModeName(r.GetSettings().GetIssues())},235 {"issues", issuesModeName(r.GetSettings().GetIssues())},
233 })236 })
234 }237 }
@@ -308,7 +311,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin
308 func newRepoEditCmd(env *cliEnv) *cobra.Command {311 func newRepoEditCmd(env *cliEnv) *cobra.Command {
309 var description, visibility, defaultBranch string312 var description, visibility, defaultBranch string
310 var topics, mergeModes []string313 var topics, mergeModes []string
311- var failingChecksBlock bool314+ var failingChecksBlock, allowAutoMerge bool
312 var issues string315 var issues string
313 cmd := &cobra.Command{316 cmd := &cobra.Command{
314 Use: "edit <name>",317 Use: "edit <name>",
@@ -331,6 +334,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
331 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")334 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
332 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")335 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
333 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")336 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)")
334 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")338 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
335 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {339 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
336 return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp340 return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp
@@ -341,7 +345,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
341 })345 })
342 asJSON := addJSONFlag(cmd)346 asJSON := addJSONFlag(cmd)
343 cmd.RunE = func(cmd *cobra.Command, args []string) error {347 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)
345 if err != nil {349 if err != nil {
346 return err350 return err
347 }351 }
@@ -365,7 +369,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
365 369
366 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags370 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
367 // the user passed go into the update mask.371 // 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) {
369 req := &ermite.UpdateRepoRequest{Repo: name}373 req := &ermite.UpdateRepoRequest{Repo: name}
370 var paths []string374 var paths []string
371 if cmd.Flags().Changed("description") {375 if cmd.Flags().Changed("description") {
@@ -414,6 +418,13 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
414 req.Settings.FailingChecksBlock = failingChecksBlock418 req.Settings.FailingChecksBlock = failingChecksBlock
415 paths = append(paths, "settings.failing_checks_block")419 paths = append(paths, "settings.failing_checks_block")
416 }420 }
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+ }
417 if cmd.Flags().Changed("issues") {428 if cmd.Flags().Changed("issues") {
418 mode, err := parseIssuesMode(issues)429 mode, err := parseIssuesMode(issues)
419 if err != nil {430 if err != nil {
@@ -423,7 +434,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
423 req.Issues = &mode434 req.Issues = &mode
424 }435 }
425 if len(paths) == 0 && req.Issues == nil {436 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")
427 }438 }
428 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}439 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
429 return req, nil440 return req, nil
M internal/app/cli_repo_test.go +34 -4
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
54 {54 {
55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56 wantStdout: `[56 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"},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,"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"}
59 ]`,59 ]`,
60 },60 },
61 {61 {
@@ -116,12 +116,13 @@ func TestRepoShow(t *testing.T) {
116 "last push 2026-03-04 05:06\n"+116 "last push 2026-03-04 05:06\n"+
117 "merge modes fast-forward, squash\n"+117 "merge modes fast-forward, squash\n"+
118 "failing checks block true\n"+118 "failing checks block true\n"+
119+ "allow auto-merge false\n"+
119 "issues off\n", res.stdout)120 "issues off\n", res.stdout)
120 _, reqs := f.snapshot()121 _, reqs := f.snapshot()
121 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())122 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
122 123
123 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)124 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)
125 }126 }
126 127
127 func TestRepoShowNeedsOneArg(t *testing.T) {128 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -177,7 +178,7 @@ func TestRepoCreateJSON(t *testing.T) {
177 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil178 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
178 }}179 }}
179 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)180 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)
181 }182 }
182 183
183 func TestRepoEditMask(t *testing.T) {184 func TestRepoEditMask(t *testing.T) {
@@ -215,6 +216,13 @@ func TestRepoEditMask(t *testing.T) {
215 {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {216 {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
216 assert.True(t, r.GetSettings().GetFailingChecksBlock())217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
217 }},218 }},
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+ }},
218 {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},226 {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
219 []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},227 []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
220 }228 }
@@ -436,3 +444,25 @@ func TestToJSONRepoFromProto(t *testing.T) {
436 assert.Equal(t, "private", r.Visibility)444 assert.Equal(t, "private", r.Visibility)
437 assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)445 assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)
438 }446 }
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{
126 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",126 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",
127 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",127 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
128 client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server",128 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",
129 }130 }
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
17 )17 )
18 18
19 // allFeatures are the features this client uses.19 // 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"}
21 21
22 // Every feature the client uses needs a sentence for the version warning.22 // Every feature the client uses needs a sentence for the version warning.
23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
45 f := versionFake(allFeatures...)45 f := versionFake(allFeatures...)
46 url := f.serve(t)46 url := f.serve(t)
47 res := runCLI(t, envFor(url), "", "version").ok(t)47 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)
49 assert.Empty(t, res.stderr, "no warning when every feature is there")49 assert.Empty(t, res.stderr, "no warning when every feature is there")
50 50
51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)51 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)
53 }53 }
54 54
55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {55 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")
57 url := f.serve(t)57 url := f.serve(t)
58 res := runCLI(t, envFor(url), "", "version").ok(t)58 res := runCLI(t, envFor(url), "", "version").ok(t)
59 assert.Contains(t, res.stdout, "v0.4.0")59 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
61 assert.NotContains(t, res.stderr, "repo.update")61 assert.NotContains(t, res.stderr, "repo.update")
62 62
63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)63 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)
65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
66 66
67 f = versionFake()67 f = versionFake()
M internal/app/cli_watch.go +25 -3
@@ -34,6 +34,10 @@ type jsonEvent struct {
34 // State and FailureClass are set for a mirror_failed event.34 // State and FailureClass are set for a mirror_failed event.
35 State string `json:"state,omitempty"`35 State string `json:"state,omitempty"`
36 FailureClass string `json:"failure_class,omitempty"`36 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"`
37 }41 }
38 42
39 type jsonRefUpdate struct {43 type jsonRefUpdate struct {
@@ -53,6 +57,8 @@ const (
53 eventMROpened = "merge_request_opened"57 eventMROpened = "merge_request_opened"
54 eventMRMerged = "merge_request_merged"58 eventMRMerged = "merge_request_merged"
55 eventMRClosed = "merge_request_closed"59 eventMRClosed = "merge_request_closed"
60+ eventMRAutoEnabled = "merge_request_auto_merge_enabled"
61+ eventMRAutoCancelled = "merge_request_auto_merge_cancelled"
56 eventCommentAdded = "comment_added"62 eventCommentAdded = "comment_added"
57 eventCommentEdited = "comment_edited"63 eventCommentEdited = "comment_edited"
58 eventCommentDeleted = "comment_deleted"64 eventCommentDeleted = "comment_deleted"
@@ -87,6 +93,14 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
87 case *ermite.Event_MergeRequestMerged:93 case *ermite.Event_MergeRequestMerged:
88 m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)94 m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
89 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m95 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()
90 case *ermite.Event_MergeRequestClosed:104 case *ermite.Event_MergeRequestClosed:
91 m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)105 m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
92 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m106 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
@@ -140,6 +154,13 @@ func eventLine(e jsonEvent) string {
140 detail = e.Repo + " " + strings.Join(parts, ", ")154 detail = e.Repo + " " + strings.Join(parts, ", ")
141 case eventMROpened, eventMRMerged, eventMRClosed:155 case eventMROpened, eventMRMerged, eventMRClosed:
142 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)156 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)
143 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:164 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
144 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)165 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
145 case eventMRMergeability:166 case eventMRMergeability:
@@ -198,18 +219,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
198 Use: "watch [repo]",219 Use: "watch [repo]",
199 Short: "Print repository events as they happen",220 Short: "Print repository events as they happen",
200 Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" +221 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" +
202 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +223 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
203 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +224 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
204 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +225 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
205 "error.\n\n" +226 "error.\n\n" +
206 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +227 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
207 "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" +228 "(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" +
209 "and, depending on the type, repo (name),\n" +231 "and, depending on the type, repo (name),\n" +
210 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +232 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
211 "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" +233 "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" +
213 "event types are skipped.",235 "event types are skipped.",
214 Args: cobra.MaximumNArgs(1),236 Args: cobra.MaximumNArgs(1),
215 ValidArgsFunction: completeRepoNames(env),237 ValidArgsFunction: completeRepoNames(env),
M internal/app/cli_watch_test.go +55 -1
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
96 }96 }
97 require.Len(t, lines, 5)97 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])
99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
@@ -283,3 +283,57 @@ func TestMirrorWatchEvents(t *testing.T) {
283 assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")283 assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284 assert.NotContains(t, eventLine(recovered), " \n")284 assert.NotContains(t, eventLine(recovered), " \n")
285 }285 }
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 {
143 SigningKey *usecase.SigningKey143 SigningKey *usecase.SigningKey
144 // Mergeability keeps the mergeability of the open merge requests up to date.144 // Mergeability keeps the mergeability of the open merge requests up to date.
145 Mergeability *usecase.Mergeability145 Mergeability *usecase.Mergeability
146+ AutoMerge *usecase.AutoMerge
146 // Signatures are the trusted keys and the commit signature checks147 // Signatures are the trusted keys and the commit signature checks
147 // (ADR 0030).148 // (ADR 0030).
148 Signatures *usecase.Signatures149 Signatures *usecase.Signatures
@@ -370,6 +371,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
370 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,371 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
371 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,372 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
372 })373 })
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)
373 f.Mergeability.Start()382 f.Mergeability.Start()
374 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)383 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
375 f.Issues = usecase.NewIssues(usecase.IssuesConfig{384 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
@@ -393,6 +402,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
393 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),402 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
394 })403 })
395 f.CI.SetPullGate(f.MergeRequests)404 f.CI.SetPullGate(f.MergeRequests)
405+ f.AutoMerge.Start()
396 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")406 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
397 407
398 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)408 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
@@ -545,6 +555,9 @@ func (f *forge) Close(ctx context.Context) {
545 if f.hooks != nil {555 if f.hooks != nil {
546 f.hooks.Close(ctx)556 f.hooks.Close(ctx)
547 }557 }
558+ if f.AutoMerge != nil {
559+ f.AutoMerge.Close(ctx)
560+ }
548 if f.MergeRequests != nil {561 if f.MergeRequests != nil {
549 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability562 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
550 }563 }
M internal/domain/event.go +28 -0
@@ -131,11 +131,39 @@ type MergeRequestMerged struct {
131 At time.Time131 At time.Time
132 Visibility Visibility132 Visibility Visibility
133 CreatedAt time.Time133 CreatedAt time.Time
134+ // Auto says that auto-merge made the merge.
135+ Auto bool
134 }136 }
135 137
136 // EventKind implements Event.138 // EventKind implements Event.
137 func (MergeRequestMerged) EventKind() string { return "mr.merged" }139 func (MergeRequestMerged) EventKind() string { return "mr.merged" }
138 140
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+
139 // MergeRequestClosed is published when a merge request was closed without167 // MergeRequestClosed is published when a merge request was closed without
140 // merging.168 // merging.
141 type MergeRequestClosed struct {169 type MergeRequestClosed struct {
M internal/domain/event_test.go +7 -0
@@ -52,3 +52,10 @@ func TestEventKinds(t *testing.T) {
52 assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind())52 assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind())
53 assert.Equal(t, "gap", domain.EventGap{}.EventKind())53 assert.Equal(t, "gap", domain.EventGap{}.EventKind())
54 }54 }
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 {
50 FailingChecksBlock bool50 FailingChecksBlock bool
51 // Issues says who may write issues; the empty mode means IssuesOff.51 // Issues says who may write issues; the empty mode means IssuesOff.
52 Issues IssuesMode52 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
53 }56 }
54 57
55 // DefaultRepoSettings are the settings of a new repository: every mode, and58 // DefaultRepoSettings are the settings of a new repository: every mode, and
@@ -139,11 +142,29 @@ type MergeRequest struct {
139 MergeMode MergeMode142 MergeMode MergeMode
140 // CommentSeq is the id of the last comment of the request.143 // CommentSeq is the id of the last comment of the request.
141 CommentSeq int64144 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
142 // Version is set by the store on read: Update fails with ErrConflict when149 // Version is set by the store on read: Update fails with ErrConflict when
143 // the stored request changed since. Opaque to everyone else.150 // the stored request changed since. Opaque to everyone else.
144 Version uint64151 Version uint64
145 }152 }
146 153
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+
147 // draftPrefixes mark a draft at the start of a title, compared without regard168 // draftPrefixes mark a draft at the start of a title, compared without regard
148 // to case, as Forgejo and Gitea do.169 // to case, as Forgejo and Gitea do.
149 var draftPrefixes = []string{"wip:", "[wip]"}170 var draftPrefixes = []string{"wip:", "[wip]"}
M internal/domain/mergerequest_test.go +6 -0
@@ -330,3 +330,9 @@ func TestMergeabilityAllowsAndIsFor(t *testing.T) {
330 assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))330 assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331 assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))331 assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332 }332 }
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 {
367 WhileOpen(ctx context.Context, repo domain.RepoName, number int64, fn func() error) error367 WhileOpen(ctx context.Context, repo domain.RepoName, number int64, fn func() error) error
368 }368 }
369 369
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+
370 // PullGit is what the refs/pull/<n>/{head,merge} of the merge requests need382 // PullGit is what the refs/pull/<n>/{head,merge} of the merge requests need
371 // from Git. Every method returns domain.ErrNotFound when the repository has no383 // from Git. Every method returns domain.ErrNotFound when the repository has no
372 // directory.384 // directory.
@@ -896,3 +908,11 @@ type JobSettings struct {
896 type CIJobSettings interface {908 type CIJobSettings interface {
897 Resolve(ctx context.Context, req JobSettingsRequest) (JobSettings, error)909 Resolve(ctx context.Context, req JobSettingsRequest) (JobSettings, error)
898 }910 }
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 {
51 FailingChecksBlock *bool51 FailingChecksBlock *bool
52 // Issues replaces the issues mode.52 // Issues replaces the issues mode.
53 Issues *domain.IssuesMode53 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
54 }65 }
55 66
56 // TokenPage is a page of stored tokens, newest first.67 // 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
132 return _c132 return _c
133 }133 }
134 134
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+
135 // Close provides a mock function for the type MergeRequestService213 // Close provides a mock function for the type MergeRequestService
136 func (_mock *MergeRequestService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {214 func (_mock *MergeRequestService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
137 ret := _mock.Called(ctx, p, name, number)215 ret := _mock.Called(ctx, p, name, number)
@@ -618,6 +696,90 @@ func (_c *MergeRequestService_EditComment_Call) RunAndReturn(run func(ctx contex
618 return _c696 return _c
619 }697 }
620 698
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+
621 // Get provides a mock function for the type MergeRequestService783 // Get provides a mock function for the type MergeRequestService
622 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {784 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
623 ret := _mock.Called(ctx, p, name, number, opts)785 ret := _mock.Called(ctx, p, name, number, opts)
M internal/ports/ports.go +7 -0
@@ -437,6 +437,13 @@ type MergeGit interface {
437 CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)437 CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
438 }438 }
439 439
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+
440 // MergeabilityTrigger queues mergeability checks. It only queues the work and447 // MergeabilityTrigger queues mergeability checks. It only queues the work and
441 // returns.448 // returns.
442 type MergeabilityTrigger interface {449 type MergeabilityTrigger interface {
M internal/ports/services.go +5 -0
@@ -125,6 +125,11 @@ type MergeRequestService interface {
125 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)125 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)
126 // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix.126 // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix.
127 MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)127 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)
128 // CountOpen is the number of open merge requests of a repository the133 // CountOpen is the number of open merge requests of a repository the
129 // principal can read. It reads no Git: the tab of every page shows it.134 // principal can read. It reads no Git: the tab of every page shows it.
130 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)135 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 (
53 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies53 // Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies
54 // the defaults of Owner and Retention).54 // the defaults of Owner and Retention).
55 type CIConfig struct {55 type CIConfig struct {
56- Store ports.CIStore56+ AutoMerge ports.AutoMergeTrigger
57- Logs ports.CILogStore57+ Store ports.CIStore
58+ Logs ports.CILogStore
58 // Summaries keeps the job summaries, deleted with the logs of their run.59 // Summaries keeps the job summaries, deleted with the logs of their run.
59 Summaries ports.CISummaryStore60 Summaries ports.CISummaryStore
60 Meta ports.RepoMetaStore61 Meta ports.RepoMetaStore
@@ -266,6 +267,9 @@ type CI struct {
266 pending notifier // woken when a job may be ready or a runner was removed267 pending notifier // woken when a job may be ready or a runner was removed
267 flows *wfCache268 flows *wfCache
268 269
270+ pushes map[domain.RepoName]int
271+ pulls map[pullKey]int
272+ autoMerge ports.AutoMergeTrigger
269 mu sync.Mutex273 mu sync.Mutex
270 heartbeats map[string]time.Time // job reference -> last heartbeat274 heartbeats map[string]time.Time // job reference -> last heartbeat
271 seen map[string]time.Time // runner id -> last seen275 seen map[string]time.Time // runner id -> last seen
@@ -311,6 +315,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
311 cfg = cfg.withDefaults()315 cfg = cfg.withDefaults()
312 run, stop := context.WithCancel(context.WithoutCancel(ctx))316 run, stop := context.WithCancel(context.WithoutCancel(ctx))
313 return &CI{317 return &CI{
318+ pushes: map[domain.RepoName]int{}, pulls: map[pullKey]int{}, autoMerge: cfg.AutoMerge,
314 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),319 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
315 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),320 ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),
316 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},321 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
@@ -343,6 +348,15 @@ func (c *CI) Close(_ context.Context) {
343 c.mu.Unlock()348 c.mu.Unlock()
344 c.stop()349 c.stop()
345 c.wg.Wait()350 c.wg.Wait()
351+ // Workers stop on cancellation; release the planning parts they did not take.
352+ for {
353+ select {
354+ case t := <-c.queue:
355+ t.done()
356+ default:
357+ return
358+ }
359+ }
346 }360 }
347 361
348 func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() }362 func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() }
@@ -560,6 +574,7 @@ func (rs *runState) flush(ctx context.Context) error {
560 }574 }
561 if completing {575 if completing {
562 c.onRunEvent(ctx, rs.run, runCompleted)576 c.onRunEvent(ctx, rs.run, runCompleted)
577+ c.kickAutoMerge(ctx, rs.run.Repo)
563 }578 }
564 return nil579 return nil
565 }580 }
@@ -740,3 +755,47 @@ func isHex40(s string) bool {
740 }755 }
741 return strings.Trim(s, "0123456789abcdef") == ""756 return strings.Trim(s, "0123456789abcdef") == ""
742 }757 }
758+
759+// SetAutoMergeTrigger installs the evaluator after constructing the merge use cases.
760+func (c *CI) SetAutoMergeTrigger(t ports.AutoMergeTrigger) {
761+ c.mu.Lock()
762+ c.autoMerge = t
763+ c.mu.Unlock()
764+}
765+
766+func (c *CI) kickAutoMerge(ctx context.Context, repo domain.RepoName) {
767+ c.mu.Lock()
768+ t := c.autoMerge
769+ c.mu.Unlock()
770+ if t != nil {
771+ t.OnRepoChanged(ctx, repo)
772+ }
773+}
774+
775+// Settled reports whether all push planning and pull activities have finished.
776+// Workflow-run events, schedules and dispatches are intentionally not counted.
777+// Repo-wide pushes conservatively delay requests even when an unrelated
778+// branch is updated.
779+func (c *CI) Settled(_ context.Context, repo domain.RepoName, number int64) bool {
780+ c.mu.Lock()
781+ defer c.mu.Unlock()
782+ return c.pushes[repo] == 0 && c.pulls[pullKey{repo: repo, number: number}] == 0
783+}
784+
785+func (c *CI) finishPush(repo domain.RepoName) {
786+ c.mu.Lock()
787+ c.pushes[repo]--
788+ if c.pushes[repo] == 0 {
789+ delete(c.pushes, repo)
790+ }
791+ c.mu.Unlock()
792+}
793+
794+func (c *CI) finishPull(key pullKey) {
795+ c.mu.Lock()
796+ c.pulls[key]--
797+ if c.pulls[key] == 0 {
798+ delete(c.pulls, key)
799+ }
800+ c.mu.Unlock()
801+}
M internal/usecase/ci_actions.go +22 -14
@@ -271,31 +271,39 @@ func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.
271 if repo.Name == runRepo {271 if repo.Name == runRepo {
272 return true, nil272 return true, nil
273 }273 }
274- var p domain.Principal274+ p, _, err := principalOfPusher(ctx, c.cfg.Tokens, by.pusher, c.now())
275- switch by.pusher.Kind {275+ if err != nil {
276+ return false, err
277+ }
278+ return canRead(p, repo), nil
279+}
280+
281+// principalOfPusher re-resolves a recorded actor without substituting a more
282+// privileged identity when its token no longer exists or has expired.
283+func principalOfPusher(ctx context.Context, tokens ports.TokenStore, by domain.CIPusher, now time.Time) (domain.Principal, bool, error) {
284+ switch by.Kind {
276 case domain.CIPusherRoot:285 case domain.CIPusherRoot:
277- p = domain.Root()286+ return domain.Root(), true, nil
278 case domain.CIPusherOwner:287 case domain.CIPusherOwner:
279- p = domain.Owner()288+ return domain.Owner(), true, nil
280 case domain.CIPusherToken:289 case domain.CIPusherToken:
281- if c.cfg.Tokens == nil {290+ if tokens == nil {
282- return false, nil291+ return domain.Anonymous(), false, nil
283 }292 }
284- tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID)293+ tok, err := tokens.Get(ctx, by.TokenID)
285 if errors.Is(err, domain.ErrNotFound) {294 if errors.Is(err, domain.ErrNotFound) {
286- return false, nil295+ return domain.Anonymous(), false, nil
287 }296 }
288 if err != nil {297 if err != nil {
289- return false, err298+ return domain.Anonymous(), false, err
290 }299 }
291- if tok.Expired(c.now()) {300+ if tok.Expired(now) {
292- return false, nil301+ return domain.Anonymous(), false, nil
293 }302 }
294- p = domain.TokenPrincipal(tok)303+ return domain.TokenPrincipal(tok), true, nil
295 default:304 default:
296- p = domain.Anonymous()305+ return domain.Anonymous(), false, nil
297 }306 }
298- return canRead(p, repo), nil
299 }307 }
300 308
301 // actionRef is a parsed "uses": owner/repo[/path]@ref.309 // actionRef is a parsed "uses": owner/repo[/path]@ref.
A internal/usecase/ci_activity_test.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+func TestSettledCountsQueuedPushesAndPullEvents(t *testing.T) {
18+ trigger := mocks.NewAutoMergeTrigger(t)
19+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.TriggerQueue = 1; cfg.AutoMerge = trigger })
20+ repo := name(t, "proj")
21+ trigger.EXPECT().OnRepoChanged(mock.Anything, repo).Return().Times(3)
22+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
23+ ctx := context.Background()
24+ settled := func(n int64) bool { return u.ci.Settled(ctx, repo, n) }
25+ assert.True(t, settled(1))
26+ u.ci.QueuePush(ctx, repo)
27+ u.ci.QueuePush(ctx, repo) // dropped: queue capacity is one
28+ assert.False(t, settled(1))
29+ assert.False(t, settled(2)) // pushes conservatively cover the whole repo
30+ u.ci.QueuePull(repo, 1)
31+ u.ci.QueuePull(repo, 1) // dropped: no counter leak
32+ u.ci.PlanQueuedPush()
33+ assert.False(t, settled(1))
34+ assert.True(t, settled(2))
35+ u.ci.FinishQueuedPull()
36+ assert.True(t, settled(1))
37+}
38+
39+func TestSettledWaitsForPushDiscoveryAndReleasesOnError(t *testing.T) {
40+ mrs := mocks.NewMergeRequestStore(t)
41+ entered, release := make(chan struct{}), make(chan struct{})
42+ u := newCIUnit(t, func(cfg *usecase.CIConfig) {
43+ cfg.MRs = mrs
44+ cfg.Pull = mocks.NewPullGit(t)
45+ cfg.PullStage = func(string) { close(entered); <-release }
46+ })
47+ repo := name(t, "proj")
48+ mrs.EXPECT().OpenByTarget(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, nil)
49+ mrs.EXPECT().OpenBySource(mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, false, domain.ErrUnavailable)
50+ u.store.EXPECT().ListRunners(mock.Anything).Return(nil, nil).Once()
51+ done := make(chan struct{})
52+ go func() {
53+ u.ci.OnPush(context.Background(), repo, []domain.RefUpdate{{Ref: "refs/heads/feat", New: "1111111111111111111111111111111111111111"}}, domain.Root())
54+ close(done)
55+ }()
56+ <-entered
57+ u.ci.PlanQueuedPush()
58+ assert.False(t, u.ci.Settled(context.Background(), repo, 1))
59+ close(release)
60+ <-done
61+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
62+}
63+
64+func TestSettledReleasesQueuedPushOnShutdown(t *testing.T) {
65+ u := newCIUnit(t, nil)
66+ repo := name(t, "proj")
67+ u.ci.QueuePush(context.Background(), repo)
68+ u.ci.Close(context.Background())
69+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
70+ u.ci.QueuePush(context.Background(), repo)
71+ assert.True(t, u.ci.Settled(context.Background(), repo, 1))
72+}
M internal/usecase/ci_github_test.go +21 -0
@@ -5,14 +5,17 @@ package usecase_test
5 import (5 import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8+ "encoding/json"
8 "fmt"9 "fmt"
9 "math"10 "math"
10 "strconv"11 "strconv"
11 "strings"12 "strings"
12 "testing"13 "testing"
13 14
15+ "bnema.dev/ermite/internal/ports/mocks"
14 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
15 "github.com/stretchr/testify/assert"17 "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"19 "github.com/stretchr/testify/require"
17 20
18 "bnema.dev/ermite-actions/workflow"21 "bnema.dev/ermite-actions/workflow"
@@ -109,3 +112,21 @@ func TestCheckRunID(t *testing.T) {
109 assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)112 assert.Contains(t, logs.String(), `"job_index":`+strconv.Itoa(c.index), name)
110 }113 }
111 }114 }
115+
116+func TestGitHubRepoReportsAllowAutoMerge(t *testing.T) {
117+ for _, allow := range []bool{false, true} {
118+ t.Run(strconv.FormatBool(allow), func(t *testing.T) {
119+ repos := mocks.NewGitRepos(t)
120+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Repos = repos })
121+ repo := domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()}
122+ repo.Settings.AllowAutoMerge = allow
123+ u.meta.EXPECT().Get(mock.Anything, repo.Name).Return(repo, nil)
124+ repos.EXPECT().HeadBranch(mock.Anything, repo.Name).Return("main", nil)
125+ data, err := u.ci.GetRepo(context.Background(), domain.Root(), "owner", "proj")
126+ require.NoError(t, err)
127+ var obj map[string]any
128+ require.NoError(t, json.Unmarshal(data, &obj))
129+ assert.Equal(t, allow, obj["allow_auto_merge"])
130+ })
131+ }
132+}
M internal/usecase/ci_githubapi.go +1 -1
@@ -74,7 +74,7 @@ func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string
74 for k, v := range map[string]any{74 for k, v := range map[string]any{
75 "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),75 "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),
76 "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),76 "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),
77- "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false,77+ "allow_auto_merge": repo.EffectiveSettings().AllowAutoMerge, "delete_branch_on_merge": false, "allow_update_branch": false,
78 "use_squash_pr_title_as_default": false, "has_pull_requests": true,78 "use_squash_pr_title_as_default": false, "has_pull_requests": true,
79 } {79 } {
80 obj[k] = v80 obj[k] = v
M internal/usecase/ci_pull.go +6 -0
@@ -482,6 +482,9 @@ func (c *CI) wakePull() {
482 func (c *CI) queuePullEvent(e pullEvent) {482 func (c *CI) queuePullEvent(e pullEvent) {
483 c.mu.Lock()483 c.mu.Lock()
484 closed := c.closed484 closed := c.closed
485+ if !closed {
486+ c.pulls[pullKey{repo: e.repo, number: e.number}]++
487+ }
485 c.mu.Unlock()488 c.mu.Unlock()
486 if closed {489 if closed {
487 return490 return
@@ -489,6 +492,7 @@ func (c *CI) queuePullEvent(e pullEvent) {
489 select {492 select {
490 case c.pull.events <- e:493 case c.pull.events <- e:
491 default:494 default:
495+ c.finishPull(pullKey{repo: e.repo, number: e.number})
492 e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run")496 e.log.Error().Str("repo", e.repo.String()).Int64("number", e.number).Str("action", e.action).Msg("ci pull request queue is full: this activity of a merge request is not run")
493 }497 }
494 }498 }
@@ -614,6 +618,8 @@ func (c *CI) pullLoop() {
614 return618 return
615 case e := <-c.pull.events:619 case e := <-c.pull.events:
616 c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) })620 c.guardPull(e.log, e.repo, func(ctx context.Context) error { return c.handlePullEvent(ctx, e) })
621+ c.finishPull(pullKey{repo: e.repo, number: e.number})
622+ c.kickAutoMerge(c.ctx, e.repo)
617 }623 }
618 }624 }
619 }625 }
M internal/usecase/ci_trigger.go +41 -6
@@ -12,6 +12,7 @@ import (
12 "slices"12 "slices"
13 "strconv"13 "strconv"
14 "strings"14 "strings"
15+ "sync/atomic"
15 "time"16 "time"
16 17
17 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
@@ -33,6 +34,7 @@ const (
33 34
34 // ciTrigger is a push waiting to be planned.35 // ciTrigger is a push waiting to be planned.
35 type ciTrigger struct {36 type ciTrigger struct {
37+ done func()
36 repo domain.RepoName38 repo domain.RepoName
37 updates []domain.RefUpdate39 updates []domain.RefUpdate
38 actor string40 actor string
@@ -54,6 +56,9 @@ type ciTrigger struct {
54 // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes56 // with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
55 // from running itself again.57 // from running itself again.
56 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {58 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
59+ done := c.pushLifecycle(ctx, repo, 2)
60+ defer done()
61+
57 // Whoever pushed, the workflow files of the default branch may have changed.62 // Whoever pushed, the workflow files of the default branch may have changed.
58 // (Who pushed was recorded before the refs moved: Pushes.Check.)63 // (Who pushed was recorded before the refs moved: Pushes.Check.)
59 c.markSchedulesDirty(repo, updates)64 c.markSchedulesDirty(repo, updates)
@@ -63,9 +68,10 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
63 if pusher.ViaJobToken() {68 if pusher.ViaJobToken() {
64 log := zerowrap.FromCtx(ctx)69 log := zerowrap.FromCtx(ctx)
65 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")70 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
71+ done()
66 return72 return
67 }73 }
68- c.enqueue(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))74+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), done)
69 }75 }
70 76
71 // OnMerge queues the move of a target branch by a merge. The actor of the run is77 // OnMerge queues the move of a target branch by a merge. The actor of the run is
@@ -75,6 +81,9 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
75 // merging. For the same reason the run is trusted (gets secrets) only when the81 // merging. For the same reason the run is trusted (gets secrets) only when the
76 // merger is: the displayed actor is not the one that decides.82 // merger is: the displayed actor is not the one that decides.
77 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {83 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
84+ done := c.pushLifecycle(ctx, repo, 2)
85+ defer done()
86+
78 c.markSchedulesDirty(repo, updates)87 c.markSchedulesDirty(repo, updates)
79 // The merge moved a target branch: the test merges of the other requests88 // The merge moved a target branch: the test merges of the other requests
80 // into it are computed again (a merge starts no synchronize run), after the89 // into it are computed again (a merge starts no synchronize run), after the
@@ -83,12 +92,32 @@ func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain
83 if merger.ViaJobToken() {92 if merger.ViaJobToken() {
84 log := zerowrap.FromCtx(ctx)93 log := zerowrap.FromCtx(ctx)
85 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")94 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
95+ done()
86 return96 return
87 }97 }
88- c.enqueue(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))98+ c.enqueuePush(ctx, repo, updates, domain.AuthorName(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), done)
89 }99 }
90 100
91 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {101 func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
102+ c.enqueuePush(ctx, repo, updates, actor, pusher, trusted, c.pushLifecycle(ctx, repo, 1))
103+}
104+
105+// pushLifecycle holds one repository activity count until all parts finish.
106+func (c *CI) pushLifecycle(ctx context.Context, repo domain.RepoName, parts int32) func() {
107+ c.mu.Lock()
108+ c.pushes[repo]++
109+ c.mu.Unlock()
110+ var remaining atomic.Int32
111+ remaining.Store(parts)
112+ return func() {
113+ if remaining.Add(-1) == 0 {
114+ c.finishPush(repo)
115+ c.kickAutoMerge(ctx, repo)
116+ }
117+ }
118+}
119+
120+func (c *CI) enqueuePush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool, done func()) {
92 var kept []domain.RefUpdate121 var kept []domain.RefUpdate
93 for _, u := range updates {122 for _, u := range updates {
94 if strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/") {123 if strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/") {
@@ -96,20 +125,25 @@ func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain
96 }125 }
97 }126 }
98 if len(kept) == 0 {127 if len(kept) == 0 {
128+ done()
99 return129 return
100 }130 }
101- t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}131+ t := ciTrigger{done: done, repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
102 c.mu.Lock()132 c.mu.Lock()
103- closed := c.closed133+ if c.closed {
104- c.mu.Unlock()134+ c.mu.Unlock()
105- if closed {135+ done()
106 return136 return
107 }137 }
108 select {138 select {
109 case c.queue <- t:139 case c.queue <- t:
140+ c.mu.Unlock()
110 default:141 default:
142+ c.mu.Unlock()
143+ done()
111 t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")144 t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")
112 }145 }
146+
113 }147 }
114 148
115 func (c *CI) triggerLoop() {149 func (c *CI) triggerLoop() {
@@ -127,6 +161,7 @@ func (c *CI) triggerLoop() {
127 // planPush plans one push; a panic or an error is logged and never escapes.161 // planPush plans one push; a panic or an error is logged and never escapes.
128 func (c *CI) planPush(t ciTrigger) {162 func (c *CI) planPush(t ciTrigger) {
129 ctx := zerowrap.WithCtx(c.ctx, t.log)163 ctx := zerowrap.WithCtx(c.ctx, t.log)
164+ defer t.done()
130 defer func() {165 defer func() {
131 if r := recover(); r != nil {166 if r := recover(); r != nil {
132 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")167 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")
M internal/usecase/events.go +9 -0
@@ -182,6 +182,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
182 return repoSnapshot(ev.Visibility, ev.CreatedAt)182 return repoSnapshot(ev.Visibility, ev.CreatedAt)
183 case domain.MergeRequestClosed:183 case domain.MergeRequestClosed:
184 return repoSnapshot(ev.Visibility, ev.CreatedAt)184 return repoSnapshot(ev.Visibility, ev.CreatedAt)
185+ case domain.MergeRequestAutoMergeEnabled:
186+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
187+ case domain.MergeRequestAutoMergeCancelled:
188+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
185 case domain.MergeRequestReady:189 case domain.MergeRequestReady:
186 return repoSnapshot(ev.Visibility, ev.CreatedAt)190 return repoSnapshot(ev.Visibility, ev.CreatedAt)
187 case domain.CommentAdded:191 case domain.CommentAdded:
@@ -535,6 +539,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
535 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
536 case domain.MergeRequestClosed:540 case domain.MergeRequestClosed:
537 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)541 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
542+ case domain.MergeRequestAutoMergeEnabled:
543+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
544+ case domain.MergeRequestAutoMergeCancelled:
545+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
538 case domain.MergeRequestReady:546 case domain.MergeRequestReady:
539 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)547 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
540 case domain.CommentAdded:548 case domain.CommentAdded:
@@ -663,6 +671,7 @@ func isItemEvent(ev domain.Event) bool {
663 case domain.IssueReferenced:671 case domain.IssueReferenced:
664 return false // back-reference refreshes do not change the inbox672 return false // back-reference refreshes do not change the inbox
665 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,673 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
674+ domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
666 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,675 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
667 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:676 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
668 return true677 return true
M internal/usecase/export_test.go +48 -0
@@ -220,3 +220,51 @@ func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []doma
220 220
221 // WindowLimits exposes windowLimits to the tests.221 // WindowLimits exposes windowLimits to the tests.
222 var WindowLimits = windowLimits222 var WindowLimits = windowLimits
223+
224+// SetChecks installs a checks reader before starting a test evaluator.
225+func (m *MergeRequests) SetChecks(checks ports.CIChecksReader) { m.checks = checks }
226+
227+// Evaluate exposes a single synchronous pass without starting workers.
228+func (s *AutoMerge) Evaluate(repo domain.RepoName) bool { return s.evaluate(repo) }
229+
230+// QueuePush exposes the normal trigger queue for activity accounting tests.
231+func (c *CI) QueuePush(ctx context.Context, repo domain.RepoName) {
232+ c.enqueue(ctx, repo, []domain.RefUpdate{{Ref: "refs/heads/main"}}, "owner", domain.PusherOf(domain.Root()), true)
233+}
234+
235+// PlanQueuedPush handles the next queued trigger without starting workers.
236+func (c *CI) PlanQueuedPush() { c.planPush(<-c.queue) }
237+
238+// QueuePull exposes the pull activity queue for accounting tests.
239+func (c *CI) QueuePull(repo domain.RepoName, number int64) {
240+ c.queuePullEvent(pullEvent{repo: repo, number: number})
241+}
242+
243+// FinishQueuedPull completes one test activity without planning workflows.
244+func (c *CI) FinishQueuedPull() {
245+ e := <-c.pull.events
246+ c.finishPull(pullKey{repo: e.repo, number: e.number})
247+ c.kickAutoMerge(c.ctx, e.repo)
248+}
249+
250+// SetWorkflowTrigger installs the merge hook before evaluation.
251+func (m *MergeRequests) SetWorkflowTrigger(t ports.WorkflowTrigger) { m.ci = t }
252+
253+// UnderRunLock exposes the production run lock to test nonblocking triggers.
254+func (c *CI) UnderRunLock(id int64, fn func()) {
255+ lock := c.runLock(id)
256+ lock.Lock()
257+ defer lock.Unlock()
258+ fn()
259+}
260+
261+// UnderRepoLock holds the production merge lock while a test interleaves a kick.
262+func (m *MergeRequests) UnderRepoLock(ctx context.Context, repo domain.RepoName, fn func()) error {
263+ unlock, err := m.lockRepo(ctx, repo)
264+ if err != nil {
265+ return err
266+ }
267+ defer unlock()
268+ fn()
269+ return nil
270+}
M internal/usecase/issue_links_test.go +27 -0
@@ -11,8 +11,10 @@ import (
11 11
12 "bnema.dev/ermite/internal/domain"12 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"13 "bnema.dev/ermite/internal/ports"
14+ "bnema.dev/ermite/internal/ports/mocks"
14 "bnema.dev/ermite/internal/usecase"15 "bnema.dev/ermite/internal/usecase"
15 "github.com/stretchr/testify/assert"16 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
17 )19 )
18 20
@@ -233,3 +235,28 @@ func TestIssueLinksMergeReindexesRequestsWhoseSourceMoved(t *testing.T) {
233 require.Len(t, after, 1)235 require.Len(t, after, 1)
234 assert.Equal(t, issue.Number, after[0].Issue)236 assert.Equal(t, issue.Number, after[0].Issue)
235 }237 }
238+
239+func TestAutoMergeClosesLinkedIssue(t *testing.T) {
240+ r, _, issues := linkedRig(t)
241+ issue, err := issues.Create(r.ctx, root, r.repo, ports.CreateIssueParams{Title: "bug"})
242+ require.NoError(t, err)
243+ r.feature("feat")
244+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "fix", Source: "feat", Description: fmt.Sprintf("closes #%d", issue.Number)})
245+ require.NoError(t, err)
246+ _, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
247+ require.NoError(t, err)
248+ checks := mocks.NewCIChecksReader(t)
249+ r.mrs.SetChecks(checks)
250+ enableAuto(t, r, mr)
251+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Twice()
252+ a := autoEvaluator(r, checks, nil, nil)
253+ defer a.Close(r.ctx)
254+ require.True(t, a.Evaluate(r.repo))
255+ r.mrs.Wait(r.ctx)
256+ merged := storedAuto(t, r, mr)
257+ require.Equal(t, domain.MRStateMerged, merged.State)
258+ require.True(t, merged.AutoMerged)
259+ got, err := issues.Get(r.ctx, root, r.repo, issue.Number)
260+ require.NoError(t, err)
261+ assert.False(t, got.Issue.IsOpen())
262+}
M internal/usecase/mergeability.go +18 -6
@@ -35,12 +35,13 @@ func mergeBackoff(attempt int) time.Duration {
35 35
36 // MergeabilityConfig holds the dependencies of the repository reconciler.36 // MergeabilityConfig holds the dependencies of the repository reconciler.
37 type MergeabilityConfig struct {37 type MergeabilityConfig struct {
38- Store ports.MergeRequestStore38+ AutoMerge ports.AutoMergeTrigger
39- Meta ports.RepoMetaStore39+ Store ports.MergeRequestStore
40- Git ports.MergeGit40+ Meta ports.RepoMetaStore
41- Bus ports.EventBus41+ Git ports.MergeGit
42- Clock ports.Clock42+ Bus ports.EventBus
43- Workers int // defaults to 243+ Clock ports.Clock
44+ Workers int // defaults to 2
44 // After is injectable to drive backoff without wall-clock waits in tests.45 // After is injectable to drive backoff without wall-clock waits in tests.
45 After func(time.Duration) <-chan time.Time46 After func(time.Duration) <-chan time.Time
46 }47 }
@@ -235,6 +236,7 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
235 return false236 return false
236 }237 }
237 ok := true238 ok := true
239+ changed := false
238 for _, mr := range mrs {240 for _, mr := range mrs {
239 if ctx.Err() != nil {241 if ctx.Err() != nil {
240 return true242 return true
@@ -275,6 +277,10 @@ func (s *Mergeability) reconcile(repo domain.RepoName) bool {
275 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,277 Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,278 Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277 })279 })
280+ changed = true
281+ }
282+ if changed && s.cfg.AutoMerge != nil {
283+ s.cfg.AutoMerge.OnRepoChanged(ctx, repo)
278 }284 }
279 return ok285 return ok
280 }286 }
@@ -465,3 +471,9 @@ func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number
465 m.Behind, m.BehindCapped = domain.BoundBehind(n)471 m.Behind, m.BehindCapped = domain.BoundBehind(n)
466 return m, nil472 return m, nil
467 }473 }
474+
475+// WithAutoMerge installs the evaluator before Start.
476+func (s *Mergeability) WithAutoMerge(t ports.AutoMergeTrigger) *Mergeability {
477+ s.cfg.AutoMerge = t
478+ return s
479+}
M internal/usecase/mergerequests.go +93 -18
@@ -59,7 +59,8 @@ type MergeRequests struct {
59 tips ports.CIRefTipStore // nil: who merges is not recorded59 tips ports.CIRefTipStore // nil: who merges is not recorded
60 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)60 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
61 mergeab MergeabilityHook // nil: no mergeability61 mergeab MergeabilityHook // nil: no mergeability
62- mirror ports.MirrorTrigger // nil: no outbound mirror62+ autoMerge ports.AutoMergeTrigger
63+ mirror ports.MirrorTrigger // nil: no outbound mirror
63 64
64 mu sync.Mutex65 mu sync.Mutex
65 locks map[domain.RepoName]chan struct{}66 locks map[domain.RepoName]chan struct{}
@@ -150,6 +151,9 @@ func (m *MergeRequests) Wait(ctx context.Context) {
150 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The151 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
151 // merges of one repository, and the creation of its merge requests, run one at152 // merges of one repository, and the creation of its merge requests, run one at
152 // a time. The returned function releases the lock.153 // a time. The returned function releases the lock.
154+// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
155+// store transaction or a CI lock may take this lock. MergeRequests never
156+// calls Repos.Update.
153 func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {157 func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
154 m.mu.Lock()158 m.mu.Lock()
155 ch, ok := m.locks[name]159 ch, ok := m.locks[name]
@@ -917,6 +921,8 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
917 if err != nil {921 if err != nil {
918 return domain.MergeRequest{}, err922 return domain.MergeRequest{}, err
919 }923 }
924+ hadAutoMerge := mr.AutoMerge != nil
925+ mr.AutoMerge = nil
920 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()926 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
921 err = m.store.Update(ctx, mr)927 err = m.store.Update(ctx, mr)
922 if errors.Is(err, domain.ErrConflict) {928 if errors.Is(err, domain.ErrConflict) {
@@ -930,6 +936,9 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
930 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")936 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
931 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)937 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
932 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})938 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
939+ if hadAutoMerge {
940+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
941+ }
933 if m.links != nil {942 if m.links != nil {
934 m.links.OnMergeRequestSettled(ctx, repo, mr)943 m.links.OnMergeRequestSettled(ctx, repo, mr)
935 }944 }
@@ -1010,7 +1019,7 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
1010 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore1019 // the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
1011 // only says that the deletion was requested.1020 // only says that the deletion was requested.
1012 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {1021 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
1013- mr, err := m.merge(ctx, p, name, number, params)1022+ mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
1014 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err1023 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
1015 }1024 }
1016 1025
@@ -1052,9 +1061,23 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m
1052 return res.Tree, nil1061 return res.Tree, nil
1053 }1062 }
1054 1063
1055-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {1064+type mergeOpts struct {
1056- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {1065+ auto *autoMergeOpts
1057- return domain.MergeRequest{}, err1066+}
1067+
1068+type autoMergeOpts struct {
1069+ intent *domain.AutoMerge
1070+ tokens ports.TokenStore
1071+ activity ports.CIActivity
1072+}
1073+
1074+var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1075+
1076+func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1077+ if opts.auto == nil {
1078+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1079+ return domain.MergeRequest{}, err
1080+ }
1058 }1081 }
1059 mode := params.Mode1082 mode := params.Mode
1060 if mode == "" {1083 if mode == "" {
@@ -1083,6 +1106,15 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1083 if err != nil {1106 if err != nil {
1084 return domain.MergeRequest{}, err1107 return domain.MergeRequest{}, err
1085 }1108 }
1109+ if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) {
1110+ return domain.MergeRequest{}, errAutoMergeWait
1111+ }
1112+ if opts.auto != nil {
1113+ p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now())
1114+ if err != nil {
1115+ return domain.MergeRequest{}, err
1116+ }
1117+ }
1086 // A merge moves the target and may delete the source: a token limited to1118 // A merge moves the target and may delete the source: a token limited to
1087 // branch prefixes may do so only within them.1119 // branch prefixes may do so only within them.
1088 if !p.AllowsRef("refs/heads/" + mr.Target) {1120 if !p.AllowsRef("refs/heads/" + mr.Target) {
@@ -1134,14 +1166,33 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1134 } else if behind {1166 } else if behind {
1135 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)1167 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1136 }1168 }
1169+ if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) {
1170+ return domain.MergeRequest{}, errAutoMergeWait
1171+ }
1137 tree, err := m.checkMode(ctx, mr, mode, tip, head)1172 tree, err := m.checkMode(ctx, mr, mode, tip, head)
1138 if err != nil {1173 if err != nil {
1174+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1175+ return domain.MergeRequest{}, errAutoMergeWait
1176+ }
1139 return domain.MergeRequest{}, err1177 return domain.MergeRequest{}, err
1140 }1178 }
1141- if settings.FailingChecksBlock {1179+ if opts.auto != nil && !settings.AllowAutoMerge {
1142- if err := m.checksAllow(ctx, name, head); err != nil {1180+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1181+ }
1182+ if settings.FailingChecksBlock || opts.auto != nil {
1183+ checks, err := m.readMergeChecks(ctx, name, head)
1184+ if err == nil {
1185+ err = checksPolicy(checks)
1186+ }
1187+ if err != nil {
1188+ if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1189+ return domain.MergeRequest{}, errAutoMergeWait
1190+ }
1143 return domain.MergeRequest{}, err1191 return domain.MergeRequest{}, err
1144 }1192 }
1193+ if opts.auto != nil && !hasSuccessfulCheck(checks) {
1194+ return domain.MergeRequest{}, errAutoMergeWait
1195+ }
1145 }1196 }
1146 if params.DeleteSource {1197 if params.DeleteSource {
1147 if err := m.checkDeletable(ctx, name, mr); err != nil {1198 if err := m.checkDeletable(ctx, name, mr); err != nil {
@@ -1154,7 +1205,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
1154 return domain.MergeRequest{}, err1205 return domain.MergeRequest{}, err
1155 }1206 }
1156 m.recordPush(ctx, name) // the target moved, whatever happens next1207 m.recordPush(ctx, name) // the target moved, whatever happens next
1157- mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)1208+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil)
1158 if err != nil {1209 if err != nil {
1159 return domain.MergeRequest{}, err1210 return domain.MergeRequest{}, err
1160 }1211 }
@@ -1199,7 +1250,7 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
1199 }1250 }
1200 at := mr.UpdatedAt1251 at := mr.UpdatedAt
1201 recordActivity(ctx, m.meta, repo.Name, at)1252 recordActivity(ctx, m.meta, repo.Name, at)
1202- m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1253+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1203 m.bus.Publish(ctx, domain.PushEvent{1254 m.bus.Publish(ctx, domain.PushEvent{
1204 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1255 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1205 })1256 })
@@ -1272,21 +1323,34 @@ func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, num
1272 return fn()1323 return fn()
1273 }1324 }
1274 1325
1275-// checksAllow is the merge rule of FailingChecksBlock: every check of the1326+// readMergeChecks reads one snapshot, classifying an incomplete result as a
1276-// head must have ended as success, skipped or neutral. A check still queued or1327+// failed precondition rather than treating it as a passing set of checks.
1277-// running blocks too (the merge could land code that then fails), and a head1328+func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) {
1278-// without any check merges.
1279-func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error {
1280 if m.checks == nil {1329 if m.checks == nil {
1281- return nil1330+ return nil, nil
1282 }1331 }
1283 checks, err := m.checks.CommitChecks(ctx, name, head.String())1332 checks, err := m.checks.CommitChecks(ctx, name, head.String())
1284 if errors.Is(err, domain.ErrResourceExhausted) {1333 if errors.Is(err, domain.ErrResourceExhausted) {
1285- return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)1334+ return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
1286 }1335 }
1287 if err != nil {1336 if err != nil {
1288- return fmt.Errorf("read the checks of %s: %w", head, err)1337+ return nil, fmt.Errorf("read the checks of %s: %w", head, err)
1289 }1338 }
1339+ return checks, nil
1340+}
1341+
1342+func hasSuccessfulCheck(checks []domain.CICheck) bool {
1343+ for _, check := range checks {
1344+ if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1345+ return true
1346+ }
1347+ }
1348+ return false
1349+}
1350+
1351+// checksPolicy requires every check to have ended as success, skipped or
1352+// neutral. Queued and running checks block too; an empty set is allowed.
1353+func checksPolicy(checks []domain.CICheck) error {
1290 var failing, pending []string1354 var failing, pending []string
1291 for _, c := range checks {1355 for _, c := range checks {
1292 switch {1356 switch {
@@ -1392,7 +1456,7 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
1392 1456
1393 // markMerged records the merge. The target already moved, so a write that1457 // markMerged records the merge. The target already moved, so a write that
1394 // loses a race (a comment added meanwhile) is repeated on the fresh request.1458 // loses a race (a comment added meanwhile) is repeated on the fresh request.
1395-func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {1459+func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) {
1396 // The merge must be recorded even when the caller gives up.1460 // The merge must be recorded even when the caller gives up.
1397 ctx = context.WithoutCancel(ctx)1461 ctx = context.WithoutCancel(ctx)
1398 for range maxWriteRetries {1462 for range maxWriteRetries {
@@ -1400,6 +1464,7 @@ func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, nu
1400 if err != nil {1464 if err != nil {
1401 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)1465 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1402 }1466 }
1467+ mr.AutoMerge, mr.AutoMerged = nil, auto
1403 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode1468 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
1404 mr.UpdatedAt = m.clock.Now().UTC()1469 mr.UpdatedAt = m.clock.Now().UTC()
1405 err = m.store.Update(ctx, mr)1470 err = m.store.Update(ctx, mr)
@@ -1550,3 +1615,13 @@ func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, rep
1550 }1615 }
1551 return ports.PushSummary{Count: len(commits), More: more}, nil1616 return ports.PushSummary{Count: len(commits), More: more}, nil
1552 }1617 }
1618+
1619+// WithMergeLock serializes fn with repository merges and closes.
1620+func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
1621+ unlock, err := m.lockRepo(ctx, repo)
1622+ if err != nil {
1623+ return err
1624+ }
1625+ defer unlock()
1626+ return fn()
1627+}
A internal/usecase/mergerequests_auto.go +168 -0
@@ -0,0 +1,168 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// WithAutoMerge installs the asynchronous evaluator before serving requests.
15+func (m *MergeRequests) WithAutoMerge(t ports.AutoMergeTrigger) *MergeRequests {
16+ m.autoMerge = t
17+ return m
18+}
19+
20+// EnableAutoMerge records the actor and the head the caller saw, which must be
21+// the current head of the source branch; re-enabling replaces the
22+// previous intent rather than preserving an older actor's authority.
23+func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
24+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
25+ return domain.MergeRequest{}, err
26+ }
27+ if m.checks == nil {
28+ return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed)
29+ }
30+ mode := params.Mode
31+ if mode == "" {
32+ mode = domain.MergeModeFastForward
33+ }
34+ var v domain.Violations
35+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
36+ v.Add("mode", "is not a merge mode")
37+ }
38+ if params.HeadID.IsZero() {
39+ v.Add("head_id", "is required")
40+ }
41+ if err := v.Err(); err != nil {
42+ return domain.MergeRequest{}, err
43+ }
44+ unlock, err := m.lockRepo(ctx, name)
45+ if err != nil {
46+ return domain.MergeRequest{}, err
47+ }
48+ defer unlock()
49+ for range maxWriteRetries {
50+ mr, err := m.store.Get(ctx, name, number)
51+ if err != nil {
52+ return domain.MergeRequest{}, err
53+ }
54+ if !mr.IsOpen() {
55+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
56+ }
57+ if mr.IsDraft() {
58+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
59+ }
60+ if !p.AllowsRef("refs/heads/" + mr.Target) {
61+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
62+ }
63+ repo, err := m.meta.Get(ctx, name)
64+ if err != nil {
65+ return domain.MergeRequest{}, err
66+ }
67+ settings := repo.EffectiveSettings()
68+ if !settings.AllowAutoMerge {
69+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s: turn it on with `ermite repo edit --allow-auto-merge`", domain.ErrPreconditionFailed, name)
70+ }
71+ if !settings.Allows(mode) {
72+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
73+ }
74+ head, err := m.branchOrZero(ctx, name, mr.Source)
75+ if err != nil {
76+ return domain.MergeRequest{}, err
77+ }
78+ if head.IsZero() {
79+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
80+ }
81+ if head != params.HeadID {
82+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
83+ }
84+ mr.UpdatedAt = m.clock.Now().UTC()
85+ mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt}
86+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
87+ continue
88+ } else if err != nil {
89+ return domain.MergeRequest{}, err
90+ }
91+ recordActivity(ctx, m.meta, name, mr.UpdatedAt)
92+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
93+ if m.autoMerge != nil {
94+ m.autoMerge.OnAutoMergeEnabled(ctx, name)
95+ }
96+ return m.withHead(ctx, mr)
97+ }
98+ return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict)
99+}
100+
101+// CancelAutoMerge clears an open request's intent with the same target access
102+// required to enable it.
103+func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
104+ if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
105+ return domain.MergeRequest{}, err
106+ }
107+ unlock, err := m.lockRepo(ctx, name)
108+ if err != nil {
109+ return domain.MergeRequest{}, err
110+ }
111+ defer unlock()
112+ mr, err := m.store.Get(ctx, name, number)
113+ if err != nil {
114+ return domain.MergeRequest{}, err
115+ }
116+ if !mr.IsOpen() {
117+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
118+ }
119+ if !p.AllowsRef("refs/heads/" + mr.Target) {
120+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
121+ }
122+ if mr.AutoMerge == nil {
123+ return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed)
124+ }
125+ mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p))
126+ if err != nil {
127+ return domain.MergeRequest{}, err
128+ }
129+ return m.withHead(ctx, mr)
130+}
131+
132+// cancelAuto takes the merge lock and only cancels the intent evaluated by the
133+// caller. A concurrent re-enable, close or merge must not be undone.
134+func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error {
135+ unlock, err := m.lockRepo(ctx, mr.Repo)
136+ if err != nil {
137+ return err
138+ }
139+ defer unlock()
140+ _, err = m.cancelAutoLocked(ctx, mr, reason)
141+ return err
142+}
143+
144+func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) {
145+ for range maxWriteRetries {
146+ mr, err := m.store.Get(ctx, expected.Repo, expected.Number)
147+ if err != nil {
148+ return domain.MergeRequest{}, err
149+ }
150+ if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge {
151+ return mr, nil
152+ }
153+ repo, err := m.meta.Get(ctx, mr.Repo)
154+ if err != nil {
155+ return domain.MergeRequest{}, err
156+ }
157+ mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC()
158+ if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
159+ continue
160+ } else if err != nil {
161+ return domain.MergeRequest{}, err
162+ }
163+ recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt)
164+ m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
165+ return mr, nil
166+ }
167+ return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict)
168+}
M internal/usecase/repos.go +14 -6
@@ -17,12 +17,14 @@ import (
17 17
18 // Repos are the repository use cases.18 // Repos are the repository use cases.
19 type Repos struct {19 type Repos struct {
20- meta ports.RepoMetaStore20+ meta ports.RepoMetaStore
21- git ports.GitRepos21+ git ports.GitRepos
22- bus ports.EventBus22+ bus ports.EventBus
23- clock ports.Clock23+ clock ports.Clock
24- ci ports.CIRunCleaner // nil: no CI logs to remove24+ ci ports.CIRunCleaner // nil: no CI logs to remove
25- mirror ports.MirrorTrigger // nil: no outbound mirror25+ autoMerge ports.AutoMergeTrigger
26+ mergeGate ports.MergeGate
27+ mirror ports.MirrorTrigger // nil: no outbound mirror
26 }28 }
27 29
28 var _ ports.RepoService = (*Repos)(nil)30 var _ ports.RepoService = (*Repos)(nil)
@@ -233,3 +235,9 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
233 }235 }
234 return rep, nil236 return rep, nil
235 }237 }
238+
239+// WithAutoMerge installs the settings-change trigger before serving requests.
240+func (r *Repos) WithAutoMerge(t ports.AutoMergeTrigger) *Repos { r.autoMerge = t; return r }
241+
242+// WithMergeGate installs the settings-write gate before serving requests.
243+func (r *Repos) WithMergeGate(g ports.MergeGate) *Repos { r.mergeGate = g; return r }
M internal/usecase/repos_write.go +19 -4
@@ -62,7 +62,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
62 }62 }
63 before := repo63 before := repo
64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&64 if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65- params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.Issues == nil {65+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil && params.AllowAutoMerge == nil && params.Issues == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
68 if params.AllowedMergeModes != nil {68 if params.AllowedMergeModes != nil {
@@ -116,11 +116,23 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
116 // branch was checked above, so the remaining causes are a vanished116 // branch was checked above, so the remaining causes are a vanished
117 // repository or an I/O failure.117 // repository or an I/O failure.
118 metaDone := false118 metaDone := false
119- if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil {119+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil {
120- if repo, err = r.updateMeta(ctx, name, params); err != nil {120+ update := func() error {
121+ repo, err = r.updateMeta(ctx, name, params)
122+ return err
123+ }
124+ if r.mergeGate != nil && (params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil) {
125+ err = r.mergeGate.WithMergeLock(ctx, name, update)
126+ } else {
127+ err = update()
128+ }
129+ if err != nil {
121 return domain.Repo{}, err130 return domain.Repo{}, err
122 }131 }
123 metaDone = true132 metaDone = true
133+ if r.autoMerge != nil {
134+ r.autoMerge.OnRepoChanged(ctx, name)
135+ }
124 }136 }
125 var headErr error137 var headErr error
126 if params.DefaultBranch != nil {138 if params.DefaultBranch != nil {
@@ -142,7 +154,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
142 Bool("topics", params.Topics != nil).154 Bool("topics", params.Topics != nil).
143 Bool("visibility", params.Visibility != nil).155 Bool("visibility", params.Visibility != nil).
144 Bool("default_branch", params.DefaultBranch != nil).156 Bool("default_branch", params.DefaultBranch != nil).
145- Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.Issues != nil).157+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil || params.AllowAutoMerge != nil || params.Issues != nil).
146 Str("principal", p.Label()).Str("token_id", p.TokenID()).158 Str("principal", p.Label()).Str("token_id", p.TokenID()).
147 Msg("repository updated")159 Msg("repository updated")
148 r.bus.Publish(ctx, domain.RepoUpdated{160 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -175,6 +187,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
175 if params.FailingChecksBlock != nil {187 if params.FailingChecksBlock != nil {
176 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock188 repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
177 }189 }
190+ if params.AllowAutoMerge != nil {
191+ repo.Settings.AllowAutoMerge = *params.AllowAutoMerge
192+ }
178 if params.Issues != nil {193 if params.Issues != nil {
179 repo.Settings.Issues = *params.Issues194 repo.Settings.Issues = *params.Issues
180 }195 }
M internal/usecase/repos_write_test.go +17 -0
@@ -15,6 +15,7 @@ import (
15 15
16 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"17 "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
18 "bnema.dev/ermite/internal/usecase"19 "bnema.dev/ermite/internal/usecase"
19 )20 )
20 21
@@ -27,6 +28,22 @@ func TestUpdate(t *testing.T) {
27 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}28 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
28 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")29 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
29 30
31+ t.Run("auto-merge setting is stored and kicks evaluator", func(t *testing.T) {
32+ f := newFixture(t)
33+ trigger := mocks.NewAutoMergeTrigger(t)
34+ f.repos.WithAutoMerge(trigger)
35+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
36+ want := priv
37+ want.Settings = domain.DefaultRepoSettings()
38+ want.Settings.AllowAutoMerge = true
39+ expectRepoMutation(t, f.meta, priv, want)
40+ trigger.EXPECT().OnRepoChanged(ctx, n).Return().Once()
41+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
42+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
43+ require.NoError(t, err)
44+ assert.True(t, got.EffectiveSettings().AllowAutoMerge)
45+ })
46+
30 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {47 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
31 t.Parallel()48 t.Parallel()
32 f := newFixture(t)49 f := newFixture(t)
M proto/ermite/storage/merge_request.proto +15 -0
@@ -30,6 +30,7 @@ message RepoSettings {
30 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on30 // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
31 // read.31 // read.
32 bool merge_modes_v2 = 4;32 bool merge_modes_v2 = 4;
33+ bool allow_auto_merge = 5;
33 }34 }
34 35
35 // IssuesMode says who may write issues in a repository.36 // IssuesMode says who may write issues in a repository.
@@ -78,6 +79,20 @@ message MergeRequest {
78 MergeMode merge_mode = 13;79 MergeMode merge_mode = 13;
79 // Last comment id handed out for this request.80 // Last comment id handed out for this request.
80 int64 comment_seq = 14;81 int64 comment_seq = 14;
82+ // Pending auto-merge; unset when off.
83+ AutoMerge auto_merge = 15;
84+ bool auto_merged = 16;
85+}
86+
87+// AutoMerge is the pending auto-merge of an open merge request.
88+message AutoMerge {
89+ MergeMode mode = 1;
90+ string head_id = 2;
91+ // Kind and token id of the CIPusher that enabled it.
92+ string enabler_kind = 3;
93+ string enabler_token_id = 4;
94+ string enabler_label = 5;
95+ google.protobuf.Timestamp enabled_at = 6;
81 }96 }
82 97
83 // Verdict is the immutable review decision of a note.98 // Verdict is the immutable review decision of a note.
M skills/ermite/CLI.md +4 -2
@@ -11,10 +11,10 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
11 | `repo list [--json]` | none | repositories you can see |11 | `repo list [--json]` | none | repositories you can see |
12 | `repo show <name> [--json]` | none | |12 | `repo show <name> [--json]` | none | |
13 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |13 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false] [--allow-auto-merge]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
15 | `repo delete <name> [--yes]` | `repo:admin` | |15 | `repo delete <name> [--yes]` | `repo:admin` | |
16 16
17-Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`.17+Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`, `allow_auto_merge`.
18 18
19 ## Tokens19 ## Tokens
20 20
@@ -62,6 +62,7 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
62 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |62 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
63 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |63 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64 | `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |64 | `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
65+| `mr merge <repo> <n> --head <full-id> --auto [--mode M]` / `mr merge <repo> <n> --cancel-auto` | `mr:merge` | the server merges as you once CI settled on the head with a passed check and no failing one; needs `repo edit --allow-auto-merge`; failing checks or conflicts wait with auto-merge on; a new head cancels it, as do a deleted source, a disallowed mode, the setting turned off or your token losing merge rights; a manual merge supersedes it (`merge_request_merged`, `auto=false`, no cancelled event); a manual close clears it (`merge_request_closed` then cancelled with reason `closed`); JSON `auto_merge` (null unless pending), `auto_merged` |
65 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |66 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
66 67
67 `ermite mr create` and `ermite mr close` print a confirmation followed by the full68 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +107,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |107 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |108 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |109 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
110+| `merge_request_auto_merge_enabled`, `merge_request_auto_merge_cancelled` | `repo`, `merge_request`; cancelled also has `reason`. `merge_request_merged` has `auto` true after an auto-merge |
109 | `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes |111 | `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes |
110 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |112 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
111 | `gap` | none: events were lost, reload the state |113 | `gap` | none: events were lost, reload the state |
M skills/ermite/SKILL.md +2 -0
@@ -39,6 +39,8 @@ git push https://git.example.com/<repo>.git main # password = token,
39 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.39 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40 5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.40 5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
41 41
42+To merge once CI passed instead of now, add `--auto` (`ermite mr merge <repo> <n> --head <head_id> --auto --mode squash`; needs `repo edit --allow-auto-merge`); `--cancel-auto` cancels it.
43+
42 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.44 The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
43 45
44 ### Act on a review46 ### Act on a review

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 44

↑↓ moveenter openesc close