detached 5d0c190

feat(cli): add mr ready and mr merge --ignore-checks, implement the MarkReady RPC

commit
5d0c190ce487932200818fcdf6324a7b82200fd4
author
brice · · 2026-10-21 13:23 UTC
parent
cfc292c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +124 -9
M internal/adapters/client/features.go +2 -0
@@ -8,6 +8,8 @@ import "slices"
88 // ServerService.GetVersion. A server without one still works for everything
99 // else: the CLI warns instead of failing.
1010 const (
11+ // FeatureMarkReady permits marking draft merge requests ready.
12+ FeatureMarkReady = "merge_requests.ready"
1113 // FeatureIgnoreChecks permits merging with checks bypassed.
1214 FeatureIgnoreChecks = "merge_requests.ignore_checks"
1315 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
@@ -8,6 +8,8 @@ import "slices"
8 // ServerService.GetVersion. A server without one still works for everything8 // ServerService.GetVersion. A server without one still works for everything
9 // else: the CLI warns instead of failing.9 // else: the CLI warns instead of failing.
10 const (10 const (
11+ // FeatureMarkReady permits marking draft merge requests ready.
12+ FeatureMarkReady = "merge_requests.ready"
11 // FeatureIgnoreChecks permits merging with checks bypassed.13 // FeatureIgnoreChecks permits merging with checks bypassed.
12 FeatureIgnoreChecks = "merge_requests.ignore_checks"14 FeatureIgnoreChecks = "merge_requests.ignore_checks"
13 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).15 // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`).
M internal/adapters/rpc/api.go +1 -1
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
M internal/adapters/rpc/mergerequest.go +10 -3
@@ -424,7 +424,14 @@ func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request
424424 return connect.NewResponse(&pb.MarkReadResponse{}), nil
425425 }
426426
427-// MarkReady is implemented in the next server slice.
428-func (s *mergeRequestService) MarkReady(context.Context, *connect.Request[pb.MarkReadyRequest]) (*connect.Response[pb.MarkReadyResponse], error) {
429- return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("MarkReady is not implemented"))
427+func (s *mergeRequestService) MarkReady(ctx context.Context, req *connect.Request[pb.MarkReadyRequest]) (*connect.Response[pb.MarkReadyResponse], error) {
428+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
429+ if err != nil {
430+ return nil, connectError(ctx, err)
431+ }
432+ mr, err := s.mrs.MarkReady(ctx, caller(ctx), name, req.Msg.GetNumber())
433+ if err != nil {
434+ return nil, connectError(ctx, err)
435+ }
436+ return connect.NewResponse(&pb.MarkReadyResponse{MergeRequest: mergeRequestToPB(mr)}), nil
430437 }
@@ -424,7 +424,14 @@ func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request
424 return connect.NewResponse(&pb.MarkReadResponse{}), nil424 return connect.NewResponse(&pb.MarkReadResponse{}), nil
425 }425 }
426 426
427-// MarkReady is implemented in the next server slice.427+func (s *mergeRequestService) MarkReady(ctx context.Context, req *connect.Request[pb.MarkReadyRequest]) (*connect.Response[pb.MarkReadyResponse], error) {
428-func (s *mergeRequestService) MarkReady(context.Context, *connect.Request[pb.MarkReadyRequest]) (*connect.Response[pb.MarkReadyResponse], error) {428+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
429- return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("MarkReady is not implemented"))429+ if err != nil {
430+ return nil, connectError(ctx, err)
431+ }
432+ mr, err := s.mrs.MarkReady(ctx, caller(ctx), name, req.Msg.GetNumber())
433+ if err != nil {
434+ return nil, connectError(ctx, err)
435+ }
436+ return connect.NewResponse(&pb.MarkReadyResponse{MergeRequest: mergeRequestToPB(mr)}), nil
430 }437 }
M internal/adapters/rpc/mergerequest_test.go +28 -0
@@ -227,3 +227,31 @@ func TestMergeRequestServiceGet(t *testing.T) {
227227 })
228228 }
229229 }
230+
231+func TestMergeRequestServiceMarkReady(t *testing.T) {
232+ t.Parallel()
233+ for _, tt := range []struct {
234+ name string
235+ err error
236+ code connect.Code
237+ }{
238+ {name: "success"},
239+ {name: "not a draft", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
240+ {name: "closed", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
241+ {name: "without mr:write", err: domain.ErrForbidden, code: connect.CodePermissionDenied},
242+ } {
243+ t.Run(tt.name, func(t *testing.T) {
244+ mrs := mocks.NewMergeRequestService(t)
245+ title, err := domain.ParseMRTitle("Ready")
246+ require.NoError(t, err)
247+ mrs.EXPECT().MarkReady(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{Number: 1, Title: title, State: domain.MRStateOpen}, tt.err).Once()
248+ res, err := (&mergeRequestService{mrs: mrs}).MarkReady(ownerCtx(), connect.NewRequest(&pb.MarkReadyRequest{Repo: "demo", Number: 1}))
249+ if tt.code != 0 {
250+ requireCode(t, tt.code, err)
251+ return
252+ }
253+ require.NoError(t, err)
254+ assert.Equal(t, "Ready", res.Msg.GetMergeRequest().GetTitle())
255+ })
256+ }
257+}
@@ -227,3 +227,31 @@ func TestMergeRequestServiceGet(t *testing.T) {
227 })227 })
228 }228 }
229 }229 }
230+
231+func TestMergeRequestServiceMarkReady(t *testing.T) {
232+ t.Parallel()
233+ for _, tt := range []struct {
234+ name string
235+ err error
236+ code connect.Code
237+ }{
238+ {name: "success"},
239+ {name: "not a draft", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
240+ {name: "closed", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
241+ {name: "without mr:write", err: domain.ErrForbidden, code: connect.CodePermissionDenied},
242+ } {
243+ t.Run(tt.name, func(t *testing.T) {
244+ mrs := mocks.NewMergeRequestService(t)
245+ title, err := domain.ParseMRTitle("Ready")
246+ require.NoError(t, err)
247+ mrs.EXPECT().MarkReady(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{Number: 1, Title: title, State: domain.MRStateOpen}, tt.err).Once()
248+ res, err := (&mergeRequestService{mrs: mrs}).MarkReady(ownerCtx(), connect.NewRequest(&pb.MarkReadyRequest{Repo: "demo", Number: 1}))
249+ if tt.code != 0 {
250+ requireCode(t, tt.code, err)
251+ return
252+ }
253+ require.NoError(t, err)
254+ assert.Equal(t, "Ready", res.Msg.GetMergeRequest().GetTitle())
255+ })
256+ }
257+}
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, 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", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, 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/cli_e2e_test.go +1 -1
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, 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)
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge", "merge_requests.ignore_checks", "merge_requests.ready"}, 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_mr.go +45 -3
@@ -291,7 +291,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
291291 }
292292 mr.AddCommand(
293293 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
294- newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),
294+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), newMRReadyCmd(env),
295295 )
296296 return mr
297297 }
@@ -1085,7 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
10851085 var mode string
10861086 var deleteBranch bool
10871087 var head string
1088- var auto, cancelAuto bool
1088+ var auto, cancelAuto, ignoreChecks bool
10891089 cmd := &cobra.Command{
10901090 Use: "merge <repo> <number>",
10911091 Short: "Merge a merge request",
@@ -1105,6 +1105,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11051105 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
11061106 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
11071107 "mr:merge scope.\n\n" +
1108+ "A merge needs the checks on the head to pass: none failed, none queued or running. A head\n" +
1109+ "without checks can be merged. --ignore-checks merges anyway and needs the mr:force-merge scope\n" +
1110+ "(the cli preset has it, the agent preset does not).\n\n" +
11081111 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
11091112 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
11101113 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
@@ -1123,6 +1126,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11231126 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
11241127 cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)")
11251128 cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")
1129+ cmd.Flags().BoolVar(&ignoreChecks, "ignore-checks", false, "merge despite failing or unfinished checks (needs mr:force-merge)")
1130+ cmd.MarkFlagsMutuallyExclusive("ignore-checks", "auto")
1131+ cmd.MarkFlagsMutuallyExclusive("ignore-checks", "cancel-auto")
11261132 cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")
11271133 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")
11281134 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")
@@ -1156,13 +1162,18 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
11561162 return err
11571163 }
11581164 ctx := env.ctx(cmd)
1165+ if ignoreChecks {
1166+ if err := requireFeature(ctx, c, client.FeatureIgnoreChecks, "cannot bypass checks (--ignore-checks)"); err != nil {
1167+ return err
1168+ }
1169+ }
11591170 if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
11601171 if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
11611172 return err
11621173 }
11631174 }
11641175 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1165- Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch,
1176+ Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch, IgnoreChecks: ignoreChecks,
11661177 }))
11671178 if err != nil {
11681179 return c.Describe(err)
@@ -1282,3 +1293,34 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
12821293 }
12831294 return cmd
12841295 }
1296+
1297+func newMRReadyCmd(env *cliEnv) *cobra.Command {
1298+ cmd := &cobra.Command{
1299+ Use: "ready <repo> <number>",
1300+ Short: "Mark a draft merge request ready for review",
1301+ Long: "Remove the WIP: or [WIP] prefix from the title so the merge request can be merged.\nFails if it is not open or not a draft. Needs the mr:write scope.\nPrints a confirmation, then the full web URL on its own line." + jsonMRHelp,
1302+ Args: cobra.ExactArgs(2),
1303+ ValidArgsFunction: completeMRRepoThenNumber(env),
1304+ }
1305+ asJSON := addJSONFlag(cmd)
1306+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
1307+ n, err := parseNumber(args[1])
1308+ if err != nil {
1309+ return err
1310+ }
1311+ c, err := env.client(cmd)
1312+ if err != nil {
1313+ return err
1314+ }
1315+ ctx := env.ctx(cmd)
1316+ if err := requireFeature(ctx, c, client.FeatureMarkReady, "cannot mark drafts ready (mr ready)"); err != nil {
1317+ return err
1318+ }
1319+ res, err := c.MergeRequests().MarkReady(ctx, connect.NewRequest(&ermite.MarkReadyRequest{Repo: args[0], Number: n}))
1320+ if err != nil {
1321+ return c.Describe(err)
1322+ }
1323+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "marked ready", c.URL())
1324+ }
1325+ return cmd
1326+}
@@ -291,7 +291,7 @@ func newMRCmd(env *cliEnv) *cobra.Command {
291 }291 }
292 mr.AddCommand(292 mr.AddCommand(
293 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),293 newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env),
294- newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env),294+ newMRResolveCmd(env), newMREditCommentCmd(env), newMRDeleteCommentCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), newMRReadyCmd(env),
295 )295 )
296 return mr296 return mr
297 }297 }
@@ -1085,7 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1085 var mode string1085 var mode string
1086 var deleteBranch bool1086 var deleteBranch bool
1087 var head string1087 var head string
1088- var auto, cancelAuto bool1088+ var auto, cancelAuto, ignoreChecks bool
1089 cmd := &cobra.Command{1089 cmd := &cobra.Command{
1090 Use: "merge <repo> <number>",1090 Use: "merge <repo> <number>",
1091 Short: "Merge a merge request",1091 Short: "Merge a merge request",
@@ -1105,6 +1105,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1105 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +1105 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
1106 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +1106 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
1107 "mr:merge scope.\n\n" +1107 "mr:merge scope.\n\n" +
1108+ "A merge needs the checks on the head to pass: none failed, none queued or running. A head\n" +
1109+ "without checks can be merged. --ignore-checks merges anyway and needs the mr:force-merge scope\n" +
1110+ "(the cli preset has it, the agent preset does not).\n\n" +
1108 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +1111 "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
1109 "the source branch right after, in the background. The merge is refused when the branch is the\n" +1112 "the source branch right after, in the background. The merge is refused when the branch is the\n" +
1110 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +1113 "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
@@ -1123,6 +1126,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1123 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")1126 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`)")1127 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")1128 cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge")
1129+ cmd.Flags().BoolVar(&ignoreChecks, "ignore-checks", false, "merge despite failing or unfinished checks (needs mr:force-merge)")
1130+ cmd.MarkFlagsMutuallyExclusive("ignore-checks", "auto")
1131+ cmd.MarkFlagsMutuallyExclusive("ignore-checks", "cancel-auto")
1126 cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")1132 cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto")
1127 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")1133 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch")
1128 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")1134 cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head")
@@ -1156,13 +1162,18 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
1156 return err1162 return err
1157 }1163 }
1158 ctx := env.ctx(cmd)1164 ctx := env.ctx(cmd)
1165+ if ignoreChecks {
1166+ if err := requireFeature(ctx, c, client.FeatureIgnoreChecks, "cannot bypass checks (--ignore-checks)"); err != nil {
1167+ return err
1168+ }
1169+ }
1159 if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {1170 if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
1160 if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {1171 if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
1161 return err1172 return err
1162 }1173 }
1163 }1174 }
1164 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{1175 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1165- Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch,1176+ Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch, IgnoreChecks: ignoreChecks,
1166 }))1177 }))
1167 if err != nil {1178 if err != nil {
1168 return c.Describe(err)1179 return c.Describe(err)
@@ -1282,3 +1293,34 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
1282 }1293 }
1283 return cmd1294 return cmd
1284 }1295 }
1296+
1297+func newMRReadyCmd(env *cliEnv) *cobra.Command {
1298+ cmd := &cobra.Command{
1299+ Use: "ready <repo> <number>",
1300+ Short: "Mark a draft merge request ready for review",
1301+ Long: "Remove the WIP: or [WIP] prefix from the title so the merge request can be merged.\nFails if it is not open or not a draft. Needs the mr:write scope.\nPrints a confirmation, then the full web URL on its own line." + jsonMRHelp,
1302+ Args: cobra.ExactArgs(2),
1303+ ValidArgsFunction: completeMRRepoThenNumber(env),
1304+ }
1305+ asJSON := addJSONFlag(cmd)
1306+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
1307+ n, err := parseNumber(args[1])
1308+ if err != nil {
1309+ return err
1310+ }
1311+ c, err := env.client(cmd)
1312+ if err != nil {
1313+ return err
1314+ }
1315+ ctx := env.ctx(cmd)
1316+ if err := requireFeature(ctx, c, client.FeatureMarkReady, "cannot mark drafts ready (mr ready)"); err != nil {
1317+ return err
1318+ }
1319+ res, err := c.MergeRequests().MarkReady(ctx, connect.NewRequest(&ermite.MarkReadyRequest{Repo: args[0], Number: n}))
1320+ if err != nil {
1321+ return c.Describe(err)
1322+ }
1323+ return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "marked ready", c.URL())
1324+ }
1325+ return cmd
1326+}
M internal/app/cli_mr_e2e_test.go +36 -0
@@ -485,3 +485,39 @@ func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
485485 require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
486486 require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
487487 }
488+
489+func TestMRReadyAndIgnoreChecksCLIEndToEnd(t *testing.T) {
490+ c := newCLIHarness(t)
491+ secret := strings.TrimSpace(c.cli(t, adminToken, "token", "create", "daily", "--preset", "cli").ok(t).stdout)
492+ agent := strings.TrimSpace(c.cli(t, adminToken, "token", "create", "agent", "--preset", "agent", "--scope", "mr:merge").ok(t).stdout)
493+ c.cli(t, secret, "repo", "create", "proj", "--visibility", "public").ok(t)
494+ wt := c.workTree("main", "README.md")
495+ url := c.urlAuth(secret, "/proj.git")
496+ c.mustGit(wt, "push", "-q", url, "main")
497+ c.mustGit(wt, "checkout", "-q", "-b", "feat/ready")
498+ c.writeFile(wt, "README.md", []byte("ready\n"))
499+ c.mustGit(wt, "push", "-q", url, "feat/ready")
500+ head := c.bareGit("proj", "rev-parse", "feat/ready")
501+ c.cli(t, agent, "mr", "create", "proj", "--source", "feat/ready", "--title", "WIP: Add feature").ok(t)
502+ ready := c.cli(t, agent, "mr", "ready", "proj", "1", "--json").ok(t)
503+ assert.Equal(t, mrKeys, keys(t, ready.stdout))
504+ assert.Equal(t, "Add feature", decode[mrOut](t, ready.stdout).Title)
505+ res := c.cli(t, agent, "mr", "ready", "proj", "1")
506+ require.Error(t, res.err)
507+ assert.Contains(t, res.err.Error(), "not a draft")
508+ res = c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks", "--auto")
509+ require.Error(t, res.err)
510+ assert.Contains(t, res.err.Error(), "ignore-checks")
511+ res = c.cli(t, agent, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks")
512+ require.Error(t, res.err)
513+ assert.Contains(t, res.err.Error(), "mr:force-merge")
514+ c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks").ok(t)
515+ c.mustGit(wt, "checkout", "-q", "-b", "feat/second")
516+ c.writeFile(wt, "README.md", []byte("second\n"))
517+ c.mustGit(wt, "push", "-q", url, "feat/second")
518+ head = c.bareGit("proj", "rev-parse", "feat/second")
519+ c.cli(t, agent, "mr", "create", "proj", "--source", "feat/second", "--title", "[WIP] Second").ok(t)
520+ res = c.cli(t, agent, "mr", "ready", "proj", "2").ok(t)
521+ assert.Contains(t, res.stdout, "marked ready proj!2 (feat/second into main)\n"+c.url+"/proj/merge-requests/2\n")
522+ c.cli(t, secret, "mr", "merge", "proj", "2", "--head", head).ok(t)
523+}
@@ -485,3 +485,39 @@ func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
485 require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)485 require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
486 require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)486 require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
487 }487 }
488+
489+func TestMRReadyAndIgnoreChecksCLIEndToEnd(t *testing.T) {
490+ c := newCLIHarness(t)
491+ secret := strings.TrimSpace(c.cli(t, adminToken, "token", "create", "daily", "--preset", "cli").ok(t).stdout)
492+ agent := strings.TrimSpace(c.cli(t, adminToken, "token", "create", "agent", "--preset", "agent", "--scope", "mr:merge").ok(t).stdout)
493+ c.cli(t, secret, "repo", "create", "proj", "--visibility", "public").ok(t)
494+ wt := c.workTree("main", "README.md")
495+ url := c.urlAuth(secret, "/proj.git")
496+ c.mustGit(wt, "push", "-q", url, "main")
497+ c.mustGit(wt, "checkout", "-q", "-b", "feat/ready")
498+ c.writeFile(wt, "README.md", []byte("ready\n"))
499+ c.mustGit(wt, "push", "-q", url, "feat/ready")
500+ head := c.bareGit("proj", "rev-parse", "feat/ready")
501+ c.cli(t, agent, "mr", "create", "proj", "--source", "feat/ready", "--title", "WIP: Add feature").ok(t)
502+ ready := c.cli(t, agent, "mr", "ready", "proj", "1", "--json").ok(t)
503+ assert.Equal(t, mrKeys, keys(t, ready.stdout))
504+ assert.Equal(t, "Add feature", decode[mrOut](t, ready.stdout).Title)
505+ res := c.cli(t, agent, "mr", "ready", "proj", "1")
506+ require.Error(t, res.err)
507+ assert.Contains(t, res.err.Error(), "not a draft")
508+ res = c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks", "--auto")
509+ require.Error(t, res.err)
510+ assert.Contains(t, res.err.Error(), "ignore-checks")
511+ res = c.cli(t, agent, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks")
512+ require.Error(t, res.err)
513+ assert.Contains(t, res.err.Error(), "mr:force-merge")
514+ c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head, "--ignore-checks").ok(t)
515+ c.mustGit(wt, "checkout", "-q", "-b", "feat/second")
516+ c.writeFile(wt, "README.md", []byte("second\n"))
517+ c.mustGit(wt, "push", "-q", url, "feat/second")
518+ head = c.bareGit("proj", "rev-parse", "feat/second")
519+ c.cli(t, agent, "mr", "create", "proj", "--source", "feat/second", "--title", "[WIP] Second").ok(t)
520+ res = c.cli(t, agent, "mr", "ready", "proj", "2").ok(t)
521+ assert.Contains(t, res.stdout, "marked ready proj!2 (feat/second into main)\n"+c.url+"/proj/merge-requests/2\n")
522+ c.cli(t, secret, "mr", "merge", "proj", "2", "--head", head).ok(t)
523+}

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