detached f61e6a6

#23 feat(merge): mr ready, passing checks to merge, mr:force-merge and --ignore-checks

state
merged · f61e6a6 · fast-forward
branch
feat/mr-ready-merge-checks → main
opened
bnema ·
updated

Closes #22.

  • ermite mr ready <repo> <n> (MarkReady RPC on the existing use case): removes the WIP:/[WIP] prefix; prints the URL, --json.
  • Every merge needs passing checks on the head: none failed, queued or running; a head with no checks can merge. Auto-merge is unchanged.
  • New scope mr:force-merge: ermite mr merge --ignore-checks (not with --auto/--cancel-auto), and an "Ignore checks" box on the owner's merge form when checks block (refreshed live, owner streams only). cli preset has it, agent does not.
  • Storage schema 5 gives mr:force-merge to unrestricted full tokens so they keep the owner rights (login, session revocation).
  • failing_checks_block removed (setting, repo edit flag, storage field reserved).
  • Docs and skill updated.

Depends on ermite-api!10. Before merge: tag ermite-api v0.15.0 and replace the local replace in go.mod with it.

CommitSubjectAuthorDateSignatureStatus
f61e6a6fix(store): keep failing_checks_block as a deprecated storage field, run the new subtests in parallelbriceGPG Verified
923f868build(deps): use ermite-api v0.15.0briceGPG Verified
eba8ed7fix(scopes): keep the owner rights of full tokens with mr:force-merge, refresh the owner's merge check controls livebriceGPG Verified
7348bb2feat(web): offer ignore checks on the merge form, document mr ready and the merge check rulesbriceGPG Verified
5d0c190feat(cli): add mr ready and mr merge --ignore-checks, implement the MarkReady RPCbriceGPG Verified
cfc292cfeat(merge): require passing checks to merge, add mr:force-merge and ignore_checks, drop failing_checks_blockbriceGPG Verified

Checks 8

