detached 6135dd5

#19 feat(mr): merge request auto-merge

state
merged · 6135dd5 · fast-forward
branch
feat/auto-merge → main
opened
bnema ·
updated

Merge request auto-merge, step 1 of ADR 0033.

  • Repository setting allow_auto_merge (default off): ermite repo edit --allow-auto-merge.
  • Enable with ermite mr merge --auto --head <sha> --mode <mode> or Merge when ready on the web; cancel with --cancel-auto.
  • Merges once CI has settled on the recorded head, every run is complete, at least one check passed and none fails (whatever failing_checks_block says), as the principal who enabled it. That principal is re-checked at every evaluation and under the merge lock.
  • Failing or conflicting heads wait. A new head, close, setting off, mode no longer allowed or a principal that can no longer merge cancels it.
  • Events mr.auto_merge_enabled, mr.auto_merge_cancelled, merged.auto; feature merge_requests.auto_merge.
  • Settings changes that affect merges take the merge lock (MergeGate). CI keeps a pushed head unsettled until its pull events are discovered.

Requires ermite-api v0.14.0.

Checks run locally: build, vet, hexcheck -fail-on warn, mocks/templ drift, race tests (all packages). The two golden failures (TestWebFilesGolden/blob-markdown, TestWebGolden/summary) and three errcheck lint findings also fail on main.

CommitSubjectAuthorDateSignatureStatus
6135dd5test(mr): close linked issues when a merge request auto-mergesbriceGPG Verified
4b10ba7merge: main with issue and merge request links into feat/auto-mergebriceGPG Verified
b2cf6d4build(deps): require ermite-api v0.14.0 for merge request auto-mergebriceGPG Verified
be277d4test(mr): cover failing checks with failing_checks_block off, the pending auto-merge index, and stop relying on event orderbriceGPG Verified
84db480perf(mr): skip auto-merge scans of repositories without a pending auto-merge, read checks once under the merge lock and keep the manual pending-checks errorbriceGPG Verified
b6e9374fix(web): give merge when ready its own mode choice and drop branch deletion from itbriceGPG Verified
355f5f4fix(mr): cancel auto-merge when the enabler can no longer merge, even while checks are pendingbriceGPG Verified
cb71bdddocs(mr): describe when auto-merge waits and when it is clearedbriceGPG Verified
22ff0d6fix(ci): keep a pushed head unsettled until its pull events are discoveredbriceGPG Verified
6609752fix(mr): resolve the auto-merge enabler under the merge lockbriceGPG Verified
8539d6ffix(repo): serialize merge settings updates with mergesbriceGPG Verified
63e55ecfeat(mr): require the head the caller saw to enable auto-mergebriceGPG Verified
b5db7e3docs(mr): document merge request auto-mergebriceGPG Verified
718df65merge: web auto-merge controls into feat/auto-mergebriceGPG Verified
b271fd8feat(cli): add mr merge --auto/--cancel-auto and repo edit --allow-auto-mergebriceGPG Verified
d1f49e5feat(rpc): serve merge request auto-merge and its eventsbriceGPG Verified
5dfb71bfeat(web): add merge when ready and auto-merge state to the merge boxbriceGPG Verified
31adb55style(web): gofmt mr_test.gobriceGPG Verified
ba47d48feat(app): wire the auto-merge evaluatorbriceGPG Verified
1314a0dfeat(mr): enable, cancel and evaluate merge request auto-mergebriceGPG Verified
9795e63feat(ports): add auto-merge service methods, CIActivity and AutoMergeTrigger portsbriceGPG Verified
4bb6db7feat(mr): store auto-merge state and allow_auto_merge settingbriceGPG Verified

Checks 8

