feat(cli): add mr merge --auto/--cancel-auto and repo edit --allow-auto-merge
- commit
- b271fd8ae7d38d9d48a98b8b58ff0a9aa9cda361
- author
- brice · · 2026-10-20 16:17 UTC
- parent
- d1f49e5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +444 -29
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | require.NoError(t, err, who) | |
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who) | |
| 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 198 | require.NoError(t, err, who) | ||
| 199 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who) | 199 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, resp.Msg.GetFeatures(), who) |
| 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 201 | } | 201 | } | ||
| 202 | }) | 202 | }) | ||
M internal/app/automerge_e2e_test.go +88 -2
| @@ -6,15 +6,18 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "connectrpc.com/connect" | |
| 9 | 10 | "github.com/stretchr/testify/assert" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 12 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 11 | 13 | ||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | 15 | "bnema.dev/ermite/internal/domain" | |
| 13 | 16 | "bnema.dev/ermite/internal/ports" | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 16 | - | // The RPC enable/cancel and Watch conversions belong to Batch D. This test | |
| 17 | - | // exercises the composition root through use cases and domain events instead. | |
| 19 | + | // This test exercises the composition root through use cases and domain | |
| 20 | + | // events; TestAutoMergeEndToEndAPI does the same through the RPC API. | |
| 18 | 21 | func TestAutoMergeEndToEnd(t *testing.T) { | |
| 19 | 22 | c := newCIHarness(t, nil) | |
| 20 | 23 | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| @@ -67,3 +70,86 @@ jobs: | |||
| 67 | 70 | } | |
| 68 | 71 | } | |
| 69 | 72 | } | |
| 73 | + | ||
| 74 | + | // TestAutoMergeEndToEndAPI enables auto-merge through the API client, lets CI | |
| 75 | + | // succeed, and reads the Watch stream: enabled, then merged with auto=true. | |
| 76 | + | func TestAutoMergeEndToEndAPI(t *testing.T) { | |
| 77 | + | c := newCIHarness(t, nil) | |
| 78 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |
| 79 | + | c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci | |
| 80 | + | on: | |
| 81 | + | push: | |
| 82 | + | branches: [feat] | |
| 83 | + | pull_request: | |
| 84 | + | jobs: | |
| 85 | + | build: | |
| 86 | + | runs-on: ubuntu-latest | |
| 87 | + | steps: | |
| 88 | + | - run: echo hello | |
| 89 | + | `}, "") | |
| 90 | + | c.push("feat", map[string]string{"change.txt": "feature\n"}, "") | |
| 91 | + | number := c.openMR(c.root, "feat") | |
| 92 | + | ||
| 93 | + | // Off by default: the server refuses. | |
| 94 | + | _, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: c.repo, Number: number})) | |
| 95 | + | require.Error(t, err) | |
| 96 | + | assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) | |
| 97 | + | ||
| 98 | + | ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | assert.Contains(t, ver.Msg.GetFeatures(), "merge_requests.auto_merge") | |
| 101 | + | ||
| 102 | + | up, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 103 | + | Repo: c.repo, | |
| 104 | + | Settings: &pb.RepoSettings{AllowAutoMerge: true}, | |
| 105 | + | UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allow_auto_merge"}}, | |
| 106 | + | })) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | assert.True(t, up.Msg.GetRepo().GetSettings().GetAllowAutoMerge()) | |
| 109 | + | ||
| 110 | + | w := c.watch(c.root, "", "home") | |
| 111 | + | res, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{ | |
| 112 | + | Repo: c.repo, Number: number, Mode: pb.MergeMode_MERGE_MODE_SQUASH, | |
| 113 | + | })) | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | am := res.Msg.GetMergeRequest().GetAutoMerge() | |
| 116 | + | require.NotNil(t, am) | |
| 117 | + | assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, am.GetMode()) | |
| 118 | + | assert.Equal(t, res.Msg.GetMergeRequest().GetHeadId(), am.GetHeadId()) | |
| 119 | + | assert.NotEmpty(t, am.GetEnabledBy()) | |
| 120 | + | assert.NotNil(t, am.GetEnabledAt()) | |
| 121 | + | ||
| 122 | + | got, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number})) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | assert.NotNil(t, got.Msg.GetMergeRequest().GetAutoMerge(), "Get reports the pending auto-merge") | |
| 125 | + | ||
| 126 | + | c.finish(runner) | |
| 127 | + | c.finish(runner) | |
| 128 | + | ||
| 129 | + | var sawEnabled bool | |
| 130 | + | deadline := time.After(15 * time.Second) | |
| 131 | + | for { | |
| 132 | + | select { | |
| 133 | + | case ev, ok := <-w.events: | |
| 134 | + | require.True(t, ok, "the stream ended: %v", w.err) | |
| 135 | + | if e := ev.GetMergeRequestAutoMergeEnabled(); e != nil { | |
| 136 | + | assert.EqualValues(t, number, e.GetMergeRequest().GetNumber()) | |
| 137 | + | assert.NotNil(t, e.GetMergeRequest().GetAutoMerge()) | |
| 138 | + | sawEnabled = true | |
| 139 | + | } | |
| 140 | + | if m := ev.GetMergeRequestMerged(); m != nil && m.GetMergeRequest().GetNumber() == number { | |
| 141 | + | assert.True(t, sawEnabled, "enabled comes before merged") | |
| 142 | + | assert.True(t, m.GetAuto()) | |
| 143 | + | assert.True(t, m.GetMergeRequest().GetAutoMerged()) | |
| 144 | + | assert.Nil(t, m.GetMergeRequest().GetAutoMerge()) | |
| 145 | + | final, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number})) | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, final.Msg.GetMergeRequest().GetState()) | |
| 148 | + | assert.True(t, final.Msg.GetMergeRequest().GetAutoMerged()) | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | case <-deadline: | |
| 152 | + | t.Fatal("no automatic merged event") | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | } | |
| @@ -6,15 +6,18 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 9 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |||
| 11 | 13 | ||||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 12 | "bnema.dev/ermite/internal/domain" | 15 | "bnema.dev/ermite/internal/domain" | ||
| 13 | "bnema.dev/ermite/internal/ports" | 16 | "bnema.dev/ermite/internal/ports" | ||
| 14 | ) | 17 | ) | ||
| 15 | 18 | ||||
| 16 | - | // The RPC enable/cancel and Watch conversions belong to Batch D. This test | 19 | + | // This test exercises the composition root through use cases and domain |
| 17 | - | // exercises the composition root through use cases and domain events instead. | 20 | + | // events; TestAutoMergeEndToEndAPI does the same through the RPC API. |
| 18 | func TestAutoMergeEndToEnd(t *testing.T) { | 21 | func TestAutoMergeEndToEnd(t *testing.T) { | ||
| 19 | c := newCIHarness(t, nil) | 22 | c := newCIHarness(t, nil) | ||
| 20 | runner := c.registerRunner("alpha", "ubuntu-latest") | 23 | runner := c.registerRunner("alpha", "ubuntu-latest") | ||
| @@ -67,3 +70,86 @@ jobs: | |||||
| 67 | } | 70 | } | ||
| 68 | } | 71 | } | ||
| 69 | } | 72 | } | ||
| 73 | + | ||||
| 74 | + | // TestAutoMergeEndToEndAPI enables auto-merge through the API client, lets CI | |||
| 75 | + | // succeed, and reads the Watch stream: enabled, then merged with auto=true. | |||
| 76 | + | func TestAutoMergeEndToEndAPI(t *testing.T) { | |||
| 77 | + | c := newCIHarness(t, nil) | |||
| 78 | + | runner := c.registerRunner("alpha", "ubuntu-latest") | |||
| 79 | + | c.push("main", map[string]string{".github/workflows/ci.yml": `name: ci | |||
| 80 | + | on: | |||
| 81 | + | push: | |||
| 82 | + | branches: [feat] | |||
| 83 | + | pull_request: | |||
| 84 | + | jobs: | |||
| 85 | + | build: | |||
| 86 | + | runs-on: ubuntu-latest | |||
| 87 | + | steps: | |||
| 88 | + | - run: echo hello | |||
| 89 | + | `}, "") | |||
| 90 | + | c.push("feat", map[string]string{"change.txt": "feature\n"}, "") | |||
| 91 | + | number := c.openMR(c.root, "feat") | |||
| 92 | + | ||||
| 93 | + | // Off by default: the server refuses. | |||
| 94 | + | _, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: c.repo, Number: number})) | |||
| 95 | + | require.Error(t, err) | |||
| 96 | + | assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) | |||
| 97 | + | ||||
| 98 | + | ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | assert.Contains(t, ver.Msg.GetFeatures(), "merge_requests.auto_merge") | |||
| 101 | + | ||||
| 102 | + | up, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |||
| 103 | + | Repo: c.repo, | |||
| 104 | + | Settings: &pb.RepoSettings{AllowAutoMerge: true}, | |||
| 105 | + | UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allow_auto_merge"}}, | |||
| 106 | + | })) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | assert.True(t, up.Msg.GetRepo().GetSettings().GetAllowAutoMerge()) | |||
| 109 | + | ||||
| 110 | + | w := c.watch(c.root, "", "home") | |||
| 111 | + | res, err := c.root.mr.EnableAutoMerge(c.ctx, connect.NewRequest(&pb.EnableAutoMergeRequest{ | |||
| 112 | + | Repo: c.repo, Number: number, Mode: pb.MergeMode_MERGE_MODE_SQUASH, | |||
| 113 | + | })) | |||
| 114 | + | require.NoError(t, err) | |||
| 115 | + | am := res.Msg.GetMergeRequest().GetAutoMerge() | |||
| 116 | + | require.NotNil(t, am) | |||
| 117 | + | assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, am.GetMode()) | |||
| 118 | + | assert.Equal(t, res.Msg.GetMergeRequest().GetHeadId(), am.GetHeadId()) | |||
| 119 | + | assert.NotEmpty(t, am.GetEnabledBy()) | |||
| 120 | + | assert.NotNil(t, am.GetEnabledAt()) | |||
| 121 | + | ||||
| 122 | + | got, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number})) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | assert.NotNil(t, got.Msg.GetMergeRequest().GetAutoMerge(), "Get reports the pending auto-merge") | |||
| 125 | + | ||||
| 126 | + | c.finish(runner) | |||
| 127 | + | c.finish(runner) | |||
| 128 | + | ||||
| 129 | + | var sawEnabled bool | |||
| 130 | + | deadline := time.After(15 * time.Second) | |||
| 131 | + | for { | |||
| 132 | + | select { | |||
| 133 | + | case ev, ok := <-w.events: | |||
| 134 | + | require.True(t, ok, "the stream ended: %v", w.err) | |||
| 135 | + | if e := ev.GetMergeRequestAutoMergeEnabled(); e != nil { | |||
| 136 | + | assert.EqualValues(t, number, e.GetMergeRequest().GetNumber()) | |||
| 137 | + | assert.NotNil(t, e.GetMergeRequest().GetAutoMerge()) | |||
| 138 | + | sawEnabled = true | |||
| 139 | + | } | |||
| 140 | + | if m := ev.GetMergeRequestMerged(); m != nil && m.GetMergeRequest().GetNumber() == number { | |||
| 141 | + | assert.True(t, sawEnabled, "enabled comes before merged") | |||
| 142 | + | assert.True(t, m.GetAuto()) | |||
| 143 | + | assert.True(t, m.GetMergeRequest().GetAutoMerged()) | |||
| 144 | + | assert.Nil(t, m.GetMergeRequest().GetAutoMerge()) | |||
| 145 | + | final, err := c.root.mr.GetMergeRequest(c.ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: c.repo, Number: number})) | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, final.Msg.GetMergeRequest().GetState()) | |||
| 148 | + | assert.True(t, final.Msg.GetMergeRequest().GetAutoMerged()) | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | case <-deadline: | |||
| 152 | + | t.Fatal("no automatic merged event") | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | } | |||
M internal/app/cli_e2e_test.go +2 -2
| @@ -203,7 +203,7 @@ type eventOut struct { | |||
| 203 | 203 | MergeRequest *mrOut `json:"merge_request"` | |
| 204 | 204 | } | |
| 205 | 205 | ||
| 206 | - | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"} | |
| 206 | + | var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"} | |
| 207 | 207 | ||
| 208 | 208 | // AC6: the CLI end to end against a real forge. | |
| 209 | 209 | func TestAC6CLIEndToEnd(t *testing.T) { | |
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 350 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 352 | assert.NotEmpty(t, ver.Server.Version) | |
| 353 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features) | |
| 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features) | |
| 354 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 356 | require.Equal(t, 0, code, stderr) | |
| @@ -203,7 +203,7 @@ type eventOut struct { | |||||
| 203 | MergeRequest *mrOut `json:"merge_request"` | 203 | MergeRequest *mrOut `json:"merge_request"` | ||
| 204 | } | 204 | } | ||
| 205 | 205 | ||||
| 206 | - | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"} | 206 | + | var repoKeys = []string{"allow_auto_merge", "created_at", "default_branch", "description", "failing_checks_block", "issues", "merge_modes", "name", "pushed_at", "topics", "visibility"} |
| 207 | 207 | ||||
| 208 | // AC6: the CLI end to end against a real forge. | 208 | // AC6: the CLI end to end against a real forge. | ||
| 209 | func TestAC6CLIEndToEnd(t *testing.T) { | 209 | func TestAC6CLIEndToEnd(t *testing.T) { | ||
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 350 | ver := decode[versionOut](t, res.stdout) | 350 | ver := decode[versionOut](t, res.stdout) | ||
| 351 | assert.Equal(t, c.url, ver.Server.URL) | 351 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 352 | assert.NotEmpty(t, ver.Server.Version) | 352 | assert.NotEmpty(t, ver.Server.Version) | ||
| 353 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features) | 353 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror", "merge_requests.auto_merge"}, ver.Server.Features) |
| 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | ||
| 355 | stdout, stderr, code = c.bin(t, secret, "version") | 355 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 356 | require.Equal(t, 0, code, stderr) | 356 | require.Equal(t, 0, code, stderr) | ||
M internal/app/cli_fake_test.go +8 -0
| @@ -62,6 +62,8 @@ type fakeAPI struct { | |||
| 62 | 62 | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | |
| 63 | 63 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | |
| 64 | 64 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | |
| 65 | + | enableAuto func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error) | |
| 66 | + | cancelAuto func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error) | |
| 65 | 67 | createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) | |
| 66 | 68 | listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) | |
| 67 | 69 | getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) | |
| @@ -152,6 +154,12 @@ func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite | |||
| 152 | 154 | func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) { | |
| 153 | 155 | return respond(f, "MergeMergeRequest", r, f.mergeMR) | |
| 154 | 156 | } | |
| 157 | + | func (f *fakeAPI) EnableAutoMerge(_ context.Context, r *connect.Request[ermite.EnableAutoMergeRequest]) (*connect.Response[ermite.EnableAutoMergeResponse], error) { | |
| 158 | + | return respond(f, "EnableAutoMerge", r, f.enableAuto) | |
| 159 | + | } | |
| 160 | + | func (f *fakeAPI) CancelAutoMerge(_ context.Context, r *connect.Request[ermite.CancelAutoMergeRequest]) (*connect.Response[ermite.CancelAutoMergeResponse], error) { | |
| 161 | + | return respond(f, "CancelAutoMerge", r, f.cancelAuto) | |
| 162 | + | } | |
| 155 | 163 | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | |
| 156 | 164 | return respond(f, "MarkRead", r, f.markRead) | |
| 157 | 165 | } | |
| @@ -62,6 +62,8 @@ type fakeAPI struct { | |||||
| 62 | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | 62 | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | ||
| 63 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | 63 | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | ||
| 64 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | 64 | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | ||
| 65 | + | enableAuto func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error) | |||
| 66 | + | cancelAuto func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error) | |||
| 65 | createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) | 67 | createIssue func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error) | ||
| 66 | listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) | 68 | listIssues func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error) | ||
| 67 | getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) | 69 | getIssue func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error) | ||
| @@ -152,6 +154,12 @@ func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite | |||||
| 152 | func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) { | 154 | func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) { | ||
| 153 | return respond(f, "MergeMergeRequest", r, f.mergeMR) | 155 | return respond(f, "MergeMergeRequest", r, f.mergeMR) | ||
| 154 | } | 156 | } | ||
| 157 | + | func (f *fakeAPI) EnableAutoMerge(_ context.Context, r *connect.Request[ermite.EnableAutoMergeRequest]) (*connect.Response[ermite.EnableAutoMergeResponse], error) { | |||
| 158 | + | return respond(f, "EnableAutoMerge", r, f.enableAuto) | |||
| 159 | + | } | |||
| 160 | + | func (f *fakeAPI) CancelAutoMerge(_ context.Context, r *connect.Request[ermite.CancelAutoMergeRequest]) (*connect.Response[ermite.CancelAutoMergeResponse], error) { | |||
| 161 | + | return respond(f, "CancelAutoMerge", r, f.cancelAuto) | |||
| 162 | + | } | |||
| 155 | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | 163 | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | ||
| 156 | return respond(f, "MarkRead", r, f.markRead) | 164 | return respond(f, "MarkRead", r, f.markRead) | ||
| 157 | } | 165 | } | ||
M internal/app/cli_mr.go +96 -3
| @@ -121,6 +121,26 @@ type jsonMergeRequest struct { | |||
| 121 | 121 | HeadID string `json:"head_id"` | |
| 122 | 122 | MergedCommitID string `json:"merged_commit_id"` | |
| 123 | 123 | MergeMode string `json:"merge_mode"` | |
| 124 | + | // AutoMerge is the pending auto-merge, null when there is none. | |
| 125 | + | AutoMerge *jsonAutoMerge `json:"auto_merge"` | |
| 126 | + | // AutoMerged is true when the server merged the request by auto-merge. | |
| 127 | + | AutoMerged bool `json:"auto_merged"` | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // jsonAutoMerge is the stable JSON shape of a pending auto-merge. | |
| 131 | + | type jsonAutoMerge struct { | |
| 132 | + | Mode string `json:"mode"` | |
| 133 | + | HeadID string `json:"head_id"` | |
| 134 | + | EnabledBy string `json:"enabled_by"` | |
| 135 | + | EnabledAt *string `json:"enabled_at"` | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // toJSONAutoMerge maps an auto-merge; nil when there is none. | |
| 139 | + | func toJSONAutoMerge(a *ermite.AutoMerge) *jsonAutoMerge { | |
| 140 | + | if a == nil { | |
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | return &jsonAutoMerge{Mode: modeName(a.GetMode()), HeadID: a.GetHeadId(), EnabledBy: a.GetEnabledBy(), EnabledAt: jsonTime(a.GetEnabledAt())} | |
| 124 | 144 | } | |
| 125 | 145 | ||
| 126 | 146 | // jsonMergeability is the stable JSON shape of the mergeability of an open | |
| @@ -201,6 +221,7 @@ func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest | |||
| 201 | 221 | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | |
| 202 | 222 | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | |
| 203 | 223 | HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()), | |
| 224 | + | AutoMerge: toJSONAutoMerge(m.GetAutoMerge()), AutoMerged: m.GetAutoMerged(), | |
| 204 | 225 | } | |
| 205 | 226 | } | |
| 206 | 227 | ||
| @@ -245,7 +266,8 @@ JSON fields of a merge request (--json): repo, url (full web URL), number, title | |||
| 245 | 266 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | |
| 246 | 267 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | |
| 247 | 268 | branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty | |
| 248 | - | unless merged).` | |
| 269 | + | unless merged), auto_merge (null unless auto-merge is pending: mode, head_id, enabled_by, | |
| 270 | + | enabled_at) and auto_merged (true when the server merged the request by auto-merge).` | |
| 249 | 271 | ||
| 250 | 272 | const jsonMergeabilityHelp = ` | |
| 251 | 273 | mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date", | |
| @@ -660,7 +682,14 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||
| 660 | 682 | {"head", orDash(m.GetHeadId())}, | |
| 661 | 683 | } | |
| 662 | 684 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | |
| 663 | - | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | |
| 685 | + | merged := orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")" | |
| 686 | + | pairs = append(pairs, [2]string{"merged", merged}) | |
| 687 | + | if m.GetAutoMerged() { | |
| 688 | + | pairs = append(pairs, [2]string{"auto-merged", "yes, by the server once the checks passed"}) | |
| 689 | + | } | |
| 690 | + | } | |
| 691 | + | if am := m.GetAutoMerge(); am != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | |
| 692 | + | pairs = append(pairs, [2]string{"auto-merge", modeName(am.GetMode()) + " when ready (enabled by " + am.GetEnabledBy() + ")"}) | |
| 664 | 693 | } | |
| 665 | 694 | if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | |
| 666 | 695 | pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)}) | |
| @@ -1056,6 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1056 | 1085 | var mode string | |
| 1057 | 1086 | var deleteBranch bool | |
| 1058 | 1087 | var head string | |
| 1088 | + | var auto, cancelAuto bool | |
| 1059 | 1089 | cmd := &cobra.Command{ | |
| 1060 | 1090 | Use: "merge <repo> <number>", | |
| 1061 | 1091 | Short: "Merge a merge request", | |
| @@ -1078,7 +1108,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1078 | 1108 | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + | |
| 1079 | 1109 | "the source branch right after, in the background. The merge is refused when the branch is the\n" + | |
| 1080 | 1110 | "default branch or used by another open merge request. If the deletion later fails (the branch\n" + | |
| 1081 | - | "moved, say), the merge stands and the branch is kept." + jsonMRHelp + | |
| 1111 | + | "moved, say), the merge stands and the branch is kept.\n\n" + | |
| 1112 | + | "--auto does not merge now: it asks the server to merge by itself, with --mode, once CI has settled\n" + | |
| 1113 | + | "with at least one successful check on the head and every check allows it. Needs the mr:merge scope,\n" + | |
| 1114 | + | "a server with CI, and `ermite repo edit --allow-auto-merge` on the repository. A new push to the\n" + | |
| 1115 | + | "source branch, a change of the settings or of your permissions cancels it. --auto takes no --head:\n" + | |
| 1116 | + | "the server merges the head current when you enable it. --cancel-auto cancels a pending auto-merge." + jsonMRHelp + | |
| 1082 | 1117 | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | |
| 1083 | 1118 | Args: cobra.ExactArgs(2), | |
| 1084 | 1119 | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| @@ -1086,6 +1121,13 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1086 | 1121 | cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)") | |
| 1087 | 1122 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | |
| 1088 | 1123 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | |
| 1124 | + | cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)") | |
| 1125 | + | cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge") | |
| 1126 | + | cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto") | |
| 1127 | + | cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch") | |
| 1128 | + | cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head") | |
| 1129 | + | cmd.MarkFlagsMutuallyExclusive("auto", "delete-branch") | |
| 1130 | + | cmd.MarkFlagsMutuallyExclusive("auto", "head") | |
| 1089 | 1131 | _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 1090 | 1132 | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | |
| 1091 | 1133 | }) | |
| @@ -1095,10 +1137,16 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1095 | 1137 | if err != nil { | |
| 1096 | 1138 | return err | |
| 1097 | 1139 | } | |
| 1140 | + | if cancelAuto { | |
| 1141 | + | return runMRCancelAuto(env, cmd, args[0], n, *asJSON) | |
| 1142 | + | } | |
| 1098 | 1143 | pbMode, err := parseMode(mode) | |
| 1099 | 1144 | if err != nil { | |
| 1100 | 1145 | return err | |
| 1101 | 1146 | } | |
| 1147 | + | if auto { | |
| 1148 | + | return runMREnableAuto(env, cmd, args[0], n, pbMode, *asJSON) | |
| 1149 | + | } | |
| 1102 | 1150 | head = strings.TrimSpace(head) | |
| 1103 | 1151 | if head == "" { | |
| 1104 | 1152 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | |
| @@ -1139,6 +1187,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 1139 | 1187 | return cmd | |
| 1140 | 1188 | } | |
| 1141 | 1189 | ||
| 1190 | + | // runMREnableAuto is `mr merge --auto`: it asks the server to merge once ready. | |
| 1191 | + | func runMREnableAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, mode ermite.MergeMode, asJSON bool) error { | |
| 1192 | + | c, err := env.client(cmd) | |
| 1193 | + | if err != nil { | |
| 1194 | + | return err | |
| 1195 | + | } | |
| 1196 | + | ctx := env.ctx(cmd) | |
| 1197 | + | if err := requireFeature(ctx, c, client.FeatureAutoMerge, "cannot merge by itself (--auto)"); err != nil { | |
| 1198 | + | return err | |
| 1199 | + | } | |
| 1200 | + | res, err := c.MergeRequests().EnableAutoMerge(ctx, connect.NewRequest(&ermite.EnableAutoMergeRequest{Repo: repo, Number: n, Mode: mode})) | |
| 1201 | + | if err != nil { | |
| 1202 | + | return c.Describe(err) | |
| 1203 | + | } | |
| 1204 | + | m := res.Msg.GetMergeRequest() | |
| 1205 | + | if asJSON { | |
| 1206 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL())) | |
| 1207 | + | } | |
| 1208 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge enabled on %s!%d: %s into %s when ready (enabled by %s)\n", clean(m.GetRepo()), m.GetNumber(), | |
| 1209 | + | modeName(m.GetAutoMerge().GetMode()), clean(m.GetTargetBranch()), clean(m.GetAutoMerge().GetEnabledBy())) | |
| 1210 | + | return err | |
| 1211 | + | } | |
| 1212 | + | ||
| 1213 | + | // runMRCancelAuto is `mr merge --cancel-auto`. | |
| 1214 | + | func runMRCancelAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, asJSON bool) error { | |
| 1215 | + | c, err := env.client(cmd) | |
| 1216 | + | if err != nil { | |
| 1217 | + | return err | |
| 1218 | + | } | |
| 1219 | + | ctx := env.ctx(cmd) | |
| 1220 | + | if err := requireFeature(ctx, c, client.FeatureAutoMerge, "has no auto-merge to cancel (--cancel-auto)"); err != nil { | |
| 1221 | + | return err | |
| 1222 | + | } | |
| 1223 | + | res, err := c.MergeRequests().CancelAutoMerge(ctx, connect.NewRequest(&ermite.CancelAutoMergeRequest{Repo: repo, Number: n})) | |
| 1224 | + | if err != nil { | |
| 1225 | + | return c.Describe(err) | |
| 1226 | + | } | |
| 1227 | + | m := res.Msg.GetMergeRequest() | |
| 1228 | + | if asJSON { | |
| 1229 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL())) | |
| 1230 | + | } | |
| 1231 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge cancelled on %s!%d\n", clean(m.GetRepo()), m.GetNumber()) | |
| 1232 | + | return err | |
| 1233 | + | } | |
| 1234 | + | ||
| 1142 | 1235 | // signedNote says that a squash or merge commit is signed by the server's | |
| 1143 | 1236 | // key, with its fingerprint when the server tells it. | |
| 1144 | 1237 | func signedNote(ctx context.Context, c *client.Client) string { | |
| @@ -121,6 +121,26 @@ type jsonMergeRequest struct { | |||||
| 121 | HeadID string `json:"head_id"` | 121 | HeadID string `json:"head_id"` | ||
| 122 | MergedCommitID string `json:"merged_commit_id"` | 122 | MergedCommitID string `json:"merged_commit_id"` | ||
| 123 | MergeMode string `json:"merge_mode"` | 123 | MergeMode string `json:"merge_mode"` | ||
| 124 | + | // AutoMerge is the pending auto-merge, null when there is none. | |||
| 125 | + | AutoMerge *jsonAutoMerge `json:"auto_merge"` | |||
| 126 | + | // AutoMerged is true when the server merged the request by auto-merge. | |||
| 127 | + | AutoMerged bool `json:"auto_merged"` | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // jsonAutoMerge is the stable JSON shape of a pending auto-merge. | |||
| 131 | + | type jsonAutoMerge struct { | |||
| 132 | + | Mode string `json:"mode"` | |||
| 133 | + | HeadID string `json:"head_id"` | |||
| 134 | + | EnabledBy string `json:"enabled_by"` | |||
| 135 | + | EnabledAt *string `json:"enabled_at"` | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // toJSONAutoMerge maps an auto-merge; nil when there is none. | |||
| 139 | + | func toJSONAutoMerge(a *ermite.AutoMerge) *jsonAutoMerge { | |||
| 140 | + | if a == nil { | |||
| 141 | + | return nil | |||
| 142 | + | } | |||
| 143 | + | return &jsonAutoMerge{Mode: modeName(a.GetMode()), HeadID: a.GetHeadId(), EnabledBy: a.GetEnabledBy(), EnabledAt: jsonTime(a.GetEnabledAt())} | |||
| 124 | } | 144 | } | ||
| 125 | 145 | ||||
| 126 | // jsonMergeability is the stable JSON shape of the mergeability of an open | 146 | // jsonMergeability is the stable JSON shape of the mergeability of an open | ||
| @@ -201,6 +221,7 @@ func toJSONMergeRequest(m *ermite.MergeRequest, baseURL string) jsonMergeRequest | |||||
| 201 | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | 221 | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | ||
| 202 | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | 222 | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | ||
| 203 | HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()), | 223 | HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()), | ||
| 224 | + | AutoMerge: toJSONAutoMerge(m.GetAutoMerge()), AutoMerged: m.GetAutoMerged(), | |||
| 204 | } | 225 | } | ||
| 205 | } | 226 | } | ||
| 206 | 227 | ||||
| @@ -245,7 +266,8 @@ JSON fields of a merge request (--json): repo, url (full web URL), number, title | |||||
| 245 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | 266 | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | ||
| 246 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | 267 | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | ||
| 247 | branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty | 268 | branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty | ||
| 248 | - | unless merged).` | 269 | + | unless merged), auto_merge (null unless auto-merge is pending: mode, head_id, enabled_by, |
| 270 | + | enabled_at) and auto_merged (true when the server merged the request by auto-merge).` | |||
| 249 | 271 | ||||
| 250 | const jsonMergeabilityHelp = ` | 272 | const jsonMergeabilityHelp = ` | ||
| 251 | mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date", | 273 | mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date", | ||
| @@ -660,7 +682,14 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin | |||||
| 660 | {"head", orDash(m.GetHeadId())}, | 682 | {"head", orDash(m.GetHeadId())}, | ||
| 661 | } | 683 | } | ||
| 662 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | 684 | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | ||
| 663 | - | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | 685 | + | merged := orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")" |
| 686 | + | pairs = append(pairs, [2]string{"merged", merged}) | |||
| 687 | + | if m.GetAutoMerged() { | |||
| 688 | + | pairs = append(pairs, [2]string{"auto-merged", "yes, by the server once the checks passed"}) | |||
| 689 | + | } | |||
| 690 | + | } | |||
| 691 | + | if am := m.GetAutoMerge(); am != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | |||
| 692 | + | pairs = append(pairs, [2]string{"auto-merge", modeName(am.GetMode()) + " when ready (enabled by " + am.GetEnabledBy() + ")"}) | |||
| 664 | } | 693 | } | ||
| 665 | if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | 694 | if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | ||
| 666 | pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)}) | 695 | pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)}) | ||
| @@ -1056,6 +1085,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1056 | var mode string | 1085 | var mode string | ||
| 1057 | var deleteBranch bool | 1086 | var deleteBranch bool | ||
| 1058 | var head string | 1087 | var head string | ||
| 1088 | + | var auto, cancelAuto bool | |||
| 1059 | cmd := &cobra.Command{ | 1089 | cmd := &cobra.Command{ | ||
| 1060 | Use: "merge <repo> <number>", | 1090 | Use: "merge <repo> <number>", | ||
| 1061 | Short: "Merge a merge request", | 1091 | Short: "Merge a merge request", | ||
| @@ -1078,7 +1108,12 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1078 | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + | 1108 | "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" + | ||
| 1079 | "the source branch right after, in the background. The merge is refused when the branch is the\n" + | 1109 | "the source branch right after, in the background. The merge is refused when the branch is the\n" + | ||
| 1080 | "default branch or used by another open merge request. If the deletion later fails (the branch\n" + | 1110 | "default branch or used by another open merge request. If the deletion later fails (the branch\n" + | ||
| 1081 | - | "moved, say), the merge stands and the branch is kept." + jsonMRHelp + | 1111 | + | "moved, say), the merge stands and the branch is kept.\n\n" + |
| 1112 | + | "--auto does not merge now: it asks the server to merge by itself, with --mode, once CI has settled\n" + | |||
| 1113 | + | "with at least one successful check on the head and every check allows it. Needs the mr:merge scope,\n" + | |||
| 1114 | + | "a server with CI, and `ermite repo edit --allow-auto-merge` on the repository. A new push to the\n" + | |||
| 1115 | + | "source branch, a change of the settings or of your permissions cancels it. --auto takes no --head:\n" + | |||
| 1116 | + | "the server merges the head current when you enable it. --cancel-auto cancels a pending auto-merge." + jsonMRHelp + | |||
| 1082 | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | 1117 | "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).", | ||
| 1083 | Args: cobra.ExactArgs(2), | 1118 | Args: cobra.ExactArgs(2), | ||
| 1084 | ValidArgsFunction: completeMRRepoThenNumber(env), | 1119 | ValidArgsFunction: completeMRRepoThenNumber(env), | ||
| @@ -1086,6 +1121,13 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1086 | cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)") | 1121 | cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)") | ||
| 1087 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | 1122 | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)") | ||
| 1088 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | 1123 | cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge") | ||
| 1124 | + | cmd.Flags().BoolVar(&auto, "auto", false, "merge by itself once the checks passed (needs `repo edit --allow-auto-merge`)") | |||
| 1125 | + | cmd.Flags().BoolVar(&cancelAuto, "cancel-auto", false, "cancel the pending auto-merge") | |||
| 1126 | + | cmd.MarkFlagsMutuallyExclusive("auto", "cancel-auto") | |||
| 1127 | + | cmd.MarkFlagsMutuallyExclusive("cancel-auto", "delete-branch") | |||
| 1128 | + | cmd.MarkFlagsMutuallyExclusive("cancel-auto", "head") | |||
| 1129 | + | cmd.MarkFlagsMutuallyExclusive("auto", "delete-branch") | |||
| 1130 | + | cmd.MarkFlagsMutuallyExclusive("auto", "head") | |||
| 1089 | _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 1131 | _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | ||
| 1090 | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | 1132 | return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp | ||
| 1091 | }) | 1133 | }) | ||
| @@ -1095,10 +1137,16 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1095 | if err != nil { | 1137 | if err != nil { | ||
| 1096 | return err | 1138 | return err | ||
| 1097 | } | 1139 | } | ||
| 1140 | + | if cancelAuto { | |||
| 1141 | + | return runMRCancelAuto(env, cmd, args[0], n, *asJSON) | |||
| 1142 | + | } | |||
| 1098 | pbMode, err := parseMode(mode) | 1143 | pbMode, err := parseMode(mode) | ||
| 1099 | if err != nil { | 1144 | if err != nil { | ||
| 1100 | return err | 1145 | return err | ||
| 1101 | } | 1146 | } | ||
| 1147 | + | if auto { | |||
| 1148 | + | return runMREnableAuto(env, cmd, args[0], n, pbMode, *asJSON) | |||
| 1149 | + | } | |||
| 1102 | head = strings.TrimSpace(head) | 1150 | head = strings.TrimSpace(head) | ||
| 1103 | if head == "" { | 1151 | if head == "" { | ||
| 1104 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | 1152 | return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+ | ||
| @@ -1139,6 +1187,51 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||||
| 1139 | return cmd | 1187 | return cmd | ||
| 1140 | } | 1188 | } | ||
| 1141 | 1189 | ||||
| 1190 | + | // runMREnableAuto is `mr merge --auto`: it asks the server to merge once ready. | |||
| 1191 | + | func runMREnableAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, mode ermite.MergeMode, asJSON bool) error { | |||
| 1192 | + | c, err := env.client(cmd) | |||
| 1193 | + | if err != nil { | |||
| 1194 | + | return err | |||
| 1195 | + | } | |||
| 1196 | + | ctx := env.ctx(cmd) | |||
| 1197 | + | if err := requireFeature(ctx, c, client.FeatureAutoMerge, "cannot merge by itself (--auto)"); err != nil { | |||
| 1198 | + | return err | |||
| 1199 | + | } | |||
| 1200 | + | res, err := c.MergeRequests().EnableAutoMerge(ctx, connect.NewRequest(&ermite.EnableAutoMergeRequest{Repo: repo, Number: n, Mode: mode})) | |||
| 1201 | + | if err != nil { | |||
| 1202 | + | return c.Describe(err) | |||
| 1203 | + | } | |||
| 1204 | + | m := res.Msg.GetMergeRequest() | |||
| 1205 | + | if asJSON { | |||
| 1206 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL())) | |||
| 1207 | + | } | |||
| 1208 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge enabled on %s!%d: %s into %s when ready (enabled by %s)\n", clean(m.GetRepo()), m.GetNumber(), | |||
| 1209 | + | modeName(m.GetAutoMerge().GetMode()), clean(m.GetTargetBranch()), clean(m.GetAutoMerge().GetEnabledBy())) | |||
| 1210 | + | return err | |||
| 1211 | + | } | |||
| 1212 | + | ||||
| 1213 | + | // runMRCancelAuto is `mr merge --cancel-auto`. | |||
| 1214 | + | func runMRCancelAuto(env *cliEnv, cmd *cobra.Command, repo string, n int64, asJSON bool) error { | |||
| 1215 | + | c, err := env.client(cmd) | |||
| 1216 | + | if err != nil { | |||
| 1217 | + | return err | |||
| 1218 | + | } | |||
| 1219 | + | ctx := env.ctx(cmd) | |||
| 1220 | + | if err := requireFeature(ctx, c, client.FeatureAutoMerge, "has no auto-merge to cancel (--cancel-auto)"); err != nil { | |||
| 1221 | + | return err | |||
| 1222 | + | } | |||
| 1223 | + | res, err := c.MergeRequests().CancelAutoMerge(ctx, connect.NewRequest(&ermite.CancelAutoMergeRequest{Repo: repo, Number: n})) | |||
| 1224 | + | if err != nil { | |||
| 1225 | + | return c.Describe(err) | |||
| 1226 | + | } | |||
| 1227 | + | m := res.Msg.GetMergeRequest() | |||
| 1228 | + | if asJSON { | |||
| 1229 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m, c.URL())) | |||
| 1230 | + | } | |||
| 1231 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "auto-merge cancelled on %s!%d\n", clean(m.GetRepo()), m.GetNumber()) | |||
| 1232 | + | return err | |||
| 1233 | + | } | |||
| 1234 | + | ||||
| 1142 | // signedNote says that a squash or merge commit is signed by the server's | 1235 | // signedNote says that a squash or merge commit is signed by the server's | ||
| 1143 | // key, with its fingerprint when the server tells it. | 1236 | // key, with its fingerprint when the server tells it. | ||
| 1144 | func signedNote(ctx context.Context, c *client.Client) string { | 1237 | func signedNote(ctx context.Context, c *client.Client) string { | ||
M internal/app/cli_mr_e2e_test.go +1 -1
| @@ -33,7 +33,7 @@ type mrOut struct { | |||
| 33 | 33 | MergeMode string `json:"merge_mode"` | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | - | var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"} | |
| 36 | + | var mrKeys = []string{"author", "auto_merge", "auto_merged", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"} | |
| 37 | 37 | ||
| 38 | 38 | type commentOut struct { | |
| 39 | 39 | ID string `json:"id"` | |
| @@ -33,7 +33,7 @@ type mrOut struct { | |||||
| 33 | MergeMode string `json:"merge_mode"` | 33 | MergeMode string `json:"merge_mode"` | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | - | var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"} | 36 | + | var mrKeys = []string{"author", "auto_merge", "auto_merged", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"} |
| 37 | 37 | ||||
| 38 | type commentOut struct { | 38 | type commentOut struct { | ||
| 39 | ID string `json:"id"` | 39 | ID string `json:"id"` | ||
M internal/app/cli_mr_test.go +111 -1
| @@ -51,7 +51,7 @@ func TestMRCreateMapsFlags(t *testing.T) { | |||
| 51 | 51 | _ = res | |
| 52 | 52 | ||
| 53 | 53 | res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t) | |
| 54 | - | assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout) | |
| 54 | + | assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":"","auto_merge":null,"auto_merged":false}`, res.stdout) | |
| 55 | 55 | ||
| 56 | 56 | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | |
| 57 | 57 | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |
| @@ -578,3 +578,113 @@ func TestJSONFileOmitted(t *testing.T) { | |||
| 578 | 578 | assert.Contains(t, string(raw), `"truncated":true`) | |
| 579 | 579 | assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`)) | |
| 580 | 580 | } | |
| 581 | + | ||
| 582 | + | func autoMergeFake(t *testing.T, features ...string) (*fakeAPI, string) { | |
| 583 | + | t.Helper() | |
| 584 | + | auto := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 585 | + | auto.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")} | |
| 586 | + | f := &fakeAPI{ | |
| 587 | + | enableAuto: func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error) { | |
| 588 | + | return &ermite.EnableAutoMergeResponse{MergeRequest: auto}, nil | |
| 589 | + | }, | |
| 590 | + | cancelAuto: func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error) { | |
| 591 | + | return &ermite.CancelAutoMergeResponse{MergeRequest: mrOne}, nil | |
| 592 | + | }, | |
| 593 | + | getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 594 | + | return &ermite.GetVersionResponse{Version: "test", Features: features}, nil | |
| 595 | + | }, | |
| 596 | + | } | |
| 597 | + | return f, f.serve(t) | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | func TestMRMergeAuto(t *testing.T) { | |
| 601 | + | f, url := autoMergeFake(t, "merge_requests.auto_merge") | |
| 602 | + | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--mode", "squash").ok(t) | |
| 603 | + | assert.Equal(t, "auto-merge enabled on alpha!1: squash into main when ready (enabled by owner)\n", res.stdout) | |
| 604 | + | calls, reqs := f.snapshot() | |
| 605 | + | assert.Equal(t, []string{"GetVersion", "EnableAutoMerge"}, calls) | |
| 606 | + | got := reqs[1].(*ermite.EnableAutoMergeRequest) | |
| 607 | + | assert.Equal(t, "alpha", got.GetRepo()) | |
| 608 | + | assert.EqualValues(t, 1, got.GetNumber()) | |
| 609 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, got.GetMode()) | |
| 610 | + | ||
| 611 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--json").ok(t) | |
| 612 | + | var out struct { | |
| 613 | + | AutoMerge struct { | |
| 614 | + | Mode string `json:"mode"` | |
| 615 | + | HeadID string `json:"head_id"` | |
| 616 | + | EnabledBy string `json:"enabled_by"` | |
| 617 | + | EnabledAt string `json:"enabled_at"` | |
| 618 | + | } `json:"auto_merge"` | |
| 619 | + | } | |
| 620 | + | require.NoError(t, json.Unmarshal([]byte(res.stdout), &out)) | |
| 621 | + | assert.Equal(t, "squash", out.AutoMerge.Mode) | |
| 622 | + | assert.Equal(t, headA, out.AutoMerge.HeadID) | |
| 623 | + | assert.Equal(t, "owner", out.AutoMerge.EnabledBy) | |
| 624 | + | assert.Equal(t, "2026-10-03T08:00:00Z", out.AutoMerge.EnabledAt) | |
| 625 | + | _, reqs = f.snapshot() | |
| 626 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, reqs[len(reqs)-1].(*ermite.EnableAutoMergeRequest).GetMode(), "fast-forward by default") | |
| 627 | + | ||
| 628 | + | for _, args := range [][]string{ | |
| 629 | + | {"--auto", "--cancel-auto"}, {"--auto", "--delete-branch"}, {"--auto", "--head", headA}, | |
| 630 | + | } { | |
| 631 | + | require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "merge", "alpha", "1"}, args...)...).err, "%v", args) | |
| 632 | + | } | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | func TestMRMergeAutoNeedsTheFeature(t *testing.T) { | |
| 636 | + | f, url := autoMergeFake(t, "merge_requests") | |
| 637 | + | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto") | |
| 638 | + | require.Error(t, res.err) | |
| 639 | + | assert.Contains(t, res.err.Error(), "merge_requests.auto_merge") | |
| 640 | + | calls, _ := f.snapshot() | |
| 641 | + | assert.NotContains(t, calls, "EnableAutoMerge") | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | func TestMRMergeCancelAuto(t *testing.T) { | |
| 645 | + | f, url := autoMergeFake(t, "merge_requests.auto_merge") | |
| 646 | + | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto").ok(t) | |
| 647 | + | assert.Equal(t, "auto-merge cancelled on alpha!1\n", res.stdout) | |
| 648 | + | calls, reqs := f.snapshot() | |
| 649 | + | assert.Equal(t, []string{"GetVersion", "CancelAutoMerge"}, calls) | |
| 650 | + | assert.EqualValues(t, 1, reqs[1].(*ermite.CancelAutoMergeRequest).GetNumber()) | |
| 651 | + | ||
| 652 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", "--json").ok(t) | |
| 653 | + | assert.Regexp(t, `"auto_merge":\s*null`, res.stdout) | |
| 654 | + | ||
| 655 | + | for _, extra := range []string{"--delete-branch", "--head=" + headA} { | |
| 656 | + | require.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", extra).err, extra) | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | func TestMRShowAutoMerge(t *testing.T) { | |
| 661 | + | m := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 662 | + | m.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")} | |
| 663 | + | cur := m | |
| 664 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |
| 665 | + | return &ermite.GetMergeRequestResponse{MergeRequest: cur}, nil | |
| 666 | + | }} | |
| 667 | + | url := f.serve(t) | |
| 668 | + | ||
| 669 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 670 | + | assert.Regexp(t, `(?m)^auto-merge\s+squash when ready \(enabled by owner\)$`, res.stdout) | |
| 671 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |
| 672 | + | assert.Contains(t, res.stdout, `"mode": "squash"`) | |
| 673 | + | assert.Contains(t, res.stdout, `"enabled_by": "owner"`) | |
| 674 | + | assert.Contains(t, res.stdout, `"auto_merged": false`) | |
| 675 | + | ||
| 676 | + | merged := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 677 | + | merged.State, merged.AutoMerged = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, true | |
| 678 | + | merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH | |
| 679 | + | cur = merged | |
| 680 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 681 | + | assert.Regexp(t, `(?m)^auto-merged\s+yes`, res.stdout) | |
| 682 | + | assert.NotContains(t, res.stdout, "when ready") | |
| 683 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |
| 684 | + | assert.Contains(t, res.stdout, `"auto_merged": true`) | |
| 685 | + | assert.Regexp(t, `"auto_merge":\s*null`, res.stdout) | |
| 686 | + | ||
| 687 | + | cur = mrOne | |
| 688 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 689 | + | assert.NotContains(t, res.stdout, "auto-merge") | |
| 690 | + | } | |
| @@ -51,7 +51,7 @@ func TestMRCreateMapsFlags(t *testing.T) { | |||||
| 51 | _ = res | 51 | _ = res | ||
| 52 | 52 | ||||
| 53 | res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t) | 53 | res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t) | ||
| 54 | - | assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout) | 54 | + | assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":"","auto_merge":null,"auto_merged":false}`, res.stdout) |
| 55 | 55 | ||||
| 56 | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | 56 | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | ||
| 57 | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | 57 | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | ||
| @@ -578,3 +578,113 @@ func TestJSONFileOmitted(t *testing.T) { | |||||
| 578 | assert.Contains(t, string(raw), `"truncated":true`) | 578 | assert.Contains(t, string(raw), `"truncated":true`) | ||
| 579 | assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`)) | 579 | assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`)) | ||
| 580 | } | 580 | } | ||
| 581 | + | ||||
| 582 | + | func autoMergeFake(t *testing.T, features ...string) (*fakeAPI, string) { | |||
| 583 | + | t.Helper() | |||
| 584 | + | auto := proto.Clone(mrOne).(*ermite.MergeRequest) | |||
| 585 | + | auto.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")} | |||
| 586 | + | f := &fakeAPI{ | |||
| 587 | + | enableAuto: func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error) { | |||
| 588 | + | return &ermite.EnableAutoMergeResponse{MergeRequest: auto}, nil | |||
| 589 | + | }, | |||
| 590 | + | cancelAuto: func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error) { | |||
| 591 | + | return &ermite.CancelAutoMergeResponse{MergeRequest: mrOne}, nil | |||
| 592 | + | }, | |||
| 593 | + | getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |||
| 594 | + | return &ermite.GetVersionResponse{Version: "test", Features: features}, nil | |||
| 595 | + | }, | |||
| 596 | + | } | |||
| 597 | + | return f, f.serve(t) | |||
| 598 | + | } | |||
| 599 | + | ||||
| 600 | + | func TestMRMergeAuto(t *testing.T) { | |||
| 601 | + | f, url := autoMergeFake(t, "merge_requests.auto_merge") | |||
| 602 | + | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--mode", "squash").ok(t) | |||
| 603 | + | assert.Equal(t, "auto-merge enabled on alpha!1: squash into main when ready (enabled by owner)\n", res.stdout) | |||
| 604 | + | calls, reqs := f.snapshot() | |||
| 605 | + | assert.Equal(t, []string{"GetVersion", "EnableAutoMerge"}, calls) | |||
| 606 | + | got := reqs[1].(*ermite.EnableAutoMergeRequest) | |||
| 607 | + | assert.Equal(t, "alpha", got.GetRepo()) | |||
| 608 | + | assert.EqualValues(t, 1, got.GetNumber()) | |||
| 609 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, got.GetMode()) | |||
| 610 | + | ||||
| 611 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto", "--json").ok(t) | |||
| 612 | + | var out struct { | |||
| 613 | + | AutoMerge struct { | |||
| 614 | + | Mode string `json:"mode"` | |||
| 615 | + | HeadID string `json:"head_id"` | |||
| 616 | + | EnabledBy string `json:"enabled_by"` | |||
| 617 | + | EnabledAt string `json:"enabled_at"` | |||
| 618 | + | } `json:"auto_merge"` | |||
| 619 | + | } | |||
| 620 | + | require.NoError(t, json.Unmarshal([]byte(res.stdout), &out)) | |||
| 621 | + | assert.Equal(t, "squash", out.AutoMerge.Mode) | |||
| 622 | + | assert.Equal(t, headA, out.AutoMerge.HeadID) | |||
| 623 | + | assert.Equal(t, "owner", out.AutoMerge.EnabledBy) | |||
| 624 | + | assert.Equal(t, "2026-10-03T08:00:00Z", out.AutoMerge.EnabledAt) | |||
| 625 | + | _, reqs = f.snapshot() | |||
| 626 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, reqs[len(reqs)-1].(*ermite.EnableAutoMergeRequest).GetMode(), "fast-forward by default") | |||
| 627 | + | ||||
| 628 | + | for _, args := range [][]string{ | |||
| 629 | + | {"--auto", "--cancel-auto"}, {"--auto", "--delete-branch"}, {"--auto", "--head", headA}, | |||
| 630 | + | } { | |||
| 631 | + | require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "merge", "alpha", "1"}, args...)...).err, "%v", args) | |||
| 632 | + | } | |||
| 633 | + | } | |||
| 634 | + | ||||
| 635 | + | func TestMRMergeAutoNeedsTheFeature(t *testing.T) { | |||
| 636 | + | f, url := autoMergeFake(t, "merge_requests") | |||
| 637 | + | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--auto") | |||
| 638 | + | require.Error(t, res.err) | |||
| 639 | + | assert.Contains(t, res.err.Error(), "merge_requests.auto_merge") | |||
| 640 | + | calls, _ := f.snapshot() | |||
| 641 | + | assert.NotContains(t, calls, "EnableAutoMerge") | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | func TestMRMergeCancelAuto(t *testing.T) { | |||
| 645 | + | f, url := autoMergeFake(t, "merge_requests.auto_merge") | |||
| 646 | + | res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto").ok(t) | |||
| 647 | + | assert.Equal(t, "auto-merge cancelled on alpha!1\n", res.stdout) | |||
| 648 | + | calls, reqs := f.snapshot() | |||
| 649 | + | assert.Equal(t, []string{"GetVersion", "CancelAutoMerge"}, calls) | |||
| 650 | + | assert.EqualValues(t, 1, reqs[1].(*ermite.CancelAutoMergeRequest).GetNumber()) | |||
| 651 | + | ||||
| 652 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", "--json").ok(t) | |||
| 653 | + | assert.Regexp(t, `"auto_merge":\s*null`, res.stdout) | |||
| 654 | + | ||||
| 655 | + | for _, extra := range []string{"--delete-branch", "--head=" + headA} { | |||
| 656 | + | require.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--cancel-auto", extra).err, extra) | |||
| 657 | + | } | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | func TestMRShowAutoMerge(t *testing.T) { | |||
| 661 | + | m := proto.Clone(mrOne).(*ermite.MergeRequest) | |||
| 662 | + | m.AutoMerge = &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner", EnabledAt: ts("2026-10-03T08:00:00Z")} | |||
| 663 | + | cur := m | |||
| 664 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |||
| 665 | + | return &ermite.GetMergeRequestResponse{MergeRequest: cur}, nil | |||
| 666 | + | }} | |||
| 667 | + | url := f.serve(t) | |||
| 668 | + | ||||
| 669 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |||
| 670 | + | assert.Regexp(t, `(?m)^auto-merge\s+squash when ready \(enabled by owner\)$`, res.stdout) | |||
| 671 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |||
| 672 | + | assert.Contains(t, res.stdout, `"mode": "squash"`) | |||
| 673 | + | assert.Contains(t, res.stdout, `"enabled_by": "owner"`) | |||
| 674 | + | assert.Contains(t, res.stdout, `"auto_merged": false`) | |||
| 675 | + | ||||
| 676 | + | merged := proto.Clone(mrOne).(*ermite.MergeRequest) | |||
| 677 | + | merged.State, merged.AutoMerged = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, true | |||
| 678 | + | merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH | |||
| 679 | + | cur = merged | |||
| 680 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |||
| 681 | + | assert.Regexp(t, `(?m)^auto-merged\s+yes`, res.stdout) | |||
| 682 | + | assert.NotContains(t, res.stdout, "when ready") | |||
| 683 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |||
| 684 | + | assert.Contains(t, res.stdout, `"auto_merged": true`) | |||
| 685 | + | assert.Regexp(t, `"auto_merge":\s*null`, res.stdout) | |||
| 686 | + | ||||
| 687 | + | cur = mrOne | |||
| 688 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |||
| 689 | + | assert.NotContains(t, res.stdout, "auto-merge") | |||
| 690 | + | } | |||
M internal/app/cli_repo.go +17 -6
| @@ -36,6 +36,7 @@ type jsonRepo struct { | |||
| 36 | 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge"). | |
| 37 | 37 | MergeModes []string `json:"merge_modes"` | |
| 38 | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 39 | + | AllowAutoMerge bool `json:"allow_auto_merge"` | |
| 39 | 40 | // Issues is the issue mode ("off", "owner" or "public"). | |
| 40 | 41 | Issues string `json:"issues"` | |
| 41 | 42 | } | |
| @@ -48,6 +49,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||
| 48 | 49 | return jsonRepo{ | |
| 49 | 50 | MergeModes: modes, | |
| 50 | 51 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | |
| 52 | + | AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(), | |
| 51 | 53 | Issues: issuesModeName(r.GetSettings().GetIssues()), | |
| 52 | 54 | Name: r.GetName(), | |
| 53 | 55 | Description: r.GetDescription(), | |
| @@ -115,8 +117,8 @@ const jsonRepoHelp = ` | |||
| 115 | 117 | JSON fields of a repository (--json): name, description, visibility ("public" or | |
| 116 | 118 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | |
| 117 | 119 | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or | |
| 118 | - | "merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | |
| 119 | - | mode: "off", "owner" or "public").` | |
| 120 | + | "merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether | |
| 121 | + | auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").` | |
| 120 | 122 | ||
| 121 | 123 | func newRepoCmd(env *cliEnv) *cobra.Command { | |
| 122 | 124 | repo := &cobra.Command{ | |
| @@ -229,6 +231,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||
| 229 | 231 | {"last push", shortTime(r.GetPushedAt(), "never")}, | |
| 230 | 232 | {"merge modes", orDash(strings.Join(modes, ", "))}, | |
| 231 | 233 | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | |
| 234 | + | {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())}, | |
| 232 | 235 | {"issues", issuesModeName(r.GetSettings().GetIssues())}, | |
| 233 | 236 | }) | |
| 234 | 237 | } | |
| @@ -308,7 +311,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin | |||
| 308 | 311 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | |
| 309 | 312 | var description, visibility, defaultBranch string | |
| 310 | 313 | var topics, mergeModes []string | |
| 311 | - | var failingChecksBlock bool | |
| 314 | + | var failingChecksBlock, allowAutoMerge bool | |
| 312 | 315 | var issues string | |
| 313 | 316 | cmd := &cobra.Command{ | |
| 314 | 317 | Use: "edit <name>", | |
| @@ -331,6 +334,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 331 | 334 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 332 | 335 | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)") | |
| 333 | 336 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | |
| 337 | + | cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)") | |
| 334 | 338 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | |
| 335 | 339 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 336 | 340 | return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp | |
| @@ -341,7 +345,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 341 | 345 | }) | |
| 342 | 346 | asJSON := addJSONFlag(cmd) | |
| 343 | 347 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 344 | - | req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues) | |
| 348 | + | req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues) | |
| 345 | 349 | if err != nil { | |
| 346 | 350 | return err | |
| 347 | 351 | } | |
| @@ -365,7 +369,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 365 | 369 | ||
| 366 | 370 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | |
| 367 | 371 | // the user passed go into the update mask. | |
| 368 | - | func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) { | |
| 372 | + | func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) { | |
| 369 | 373 | req := &ermite.UpdateRepoRequest{Repo: name} | |
| 370 | 374 | var paths []string | |
| 371 | 375 | if cmd.Flags().Changed("description") { | |
| @@ -414,6 +418,13 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 414 | 418 | req.Settings.FailingChecksBlock = failingChecksBlock | |
| 415 | 419 | paths = append(paths, "settings.failing_checks_block") | |
| 416 | 420 | } | |
| 421 | + | if cmd.Flags().Changed("allow-auto-merge") { | |
| 422 | + | if req.Settings == nil { | |
| 423 | + | req.Settings = &ermite.RepoSettings{} | |
| 424 | + | } | |
| 425 | + | req.Settings.AllowAutoMerge = allowAutoMerge | |
| 426 | + | paths = append(paths, "settings.allow_auto_merge") | |
| 427 | + | } | |
| 417 | 428 | if cmd.Flags().Changed("issues") { | |
| 418 | 429 | mode, err := parseIssuesMode(issues) | |
| 419 | 430 | if err != nil { | |
| @@ -423,7 +434,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||
| 423 | 434 | req.Issues = &mode | |
| 424 | 435 | } | |
| 425 | 436 | if len(paths) == 0 && req.Issues == nil { | |
| 426 | - | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues") | |
| 437 | + | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --allow-auto-merge, --issues") | |
| 427 | 438 | } | |
| 428 | 439 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | |
| 429 | 440 | return req, nil | |
| @@ -36,6 +36,7 @@ type jsonRepo struct { | |||||
| 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge"). | 36 | // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge"). | ||
| 37 | MergeModes []string `json:"merge_modes"` | 37 | MergeModes []string `json:"merge_modes"` | ||
| 38 | FailingChecksBlock bool `json:"failing_checks_block"` | 38 | FailingChecksBlock bool `json:"failing_checks_block"` | ||
| 39 | + | AllowAutoMerge bool `json:"allow_auto_merge"` | |||
| 39 | // Issues is the issue mode ("off", "owner" or "public"). | 40 | // Issues is the issue mode ("off", "owner" or "public"). | ||
| 40 | Issues string `json:"issues"` | 41 | Issues string `json:"issues"` | ||
| 41 | } | 42 | } | ||
| @@ -48,6 +49,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo { | |||||
| 48 | return jsonRepo{ | 49 | return jsonRepo{ | ||
| 49 | MergeModes: modes, | 50 | MergeModes: modes, | ||
| 50 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | 51 | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | ||
| 52 | + | AllowAutoMerge: r.GetSettings().GetAllowAutoMerge(), | |||
| 51 | Issues: issuesModeName(r.GetSettings().GetIssues()), | 53 | Issues: issuesModeName(r.GetSettings().GetIssues()), | ||
| 52 | Name: r.GetName(), | 54 | Name: r.GetName(), | ||
| 53 | Description: r.GetDescription(), | 55 | Description: r.GetDescription(), | ||
| @@ -115,8 +117,8 @@ const jsonRepoHelp = ` | |||||
| 115 | JSON fields of a repository (--json): name, description, visibility ("public" or | 117 | JSON fields of a repository (--json): name, description, visibility ("public" or | ||
| 116 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | 118 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | ||
| 117 | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or | 119 | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or | ||
| 118 | - | "merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue | 120 | + | "merge"), failing_checks_block (whether a failing check prevents a merge), allow_auto_merge (whether |
| 119 | - | mode: "off", "owner" or "public").` | 121 | + | auto-merge can be enabled) and issues (the issue mode: "off", "owner" or "public").` |
| 120 | 122 | ||||
| 121 | func newRepoCmd(env *cliEnv) *cobra.Command { | 123 | func newRepoCmd(env *cliEnv) *cobra.Command { | ||
| 122 | repo := &cobra.Command{ | 124 | repo := &cobra.Command{ | ||
| @@ -229,6 +231,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||||
| 229 | {"last push", shortTime(r.GetPushedAt(), "never")}, | 231 | {"last push", shortTime(r.GetPushedAt(), "never")}, | ||
| 230 | {"merge modes", orDash(strings.Join(modes, ", "))}, | 232 | {"merge modes", orDash(strings.Join(modes, ", "))}, | ||
| 231 | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | 233 | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | ||
| 234 | + | {"allow auto-merge", strconv.FormatBool(r.GetSettings().GetAllowAutoMerge())}, | |||
| 232 | {"issues", issuesModeName(r.GetSettings().GetIssues())}, | 235 | {"issues", issuesModeName(r.GetSettings().GetIssues())}, | ||
| 233 | }) | 236 | }) | ||
| 234 | } | 237 | } | ||
| @@ -308,7 +311,7 @@ func buildCreateRepo(name, description string, topics []string, visibility strin | |||||
| 308 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | 311 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | ||
| 309 | var description, visibility, defaultBranch string | 312 | var description, visibility, defaultBranch string | ||
| 310 | var topics, mergeModes []string | 313 | var topics, mergeModes []string | ||
| 311 | - | var failingChecksBlock bool | 314 | + | var failingChecksBlock, allowAutoMerge bool |
| 312 | var issues string | 315 | var issues string | ||
| 313 | cmd := &cobra.Command{ | 316 | cmd := &cobra.Command{ | ||
| 314 | Use: "edit <name>", | 317 | Use: "edit <name>", | ||
| @@ -331,6 +334,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 331 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | 334 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | ||
| 332 | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)") | 335 | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)") | ||
| 333 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | 336 | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | ||
| 337 | + | cmd.Flags().BoolVar(&allowAutoMerge, "allow-auto-merge", false, "allow auto-merge on merge requests (--allow-auto-merge=false to forbid it)") | |||
| 334 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | 338 | cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public") | ||
| 335 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | 339 | _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | ||
| 336 | return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp | 340 | return []string{issuesOff, issuesOwner, issuesPublic}, cobra.ShellCompDirectiveNoFileComp | ||
| @@ -341,7 +345,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 341 | }) | 345 | }) | ||
| 342 | asJSON := addJSONFlag(cmd) | 346 | asJSON := addJSONFlag(cmd) | ||
| 343 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 347 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 344 | - | req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, issues) | 348 | + | req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock, allowAutoMerge, issues) |
| 345 | if err != nil { | 349 | if err != nil { | ||
| 346 | return err | 350 | return err | ||
| 347 | } | 351 | } | ||
| @@ -365,7 +369,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 365 | 369 | ||||
| 366 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | 370 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | ||
| 367 | // the user passed go into the update mask. | 371 | // the user passed go into the update mask. | ||
| 368 | - | func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool, issues string) (*ermite.UpdateRepoRequest, error) { | 372 | + | func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock, allowAutoMerge bool, issues string) (*ermite.UpdateRepoRequest, error) { |
| 369 | req := &ermite.UpdateRepoRequest{Repo: name} | 373 | req := &ermite.UpdateRepoRequest{Repo: name} | ||
| 370 | var paths []string | 374 | var paths []string | ||
| 371 | if cmd.Flags().Changed("description") { | 375 | if cmd.Flags().Changed("description") { | ||
| @@ -414,6 +418,13 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||||
| 414 | req.Settings.FailingChecksBlock = failingChecksBlock | 418 | req.Settings.FailingChecksBlock = failingChecksBlock | ||
| 415 | paths = append(paths, "settings.failing_checks_block") | 419 | paths = append(paths, "settings.failing_checks_block") | ||
| 416 | } | 420 | } | ||
| 421 | + | if cmd.Flags().Changed("allow-auto-merge") { | |||
| 422 | + | if req.Settings == nil { | |||
| 423 | + | req.Settings = &ermite.RepoSettings{} | |||
| 424 | + | } | |||
| 425 | + | req.Settings.AllowAutoMerge = allowAutoMerge | |||
| 426 | + | paths = append(paths, "settings.allow_auto_merge") | |||
| 427 | + | } | |||
| 417 | if cmd.Flags().Changed("issues") { | 428 | if cmd.Flags().Changed("issues") { | ||
| 418 | mode, err := parseIssuesMode(issues) | 429 | mode, err := parseIssuesMode(issues) | ||
| 419 | if err != nil { | 430 | if err != nil { | ||
| @@ -423,7 +434,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri | |||||
| 423 | req.Issues = &mode | 434 | req.Issues = &mode | ||
| 424 | } | 435 | } | ||
| 425 | if len(paths) == 0 && req.Issues == nil { | 436 | if len(paths) == 0 && req.Issues == nil { | ||
| 426 | - | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --issues") | 437 | + | return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block, --allow-auto-merge, --issues") |
| 427 | } | 438 | } | ||
| 428 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | 439 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | ||
| 429 | return req, nil | 440 | return req, nil | ||
M internal/app/cli_repo_test.go +34 -4
| @@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) { | |||
| 54 | 54 | { | |
| 55 | 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | |
| 56 | 56 | wantStdout: `[ | |
| 57 | - | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}, | |
| 58 | - | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"} | |
| 57 | + | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}, | |
| 58 | + | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"} | |
| 59 | 59 | ]`, | |
| 60 | 60 | }, | |
| 61 | 61 | { | |
| @@ -116,12 +116,13 @@ func TestRepoShow(t *testing.T) { | |||
| 116 | 116 | "last push 2026-03-04 05:06\n"+ | |
| 117 | 117 | "merge modes fast-forward, squash\n"+ | |
| 118 | 118 | "failing checks block true\n"+ | |
| 119 | + | "allow auto-merge false\n"+ | |
| 119 | 120 | "issues off\n", res.stdout) | |
| 120 | 121 | _, reqs := f.snapshot() | |
| 121 | 122 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | |
| 122 | 123 | ||
| 123 | 124 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | |
| 124 | - | assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}`, res.stdout) | |
| 125 | + | assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}`, res.stdout) | |
| 125 | 126 | } | |
| 126 | 127 | ||
| 127 | 128 | func TestRepoShowNeedsOneArg(t *testing.T) { | |
| @@ -177,7 +178,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||
| 177 | 178 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | |
| 178 | 179 | }} | |
| 179 | 180 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | |
| 180 | - | assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}`, res.stdout) | |
| 181 | + | assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}`, res.stdout) | |
| 181 | 182 | } | |
| 182 | 183 | ||
| 183 | 184 | func TestRepoEditMask(t *testing.T) { | |
| @@ -215,6 +216,13 @@ func TestRepoEditMask(t *testing.T) { | |||
| 215 | 216 | {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 216 | 217 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | |
| 217 | 218 | }}, | |
| 219 | + | {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 220 | + | assert.True(t, r.GetSettings().GetAllowAutoMerge()) | |
| 221 | + | assert.False(t, r.GetSettings().GetFailingChecksBlock()) | |
| 222 | + | }}, | |
| 223 | + | {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 224 | + | assert.False(t, r.GetSettings().GetAllowAutoMerge()) | |
| 225 | + | }}, | |
| 218 | 226 | {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"}, | |
| 219 | 227 | []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}}, | |
| 220 | 228 | } | |
| @@ -436,3 +444,25 @@ func TestToJSONRepoFromProto(t *testing.T) { | |||
| 436 | 444 | assert.Equal(t, "private", r.Visibility) | |
| 437 | 445 | assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility) | |
| 438 | 446 | } | |
| 447 | + | ||
| 448 | + | func TestRepoEditAllowAutoMerge(t *testing.T) { | |
| 449 | + | updated := &ermite.Repo{Name: "alpha", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, DefaultBranch: "main", | |
| 450 | + | Settings: &ermite.RepoSettings{AllowAutoMerge: true}} | |
| 451 | + | f := &fakeAPI{updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) { | |
| 452 | + | return &ermite.UpdateRepoResponse{Repo: updated}, nil | |
| 453 | + | }} | |
| 454 | + | url := f.serve(t) | |
| 455 | + | ||
| 456 | + | res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge").ok(t) | |
| 457 | + | assert.Regexp(t, `(?m)^allow auto-merge\s+true$`, res.stdout) | |
| 458 | + | _, reqs := f.snapshot() | |
| 459 | + | got := reqs[0].(*ermite.UpdateRepoRequest) | |
| 460 | + | assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths()) | |
| 461 | + | assert.True(t, got.GetSettings().GetAllowAutoMerge()) | |
| 462 | + | ||
| 463 | + | res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t) | |
| 464 | + | assert.Contains(t, res.stdout, `"allow_auto_merge": true`) | |
| 465 | + | _, reqs = f.snapshot() | |
| 466 | + | got = reqs[1].(*ermite.UpdateRepoRequest) | |
| 467 | + | assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths()) | |
| 468 | + | } | |
| @@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) { | |||||
| 54 | { | 54 | { | ||
| 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | 55 | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | ||
| 56 | wantStdout: `[ | 56 | wantStdout: `[ | ||
| 57 | - | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}, | 57 | + | {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}, |
| 58 | - | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"} | 58 | + | {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"} |
| 59 | ]`, | 59 | ]`, | ||
| 60 | }, | 60 | }, | ||
| 61 | { | 61 | { | ||
| @@ -116,12 +116,13 @@ func TestRepoShow(t *testing.T) { | |||||
| 116 | "last push 2026-03-04 05:06\n"+ | 116 | "last push 2026-03-04 05:06\n"+ | ||
| 117 | "merge modes fast-forward, squash\n"+ | 117 | "merge modes fast-forward, squash\n"+ | ||
| 118 | "failing checks block true\n"+ | 118 | "failing checks block true\n"+ | ||
| 119 | + | "allow auto-merge false\n"+ | |||
| 119 | "issues off\n", res.stdout) | 120 | "issues off\n", res.stdout) | ||
| 120 | _, reqs := f.snapshot() | 121 | _, reqs := f.snapshot() | ||
| 121 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | 122 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | ||
| 122 | 123 | ||||
| 123 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | 124 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | ||
| 124 | - | assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"issues":"off"}`, res.stdout) | 125 | + | assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true,"allow_auto_merge":false,"issues":"off"}`, res.stdout) |
| 125 | } | 126 | } | ||
| 126 | 127 | ||||
| 127 | func TestRepoShowNeedsOneArg(t *testing.T) { | 128 | func TestRepoShowNeedsOneArg(t *testing.T) { | ||
| @@ -177,7 +178,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||||
| 177 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | 178 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | ||
| 178 | }} | 179 | }} | ||
| 179 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | 180 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | ||
| 180 | - | assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}`, res.stdout) | 181 | + | assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}`, res.stdout) |
| 181 | } | 182 | } | ||
| 182 | 183 | ||||
| 183 | func TestRepoEditMask(t *testing.T) { | 184 | func TestRepoEditMask(t *testing.T) { | ||
| @@ -215,6 +216,13 @@ func TestRepoEditMask(t *testing.T) { | |||||
| 215 | {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | 216 | {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | ||
| 216 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | 217 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | ||
| 217 | }}, | 218 | }}, | ||
| 219 | + | {"allow auto-merge", []string{"--allow-auto-merge"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |||
| 220 | + | assert.True(t, r.GetSettings().GetAllowAutoMerge()) | |||
| 221 | + | assert.False(t, r.GetSettings().GetFailingChecksBlock()) | |||
| 222 | + | }}, | |||
| 223 | + | {"forbid auto-merge", []string{"--allow-auto-merge=false"}, []string{"settings.allow_auto_merge"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |||
| 224 | + | assert.False(t, r.GetSettings().GetAllowAutoMerge()) | |||
| 225 | + | }}, | |||
| 218 | {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"}, | 226 | {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"}, | ||
| 219 | []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}}, | 227 | []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}}, | ||
| 220 | } | 228 | } | ||
| @@ -436,3 +444,25 @@ func TestToJSONRepoFromProto(t *testing.T) { | |||||
| 436 | assert.Equal(t, "private", r.Visibility) | 444 | assert.Equal(t, "private", r.Visibility) | ||
| 437 | assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility) | 445 | assert.Equal(t, "unknown", toJSONRepo(&ermite.Repo{}).Visibility) | ||
| 438 | } | 446 | } | ||
| 447 | + | ||||
| 448 | + | func TestRepoEditAllowAutoMerge(t *testing.T) { | |||
| 449 | + | updated := &ermite.Repo{Name: "alpha", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, DefaultBranch: "main", | |||
| 450 | + | Settings: &ermite.RepoSettings{AllowAutoMerge: true}} | |||
| 451 | + | f := &fakeAPI{updateRepo: func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error) { | |||
| 452 | + | return &ermite.UpdateRepoResponse{Repo: updated}, nil | |||
| 453 | + | }} | |||
| 454 | + | url := f.serve(t) | |||
| 455 | + | ||||
| 456 | + | res := runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge").ok(t) | |||
| 457 | + | assert.Regexp(t, `(?m)^allow auto-merge\s+true$`, res.stdout) | |||
| 458 | + | _, reqs := f.snapshot() | |||
| 459 | + | got := reqs[0].(*ermite.UpdateRepoRequest) | |||
| 460 | + | assert.Equal(t, []string{"settings.allow_auto_merge"}, got.GetUpdateMask().GetPaths()) | |||
| 461 | + | assert.True(t, got.GetSettings().GetAllowAutoMerge()) | |||
| 462 | + | ||||
| 463 | + | res = runCLI(t, envFor(url), "", "repo", "edit", "alpha", "--allow-auto-merge", "--failing-checks-block=false", "--json").ok(t) | |||
| 464 | + | assert.Contains(t, res.stdout, `"allow_auto_merge": true`) | |||
| 465 | + | _, reqs = f.snapshot() | |||
| 466 | + | got = reqs[1].(*ermite.UpdateRepoRequest) | |||
| 467 | + | assert.ElementsMatch(t, []string{"settings.allow_auto_merge", "settings.failing_checks_block"}, got.GetUpdateMask().GetPaths()) | |||
| 468 | + | } | |||
M internal/app/cli_version.go +1 -0
| @@ -126,4 +126,5 @@ var featureUse = map[string]string{ | |||
| 126 | 126 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | |
| 127 | 127 | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | |
| 128 | 128 | client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server", | |
| 129 | + | client.FeatureAutoMerge: "`ermite mr merge --auto` and `repo edit --allow-auto-merge` need: the commands are refused", | |
| 129 | 130 | } | |
| @@ -126,4 +126,5 @@ var featureUse = map[string]string{ | |||||
| 126 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | 126 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | ||
| 127 | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | 127 | client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)", | ||
| 128 | client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server", | 128 | client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server", | ||
| 129 | + | client.FeatureAutoMerge: "`ermite mr merge --auto` and `repo edit --allow-auto-merge` need: the commands are refused", | |||
| 129 | } | 130 | } | ||
M internal/app/cli_version_test.go +5 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // allFeatures are the features this client uses. | |
| 20 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} | |
| 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"} | |
| 21 | 21 | ||
| 22 | 22 | // Every feature the client uses needs a sentence for the version warning. | |
| 23 | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | |
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 45 | 45 | f := versionFake(allFeatures...) | |
| 46 | 46 | url := f.serve(t) | |
| 47 | 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 48 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | |
| 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout) | |
| 49 | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 50 | 50 | ||
| 51 | 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 52 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") | |
| 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys") | |
| 57 | 57 | url := f.serve(t) | |
| 58 | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 59 | 59 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 61 | 61 | assert.NotContains(t, res.stderr, "repo.update") | |
| 62 | 62 | ||
| 63 | 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 64 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 65 | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 66 | 66 | ||
| 67 | 67 | f = versionFake() | |
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // allFeatures are the features this client uses. | 19 | // allFeatures are the features this client uses. | ||
| 20 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"} | 20 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys"} |
| 21 | 21 | ||||
| 22 | // Every feature the client uses needs a sentence for the version warning. | 22 | // Every feature the client uses needs a sentence for the version warning. | ||
| 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | ||
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||||
| 45 | f := versionFake(allFeatures...) | 45 | f := versionFake(allFeatures...) | ||
| 46 | url := f.serve(t) | 46 | url := f.serve(t) | ||
| 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | 47 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 48 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout) | 48 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, merge_requests.auto_merge, passkeys\n", res.stdout) |
| 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 50 | 50 | ||||
| 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 51 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 52 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout) | 52 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":[]}}`, res.stdout) |
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 55 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | ||
| 56 | - | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys") | 56 | + | f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "merge_requests.auto_merge", "passkeys") |
| 57 | url := f.serve(t) | 57 | url := f.serve(t) | ||
| 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 59 | assert.Contains(t, res.stdout, "v0.4.0") | 59 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 61 | assert.NotContains(t, res.stderr, "repo.update") | 61 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 62 | 62 | ||||
| 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 63 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 64 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) | 64 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","merge_requests.auto_merge","passkeys"],"missing_features":["events.resume"]}}`, res.stdout) |
| 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 66 | 66 | ||||
| 67 | f = versionFake() | 67 | f = versionFake() | ||
M internal/app/cli_watch.go +25 -3
| @@ -34,6 +34,10 @@ type jsonEvent struct { | |||
| 34 | 34 | // State and FailureClass are set for a mirror_failed event. | |
| 35 | 35 | State string `json:"state,omitempty"` | |
| 36 | 36 | FailureClass string `json:"failure_class,omitempty"` | |
| 37 | + | // Auto is set for merge_request_merged: auto-merge made the merge. | |
| 38 | + | Auto bool `json:"auto,omitempty"` | |
| 39 | + | // Reason is set for merge_request_auto_merge_cancelled. | |
| 40 | + | Reason string `json:"reason,omitempty"` | |
| 37 | 41 | } | |
| 38 | 42 | ||
| 39 | 43 | type jsonRefUpdate struct { | |
| @@ -53,6 +57,8 @@ const ( | |||
| 53 | 57 | eventMROpened = "merge_request_opened" | |
| 54 | 58 | eventMRMerged = "merge_request_merged" | |
| 55 | 59 | eventMRClosed = "merge_request_closed" | |
| 60 | + | eventMRAutoEnabled = "merge_request_auto_merge_enabled" | |
| 61 | + | eventMRAutoCancelled = "merge_request_auto_merge_cancelled" | |
| 56 | 62 | eventCommentAdded = "comment_added" | |
| 57 | 63 | eventCommentEdited = "comment_edited" | |
| 58 | 64 | eventCommentDeleted = "comment_deleted" | |
| @@ -87,6 +93,14 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |||
| 87 | 93 | case *ermite.Event_MergeRequestMerged: | |
| 88 | 94 | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL) | |
| 89 | 95 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | |
| 96 | + | out.Auto = k.MergeRequestMerged.GetAuto() | |
| 97 | + | case *ermite.Event_MergeRequestAutoMergeEnabled: | |
| 98 | + | m := toJSONMergeRequest(k.MergeRequestAutoMergeEnabled.GetMergeRequest(), baseURL) | |
| 99 | + | out.Type, out.Repo, out.MergeRequest = eventMRAutoEnabled, m.Repo, &m | |
| 100 | + | case *ermite.Event_MergeRequestAutoMergeCancelled: | |
| 101 | + | m := toJSONMergeRequest(k.MergeRequestAutoMergeCancelled.GetMergeRequest(), baseURL) | |
| 102 | + | out.Type, out.Repo, out.MergeRequest = eventMRAutoCancelled, m.Repo, &m | |
| 103 | + | out.Reason = k.MergeRequestAutoMergeCancelled.GetReason() | |
| 90 | 104 | case *ermite.Event_MergeRequestClosed: | |
| 91 | 105 | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL) | |
| 92 | 106 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | |
| @@ -140,6 +154,13 @@ func eventLine(e jsonEvent) string { | |||
| 140 | 154 | detail = e.Repo + " " + strings.Join(parts, ", ") | |
| 141 | 155 | case eventMROpened, eventMRMerged, eventMRClosed: | |
| 142 | 156 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | |
| 157 | + | if e.Auto { | |
| 158 | + | detail += " (auto-merge)" | |
| 159 | + | } | |
| 160 | + | case eventMRAutoEnabled: | |
| 161 | + | detail = fmt.Sprintf("%s!%d %s when ready", e.Repo, e.MergeRequest.Number, e.MergeRequest.AutoMerge.Mode) | |
| 162 | + | case eventMRAutoCancelled: | |
| 163 | + | detail = fmt.Sprintf("%s!%d: %s", e.Repo, e.MergeRequest.Number, e.Reason) | |
| 143 | 164 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | |
| 144 | 165 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |
| 145 | 166 | case eventMRMergeability: | |
| @@ -198,18 +219,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||
| 198 | 219 | Use: "watch [repo]", | |
| 199 | 220 | Short: "Print repository events as they happen", | |
| 200 | 221 | Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" + | |
| 201 | - | "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" + | |
| 222 | + | "opened, merged (by auto-merge or not) or closed, auto-merge enabled or cancelled, notes added, edited, deleted or resolved) for every\n" + | |
| 202 | 223 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | |
| 203 | 224 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | |
| 204 | 225 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | |
| 205 | 226 | "error.\n\n" + | |
| 206 | 227 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | |
| 207 | 228 | "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" + | |
| 208 | - | "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" + | |
| 229 | + | "merge_request_closed, merge_request_auto_merge_enabled, merge_request_auto_merge_cancelled\n" + | |
| 230 | + | "(with reason), comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" + | |
| 209 | 231 | "and, depending on the type, repo (name),\n" + | |
| 210 | 232 | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + | |
| 211 | 233 | "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" + | |
| 212 | - | "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" + | |
| 234 | + | "`mr show --json`; merge_request_merged also has auto, true for an auto-merge) and, for comment events, number (of the merge request) and comment. Unknown\n" + | |
| 213 | 235 | "event types are skipped.", | |
| 214 | 236 | Args: cobra.MaximumNArgs(1), | |
| 215 | 237 | ValidArgsFunction: completeRepoNames(env), | |
| @@ -34,6 +34,10 @@ type jsonEvent struct { | |||||
| 34 | // State and FailureClass are set for a mirror_failed event. | 34 | // State and FailureClass are set for a mirror_failed event. | ||
| 35 | State string `json:"state,omitempty"` | 35 | State string `json:"state,omitempty"` | ||
| 36 | FailureClass string `json:"failure_class,omitempty"` | 36 | FailureClass string `json:"failure_class,omitempty"` | ||
| 37 | + | // Auto is set for merge_request_merged: auto-merge made the merge. | |||
| 38 | + | Auto bool `json:"auto,omitempty"` | |||
| 39 | + | // Reason is set for merge_request_auto_merge_cancelled. | |||
| 40 | + | Reason string `json:"reason,omitempty"` | |||
| 37 | } | 41 | } | ||
| 38 | 42 | ||||
| 39 | type jsonRefUpdate struct { | 43 | type jsonRefUpdate struct { | ||
| @@ -53,6 +57,8 @@ const ( | |||||
| 53 | eventMROpened = "merge_request_opened" | 57 | eventMROpened = "merge_request_opened" | ||
| 54 | eventMRMerged = "merge_request_merged" | 58 | eventMRMerged = "merge_request_merged" | ||
| 55 | eventMRClosed = "merge_request_closed" | 59 | eventMRClosed = "merge_request_closed" | ||
| 60 | + | eventMRAutoEnabled = "merge_request_auto_merge_enabled" | |||
| 61 | + | eventMRAutoCancelled = "merge_request_auto_merge_cancelled" | |||
| 56 | eventCommentAdded = "comment_added" | 62 | eventCommentAdded = "comment_added" | ||
| 57 | eventCommentEdited = "comment_edited" | 63 | eventCommentEdited = "comment_edited" | ||
| 58 | eventCommentDeleted = "comment_deleted" | 64 | eventCommentDeleted = "comment_deleted" | ||
| @@ -87,6 +93,14 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |||||
| 87 | case *ermite.Event_MergeRequestMerged: | 93 | case *ermite.Event_MergeRequestMerged: | ||
| 88 | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL) | 94 | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest(), baseURL) | ||
| 89 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | 95 | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | ||
| 96 | + | out.Auto = k.MergeRequestMerged.GetAuto() | |||
| 97 | + | case *ermite.Event_MergeRequestAutoMergeEnabled: | |||
| 98 | + | m := toJSONMergeRequest(k.MergeRequestAutoMergeEnabled.GetMergeRequest(), baseURL) | |||
| 99 | + | out.Type, out.Repo, out.MergeRequest = eventMRAutoEnabled, m.Repo, &m | |||
| 100 | + | case *ermite.Event_MergeRequestAutoMergeCancelled: | |||
| 101 | + | m := toJSONMergeRequest(k.MergeRequestAutoMergeCancelled.GetMergeRequest(), baseURL) | |||
| 102 | + | out.Type, out.Repo, out.MergeRequest = eventMRAutoCancelled, m.Repo, &m | |||
| 103 | + | out.Reason = k.MergeRequestAutoMergeCancelled.GetReason() | |||
| 90 | case *ermite.Event_MergeRequestClosed: | 104 | case *ermite.Event_MergeRequestClosed: | ||
| 91 | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL) | 105 | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest(), baseURL) | ||
| 92 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | 106 | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | ||
| @@ -140,6 +154,13 @@ func eventLine(e jsonEvent) string { | |||||
| 140 | detail = e.Repo + " " + strings.Join(parts, ", ") | 154 | detail = e.Repo + " " + strings.Join(parts, ", ") | ||
| 141 | case eventMROpened, eventMRMerged, eventMRClosed: | 155 | case eventMROpened, eventMRMerged, eventMRClosed: | ||
| 142 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | 156 | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | ||
| 157 | + | if e.Auto { | |||
| 158 | + | detail += " (auto-merge)" | |||
| 159 | + | } | |||
| 160 | + | case eventMRAutoEnabled: | |||
| 161 | + | detail = fmt.Sprintf("%s!%d %s when ready", e.Repo, e.MergeRequest.Number, e.MergeRequest.AutoMerge.Mode) | |||
| 162 | + | case eventMRAutoCancelled: | |||
| 163 | + | detail = fmt.Sprintf("%s!%d: %s", e.Repo, e.MergeRequest.Number, e.Reason) | |||
| 143 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | 164 | case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved: | ||
| 144 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | 165 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | ||
| 145 | case eventMRMergeability: | 166 | case eventMRMergeability: | ||
| @@ -198,18 +219,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||||
| 198 | Use: "watch [repo]", | 219 | Use: "watch [repo]", | ||
| 199 | Short: "Print repository events as they happen", | 220 | Short: "Print repository events as they happen", | ||
| 200 | Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" + | 221 | Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" + | ||
| 201 | - | "opened, merged or closed, notes added, edited, deleted or resolved) for every\n" + | 222 | + | "opened, merged (by auto-merge or not) or closed, auto-merge enabled or cancelled, notes added, edited, deleted or resolved) for every\n" + |
| 202 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | 223 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | ||
| 203 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | 224 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | ||
| 204 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | 225 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | ||
| 205 | "error.\n\n" + | 226 | "error.\n\n" + | ||
| 206 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | 227 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | ||
| 207 | "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" + | 228 | "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" + | ||
| 208 | - | "merge_request_closed, comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" + | 229 | + | "merge_request_closed, merge_request_auto_merge_enabled, merge_request_auto_merge_cancelled\n" + |
| 230 | + | "(with reason), comment_added, comment_edited, comment_deleted, comment_resolved or gap)\n" + | |||
| 209 | "and, depending on the type, repo (name),\n" + | 231 | "and, depending on the type, repo (name),\n" + | ||
| 210 | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + | 232 | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + | ||
| 211 | "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" + | 233 | "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" + | ||
| 212 | - | "`mr show --json`) and, for comment events, number (of the merge request) and comment. Unknown\n" + | 234 | + | "`mr show --json`; merge_request_merged also has auto, true for an auto-merge) and, for comment events, number (of the merge request) and comment. Unknown\n" + |
| 213 | "event types are skipped.", | 235 | "event types are skipped.", | ||
| 214 | Args: cobra.MaximumNArgs(1), | 236 | Args: cobra.MaximumNArgs(1), | ||
| 215 | ValidArgsFunction: completeRepoNames(env), | 237 | ValidArgsFunction: completeRepoNames(env), | ||
M internal/app/cli_watch_test.go +55 -1
| @@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) { | |||
| 95 | 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | |
| 96 | 96 | } | |
| 97 | 97 | require.Len(t, lines, 5) | |
| 98 | - | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}}`, lines[0]) | |
| 98 | + | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}}`, lines[0]) | |
| 99 | 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | |
| 100 | 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | |
| 101 | 101 | {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"}, | |
| @@ -283,3 +283,57 @@ func TestMirrorWatchEvents(t *testing.T) { | |||
| 283 | 283 | assert.Contains(t, eventLine(recovered), "mirror_recovered alpha") | |
| 284 | 284 | assert.NotContains(t, eventLine(recovered), " \n") | |
| 285 | 285 | } | |
| 286 | + | ||
| 287 | + | func TestWatchAutoMergeEvents(t *testing.T) { | |
| 288 | + | events := []*ermite.WatchResponse{ | |
| 289 | + | evt("1", func(e *ermite.Event) { | |
| 290 | + | e.Kind = &ermite.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &ermite.MergeRequestAutoMergeEnabled{MergeRequest: &ermite.MergeRequest{ | |
| 291 | + | Repo: "alpha", Number: 1, SourceBranch: "feat/x", TargetBranch: "main", | |
| 292 | + | AutoMerge: &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner"}, | |
| 293 | + | }}} | |
| 294 | + | }), | |
| 295 | + | evt("2", func(e *ermite.Event) { | |
| 296 | + | e.Kind = &ermite.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &ermite.MergeRequestAutoMergeCancelled{MergeRequest: mrOne, Reason: "the branch moved"}} | |
| 297 | + | }), | |
| 298 | + | evt("3", func(e *ermite.Event) { | |
| 299 | + | e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne, Auto: true}} | |
| 300 | + | }), | |
| 301 | + | evt("4", func(e *ermite.Event) { | |
| 302 | + | e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne}} | |
| 303 | + | }), | |
| 304 | + | } | |
| 305 | + | f := &fakeAPI{watch: finishing(events)} | |
| 306 | + | url := f.serve(t) | |
| 307 | + | ||
| 308 | + | res := runCLI(t, envFor(url), "", "watch") | |
| 309 | + | require.Error(t, res.err) | |
| 310 | + | assert.Equal(t, ""+ | |
| 311 | + | "2026-10-10T12:00:00Z merge_request_auto_merge_enabled alpha!1 squash when ready\n"+ | |
| 312 | + | "2026-10-10T12:00:00Z merge_request_auto_merge_cancelled alpha!1: the branch moved\n"+ | |
| 313 | + | "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main (auto-merge)\n"+ | |
| 314 | + | "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main\n", res.stdout) | |
| 315 | + | ||
| 316 | + | res = runCLI(t, envFor(url), "", "watch", "--json") | |
| 317 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |
| 318 | + | require.Len(t, lines, 4) | |
| 319 | + | var enabled, cancelled, auto, manual struct { | |
| 320 | + | Type string `json:"type"` | |
| 321 | + | Reason string `json:"reason"` | |
| 322 | + | Auto bool `json:"auto"` | |
| 323 | + | MergeRequest *struct { | |
| 324 | + | AutoMerge *struct { | |
| 325 | + | Mode string `json:"mode"` | |
| 326 | + | } `json:"auto_merge"` | |
| 327 | + | } `json:"merge_request"` | |
| 328 | + | } | |
| 329 | + | for i, dst := range []any{&enabled, &cancelled, &auto, &manual} { | |
| 330 | + | require.NoError(t, json.Unmarshal([]byte(lines[i]), dst)) | |
| 331 | + | } | |
| 332 | + | assert.Equal(t, "merge_request_auto_merge_enabled", enabled.Type) | |
| 333 | + | require.NotNil(t, enabled.MergeRequest.AutoMerge) | |
| 334 | + | assert.Equal(t, "squash", enabled.MergeRequest.AutoMerge.Mode) | |
| 335 | + | assert.Equal(t, "merge_request_auto_merge_cancelled", cancelled.Type) | |
| 336 | + | assert.Equal(t, "the branch moved", cancelled.Reason) | |
| 337 | + | assert.True(t, auto.Auto) | |
| 338 | + | assert.False(t, manual.Auto) | |
| 339 | + | } | |
| @@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) { | |||||
| 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | 95 | assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text()) | ||
| 96 | } | 96 | } | ||
| 97 | require.Len(t, lines, 5) | 97 | require.Len(t, lines, 5) | ||
| 98 | - | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"issues":"off"}}`, lines[0]) | 98 | + | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false,"allow_auto_merge":false,"issues":"off"}}`, lines[0]) |
| 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | 99 | assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[ | ||
| 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | 100 | {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"}, | ||
| 101 | {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"}, | 101 | {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"}, | ||
| @@ -283,3 +283,57 @@ func TestMirrorWatchEvents(t *testing.T) { | |||||
| 283 | assert.Contains(t, eventLine(recovered), "mirror_recovered alpha") | 283 | assert.Contains(t, eventLine(recovered), "mirror_recovered alpha") | ||
| 284 | assert.NotContains(t, eventLine(recovered), " \n") | 284 | assert.NotContains(t, eventLine(recovered), " \n") | ||
| 285 | } | 285 | } | ||
| 286 | + | ||||
| 287 | + | func TestWatchAutoMergeEvents(t *testing.T) { | |||
| 288 | + | events := []*ermite.WatchResponse{ | |||
| 289 | + | evt("1", func(e *ermite.Event) { | |||
| 290 | + | e.Kind = &ermite.Event_MergeRequestAutoMergeEnabled{MergeRequestAutoMergeEnabled: &ermite.MergeRequestAutoMergeEnabled{MergeRequest: &ermite.MergeRequest{ | |||
| 291 | + | Repo: "alpha", Number: 1, SourceBranch: "feat/x", TargetBranch: "main", | |||
| 292 | + | AutoMerge: &ermite.AutoMerge{Mode: ermite.MergeMode_MERGE_MODE_SQUASH, HeadId: headA, EnabledBy: "owner"}, | |||
| 293 | + | }}} | |||
| 294 | + | }), | |||
| 295 | + | evt("2", func(e *ermite.Event) { | |||
| 296 | + | e.Kind = &ermite.Event_MergeRequestAutoMergeCancelled{MergeRequestAutoMergeCancelled: &ermite.MergeRequestAutoMergeCancelled{MergeRequest: mrOne, Reason: "the branch moved"}} | |||
| 297 | + | }), | |||
| 298 | + | evt("3", func(e *ermite.Event) { | |||
| 299 | + | e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne, Auto: true}} | |||
| 300 | + | }), | |||
| 301 | + | evt("4", func(e *ermite.Event) { | |||
| 302 | + | e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne}} | |||
| 303 | + | }), | |||
| 304 | + | } | |||
| 305 | + | f := &fakeAPI{watch: finishing(events)} | |||
| 306 | + | url := f.serve(t) | |||
| 307 | + | ||||
| 308 | + | res := runCLI(t, envFor(url), "", "watch") | |||
| 309 | + | require.Error(t, res.err) | |||
| 310 | + | assert.Equal(t, ""+ | |||
| 311 | + | "2026-10-10T12:00:00Z merge_request_auto_merge_enabled alpha!1 squash when ready\n"+ | |||
| 312 | + | "2026-10-10T12:00:00Z merge_request_auto_merge_cancelled alpha!1: the branch moved\n"+ | |||
| 313 | + | "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main (auto-merge)\n"+ | |||
| 314 | + | "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main\n", res.stdout) | |||
| 315 | + | ||||
| 316 | + | res = runCLI(t, envFor(url), "", "watch", "--json") | |||
| 317 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |||
| 318 | + | require.Len(t, lines, 4) | |||
| 319 | + | var enabled, cancelled, auto, manual struct { | |||
| 320 | + | Type string `json:"type"` | |||
| 321 | + | Reason string `json:"reason"` | |||
| 322 | + | Auto bool `json:"auto"` | |||
| 323 | + | MergeRequest *struct { | |||
| 324 | + | AutoMerge *struct { | |||
| 325 | + | Mode string `json:"mode"` | |||
| 326 | + | } `json:"auto_merge"` | |||
| 327 | + | } `json:"merge_request"` | |||
| 328 | + | } | |||
| 329 | + | for i, dst := range []any{&enabled, &cancelled, &auto, &manual} { | |||
| 330 | + | require.NoError(t, json.Unmarshal([]byte(lines[i]), dst)) | |||
| 331 | + | } | |||
| 332 | + | assert.Equal(t, "merge_request_auto_merge_enabled", enabled.Type) | |||
| 333 | + | require.NotNil(t, enabled.MergeRequest.AutoMerge) | |||
| 334 | + | assert.Equal(t, "squash", enabled.MergeRequest.AutoMerge.Mode) | |||
| 335 | + | assert.Equal(t, "merge_request_auto_merge_cancelled", cancelled.Type) | |||
| 336 | + | assert.Equal(t, "the branch moved", cancelled.Reason) | |||
| 337 | + | assert.True(t, auto.Auto) | |||
| 338 | + | assert.False(t, manual.Auto) | |||
| 339 | + | } | |||