detached b271fd8

feat(cli): add mr merge --auto/--cancel-auto and repo edit --allow-auto-merge

commit
b271fd8ae7d38d9d48a98b8b58ff0a9aa9cda361
author
brice · · 2026-10-20 16:17 UTC
parent
d1f49e5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +444 -29
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/automerge_e2e_test.go +88 -2
@@ -6,15 +6,18 @@ import (
66 "testing"
77 "time"
88
9+ "connectrpc.com/connect"
910 "github.com/stretchr/testify/assert"
1011 "github.com/stretchr/testify/require"
12+ "google.golang.org/protobuf/types/known/fieldmaskpb"
1113
14+ pb "bnema.dev/ermite-api/gen/ermite"
1215 "bnema.dev/ermite/internal/domain"
1316 "bnema.dev/ermite/internal/ports"
1417 )
1518
16-// The RPC enable/cancel and Watch conversions belong to Batch D. This test
17-// exercises the composition root through use cases and domain events instead.
19+// This test exercises the composition root through use cases and domain
20+// events; TestAutoMergeEndToEndAPI does the same through the RPC API.
1821 func TestAutoMergeEndToEnd(t *testing.T) {
1922 c := newCIHarness(t, nil)
2023 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -67,3 +70,86 @@ jobs:
6770 }
6871 }
6972 }
73+
74+// TestAutoMergeEndToEndAPI enables auto-merge through the API client, lets CI
75+// succeed, and reads the Watch stream: enabled, then merged with auto=true.
76+func TestAutoMergeEndToEndAPI(t *testing.T) {
77+ c := newCIHarness(t, nil)
78+ runner := c.registerRunner("alpha", "ubuntu-latest")
79+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
80+on:
81+ push:
82+ branches: [feat]
83+ pull_request:
84+jobs:
85+ build:
86+ runs-on: ubuntu-latest
87+ steps:
88+ - run: echo hello
89+`}, "")
90+ c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
91+ number := c.openMR(c.root, "feat")
92+
93+ // Off by default: the server refuses.
94+ _, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: c.repo, Number: number}))
95+ require.Error(t, err)
96+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
97+
98+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
99+ require.NoError(t, err)
100+ assert.Contains(t, ver.Msg.GetFeatures(), "merge_requests.auto_merge")
101+
102+ up, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
103+ Repo: c.repo,
104+ Settings: &pb.RepoSettings{AllowAutoMerge: true},
105+ UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allow_auto_merge"}},
106+ }))
107+ require.NoError(t, err)
108+ assert.True(t, up.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
109+
110+ w := c.watch(c.root, "", "home")
111+ res, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{
112+ Repo: c.repo, Number: number, Mode: pb.MergeMode_MERGE_MODE_SQUASH,
113+ }))
114+ require.NoError(t, err)
115+ am := res.Msg.GetMergeRequest().GetAutoMerge()
116+ require.NotNil(t, am)
117+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, am.GetMode())
118+ assert.Equal(t, res.Msg.GetMergeRequest().GetHeadId(), am.GetHeadId())
119+ assert.NotEmpty(t, am.GetEnabledBy())
120+ assert.NotNil(t, am.GetEnabledAt())
121+
122+ got, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
123+ require.NoError(t, err)
124+ assert.NotNil(t, got.Msg.GetMergeRequest().GetAutoMerge(), "Get reports the pending auto-merge")
125+
126+ c.finish(runner)
127+ c.finish(runner)
128+
129+ var sawEnabled bool
130+ deadline := time.After(15 * time.Second)
131+ for {
132+ select {
133+ case ev, ok := <-w.events:
134+ require.True(t, ok, "the stream ended: %v", w.err)
135+ if e := ev.GetMergeRequestAutoMergeEnabled(); e != nil {
136+ assert.EqualValues(t, number, e.GetMergeRequest().GetNumber())
137+ assert.NotNil(t, e.GetMergeRequest().GetAutoMerge())
138+ sawEnabled = true
139+ }
140+ if m := ev.GetMergeRequestMerged(); m != nil && m.GetMergeRequest().GetNumber() == number {
141+ assert.True(t, sawEnabled, "enabled comes before merged")
142+ assert.True(t, m.GetAuto())
143+ assert.True(t, m.GetMergeRequest().GetAutoMerged())
144+ assert.Nil(t, m.GetMergeRequest().GetAutoMerge())
145+ final, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
146+ require.NoError(t, err)
147+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, final.Msg.GetMergeRequest().GetState())
148+ assert.True(t, final.Msg.GetMergeRequest().GetAutoMerged())
149+ return
150+ }
151+ case <-deadline:
152+ t.Fatal("no automatic merged event")
153+ }
154+ }
155+}
@@ -6,15 +6,18 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9+ "connectrpc.com/connect"
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12+ "google.golang.org/protobuf/types/known/fieldmaskpb"
11 13
14+ pb "bnema.dev/ermite-api/gen/ermite"
12 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"16 "bnema.dev/ermite/internal/ports"
14 )17 )
15 18
16-// The RPC enable/cancel and Watch conversions belong to Batch D. This test19+// This test exercises the composition root through use cases and domain
17-// exercises the composition root through use cases and domain events instead.20+// events; TestAutoMergeEndToEndAPI does the same through the RPC API.
18 func TestAutoMergeEndToEnd(t *testing.T) {21 func TestAutoMergeEndToEnd(t *testing.T) {
19 c := newCIHarness(t, nil)22 c := newCIHarness(t, nil)
20 runner := c.registerRunner("alpha", "ubuntu-latest")23 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -67,3 +70,86 @@ jobs:
67 }70 }
68 }71 }
69 }72 }
73+
74+// TestAutoMergeEndToEndAPI enables auto-merge through the API client, lets CI
75+// succeed, and reads the Watch stream: enabled, then merged with auto=true.
76+func TestAutoMergeEndToEndAPI(t *testing.T) {
77+ c := newCIHarness(t, nil)
78+ runner := c.registerRunner("alpha", "ubuntu-latest")
79+ c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci
80+on:
81+ push:
82+ branches: [feat]
83+ pull_request:
84+jobs:
85+ build:
86+ runs-on: ubuntu-latest
87+ steps:
88+ - run: echo hello
89+`}, "")
90+ c.push("feat", map[string]string{"change.txt": "feature\n"}, "")
91+ number := c.openMR(c.root, "feat")
92+
93+ // Off by default: the server refuses.
94+ _, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: c.repo, Number: number}))
95+ require.Error(t, err)
96+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err))
97+
98+ ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
99+ require.NoError(t, err)
100+ assert.Contains(t, ver.Msg.GetFeatures(), "merge_requests.auto_merge")
101+
102+ up, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
103+ Repo: c.repo,
104+ Settings: &pb.RepoSettings{AllowAutoMerge: true},
105+ UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allow_auto_merge"}},
106+ }))
107+ require.NoError(t, err)
108+ assert.True(t, up.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
109+
110+ w := c.watch(c.root, "", "home")
111+ res, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{
112+ Repo: c.repo, Number: number, Mode: pb.MergeMode_MERGE_MODE_SQUASH,
113+ }))
114+ require.NoError(t, err)
115+ am := res.Msg.GetMergeRequest().GetAutoMerge()
116+ require.NotNil(t, am)
117+ assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, am.GetMode())
118+ assert.Equal(t, res.Msg.GetMergeRequest().GetHeadId(), am.GetHeadId())
119+ assert.NotEmpty(t, am.GetEnabledBy())
120+ assert.NotNil(t, am.GetEnabledAt())
121+
122+ got, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
123+ require.NoError(t, err)
124+ assert.NotNil(t, got.Msg.GetMergeRequest().GetAutoMerge(), "Get reports the pending auto-merge")
125+
126+ c.finish(runner)
127+ c.finish(runner)
128+
129+ var sawEnabled bool
130+ deadline := time.After(15 * time.Second)
131+ for {
132+ select {
133+ case ev, ok := <-w.events:
134+ require.True(t, ok, "the stream ended: %v", w.err)
135+ if e := ev.GetMergeRequestAutoMergeEnabled(); e != nil {
136+ assert.EqualValues(t, number, e.GetMergeRequest().GetNumber())
137+ assert.NotNil(t, e.GetMergeRequest().GetAutoMerge())
138+ sawEnabled = true
139+ }
140+ if m := ev.GetMergeRequestMerged(); m != nil && m.GetMergeRequest().GetNumber() == number {
141+ assert.True(t, sawEnabled, "enabled comes before merged")
142+ assert.True(t, m.GetAuto())
143+ assert.True(t, m.GetMergeRequest().GetAutoMerged())
144+ assert.Nil(t, m.GetMergeRequest().GetAutoMerge())
145+ final, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number}))
146+ require.NoError(t, err)
147+ assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, final.Msg.GetMergeRequest().GetState())
148+ assert.True(t, final.Msg.GetMergeRequest().GetAutoMerged())
149+ return
150+ }
151+ case <-deadline:
152+ t.Fatal("no automatic merged event")
153+ }
154+ }
155+}
M internal/app/cli_e2e_test.go +2 -2
@@ -203,7 +203,7 @@ type eventOut struct {
203203 MergeRequest *mrOut `json:"merge_request"`
204204 }
205205
206-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
206+var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207207
208208 // AC6: the CLI end to end against a real forge.
209209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -203,7 +203,7 @@ type eventOut struct {
203 MergeRequest *mrOut `json:"merge_request"`203 MergeRequest *mrOut `json:"merge_request"`
204 }204 }
205 205
206-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}206+var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207 207
208 // AC6: the CLI end to end against a real forge.208 // AC6: the CLI end to end against a real forge.
209 func TestAC6CLIEndToEnd(t *testing.T) {209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_fake_test.go +8 -0
@@ -62,6 +62,8 @@ type fakeAPI struct {
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 +96 -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 no --head:\n" +
1116+ "the server merges the head current when you enable 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,13 @@ 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")
1130+ cmd.MarkFlagsMutuallyExclusive("auto", "head")
10891131 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
10901132 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
10911133 })
@@ -1095,10 +1137,16 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10951137 if err != nil {
10961138 return err
10971139 }
1140+ if cancelAuto {
1141+ return runMRCancelAuto(env, cmd, args[0], n, *asJSON)
1142+ }
10981143 pbMode, err := parseMode(mode)
10991144 if err != nil {
11001145 return err
11011146 }
1147+ if auto {
1148+ return runMREnableAuto(env, cmd, args[0], n, pbMode, *asJSON)
1149+ }
11021150 head = strings.TrimSpace(head)
11031151 if head == "" {
11041152 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
@@ -1139,6 +1187,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11391187 return cmd
11401188 }
11411189
1190+// runMREnableAuto is `mr merge --auto`: it asks the server to merge once ready.
1191+func runMREnableAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, mode ermite.MergeMode, asJSON bool) error {
1192+ c, err := env.client(cmd)
1193+ if err != nil {
1194+ return err
1195+ }
1196+ ctx := env.ctx(cmd)
1197+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "cannot merge by itself (--auto)"); err != nil {
1198+ return err
1199+ }
1200+ res, err := c.MergeRequests().EnableAutoMerge(ctx, connect.NewRequest(&ermite.EnableAutoMergeRequest{Repo: repo, Number: n, Mode: mode}))
1201+ if err != nil {
1202+ return c.Describe(err)
1203+ }
1204+ m := res.Msg.GetMergeRequest()
1205+ if asJSON {
1206+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1207+ }
1208+ _, 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(),
1209+ modeName(m.GetAutoMerge().GetMode()), clean(m.GetTargetBranch()), clean(m.GetAutoMerge().GetEnabledBy()))
1210+ return err
1211+}
1212+
1213+// runMRCancelAuto is `mr merge --cancel-auto`.
1214+func runMRCancelAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, asJSON bool) error {
1215+ c, err := env.client(cmd)
1216+ if err != nil {
1217+ return err
1218+ }
1219+ ctx := env.ctx(cmd)
1220+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "has no auto-merge to cancel (--cancel-auto)"); err != nil {
1221+ return err
1222+ }
1223+ res, err := c.MergeRequests().CancelAutoMerge(ctx, connect.NewRequest(&ermite.CancelAutoMergeRequest{Repo: repo, Number: n}))
1224+ if err != nil {
1225+ return c.Describe(err)
1226+ }
1227+ m := res.Msg.GetMergeRequest()
1228+ if asJSON {
1229+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1230+ }
1231+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge cancelled on %s!%d\n", clean(m.GetRepo()), m.GetNumber())
1232+ return err
1233+}
1234+
11421235 // signedNote says that a squash or merge commit is signed by the server's
11431236 // key, with its fingerprint when the server tells it.
11441237 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 no --head:\n" +
1116+ "the server merges the head current when you enable 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,13 @@ 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")
1130+ cmd.MarkFlagsMutuallyExclusive("auto", "head")
1089 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {1131 _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
1090 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp1132 return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
1091 })1133 })
@@ -1095,10 +1137,16 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1095 if err != nil {1137 if err != nil {
1096 return err1138 return err
1097 }1139 }
1140+ if cancelAuto {
1141+ return runMRCancelAuto(env, cmd, args[0], n, *asJSON)
1142+ }
1098 pbMode, err := parseMode(mode)1143 pbMode, err := parseMode(mode)
1099 if err != nil {1144 if err != nil {
1100 return err1145 return err
1101 }1146 }
1147+ if auto {
1148+ return runMREnableAuto(env, cmd, args[0], n, pbMode, *asJSON)
1149+ }
1102 head = strings.TrimSpace(head)1150 head = strings.TrimSpace(head)
1103 if head == "" {1151 if head == "" {
1104 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+1152 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
@@ -1139,6 +1187,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1139 return cmd1187 return cmd
1140 }1188 }
1141 1189
1190+// runMREnableAuto is `mr merge --auto`: it asks the server to merge once ready.
1191+func runMREnableAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, mode ermite.MergeMode, asJSON bool) error {
1192+ c, err := env.client(cmd)
1193+ if err != nil {
1194+ return err
1195+ }
1196+ ctx := env.ctx(cmd)
1197+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "cannot merge by itself (--auto)"); err != nil {
1198+ return err
1199+ }
1200+ res, err := c.MergeRequests().EnableAutoMerge(ctx, connect.NewRequest(&ermite.EnableAutoMergeRequest{Repo: repo, Number: n, Mode: mode}))
1201+ if err != nil {
1202+ return c.Describe(err)
1203+ }
1204+ m := res.Msg.GetMergeRequest()
1205+ if asJSON {
1206+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1207+ }
1208+ _, 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(),
1209+ modeName(m.GetAutoMerge().GetMode()), clean(m.GetTargetBranch()), clean(m.GetAutoMerge().GetEnabledBy()))
1210+ return err
1211+}
1212+
1213+// runMRCancelAuto is `mr merge --cancel-auto`.
1214+func runMRCancelAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, asJSON bool) error {
1215+ c, err := env.client(cmd)
1216+ if err != nil {
1217+ return err
1218+ }
1219+ ctx := env.ctx(cmd)
1220+ if err := requireFeature(ctx, c, client.FeatureAutoMerge, "has no auto-merge to cancel (--cancel-auto)"); err != nil {
1221+ return err
1222+ }
1223+ res, err := c.MergeRequests().CancelAutoMerge(ctx, connect.NewRequest(&ermite.CancelAutoMergeRequest{Repo: repo, Number: n}))
1224+ if err != nil {
1225+ return c.Describe(err)
1226+ }
1227+ m := res.Msg.GetMergeRequest()
1228+ if asJSON {
1229+ return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL()))
1230+ }
1231+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge cancelled on %s!%d\n", clean(m.GetRepo()), m.GetNumber())
1232+ return err
1233+}
1234+
1142 // signedNote says that a squash or merge commit is signed by the server's1235 // signedNote says that a squash or merge commit is signed by the server's
1143 // key, with its fingerprint when the server tells it.1236 // key, with its fingerprint when the server tells it.
1144 func signedNote(ctx context.Context, c *client.Client) string {1237 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 +111 -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,113 @@ 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").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+
611+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--json").ok(t)
612+ var out struct {
613+ AutoMerge struct {
614+ Mode string `json:"mode"`
615+ HeadID string `json:"head_id"`
616+ EnabledBy string `json:"enabled_by"`
617+ EnabledAt string `json:"enabled_at"`
618+ } `json:"auto_merge"`
619+ }
620+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &out))
621+ assert.Equal(t, "squash", out.AutoMerge.Mode)
622+ assert.Equal(t, headA, out.AutoMerge.HeadID)
623+ assert.Equal(t, "owner", out.AutoMerge.EnabledBy)
624+ assert.Equal(t, "2026-10-03T08:00:00Z", out.AutoMerge.EnabledAt)
625+ _, reqs = f.snapshot()
626+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, reqs[len(reqs)-1].(*ermite.EnableAutoMergeRequest).GetMode(), "fast-forward by default")
627+
628+ for _, args := range [][]string{
629+ {"--auto", "--cancel-auto"}, {"--auto", "--delete-branch"}, {"--auto", "--head", headA},
630+ } {
631+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "merge", "alpha", "1"}, args...)...).err, "%v", args)
632+ }
633+}
634+
635+func TestMRMergeAutoNeedsTheFeature(t *testing.T) {
636+ f, url := autoMergeFake(t, "merge_requests")
637+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto")
638+ require.Error(t, res.err)
639+ assert.Contains(t, res.err.Error(), "merge_requests.auto_merge")
640+ calls, _ := f.snapshot()
641+ assert.NotContains(t, calls, "EnableAutoMerge")
642+}
643+
644+func TestMRMergeCancelAuto(t *testing.T) {
645+ f, url := autoMergeFake(t, "merge_requests.auto_merge")
646+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto").ok(t)
647+ assert.Equal(t, "auto-merge cancelled on alpha!1\n", res.stdout)
648+ calls, reqs := f.snapshot()
649+ assert.Equal(t, []string{"GetVersion", "CancelAutoMerge"}, calls)
650+ assert.EqualValues(t, 1, reqs[1].(*ermite.CancelAutoMergeRequest).GetNumber())
651+
652+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", "--json").ok(t)
653+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
654+
655+ for _, extra := range []string{"--delete-branch", "--head=" + headA} {
656+ require.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", extra).err, extra)
657+ }
658+}
659+
660+func TestMRShowAutoMerge(t *testing.T) {
661+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
662+ m.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")}
663+ cur := m
664+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
665+ return &ermite.GetMergeRequestResponse{MergeRequest: cur}, nil
666+ }}
667+ url := f.serve(t)
668+
669+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
670+ assert.Regexp(t, `(?m)^auto-merge\s+squash when ready \(enabled by owner\)$`, res.stdout)
671+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
672+ assert.Contains(t, res.stdout, `"mode": "squash"`)
673+ assert.Contains(t, res.stdout, `"enabled_by": "owner"`)
674+ assert.Contains(t, res.stdout, `"auto_merged": false`)
675+
676+ merged := proto.Clone(mrOne).(*ermite.MergeRequest)
677+ merged.State, merged.AutoMerged = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, true
678+ merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH
679+ cur = merged
680+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
681+ assert.Regexp(t, `(?m)^auto-merged\s+yes`, res.stdout)
682+ assert.NotContains(t, res.stdout, "when ready")
683+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
684+ assert.Contains(t, res.stdout, `"auto_merged": true`)
685+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
686+
687+ cur = mrOne
688+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
689+ assert.NotContains(t, res.stdout, "auto-merge")
690+}
@@ -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,113 @@ 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").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+
611+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--json").ok(t)
612+ var out struct {
613+ AutoMerge struct {
614+ Mode string `json:"mode"`
615+ HeadID string `json:"head_id"`
616+ EnabledBy string `json:"enabled_by"`
617+ EnabledAt string `json:"enabled_at"`
618+ } `json:"auto_merge"`
619+ }
620+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &out))
621+ assert.Equal(t, "squash", out.AutoMerge.Mode)
622+ assert.Equal(t, headA, out.AutoMerge.HeadID)
623+ assert.Equal(t, "owner", out.AutoMerge.EnabledBy)
624+ assert.Equal(t, "2026-10-03T08:00:00Z", out.AutoMerge.EnabledAt)
625+ _, reqs = f.snapshot()
626+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, reqs[len(reqs)-1].(*ermite.EnableAutoMergeRequest).GetMode(), "fast-forward by default")
627+
628+ for _, args := range [][]string{
629+ {"--auto", "--cancel-auto"}, {"--auto", "--delete-branch"}, {"--auto", "--head", headA},
630+ } {
631+ require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "merge", "alpha", "1"}, args...)...).err, "%v", args)
632+ }
633+}
634+
635+func TestMRMergeAutoNeedsTheFeature(t *testing.T) {
636+ f, url := autoMergeFake(t, "merge_requests")
637+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto")
638+ require.Error(t, res.err)
639+ assert.Contains(t, res.err.Error(), "merge_requests.auto_merge")
640+ calls, _ := f.snapshot()
641+ assert.NotContains(t, calls, "EnableAutoMerge")
642+}
643+
644+func TestMRMergeCancelAuto(t *testing.T) {
645+ f, url := autoMergeFake(t, "merge_requests.auto_merge")
646+ res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto").ok(t)
647+ assert.Equal(t, "auto-merge cancelled on alpha!1\n", res.stdout)
648+ calls, reqs := f.snapshot()
649+ assert.Equal(t, []string{"GetVersion", "CancelAutoMerge"}, calls)
650+ assert.EqualValues(t, 1, reqs[1].(*ermite.CancelAutoMergeRequest).GetNumber())
651+
652+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", "--json").ok(t)
653+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
654+
655+ for _, extra := range []string{"--delete-branch", "--head=" + headA} {
656+ require.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", extra).err, extra)
657+ }
658+}
659+
660+func TestMRShowAutoMerge(t *testing.T) {
661+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
662+ m.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")}
663+ cur := m
664+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
665+ return &ermite.GetMergeRequestResponse{MergeRequest: cur}, nil
666+ }}
667+ url := f.serve(t)
668+
669+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
670+ assert.Regexp(t, `(?m)^auto-merge\s+squash when ready \(enabled by owner\)$`, res.stdout)
671+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
672+ assert.Contains(t, res.stdout, `"mode": "squash"`)
673+ assert.Contains(t, res.stdout, `"enabled_by": "owner"`)
674+ assert.Contains(t, res.stdout, `"auto_merged": false`)
675+
676+ merged := proto.Clone(mrOne).(*ermite.MergeRequest)
677+ merged.State, merged.AutoMerged = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, true
678+ merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH
679+ cur = merged
680+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
681+ assert.Regexp(t, `(?m)^auto-merged\s+yes`, res.stdout)
682+ assert.NotContains(t, res.stdout, "when ready")
683+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
684+ assert.Contains(t, res.stdout, `"auto_merged": true`)
685+ assert.Regexp(t, `"auto_merge":\s*null`, res.stdout)
686+
687+ cur = mrOne
688+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
689+ assert.NotContains(t, res.stdout, "auto-merge")
690+}
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close