70 files · +3994 -492
7/8
M docs/cli.md +3 -2
M docs/merge-requests.md +14 -0
M gen/ermite/storage/merge_request.pb.go +163 -35
M go.mod +1 -1
M go.sum +2 -2
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +5 -1
M internal/adapters/kvstore/mergerequests.go +32 -2
M internal/adapters/kvstore/mergerequests_test.go +47 -0
M internal/adapters/rpc/api.go +1 -1
M internal/adapters/rpc/convert.go +8 -2
M internal/adapters/rpc/mergerequest.go +39 -0
M internal/adapters/rpc/mr_api_test.go +80 -0
M internal/adapters/rpc/repo.go +9 -5
M internal/adapters/rpc/repo_test.go +25 -0
M internal/adapters/web/fragments.go +5 -1
M internal/adapters/web/mr.go +26 -0
M internal/adapters/web/mr.templ +28 -0
M internal/adapters/web/mr_post.go +62 -0
M internal/adapters/web/mr_templ.go +554 -350
M internal/adapters/web/mr_test.go +103 -1
M internal/adapters/web/web.go +2 -0
M internal/app/api_test.go +1 -1
A internal/app/automerge_e2e_test.go +163 -0
M internal/app/cli_e2e_test.go +2 -2
M internal/app/cli_fake_test.go +8 -0
@@ -62,6 +62,8 @@ type fakeAPI struct {
6262 closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
6363 mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
6464 markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
65+ enableAuto func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error)
66+ cancelAuto func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error)
6567 createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error)
6668 listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)
6769 getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)
@@ -152,6 +154,12 @@ func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite
152154 func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) {
153155 return respond(f, "MergeMergeRequest", r, f.mergeMR)
154156 }
157+func (f *fakeAPI) EnableAutoMerge(_ context.Context, r *connect.Request[ermite.EnableAutoMergeRequest]) (*connect.Response[ermite.EnableAutoMergeResponse], error) {
158+ return respond(f, "EnableAutoMerge", r, f.enableAuto)
159+}
160+func (f *fakeAPI) CancelAutoMerge(_ context.Context, r *connect.Request[ermite.CancelAutoMergeRequest]) (*connect.Response[ermite.CancelAutoMergeResponse], error) {
161+ return respond(f, "CancelAutoMerge", r, f.cancelAuto)
162+}
155163 func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
156164 return respond(f, "MarkRead", r, f.markRead)
157165 }
@@ -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 {
121121 HeadID string `json:"head_id"`
122122 MergedCommitID string `json:"merged_commit_id"`
123123 MergeMode string `json:"merge_mode"`
124+ // AutoMerge is the pending auto-merge, null when there is none.
125+ AutoMerge *jsonAutoMerge `json:"auto_merge"`
126+ // AutoMerged is true when the server merged the request by auto-merge.
127+ AutoMerged bool `json:"auto_merged"`
128+}
129+
130+// jsonAutoMerge is the stable JSON shape of a pending auto-merge.
131+type jsonAutoMerge struct {
132+ Mode string `json:"mode"`
133+ HeadID string `json:"head_id"`
134+ EnabledBy string `json:"enabled_by"`
135+ EnabledAt *string `json:"enabled_at"`
136+}
137+
138+// toJSONAutoMerge maps an auto-merge; nil when there is none.
139+func toJSONAutoMerge(a *ermite.AutoMerge) *jsonAutoMerge {
140+ if a == nil {
141+ return nil
142+ }
143+ return &jsonAutoMerge{Mode: modeName(a.GetMode()), HeadID: a.GetHeadId(), EnabledBy: a.GetEnabledBy(), EnabledAt: jsonTime(a.GetEnabledAt())}
124144 }
125145
126146 // jsonMergeability is the stable JSON shape of the mergeability of an open
@@ -201,6 +221,7 @@ func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest
201221 SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()),
202222 Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()),
203223 HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()),
224+ AutoMerge: toJSONAutoMerge(m.GetAutoMerge()), AutoMerged: m.GetAutoMerged(),
204225 }
205226 }
206227
@@ -245,7 +266,8 @@ JSON fields of a merge request (--json): repo, url (full web URL), number, title
245266 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
246267 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
247268 branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty
248-unless merged).`
269+unless merged), auto_merge (null unless auto-merge is pending: mode, head_id, enabled_by,
270+enabled_at) and auto_merged (true when the server merged the request by auto-merge).`
249271
250272 const jsonMergeabilityHelp = `
251273 mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date",
@@ -660,7 +682,14 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
660682 {"head", orDash(m.GetHeadId())},
661683 }
662684 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
663- pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
685+ merged := orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"
686+ pairs = append(pairs, [2]string{"merged", merged})
687+ if m.GetAutoMerged() {
688+ pairs = append(pairs, [2]string{"auto-merged", "yes, by the server once the checks passed"})
689+ }
690+ }
691+ if am := m.GetAutoMerge(); am != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
692+ pairs = append(pairs, [2]string{"auto-merge", modeName(am.GetMode()) + " when ready (enabled by " + am.GetEnabledBy() + ")"})
664693 }
665694 if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
666695 pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
@@ -1056,6 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10561085 var mode string
10571086 var deleteBranch bool
10581087 var head string
1088+ var auto, cancelAuto bool
10591089 cmd := &cobra.Command{
10601090 Use: "merge <repo> <number>",
10611091 Short: "Merge a merge request",
@@ -1078,7 +1108,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10781108 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
10791109 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
10801110 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
1081- "moved, say), the merge stands and the branch is kept." + jsonMRHelp +
1111+ "moved, say), the merge stands and the branch is kept.\n\n" +
1112+ "--auto does not merge now: it asks the server to merge by itself, with --mode, once CI has settled\n" +
1113+ "with at least one successful check on the head and every check allows it. Needs the mr:merge scope,\n" +
1114+ "a server with CI, and `ermite repo edit --allow-auto-merge` on the repository. A new push to the\n" +
1115+ "source branch, a change of the settings or of your permissions cancels it. --auto takes --head\n" +
1116+ "like a plain merge: it is enabled only if the source branch still points to it. --cancel-auto cancels a pending auto-merge." + jsonMRHelp +
10821117 "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
10831118 Args: cobra.ExactArgs(2),
10841119 ValidArgsFunction: completeMRRepoThenNumber(env),
@@ -1086,6 +1121,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10861121 cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
10871122 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
10881123 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1124+ cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)")
1125+ cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")
1126+ cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")
1127+ cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")
1128+ cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")
1129+ cmd.MarkFlagsMutuallyExclusive("auto", "delete-branch")
10891130 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
10901131 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
10911132 })
@@ -1095,6 +1136,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10951136 if err != nil {
10961137 return err
10971138 }
1139+ if cancelAuto {
1140+ return runMRCancelAuto(env, cmd, args[0], n, *asJSON)
1141+ }
10981142 pbMode, err := parseMode(mode)
10991143 if err != nil {
11001144 return err
@@ -1104,6 +1148,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11041148 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
11051149 "so that only the commit you reviewed is merged", args[0], n)
11061150 }
1151+ if auto {
1152+ return runMREnableAuto(env, cmd, args[0], n, pbMode, head, *asJSON)
1153+ }
11071154 c, err := env.client(cmd)
11081155 if err != nil {
11091156 return err
@@ -1139,6 +1186,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11391186 return cmd
11401187 }
11411188
1189+// runMREnableAuto is `mr merge --auto`: it asks the server to merge once ready.
1190+func runMREnableAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, mode ermite.MergeMode, head string, asJSON bool) error {
1191+ c, err := env.client(cmd)
1192+ if err != nil {
1193+ return err
1194+ }
1195+ ctx := env.ctx(cmd)
1196+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "cannot merge by itself (--auto)"); err != nil {
1197+ return err
1198+ }
1199+ res, err := c.MergeRequests().EnableAutoMerge(ctx, connect.NewRequest(&ermite.EnableAutoMergeRequest{Repo: repo, Number: n, Mode: mode, HeadId: head}))
1200+ if err != nil {
1201+ return c.Describe(err)
1202+ }
1203+ m := res.Msg.GetMergeRequest()
1204+ if asJSON {
1205+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1206+ }
1207+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge enabled on %s!%d: %s into %s when ready (enabled by %s)\n", clean(m.GetRepo()), m.GetNumber(),
1208+ modeName(m.GetAutoMerge().GetMode()), clean(m.GetTargetBranch()), clean(m.GetAutoMerge().GetEnabledBy()))
1209+ return err
1210+}
1211+
1212+// runMRCancelAuto is `mr merge --cancel-auto`.
1213+func runMRCancelAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, asJSON bool) error {
1214+ c, err := env.client(cmd)
1215+ if err != nil {
1216+ return err
1217+ }
1218+ ctx := env.ctx(cmd)
1219+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "has no auto-merge to cancel (--cancel-auto)"); err != nil {
1220+ return err
1221+ }
1222+ res, err := c.MergeRequests().CancelAutoMerge(ctx, connect.NewRequest(&ermite.CancelAutoMergeRequest{Repo: repo, Number: n}))
1223+ if err != nil {
1224+ return c.Describe(err)
1225+ }
1226+ m := res.Msg.GetMergeRequest()
1227+ if asJSON {
1228+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1229+ }
1230+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge cancelled on %s!%d\n", clean(m.GetRepo()), m.GetNumber())
1231+ return err
1232+}
1233+
11421234 // signedNote says that a squash or merge commit is signed by the server's
11431235 // key, with its fingerprint when the server tells it.
11441236 func signedNote(ctx context.Context, c *client.Client) string {
@@ -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 {
3333 MergeMode string `json:"merge_mode"`
3434 }
3535
36-var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
36+var mrKeys = []string{"author", "auto_merge", "auto_merged", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
3737
3838 type commentOut struct {
3939 ID string `json:"id"`
@@ -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) {
5151 _ = res
5252
5353 res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)
54- assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
54+ assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":"","auto_merge":null,"auto_merged":false}`, res.stdout)
5555
5656 for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
5757 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
@@ -578,3 +578,114 @@ func TestJSONFileOmitted(t *testing.T) {
578578 assert.Contains(t, string(raw), `"truncated":true`)
579579 assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580580 }
581+
582+func autoMergeFake(t *testing.T, features ...string) (*fakeAPI, string) {
583+ t.Helper()
584+ auto := proto.Clone(mrOne).(*ermite.MergeRequest)
585+ auto.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")}
586+ f := &fakeAPI{
587+ enableAuto: func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error) {
588+ return &ermite.EnableAutoMergeResponse{MergeRequest: auto}, nil
589+ },
590+ cancelAuto: func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error) {
591+ return &ermite.CancelAutoMergeResponse{MergeRequest: mrOne}, nil
592+ },
593+ getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
594+ return &ermite.GetVersionResponse{Version: "test", Features: features}, nil
595+ },
596+ }
597+ return f, f.serve(t)
598+}
599+
600+func TestMRMergeAuto(t *testing.T) {
601+ f, url := autoMergeFake(t, "merge_requests.auto_merge")
602+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--mode", "squash", "--head", headA).ok(t)
603+ assert.Equal(t, "auto-merge enabled on alpha!1: squash into main when ready (enabled by owner)\n", res.stdout)
604+ calls, reqs := f.snapshot()
605+ assert.Equal(t, []string{"GetVersion", "EnableAutoMerge"}, calls)
606+ got := reqs[1].(*ermite.EnableAutoMergeRequest)
607+ assert.Equal(t, "alpha", got.GetRepo())
608+ assert.EqualValues(t, 1, got.GetNumber())
609+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, got.GetMode())
610+ assert.Equal(t, headA, got.GetHeadId())
611+
612+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--head", headA, "--json").ok(t)
613+ var out struct {
614+ AutoMerge struct {
615+ Mode string `json:"mode"`
616+ HeadID string `json:"head_id"`
617+ EnabledBy string `json:"enabled_by"`
618+ EnabledAt string `json:"enabled_at"`
619+ } `json:"auto_merge"`
620+ }
621+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &out))
622+ assert.Equal(t, "squash", out.AutoMerge.Mode)
623+ assert.Equal(t, headA, out.AutoMerge.HeadID)
624+ assert.Equal(t, "owner", out.AutoMerge.EnabledBy)
625+ assert.Equal(t, "2026-10-03T08:00:00Z", out.AutoMerge.EnabledAt)
626+ _, reqs = f.snapshot()
627+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, reqs[len(reqs)-1].(*ermite.EnableAutoMergeRequest).GetMode(), "fast-forward by default")
628+
629+ for _, args := range [][]string{
630+ {"--auto", "--head", headA, "--cancel-auto"}, {"--auto", "--head", headA, "--delete-branch"}, {"--cancel-auto", "--head", headA}, {"--auto"},
631+ } {
632+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "merge", "alpha", "1"}, args...)...).err, "%v", args)
633+ }
634+}
635+
636+func TestMRMergeAutoNeedsTheFeature(t *testing.T) {
637+ f, url := autoMergeFake(t, "merge_requests")
638+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--head", headA)
639+ require.Error(t, res.err)
640+ assert.Contains(t, res.err.Error(), "merge_requests.auto_merge")
641+ calls, _ := f.snapshot()
642+ assert.NotContains(t, calls, "EnableAutoMerge")
643+}
644+
645+func TestMRMergeCancelAuto(t *testing.T) {
646+ f, url := autoMergeFake(t, "merge_requests.auto_merge")
647+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto").ok(t)
648+ assert.Equal(t, "auto-merge cancelled on alpha!1\n", res.stdout)
649+ calls, reqs := f.snapshot()
650+ assert.Equal(t, []string{"GetVersion", "CancelAutoMerge"}, calls)
651+ assert.EqualValues(t, 1, reqs[1].(*ermite.CancelAutoMergeRequest).GetNumber())
652+
653+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", "--json").ok(t)
654+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
655+
656+ for _, extra := range []string{"--delete-branch", "--head=" + headA} {
657+ require.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", extra).err, extra)
658+ }
659+}
660+
661+func TestMRShowAutoMerge(t *testing.T) {
662+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
663+ m.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")}
664+ cur := m
665+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
666+ return &ermite.GetMergeRequestResponse{MergeRequest: cur}, nil
667+ }}
668+ url := f.serve(t)
669+
670+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
671+ assert.Regexp(t, `(?m)^auto-merge\s+squash when ready \(enabled by owner\)$`, res.stdout)
672+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
673+ assert.Contains(t, res.stdout, `"mode": "squash"`)
674+ assert.Contains(t, res.stdout, `"enabled_by": "owner"`)
675+ assert.Contains(t, res.stdout, `"auto_merged": false`)
676+
677+ merged := proto.Clone(mrOne).(*ermite.MergeRequest)
678+ merged.State, merged.AutoMerged = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, true
679+ merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH
680+ cur = merged
681+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
682+ assert.Regexp(t, `(?m)^auto-merged\s+yes`, res.stdout)
683+ assert.NotContains(t, res.stdout, "when ready")
684+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
685+ assert.Contains(t, res.stdout, `"auto_merged": true`)
686+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
687+
688+ cur = mrOne
689+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
690+ assert.NotContains(t, res.stdout, "auto-merge")
691+}
@@ -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 {
3636 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
39+ AllowAutoMerge bool `json:"allow_auto_merge"`
3940 // Issues is the issue mode ("off", "owner" or "public").
4041 Issues string `json:"issues"`
4142 }
@@ -48,6 +49,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4849 return jsonRepo{
4950 MergeModes: modes,
5051 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
52+ AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
5153 Issues: issuesModeName(r.GetSettings().GetIssues()),
5254 Name: r.GetName(),
5355 Description: r.GetDescription(),
@@ -115,8 +117,8 @@ const jsonRepoHelp = `
115117 JSON fields of a repository (--json): name, description, visibility ("public" or
116118 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
117119 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
118-"merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119-mode: "off", "owner" or "public").`
120+"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether
121+auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
120122
121123 func newRepoCmd(env *cliEnv) *cobra.Command {
122124 repo := &cobra.Command{
@@ -229,6 +231,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
229231 {"last push", shortTime(r.GetPushedAt(), "never")},
230232 {"merge modes", orDash(strings.Join(modes, ", "))},
231233 {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234+ {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
232235 {"issues", issuesModeName(r.GetSettings().GetIssues())},
233236 })
234237 }
@@ -308,7 +311,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin
308311 func newRepoEditCmd(env *cliEnv) *cobra.Command {
309312 var description, visibility, defaultBranch string
310313 var topics, mergeModes []string
311- var failingChecksBlock bool
314+ var failingChecksBlock, allowAutoMerge bool
312315 var issues string
313316 cmd := &cobra.Command{
314317 Use: "edit <name>",
@@ -331,6 +334,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
331334 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
332335 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
333336 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
337+ cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")
334338 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
335339 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
336340 return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp
@@ -341,7 +345,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
341345 })
342346 asJSON := addJSONFlag(cmd)
343347 cmd.RunE = func(cmd *cobra.Command, args []string) error {
344- req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues)
348+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues)
345349 if err != nil {
346350 return err
347351 }
@@ -365,7 +369,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
365369
366370 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
367371 // the user passed go into the update mask.
368-func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) {
372+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
369373 req := &ermite.UpdateRepoRequest{Repo: name}
370374 var paths []string
371375 if cmd.Flags().Changed("description") {
@@ -414,6 +418,13 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
414418 req.Settings.FailingChecksBlock = failingChecksBlock
415419 paths = append(paths, "settings.failing_checks_block")
416420 }
421+ if cmd.Flags().Changed("allow-auto-merge") {
422+ if req.Settings == nil {
423+ req.Settings = &ermite.RepoSettings{}
424+ }
425+ req.Settings.AllowAutoMerge = allowAutoMerge
426+ paths = append(paths, "settings.allow_auto_merge")
427+ }
417428 if cmd.Flags().Changed("issues") {
418429 mode, err := parseIssuesMode(issues)
419430 if err != nil {
@@ -423,7 +434,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
423434 req.Issues = &mode
424435 }
425436 if len(paths) == 0 && req.Issues == nil {
426- return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues")
437+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --allow-auto-merge, --issues")
427438 }
428439 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
429440 return req, nil
@@ -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) {
5454 {
5555 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5656 wantStdout: `[
57- {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"},
58- {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}
57+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"},
58+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}
5959 ]`,
6060 },
6161 {
@@ -116,12 +116,13 @@ func TestRepoShow(t *testing.T) {
116116 "last push 2026-03-04 05:06\n"+
117117 "merge modes fast-forward, squash\n"+
118118 "failing checks block true\n"+
119+ "allow auto-merge false\n"+
119120 "issues off\n", res.stdout)
120121 _, reqs := f.snapshot()
121122 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
122123
123124 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
124- assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}`, res.stdout)
125+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
125126 }
126127
127128 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -177,7 +178,7 @@ func TestRepoCreateJSON(t *testing.T) {
177178 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
178179 }}
179180 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
180- assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}`, res.stdout)
181+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}`, res.stdout)
181182 }
182183
183184 func TestRepoEditMask(t *testing.T) {
@@ -215,6 +216,13 @@ func TestRepoEditMask(t *testing.T) {
215216 {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
216217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
217218 }},
219+ {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
220+ assert.True(t, r.GetSettings().GetAllowAutoMerge())
221+ assert.False(t, r.GetSettings().GetFailingChecksBlock())
222+ }},
223+ {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
224+ assert.False(t, r.GetSettings().GetAllowAutoMerge())
225+ }},
218226 {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
219227 []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
220228 }
@@ -436,3 +444,25 @@ func TestToJSONRepoFromProto(t *testing.T) {
436444 assert.Equal(t, "private", r.Visibility)
437445 assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility)
438446 }
447+
448+func TestRepoEditAllowAutoMerge(t *testing.T) {
449+ updated := &ermite.Repo{Name: "alpha", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, DefaultBranch: "main",
450+ Settings: &ermite.RepoSettings{AllowAutoMerge: true}}
451+ f := &fakeAPI{updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) {
452+ return &ermite.UpdateRepoResponse{Repo: updated}, nil
453+ }}
454+ url := f.serve(t)
455+
456+ res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge").ok(t)
457+ assert.Regexp(t, `(?m)^allow auto-merge\s+true$`, res.stdout)
458+ _, reqs := f.snapshot()
459+ got := reqs[0].(*ermite.UpdateRepoRequest)
460+ assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
461+ assert.True(t, got.GetSettings().GetAllowAutoMerge())
462+
463+ res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t)
464+ assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
465+ _, reqs = f.snapshot()
466+ got = reqs[1].(*ermite.UpdateRepoRequest)
467+ assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths())
468+}
@@ -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{
126126 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",
127127 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
128128 client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server",
129+ client.FeatureAutoMerge: "`ermite mr merge --auto` and `repo edit --allow-auto-merge` need: the commands are refused",
129130 }
@@ -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 (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
@@ -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 {
3434 // State and FailureClass are set for a mirror_failed event.
3535 State string `json:"state,omitempty"`
3636 FailureClass string `json:"failure_class,omitempty"`
37+ // Auto is set for merge_request_merged: auto-merge made the merge.
38+ Auto bool `json:"auto,omitempty"`
39+ // Reason is set for merge_request_auto_merge_cancelled.
40+ Reason string `json:"reason,omitempty"`
3741 }
3842
3943 type jsonRefUpdate struct {
@@ -53,6 +57,8 @@ const (
5357 eventMROpened = "merge_request_opened"
5458 eventMRMerged = "merge_request_merged"
5559 eventMRClosed = "merge_request_closed"
60+ eventMRAutoEnabled = "merge_request_auto_merge_enabled"
61+ eventMRAutoCancelled = "merge_request_auto_merge_cancelled"
5662 eventCommentAdded = "comment_added"
5763 eventCommentEdited = "comment_edited"
5864 eventCommentDeleted = "comment_deleted"
@@ -87,6 +93,14 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
8793 case *ermite.Event_MergeRequestMerged:
8894 m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL)
8995 out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m
96+ out.Auto = k.MergeRequestMerged.GetAuto()
97+ case *ermite.Event_MergeRequestAutoMergeEnabled:
98+ m := toJSONMergeRequest(k.MergeRequestAutoMergeEnabled.GetMergeRequest(), baseURL)
99+ out.Type, out.Repo, out.MergeRequest = eventMRAutoEnabled, m.Repo, &m
100+ case *ermite.Event_MergeRequestAutoMergeCancelled:
101+ m := toJSONMergeRequest(k.MergeRequestAutoMergeCancelled.GetMergeRequest(), baseURL)
102+ out.Type, out.Repo, out.MergeRequest = eventMRAutoCancelled, m.Repo, &m
103+ out.Reason = k.MergeRequestAutoMergeCancelled.GetReason()
90104 case *ermite.Event_MergeRequestClosed:
91105 m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL)
92106 out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m
@@ -140,6 +154,13 @@ func eventLine(e jsonEvent) string {
140154 detail = e.Repo + " " + strings.Join(parts, ", ")
141155 case eventMROpened, eventMRMerged, eventMRClosed:
142156 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
157+ if e.Auto {
158+ detail += " (auto-merge)"
159+ }
160+ case eventMRAutoEnabled:
161+ detail = fmt.Sprintf("%s!%d %s when ready", e.Repo, e.MergeRequest.Number, e.MergeRequest.AutoMerge.Mode)
162+ case eventMRAutoCancelled:
163+ detail = fmt.Sprintf("%s!%d: %s", e.Repo, e.MergeRequest.Number, e.Reason)
143164 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
144165 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
145166 case eventMRMergeability:
@@ -198,18 +219,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
198219 Use: "watch [repo]",
199220 Short: "Print repository events as they happen",
200221 Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" +
201- "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" +
222+ "opened, merged (by auto-merge or not) or closed, auto-merge enabled or cancelled, notes added, edited, deleted or resolved) for every\n" +
202223 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
203224 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
204225 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
205226 "error.\n\n" +
206227 "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" +
207228 "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" +
208- "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
229+ "merge_request_closed, merge_request_auto_merge_enabled, merge_request_auto_merge_cancelled\n" +
230+ "(with reason), comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" +
209231 "and, depending on the type, repo (name),\n" +
210232 "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" +
211233 "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" +
212- "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" +
234+ "`mr show --json`; merge_request_merged also has auto, true for an auto-merge) and, for comment events, number (of the merge request) and comment. Unknown\n" +
213235 "event types are skipped.",
214236 Args: cobra.MaximumNArgs(1),
215237 ValidArgsFunction: completeRepoNames(env),
@@ -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) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}}`, lines[0])
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}}`, lines[0])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
@@ -283,3 +283,57 @@ func TestMirrorWatchEvents(t *testing.T) {
283283 assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284284 assert.NotContains(t, eventLine(recovered), " \n")
285285 }
286+
287+func TestWatchAutoMergeEvents(t *testing.T) {
288+ events := []*ermite.WatchResponse{
289+ evt("1", func(e *ermite.Event) {
290+ e.Kind = &ermite.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &ermite.MergeRequestAutoMergeEnabled{MergeRequest: &ermite.MergeRequest{
291+ Repo: "alpha", Number: 1, SourceBranch: "feat/x", TargetBranch: "main",
292+ AutoMerge: &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner"},
293+ }}}
294+ }),
295+ evt("2", func(e *ermite.Event) {
296+ e.Kind = &ermite.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &ermite.MergeRequestAutoMergeCancelled{MergeRequest: mrOne, Reason: "the branch moved"}}
297+ }),
298+ evt("3", func(e *ermite.Event) {
299+ e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne, Auto: true}}
300+ }),
301+ evt("4", func(e *ermite.Event) {
302+ e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne}}
303+ }),
304+ }
305+ f := &fakeAPI{watch: finishing(events)}
306+ url := f.serve(t)
307+
308+ res := runCLI(t, envFor(url), "", "watch")
309+ require.Error(t, res.err)
310+ assert.Equal(t, ""+
311+ "2026-10-10T12:00:00Z merge_request_auto_merge_enabled alpha!1 squash when ready\n"+
312+ "2026-10-10T12:00:00Z merge_request_auto_merge_cancelled alpha!1: the branch moved\n"+
313+ "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main (auto-merge)\n"+
314+ "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main\n", res.stdout)
315+
316+ res = runCLI(t, envFor(url), "", "watch", "--json")
317+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
318+ require.Len(t, lines, 4)
319+ var enabled, cancelled, auto, manual struct {
320+ Type string `json:"type"`
321+ Reason string `json:"reason"`
322+ Auto bool `json:"auto"`
323+ MergeRequest *struct {
324+ AutoMerge *struct {
325+ Mode string `json:"mode"`
326+ } `json:"auto_merge"`
327+ } `json:"merge_request"`
328+ }
329+ for i, dst := range []any{&enabled, &cancelled, &auto, &manual} {
330+ require.NoError(t, json.Unmarshal([]byte(lines[i]), dst))
331+ }
332+ assert.Equal(t, "merge_request_auto_merge_enabled", enabled.Type)
333+ require.NotNil(t, enabled.MergeRequest.AutoMerge)
334+ assert.Equal(t, "squash", enabled.MergeRequest.AutoMerge.Mode)
335+ assert.Equal(t, "merge_request_auto_merge_cancelled", cancelled.Type)
336+ assert.Equal(t, "the branch moved", cancelled.Reason)
337+ assert.True(t, auto.Auto)
338+ assert.False(t, manual.Auto)
339+}
@@ -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 {
143143 SigningKey *usecase.SigningKey
144144 // Mergeability keeps the mergeability of the open merge requests up to date.
145145 Mergeability *usecase.Mergeability
146+ AutoMerge *usecase.AutoMerge
146147 // Signatures are the trusted keys and the commit signature checks
147148 // (ADR 0030).
148149 Signatures *usecase.Signatures
@@ -370,6 +371,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
370371 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
371372 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
372373 })
374+ f.AutoMerge = usecase.NewAutoMerge(ctx, usecase.AutoMergeConfig{
375+ MRs: f.MergeRequests, Store: store.MergeRequests(), Meta: store, Git: git,
376+ Checks: f.CI, Activity: f.CI, Tokens: store.Tokens(), Clock: clock,
377+ })
378+ f.MergeRequests.WithAutoMerge(f.AutoMerge)
379+ f.CI.SetAutoMergeTrigger(f.AutoMerge)
380+ f.Repos.WithAutoMerge(f.AutoMerge).WithMergeGate(f.MergeRequests)
381+ f.Mergeability.WithAutoMerge(f.AutoMerge)
373382 f.Mergeability.Start()
374383 f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
375384 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
@@ -393,6 +402,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
393402 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
394403 })
395404 f.CI.SetPullGate(f.MergeRequests)
405+ f.AutoMerge.Start()
396406 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
397407
398408 f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL)
@@ -545,6 +555,9 @@ func (f *forge) Close(ctx context.Context) {
545555 if f.hooks != nil {
546556 f.hooks.Close(ctx)
547557 }
558+ if f.AutoMerge != nil {
559+ f.AutoMerge.Close(ctx)
560+ }
548561 if f.MergeRequests != nil {
549562 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
550563 }
@@ -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 {
131131 At time.Time
132132 Visibility Visibility
133133 CreatedAt time.Time
134+ // Auto says that auto-merge made the merge.
135+ Auto bool
134136 }
135137
136138 // EventKind implements Event.
137139 func (MergeRequestMerged) EventKind() string { return "mr.merged" }
138140
141+// MergeRequestAutoMergeEnabled is published when auto-merge was enabled on a
142+// merge request.
143+type MergeRequestAutoMergeEnabled struct {
144+ MergeRequest MergeRequest
145+ At time.Time
146+ Visibility Visibility
147+ CreatedAt time.Time
148+}
149+
150+// EventKind implements Event.
151+func (MergeRequestAutoMergeEnabled) EventKind() string { return "mr.auto_merge_enabled" }
152+
153+// MergeRequestAutoMergeCancelled is published when auto-merge was cancelled
154+// on a merge request, by hand or by the server.
155+type MergeRequestAutoMergeCancelled struct {
156+ MergeRequest MergeRequest
157+ // Reason says why, in words.
158+ Reason string
159+ At time.Time
160+ Visibility Visibility
161+ CreatedAt time.Time
162+}
163+
164+// EventKind implements Event.
165+func (MergeRequestAutoMergeCancelled) EventKind() string { return "mr.auto_merge_cancelled" }
166+
139167 // MergeRequestClosed is published when a merge request was closed without
140168 // merging.
141169 type MergeRequestClosed struct {
@@ -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) {
5252 assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind())
5353 assert.Equal(t, "gap", domain.EventGap{}.EventKind())
5454 }
55+
56+func TestAutoMergeEventKinds(t *testing.T) {
57+ t.Parallel()
58+ assert.Equal(t, "mr.auto_merge_enabled", domain.MergeRequestAutoMergeEnabled{}.EventKind())
59+ assert.Equal(t, "mr.auto_merge_cancelled", domain.MergeRequestAutoMergeCancelled{}.EventKind())
60+ assert.Equal(t, "mr.merged", domain.MergeRequestMerged{Auto: true}.EventKind())
61+}
@@ -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 {
5050 FailingChecksBlock bool
5151 // Issues says who may write issues; the empty mode means IssuesOff.
5252 Issues IssuesMode
53+ // AllowAutoMerge says that merge requests may be set to merge themselves
54+ // once their checks pass. Off by default.
55+ AllowAutoMerge bool
5356 }
5457
5558 // DefaultRepoSettings are the settings of a new repository: every mode, and
@@ -139,11 +142,29 @@ type MergeRequest struct {
139142 MergeMode MergeMode
140143 // CommentSeq is the id of the last comment of the request.
141144 CommentSeq int64
145+ // AutoMerge is the pending auto-merge; nil when off.
146+ AutoMerge *AutoMerge
147+ // AutoMerged says that the request was merged by auto-merge.
148+ AutoMerged bool
142149 // Version is set by the store on read: Update fails with ErrConflict when
143150 // the stored request changed since. Opaque to everyone else.
144151 Version uint64
145152 }
146153
154+// AutoMerge is the pending auto-merge of an open merge request.
155+type AutoMerge struct {
156+ // Mode is the merge mode chosen when auto-merge was enabled.
157+ Mode MergeMode
158+ // HeadID is the head it was enabled for; a new head cancels it.
159+ HeadID ObjectID
160+ // EnabledBy is who enabled it; the merge is made on their behalf.
161+ EnabledBy CIPusher
162+ // EnablerLabel is the display name of the enabler.
163+ EnablerLabel string
164+ // EnabledAt is when auto-merge was enabled.
165+ EnabledAt time.Time
166+}
167+
147168 // draftPrefixes mark a draft at the start of a title, compared without regard
148169 // to case, as Forgejo and Gitea do.
149170 var draftPrefixes = []string{"wip:", "[wip]"}
@@ -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) {
330330 assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331331 assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332332 }
333+
334+func TestRepoSettingsAllowAutoMergeDefaultOff(t *testing.T) {
335+ t.Parallel()
336+ assert.False(t, domain.DefaultRepoSettings().AllowAutoMerge)
337+ assert.False(t, domain.RepoSettings{}.AllowAutoMerge)
338+}
@@ -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 {
367367 WhileOpen(ctx context.Context, repo domain.RepoName, number int64, fn func() error) error
368368 }
369369
370+// MergeGate serializes a change with the merges of a repository.
371+// *usecase.MergeRequests implements it.
372+// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
373+// store transaction or a CI lock may take the merge lock. MergeRequests never
374+// calls Repos.Update.
375+type MergeGate interface {
376+ // WithMergeLock runs fn holding the repository merge lock (also taken by
377+ // Merge, Close and auto-merge). fn must be short and must not call the
378+ // merge request use cases.
379+ WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error
380+}
381+
370382 // PullGit is what the refs/pull/<n>/{head,merge} of the merge requests need
371383 // from Git. Every method returns domain.ErrNotFound when the repository has no
372384 // directory.
@@ -896,3 +908,11 @@ type JobSettings struct {
896908 type CIJobSettings interface {
897909 Resolve(ctx context.Context, req JobSettingsRequest) (JobSettings, error)
898910 }
911+
912+// CIActivity tells whether CI still has queued work that may create runs for a
913+// head. *usecase.CI implements it.
914+type CIActivity interface {
915+ // Settled reports that no push trigger of the repository and no pull event
916+ // of the merge request is queued or being planned.
917+ Settled(ctx context.Context, repo domain.RepoName, number int64) bool
918+}
@@ -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 {
5151 FailingChecksBlock *bool
5252 // Issues replaces the issues mode.
5353 Issues *domain.IssuesMode
54+ // AllowAutoMerge replaces the setting of that name.
55+ AllowAutoMerge *bool
56+}
57+
58+// EnableAutoMergeParams are the settings of EnableAutoMerge.
59+type EnableAutoMergeParams struct {
60+ // Mode is the merge mode to use; empty means fast-forward.
61+ Mode domain.MergeMode
62+ // HeadID is the head the caller saw; required. Auto-merge is enabled only
63+ // if the source branch still points to it.
64+ HeadID domain.ObjectID
5465 }
5566
5667 // TokenPage is a page of stored tokens, newest first.
@@ -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+}
@@ -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+}
@@ -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+}
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeGate creates a new instance of MergeGate. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeGate(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeGate {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeGate{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeGate is an autogenerated mock type for the MergeGate type
38+type MergeGate struct {
39+ mock.Mock
40+}
41+
42+type MergeGate_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeGate) EXPECT() *MergeGate_Expecter {
47+ return &MergeGate_Expecter{mock: &_m.Mock}
48+}
49+
50+// WithMergeLock provides a mock function for the type MergeGate
51+func (_mock *MergeGate) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
52+ ret := _mock.Called(ctx, repo, fn)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for WithMergeLock")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func() error) error); ok {
60+ r0 = returnFunc(ctx, repo, fn)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// MergeGate_WithMergeLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithMergeLock'
68+type MergeGate_WithMergeLock_Call struct {
69+ *mock.Call
70+}
71+
72+// WithMergeLock is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+// - fn func() error
76+func (_e *MergeGate_Expecter) WithMergeLock(ctx any, repo any, fn any) *MergeGate_WithMergeLock_Call {
77+ return &MergeGate_WithMergeLock_Call{Call: _e.mock.On("WithMergeLock", ctx, repo, fn)}
78+}
79+
80+func (_c *MergeGate_WithMergeLock_Call) Run(run func(ctx context.Context, repo domain.RepoName, fn func() error)) *MergeGate_WithMergeLock_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.RepoName
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.RepoName)
89+ }
90+ var arg2 func() error
91+ if args[2] != nil {
92+ arg2 = args[2].(func() error)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeGate_WithMergeLock_Call) Return(err error) *MergeGate_WithMergeLock_Call {
104+ _c.Call.Return(err)
105+ return _c
106+}
107+
108+func (_c *MergeGate_WithMergeLock_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, fn func() error) error) *MergeGate_WithMergeLock_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
M internal/ports/mocks/MergeRequestService.go +162 -0
@@ -132,6 +132,84 @@ func (_c *MergeRequestService_AddComment_Call) RunAndReturn(run func(ctx context
132132 return _c
133133 }
134134
135+// CancelAutoMerge provides a mock function for the type MergeRequestService
136+func (_mock *MergeRequestService) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
137+ ret := _mock.Called(ctx, p, name, number)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for CancelAutoMerge")
141+ }
142+
143+ var r0 domain.MergeRequest
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.MergeRequest, error)); ok {
146+ return returnFunc(ctx, p, name, number)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.MergeRequest); ok {
149+ r0 = returnFunc(ctx, p, name, number)
150+ } else {
151+ r0 = ret.Get(0).(domain.MergeRequest)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
154+ r1 = returnFunc(ctx, p, name, number)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// MergeRequestService_CancelAutoMerge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelAutoMerge'
162+type MergeRequestService_CancelAutoMerge_Call struct {
163+ *mock.Call
164+}
165+
166+// CancelAutoMerge is a helper method to define mock.On call
167+// - ctx context.Context
168+// - p domain.Principal
169+// - name domain.RepoName
170+// - number int64
171+func (_e *MergeRequestService_Expecter) CancelAutoMerge(ctx any, p any, name any, number any) *MergeRequestService_CancelAutoMerge_Call {
172+ return &MergeRequestService_CancelAutoMerge_Call{Call: _e.mock.On("CancelAutoMerge", ctx, p, name, number)}
173+}
174+
175+func (_c *MergeRequestService_CancelAutoMerge_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_CancelAutoMerge_Call {
176+ _c.Call.Run(func(args mock.Arguments) {
177+ var arg0 context.Context
178+ if args[0] != nil {
179+ arg0 = args[0].(context.Context)
180+ }
181+ var arg1 domain.Principal
182+ if args[1] != nil {
183+ arg1 = args[1].(domain.Principal)
184+ }
185+ var arg2 domain.RepoName
186+ if args[2] != nil {
187+ arg2 = args[2].(domain.RepoName)
188+ }
189+ var arg3 int64
190+ if args[3] != nil {
191+ arg3 = args[3].(int64)
192+ }
193+ run(
194+ arg0,
195+ arg1,
196+ arg2,
197+ arg3,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *MergeRequestService_CancelAutoMerge_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_CancelAutoMerge_Call {
204+ _c.Call.Return(mergeRequest, err)
205+ return _c
206+}
207+
208+func (_c *MergeRequestService_CancelAutoMerge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)) *MergeRequestService_CancelAutoMerge_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
135213 // Close provides a mock function for the type MergeRequestService
136214 func (_mock *MergeRequestService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
137215 ret := _mock.Called(ctx, p, name, number)
@@ -618,6 +696,90 @@ func (_c *MergeRequestService_EditComment_Call) RunAndReturn(run func(ctx contex
618696 return _c
619697 }
620698
699+// EnableAutoMerge provides a mock function for the type MergeRequestService
700+func (_mock *MergeRequestService) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
701+ ret := _mock.Called(ctx, p, name, number, params)
702+
703+ if len(ret) == 0 {
704+ panic("no return value specified for EnableAutoMerge")
705+ }
706+
707+ var r0 domain.MergeRequest
708+ var r1 error
709+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) (domain.MergeRequest, error)); ok {
710+ return returnFunc(ctx, p, name, number, params)
711+ }
712+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) domain.MergeRequest); ok {
713+ r0 = returnFunc(ctx, p, name, number, params)
714+ } else {
715+ r0 = ret.Get(0).(domain.MergeRequest)
716+ }
717+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.EnableAutoMergeParams) error); ok {
718+ r1 = returnFunc(ctx, p, name, number, params)
719+ } else {
720+ r1 = ret.Error(1)
721+ }
722+ return r0, r1
723+}
724+
725+// MergeRequestService_EnableAutoMerge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnableAutoMerge'
726+type MergeRequestService_EnableAutoMerge_Call struct {
727+ *mock.Call
728+}
729+
730+// EnableAutoMerge is a helper method to define mock.On call
731+// - ctx context.Context
732+// - p domain.Principal
733+// - name domain.RepoName
734+// - number int64
735+// - params ports.EnableAutoMergeParams
736+func (_e *MergeRequestService_Expecter) EnableAutoMerge(ctx any, p any, name any, number any, params any) *MergeRequestService_EnableAutoMerge_Call {
737+ return &MergeRequestService_EnableAutoMerge_Call{Call: _e.mock.On("EnableAutoMerge", ctx, p, name, number, params)}
738+}
739+
740+func (_c *MergeRequestService_EnableAutoMerge_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams)) *MergeRequestService_EnableAutoMerge_Call {
741+ _c.Call.Run(func(args mock.Arguments) {
742+ var arg0 context.Context
743+ if args[0] != nil {
744+ arg0 = args[0].(context.Context)
745+ }
746+ var arg1 domain.Principal
747+ if args[1] != nil {
748+ arg1 = args[1].(domain.Principal)
749+ }
750+ var arg2 domain.RepoName
751+ if args[2] != nil {
752+ arg2 = args[2].(domain.RepoName)
753+ }
754+ var arg3 int64
755+ if args[3] != nil {
756+ arg3 = args[3].(int64)
757+ }
758+ var arg4 ports.EnableAutoMergeParams
759+ if args[4] != nil {
760+ arg4 = args[4].(ports.EnableAutoMergeParams)
761+ }
762+ run(
763+ arg0,
764+ arg1,
765+ arg2,
766+ arg3,
767+ arg4,
768+ )
769+ })
770+ return _c
771+}
772+
773+func (_c *MergeRequestService_EnableAutoMerge_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_EnableAutoMerge_Call {
774+ _c.Call.Return(mergeRequest, err)
775+ return _c
776+}
777+
778+func (_c *MergeRequestService_EnableAutoMerge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error)) *MergeRequestService_EnableAutoMerge_Call {
779+ _c.Call.Return(run)
780+ return _c
781+}
782+
621783 // Get provides a mock function for the type MergeRequestService
622784 func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
623785 ret := _mock.Called(ctx, p, name, number, opts)
@@ -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 {
437437 CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
438438 }
439439
440+// AutoMergeTrigger asks the auto-merge evaluator to look at a repository
441+// again. It only marks the repository and returns.
442+type AutoMergeTrigger interface {
443+ OnRepoChanged(ctx context.Context, repo domain.RepoName)
444+ OnAutoMergeEnabled(ctx context.Context, repo domain.RepoName)
445+}
446+
440447 // MergeabilityTrigger queues mergeability checks. It only queues the work and
441448 // returns.
442449 type MergeabilityTrigger interface {
@@ -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 {
125125 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)
126126 // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix.
127127 MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
128+ // EnableAutoMerge sets an open merge request to merge itself once its
129+ // checks pass; enabling again replaces the mode and the head.
130+ EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params EnableAutoMergeParams) (domain.MergeRequest, error)
131+ // CancelAutoMerge turns auto-merge off on an open merge request.
132+ CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
128133 // CountOpen is the number of open merge requests of a repository the
129134 // principal can read. It reads no Git: the tab of every page shows it.
130135 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
@@ -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+}
@@ -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+}
@@ -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
M internal/usecase/ci.go +61 -2
M internal/usecase/ci_actions.go +22 -14
A internal/usecase/ci_activity_test.go +72 -0
M internal/usecase/ci_github_test.go +21 -0
M internal/usecase/ci_githubapi.go +1 -1
M internal/usecase/ci_pull.go +6 -0
M internal/usecase/ci_trigger.go +41 -6
M internal/usecase/events.go +9 -0
M internal/usecase/export_test.go +48 -0
M internal/usecase/issue_links_test.go +27 -0
M internal/usecase/mergeability.go +18 -6
M internal/usecase/mergerequests.go +93 -18
A internal/usecase/mergerequests_auto.go +168 -0
M internal/usecase/repos.go +14 -6
M internal/usecase/repos_write.go +19 -4
M internal/usecase/repos_write_test.go +17 -0
M proto/ermite/storage/merge_request.proto +15 -0
M skills/ermite/CLI.md +4 -2
M skills/ermite/SKILL.md +2 -0

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close