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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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" | |||
| 8 | 8 | // ServerService.GetVersion. A server without one still works for everything | |
| 9 | 9 | // else: the CLI warns instead of failing. | |
| 10 | 10 | const ( | |
| 11 | + | // FeatureMarkReady permits marking draft merge requests ready. | |
| 12 | + | FeatureMarkReady = "merge_requests.ready" | |
| 11 | 13 | // FeatureIgnoreChecks permits merging with checks bypassed. | |
| 12 | 14 | FeatureIgnoreChecks = "merge_requests.ignore_checks" | |
| 13 | 15 | // FeatureRepoUpdate is RepoService.UpdateRepo (`ermite repo edit`). | |
| @@ -8,6 +8,8 @@ import "slices" | |||||
| 8 | // ServerService.GetVersion. A server without one still works for everything | 8 | // 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 | |||
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | 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 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| 41 | 41 | // 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 top | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | ||
| 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | 36 | // 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 | |||
| 424 | 424 | return connect.NewResponse(&pb.MarkReadResponse{}), nil | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 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 | |
| 430 | 437 | } | |
| @@ -424,7 +424,14 @@ func (s *mergeRequestService) MarkRead(ctx context.Context, req *connect.Request | |||||
| 424 | return connect.NewResponse(&pb.MarkReadResponse{}), nil | 424 | 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) { | |||
| 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 | + | } | |
| @@ -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) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 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 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -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) { | |||
| 350 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 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 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 356 | 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 { | |||
| 291 | 291 | } | |
| 292 | 292 | mr.AddCommand( | |
| 293 | 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 | 296 | return mr | |
| 297 | 297 | } | |
| @@ -1085,7 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1085 | 1085 | var mode string | |
| 1086 | 1086 | var deleteBranch bool | |
| 1087 | 1087 | var head string | |
| 1088 | - | var auto, cancelAuto bool | |
| 1088 | + | var auto, cancelAuto, ignoreChecks bool | |
| 1089 | 1089 | cmd := &cobra.Command{ | |
| 1090 | 1090 | Use: "merge <repo> <number>", | |
| 1091 | 1091 | Short: "Merge a merge request", | |
| @@ -1105,6 +1105,9 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1105 | 1105 | "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" + | |
| 1106 | 1106 | "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" + | |
| 1107 | 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 | 1111 | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + | |
| 1109 | 1112 | "the source branch right after, in the background. The merge is refused when the branch is the\n" + | |
| 1110 | 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 | 1126 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | |
| 1124 | 1127 | cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)") | |
| 1125 | 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 | 1132 | cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto") | |
| 1127 | 1133 | cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch") | |
| 1128 | 1134 | cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head") | |
| @@ -1156,13 +1162,18 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1156 | 1162 | return err | |
| 1157 | 1163 | } | |
| 1158 | 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 | 1170 | if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT { | |
| 1160 | 1171 | if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil { | |
| 1161 | 1172 | return err | |
| 1162 | 1173 | } | |
| 1163 | 1174 | } | |
| 1164 | 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 | 1178 | if err != nil { | |
| 1168 | 1179 | return c.Describe(err) | |
| @@ -1282,3 +1293,34 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 1282 | 1293 | } | |
| 1283 | 1294 | 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 | + | } | |
| @@ -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 mr | 296 | return mr | ||
| 297 | } | 297 | } | ||
| @@ -1085,7 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1085 | var mode string | 1085 | var mode string | ||
| 1086 | var deleteBranch bool | 1086 | var deleteBranch bool | ||
| 1087 | var head string | 1087 | var head string | ||
| 1088 | - | var auto, cancelAuto bool | 1088 | + | 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 err | 1162 | 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 err | 1172 | 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 cmd | 1294 | 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) { | |||
| 485 | 485 | require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err) | |
| 486 | 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 | + | } | |
| @@ -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 | + | } | |||