56 files · +645 -265
8/8
M docs/ci.md +1 -1
M docs/cli.md +4 -2
M docs/merge-requests.md +5 -3
M gen/ermite/storage/merge_request.pb.go +9 -5
M go.mod +1 -1
M go.sum +2 -2
M internal/adapters/client/features.go +4 -0
M internal/adapters/client/scopes.go +3 -0
M internal/adapters/client/scopes_test.go +1 -0
M internal/adapters/kvstore/issues_test.go +53 -6
M internal/adapters/kvstore/mergerequests.go +2 -2
M internal/adapters/kvstore/mergerequests_test.go +2 -2
M internal/adapters/kvstore/store.go +36 -8
M internal/adapters/rpc/api.go +1 -1
M internal/adapters/rpc/convert.go +2 -1
M internal/adapters/rpc/convert_test.go +9 -9
M internal/adapters/rpc/mergerequest.go +13 -1
M internal/adapters/rpc/mergerequest_test.go +31 -1
M internal/adapters/rpc/repo.go +2 -5
M internal/adapters/rpc/repo_test.go +1 -7
M internal/adapters/web/checks.go +1 -3
M internal/adapters/web/fragments.go +18 -1
M internal/adapters/web/mr.go +1 -1
M internal/adapters/web/mr.templ +13 -1
M internal/adapters/web/mr_post.go +1 -0
M internal/adapters/web/mr_templ.go +111 -53 generated file: collapsed
M internal/adapters/web/mr_test.go +35 -1
@@ -161,7 +161,7 @@ func TestMRPanelsAndMenus(t *testing.T) {
161161 assert.NotContains(t, rail, text)
162162 }
163163 more := renderToString(t, moreMenu(p))
164- for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
164+ for _, text := range []string{"Merge settings", "squash, merge", "failing or unfinished checks block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
165165 assert.Contains(t, more, text)
166166 }
167167 merge := renderToString(t, mergeMenu(p))
@@ -297,3 +297,37 @@ func TestAutoMergeLiveEvents(t *testing.T) {
297297 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
298298 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
299299 }
300+
301+func TestMergeIgnoreChecks(t *testing.T) {
302+ t.Parallel()
303+ for _, kind := range []string{"bad", "running", "ok", ""} {
304+ t.Run(kind, func(t *testing.T) {
305+ t.Parallel()
306+ box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
307+ html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}}))
308+ if kind == "bad" || kind == "running" {
309+ assert.Contains(t, html, `name="ignore_checks"`)
310+ assert.Contains(t, html, "Ignore checks")
311+ } else {
312+ assert.NotContains(t, html, `name="ignore_checks"`)
313+ }
314+ assert.True(t, box.CanMerge())
315+ })
316+ }
317+ name, err := domain.ParseRepoName("demo")
318+ require.NoError(t, err)
319+ head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
320+ require.NoError(t, err)
321+ for _, ignore := range []bool{false, true} {
322+ svc := mocks.NewMergeRequestService(t)
323+ svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once()
324+ body := "mode=squash&head_id=" + head.String()
325+ if ignore {
326+ body += "&ignore_checks=on"
327+ }
328+ s := &Server{cfg: Config{MergeRequests: svc}}
329+ to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body))
330+ require.NoError(t, err)
331+ assert.Equal(t, mrHref("demo", 3), to)
332+ }
333+}
@@ -161,7 +161,7 @@ func TestMRPanelsAndMenus(t *testing.T) {
161 assert.NotContains(t, rail, text)161 assert.NotContains(t, rail, text)
162 }162 }
163 more := renderToString(t, moreMenu(p))163 more := renderToString(t, moreMenu(p))
164- for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {164+ for _, text := range []string{"Merge settings", "squash, merge", "failing or unfinished checks block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
165 assert.Contains(t, more, text)165 assert.Contains(t, more, text)
166 }166 }
167 merge := renderToString(t, mergeMenu(p))167 merge := renderToString(t, mergeMenu(p))
@@ -297,3 +297,37 @@ func TestAutoMergeLiveEvents(t *testing.T) {
297 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))297 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
298 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))298 assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
299 }299 }
300+
301+func TestMergeIgnoreChecks(t *testing.T) {
302+ t.Parallel()
303+ for _, kind := range []string{"bad", "running", "ok", ""} {
304+ t.Run(kind, func(t *testing.T) {
305+ t.Parallel()
306+ box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
307+ html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}}))
308+ if kind == "bad" || kind == "running" {
309+ assert.Contains(t, html, `name="ignore_checks"`)
310+ assert.Contains(t, html, "Ignore checks")
311+ } else {
312+ assert.NotContains(t, html, `name="ignore_checks"`)
313+ }
314+ assert.True(t, box.CanMerge())
315+ })
316+ }
317+ name, err := domain.ParseRepoName("demo")
318+ require.NoError(t, err)
319+ head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
320+ require.NoError(t, err)
321+ for _, ignore := range []bool{false, true} {
322+ svc := mocks.NewMergeRequestService(t)
323+ svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once()
324+ body := "mode=squash&head_id=" + head.String()
325+ if ignore {
326+ body += "&ignore_checks=on"
327+ }
328+ s := &Server{cfg: Config{MergeRequests: svc}}
329+ to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body))
330+ require.NoError(t, err)
331+ assert.Equal(t, mrHref("demo", 3), to)
332+ }
333+}
M internal/adapters/web/static/js/ermite.js +7 -0
@@ -966,6 +966,13 @@ window.ermiteLive = (() => {
966966 continue;
967967 }
968968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
969+ // data-keep-checks: the checkboxes the user ticked stay ticked.
970+ if (old.hasAttribute('data-keep-checks')) {
971+ for (const box of old.querySelectorAll('input[type=checkbox][name]:checked')) {
972+ const same = el.querySelector(`input[type=checkbox][name="${CSS.escape(box.name)}"]`);
973+ if (same) same.checked = true;
974+ }
975+ }
969976 old.replaceWith(el);
970977 const rows = document.getElementById('repo-rows');
971978 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
@@ -966,6 +966,13 @@ window.ermiteLive = (() => {
966 continue;966 continue;
967 }967 }
968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');968 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
969+ // data-keep-checks: the checkboxes the user ticked stay ticked.
970+ if (old.hasAttribute('data-keep-checks')) {
971+ for (const box of old.querySelectorAll('input[type=checkbox][name]:checked')) {
972+ const same = el.querySelector(`input[type=checkbox][name="${CSS.escape(box.name)}"]`);
973+ if (same) same.checked = true;
974+ }
975+ }
969 old.replaceWith(el);976 old.replaceWith(el);
970 const rows = document.getElementById('repo-rows');977 const rows = document.getElementById('repo-rows');
971 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&978 if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
M internal/adapters/web/summary_actions_test.go +53 -0
@@ -4,6 +4,7 @@ package web
44
55 import (
66 "bytes"
7+ "context"
78 "errors"
89 "strings"
910 "testing"
@@ -125,3 +126,55 @@ func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
125126 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126127 assert.NotContains(t, got[0].html, "rr-name")
127128 }
129+
130+func TestMRChecksLiveMergeControls(t *testing.T) {
131+ t.Parallel()
132+ repo := repoName(t, "proj")
133+ head := strings.Repeat("a", domain.ObjectIDLen)
134+ for _, kind := range []string{"bad", "running", "ok", ""} {
135+ t.Run(kind, func(t *testing.T) {
136+ t.Parallel()
137+ runs := mocks.NewRunService(t)
138+ var checks []ports.CheckView
139+ if kind != "" {
140+ check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess}
141+ if kind == "bad" {
142+ check.Conclusion = domain.CIFailure
143+ }
144+ if kind == "running" {
145+ check.Status, check.Conclusion = domain.CIQueued, ""
146+ }
147+ checks = []ports.CheckView{{CICheck: check}}
148+ }
149+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil)
150+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs})
151+
152+ // A visitor's stream never carries the owner's merge form.
153+ visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context())
154+ require.NoError(t, err)
155+ require.Len(t, visitor, 2)
156+ for _, f := range visitor {
157+ assert.NotContains(t, f.html, `mr-merge-checks`)
158+ }
159+
160+ owner := context.WithValue(t.Context(), signedInKey{}, struct{}{})
161+ ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"}
162+ got, err := ls.mrChecks(owner)
163+ require.NoError(t, err)
164+ require.Len(t, got, 3)
165+ assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box")
166+ assert.Equal(t, "ci-run", got[2].event)
167+ assert.Contains(t, got[2].html, `id="mr-merge-checks"`)
168+ assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html)
169+ if kind == "bad" || kind == "running" {
170+ assert.Contains(t, got[2].html, `name="ignore_checks"`)
171+ } else {
172+ assert.NotContains(t, got[2].html, `name="ignore_checks"`)
173+ }
174+ // An unrelated job event with identical checks must not reset the checkbox.
175+ got, err = ls.mrChecks(owner)
176+ require.NoError(t, err)
177+ require.Len(t, got, 2)
178+ })
179+ }
180+}
@@ -4,6 +4,7 @@ package web
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "errors"8 "errors"
8 "strings"9 "strings"
9 "testing"10 "testing"
@@ -125,3 +126,55 @@ func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
125 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")126 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126 assert.NotContains(t, got[0].html, "rr-name")127 assert.NotContains(t, got[0].html, "rr-name")
127 }128 }
129+
130+func TestMRChecksLiveMergeControls(t *testing.T) {
131+ t.Parallel()
132+ repo := repoName(t, "proj")
133+ head := strings.Repeat("a", domain.ObjectIDLen)
134+ for _, kind := range []string{"bad", "running", "ok", ""} {
135+ t.Run(kind, func(t *testing.T) {
136+ t.Parallel()
137+ runs := mocks.NewRunService(t)
138+ var checks []ports.CheckView
139+ if kind != "" {
140+ check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess}
141+ if kind == "bad" {
142+ check.Conclusion = domain.CIFailure
143+ }
144+ if kind == "running" {
145+ check.Status, check.Conclusion = domain.CIQueued, ""
146+ }
147+ checks = []ports.CheckView{{CICheck: check}}
148+ }
149+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil)
150+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs})
151+
152+ // A visitor's stream never carries the owner's merge form.
153+ visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context())
154+ require.NoError(t, err)
155+ require.Len(t, visitor, 2)
156+ for _, f := range visitor {
157+ assert.NotContains(t, f.html, `mr-merge-checks`)
158+ }
159+
160+ owner := context.WithValue(t.Context(), signedInKey{}, struct{}{})
161+ ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"}
162+ got, err := ls.mrChecks(owner)
163+ require.NoError(t, err)
164+ require.Len(t, got, 3)
165+ assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box")
166+ assert.Equal(t, "ci-run", got[2].event)
167+ assert.Contains(t, got[2].html, `id="mr-merge-checks"`)
168+ assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html)
169+ if kind == "bad" || kind == "running" {
170+ assert.Contains(t, got[2].html, `name="ignore_checks"`)
171+ } else {
172+ assert.NotContains(t, got[2].html, `name="ignore_checks"`)
173+ }
174+ // An unrelated job event with identical checks must not reset the checkbox.
175+ got, err = ls.mrChecks(owner)
176+ require.NoError(t, err)
177+ require.Len(t, got, 2)
178+ })
179+ }
180+}
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"}, 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"}, 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/ci_e2e_test.go +3 -9
@@ -13,7 +13,6 @@ import (
1313 "connectrpc.com/connect"
1414 "github.com/stretchr/testify/assert"
1515 "github.com/stretchr/testify/require"
16- "google.golang.org/protobuf/types/known/fieldmaskpb"
1716 "google.golang.org/protobuf/types/known/timestamppb"
1817
1918 pb "bnema.dev/ermite-api/gen/ermite"
@@ -271,7 +270,7 @@ func TestCIPushRunsAWorkflowOnARunner(t *testing.T) {
271270 }
272271
273272 // AC5: merge refused while checks are pending or failing, allowed when they
274-// pass, and when failing_checks_block is off.
273+// pass, or when explicitly bypassed by an authorized caller.
275274 func TestCIMergeIsBlockedByChecks(t *testing.T) {
276275 c := newCIHarness(t, nil)
277276 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -302,14 +301,9 @@ func TestCIMergeIsBlockedByChecks(t *testing.T) {
302301 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
303302 assert.Contains(t, err.Error(), "failing checks block the merge")
304303
305- // Off: the merge goes through.
306- _, err = c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
307- Repo: "proj",
308- Settings: &pb.RepoSettings{FailingChecksBlock: false},
309- UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.failing_checks_block"}},
310- }))
304+ // An explicit override by root allows the merge despite failure.
305+ _, err = c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: mr.Msg.GetMergeRequest().GetNumber(), HeadId: feat, IgnoreChecks: true}))
311306 require.NoError(t, err)
312- require.NoError(t, merge())
313307 }
314308
315309 // AC2: a job token works for its job only, and not once the job has ended.
@@ -13,7 +13,6 @@ import (
13 "connectrpc.com/connect"13 "connectrpc.com/connect"
14 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16- "google.golang.org/protobuf/types/known/fieldmaskpb"
17 "google.golang.org/protobuf/types/known/timestamppb"16 "google.golang.org/protobuf/types/known/timestamppb"
18 17
19 pb "bnema.dev/ermite-api/gen/ermite"18 pb "bnema.dev/ermite-api/gen/ermite"
@@ -271,7 +270,7 @@ func TestCIPushRunsAWorkflowOnARunner(t *testing.T) {
271 }270 }
272 271
273 // AC5: merge refused while checks are pending or failing, allowed when they272 // AC5: merge refused while checks are pending or failing, allowed when they
274-// pass, and when failing_checks_block is off.273+// pass, or when explicitly bypassed by an authorized caller.
275 func TestCIMergeIsBlockedByChecks(t *testing.T) {274 func TestCIMergeIsBlockedByChecks(t *testing.T) {
276 c := newCIHarness(t, nil)275 c := newCIHarness(t, nil)
277 runner := c.registerRunner("alpha", "ubuntu-latest")276 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -302,14 +301,9 @@ func TestCIMergeIsBlockedByChecks(t *testing.T) {
302 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))301 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
303 assert.Contains(t, err.Error(), "failing checks block the merge")302 assert.Contains(t, err.Error(), "failing checks block the merge")
304 303
305- // Off: the merge goes through.304+ // An explicit override by root allows the merge despite failure.
306- _, err = c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{305+ _, err = c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: mr.Msg.GetMergeRequest().GetNumber(), HeadId: feat, IgnoreChecks: true}))
307- Repo: "proj",
308- Settings: &pb.RepoSettings{FailingChecksBlock: false},
309- UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.failing_checks_block"}},
310- }))
311 require.NoError(t, err)306 require.NoError(t, err)
312- require.NoError(t, merge())
313 }307 }
314 308
315 // AC2: a job token works for its job only, and not once the job has ended.309 // AC2: a job token works for its job only, and not once the job has ended.
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{"allow_auto_merge", "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", "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", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features)
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)
@@ -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{"allow_auto_merge", "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", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"}
207 207
208 // AC6: the CLI end to end against a real forge.208 // AC6: the CLI end to end against a real forge.
209 func TestAC6CLIEndToEnd(t *testing.T) {209 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "issues.links", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, 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_issue_test.go +1 -1
@@ -202,7 +202,7 @@ func TestRepoShowIssues(t *testing.T) {
202202 }}
203203 url := f.serve(t)
204204 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
205- assert.Contains(t, res.stdout, "issues owner\n")
205+ assert.Contains(t, res.stdout, "issues owner\n")
206206 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
207207 assert.Contains(t, res.stdout, `"issues": "owner"`)
208208 }
@@ -202,7 +202,7 @@ func TestRepoShowIssues(t *testing.T) {
202 }}202 }}
203 url := f.serve(t)203 url := f.serve(t)
204 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)204 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
205- assert.Contains(t, res.stdout, "issues owner\n")205+ assert.Contains(t, res.stdout, "issues owner\n")
206 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)206 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
207 assert.Contains(t, res.stdout, `"issues": "owner"`)207 assert.Contains(t, res.stdout, `"issues": "owner"`)
208 }208 }
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 +38 -14
@@ -382,29 +382,17 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
382382 c.seed("proj", domain.VisibilityPublic)
383383 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
384384 repo := decode[struct {
385- MergeModes []string `json:"merge_modes"`
386- FailingChecksBlock bool `json:"failing_checks_block"`
385+ MergeModes []string `json:"merge_modes"`
387386 }](t, res.stdout)
388387 assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
389- assert.True(t, repo.FailingChecksBlock)
390388
391389 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
392390 repo = decode[struct {
393- MergeModes []string `json:"merge_modes"`
394- FailingChecksBlock bool `json:"failing_checks_block"`
391+ MergeModes []string `json:"merge_modes"`
395392 }](t, res.stdout)
396393 assert.Equal(t, []string{"squash"}, repo.MergeModes)
397- assert.True(t, repo.FailingChecksBlock, "untouched")
398- res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t)
399- repo = decode[struct {
400- MergeModes []string `json:"merge_modes"`
401- FailingChecksBlock bool `json:"failing_checks_block"`
402- }](t, res.stdout)
403- assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched")
404- assert.False(t, repo.FailingChecksBlock)
405394 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
406395 assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)
407- assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout)
408396
409397 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)
410398 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)
@@ -497,3 +485,39 @@ func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
497485 require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
498486 require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
499487 }
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+}
@@ -382,29 +382,17 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
382 c.seed("proj", domain.VisibilityPublic)382 c.seed("proj", domain.VisibilityPublic)
383 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)383 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
384 repo := decode[struct {384 repo := decode[struct {
385- MergeModes []string `json:"merge_modes"`385+ MergeModes []string `json:"merge_modes"`
386- FailingChecksBlock bool `json:"failing_checks_block"`
387 }](t, res.stdout)386 }](t, res.stdout)
388 assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)387 assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
389- assert.True(t, repo.FailingChecksBlock)
390 388
391 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)389 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
392 repo = decode[struct {390 repo = decode[struct {
393- MergeModes []string `json:"merge_modes"`391+ MergeModes []string `json:"merge_modes"`
394- FailingChecksBlock bool `json:"failing_checks_block"`
395 }](t, res.stdout)392 }](t, res.stdout)
396 assert.Equal(t, []string{"squash"}, repo.MergeModes)393 assert.Equal(t, []string{"squash"}, repo.MergeModes)
397- assert.True(t, repo.FailingChecksBlock, "untouched")
398- res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t)
399- repo = decode[struct {
400- MergeModes []string `json:"merge_modes"`
401- FailingChecksBlock bool `json:"failing_checks_block"`
402- }](t, res.stdout)
403- assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched")
404- assert.False(t, repo.FailingChecksBlock)
405 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)394 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
406 assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)395 assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)
407- assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout)
408 396
409 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)397 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)
410 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)398 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)
@@ -497,3 +485,39 @@ func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
497 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)
498 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)
499 }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+}
M internal/app/cli_repo.go +17 -29
@@ -34,9 +34,8 @@ type jsonRepo struct {
3434 CreatedAt *string `json:"created_at"`
3535 PushedAt *string `json:"pushed_at"`
3636 // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
37- MergeModes []string `json:"merge_modes"`
38- FailingChecksBlock bool `json:"failing_checks_block"`
39- AllowAutoMerge bool `json:"allow_auto_merge"`
37+ MergeModes []string `json:"merge_modes"`
38+ AllowAutoMerge bool `json:"allow_auto_merge"`
4039 // Issues is the issue mode ("off", "owner" or "public").
4140 Issues string `json:"issues"`
4241 }
@@ -47,17 +46,16 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4746 modes = append(modes, modeName(m))
4847 }
4948 return jsonRepo{
50- MergeModes: modes,
51- FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
52- AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
53- Issues: issuesModeName(r.GetSettings().GetIssues()),
54- Name: r.GetName(),
55- Description: r.GetDescription(),
56- Topics: append([]string{}, r.GetTopics()...),
57- Visibility: visibilityName(r.GetVisibility()),
58- DefaultBranch: r.GetDefaultBranch(),
59- CreatedAt: jsonTime(r.GetCreatedAt()),
60- PushedAt: jsonTime(r.GetPushedAt()),
49+ MergeModes: modes,
50+ AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
51+ Issues: issuesModeName(r.GetSettings().GetIssues()),
52+ Name: r.GetName(),
53+ Description: r.GetDescription(),
54+ Topics: append([]string{}, r.GetTopics()...),
55+ Visibility: visibilityName(r.GetVisibility()),
56+ DefaultBranch: r.GetDefaultBranch(),
57+ CreatedAt: jsonTime(r.GetCreatedAt()),
58+ PushedAt: jsonTime(r.GetPushedAt()),
6159 }
6260 }
6361
@@ -117,7 +115,7 @@ const jsonRepoHelp = `
117115 JSON fields of a repository (--json): name, description, visibility ("public" or
118116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
119117 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
120-"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether
118+"merge"), allow_auto_merge (whether
121119 auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
122120
123121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -230,7 +228,6 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
230228 {"created", shortTime(r.GetCreatedAt(), "-")},
231229 {"last push", shortTime(r.GetPushedAt(), "never")},
232230 {"merge modes", orDash(strings.Join(modes, ", "))},
233- {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234231 {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
235232 {"issues", issuesModeName(r.GetSettings().GetIssues())},
236233 })
@@ -311,7 +308,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin
311308 func newRepoEditCmd(env *cliEnv) *cobra.Command {
312309 var description, visibility, defaultBranch string
313310 var topics, mergeModes []string
314- var failingChecksBlock, allowAutoMerge bool
311+ var allowAutoMerge bool
315312 var issues string
316313 cmd := &cobra.Command{
317314 Use: "edit <name>",
@@ -321,7 +318,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
321318 "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
322319 "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
323320 "commits are signed with the server's key, see `ermite signing-key show`), and\n" +
324- "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
325321 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
326322 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
327323 "Needs the repo:admin scope." + jsonRepoHelp,
@@ -333,7 +329,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
333329 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
334330 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
335331 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
336- cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
337332 cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")
338333 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
339334 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -345,7 +340,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
345340 })
346341 asJSON := addJSONFlag(cmd)
347342 cmd.RunE = func(cmd *cobra.Command, args []string) error {
348- req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues)
343+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, allowAutoMerge, issues)
349344 if err != nil {
350345 return err
351346 }
@@ -369,7 +364,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
369364
370365 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
371366 // the user passed go into the update mask.
372-func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
367+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
373368 req := &ermite.UpdateRepoRequest{Repo: name}
374369 var paths []string
375370 if cmd.Flags().Changed("description") {
@@ -411,13 +406,6 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
411406 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
412407 paths = append(paths, "settings.allowed_merge_modes")
413408 }
414- if cmd.Flags().Changed("failing-checks-block") {
415- if req.Settings == nil {
416- req.Settings = &ermite.RepoSettings{}
417- }
418- req.Settings.FailingChecksBlock = failingChecksBlock
419- paths = append(paths, "settings.failing_checks_block")
420- }
421409 if cmd.Flags().Changed("allow-auto-merge") {
422410 if req.Settings == nil {
423411 req.Settings = &ermite.RepoSettings{}
@@ -434,7 +422,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
434422 req.Issues = &mode
435423 }
436424 if len(paths) == 0 && req.Issues == nil {
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")
425+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --allow-auto-merge, --issues")
438426 }
439427 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
440428 return req, nil
@@ -34,9 +34,8 @@ type jsonRepo struct {
34 CreatedAt *string `json:"created_at"`34 CreatedAt *string `json:"created_at"`
35 PushedAt *string `json:"pushed_at"`35 PushedAt *string `json:"pushed_at"`
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+ AllowAutoMerge bool `json:"allow_auto_merge"`
39- AllowAutoMerge bool `json:"allow_auto_merge"`
40 // Issues is the issue mode ("off", "owner" or "public").39 // Issues is the issue mode ("off", "owner" or "public").
41 Issues string `json:"issues"`40 Issues string `json:"issues"`
42 }41 }
@@ -47,17 +46,16 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
47 modes = append(modes, modeName(m))46 modes = append(modes, modeName(m))
48 }47 }
49 return jsonRepo{48 return jsonRepo{
50- MergeModes: modes,49+ MergeModes: modes,
51- FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),50+ AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(),
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(),54+ Topics: append([]string{}, r.GetTopics()...),
56- Topics: append([]string{}, r.GetTopics()...),55+ Visibility: visibilityName(r.GetVisibility()),
57- Visibility: visibilityName(r.GetVisibility()),56+ DefaultBranch: r.GetDefaultBranch(),
58- DefaultBranch: r.GetDefaultBranch(),57+ CreatedAt: jsonTime(r.GetCreatedAt()),
59- CreatedAt: jsonTime(r.GetCreatedAt()),58+ PushedAt: jsonTime(r.GetPushedAt()),
60- PushedAt: jsonTime(r.GetPushedAt()),
61 }59 }
62 }60 }
63 61
@@ -117,7 +115,7 @@ const jsonRepoHelp = `
117 JSON fields of a repository (--json): name, description, visibility ("public" or115 JSON fields of a repository (--json): name, description, visibility ("public" or
118 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
119 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or117 null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
120-"merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether118+"merge"), allow_auto_merge (whether
121 auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`119 auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").`
122 120
123 func newRepoCmd(env *cliEnv) *cobra.Command {121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -230,7 +228,6 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
230 {"created", shortTime(r.GetCreatedAt(), "-")},228 {"created", shortTime(r.GetCreatedAt(), "-")},
231 {"last push", shortTime(r.GetPushedAt(), "never")},229 {"last push", shortTime(r.GetPushedAt(), "never")},
232 {"merge modes", orDash(strings.Join(modes, ", "))},230 {"merge modes", orDash(strings.Join(modes, ", "))},
233- {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
234 {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},231 {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())},
235 {"issues", issuesModeName(r.GetSettings().GetIssues())},232 {"issues", issuesModeName(r.GetSettings().GetIssues())},
236 })233 })
@@ -311,7 +308,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin
311 func newRepoEditCmd(env *cliEnv) *cobra.Command {308 func newRepoEditCmd(env *cliEnv) *cobra.Command {
312 var description, visibility, defaultBranch string309 var description, visibility, defaultBranch string
313 var topics, mergeModes []string310 var topics, mergeModes []string
314- var failingChecksBlock, allowAutoMerge bool311+ var allowAutoMerge bool
315 var issues string312 var issues string
316 cmd := &cobra.Command{313 cmd := &cobra.Command{
317 Use: "edit <name>",314 Use: "edit <name>",
@@ -321,7 +318,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
321 "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +318 "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
322 "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +319 "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
323 "commits are signed with the server's key, see `ermite signing-key show`), and\n" +320 "commits are signed with the server's key, see `ermite signing-key show`), and\n" +
324- "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
325 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +321 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
326 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +322 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
327 "Needs the repo:admin scope." + jsonRepoHelp,323 "Needs the repo:admin scope." + jsonRepoHelp,
@@ -333,7 +329,6 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
333 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")329 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
334 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")330 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
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)")331 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
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)")332 cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)")
338 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")333 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
339 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {334 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -345,7 +340,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
345 })340 })
346 asJSON := addJSONFlag(cmd)341 asJSON := addJSONFlag(cmd)
347 cmd.RunE = func(cmd *cobra.Command, args []string) error {342 cmd.RunE = func(cmd *cobra.Command, args []string) error {
348- req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues)343+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, allowAutoMerge, issues)
349 if err != nil {344 if err != nil {
350 return err345 return err
351 }346 }
@@ -369,7 +364,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
369 364
370 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags365 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
371 // the user passed go into the update mask.366 // the user passed go into the update mask.
372-func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {367+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) {
373 req := &ermite.UpdateRepoRequest{Repo: name}368 req := &ermite.UpdateRepoRequest{Repo: name}
374 var paths []string369 var paths []string
375 if cmd.Flags().Changed("description") {370 if cmd.Flags().Changed("description") {
@@ -411,13 +406,6 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
411 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}406 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
412 paths = append(paths, "settings.allowed_merge_modes")407 paths = append(paths, "settings.allowed_merge_modes")
413 }408 }
414- if cmd.Flags().Changed("failing-checks-block") {
415- if req.Settings == nil {
416- req.Settings = &ermite.RepoSettings{}
417- }
418- req.Settings.FailingChecksBlock = failingChecksBlock
419- paths = append(paths, "settings.failing_checks_block")
420- }
421 if cmd.Flags().Changed("allow-auto-merge") {409 if cmd.Flags().Changed("allow-auto-merge") {
422 if req.Settings == nil {410 if req.Settings == nil {
423 req.Settings = &ermite.RepoSettings{}411 req.Settings = &ermite.RepoSettings{}
@@ -434,7 +422,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
434 req.Issues = &mode422 req.Issues = &mode
435 }423 }
436 if len(paths) == 0 && req.Issues == nil {424 if len(paths) == 0 && req.Issues == nil {
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")425+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --allow-auto-merge, --issues")
438 }426 }
439 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}427 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
440 return req, nil428 return req, nil
M internal/app/cli_repo_test.go +17 -27
@@ -19,8 +19,7 @@ var (
1919 Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
2020 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
2121 Settings: &ermite.RepoSettings{
22- AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
23- FailingChecksBlock: true,
22+ AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
2423 },
2524 }
2625 repoBeta = &ermite.Repo{
@@ -54,8 +53,8 @@ func TestRepoList(t *testing.T) {
5453 {
5554 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5655 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,"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"}
56+ {"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"],"allow_auto_merge":false,"issues":"off"},
57+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}
5958 ]`,
6059 },
6160 {
@@ -107,22 +106,21 @@ func TestRepoShow(t *testing.T) {
107106 url := f.serve(t)
108107 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
109108 assert.Equal(t, ""+
110- "name alpha\n"+
111- "visibility public\n"+
112- "default branch main\n"+
113- "description The first one\n"+
114- "topics cli, go\n"+
115- "created 2026-01-02 03:04\n"+
116- "last push 2026-03-04 05:06\n"+
117- "merge modes fast-forward, squash\n"+
118- "failing checks block true\n"+
119- "allow auto-merge false\n"+
120- "issues off\n", res.stdout)
109+ "name alpha\n"+
110+ "visibility public\n"+
111+ "default branch main\n"+
112+ "description The first one\n"+
113+ "topics cli, go\n"+
114+ "created 2026-01-02 03:04\n"+
115+ "last push 2026-03-04 05:06\n"+
116+ "merge modes fast-forward, squash\n"+
117+ "allow auto-merge false\n"+
118+ "issues off\n", res.stdout)
121119 _, reqs := f.snapshot()
122120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
123121
124122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
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)
123+ 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"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
126124 }
127125
128126 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -178,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) {
178176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
179177 }}
180178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
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)
179+ 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"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
182180 }
183181
184182 func TestRepoEditMask(t *testing.T) {
@@ -209,16 +207,8 @@ func TestRepoEditMask(t *testing.T) {
209207 {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
210208 assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes())
211209 }},
212- {"failing checks do not block", []string{"--failing-checks-block=false"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
213- assert.False(t, r.GetSettings().GetFailingChecksBlock())
214- assert.Empty(t, r.GetSettings().GetAllowedMergeModes())
215- }},
216- {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
217- assert.True(t, r.GetSettings().GetFailingChecksBlock())
218- }},
219210 {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
220211 assert.True(t, r.GetSettings().GetAllowAutoMerge())
221- assert.False(t, r.GetSettings().GetFailingChecksBlock())
222212 }},
223213 {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
224214 assert.False(t, r.GetSettings().GetAllowAutoMerge())
@@ -460,9 +450,9 @@ func TestRepoEditAllowAutoMerge(t *testing.T) {
460450 assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
461451 assert.True(t, got.GetSettings().GetAllowAutoMerge())
462452
463- res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t)
453+ res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--json").ok(t)
464454 assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
465455 _, reqs = f.snapshot()
466456 got = reqs[1].(*ermite.UpdateRepoRequest)
467- assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths())
457+ assert.ElementsMatch(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
468458 }
@@ -19,8 +19,7 @@ var (
19 Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,19 Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
21 Settings: &ermite.RepoSettings{21 Settings: &ermite.RepoSettings{
22- AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},22+ AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
23- FailingChecksBlock: true,
24 },23 },
25 }24 }
26 repoBeta = &ermite.Repo{25 repoBeta = &ermite.Repo{
@@ -54,8 +53,8 @@ func TestRepoList(t *testing.T) {
54 {53 {
55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,54 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56 wantStdout: `[55 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,"allow_auto_merge":false,"issues":"off"},56+ {"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"],"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"}57+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"allow_auto_merge":false,"issues":"off"}
59 ]`,58 ]`,
60 },59 },
61 {60 {
@@ -107,22 +106,21 @@ func TestRepoShow(t *testing.T) {
107 url := f.serve(t)106 url := f.serve(t)
108 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)107 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
109 assert.Equal(t, ""+108 assert.Equal(t, ""+
110- "name alpha\n"+109+ "name alpha\n"+
111- "visibility public\n"+110+ "visibility public\n"+
112- "default branch main\n"+111+ "default branch main\n"+
113- "description The first one\n"+112+ "description The first one\n"+
114- "topics cli, go\n"+113+ "topics cli, go\n"+
115- "created 2026-01-02 03:04\n"+114+ "created 2026-01-02 03:04\n"+
116- "last push 2026-03-04 05:06\n"+115+ "last push 2026-03-04 05:06\n"+
117- "merge modes fast-forward, squash\n"+116+ "merge modes fast-forward, squash\n"+
118- "failing checks block true\n"+117+ "allow auto-merge false\n"+
119- "allow auto-merge false\n"+118+ "issues off\n", res.stdout)
120- "issues off\n", res.stdout)
121 _, reqs := f.snapshot()119 _, reqs := f.snapshot()
122 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
123 121
124 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
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)123+ 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"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
126 }124 }
127 125
128 func TestRepoShowNeedsOneArg(t *testing.T) {126 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -178,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) {
178 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
179 }}177 }}
180 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
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)179+ 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"],"allow_auto_merge":false,"issues":"off"}`, res.stdout)
182 }180 }
183 181
184 func TestRepoEditMask(t *testing.T) {182 func TestRepoEditMask(t *testing.T) {
@@ -209,16 +207,8 @@ func TestRepoEditMask(t *testing.T) {
209 {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {207 {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
210 assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes())208 assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes())
211 }},209 }},
212- {"failing checks do not block", []string{"--failing-checks-block=false"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
213- assert.False(t, r.GetSettings().GetFailingChecksBlock())
214- assert.Empty(t, r.GetSettings().GetAllowedMergeModes())
215- }},
216- {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
217- assert.True(t, r.GetSettings().GetFailingChecksBlock())
218- }},
219 {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {210 {"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())211 assert.True(t, r.GetSettings().GetAllowAutoMerge())
221- assert.False(t, r.GetSettings().GetFailingChecksBlock())
222 }},212 }},
223 {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {213 {"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())214 assert.False(t, r.GetSettings().GetAllowAutoMerge())
@@ -460,9 +450,9 @@ func TestRepoEditAllowAutoMerge(t *testing.T) {
460 assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())450 assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
461 assert.True(t, got.GetSettings().GetAllowAutoMerge())451 assert.True(t, got.GetSettings().GetAllowAutoMerge())
462 452
463- res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t)453+ res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--json").ok(t)
464 assert.Contains(t, res.stdout, `"allow_auto_merge": true`)454 assert.Contains(t, res.stdout, `"allow_auto_merge": true`)
465 _, reqs = f.snapshot()455 _, reqs = f.snapshot()
466 got = reqs[1].(*ermite.UpdateRepoRequest)456 got = reqs[1].(*ermite.UpdateRepoRequest)
467- assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths())457+ assert.ElementsMatch(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths())
468 }458 }
M internal/app/cli_watch_test.go +1 -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,"allow_auto_merge":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"],"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"},
@@ -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,"allow_auto_merge":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"],"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"},
M internal/app/issue_api_test.go +0 -1
@@ -221,7 +221,6 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
221221 updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222222 require.NoError(t, err)
223223 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224- assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225224 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226225 require.NoError(t, err)
227226
@@ -221,7 +221,6 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
221 updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)221 updated, err := update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
222 require.NoError(t, err)222 require.NoError(t, err)
223 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())223 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OWNER, updated.GetSettings().GetIssues())
224- assert.True(t, updated.GetSettings().GetFailingChecksBlock(), "the other settings are kept")
225 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))224 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226 require.NoError(t, err)225 require.NoError(t, err)
227 226
M internal/app/mr_api_test.go +9 -9
@@ -299,7 +299,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
299299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300300 require.NoError(t, err)
301301 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302- assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
302+ assert.False(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
303303
304304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
305305 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
@@ -311,23 +311,23 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
311311 return res.Msg.GetRepo(), nil
312312 }
313313 repo, err := update(root, []string{"settings.allowed_merge_modes"}, &pb.RepoSettings{
314- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: false,
314+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, AllowAutoMerge: false,
315315 })
316316 require.NoError(t, err)
317317 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())
318- assert.True(t, repo.GetSettings().GetFailingChecksBlock(), "not named in the mask: unchanged")
318+ assert.False(t, repo.GetSettings().GetAllowAutoMerge(), "not named in the mask: unchanged")
319319
320- repo, err = update(root, []string{"settings.failing_checks_block"}, &pb.RepoSettings{FailingChecksBlock: false})
320+ repo, err = update(root, []string{"settings.allow_auto_merge"}, &pb.RepoSettings{AllowAutoMerge: false})
321321 require.NoError(t, err)
322- assert.False(t, repo.GetSettings().GetFailingChecksBlock())
322+ assert.False(t, repo.GetSettings().GetAllowAutoMerge())
323323 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged")
324324
325325 repo, err = update(root, []string{"settings"}, &pb.RepoSettings{
326- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: true,
326+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, AllowAutoMerge: true,
327327 })
328328 require.NoError(t, err)
329329 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "canonical order, no duplicates")
330- assert.True(t, repo.GetSettings().GetFailingChecksBlock())
330+ assert.True(t, repo.GetSettings().GetAllowAutoMerge())
331331
332332 for name, s := range map[string]*pb.RepoSettings{
333333 "no modes": {},
@@ -341,11 +341,11 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
341341 assert.Equal(t, "invalid_argument", codeName(err))
342342
343343 // repo:admin is needed.
344- _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.failing_checks_block"}, &pb.RepoSettings{})
344+ _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.allow_auto_merge"}, &pb.RepoSettings{})
345345 assert.Equal(t, "permission_denied", codeName(err))
346346 got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
347347 require.NoError(t, err)
348- assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
348+ assert.True(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
349349 }
350350
351351 // AC4 over the API: a mode the settings do not allow is refused.
@@ -299,7 +299,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300 require.NoError(t, err)300 require.NoError(t, err)
301 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())301 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302- assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())302+ assert.False(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
303 303
304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
305 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{305 res, err := c.repo.UpdateRepo(ctx, connect.NewRequest(&pb.UpdateRepoRequest{
@@ -311,23 +311,23 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
311 return res.Msg.GetRepo(), nil311 return res.Msg.GetRepo(), nil
312 }312 }
313 repo, err := update(root, []string{"settings.allowed_merge_modes"}, &pb.RepoSettings{313 repo, err := update(root, []string{"settings.allowed_merge_modes"}, &pb.RepoSettings{
314- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: false,314+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, AllowAutoMerge: false,
315 })315 })
316 require.NoError(t, err)316 require.NoError(t, err)
317 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())317 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes())
318- assert.True(t, repo.GetSettings().GetFailingChecksBlock(), "not named in the mask: unchanged")318+ assert.False(t, repo.GetSettings().GetAllowAutoMerge(), "not named in the mask: unchanged")
319 319
320- repo, err = update(root, []string{"settings.failing_checks_block"}, &pb.RepoSettings{FailingChecksBlock: false})320+ repo, err = update(root, []string{"settings.allow_auto_merge"}, &pb.RepoSettings{AllowAutoMerge: false})
321 require.NoError(t, err)321 require.NoError(t, err)
322- assert.False(t, repo.GetSettings().GetFailingChecksBlock())322+ assert.False(t, repo.GetSettings().GetAllowAutoMerge())
323 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged")323 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "not named in the mask: unchanged")
324 324
325 repo, err = update(root, []string{"settings"}, &pb.RepoSettings{325 repo, err = update(root, []string{"settings"}, &pb.RepoSettings{
326- AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, FailingChecksBlock: true,326+ AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, AllowAutoMerge: true,
327 })327 })
328 require.NoError(t, err)328 require.NoError(t, err)
329 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "canonical order, no duplicates")329 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, repo.GetSettings().GetAllowedMergeModes(), "canonical order, no duplicates")
330- assert.True(t, repo.GetSettings().GetFailingChecksBlock())330+ assert.True(t, repo.GetSettings().GetAllowAutoMerge())
331 331
332 for name, s := range map[string]*pb.RepoSettings{332 for name, s := range map[string]*pb.RepoSettings{
333 "no modes": {},333 "no modes": {},
@@ -341,11 +341,11 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
341 assert.Equal(t, "invalid_argument", codeName(err))341 assert.Equal(t, "invalid_argument", codeName(err))
342 342
343 // repo:admin is needed.343 // repo:admin is needed.
344- _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.failing_checks_block"}, &pb.RepoSettings{})344+ _, err = update(h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge))), []string{"settings.allow_auto_merge"}, &pb.RepoSettings{})
345 assert.Equal(t, "permission_denied", codeName(err))345 assert.Equal(t, "permission_denied", codeName(err))
346 got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))346 got, err = root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
347 require.NoError(t, err)347 require.NoError(t, err)
348- assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())348+ assert.True(t, got.Msg.GetRepo().GetSettings().GetAllowAutoMerge())
349 }349 }
350 350
351 // AC4 over the API: a mode the settings do not allow is refused.351 // AC4 over the API: a mode the settings do not allow is refused.
M internal/domain/ci.go +1 -1
@@ -42,7 +42,7 @@ const (
4242 )
4343
4444 // PassesMerge reports whether a check that ended this way lets a merge go
45-// through under FailingChecksBlock: success, skipped and neutral do.
45+// through under the merge checks policy: success, skipped and neutral do.
4646 func (c CIConclusion) PassesMerge() bool {
4747 return c == CISuccess || c == CISkipped || c == CINeutral
4848 }
@@ -42,7 +42,7 @@ const (
42 )42 )
43 43
44 // PassesMerge reports whether a check that ended this way lets a merge go44 // PassesMerge reports whether a check that ended this way lets a merge go
45-// through under FailingChecksBlock: success, skipped and neutral do.45+// through under the merge checks policy: success, skipped and neutral do.
46 func (c CIConclusion) PassesMerge() bool {46 func (c CIConclusion) PassesMerge() bool {
47 return c == CISuccess || c == CISkipped || c == CINeutral47 return c == CISuccess || c == CISkipped || c == CINeutral
48 }48 }
M internal/domain/mergerequest.go +3 -5
@@ -46,8 +46,6 @@ type RepoSettings struct {
4646 // AllowedMergeModes is never empty, in canonical order, without
4747 // duplicates.
4848 AllowedMergeModes []MergeMode
49- // FailingChecksBlock says that a failing check prevents the merge.
50- FailingChecksBlock bool
5149 // Issues says who may write issues; the empty mode means IssuesOff.
5250 Issues IssuesMode
5351 // AllowAutoMerge says that merge requests may be set to merge themselves
@@ -55,10 +53,10 @@ type RepoSettings struct {
5553 AllowAutoMerge bool
5654 }
5755
58-// DefaultRepoSettings are the settings of a new repository: every mode, and
59-// failing checks block the merge.
56+// DefaultRepoSettings are the settings of a new repository: every merge mode,
57+// with issues and auto-merge disabled.
6058 func DefaultRepoSettings() RepoSettings {
61- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
59+ return RepoSettings{AllowedMergeModes: MergeModes(), Issues: IssuesOff}
6260 }
6361
6462 // IsZero reports whether s holds no mode, which only the zero value does.
@@ -46,8 +46,6 @@ type RepoSettings struct {
46 // AllowedMergeModes is never empty, in canonical order, without46 // AllowedMergeModes is never empty, in canonical order, without
47 // duplicates.47 // duplicates.
48 AllowedMergeModes []MergeMode48 AllowedMergeModes []MergeMode
49- // FailingChecksBlock says that a failing check prevents the merge.
50- FailingChecksBlock bool
51 // Issues says who may write issues; the empty mode means IssuesOff.49 // Issues says who may write issues; the empty mode means IssuesOff.
52 Issues IssuesMode50 Issues IssuesMode
53 // AllowAutoMerge says that merge requests may be set to merge themselves51 // AllowAutoMerge says that merge requests may be set to merge themselves
@@ -55,10 +53,10 @@ type RepoSettings struct {
55 AllowAutoMerge bool53 AllowAutoMerge bool
56 }54 }
57 55
58-// DefaultRepoSettings are the settings of a new repository: every mode, and56+// DefaultRepoSettings are the settings of a new repository: every merge mode,
59-// failing checks block the merge.57+// with issues and auto-merge disabled.
60 func DefaultRepoSettings() RepoSettings {58 func DefaultRepoSettings() RepoSettings {
61- return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}59+ return RepoSettings{AllowedMergeModes: MergeModes(), Issues: IssuesOff}
62 }60 }
63 61
64 // IsZero reports whether s holds no mode, which only the zero value does.62 // IsZero reports whether s holds no mode, which only the zero value does.
M internal/domain/mergerequest_test.go +0 -1
@@ -25,7 +25,6 @@ func TestDefaultRepoSettings(t *testing.T) {
2525 t.Parallel()
2626 s := domain.DefaultRepoSettings()
2727 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
28- assert.True(t, s.FailingChecksBlock)
2928 assert.True(t, s.Allows(domain.MergeModeSquash))
3029 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
3130 assert.True(t, domain.RepoSettings{}.IsZero())
@@ -25,7 +25,6 @@ func TestDefaultRepoSettings(t *testing.T) {
25 t.Parallel()25 t.Parallel()
26 s := domain.DefaultRepoSettings()26 s := domain.DefaultRepoSettings()
27 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)27 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
28- assert.True(t, s.FailingChecksBlock)
29 assert.True(t, s.Allows(domain.MergeModeSquash))28 assert.True(t, s.Allows(domain.MergeModeSquash))
30 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))29 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
31 assert.True(t, domain.RepoSettings{}.IsZero())30 assert.True(t, domain.RepoSettings{}.IsZero())
M internal/domain/token.go +4 -2
@@ -30,6 +30,8 @@ const (
3030 ScopeMRWrite Scope = "mr:write"
3131 // ScopeMRMerge merges, and deletes the merged source branch on request.
3232 ScopeMRMerge Scope = "mr:merge"
33+ // ScopeMRForceMerge bypasses failing or unfinished merge checks.
34+ ScopeMRForceMerge Scope = "mr:force-merge"
3335 // ScopeMRReview sets a verdict on a merge request.
3436 ScopeMRReview Scope = "mr:review"
3537 // ScopeCIWrite re-runs or cancels CI runs.
@@ -45,11 +47,11 @@ const (
4547 ScopeIssueWrite Scope = "issue:write"
4648 )
4749
48-// AllScopes returns the eleven scopes in a stable order.
50+// AllScopes returns the twelve scopes in a stable order.
4951 func AllScopes() []Scope {
5052 return []Scope{
5153 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
52- ScopeMRWrite, ScopeMRMerge, ScopeMRReview,
54+ ScopeMRWrite, ScopeMRMerge, ScopeMRForceMerge, ScopeMRReview,
5355 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
5456 ScopeTokenAdmin,
5557 ScopeIssueWrite,
@@ -30,6 +30,8 @@ const (
30 ScopeMRWrite Scope = "mr:write"30 ScopeMRWrite Scope = "mr:write"
31 // ScopeMRMerge merges, and deletes the merged source branch on request.31 // ScopeMRMerge merges, and deletes the merged source branch on request.
32 ScopeMRMerge Scope = "mr:merge"32 ScopeMRMerge Scope = "mr:merge"
33+ // ScopeMRForceMerge bypasses failing or unfinished merge checks.
34+ ScopeMRForceMerge Scope = "mr:force-merge"
33 // ScopeMRReview sets a verdict on a merge request.35 // ScopeMRReview sets a verdict on a merge request.
34 ScopeMRReview Scope = "mr:review"36 ScopeMRReview Scope = "mr:review"
35 // ScopeCIWrite re-runs or cancels CI runs.37 // ScopeCIWrite re-runs or cancels CI runs.
@@ -45,11 +47,11 @@ const (
45 ScopeIssueWrite Scope = "issue:write"47 ScopeIssueWrite Scope = "issue:write"
46 )48 )
47 49
48-// AllScopes returns the eleven scopes in a stable order.50+// AllScopes returns the twelve scopes in a stable order.
49 func AllScopes() []Scope {51 func AllScopes() []Scope {
50 return []Scope{52 return []Scope{
51 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,53 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
52- ScopeMRWrite, ScopeMRMerge, ScopeMRReview,54+ ScopeMRWrite, ScopeMRMerge, ScopeMRForceMerge, ScopeMRReview,
53 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,55 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
54 ScopeTokenAdmin,56 ScopeTokenAdmin,
55 ScopeIssueWrite,57 ScopeIssueWrite,
M internal/domain/token_test.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 func TestScopes(t *testing.T) {
1717 t.Parallel()
1818 all := domain.AllScopes()
19- assert.Len(t, all, 11)
19+ assert.Len(t, all, 12)
2020 assert.Contains(t, all, domain.ScopeIssueWrite)
2121 got, err := domain.ParseScope("issue:write")
2222 require.NoError(t, err)
@@ -16,7 +16,7 @@ import (
16 func TestScopes(t *testing.T) {16 func TestScopes(t *testing.T) {
17 t.Parallel()17 t.Parallel()
18 all := domain.AllScopes()18 all := domain.AllScopes()
19- assert.Len(t, all, 11)19+ assert.Len(t, all, 12)
20 assert.Contains(t, all, domain.ScopeIssueWrite)20 assert.Contains(t, all, domain.ScopeIssueWrite)
21 got, err := domain.ParseScope("issue:write")21 got, err := domain.ParseScope("issue:write")
22 require.NoError(t, err)22 require.NoError(t, err)
M internal/ports/dto.go +2 -2
@@ -47,8 +47,6 @@ type UpdateRepoParams struct {
4747 // AllowedMergeModes replaces the allowed merge modes; it must hold at
4848 // least one known mode.
4949 AllowedMergeModes *[]domain.MergeMode
50- // FailingChecksBlock replaces the setting of that name.
51- FailingChecksBlock *bool
5250 // Issues replaces the issues mode.
5351 Issues *domain.IssuesMode
5452 // AllowAutoMerge replaces the setting of that name.
@@ -381,6 +379,8 @@ type AddCommentParams struct {
381379
382380 // MergeParams describes the merge to perform.
383381 type MergeParams struct {
382+ // IgnoreChecks bypasses checks and requires mr:force-merge.
383+ IgnoreChecks bool
384384 // Mode defaults to fast-forward.
385385 Mode domain.MergeMode
386386 // HeadID is the commit the caller reviewed; the source branch must still
@@ -47,8 +47,6 @@ type UpdateRepoParams struct {
47 // AllowedMergeModes replaces the allowed merge modes; it must hold at47 // AllowedMergeModes replaces the allowed merge modes; it must hold at
48 // least one known mode.48 // least one known mode.
49 AllowedMergeModes *[]domain.MergeMode49 AllowedMergeModes *[]domain.MergeMode
50- // FailingChecksBlock replaces the setting of that name.
51- FailingChecksBlock *bool
52 // Issues replaces the issues mode.50 // Issues replaces the issues mode.
53 Issues *domain.IssuesMode51 Issues *domain.IssuesMode
54 // AllowAutoMerge replaces the setting of that name.52 // AllowAutoMerge replaces the setting of that name.
@@ -381,6 +379,8 @@ type AddCommentParams struct {
381 379
382 // MergeParams describes the merge to perform.380 // MergeParams describes the merge to perform.
383 type MergeParams struct {381 type MergeParams struct {
382+ // IgnoreChecks bypasses checks and requires mr:force-merge.
383+ IgnoreChecks bool
384 // Mode defaults to fast-forward.384 // Mode defaults to fast-forward.
385 Mode domain.MergeMode385 Mode domain.MergeMode
386 // HeadID is the commit the caller reviewed; the source branch must still386 // HeadID is the commit the caller reviewed; the source branch must still
M internal/usecase/access.go +1 -1
@@ -36,7 +36,7 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility)
3636 // repo:admin on it, and no branch prefixes. A token limited to some branches
3737 // may not delete the repository (that deletes every branch, those outside its
3838 // prefixes included), nor change its default branch, visibility, description,
39-// merge modes or failing-checks rule: these decide what happens to branches the
39+// merge modes or auto-merge settings: these decide what happens to branches the
4040 // token may not write. A caller that may not administer it learns nothing it
4141 // could not already read: not found when it cannot see the repository,
4242 // unauthenticated when it is anonymous, forbidden otherwise.
@@ -36,7 +36,7 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility)
36 // repo:admin on it, and no branch prefixes. A token limited to some branches36 // repo:admin on it, and no branch prefixes. A token limited to some branches
37 // may not delete the repository (that deletes every branch, those outside its37 // may not delete the repository (that deletes every branch, those outside its
38 // prefixes included), nor change its default branch, visibility, description,38 // prefixes included), nor change its default branch, visibility, description,
39-// merge modes or failing-checks rule: these decide what happens to branches the39+// merge modes or auto-merge settings: these decide what happens to branches the
40 // token may not write. A caller that may not administer it learns nothing it40 // token may not write. A caller that may not administer it learns nothing it
41 // could not already read: not found when it cannot see the repository,41 // could not already read: not found when it cannot see the repository,
42 // unauthenticated when it is anonymous, forbidden otherwise.42 // unauthenticated when it is anonymous, forbidden otherwise.
M internal/usecase/automerge_test.go +1 -4
@@ -159,7 +159,7 @@ func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) {
159159 require.True(t, found)
160160 }
161161
162-func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
162+func TestAutoMergeRequiresPassingChecks(t *testing.T) {
163163 for _, tc := range []struct {
164164 name string
165165 check domain.CICheck
@@ -170,8 +170,6 @@ func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
170170 t.Run(tc.name, func(t *testing.T) {
171171 r, mr, checks := autoRig(t)
172172 enableAuto(t, r, mr)
173- _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
174- require.NoError(t, err)
175173 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
176174 a := autoEvaluator(r, checks, nil, nil)
177175 defer a.Close(r.ctx)
@@ -388,7 +386,6 @@ func TestMergeGateSettingsUpdateWaits(t *testing.T) {
388386 for _, params := range []ports.UpdateRepoParams{
389387 {AllowAutoMerge: ptr(false)},
390388 {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
391- {FailingChecksBlock: ptr(false)},
392389 } {
393390 r, _, _ := autoRig(t)
394391 r.repos.WithMergeGate(r.mrs)
@@ -159,7 +159,7 @@ func TestAutoMergeMergesAsEnablerWhenAChecksPassed(t *testing.T) {
159 require.True(t, found)159 require.True(t, found)
160 }160 }
161 161
162-func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {162+func TestAutoMergeRequiresPassingChecks(t *testing.T) {
163 for _, tc := range []struct {163 for _, tc := range []struct {
164 name string164 name string
165 check domain.CICheck165 check domain.CICheck
@@ -170,8 +170,6 @@ func TestAutoMergeIgnoresFailingChecksBlockOff(t *testing.T) {
170 t.Run(tc.name, func(t *testing.T) {170 t.Run(tc.name, func(t *testing.T) {
171 r, mr, checks := autoRig(t)171 r, mr, checks := autoRig(t)
172 enableAuto(t, r, mr)172 enableAuto(t, r, mr)
173- _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: ptr(false)})
174- require.NoError(t, err)
175 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)173 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}, tc.check}, nil)
176 a := autoEvaluator(r, checks, nil, nil)174 a := autoEvaluator(r, checks, nil, nil)
177 defer a.Close(r.ctx)175 defer a.Close(r.ctx)
@@ -388,7 +386,6 @@ func TestMergeGateSettingsUpdateWaits(t *testing.T) {
388 for _, params := range []ports.UpdateRepoParams{386 for _, params := range []ports.UpdateRepoParams{
389 {AllowAutoMerge: ptr(false)},387 {AllowAutoMerge: ptr(false)},
390 {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},388 {AllowedMergeModes: ptr([]domain.MergeMode{domain.MergeModeSquash})},
391- {FailingChecksBlock: ptr(false)},
392 } {389 } {
393 r, _, _ := autoRig(t)390 r, _, _ := autoRig(t)
394 r.repos.WithMergeGate(r.mrs)391 r.repos.WithMergeGate(r.mrs)
M internal/usecase/ci_unit_test.go +37 -6
@@ -24,7 +24,7 @@ type mergeUnit struct {
2424 head domain.ObjectID
2525 }
2626
27-func newMergeUnit(t *testing.T, block bool) *mergeUnit {
27+func newMergeUnit(t *testing.T) *mergeUnit {
2828 t.Helper()
2929 u := &mrUnit{
3030 meta: mocks.NewRepoMetaStore(t),
@@ -34,7 +34,6 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit {
3434 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
3535 }
3636 u.repo.Settings = domain.DefaultRepoSettings()
37- u.repo.Settings.FailingChecksBlock = block
3837 clock := mocks.NewClock(t)
3938 clock.EXPECT().Now().Return(now).Maybe()
4039 sched := mocks.NewMaintenanceScheduler(t)
@@ -63,8 +62,8 @@ func (m *mergeUnit) merge() error {
6362 return err
6463 }
6564
66-// AC5: the merge rule under FailingChecksBlock.
67-func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
65+// AC5: the merge rule under ChecksPolicy.
66+func TestMergeRuleOfChecksPolicy(t *testing.T) {
6867 t.Parallel()
6968 check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
7069 return domain.CICheck{Name: name, Status: status, Conclusion: c}
@@ -84,7 +83,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
8483 for _, tt := range tests {
8584 t.Run(tt.name, func(t *testing.T) {
8685 t.Parallel()
87- m := newMergeUnit(t, true)
86+ m := newMergeUnit(t)
8887 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
8988 // The merge stops at the rule: the branch mock expects no move.
9089 err := m.merge()
@@ -94,7 +93,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
9493 }
9594 t.Run("too many checks to tell", func(t *testing.T) {
9695 t.Parallel()
97- m := newMergeUnit(t, true)
96+ m := newMergeUnit(t)
9897 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
9998 err := m.merge()
10099 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
@@ -174,3 +173,35 @@ func TestTokenShapes(t *testing.T) {
174173 assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
175174 assert.False(t, domain.IsJobToken(good+"="))
176175 }
176+
177+func TestMergeChecksAndOverride(t *testing.T) {
178+ for _, tc := range []struct {
179+ name string
180+ principal domain.Principal
181+ checks []domain.CICheck
182+ ignore bool
183+ want error
184+ }{
185+ {name: "no checks allowed", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge)},
186+ {name: "failure refused", principal: root, checks: []domain.CICheck{{Name: "build", Status: domain.CICompleted, Conclusion: domain.CIFailure}}, want: domain.ErrPreconditionFailed},
187+ {name: "pending refused", principal: root, checks: []domain.CICheck{{Name: "build", Status: domain.CIQueued}}, want: domain.ErrPreconditionFailed},
188+ {name: "force scope bypasses failure", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge, domain.ScopeMRForceMerge), checks: []domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CIFailure}}, ignore: true},
189+ {name: "missing force scope denied", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge), ignore: true, want: domain.ErrForbidden},
190+ {name: "force scope alone insufficient", principal: writer(domain.ScopeRepoRead, domain.ScopeMRForceMerge), ignore: true, want: domain.ErrForbidden},
191+ {name: "root override", principal: domain.Root(), ignore: true},
192+ {name: "owner override", principal: domain.Owner(), ignore: true},
193+ } {
194+ t.Run(tc.name, func(t *testing.T) {
195+ r, mr, checks := autoRig(t)
196+ if !tc.ignore {
197+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(tc.checks, nil).Once()
198+ }
199+ _, err := r.mrs.Merge(r.ctx, tc.principal, r.repo, mr.Number, ports.MergeParams{HeadID: r.head(), IgnoreChecks: tc.ignore})
200+ if tc.want != nil {
201+ require.ErrorIs(t, err, tc.want)
202+ } else {
203+ require.NoError(t, err)
204+ }
205+ })
206+ }
207+}
@@ -24,7 +24,7 @@ type mergeUnit struct {
24 head domain.ObjectID24 head domain.ObjectID
25 }25 }
26 26
27-func newMergeUnit(t *testing.T, block bool) *mergeUnit {27+func newMergeUnit(t *testing.T) *mergeUnit {
28 t.Helper()28 t.Helper()
29 u := &mrUnit{29 u := &mrUnit{
30 meta: mocks.NewRepoMetaStore(t),30 meta: mocks.NewRepoMetaStore(t),
@@ -34,7 +34,6 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit {
34 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},34 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
35 }35 }
36 u.repo.Settings = domain.DefaultRepoSettings()36 u.repo.Settings = domain.DefaultRepoSettings()
37- u.repo.Settings.FailingChecksBlock = block
38 clock := mocks.NewClock(t)37 clock := mocks.NewClock(t)
39 clock.EXPECT().Now().Return(now).Maybe()38 clock.EXPECT().Now().Return(now).Maybe()
40 sched := mocks.NewMaintenanceScheduler(t)39 sched := mocks.NewMaintenanceScheduler(t)
@@ -63,8 +62,8 @@ func (m *mergeUnit) merge() error {
63 return err62 return err
64 }63 }
65 64
66-// AC5: the merge rule under FailingChecksBlock.65+// AC5: the merge rule under ChecksPolicy.
67-func TestMergeRuleOfFailingChecksBlock(t *testing.T) {66+func TestMergeRuleOfChecksPolicy(t *testing.T) {
68 t.Parallel()67 t.Parallel()
69 check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {68 check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
70 return domain.CICheck{Name: name, Status: status, Conclusion: c}69 return domain.CICheck{Name: name, Status: status, Conclusion: c}
@@ -84,7 +83,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
84 for _, tt := range tests {83 for _, tt := range tests {
85 t.Run(tt.name, func(t *testing.T) {84 t.Run(tt.name, func(t *testing.T) {
86 t.Parallel()85 t.Parallel()
87- m := newMergeUnit(t, true)86+ m := newMergeUnit(t)
88 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)87 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
89 // The merge stops at the rule: the branch mock expects no move.88 // The merge stops at the rule: the branch mock expects no move.
90 err := m.merge()89 err := m.merge()
@@ -94,7 +93,7 @@ func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
94 }93 }
95 t.Run("too many checks to tell", func(t *testing.T) {94 t.Run("too many checks to tell", func(t *testing.T) {
96 t.Parallel()95 t.Parallel()
97- m := newMergeUnit(t, true)96+ m := newMergeUnit(t)
98 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)97 m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
99 err := m.merge()98 err := m.merge()
100 require.ErrorIs(t, err, domain.ErrPreconditionFailed)99 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
@@ -174,3 +173,35 @@ func TestTokenShapes(t *testing.T) {
174 assert.False(t, domain.IsJobToken("ermite_"+good[10:]))173 assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
175 assert.False(t, domain.IsJobToken(good+"="))174 assert.False(t, domain.IsJobToken(good+"="))
176 }175 }
176+
177+func TestMergeChecksAndOverride(t *testing.T) {
178+ for _, tc := range []struct {
179+ name string
180+ principal domain.Principal
181+ checks []domain.CICheck
182+ ignore bool
183+ want error
184+ }{
185+ {name: "no checks allowed", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge)},
186+ {name: "failure refused", principal: root, checks: []domain.CICheck{{Name: "build", Status: domain.CICompleted, Conclusion: domain.CIFailure}}, want: domain.ErrPreconditionFailed},
187+ {name: "pending refused", principal: root, checks: []domain.CICheck{{Name: "build", Status: domain.CIQueued}}, want: domain.ErrPreconditionFailed},
188+ {name: "force scope bypasses failure", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge, domain.ScopeMRForceMerge), checks: []domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CIFailure}}, ignore: true},
189+ {name: "missing force scope denied", principal: writer(domain.ScopeRepoRead, domain.ScopeMRMerge), ignore: true, want: domain.ErrForbidden},
190+ {name: "force scope alone insufficient", principal: writer(domain.ScopeRepoRead, domain.ScopeMRForceMerge), ignore: true, want: domain.ErrForbidden},
191+ {name: "root override", principal: domain.Root(), ignore: true},
192+ {name: "owner override", principal: domain.Owner(), ignore: true},
193+ } {
194+ t.Run(tc.name, func(t *testing.T) {
195+ r, mr, checks := autoRig(t)
196+ if !tc.ignore {
197+ checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return(tc.checks, nil).Once()
198+ }
199+ _, err := r.mrs.Merge(r.ctx, tc.principal, r.repo, mr.Number, ports.MergeParams{HeadID: r.head(), IgnoreChecks: tc.ignore})
200+ if tc.want != nil {
201+ require.ErrorIs(t, err, tc.want)
202+ } else {
203+ require.NoError(t, err)
204+ }
205+ })
206+ }
207+}
M internal/usecase/mergerequests.go +12 -5
@@ -995,8 +995,9 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
995995 // allowed by the settings of the repository, the source branch no longer
996996 // points to the head ("branch moved, read again") or is gone, the target is
997997 // gone, the mode's own condition does not hold (see below), or the branch
998-// brings nothing. Checks come with CI (CI-1): there are none yet, so the
999-// setting that lets failing checks block the merge has nothing to act on.
998+// brings nothing. Checks on the head must pass: none may be failed, queued
999+// or running; a head with no checks may be merged. params.IgnoreChecks bypasses
1000+// this requirement and needs mr:force-merge.
10001001 //
10011002 // The conditions are always checked with Git, under the lock, never from the
10021003 // stored mergeability. A fast-forward needs the target to be an ancestor of
@@ -1075,9 +1076,15 @@ var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
10751076
10761077 func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
10771078 if opts.auto == nil {
1078- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1079+ repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
1080+ if err != nil {
10791081 return domain.MergeRequest{}, err
10801082 }
1083+ if params.IgnoreChecks {
1084+ if err := scopeAccess(p, repo, domain.ScopeMRForceMerge); err != nil {
1085+ return domain.MergeRequest{}, err
1086+ }
1087+ }
10811088 }
10821089 mode := params.Mode
10831090 if mode == "" {
@@ -1179,7 +1186,7 @@ func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name do
11791186 if opts.auto != nil && !settings.AllowAutoMerge {
11801187 return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
11811188 }
1182- if settings.FailingChecksBlock || opts.auto != nil {
1189+ if !params.IgnoreChecks || opts.auto != nil {
11831190 checks, err := m.readMergeChecks(ctx, name, head)
11841191 if err == nil {
11851192 err = checksPolicy(checks)
@@ -1362,7 +1369,7 @@ func checksPolicy(checks []domain.CICheck) error {
13621369 }
13631370 switch {
13641371 case len(failing) > 0:
1365- return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1372+ return fmt.Errorf("%w: failing checks block the merge: %s (merge with --ignore-checks, needs the mr:force-merge scope)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
13661373 case len(pending) > 0:
13671374 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
13681375 }
@@ -995,8 +995,9 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
995 // allowed by the settings of the repository, the source branch no longer995 // allowed by the settings of the repository, the source branch no longer
996 // points to the head ("branch moved, read again") or is gone, the target is996 // points to the head ("branch moved, read again") or is gone, the target is
997 // gone, the mode's own condition does not hold (see below), or the branch997 // gone, the mode's own condition does not hold (see below), or the branch
998-// brings nothing. Checks come with CI (CI-1): there are none yet, so the998+// brings nothing. Checks on the head must pass: none may be failed, queued
999-// setting that lets failing checks block the merge has nothing to act on.999+// or running; a head with no checks may be merged. params.IgnoreChecks bypasses
1000+// this requirement and needs mr:force-merge.
1000 //1001 //
1001 // The conditions are always checked with Git, under the lock, never from the1002 // The conditions are always checked with Git, under the lock, never from the
1002 // stored mergeability. A fast-forward needs the target to be an ancestor of1003 // stored mergeability. A fast-forward needs the target to be an ancestor of
@@ -1075,9 +1076,15 @@ var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1075 1076
1076 func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {1077 func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1077 if opts.auto == nil {1078 if opts.auto == nil {
1078- if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {1079+ repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
1080+ if err != nil {
1079 return domain.MergeRequest{}, err1081 return domain.MergeRequest{}, err
1080 }1082 }
1083+ if params.IgnoreChecks {
1084+ if err := scopeAccess(p, repo, domain.ScopeMRForceMerge); err != nil {
1085+ return domain.MergeRequest{}, err
1086+ }
1087+ }
1081 }1088 }
1082 mode := params.Mode1089 mode := params.Mode
1083 if mode == "" {1090 if mode == "" {
@@ -1179,7 +1186,7 @@ func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name do
1179 if opts.auto != nil && !settings.AllowAutoMerge {1186 if opts.auto != nil && !settings.AllowAutoMerge {
1180 return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)1187 return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1181 }1188 }
1182- if settings.FailingChecksBlock || opts.auto != nil {1189+ if !params.IgnoreChecks || opts.auto != nil {
1183 checks, err := m.readMergeChecks(ctx, name, head)1190 checks, err := m.readMergeChecks(ctx, name, head)
1184 if err == nil {1191 if err == nil {
1185 err = checksPolicy(checks)1192 err = checksPolicy(checks)
@@ -1362,7 +1369,7 @@ func checksPolicy(checks []domain.CICheck) error {
1362 }1369 }
1363 switch {1370 switch {
1364 case len(failing) > 0:1371 case len(failing) > 0:
1365- return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))1372+ return fmt.Errorf("%w: failing checks block the merge: %s (merge with --ignore-checks, needs the mr:force-merge scope)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1366 case len(pending) > 0:1373 case len(pending) > 0:
1367 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))1374 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
1368 }1375 }
M internal/usecase/mergerequests_integration_test.go +5 -5
M internal/usecase/repos_write.go +4 -7
M internal/usecase/repos_write_test.go +1 -3
M proto/ermite/storage/merge_request.proto +2 -1
M skills/ermite/CLI.md +10 -4
M skills/ermite/SKILL.md +5 -3

Comments2 open / 2

bnema outdated

CI fails on purpose for now: go.mod has a local replace for ermite-api (ermite-api!10). Once ermite-api!10 is merged and tagged v0.15.0, I replace it with the tag and CI should go green. ermite-api keeps failing_checks_block as a deprecated, ignored field (buf breaking).

bnema

go.mod now uses ermite-api v0.15.0 (no local replace). The storage field failing_checks_block is kept as deprecated (buf breaking). CI green (run 159).

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close