feat(cli): add ermite mr and ermite signing-key
mr create, list, show (with --diff, --since-last-read, --mark-read), comment (general or on a line of the diff), resolve, merge (--squash, --head) and close; signing-key show prints the public key and the allowed_signers line. repo edit gains --merge-modes and --failing-checks-block, repo show and watch report the settings and the merge request events. The client warns about the two new features when a server lacks them. The CLI is tested against a fake API and end to end with real git pushes: create, list, show, comment, resolve, both merge modes, the head check, since-last-read after a force-push, the signed squash commit verified with git verify-commit, and the invisibility of a private repository.
- commit
- 3e5f81bca46f9f00011f5efc1b0910fa3a62e95e
- author
- brice · · 2026-10-10 17:04 UTC
- parent
- 17f0117
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +1942 -39
M cmd/ermite/api_helpers_test.go +2 -0
| @@ -30,6 +30,7 @@ type clients struct { | |||
| 30 | 30 | token ermiteconnect.TokenServiceClient | |
| 31 | 31 | event ermiteconnect.EventServiceClient | |
| 32 | 32 | server ermiteconnect.ServerServiceClient | |
| 33 | + | mr ermiteconnect.MergeRequestServiceClient | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | 36 | // bearerTransport adds the Authorization header of a secret, if any. | |
| @@ -75,6 +76,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti | |||
| 75 | 76 | token: ermiteconnect.NewTokenServiceClient(hc, base, opts...), | |
| 76 | 77 | event: ermiteconnect.NewEventServiceClient(hc, base, opts...), | |
| 77 | 78 | server: ermiteconnect.NewServerServiceClient(hc, base, opts...), | |
| 79 | + | mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...), | |
| 78 | 80 | } | |
| 79 | 81 | } | |
| 80 | 82 | ||
| @@ -30,6 +30,7 @@ type clients struct { | |||||
| 30 | token ermiteconnect.TokenServiceClient | 30 | token ermiteconnect.TokenServiceClient | ||
| 31 | event ermiteconnect.EventServiceClient | 31 | event ermiteconnect.EventServiceClient | ||
| 32 | server ermiteconnect.ServerServiceClient | 32 | server ermiteconnect.ServerServiceClient | ||
| 33 | + | mr ermiteconnect.MergeRequestServiceClient | |||
| 33 | } | 34 | } | ||
| 34 | 35 | ||||
| 35 | // bearerTransport adds the Authorization header of a secret, if any. | 36 | // bearerTransport adds the Authorization header of a secret, if any. | ||
| @@ -75,6 +76,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti | |||||
| 75 | token: ermiteconnect.NewTokenServiceClient(hc, base, opts...), | 76 | token: ermiteconnect.NewTokenServiceClient(hc, base, opts...), | ||
| 76 | event: ermiteconnect.NewEventServiceClient(hc, base, opts...), | 77 | event: ermiteconnect.NewEventServiceClient(hc, base, opts...), | ||
| 77 | server: ermiteconnect.NewServerServiceClient(hc, base, opts...), | 78 | server: ermiteconnect.NewServerServiceClient(hc, base, opts...), | ||
| 79 | + | mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...), | |||
| 78 | } | 80 | } | ||
| 79 | } | 81 | } | ||
| 80 | 82 | ||||
M cmd/ermite/cli_e2e_test.go +3 -2
| @@ -187,9 +187,10 @@ type eventOut struct { | |||
| 187 | 187 | OldID string `json:"old_id"` | |
| 188 | 188 | NewID string `json:"new_id"` | |
| 189 | 189 | } `json:"ref_updates"` | |
| 190 | + | MergeRequest *mrOut `json:"merge_request"` | |
| 190 | 191 | } | |
| 191 | 192 | ||
| 192 | - | var repoKeys = []string{"created_at", "default_branch", "description", "name", "pushed_at", "visibility"} | |
| 193 | + | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "visibility"} | |
| 193 | 194 | ||
| 194 | 195 | // AC6: the CLI end to end against a real forge. | |
| 195 | 196 | func TestAC6CLIEndToEnd(t *testing.T) { | |
| @@ -323,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 323 | 324 | ver := decode[versionOut](t, res.stdout) | |
| 324 | 325 | assert.Equal(t, c.url, ver.Server.URL) | |
| 325 | 326 | assert.NotEmpty(t, ver.Server.Version) | |
| 326 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume"}, ver.Server.Features) | |
| 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, ver.Server.Features) | |
| 327 | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 328 | 329 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 329 | 330 | require.Equal(t, 0, code, stderr) | |
| @@ -187,9 +187,10 @@ type eventOut struct { | |||||
| 187 | OldID string `json:"old_id"` | 187 | OldID string `json:"old_id"` | ||
| 188 | NewID string `json:"new_id"` | 188 | NewID string `json:"new_id"` | ||
| 189 | } `json:"ref_updates"` | 189 | } `json:"ref_updates"` | ||
| 190 | + | MergeRequest *mrOut `json:"merge_request"` | |||
| 190 | } | 191 | } | ||
| 191 | 192 | ||||
| 192 | - | var repoKeys = []string{"created_at", "default_branch", "description", "name", "pushed_at", "visibility"} | 193 | + | var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "visibility"} |
| 193 | 194 | ||||
| 194 | // AC6: the CLI end to end against a real forge. | 195 | // AC6: the CLI end to end against a real forge. | ||
| 195 | func TestAC6CLIEndToEnd(t *testing.T) { | 196 | func TestAC6CLIEndToEnd(t *testing.T) { | ||
| @@ -323,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 323 | ver := decode[versionOut](t, res.stdout) | 324 | ver := decode[versionOut](t, res.stdout) | ||
| 324 | assert.Equal(t, c.url, ver.Server.URL) | 325 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 325 | assert.NotEmpty(t, ver.Server.Version) | 326 | assert.NotEmpty(t, ver.Server.Version) | ||
| 326 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume"}, ver.Server.Features) | 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, ver.Server.Features) |
| 327 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | ||
| 328 | stdout, stderr, code = c.bin(t, secret, "version") | 329 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 329 | require.Equal(t, 0, code, stderr) | 330 | require.Equal(t, 0, code, stderr) | ||
M cmd/ermite/cli_fake_test.go +40 -0
| @@ -28,6 +28,7 @@ type fakeAPI struct { | |||
| 28 | 28 | ermiteconnect.UnimplementedTokenServiceHandler | |
| 29 | 29 | ermiteconnect.UnimplementedServerServiceHandler | |
| 30 | 30 | ermiteconnect.UnimplementedEventServiceHandler | |
| 31 | + | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 31 | 32 | ||
| 32 | 33 | mu sync.Mutex | |
| 33 | 34 | calls []string // procedure names, in order | |
| @@ -44,6 +45,16 @@ type fakeAPI struct { | |||
| 44 | 45 | revokeToken func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error) | |
| 45 | 46 | getVersion func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) | |
| 46 | 47 | watch func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error | |
| 48 | + | ||
| 49 | + | createMR func(*ermite.CreateMergeRequestRequest) (*ermite.CreateMergeRequestResponse, error) | |
| 50 | + | listMR func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) | |
| 51 | + | getMR func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) | |
| 52 | + | addComment func(*ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) | |
| 53 | + | resolveCmt func(*ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error) | |
| 54 | + | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | |
| 55 | + | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | |
| 56 | + | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | |
| 57 | + | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | |
| 47 | 58 | } | |
| 48 | 59 | ||
| 49 | 60 | func (f *fakeAPI) record(name string, h http.Header, req any) { | |
| @@ -99,6 +110,33 @@ func (f *fakeAPI) RevokeToken(_ context.Context, r *connect.Request[ermite.Revok | |||
| 99 | 110 | func (f *fakeAPI) GetVersion(_ context.Context, r *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) { | |
| 100 | 111 | return respond(f, "GetVersion", r, f.getVersion) | |
| 101 | 112 | } | |
| 113 | + | func (f *fakeAPI) CreateMergeRequest(_ context.Context, r *connect.Request[ermite.CreateMergeRequestRequest]) (*connect.Response[ermite.CreateMergeRequestResponse], error) { | |
| 114 | + | return respond(f, "CreateMergeRequest", r, f.createMR) | |
| 115 | + | } | |
| 116 | + | func (f *fakeAPI) ListMergeRequests(_ context.Context, r *connect.Request[ermite.ListMergeRequestsRequest]) (*connect.Response[ermite.ListMergeRequestsResponse], error) { | |
| 117 | + | return respond(f, "ListMergeRequests", r, f.listMR) | |
| 118 | + | } | |
| 119 | + | func (f *fakeAPI) GetMergeRequest(_ context.Context, r *connect.Request[ermite.GetMergeRequestRequest]) (*connect.Response[ermite.GetMergeRequestResponse], error) { | |
| 120 | + | return respond(f, "GetMergeRequest", r, f.getMR) | |
| 121 | + | } | |
| 122 | + | func (f *fakeAPI) AddComment(_ context.Context, r *connect.Request[ermite.AddCommentRequest]) (*connect.Response[ermite.AddCommentResponse], error) { | |
| 123 | + | return respond(f, "AddComment", r, f.addComment) | |
| 124 | + | } | |
| 125 | + | func (f *fakeAPI) ResolveComment(_ context.Context, r *connect.Request[ermite.ResolveCommentRequest]) (*connect.Response[ermite.ResolveCommentResponse], error) { | |
| 126 | + | return respond(f, "ResolveComment", r, f.resolveCmt) | |
| 127 | + | } | |
| 128 | + | func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite.CloseMergeRequestRequest]) (*connect.Response[ermite.CloseMergeRequestResponse], error) { | |
| 129 | + | return respond(f, "CloseMergeRequest", r, f.closeMR) | |
| 130 | + | } | |
| 131 | + | func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) { | |
| 132 | + | return respond(f, "MergeMergeRequest", r, f.mergeMR) | |
| 133 | + | } | |
| 134 | + | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | |
| 135 | + | return respond(f, "MarkRead", r, f.markRead) | |
| 136 | + | } | |
| 137 | + | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | |
| 138 | + | return respond(f, "GetSigningKey", r, f.getSigningKey) | |
| 139 | + | } | |
| 102 | 140 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | |
| 103 | 141 | f.record("Watch", r.Header(), r.Msg) | |
| 104 | 142 | if f.watch == nil { | |
| @@ -120,6 +158,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 120 | 158 | mount(p, h) | |
| 121 | 159 | p, h = ermiteconnect.NewEventServiceHandler(f) | |
| 122 | 160 | mount(p, h) | |
| 161 | + | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | |
| 162 | + | mount(p, h) | |
| 123 | 163 | srv := httptest.NewServer(mux) | |
| 124 | 164 | t.Cleanup(srv.Close) | |
| 125 | 165 | return srv.URL | |
| @@ -28,6 +28,7 @@ type fakeAPI struct { | |||||
| 28 | ermiteconnect.UnimplementedTokenServiceHandler | 28 | ermiteconnect.UnimplementedTokenServiceHandler | ||
| 29 | ermiteconnect.UnimplementedServerServiceHandler | 29 | ermiteconnect.UnimplementedServerServiceHandler | ||
| 30 | ermiteconnect.UnimplementedEventServiceHandler | 30 | ermiteconnect.UnimplementedEventServiceHandler | ||
| 31 | + | ermiteconnect.UnimplementedMergeRequestServiceHandler | |||
| 31 | 32 | ||||
| 32 | mu sync.Mutex | 33 | mu sync.Mutex | ||
| 33 | calls []string // procedure names, in order | 34 | calls []string // procedure names, in order | ||
| @@ -44,6 +45,16 @@ type fakeAPI struct { | |||||
| 44 | revokeToken func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error) | 45 | revokeToken func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error) | ||
| 45 | getVersion func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) | 46 | getVersion func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) | ||
| 46 | watch func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error | 47 | watch func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error | ||
| 48 | + | ||||
| 49 | + | createMR func(*ermite.CreateMergeRequestRequest) (*ermite.CreateMergeRequestResponse, error) | |||
| 50 | + | listMR func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) | |||
| 51 | + | getMR func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) | |||
| 52 | + | addComment func(*ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) | |||
| 53 | + | resolveCmt func(*ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error) | |||
| 54 | + | closeMR func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) | |||
| 55 | + | mergeMR func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) | |||
| 56 | + | markRead func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) | |||
| 57 | + | getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) | |||
| 47 | } | 58 | } | ||
| 48 | 59 | ||||
| 49 | func (f *fakeAPI) record(name string, h http.Header, req any) { | 60 | func (f *fakeAPI) record(name string, h http.Header, req any) { | ||
| @@ -99,6 +110,33 @@ func (f *fakeAPI) RevokeToken(_ context.Context, r *connect.Request[ermite.Revok | |||||
| 99 | func (f *fakeAPI) GetVersion(_ context.Context, r *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) { | 110 | func (f *fakeAPI) GetVersion(_ context.Context, r *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) { | ||
| 100 | return respond(f, "GetVersion", r, f.getVersion) | 111 | return respond(f, "GetVersion", r, f.getVersion) | ||
| 101 | } | 112 | } | ||
| 113 | + | func (f *fakeAPI) CreateMergeRequest(_ context.Context, r *connect.Request[ermite.CreateMergeRequestRequest]) (*connect.Response[ermite.CreateMergeRequestResponse], error) { | |||
| 114 | + | return respond(f, "CreateMergeRequest", r, f.createMR) | |||
| 115 | + | } | |||
| 116 | + | func (f *fakeAPI) ListMergeRequests(_ context.Context, r *connect.Request[ermite.ListMergeRequestsRequest]) (*connect.Response[ermite.ListMergeRequestsResponse], error) { | |||
| 117 | + | return respond(f, "ListMergeRequests", r, f.listMR) | |||
| 118 | + | } | |||
| 119 | + | func (f *fakeAPI) GetMergeRequest(_ context.Context, r *connect.Request[ermite.GetMergeRequestRequest]) (*connect.Response[ermite.GetMergeRequestResponse], error) { | |||
| 120 | + | return respond(f, "GetMergeRequest", r, f.getMR) | |||
| 121 | + | } | |||
| 122 | + | func (f *fakeAPI) AddComment(_ context.Context, r *connect.Request[ermite.AddCommentRequest]) (*connect.Response[ermite.AddCommentResponse], error) { | |||
| 123 | + | return respond(f, "AddComment", r, f.addComment) | |||
| 124 | + | } | |||
| 125 | + | func (f *fakeAPI) ResolveComment(_ context.Context, r *connect.Request[ermite.ResolveCommentRequest]) (*connect.Response[ermite.ResolveCommentResponse], error) { | |||
| 126 | + | return respond(f, "ResolveComment", r, f.resolveCmt) | |||
| 127 | + | } | |||
| 128 | + | func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite.CloseMergeRequestRequest]) (*connect.Response[ermite.CloseMergeRequestResponse], error) { | |||
| 129 | + | return respond(f, "CloseMergeRequest", r, f.closeMR) | |||
| 130 | + | } | |||
| 131 | + | func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) { | |||
| 132 | + | return respond(f, "MergeMergeRequest", r, f.mergeMR) | |||
| 133 | + | } | |||
| 134 | + | func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) { | |||
| 135 | + | return respond(f, "MarkRead", r, f.markRead) | |||
| 136 | + | } | |||
| 137 | + | func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) { | |||
| 138 | + | return respond(f, "GetSigningKey", r, f.getSigningKey) | |||
| 139 | + | } | |||
| 102 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | 140 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | ||
| 103 | f.record("Watch", r.Header(), r.Msg) | 141 | f.record("Watch", r.Header(), r.Msg) | ||
| 104 | if f.watch == nil { | 142 | if f.watch == nil { | ||
| @@ -120,6 +158,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||||
| 120 | mount(p, h) | 158 | mount(p, h) | ||
| 121 | p, h = ermiteconnect.NewEventServiceHandler(f) | 159 | p, h = ermiteconnect.NewEventServiceHandler(f) | ||
| 122 | mount(p, h) | 160 | mount(p, h) | ||
| 161 | + | p, h = ermiteconnect.NewMergeRequestServiceHandler(f) | |||
| 162 | + | mount(p, h) | |||
| 123 | srv := httptest.NewServer(mux) | 163 | srv := httptest.NewServer(mux) | ||
| 124 | t.Cleanup(srv.Close) | 164 | t.Cleanup(srv.Close) | ||
| 125 | return srv.URL | 165 | return srv.URL | ||
A cmd/ermite/cli_mr.go +842 -0
| @@ -0,0 +1,842 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "connectrpc.com/connect" | |
| 14 | + | "github.com/spf13/cobra" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Names of the states, the sides and the merge modes in the output and in the | |
| 21 | + | // flags. | |
| 22 | + | const ( | |
| 23 | + | stateOpen = "open" | |
| 24 | + | stateMerged = "merged" | |
| 25 | + | stateClosed = "closed" | |
| 26 | + | stateAll = "all" | |
| 27 | + | ||
| 28 | + | sideOld = "old" | |
| 29 | + | sideNew = "new" | |
| 30 | + | ||
| 31 | + | modeFastForward = "fast-forward" | |
| 32 | + | modeSquash = "squash" | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | func stateName(s ermite.MergeRequestState) string { | |
| 36 | + | switch s { | |
| 37 | + | case ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN: | |
| 38 | + | return stateOpen | |
| 39 | + | case ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED: | |
| 40 | + | return stateMerged | |
| 41 | + | case ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED: | |
| 42 | + | return stateClosed | |
| 43 | + | } | |
| 44 | + | return "unknown" | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func parseState(s string) (ermite.MergeRequestState, error) { | |
| 48 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |
| 49 | + | case stateOpen: | |
| 50 | + | return ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, nil | |
| 51 | + | case stateMerged: | |
| 52 | + | return ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, nil | |
| 53 | + | case stateClosed: | |
| 54 | + | return ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, nil | |
| 55 | + | case stateAll: | |
| 56 | + | return ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, nil | |
| 57 | + | } | |
| 58 | + | return 0, fmt.Errorf("invalid state %q: use %s, %s, %s or %s", s, stateOpen, stateMerged, stateClosed, stateAll) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func modeName(m ermite.MergeMode) string { | |
| 62 | + | switch m { | |
| 63 | + | case ermite.MergeMode_MERGE_MODE_FAST_FORWARD: | |
| 64 | + | return modeFastForward | |
| 65 | + | case ermite.MergeMode_MERGE_MODE_SQUASH: | |
| 66 | + | return modeSquash | |
| 67 | + | } | |
| 68 | + | return "" | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func parseMode(s string) (ermite.MergeMode, error) { | |
| 72 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |
| 73 | + | case modeFastForward, "ff": | |
| 74 | + | return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil | |
| 75 | + | case modeSquash: | |
| 76 | + | return ermite.MergeMode_MERGE_MODE_SQUASH, nil | |
| 77 | + | } | |
| 78 | + | return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func sideName(s ermite.DiffSide) string { | |
| 82 | + | switch s { | |
| 83 | + | case ermite.DiffSide_DIFF_SIDE_OLD: | |
| 84 | + | return sideOld | |
| 85 | + | case ermite.DiffSide_DIFF_SIDE_NEW: | |
| 86 | + | return sideNew | |
| 87 | + | } | |
| 88 | + | return "" | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func parseSide(s string) (ermite.DiffSide, error) { | |
| 92 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |
| 93 | + | case sideOld: | |
| 94 | + | return ermite.DiffSide_DIFF_SIDE_OLD, nil | |
| 95 | + | case sideNew: | |
| 96 | + | return ermite.DiffSide_DIFF_SIDE_NEW, nil | |
| 97 | + | } | |
| 98 | + | return 0, fmt.Errorf("invalid side %q: use %s or %s", s, sideOld, sideNew) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // jsonMergeRequest is the stable JSON shape of a merge request. | |
| 102 | + | type jsonMergeRequest struct { | |
| 103 | + | Repo string `json:"repo"` | |
| 104 | + | Number int64 `json:"number"` | |
| 105 | + | Title string `json:"title"` | |
| 106 | + | Description string `json:"description"` | |
| 107 | + | SourceBranch string `json:"source_branch"` | |
| 108 | + | TargetBranch string `json:"target_branch"` | |
| 109 | + | State string `json:"state"` | |
| 110 | + | Author string `json:"author"` | |
| 111 | + | CreatedAt *string `json:"created_at"` | |
| 112 | + | UpdatedAt *string `json:"updated_at"` | |
| 113 | + | HeadID string `json:"head_id"` | |
| 114 | + | MergedCommitID string `json:"merged_commit_id"` | |
| 115 | + | MergeMode string `json:"merge_mode"` | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest { | |
| 119 | + | return jsonMergeRequest{ | |
| 120 | + | Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(), | |
| 121 | + | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | |
| 122 | + | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | |
| 123 | + | HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()), | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // jsonComment is the stable JSON shape of a comment. | |
| 128 | + | type jsonComment struct { | |
| 129 | + | ID string `json:"id"` | |
| 130 | + | Author string `json:"author"` | |
| 131 | + | Path string `json:"path"` | |
| 132 | + | Line int32 `json:"line"` | |
| 133 | + | Side string `json:"side"` | |
| 134 | + | CommitID string `json:"commit_id"` | |
| 135 | + | Text string `json:"text"` | |
| 136 | + | Resolved bool `json:"resolved"` | |
| 137 | + | CreatedAt *string `json:"created_at"` | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func toJSONComment(c *ermite.Comment) jsonComment { | |
| 141 | + | return jsonComment{ | |
| 142 | + | ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()), | |
| 143 | + | CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()), | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | const jsonMRHelp = ` | |
| 148 | + | JSON fields of a merge request (--json): repo, number, title, description, source_branch, | |
| 149 | + | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | |
| 150 | + | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | |
| 151 | + | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | |
| 152 | + | unless merged).` | |
| 153 | + | ||
| 154 | + | const jsonCommentHelp = ` | |
| 155 | + | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | |
| 156 | + | and empty side for a general comment), commit_id (the head when it was written), text, | |
| 157 | + | resolved, created_at.` | |
| 158 | + | ||
| 159 | + | func newMRCmd(env *cliEnv) *cobra.Command { | |
| 160 | + | mr := &cobra.Command{ | |
| 161 | + | Use: "mr", | |
| 162 | + | Short: "Review and merge branches on a remote server", | |
| 163 | + | Long: "Merge requests ask to merge one branch of a repository into another. They are about a branch,\n" + | |
| 164 | + | "not about who wrote it. Needs a server with the merge_requests feature (see `ermite version`).\n" + | |
| 165 | + | "A merge request is named by its repository and its number: `ermite mr show proj 3`.", | |
| 166 | + | } | |
| 167 | + | mr.AddCommand( | |
| 168 | + | newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env), | |
| 169 | + | newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), | |
| 170 | + | ) | |
| 171 | + | return mr | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // parseNumber reads the number of a merge request, written 3 or !3. | |
| 175 | + | func parseNumber(s string) (int64, error) { | |
| 176 | + | n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "!"), 10, 64) | |
| 177 | + | if err != nil || n < 1 { | |
| 178 | + | return 0, fmt.Errorf("invalid merge request number %q: use a positive number such as 3 or !3", s) | |
| 179 | + | } | |
| 180 | + | return n, nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // readText returns value, or all of standard input when value is "-". | |
| 184 | + | func readText(cmd *cobra.Command, value string) (string, error) { | |
| 185 | + | if value != "-" { | |
| 186 | + | return value, nil | |
| 187 | + | } | |
| 188 | + | b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20)) | |
| 189 | + | if err != nil { | |
| 190 | + | return "", fmt.Errorf("read standard input: %w", err) | |
| 191 | + | } | |
| 192 | + | return strings.TrimRight(string(b), "\r\n"), nil | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 196 | + | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { | |
| 197 | + | switch len(args) { | |
| 198 | + | case 0: | |
| 199 | + | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | |
| 200 | + | case 1: | |
| 201 | + | c, err := env.client(cmd, false) | |
| 202 | + | if err != nil { | |
| 203 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 204 | + | } | |
| 205 | + | mrs, err := listMergeRequests(env.ctx(cmd), c, args[0], ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED) | |
| 206 | + | if err != nil { | |
| 207 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 208 | + | } | |
| 209 | + | var out []string | |
| 210 | + | for _, m := range mrs { | |
| 211 | + | if n := strconv.FormatInt(m.GetNumber(), 10); strings.HasPrefix(n, toComplete) { | |
| 212 | + | out = append(out, n+"\t"+stateName(m.GetState())+", "+clean(m.GetTitle())) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | return out, cobra.ShellCompDirectiveNoFileComp | |
| 216 | + | } | |
| 217 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func listMergeRequests(ctx context.Context, c *client.Client, repo string, state ermite.MergeRequestState) ([]*ermite.MergeRequest, error) { | |
| 222 | + | return collect(ctx, func(token string) ([]*ermite.MergeRequest, string, error) { | |
| 223 | + | res, err := c.MergeRequests().ListMergeRequests(ctx, connect.NewRequest(&ermite.ListMergeRequestsRequest{ | |
| 224 | + | Repo: repo, State: state, PageToken: token, | |
| 225 | + | })) | |
| 226 | + | if err != nil { | |
| 227 | + | return nil, "", c.Describe(err) | |
| 228 | + | } | |
| 229 | + | return res.Msg.GetMergeRequests(), res.Msg.GetNextPageToken(), nil | |
| 230 | + | }) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func newMRCreateCmd(env *cliEnv) *cobra.Command { | |
| 234 | + | var title, description, source, target string | |
| 235 | + | cmd := &cobra.Command{ | |
| 236 | + | Use: "create <repo>", | |
| 237 | + | Short: "Open a merge request", | |
| 238 | + | Long: "Open a merge request for a branch of the repository. --target defaults to the default branch.\n" + | |
| 239 | + | "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" + | |
| 240 | + | "neither may hold control characters (a description may hold line breaks) or bidirectional\n" + | |
| 241 | + | "override characters. --description - reads it from standard input. Fails if an open merge request\n" + | |
| 242 | + | "already has the same source and target. Needs the mr:write scope." + jsonMRHelp, | |
| 243 | + | Args: cobra.ExactArgs(1), | |
| 244 | + | ValidArgsFunction: completeRepoNames(env), | |
| 245 | + | } | |
| 246 | + | cmd.Flags().StringVar(&title, "title", "", "title of the merge request (required)") | |
| 247 | + | cmd.Flags().StringVar(&description, "description", "", "description, or - to read standard input") | |
| 248 | + | cmd.Flags().StringVar(&source, "source", "", "branch to merge (required)") | |
| 249 | + | cmd.Flags().StringVar(&target, "target", "", "branch to merge into (default: the default branch)") | |
| 250 | + | _ = cmd.MarkFlagRequired("title") | |
| 251 | + | _ = cmd.MarkFlagRequired("source") | |
| 252 | + | asJSON := addJSONFlag(cmd) | |
| 253 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 254 | + | desc, err := readText(cmd, description) | |
| 255 | + | if err != nil { | |
| 256 | + | return err | |
| 257 | + | } | |
| 258 | + | req := &ermite.CreateMergeRequestRequest{ | |
| 259 | + | Repo: args[0], Title: title, Description: desc, | |
| 260 | + | SourceBranch: trimBranch(source), TargetBranch: trimBranch(target), | |
| 261 | + | } | |
| 262 | + | c, err := env.client(cmd, true) | |
| 263 | + | if err != nil { | |
| 264 | + | return err | |
| 265 | + | } | |
| 266 | + | res, err := c.MergeRequests().CreateMergeRequest(env.ctx(cmd), connect.NewRequest(req)) | |
| 267 | + | if err != nil { | |
| 268 | + | return c.Describe(err) | |
| 269 | + | } | |
| 270 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened") | |
| 271 | + | } | |
| 272 | + | return cmd | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") } | |
| 276 | + | ||
| 277 | + | // outputMR prints a merge request as JSON, or as one confirmation line. | |
| 278 | + | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error { | |
| 279 | + | if asJSON { | |
| 280 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m)) | |
| 281 | + | } | |
| 282 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(), | |
| 283 | + | clean(m.GetSourceBranch()), clean(m.GetTargetBranch())) | |
| 284 | + | return err | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func newMRListCmd(env *cliEnv) *cobra.Command { | |
| 288 | + | var state string | |
| 289 | + | cmd := &cobra.Command{ | |
| 290 | + | Use: "list <repo>", | |
| 291 | + | Short: "List the merge requests of a repository", | |
| 292 | + | Long: "List the merge requests of a repository, newest first. --state restricts them to open, merged or\n" + | |
| 293 | + | "closed ones (default: open; all shows every state)." + jsonMRHelp + "\n--json prints an array of merge requests.", | |
| 294 | + | Args: cobra.ExactArgs(1), | |
| 295 | + | ValidArgsFunction: completeRepoNames(env), | |
| 296 | + | } | |
| 297 | + | cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, merged, closed or all") | |
| 298 | + | _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 299 | + | return []string{stateOpen, stateMerged, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp | |
| 300 | + | }) | |
| 301 | + | asJSON := addJSONFlag(cmd) | |
| 302 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 303 | + | st, err := parseState(state) | |
| 304 | + | if err != nil { | |
| 305 | + | return err | |
| 306 | + | } | |
| 307 | + | c, err := env.client(cmd, true) | |
| 308 | + | if err != nil { | |
| 309 | + | return err | |
| 310 | + | } | |
| 311 | + | mrs, err := listMergeRequests(env.ctx(cmd), c, args[0], st) | |
| 312 | + | if err != nil { | |
| 313 | + | return err | |
| 314 | + | } | |
| 315 | + | if *asJSON { | |
| 316 | + | out := make([]jsonMergeRequest, 0, len(mrs)) | |
| 317 | + | for _, m := range mrs { | |
| 318 | + | out = append(out, toJSONMergeRequest(m)) | |
| 319 | + | } | |
| 320 | + | return writeJSON(cmd.OutOrStdout(), out) | |
| 321 | + | } | |
| 322 | + | if len(mrs) == 0 { | |
| 323 | + | noticef(cmd, "no merge requests") | |
| 324 | + | return nil | |
| 325 | + | } | |
| 326 | + | rows := make([][]string, 0, len(mrs)) | |
| 327 | + | for _, m := range mrs { | |
| 328 | + | rows = append(rows, []string{ | |
| 329 | + | "!" + strconv.FormatInt(m.GetNumber(), 10), stateName(m.GetState()), | |
| 330 | + | m.GetSourceBranch() + " -> " + m.GetTargetBranch(), m.GetAuthor(), | |
| 331 | + | shortTime(m.GetUpdatedAt(), "-"), m.GetTitle(), | |
| 332 | + | }) | |
| 333 | + | } | |
| 334 | + | return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "BRANCHES", "AUTHOR", "UPDATED", "TITLE"}, rows) | |
| 335 | + | } | |
| 336 | + | return cmd | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // jsonStats are the totals of a diff. | |
| 340 | + | type jsonStats struct { | |
| 341 | + | FilesChanged int32 `json:"files_changed"` | |
| 342 | + | Additions int32 `json:"additions"` | |
| 343 | + | Deletions int32 `json:"deletions"` | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | type jsonLine struct { | |
| 347 | + | Kind string `json:"kind"` | |
| 348 | + | OldNumber int32 `json:"old_number"` | |
| 349 | + | NewNumber int32 `json:"new_number"` | |
| 350 | + | Text string `json:"text"` | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | type jsonHunk struct { | |
| 354 | + | Header string `json:"header"` | |
| 355 | + | OldStart int32 `json:"old_start"` | |
| 356 | + | OldLines int32 `json:"old_lines"` | |
| 357 | + | NewStart int32 `json:"new_start"` | |
| 358 | + | NewLines int32 `json:"new_lines"` | |
| 359 | + | Lines []jsonLine `json:"lines"` | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | type jsonFile struct { | |
| 363 | + | OldPath string `json:"old_path"` | |
| 364 | + | NewPath string `json:"new_path"` | |
| 365 | + | Status string `json:"status"` | |
| 366 | + | Additions int32 `json:"additions"` | |
| 367 | + | Deletions int32 `json:"deletions"` | |
| 368 | + | Binary bool `json:"binary"` | |
| 369 | + | Truncated bool `json:"truncated"` | |
| 370 | + | Hunks []jsonHunk `json:"hunks,omitempty"` | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | type jsonCommit struct { | |
| 374 | + | ID string `json:"id"` | |
| 375 | + | Author string `json:"author"` | |
| 376 | + | Summary string `json:"summary"` | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // jsonMRDetail is the stable JSON shape of `mr show --json`. | |
| 380 | + | type jsonMRDetail struct { | |
| 381 | + | jsonMergeRequest | |
| 382 | + | Commits []jsonCommit `json:"commits"` | |
| 383 | + | CommitsTruncated bool `json:"commits_truncated"` | |
| 384 | + | DiffBaseID string `json:"diff_base_id"` | |
| 385 | + | Stats jsonStats `json:"stats"` | |
| 386 | + | DiffTruncated bool `json:"diff_truncated"` | |
| 387 | + | Files []jsonFile `json:"files"` | |
| 388 | + | Comments []jsonComment `json:"comments"` | |
| 389 | + | LastReadID string `json:"last_read_id"` | |
| 390 | + | LastReadLost bool `json:"last_read_lost"` | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func fileStatusName(s ermite.FileStatus) string { | |
| 394 | + | return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_")) | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | func lineKindName(k ermite.LineKind) string { | |
| 398 | + | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail { | |
| 402 | + | out := jsonMRDetail{ | |
| 403 | + | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()), | |
| 404 | + | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | |
| 405 | + | DiffBaseID: d.GetDiffBaseId(), | |
| 406 | + | Stats: jsonStats{ | |
| 407 | + | FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(), | |
| 408 | + | }, | |
| 409 | + | DiffTruncated: d.GetDiff().GetTruncated(), | |
| 410 | + | Files: []jsonFile{}, Comments: []jsonComment{}, | |
| 411 | + | LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(), | |
| 412 | + | } | |
| 413 | + | for _, c := range d.GetCommits() { | |
| 414 | + | out.Commits = append(out.Commits, jsonCommit{ID: c.GetId(), Author: c.GetAuthor().GetName(), Summary: c.GetSummary()}) | |
| 415 | + | } | |
| 416 | + | for _, f := range d.GetDiff().GetFiles() { | |
| 417 | + | jf := jsonFile{ | |
| 418 | + | OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()), | |
| 419 | + | Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(), | |
| 420 | + | } | |
| 421 | + | if withDiff { | |
| 422 | + | for _, h := range f.GetHunks() { | |
| 423 | + | jh := jsonHunk{ | |
| 424 | + | Header: h.GetHeader(), OldStart: h.GetOldStart(), OldLines: h.GetOldLines(), | |
| 425 | + | NewStart: h.GetNewStart(), NewLines: h.GetNewLines(), Lines: []jsonLine{}, | |
| 426 | + | } | |
| 427 | + | for _, l := range h.GetLines() { | |
| 428 | + | jh.Lines = append(jh.Lines, jsonLine{ | |
| 429 | + | Kind: lineKindName(l.GetKind()), OldNumber: l.GetOldNumber(), NewNumber: l.GetNewNumber(), Text: l.GetText(), | |
| 430 | + | }) | |
| 431 | + | } | |
| 432 | + | jf.Hunks = append(jf.Hunks, jh) | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | out.Files = append(out.Files, jf) | |
| 436 | + | } | |
| 437 | + | for _, c := range d.GetComments() { | |
| 438 | + | out.Comments = append(out.Comments, toJSONComment(c)) | |
| 439 | + | } | |
| 440 | + | return out | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func newMRShowCmd(env *cliEnv) *cobra.Command { | |
| 444 | + | var sinceLastRead, withDiff, markRead bool | |
| 445 | + | cmd := &cobra.Command{ | |
| 446 | + | Use: "show <repo> <number>", | |
| 447 | + | Short: "Show a merge request with its commits, comments and diff", | |
| 448 | + | Long: "Show a merge request: its fields, the commits of the branch that the target lacks, the changed\n" + | |
| 449 | + | "files and the comments. --diff adds the diff, with the line numbers to use in `mr comment`.\n" + | |
| 450 | + | "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" + | |
| 451 | + | "the whole change; after a force-push it still works when the recorded commit exists, else the\n" + | |
| 452 | + | "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" + | |
| 453 | + | "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" + | |
| 454 | + | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | |
| 455 | + | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | |
| 456 | + | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | |
| 457 | + | "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + | |
| 458 | + | "and last_read_lost.", | |
| 459 | + | Args: cobra.ExactArgs(2), | |
| 460 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 461 | + | } | |
| 462 | + | cmd.Flags().BoolVar(&withDiff, "diff", false, "include the diff") | |
| 463 | + | cmd.Flags().BoolVar(&sinceLastRead, "since-last-read", false, "diff since the head last marked read") | |
| 464 | + | cmd.Flags().BoolVar(&markRead, "mark-read", false, "record the head shown as read (needs mr:write)") | |
| 465 | + | asJSON := addJSONFlag(cmd) | |
| 466 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 467 | + | n, err := parseNumber(args[1]) | |
| 468 | + | if err != nil { | |
| 469 | + | return err | |
| 470 | + | } | |
| 471 | + | c, err := env.client(cmd, true) | |
| 472 | + | if err != nil { | |
| 473 | + | return err | |
| 474 | + | } | |
| 475 | + | ctx := env.ctx(cmd) | |
| 476 | + | res, err := c.MergeRequests().GetMergeRequest(ctx, connect.NewRequest(&ermite.GetMergeRequestRequest{ | |
| 477 | + | Repo: args[0], Number: n, SinceLastRead: sinceLastRead, | |
| 478 | + | })) | |
| 479 | + | if err != nil { | |
| 480 | + | return c.Describe(err) | |
| 481 | + | } | |
| 482 | + | d := res.Msg | |
| 483 | + | if *asJSON { | |
| 484 | + | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff)) | |
| 485 | + | } else { | |
| 486 | + | err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead) | |
| 487 | + | } | |
| 488 | + | if err != nil { | |
| 489 | + | return err | |
| 490 | + | } | |
| 491 | + | if markRead && d.GetMergeRequest().GetHeadId() != "" { | |
| 492 | + | _, err = c.MergeRequests().MarkRead(ctx, connect.NewRequest(&ermite.MarkReadRequest{ | |
| 493 | + | Repo: args[0], Number: n, HeadId: d.GetMergeRequest().GetHeadId(), | |
| 494 | + | })) | |
| 495 | + | if err != nil { | |
| 496 | + | return c.Describe(err) | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | if d.GetLastReadLost() { | |
| 500 | + | noticef(cmd, "the head you last read is gone (force-push?): showing the whole change") | |
| 501 | + | } | |
| 502 | + | return nil | |
| 503 | + | } | |
| 504 | + | return cmd | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, since bool) error { | |
| 508 | + | m := d.GetMergeRequest() | |
| 509 | + | desc := m.GetDescription() | |
| 510 | + | if desc == "" { | |
| 511 | + | desc = "-" | |
| 512 | + | } | |
| 513 | + | pairs := [][2]string{ | |
| 514 | + | {"merge request", fmt.Sprintf("%s!%d", m.GetRepo(), m.GetNumber())}, | |
| 515 | + | {"title", m.GetTitle()}, | |
| 516 | + | {"state", stateName(m.GetState())}, | |
| 517 | + | {"branches", m.GetSourceBranch() + " -> " + m.GetTargetBranch()}, | |
| 518 | + | {"author", m.GetAuthor()}, | |
| 519 | + | {"created", shortTime(m.GetCreatedAt(), "-")}, | |
| 520 | + | {"updated", shortTime(m.GetUpdatedAt(), "-")}, | |
| 521 | + | {"head", orDash(m.GetHeadId())}, | |
| 522 | + | } | |
| 523 | + | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | |
| 524 | + | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | |
| 525 | + | } | |
| 526 | + | if err := fields(w, pairs); err != nil { | |
| 527 | + | return err | |
| 528 | + | } | |
| 529 | + | _, _ = fmt.Fprintf(w, "\n%s\n", indentText(desc)) | |
| 530 | + | ||
| 531 | + | _, _ = fmt.Fprintf(w, "\ncommits (%d%s)\n", len(d.GetCommits()), plus(d.GetCommitsTruncated())) | |
| 532 | + | for _, c := range d.GetCommits() { | |
| 533 | + | _, _ = fmt.Fprintf(w, " %s %s %s\n", shortID(c.GetId()), clean(c.GetAuthor().GetName()), clean(c.GetSummary())) | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | st := d.GetStats() | |
| 537 | + | what := "changes" | |
| 538 | + | if since && !d.GetLastReadLost() && d.GetLastReadId() != "" { | |
| 539 | + | what = "changes since " + shortID(d.GetLastReadId()) | |
| 540 | + | } | |
| 541 | + | _, _ = fmt.Fprintf(w, "\n%s: %d file(s), +%d -%d, from %s\n", what, st.GetFilesChanged(), st.GetAdditions(), st.GetDeletions(), orDash(shortID(d.GetDiffBaseId()))) | |
| 542 | + | for _, f := range d.GetDiff().GetFiles() { | |
| 543 | + | path := f.GetNewPath() | |
| 544 | + | if path == "" { | |
| 545 | + | path = f.GetOldPath() | |
| 546 | + | } | |
| 547 | + | note := "" | |
| 548 | + | if f.GetTruncated() { | |
| 549 | + | note = " (too large, not shown)" | |
| 550 | + | } | |
| 551 | + | _, _ = fmt.Fprintf(w, " %-8s +%d -%d %s%s\n", fileStatusName(f.GetStatus()), f.GetAdditions(), f.GetDeletions(), clean(path), note) | |
| 552 | + | } | |
| 553 | + | if d.GetDiff().GetTruncated() { | |
| 554 | + | _, _ = fmt.Fprintln(w, " (the diff is truncated)") | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())) | |
| 558 | + | for _, c := range d.GetComments() { | |
| 559 | + | where := "general" | |
| 560 | + | if c.GetPath() != "" { | |
| 561 | + | where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide())) | |
| 562 | + | } | |
| 563 | + | mark := "" | |
| 564 | + | if c.GetResolved() { | |
| 565 | + | mark = " [resolved]" | |
| 566 | + | } | |
| 567 | + | _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText())) | |
| 568 | + | } | |
| 569 | + | if withDiff { | |
| 570 | + | printDiff(w, d.GetDiff()) | |
| 571 | + | } | |
| 572 | + | return nil | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | func orDash(s string) string { | |
| 576 | + | if s == "" { | |
| 577 | + | return "-" | |
| 578 | + | } | |
| 579 | + | return s | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | func plus(truncated bool) string { | |
| 583 | + | if truncated { | |
| 584 | + | return "+" | |
| 585 | + | } | |
| 586 | + | return "" | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func shortID(id string) string { | |
| 590 | + | if len(id) > 10 { | |
| 591 | + | return id[:10] | |
| 592 | + | } | |
| 593 | + | return id | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | // indentText indents a multi-line text by four spaces, making each line safe | |
| 597 | + | // for a terminal. | |
| 598 | + | func indentText(s string) string { | |
| 599 | + | lines := strings.Split(s, "\n") | |
| 600 | + | for i, l := range lines { | |
| 601 | + | lines[i] = " " + client.Clean(l) | |
| 602 | + | } | |
| 603 | + | return strings.Join(lines, "\n") | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // printDiff prints the diff with a gutter of the old and new line numbers, | |
| 607 | + | // which are the numbers `mr comment --line` takes with --side old or new. | |
| 608 | + | func printDiff(w io.Writer, d *ermite.Diff) { | |
| 609 | + | _, _ = fmt.Fprintln(w, "\ndiff (old and new line numbers on the left)") | |
| 610 | + | num := func(n int32) string { | |
| 611 | + | if n == 0 { | |
| 612 | + | return "" | |
| 613 | + | } | |
| 614 | + | return strconv.Itoa(int(n)) | |
| 615 | + | } | |
| 616 | + | for _, f := range d.GetFiles() { | |
| 617 | + | _, _ = fmt.Fprintf(w, "\n--- %s\n+++ %s\n", clean(orDash(f.GetOldPath())), clean(orDash(f.GetNewPath()))) | |
| 618 | + | if f.GetBinary() { | |
| 619 | + | _, _ = fmt.Fprintln(w, "(binary file)") | |
| 620 | + | } | |
| 621 | + | if f.GetTruncated() { | |
| 622 | + | _, _ = fmt.Fprintln(w, "(too large, not shown)") | |
| 623 | + | } | |
| 624 | + | for _, h := range f.GetHunks() { | |
| 625 | + | _, _ = fmt.Fprintf(w, "@@ -%d,%d +%d,%d @@ %s\n", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines(), clean(h.GetHeader())) | |
| 626 | + | for _, l := range h.GetLines() { | |
| 627 | + | sign := map[ermite.LineKind]string{ | |
| 628 | + | ermite.LineKind_LINE_KIND_ADDITION: "+", ermite.LineKind_LINE_KIND_DELETION: "-", | |
| 629 | + | }[l.GetKind()] | |
| 630 | + | if sign == "" { | |
| 631 | + | sign = " " | |
| 632 | + | } | |
| 633 | + | _, _ = fmt.Fprintf(w, "%5s %5s %s%s\n", num(l.GetOldNumber()), num(l.GetNewNumber()), sign, clean(l.GetText())) | |
| 634 | + | } | |
| 635 | + | } | |
| 636 | + | } | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | func newMRCommentCmd(env *cliEnv) *cobra.Command { | |
| 640 | + | var message, path, side string | |
| 641 | + | var line int | |
| 642 | + | cmd := &cobra.Command{ | |
| 643 | + | Use: "comment <repo> <number>", | |
| 644 | + | Short: "Comment on a merge request", | |
| 645 | + | Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" + | |
| 646 | + | "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" + | |
| 647 | + | "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" + | |
| 648 | + | "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" + | |
| 649 | + | "that is not in the diff is refused. Needs the mr:write scope." + jsonCommentHelp, | |
| 650 | + | Args: cobra.ExactArgs(2), | |
| 651 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 652 | + | } | |
| 653 | + | cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)") | |
| 654 | + | cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to") | |
| 655 | + | cmd.Flags().IntVar(&line, "line", 0, "1-based line number on --side") | |
| 656 | + | cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new") | |
| 657 | + | _ = cmd.MarkFlagRequired("message") | |
| 658 | + | _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 659 | + | return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp | |
| 660 | + | }) | |
| 661 | + | asJSON := addJSONFlag(cmd) | |
| 662 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 663 | + | n, err := parseNumber(args[1]) | |
| 664 | + | if err != nil { | |
| 665 | + | return err | |
| 666 | + | } | |
| 667 | + | text, err := readText(cmd, message) | |
| 668 | + | if err != nil { | |
| 669 | + | return err | |
| 670 | + | } | |
| 671 | + | req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text} | |
| 672 | + | switch { | |
| 673 | + | case path != "": | |
| 674 | + | if line < 1 { | |
| 675 | + | return errors.New("--line is required with --path: give the line number") | |
| 676 | + | } | |
| 677 | + | if req.Side, err = parseSide(side); err != nil { | |
| 678 | + | return err | |
| 679 | + | } | |
| 680 | + | req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person | |
| 681 | + | case line != 0 || cmd.Flags().Changed("side"): | |
| 682 | + | return errors.New("--line and --side need --path") | |
| 683 | + | } | |
| 684 | + | c, err := env.client(cmd, true) | |
| 685 | + | if err != nil { | |
| 686 | + | return err | |
| 687 | + | } | |
| 688 | + | res, err := c.MergeRequests().AddComment(env.ctx(cmd), connect.NewRequest(req)) | |
| 689 | + | if err != nil { | |
| 690 | + | return c.Describe(err) | |
| 691 | + | } | |
| 692 | + | if *asJSON { | |
| 693 | + | return writeJSON(cmd.OutOrStdout(), toJSONComment(res.Msg.GetComment())) | |
| 694 | + | } | |
| 695 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s!%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n) | |
| 696 | + | return err | |
| 697 | + | } | |
| 698 | + | return cmd | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | func newMRResolveCmd(env *cliEnv) *cobra.Command { | |
| 702 | + | var undo bool | |
| 703 | + | cmd := &cobra.Command{ | |
| 704 | + | Use: "resolve <repo> <number> <comment-id>", | |
| 705 | + | Short: "Mark a comment resolved", | |
| 706 | + | Long: "Mark a comment of a merge request resolved, or unresolved with --undo. Doing it twice is not an\n" + | |
| 707 | + | "error. Needs the mr:write scope." + jsonCommentHelp, | |
| 708 | + | Args: cobra.ExactArgs(3), | |
| 709 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 710 | + | } | |
| 711 | + | cmd.Flags().BoolVar(&undo, "undo", false, "mark the comment unresolved instead") | |
| 712 | + | asJSON := addJSONFlag(cmd) | |
| 713 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 714 | + | n, err := parseNumber(args[1]) | |
| 715 | + | if err != nil { | |
| 716 | + | return err | |
| 717 | + | } | |
| 718 | + | id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#") | |
| 719 | + | c, err := env.client(cmd, true) | |
| 720 | + | if err != nil { | |
| 721 | + | return err | |
| 722 | + | } | |
| 723 | + | res, err := c.MergeRequests().ResolveComment(env.ctx(cmd), connect.NewRequest(&ermite.ResolveCommentRequest{ | |
| 724 | + | Repo: args[0], Number: n, CommentId: id, Resolved: !undo, | |
| 725 | + | })) | |
| 726 | + | if err != nil { | |
| 727 | + | return c.Describe(err) | |
| 728 | + | } | |
| 729 | + | if *asJSON { | |
| 730 | + | return writeJSON(cmd.OutOrStdout(), toJSONComment(res.Msg.GetComment())) | |
| 731 | + | } | |
| 732 | + | verb := "resolved" | |
| 733 | + | if undo { | |
| 734 | + | verb = "reopened" | |
| 735 | + | } | |
| 736 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, verb) | |
| 737 | + | return err | |
| 738 | + | } | |
| 739 | + | return cmd | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | func newMRMergeCmd(env *cliEnv) *cobra.Command { | |
| 743 | + | var squash bool | |
| 744 | + | var head string | |
| 745 | + | cmd := &cobra.Command{ | |
| 746 | + | Use: "merge <repo> <number>", | |
| 747 | + | Short: "Merge a merge request", | |
| 748 | + | Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" + | |
| 749 | + | "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" + | |
| 750 | + | "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" + | |
| 751 | + | "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" + | |
| 752 | + | "only one of the modes.\n\n" + | |
| 753 | + | "The server merges only if the source branch still points to the head you reviewed. --head gives\n" + | |
| 754 | + | "that commit (the head of `mr show`, a full id). Without it the CLI reads the current head and merges\n" + | |
| 755 | + | "that, which skips the safeguard: pass --head when others may push to the branch. Needs the\n" + | |
| 756 | + | "mr:merge scope." + jsonMRHelp, | |
| 757 | + | Args: cobra.ExactArgs(2), | |
| 758 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 759 | + | } | |
| 760 | + | cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward") | |
| 761 | + | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed (default: the current head)") | |
| 762 | + | asJSON := addJSONFlag(cmd) | |
| 763 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 764 | + | n, err := parseNumber(args[1]) | |
| 765 | + | if err != nil { | |
| 766 | + | return err | |
| 767 | + | } | |
| 768 | + | c, err := env.client(cmd, true) | |
| 769 | + | if err != nil { | |
| 770 | + | return err | |
| 771 | + | } | |
| 772 | + | ctx := env.ctx(cmd) | |
| 773 | + | head = strings.TrimSpace(head) | |
| 774 | + | if head == "" { | |
| 775 | + | cur, err := currentHead(ctx, c, args[0], n) | |
| 776 | + | if err != nil { | |
| 777 | + | return err | |
| 778 | + | } | |
| 779 | + | head = cur | |
| 780 | + | noticef(cmd, "merging %s!%d at %s (no --head given)", clean(args[0]), n, head) | |
| 781 | + | } | |
| 782 | + | mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD | |
| 783 | + | if squash { | |
| 784 | + | mode = ermite.MergeMode_MERGE_MODE_SQUASH | |
| 785 | + | } | |
| 786 | + | res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{ | |
| 787 | + | Repo: args[0], Number: n, Mode: mode, HeadId: head, | |
| 788 | + | })) | |
| 789 | + | if err != nil { | |
| 790 | + | return c.Describe(err) | |
| 791 | + | } | |
| 792 | + | m := res.Msg.GetMergeRequest() | |
| 793 | + | if *asJSON { | |
| 794 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m)) | |
| 795 | + | } | |
| 796 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |
| 797 | + | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | |
| 798 | + | return err | |
| 799 | + | } | |
| 800 | + | return cmd | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | // currentHead reads the head of a merge request from the server. | |
| 804 | + | func currentHead(ctx context.Context, c *client.Client, repo string, n int64) (string, error) { | |
| 805 | + | res, err := c.MergeRequests().GetMergeRequest(ctx, connect.NewRequest(&ermite.GetMergeRequestRequest{Repo: repo, Number: n})) | |
| 806 | + | if err != nil { | |
| 807 | + | return "", c.Describe(err) | |
| 808 | + | } | |
| 809 | + | head := res.Msg.GetMergeRequest().GetHeadId() | |
| 810 | + | if head == "" { | |
| 811 | + | return "", errors.New("the source branch no longer exists: nothing to merge") | |
| 812 | + | } | |
| 813 | + | return head, nil | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | func newMRCloseCmd(env *cliEnv) *cobra.Command { | |
| 817 | + | cmd := &cobra.Command{ | |
| 818 | + | Use: "close <repo> <number>", | |
| 819 | + | Short: "Close a merge request without merging it", | |
| 820 | + | Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" + | |
| 821 | + | "new one. Needs the mr:write scope." + jsonMRHelp, | |
| 822 | + | Args: cobra.ExactArgs(2), | |
| 823 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |
| 824 | + | } | |
| 825 | + | asJSON := addJSONFlag(cmd) | |
| 826 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 827 | + | n, err := parseNumber(args[1]) | |
| 828 | + | if err != nil { | |
| 829 | + | return err | |
| 830 | + | } | |
| 831 | + | c, err := env.client(cmd, true) | |
| 832 | + | if err != nil { | |
| 833 | + | return err | |
| 834 | + | } | |
| 835 | + | res, err := c.MergeRequests().CloseMergeRequest(env.ctx(cmd), connect.NewRequest(&ermite.CloseMergeRequestRequest{Repo: args[0], Number: n})) | |
| 836 | + | if err != nil { | |
| 837 | + | return c.Describe(err) | |
| 838 | + | } | |
| 839 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed") | |
| 840 | + | } | |
| 841 | + | return cmd | |
| 842 | + | } | |
| @@ -0,0 +1,842 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "strconv" | |||
| 11 | + | "strings" | |||
| 12 | + | ||||
| 13 | + | "connectrpc.com/connect" | |||
| 14 | + | "github.com/spf13/cobra" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 17 | + | "bnema.dev/ermite/internal/adapters/client" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // Names of the states, the sides and the merge modes in the output and in the | |||
| 21 | + | // flags. | |||
| 22 | + | const ( | |||
| 23 | + | stateOpen = "open" | |||
| 24 | + | stateMerged = "merged" | |||
| 25 | + | stateClosed = "closed" | |||
| 26 | + | stateAll = "all" | |||
| 27 | + | ||||
| 28 | + | sideOld = "old" | |||
| 29 | + | sideNew = "new" | |||
| 30 | + | ||||
| 31 | + | modeFastForward = "fast-forward" | |||
| 32 | + | modeSquash = "squash" | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | func stateName(s ermite.MergeRequestState) string { | |||
| 36 | + | switch s { | |||
| 37 | + | case ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN: | |||
| 38 | + | return stateOpen | |||
| 39 | + | case ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED: | |||
| 40 | + | return stateMerged | |||
| 41 | + | case ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED: | |||
| 42 | + | return stateClosed | |||
| 43 | + | } | |||
| 44 | + | return "unknown" | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func parseState(s string) (ermite.MergeRequestState, error) { | |||
| 48 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |||
| 49 | + | case stateOpen: | |||
| 50 | + | return ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, nil | |||
| 51 | + | case stateMerged: | |||
| 52 | + | return ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, nil | |||
| 53 | + | case stateClosed: | |||
| 54 | + | return ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, nil | |||
| 55 | + | case stateAll: | |||
| 56 | + | return ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, nil | |||
| 57 | + | } | |||
| 58 | + | return 0, fmt.Errorf("invalid state %q: use %s, %s, %s or %s", s, stateOpen, stateMerged, stateClosed, stateAll) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func modeName(m ermite.MergeMode) string { | |||
| 62 | + | switch m { | |||
| 63 | + | case ermite.MergeMode_MERGE_MODE_FAST_FORWARD: | |||
| 64 | + | return modeFastForward | |||
| 65 | + | case ermite.MergeMode_MERGE_MODE_SQUASH: | |||
| 66 | + | return modeSquash | |||
| 67 | + | } | |||
| 68 | + | return "" | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func parseMode(s string) (ermite.MergeMode, error) { | |||
| 72 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |||
| 73 | + | case modeFastForward, "ff": | |||
| 74 | + | return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil | |||
| 75 | + | case modeSquash: | |||
| 76 | + | return ermite.MergeMode_MERGE_MODE_SQUASH, nil | |||
| 77 | + | } | |||
| 78 | + | return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func sideName(s ermite.DiffSide) string { | |||
| 82 | + | switch s { | |||
| 83 | + | case ermite.DiffSide_DIFF_SIDE_OLD: | |||
| 84 | + | return sideOld | |||
| 85 | + | case ermite.DiffSide_DIFF_SIDE_NEW: | |||
| 86 | + | return sideNew | |||
| 87 | + | } | |||
| 88 | + | return "" | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func parseSide(s string) (ermite.DiffSide, error) { | |||
| 92 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |||
| 93 | + | case sideOld: | |||
| 94 | + | return ermite.DiffSide_DIFF_SIDE_OLD, nil | |||
| 95 | + | case sideNew: | |||
| 96 | + | return ermite.DiffSide_DIFF_SIDE_NEW, nil | |||
| 97 | + | } | |||
| 98 | + | return 0, fmt.Errorf("invalid side %q: use %s or %s", s, sideOld, sideNew) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // jsonMergeRequest is the stable JSON shape of a merge request. | |||
| 102 | + | type jsonMergeRequest struct { | |||
| 103 | + | Repo string `json:"repo"` | |||
| 104 | + | Number int64 `json:"number"` | |||
| 105 | + | Title string `json:"title"` | |||
| 106 | + | Description string `json:"description"` | |||
| 107 | + | SourceBranch string `json:"source_branch"` | |||
| 108 | + | TargetBranch string `json:"target_branch"` | |||
| 109 | + | State string `json:"state"` | |||
| 110 | + | Author string `json:"author"` | |||
| 111 | + | CreatedAt *string `json:"created_at"` | |||
| 112 | + | UpdatedAt *string `json:"updated_at"` | |||
| 113 | + | HeadID string `json:"head_id"` | |||
| 114 | + | MergedCommitID string `json:"merged_commit_id"` | |||
| 115 | + | MergeMode string `json:"merge_mode"` | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func toJSONMergeRequest(m *ermite.MergeRequest) jsonMergeRequest { | |||
| 119 | + | return jsonMergeRequest{ | |||
| 120 | + | Repo: m.GetRepo(), Number: m.GetNumber(), Title: m.GetTitle(), Description: m.GetDescription(), | |||
| 121 | + | SourceBranch: m.GetSourceBranch(), TargetBranch: m.GetTargetBranch(), State: stateName(m.GetState()), | |||
| 122 | + | Author: m.GetAuthor(), CreatedAt: jsonTime(m.GetCreatedAt()), UpdatedAt: jsonTime(m.GetUpdatedAt()), | |||
| 123 | + | HeadID: m.GetHeadId(), MergedCommitID: m.GetMergedCommitId(), MergeMode: modeName(m.GetMergeMode()), | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // jsonComment is the stable JSON shape of a comment. | |||
| 128 | + | type jsonComment struct { | |||
| 129 | + | ID string `json:"id"` | |||
| 130 | + | Author string `json:"author"` | |||
| 131 | + | Path string `json:"path"` | |||
| 132 | + | Line int32 `json:"line"` | |||
| 133 | + | Side string `json:"side"` | |||
| 134 | + | CommitID string `json:"commit_id"` | |||
| 135 | + | Text string `json:"text"` | |||
| 136 | + | Resolved bool `json:"resolved"` | |||
| 137 | + | CreatedAt *string `json:"created_at"` | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func toJSONComment(c *ermite.Comment) jsonComment { | |||
| 141 | + | return jsonComment{ | |||
| 142 | + | ID: c.GetId(), Author: c.GetAuthor(), Path: c.GetPath(), Line: c.GetLine(), Side: sideName(c.GetSide()), | |||
| 143 | + | CommitID: c.GetCommitId(), Text: c.GetText(), Resolved: c.GetResolved(), CreatedAt: jsonTime(c.GetCreatedAt()), | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | const jsonMRHelp = ` | |||
| 148 | + | JSON fields of a merge request (--json): repo, number, title, description, source_branch, | |||
| 149 | + | target_branch, state ("open", "merged" or "closed"), author (token label), created_at and | |||
| 150 | + | updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the | |||
| 151 | + | branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty | |||
| 152 | + | unless merged).` | |||
| 153 | + | ||||
| 154 | + | const jsonCommentHelp = ` | |||
| 155 | + | JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0 | |||
| 156 | + | and empty side for a general comment), commit_id (the head when it was written), text, | |||
| 157 | + | resolved, created_at.` | |||
| 158 | + | ||||
| 159 | + | func newMRCmd(env *cliEnv) *cobra.Command { | |||
| 160 | + | mr := &cobra.Command{ | |||
| 161 | + | Use: "mr", | |||
| 162 | + | Short: "Review and merge branches on a remote server", | |||
| 163 | + | Long: "Merge requests ask to merge one branch of a repository into another. They are about a branch,\n" + | |||
| 164 | + | "not about who wrote it. Needs a server with the merge_requests feature (see `ermite version`).\n" + | |||
| 165 | + | "A merge request is named by its repository and its number: `ermite mr show proj 3`.", | |||
| 166 | + | } | |||
| 167 | + | mr.AddCommand( | |||
| 168 | + | newMRCreateCmd(env), newMRListCmd(env), newMRShowCmd(env), newMRCommentCmd(env), | |||
| 169 | + | newMRResolveCmd(env), newMRMergeCmd(env), newMRCloseCmd(env), | |||
| 170 | + | ) | |||
| 171 | + | return mr | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // parseNumber reads the number of a merge request, written 3 or !3. | |||
| 175 | + | func parseNumber(s string) (int64, error) { | |||
| 176 | + | n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(s), "!"), 10, 64) | |||
| 177 | + | if err != nil || n < 1 { | |||
| 178 | + | return 0, fmt.Errorf("invalid merge request number %q: use a positive number such as 3 or !3", s) | |||
| 179 | + | } | |||
| 180 | + | return n, nil | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // readText returns value, or all of standard input when value is "-". | |||
| 184 | + | func readText(cmd *cobra.Command, value string) (string, error) { | |||
| 185 | + | if value != "-" { | |||
| 186 | + | return value, nil | |||
| 187 | + | } | |||
| 188 | + | b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20)) | |||
| 189 | + | if err != nil { | |||
| 190 | + | return "", fmt.Errorf("read standard input: %w", err) | |||
| 191 | + | } | |||
| 192 | + | return strings.TrimRight(string(b), "\r\n"), nil | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |||
| 196 | + | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { | |||
| 197 | + | switch len(args) { | |||
| 198 | + | case 0: | |||
| 199 | + | return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp | |||
| 200 | + | case 1: | |||
| 201 | + | c, err := env.client(cmd, false) | |||
| 202 | + | if err != nil { | |||
| 203 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 204 | + | } | |||
| 205 | + | mrs, err := listMergeRequests(env.ctx(cmd), c, args[0], ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED) | |||
| 206 | + | if err != nil { | |||
| 207 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 208 | + | } | |||
| 209 | + | var out []string | |||
| 210 | + | for _, m := range mrs { | |||
| 211 | + | if n := strconv.FormatInt(m.GetNumber(), 10); strings.HasPrefix(n, toComplete) { | |||
| 212 | + | out = append(out, n+"\t"+stateName(m.GetState())+", "+clean(m.GetTitle())) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | return out, cobra.ShellCompDirectiveNoFileComp | |||
| 216 | + | } | |||
| 217 | + | return nil, cobra.ShellCompDirectiveNoFileComp | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func listMergeRequests(ctx context.Context, c *client.Client, repo string, state ermite.MergeRequestState) ([]*ermite.MergeRequest, error) { | |||
| 222 | + | return collect(ctx, func(token string) ([]*ermite.MergeRequest, string, error) { | |||
| 223 | + | res, err := c.MergeRequests().ListMergeRequests(ctx, connect.NewRequest(&ermite.ListMergeRequestsRequest{ | |||
| 224 | + | Repo: repo, State: state, PageToken: token, | |||
| 225 | + | })) | |||
| 226 | + | if err != nil { | |||
| 227 | + | return nil, "", c.Describe(err) | |||
| 228 | + | } | |||
| 229 | + | return res.Msg.GetMergeRequests(), res.Msg.GetNextPageToken(), nil | |||
| 230 | + | }) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func newMRCreateCmd(env *cliEnv) *cobra.Command { | |||
| 234 | + | var title, description, source, target string | |||
| 235 | + | cmd := &cobra.Command{ | |||
| 236 | + | Use: "create <repo>", | |||
| 237 | + | Short: "Open a merge request", | |||
| 238 | + | Long: "Open a merge request for a branch of the repository. --target defaults to the default branch.\n" + | |||
| 239 | + | "The title is one line of at most 120 characters, the description plain text of at most 8000;\n" + | |||
| 240 | + | "neither may hold control characters (a description may hold line breaks) or bidirectional\n" + | |||
| 241 | + | "override characters. --description - reads it from standard input. Fails if an open merge request\n" + | |||
| 242 | + | "already has the same source and target. Needs the mr:write scope." + jsonMRHelp, | |||
| 243 | + | Args: cobra.ExactArgs(1), | |||
| 244 | + | ValidArgsFunction: completeRepoNames(env), | |||
| 245 | + | } | |||
| 246 | + | cmd.Flags().StringVar(&title, "title", "", "title of the merge request (required)") | |||
| 247 | + | cmd.Flags().StringVar(&description, "description", "", "description, or - to read standard input") | |||
| 248 | + | cmd.Flags().StringVar(&source, "source", "", "branch to merge (required)") | |||
| 249 | + | cmd.Flags().StringVar(&target, "target", "", "branch to merge into (default: the default branch)") | |||
| 250 | + | _ = cmd.MarkFlagRequired("title") | |||
| 251 | + | _ = cmd.MarkFlagRequired("source") | |||
| 252 | + | asJSON := addJSONFlag(cmd) | |||
| 253 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 254 | + | desc, err := readText(cmd, description) | |||
| 255 | + | if err != nil { | |||
| 256 | + | return err | |||
| 257 | + | } | |||
| 258 | + | req := &ermite.CreateMergeRequestRequest{ | |||
| 259 | + | Repo: args[0], Title: title, Description: desc, | |||
| 260 | + | SourceBranch: trimBranch(source), TargetBranch: trimBranch(target), | |||
| 261 | + | } | |||
| 262 | + | c, err := env.client(cmd, true) | |||
| 263 | + | if err != nil { | |||
| 264 | + | return err | |||
| 265 | + | } | |||
| 266 | + | res, err := c.MergeRequests().CreateMergeRequest(env.ctx(cmd), connect.NewRequest(req)) | |||
| 267 | + | if err != nil { | |||
| 268 | + | return c.Describe(err) | |||
| 269 | + | } | |||
| 270 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "opened") | |||
| 271 | + | } | |||
| 272 | + | return cmd | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func trimBranch(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "refs/heads/") } | |||
| 276 | + | ||||
| 277 | + | // outputMR prints a merge request as JSON, or as one confirmation line. | |||
| 278 | + | func outputMR(cmd *cobra.Command, m *ermite.MergeRequest, asJSON bool, verb string) error { | |||
| 279 | + | if asJSON { | |||
| 280 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m)) | |||
| 281 | + | } | |||
| 282 | + | _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s!%d (%s into %s)\n", verb, clean(m.GetRepo()), m.GetNumber(), | |||
| 283 | + | clean(m.GetSourceBranch()), clean(m.GetTargetBranch())) | |||
| 284 | + | return err | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func newMRListCmd(env *cliEnv) *cobra.Command { | |||
| 288 | + | var state string | |||
| 289 | + | cmd := &cobra.Command{ | |||
| 290 | + | Use: "list <repo>", | |||
| 291 | + | Short: "List the merge requests of a repository", | |||
| 292 | + | Long: "List the merge requests of a repository, newest first. --state restricts them to open, merged or\n" + | |||
| 293 | + | "closed ones (default: open; all shows every state)." + jsonMRHelp + "\n--json prints an array of merge requests.", | |||
| 294 | + | Args: cobra.ExactArgs(1), | |||
| 295 | + | ValidArgsFunction: completeRepoNames(env), | |||
| 296 | + | } | |||
| 297 | + | cmd.Flags().StringVar(&state, "state", stateOpen, "state: open, merged, closed or all") | |||
| 298 | + | _ = cmd.RegisterFlagCompletionFunc("state", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |||
| 299 | + | return []string{stateOpen, stateMerged, stateClosed, stateAll}, cobra.ShellCompDirectiveNoFileComp | |||
| 300 | + | }) | |||
| 301 | + | asJSON := addJSONFlag(cmd) | |||
| 302 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 303 | + | st, err := parseState(state) | |||
| 304 | + | if err != nil { | |||
| 305 | + | return err | |||
| 306 | + | } | |||
| 307 | + | c, err := env.client(cmd, true) | |||
| 308 | + | if err != nil { | |||
| 309 | + | return err | |||
| 310 | + | } | |||
| 311 | + | mrs, err := listMergeRequests(env.ctx(cmd), c, args[0], st) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return err | |||
| 314 | + | } | |||
| 315 | + | if *asJSON { | |||
| 316 | + | out := make([]jsonMergeRequest, 0, len(mrs)) | |||
| 317 | + | for _, m := range mrs { | |||
| 318 | + | out = append(out, toJSONMergeRequest(m)) | |||
| 319 | + | } | |||
| 320 | + | return writeJSON(cmd.OutOrStdout(), out) | |||
| 321 | + | } | |||
| 322 | + | if len(mrs) == 0 { | |||
| 323 | + | noticef(cmd, "no merge requests") | |||
| 324 | + | return nil | |||
| 325 | + | } | |||
| 326 | + | rows := make([][]string, 0, len(mrs)) | |||
| 327 | + | for _, m := range mrs { | |||
| 328 | + | rows = append(rows, []string{ | |||
| 329 | + | "!" + strconv.FormatInt(m.GetNumber(), 10), stateName(m.GetState()), | |||
| 330 | + | m.GetSourceBranch() + " -> " + m.GetTargetBranch(), m.GetAuthor(), | |||
| 331 | + | shortTime(m.GetUpdatedAt(), "-"), m.GetTitle(), | |||
| 332 | + | }) | |||
| 333 | + | } | |||
| 334 | + | return table(cmd.OutOrStdout(), []string{"NUMBER", "STATE", "BRANCHES", "AUTHOR", "UPDATED", "TITLE"}, rows) | |||
| 335 | + | } | |||
| 336 | + | return cmd | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | // jsonStats are the totals of a diff. | |||
| 340 | + | type jsonStats struct { | |||
| 341 | + | FilesChanged int32 `json:"files_changed"` | |||
| 342 | + | Additions int32 `json:"additions"` | |||
| 343 | + | Deletions int32 `json:"deletions"` | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | type jsonLine struct { | |||
| 347 | + | Kind string `json:"kind"` | |||
| 348 | + | OldNumber int32 `json:"old_number"` | |||
| 349 | + | NewNumber int32 `json:"new_number"` | |||
| 350 | + | Text string `json:"text"` | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | type jsonHunk struct { | |||
| 354 | + | Header string `json:"header"` | |||
| 355 | + | OldStart int32 `json:"old_start"` | |||
| 356 | + | OldLines int32 `json:"old_lines"` | |||
| 357 | + | NewStart int32 `json:"new_start"` | |||
| 358 | + | NewLines int32 `json:"new_lines"` | |||
| 359 | + | Lines []jsonLine `json:"lines"` | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | type jsonFile struct { | |||
| 363 | + | OldPath string `json:"old_path"` | |||
| 364 | + | NewPath string `json:"new_path"` | |||
| 365 | + | Status string `json:"status"` | |||
| 366 | + | Additions int32 `json:"additions"` | |||
| 367 | + | Deletions int32 `json:"deletions"` | |||
| 368 | + | Binary bool `json:"binary"` | |||
| 369 | + | Truncated bool `json:"truncated"` | |||
| 370 | + | Hunks []jsonHunk `json:"hunks,omitempty"` | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | type jsonCommit struct { | |||
| 374 | + | ID string `json:"id"` | |||
| 375 | + | Author string `json:"author"` | |||
| 376 | + | Summary string `json:"summary"` | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // jsonMRDetail is the stable JSON shape of `mr show --json`. | |||
| 380 | + | type jsonMRDetail struct { | |||
| 381 | + | jsonMergeRequest | |||
| 382 | + | Commits []jsonCommit `json:"commits"` | |||
| 383 | + | CommitsTruncated bool `json:"commits_truncated"` | |||
| 384 | + | DiffBaseID string `json:"diff_base_id"` | |||
| 385 | + | Stats jsonStats `json:"stats"` | |||
| 386 | + | DiffTruncated bool `json:"diff_truncated"` | |||
| 387 | + | Files []jsonFile `json:"files"` | |||
| 388 | + | Comments []jsonComment `json:"comments"` | |||
| 389 | + | LastReadID string `json:"last_read_id"` | |||
| 390 | + | LastReadLost bool `json:"last_read_lost"` | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | func fileStatusName(s ermite.FileStatus) string { | |||
| 394 | + | return strings.ToLower(strings.TrimPrefix(s.String(), "FILE_STATUS_")) | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | func lineKindName(k ermite.LineKind) string { | |||
| 398 | + | return strings.ToLower(strings.TrimPrefix(k.String(), "LINE_KIND_")) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool) jsonMRDetail { | |||
| 402 | + | out := jsonMRDetail{ | |||
| 403 | + | jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest()), | |||
| 404 | + | Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(), | |||
| 405 | + | DiffBaseID: d.GetDiffBaseId(), | |||
| 406 | + | Stats: jsonStats{ | |||
| 407 | + | FilesChanged: d.GetStats().GetFilesChanged(), Additions: d.GetStats().GetAdditions(), Deletions: d.GetStats().GetDeletions(), | |||
| 408 | + | }, | |||
| 409 | + | DiffTruncated: d.GetDiff().GetTruncated(), | |||
| 410 | + | Files: []jsonFile{}, Comments: []jsonComment{}, | |||
| 411 | + | LastReadID: d.GetLastReadId(), LastReadLost: d.GetLastReadLost(), | |||
| 412 | + | } | |||
| 413 | + | for _, c := range d.GetCommits() { | |||
| 414 | + | out.Commits = append(out.Commits, jsonCommit{ID: c.GetId(), Author: c.GetAuthor().GetName(), Summary: c.GetSummary()}) | |||
| 415 | + | } | |||
| 416 | + | for _, f := range d.GetDiff().GetFiles() { | |||
| 417 | + | jf := jsonFile{ | |||
| 418 | + | OldPath: f.GetOldPath(), NewPath: f.GetNewPath(), Status: fileStatusName(f.GetStatus()), | |||
| 419 | + | Additions: f.GetAdditions(), Deletions: f.GetDeletions(), Binary: f.GetBinary(), Truncated: f.GetTruncated(), | |||
| 420 | + | } | |||
| 421 | + | if withDiff { | |||
| 422 | + | for _, h := range f.GetHunks() { | |||
| 423 | + | jh := jsonHunk{ | |||
| 424 | + | Header: h.GetHeader(), OldStart: h.GetOldStart(), OldLines: h.GetOldLines(), | |||
| 425 | + | NewStart: h.GetNewStart(), NewLines: h.GetNewLines(), Lines: []jsonLine{}, | |||
| 426 | + | } | |||
| 427 | + | for _, l := range h.GetLines() { | |||
| 428 | + | jh.Lines = append(jh.Lines, jsonLine{ | |||
| 429 | + | Kind: lineKindName(l.GetKind()), OldNumber: l.GetOldNumber(), NewNumber: l.GetNewNumber(), Text: l.GetText(), | |||
| 430 | + | }) | |||
| 431 | + | } | |||
| 432 | + | jf.Hunks = append(jf.Hunks, jh) | |||
| 433 | + | } | |||
| 434 | + | } | |||
| 435 | + | out.Files = append(out.Files, jf) | |||
| 436 | + | } | |||
| 437 | + | for _, c := range d.GetComments() { | |||
| 438 | + | out.Comments = append(out.Comments, toJSONComment(c)) | |||
| 439 | + | } | |||
| 440 | + | return out | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func newMRShowCmd(env *cliEnv) *cobra.Command { | |||
| 444 | + | var sinceLastRead, withDiff, markRead bool | |||
| 445 | + | cmd := &cobra.Command{ | |||
| 446 | + | Use: "show <repo> <number>", | |||
| 447 | + | Short: "Show a merge request with its commits, comments and diff", | |||
| 448 | + | Long: "Show a merge request: its fields, the commits of the branch that the target lacks, the changed\n" + | |||
| 449 | + | "files and the comments. --diff adds the diff, with the line numbers to use in `mr comment`.\n" + | |||
| 450 | + | "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" + | |||
| 451 | + | "the whole change; after a force-push it still works when the recorded commit exists, else the\n" + | |||
| 452 | + | "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" + | |||
| 453 | + | "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" + | |||
| 454 | + | "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" + | |||
| 455 | + | "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" + | |||
| 456 | + | "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" + | |||
| 457 | + | "with --diff, hunks of lines with kind, old_number, new_number and text), comments, last_read_id\n" + | |||
| 458 | + | "and last_read_lost.", | |||
| 459 | + | Args: cobra.ExactArgs(2), | |||
| 460 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |||
| 461 | + | } | |||
| 462 | + | cmd.Flags().BoolVar(&withDiff, "diff", false, "include the diff") | |||
| 463 | + | cmd.Flags().BoolVar(&sinceLastRead, "since-last-read", false, "diff since the head last marked read") | |||
| 464 | + | cmd.Flags().BoolVar(&markRead, "mark-read", false, "record the head shown as read (needs mr:write)") | |||
| 465 | + | asJSON := addJSONFlag(cmd) | |||
| 466 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 467 | + | n, err := parseNumber(args[1]) | |||
| 468 | + | if err != nil { | |||
| 469 | + | return err | |||
| 470 | + | } | |||
| 471 | + | c, err := env.client(cmd, true) | |||
| 472 | + | if err != nil { | |||
| 473 | + | return err | |||
| 474 | + | } | |||
| 475 | + | ctx := env.ctx(cmd) | |||
| 476 | + | res, err := c.MergeRequests().GetMergeRequest(ctx, connect.NewRequest(&ermite.GetMergeRequestRequest{ | |||
| 477 | + | Repo: args[0], Number: n, SinceLastRead: sinceLastRead, | |||
| 478 | + | })) | |||
| 479 | + | if err != nil { | |||
| 480 | + | return c.Describe(err) | |||
| 481 | + | } | |||
| 482 | + | d := res.Msg | |||
| 483 | + | if *asJSON { | |||
| 484 | + | err = writeJSON(cmd.OutOrStdout(), toJSONDetail(d, withDiff)) | |||
| 485 | + | } else { | |||
| 486 | + | err = printMRDetail(cmd.OutOrStdout(), d, withDiff, sinceLastRead) | |||
| 487 | + | } | |||
| 488 | + | if err != nil { | |||
| 489 | + | return err | |||
| 490 | + | } | |||
| 491 | + | if markRead && d.GetMergeRequest().GetHeadId() != "" { | |||
| 492 | + | _, err = c.MergeRequests().MarkRead(ctx, connect.NewRequest(&ermite.MarkReadRequest{ | |||
| 493 | + | Repo: args[0], Number: n, HeadId: d.GetMergeRequest().GetHeadId(), | |||
| 494 | + | })) | |||
| 495 | + | if err != nil { | |||
| 496 | + | return c.Describe(err) | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | if d.GetLastReadLost() { | |||
| 500 | + | noticef(cmd, "the head you last read is gone (force-push?): showing the whole change") | |||
| 501 | + | } | |||
| 502 | + | return nil | |||
| 503 | + | } | |||
| 504 | + | return cmd | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, since bool) error { | |||
| 508 | + | m := d.GetMergeRequest() | |||
| 509 | + | desc := m.GetDescription() | |||
| 510 | + | if desc == "" { | |||
| 511 | + | desc = "-" | |||
| 512 | + | } | |||
| 513 | + | pairs := [][2]string{ | |||
| 514 | + | {"merge request", fmt.Sprintf("%s!%d", m.GetRepo(), m.GetNumber())}, | |||
| 515 | + | {"title", m.GetTitle()}, | |||
| 516 | + | {"state", stateName(m.GetState())}, | |||
| 517 | + | {"branches", m.GetSourceBranch() + " -> " + m.GetTargetBranch()}, | |||
| 518 | + | {"author", m.GetAuthor()}, | |||
| 519 | + | {"created", shortTime(m.GetCreatedAt(), "-")}, | |||
| 520 | + | {"updated", shortTime(m.GetUpdatedAt(), "-")}, | |||
| 521 | + | {"head", orDash(m.GetHeadId())}, | |||
| 522 | + | } | |||
| 523 | + | if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED { | |||
| 524 | + | pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"}) | |||
| 525 | + | } | |||
| 526 | + | if err := fields(w, pairs); err != nil { | |||
| 527 | + | return err | |||
| 528 | + | } | |||
| 529 | + | _, _ = fmt.Fprintf(w, "\n%s\n", indentText(desc)) | |||
| 530 | + | ||||
| 531 | + | _, _ = fmt.Fprintf(w, "\ncommits (%d%s)\n", len(d.GetCommits()), plus(d.GetCommitsTruncated())) | |||
| 532 | + | for _, c := range d.GetCommits() { | |||
| 533 | + | _, _ = fmt.Fprintf(w, " %s %s %s\n", shortID(c.GetId()), clean(c.GetAuthor().GetName()), clean(c.GetSummary())) | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | st := d.GetStats() | |||
| 537 | + | what := "changes" | |||
| 538 | + | if since && !d.GetLastReadLost() && d.GetLastReadId() != "" { | |||
| 539 | + | what = "changes since " + shortID(d.GetLastReadId()) | |||
| 540 | + | } | |||
| 541 | + | _, _ = fmt.Fprintf(w, "\n%s: %d file(s), +%d -%d, from %s\n", what, st.GetFilesChanged(), st.GetAdditions(), st.GetDeletions(), orDash(shortID(d.GetDiffBaseId()))) | |||
| 542 | + | for _, f := range d.GetDiff().GetFiles() { | |||
| 543 | + | path := f.GetNewPath() | |||
| 544 | + | if path == "" { | |||
| 545 | + | path = f.GetOldPath() | |||
| 546 | + | } | |||
| 547 | + | note := "" | |||
| 548 | + | if f.GetTruncated() { | |||
| 549 | + | note = " (too large, not shown)" | |||
| 550 | + | } | |||
| 551 | + | _, _ = fmt.Fprintf(w, " %-8s +%d -%d %s%s\n", fileStatusName(f.GetStatus()), f.GetAdditions(), f.GetDeletions(), clean(path), note) | |||
| 552 | + | } | |||
| 553 | + | if d.GetDiff().GetTruncated() { | |||
| 554 | + | _, _ = fmt.Fprintln(w, " (the diff is truncated)") | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | _, _ = fmt.Fprintf(w, "\ncomments (%d)\n", len(d.GetComments())) | |||
| 558 | + | for _, c := range d.GetComments() { | |||
| 559 | + | where := "general" | |||
| 560 | + | if c.GetPath() != "" { | |||
| 561 | + | where = fmt.Sprintf("%s:%d (%s)", clean(c.GetPath()), c.GetLine(), sideName(c.GetSide())) | |||
| 562 | + | } | |||
| 563 | + | mark := "" | |||
| 564 | + | if c.GetResolved() { | |||
| 565 | + | mark = " [resolved]" | |||
| 566 | + | } | |||
| 567 | + | _, _ = fmt.Fprintf(w, " #%s %s, %s, %s%s\n%s\n", c.GetId(), clean(c.GetAuthor()), shortTime(c.GetCreatedAt(), "-"), where, mark, indentText(c.GetText())) | |||
| 568 | + | } | |||
| 569 | + | if withDiff { | |||
| 570 | + | printDiff(w, d.GetDiff()) | |||
| 571 | + | } | |||
| 572 | + | return nil | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | func orDash(s string) string { | |||
| 576 | + | if s == "" { | |||
| 577 | + | return "-" | |||
| 578 | + | } | |||
| 579 | + | return s | |||
| 580 | + | } | |||
| 581 | + | ||||
| 582 | + | func plus(truncated bool) string { | |||
| 583 | + | if truncated { | |||
| 584 | + | return "+" | |||
| 585 | + | } | |||
| 586 | + | return "" | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func shortID(id string) string { | |||
| 590 | + | if len(id) > 10 { | |||
| 591 | + | return id[:10] | |||
| 592 | + | } | |||
| 593 | + | return id | |||
| 594 | + | } | |||
| 595 | + | ||||
| 596 | + | // indentText indents a multi-line text by four spaces, making each line safe | |||
| 597 | + | // for a terminal. | |||
| 598 | + | func indentText(s string) string { | |||
| 599 | + | lines := strings.Split(s, "\n") | |||
| 600 | + | for i, l := range lines { | |||
| 601 | + | lines[i] = " " + client.Clean(l) | |||
| 602 | + | } | |||
| 603 | + | return strings.Join(lines, "\n") | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | // printDiff prints the diff with a gutter of the old and new line numbers, | |||
| 607 | + | // which are the numbers `mr comment --line` takes with --side old or new. | |||
| 608 | + | func printDiff(w io.Writer, d *ermite.Diff) { | |||
| 609 | + | _, _ = fmt.Fprintln(w, "\ndiff (old and new line numbers on the left)") | |||
| 610 | + | num := func(n int32) string { | |||
| 611 | + | if n == 0 { | |||
| 612 | + | return "" | |||
| 613 | + | } | |||
| 614 | + | return strconv.Itoa(int(n)) | |||
| 615 | + | } | |||
| 616 | + | for _, f := range d.GetFiles() { | |||
| 617 | + | _, _ = fmt.Fprintf(w, "\n--- %s\n+++ %s\n", clean(orDash(f.GetOldPath())), clean(orDash(f.GetNewPath()))) | |||
| 618 | + | if f.GetBinary() { | |||
| 619 | + | _, _ = fmt.Fprintln(w, "(binary file)") | |||
| 620 | + | } | |||
| 621 | + | if f.GetTruncated() { | |||
| 622 | + | _, _ = fmt.Fprintln(w, "(too large, not shown)") | |||
| 623 | + | } | |||
| 624 | + | for _, h := range f.GetHunks() { | |||
| 625 | + | _, _ = fmt.Fprintf(w, "@@ -%d,%d +%d,%d @@ %s\n", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines(), clean(h.GetHeader())) | |||
| 626 | + | for _, l := range h.GetLines() { | |||
| 627 | + | sign := map[ermite.LineKind]string{ | |||
| 628 | + | ermite.LineKind_LINE_KIND_ADDITION: "+", ermite.LineKind_LINE_KIND_DELETION: "-", | |||
| 629 | + | }[l.GetKind()] | |||
| 630 | + | if sign == "" { | |||
| 631 | + | sign = " " | |||
| 632 | + | } | |||
| 633 | + | _, _ = fmt.Fprintf(w, "%5s %5s %s%s\n", num(l.GetOldNumber()), num(l.GetNewNumber()), sign, clean(l.GetText())) | |||
| 634 | + | } | |||
| 635 | + | } | |||
| 636 | + | } | |||
| 637 | + | } | |||
| 638 | + | ||||
| 639 | + | func newMRCommentCmd(env *cliEnv) *cobra.Command { | |||
| 640 | + | var message, path, side string | |||
| 641 | + | var line int | |||
| 642 | + | cmd := &cobra.Command{ | |||
| 643 | + | Use: "comment <repo> <number>", | |||
| 644 | + | Short: "Comment on a merge request", | |||
| 645 | + | Long: "Add a plain-text comment (at most 8000 characters, no control characters except line breaks).\n" + | |||
| 646 | + | "-m - reads it from standard input. Without --path it is a general comment. With --path, --line and\n" + | |||
| 647 | + | "--side (default new) it is attached to a line of the current diff: the path as in `mr show --diff`\n" + | |||
| 648 | + | "(the new path, the old one for a deleted file), the line number on the old or new side; a line\n" + | |||
| 649 | + | "that is not in the diff is refused. Needs the mr:write scope." + jsonCommentHelp, | |||
| 650 | + | Args: cobra.ExactArgs(2), | |||
| 651 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |||
| 652 | + | } | |||
| 653 | + | cmd.Flags().StringVarP(&message, "message", "m", "", "text of the comment, or - to read standard input (required)") | |||
| 654 | + | cmd.Flags().StringVar(&path, "path", "", "file the comment is attached to") | |||
| 655 | + | cmd.Flags().IntVar(&line, "line", 0, "1-based line number on --side") | |||
| 656 | + | cmd.Flags().StringVar(&side, "side", sideNew, "side the line counts on: old or new") | |||
| 657 | + | _ = cmd.MarkFlagRequired("message") | |||
| 658 | + | _ = cmd.RegisterFlagCompletionFunc("side", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |||
| 659 | + | return []string{sideOld, sideNew}, cobra.ShellCompDirectiveNoFileComp | |||
| 660 | + | }) | |||
| 661 | + | asJSON := addJSONFlag(cmd) | |||
| 662 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 663 | + | n, err := parseNumber(args[1]) | |||
| 664 | + | if err != nil { | |||
| 665 | + | return err | |||
| 666 | + | } | |||
| 667 | + | text, err := readText(cmd, message) | |||
| 668 | + | if err != nil { | |||
| 669 | + | return err | |||
| 670 | + | } | |||
| 671 | + | req := &ermite.AddCommentRequest{Repo: args[0], Number: n, Text: text} | |||
| 672 | + | switch { | |||
| 673 | + | case path != "": | |||
| 674 | + | if line < 1 { | |||
| 675 | + | return errors.New("--line is required with --path: give the line number") | |||
| 676 | + | } | |||
| 677 | + | if req.Side, err = parseSide(side); err != nil { | |||
| 678 | + | return err | |||
| 679 | + | } | |||
| 680 | + | req.Path, req.Line = path, int32(line) //nolint:gosec // a line number typed by a person | |||
| 681 | + | case line != 0 || cmd.Flags().Changed("side"): | |||
| 682 | + | return errors.New("--line and --side need --path") | |||
| 683 | + | } | |||
| 684 | + | c, err := env.client(cmd, true) | |||
| 685 | + | if err != nil { | |||
| 686 | + | return err | |||
| 687 | + | } | |||
| 688 | + | res, err := c.MergeRequests().AddComment(env.ctx(cmd), connect.NewRequest(req)) | |||
| 689 | + | if err != nil { | |||
| 690 | + | return c.Describe(err) | |||
| 691 | + | } | |||
| 692 | + | if *asJSON { | |||
| 693 | + | return writeJSON(cmd.OutOrStdout(), toJSONComment(res.Msg.GetComment())) | |||
| 694 | + | } | |||
| 695 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s added to %s!%d\n", res.Msg.GetComment().GetId(), clean(args[0]), n) | |||
| 696 | + | return err | |||
| 697 | + | } | |||
| 698 | + | return cmd | |||
| 699 | + | } | |||
| 700 | + | ||||
| 701 | + | func newMRResolveCmd(env *cliEnv) *cobra.Command { | |||
| 702 | + | var undo bool | |||
| 703 | + | cmd := &cobra.Command{ | |||
| 704 | + | Use: "resolve <repo> <number> <comment-id>", | |||
| 705 | + | Short: "Mark a comment resolved", | |||
| 706 | + | Long: "Mark a comment of a merge request resolved, or unresolved with --undo. Doing it twice is not an\n" + | |||
| 707 | + | "error. Needs the mr:write scope." + jsonCommentHelp, | |||
| 708 | + | Args: cobra.ExactArgs(3), | |||
| 709 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |||
| 710 | + | } | |||
| 711 | + | cmd.Flags().BoolVar(&undo, "undo", false, "mark the comment unresolved instead") | |||
| 712 | + | asJSON := addJSONFlag(cmd) | |||
| 713 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 714 | + | n, err := parseNumber(args[1]) | |||
| 715 | + | if err != nil { | |||
| 716 | + | return err | |||
| 717 | + | } | |||
| 718 | + | id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#") | |||
| 719 | + | c, err := env.client(cmd, true) | |||
| 720 | + | if err != nil { | |||
| 721 | + | return err | |||
| 722 | + | } | |||
| 723 | + | res, err := c.MergeRequests().ResolveComment(env.ctx(cmd), connect.NewRequest(&ermite.ResolveCommentRequest{ | |||
| 724 | + | Repo: args[0], Number: n, CommentId: id, Resolved: !undo, | |||
| 725 | + | })) | |||
| 726 | + | if err != nil { | |||
| 727 | + | return c.Describe(err) | |||
| 728 | + | } | |||
| 729 | + | if *asJSON { | |||
| 730 | + | return writeJSON(cmd.OutOrStdout(), toJSONComment(res.Msg.GetComment())) | |||
| 731 | + | } | |||
| 732 | + | verb := "resolved" | |||
| 733 | + | if undo { | |||
| 734 | + | verb = "reopened" | |||
| 735 | + | } | |||
| 736 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "comment %s of %s!%d %s\n", clean(id), clean(args[0]), n, verb) | |||
| 737 | + | return err | |||
| 738 | + | } | |||
| 739 | + | return cmd | |||
| 740 | + | } | |||
| 741 | + | ||||
| 742 | + | func newMRMergeCmd(env *cliEnv) *cobra.Command { | |||
| 743 | + | var squash bool | |||
| 744 | + | var head string | |||
| 745 | + | cmd := &cobra.Command{ | |||
| 746 | + | Use: "merge <repo> <number>", | |||
| 747 | + | Short: "Merge a merge request", | |||
| 748 | + | Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" + | |||
| 749 | + | "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" + | |||
| 750 | + | "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" + | |||
| 751 | + | "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" + | |||
| 752 | + | "only one of the modes.\n\n" + | |||
| 753 | + | "The server merges only if the source branch still points to the head you reviewed. --head gives\n" + | |||
| 754 | + | "that commit (the head of `mr show`, a full id). Without it the CLI reads the current head and merges\n" + | |||
| 755 | + | "that, which skips the safeguard: pass --head when others may push to the branch. Needs the\n" + | |||
| 756 | + | "mr:merge scope." + jsonMRHelp, | |||
| 757 | + | Args: cobra.ExactArgs(2), | |||
| 758 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |||
| 759 | + | } | |||
| 760 | + | cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward") | |||
| 761 | + | cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed (default: the current head)") | |||
| 762 | + | asJSON := addJSONFlag(cmd) | |||
| 763 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 764 | + | n, err := parseNumber(args[1]) | |||
| 765 | + | if err != nil { | |||
| 766 | + | return err | |||
| 767 | + | } | |||
| 768 | + | c, err := env.client(cmd, true) | |||
| 769 | + | if err != nil { | |||
| 770 | + | return err | |||
| 771 | + | } | |||
| 772 | + | ctx := env.ctx(cmd) | |||
| 773 | + | head = strings.TrimSpace(head) | |||
| 774 | + | if head == "" { | |||
| 775 | + | cur, err := currentHead(ctx, c, args[0], n) | |||
| 776 | + | if err != nil { | |||
| 777 | + | return err | |||
| 778 | + | } | |||
| 779 | + | head = cur | |||
| 780 | + | noticef(cmd, "merging %s!%d at %s (no --head given)", clean(args[0]), n, head) | |||
| 781 | + | } | |||
| 782 | + | mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD | |||
| 783 | + | if squash { | |||
| 784 | + | mode = ermite.MergeMode_MERGE_MODE_SQUASH | |||
| 785 | + | } | |||
| 786 | + | res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{ | |||
| 787 | + | Repo: args[0], Number: n, Mode: mode, HeadId: head, | |||
| 788 | + | })) | |||
| 789 | + | if err != nil { | |||
| 790 | + | return c.Describe(err) | |||
| 791 | + | } | |||
| 792 | + | m := res.Msg.GetMergeRequest() | |||
| 793 | + | if *asJSON { | |||
| 794 | + | return writeJSON(cmd.OutOrStdout(), toJSONMergeRequest(m)) | |||
| 795 | + | } | |||
| 796 | + | _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(), | |||
| 797 | + | clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId())) | |||
| 798 | + | return err | |||
| 799 | + | } | |||
| 800 | + | return cmd | |||
| 801 | + | } | |||
| 802 | + | ||||
| 803 | + | // currentHead reads the head of a merge request from the server. | |||
| 804 | + | func currentHead(ctx context.Context, c *client.Client, repo string, n int64) (string, error) { | |||
| 805 | + | res, err := c.MergeRequests().GetMergeRequest(ctx, connect.NewRequest(&ermite.GetMergeRequestRequest{Repo: repo, Number: n})) | |||
| 806 | + | if err != nil { | |||
| 807 | + | return "", c.Describe(err) | |||
| 808 | + | } | |||
| 809 | + | head := res.Msg.GetMergeRequest().GetHeadId() | |||
| 810 | + | if head == "" { | |||
| 811 | + | return "", errors.New("the source branch no longer exists: nothing to merge") | |||
| 812 | + | } | |||
| 813 | + | return head, nil | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | func newMRCloseCmd(env *cliEnv) *cobra.Command { | |||
| 817 | + | cmd := &cobra.Command{ | |||
| 818 | + | Use: "close <repo> <number>", | |||
| 819 | + | Short: "Close a merge request without merging it", | |||
| 820 | + | Long: "Close an open merge request without merging it. A closed merge request cannot be reopened: open a\n" + | |||
| 821 | + | "new one. Needs the mr:write scope." + jsonMRHelp, | |||
| 822 | + | Args: cobra.ExactArgs(2), | |||
| 823 | + | ValidArgsFunction: completeMRRepoThenNumber(env), | |||
| 824 | + | } | |||
| 825 | + | asJSON := addJSONFlag(cmd) | |||
| 826 | + | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |||
| 827 | + | n, err := parseNumber(args[1]) | |||
| 828 | + | if err != nil { | |||
| 829 | + | return err | |||
| 830 | + | } | |||
| 831 | + | c, err := env.client(cmd, true) | |||
| 832 | + | if err != nil { | |||
| 833 | + | return err | |||
| 834 | + | } | |||
| 835 | + | res, err := c.MergeRequests().CloseMergeRequest(env.ctx(cmd), connect.NewRequest(&ermite.CloseMergeRequestRequest{Repo: args[0], Number: n})) | |||
| 836 | + | if err != nil { | |||
| 837 | + | return c.Describe(err) | |||
| 838 | + | } | |||
| 839 | + | return outputMR(cmd, res.Msg.GetMergeRequest(), *asJSON, "closed") | |||
| 840 | + | } | |||
| 841 | + | return cmd | |||
| 842 | + | } | |||
A cmd/ermite/cli_mr_e2e_test.go +420 -0
| @@ -0,0 +1,420 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type mrOut struct { | |
| 20 | + | Repo string `json:"repo"` | |
| 21 | + | Number int64 `json:"number"` | |
| 22 | + | Title string `json:"title"` | |
| 23 | + | Description string `json:"description"` | |
| 24 | + | SourceBranch string `json:"source_branch"` | |
| 25 | + | TargetBranch string `json:"target_branch"` | |
| 26 | + | State string `json:"state"` | |
| 27 | + | Author string `json:"author"` | |
| 28 | + | CreatedAt *string `json:"created_at"` | |
| 29 | + | UpdatedAt *string `json:"updated_at"` | |
| 30 | + | HeadID string `json:"head_id"` | |
| 31 | + | MergedCommitID string `json:"merged_commit_id"` | |
| 32 | + | MergeMode string `json:"merge_mode"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at"} | |
| 36 | + | ||
| 37 | + | type commentOut struct { | |
| 38 | + | ID string `json:"id"` | |
| 39 | + | Author string `json:"author"` | |
| 40 | + | Path string `json:"path"` | |
| 41 | + | Line int `json:"line"` | |
| 42 | + | Side string `json:"side"` | |
| 43 | + | CommitID string `json:"commit_id"` | |
| 44 | + | Text string `json:"text"` | |
| 45 | + | Resolved bool `json:"resolved"` | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type showOut struct { | |
| 49 | + | mrOut | |
| 50 | + | Commits []struct{ ID, Author, Summary string } | |
| 51 | + | CommitsTruncated bool `json:"commits_truncated"` | |
| 52 | + | DiffBaseID string `json:"diff_base_id"` | |
| 53 | + | Stats struct { | |
| 54 | + | FilesChanged int `json:"files_changed"` | |
| 55 | + | Additions int | |
| 56 | + | Deletions int | |
| 57 | + | } | |
| 58 | + | Files []struct { | |
| 59 | + | OldPath string `json:"old_path"` | |
| 60 | + | NewPath string `json:"new_path"` | |
| 61 | + | Status string | |
| 62 | + | Hunks []struct { | |
| 63 | + | Lines []struct { | |
| 64 | + | Kind string | |
| 65 | + | OldNumber int `json:"old_number"` | |
| 66 | + | NewNumber int `json:"new_number"` | |
| 67 | + | Text string | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | Comments []commentOut | |
| 72 | + | LastReadID string `json:"last_read_id"` | |
| 73 | + | LastReadLost bool `json:"last_read_lost"` | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // AC3 to AC6 through the CLI against a real forge, with branches pushed by git. | |
| 77 | + | func TestMergeRequestsCLIEndToEnd(t *testing.T) { | |
| 78 | + | c := newCLIHarness(t) | |
| 79 | + | stdout, stderr, code := c.bin(t, adminToken, "token", "create", "daily", "--preset", "cli") | |
| 80 | + | require.Equal(t, 0, code, stderr) | |
| 81 | + | secret := strings.TrimSpace(stdout) | |
| 82 | + | agent := strings.TrimSpace(func() string { | |
| 83 | + | out, errOut, code := c.bin(t, adminToken, "token", "create", "agent-7", "--preset", "agent", "--scope", "repo:read") | |
| 84 | + | require.Equal(t, 0, code, errOut) | |
| 85 | + | return out | |
| 86 | + | }()) | |
| 87 | + | ||
| 88 | + | c.cli(t, secret, "repo", "create", "proj", "--visibility", "public").ok(t) | |
| 89 | + | wt := c.workTree("main", "README.md") | |
| 90 | + | url := c.urlAuth(secret, "/proj.git") | |
| 91 | + | c.mustGit(wt, "push", "-q", url, "main") | |
| 92 | + | main0 := c.bareGit("proj", "rev-parse", "main") | |
| 93 | + | ||
| 94 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/search") | |
| 95 | + | c.writeFile(wt, "search.go", []byte("package search\n\nfunc Find() {}\n")) | |
| 96 | + | c.writeFile(wt, "README.md", []byte("# proj\nsearch\n")) | |
| 97 | + | c.mustGit(wt, "push", "-q", url, "feat/search") | |
| 98 | + | head1 := c.bareGit("proj", "rev-parse", "feat/search") | |
| 99 | + | ||
| 100 | + | w := c.startWatch(t, secret) | |
| 101 | + | ||
| 102 | + | // create: needs mr:write; the agent token has it. | |
| 103 | + | res := c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search", "--description", "Finds things.\nSecond line.", "--json").ok(t) | |
| 104 | + | assert.Equal(t, mrKeys, keys(t, res.stdout)) | |
| 105 | + | mr := decode[mrOut](t, res.stdout) | |
| 106 | + | assert.EqualValues(t, 1, mr.Number) | |
| 107 | + | assert.Equal(t, "open", mr.State) | |
| 108 | + | assert.Equal(t, "agent-7", mr.Author) | |
| 109 | + | assert.Equal(t, "main", mr.TargetBranch, "defaults to the default branch") | |
| 110 | + | assert.Equal(t, head1, mr.HeadID) | |
| 111 | + | assert.Empty(t, mr.MergedCommitID) | |
| 112 | + | assert.Empty(t, mr.MergeMode) | |
| 113 | + | ev := decode[eventOut](t, w.next(t, 10*time.Second)) | |
| 114 | + | assert.Equal(t, "merge_request_opened", ev.Type) | |
| 115 | + | assert.Equal(t, "proj", ev.Repo) | |
| 116 | + | ||
| 117 | + | // The human output is one line. | |
| 118 | + | res = c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search", "--target", "main", "--title", "dup") | |
| 119 | + | require.Error(t, res.err) | |
| 120 | + | assert.Contains(t, res.err.Error(), "already exists") | |
| 121 | + | ||
| 122 | + | // Rules: every offending field in one message. | |
| 123 | + | _, stderr, code = c.bin(t, agent, "mr", "create", "proj", "--source", "nope", "--title", "bad\u202etitle") | |
| 124 | + | requireOneLineError(t, stderr, code, "invalid request") | |
| 125 | + | _, stderr, code = c.bin(t, agent, "mr", "create", "proj", "--source", "feat/search", "--target", "feat/search", "--title", "t") | |
| 126 | + | requireOneLineError(t, stderr, code, "invalid request") | |
| 127 | + | // Missing flags are usage errors that do not reach the server. | |
| 128 | + | res = c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search") | |
| 129 | + | require.Error(t, res.err) | |
| 130 | + | ||
| 131 | + | // list. | |
| 132 | + | res = c.cli(t, "", "mr", "list", "proj", "--json").ok(t) | |
| 133 | + | list := decode[[]mrOut](t, res.stdout) | |
| 134 | + | require.Len(t, list, 1, "public: anonymous lists") | |
| 135 | + | assert.Equal(t, "Add search", list[0].Title) | |
| 136 | + | res = c.cli(t, "", "mr", "list", "proj").ok(t) | |
| 137 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |
| 138 | + | require.Len(t, lines, 2) | |
| 139 | + | assert.Regexp(t, `^NUMBER\s+STATE\s+BRANCHES\s+AUTHOR\s+UPDATED\s+TITLE$`, lines[0]) | |
| 140 | + | assert.Regexp(t, `^!1\s+open\s+feat/search -> main\s+agent-7\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}\s+Add search$`, lines[1]) | |
| 141 | + | res = c.cli(t, "", "mr", "list", "proj", "--state", "merged", "--json").ok(t) | |
| 142 | + | assert.Equal(t, "[]\n", res.stdout) | |
| 143 | + | res = c.cli(t, "", "mr", "list", "proj", "--state", "merged").ok(t) | |
| 144 | + | assert.Equal(t, "ermite: no merge requests\n", res.stderr) | |
| 145 | + | res = c.cli(t, "", "mr", "list", "proj", "--state", "nonsense") | |
| 146 | + | require.Error(t, res.err) | |
| 147 | + | ||
| 148 | + | // show: the line numbers of the diff are what comment takes. | |
| 149 | + | res = c.cli(t, "", "mr", "show", "proj", "1", "--diff", "--json").ok(t) | |
| 150 | + | show := decode[showOut](t, res.stdout) | |
| 151 | + | assert.Equal(t, head1, show.HeadID) | |
| 152 | + | assert.Equal(t, main0, show.DiffBaseID) | |
| 153 | + | require.Len(t, show.Commits, 2) | |
| 154 | + | assert.Equal(t, 2, show.Stats.FilesChanged) | |
| 155 | + | paths := map[string]bool{} | |
| 156 | + | for _, f := range show.Files { | |
| 157 | + | paths[f.NewPath] = true | |
| 158 | + | } | |
| 159 | + | assert.Equal(t, map[string]bool{"README.md": true, "search.go": true}, paths) | |
| 160 | + | res = c.cli(t, "", "mr", "show", "proj", "!1", "--diff").ok(t) | |
| 161 | + | assert.Contains(t, res.stdout, "Add search") | |
| 162 | + | assert.Contains(t, res.stdout, "feat/search -> main") | |
| 163 | + | assert.Contains(t, res.stdout, "+package search") | |
| 164 | + | assert.Contains(t, res.stdout, "commits (2)") | |
| 165 | + | _, stderr, code = c.bin(t, "", "mr", "show", "proj", "7") | |
| 166 | + | requireOneLineError(t, stderr, code, "not found") | |
| 167 | + | ||
| 168 | + | // comment: general, on a line, and on a line that is not in the diff. | |
| 169 | + | res = c.cli(t, agent, "mr", "comment", "proj", "1", "-m", "Looks fine overall.", "--json").ok(t) | |
| 170 | + | general := decode[commentOut](t, res.stdout) | |
| 171 | + | assert.Equal(t, "1", general.ID) | |
| 172 | + | assert.Empty(t, general.Path) | |
| 173 | + | assert.Equal(t, "agent-7", general.Author) | |
| 174 | + | assert.Equal(t, "comment_added", decode[eventOut](t, w.next(t, 10*time.Second)).Type) | |
| 175 | + | res = c.cli(t, agent, "mr", "comment", "proj", "1", "--path", "search.go", "--line", "3", "-m", "Why empty?", "--json").ok(t) | |
| 176 | + | online := decode[commentOut](t, res.stdout) | |
| 177 | + | assert.Equal(t, "search.go", online.Path) | |
| 178 | + | assert.Equal(t, 3, online.Line) | |
| 179 | + | assert.Equal(t, "new", online.Side) | |
| 180 | + | assert.Equal(t, head1, online.CommitID) | |
| 181 | + | w.next(t, 10*time.Second) | |
| 182 | + | _, stderr, code = c.bin(t, agent, "mr", "comment", "proj", "1", "--path", "search.go", "--line", "99", "-m", "nowhere") | |
| 183 | + | requireOneLineError(t, stderr, code, "invalid request", "search.go:99") | |
| 184 | + | for _, args := range [][]string{ | |
| 185 | + | {"--path", "search.go", "-m", "x"}, // no line | |
| 186 | + | {"--line", "3", "-m", "x"}, // no path | |
| 187 | + | {"--path", "search.go", "--line", "3", "--side", "up", "-m", "x"}, // bad side | |
| 188 | + | } { | |
| 189 | + | res = c.cli(t, agent, append([]string{"mr", "comment", "proj", "1"}, args...)...) | |
| 190 | + | require.Error(t, res.err, "%v", args) | |
| 191 | + | } | |
| 192 | + | // stdin. | |
| 193 | + | res = runCLI(t, c.vars(agent), "from stdin\nwith two lines\n", "mr", "comment", "proj", "1", "-m", "-", "--json").ok(t) | |
| 194 | + | assert.Equal(t, "from stdin\nwith two lines", decode[commentOut](t, res.stdout).Text) | |
| 195 | + | w.next(t, 10*time.Second) | |
| 196 | + | // An agent cannot merge. | |
| 197 | + | _, stderr, code = c.bin(t, agent, "mr", "merge", "proj", "1", "--head", head1) | |
| 198 | + | requireOneLineError(t, stderr, code, "permission denied") | |
| 199 | + | ||
| 200 | + | // resolve, twice, and undone. | |
| 201 | + | res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--json").ok(t) | |
| 202 | + | assert.True(t, decode[commentOut](t, res.stdout).Resolved) | |
| 203 | + | res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--json").ok(t) | |
| 204 | + | assert.True(t, decode[commentOut](t, res.stdout).Resolved) | |
| 205 | + | res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--undo", "--json").ok(t) | |
| 206 | + | assert.False(t, decode[commentOut](t, res.stdout).Resolved) | |
| 207 | + | _, stderr, code = c.bin(t, agent, "mr", "resolve", "proj", "1", "42") | |
| 208 | + | requireOneLineError(t, stderr, code, "not found") | |
| 209 | + | ||
| 210 | + | // since-last-read: mark, push more, then only the new change. | |
| 211 | + | c.cli(t, agent, "mr", "show", "proj", "1", "--mark-read", "--json").ok(t) | |
| 212 | + | c.writeFile(wt, "extra.txt", []byte("extra\n")) | |
| 213 | + | c.mustGit(wt, "push", "-q", url, "feat/search") | |
| 214 | + | head2 := c.bareGit("proj", "rev-parse", "feat/search") | |
| 215 | + | res = c.cli(t, "", "mr", "show", "proj", "1", "--since-last-read", "--json").ok(t) | |
| 216 | + | show = decode[showOut](t, res.stdout) | |
| 217 | + | assert.Equal(t, head1, show.LastReadID) | |
| 218 | + | assert.Equal(t, head1, show.DiffBaseID) | |
| 219 | + | require.Len(t, show.Files, 1) | |
| 220 | + | assert.Equal(t, "extra.txt", show.Files[0].NewPath) | |
| 221 | + | assert.False(t, show.LastReadLost) | |
| 222 | + | // A force-push that drops the recorded commit: pruned, the whole change is shown, and said. | |
| 223 | + | c.mustGit(wt, "reset", "-q", "--hard", main0) | |
| 224 | + | c.writeFile(wt, "other.txt", []byte("o\n")) | |
| 225 | + | c.mustGit(wt, "push", "-q", "--force", url, "feat/search") | |
| 226 | + | c.bareGit("proj", "reflog", "expire", "--expire=now", "--all") | |
| 227 | + | c.bareGit("proj", "gc", "-q", "--prune=now") | |
| 228 | + | res = c.cli(t, "", "mr", "show", "proj", "1", "--since-last-read", "--json").ok(t) | |
| 229 | + | show = decode[showOut](t, res.stdout) | |
| 230 | + | assert.True(t, show.LastReadLost) | |
| 231 | + | assert.Equal(t, main0, show.DiffBaseID, "the whole change") | |
| 232 | + | assert.Contains(t, res.stderr, "head you last read is gone", "the reader is told") | |
| 233 | + | head3 := c.bareGit("proj", "rev-parse", "feat/search") | |
| 234 | + | _ = head2 | |
| 235 | + | ||
| 236 | + | // Merging a stale head fails and says to read again. | |
| 237 | + | _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "1", "--head", head1) | |
| 238 | + | requireOneLineError(t, stderr, code, "precondition failed", "branch moved") | |
| 239 | + | assert.Equal(t, main0, c.bareGit("proj", "rev-parse", "main")) | |
| 240 | + | ||
| 241 | + | // Fast-forward with the reviewed head. | |
| 242 | + | res = c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head3, "--json").ok(t) | |
| 243 | + | merged := decode[mrOut](t, res.stdout) | |
| 244 | + | assert.Equal(t, "merged", merged.State) | |
| 245 | + | assert.Equal(t, "fast-forward", merged.MergeMode) | |
| 246 | + | assert.Equal(t, head3, merged.MergedCommitID) | |
| 247 | + | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", "main")) | |
| 248 | + | mev := w.nextOfType(t, "merge_request_merged") | |
| 249 | + | require.NotNil(t, mev.MergeRequest) | |
| 250 | + | assert.Equal(t, head3, mev.MergeRequest.MergedCommitID) | |
| 251 | + | pev := decode[eventOut](t, w.next(t, 10*time.Second)) | |
| 252 | + | assert.Equal(t, "pushed", pev.Type, "the push of the target follows the merge") | |
| 253 | + | require.Len(t, pev.RefUpdates, 1) | |
| 254 | + | assert.Equal(t, "refs/heads/main", pev.RefUpdates[0].Ref) | |
| 255 | + | assert.Equal(t, head3, pev.RefUpdates[0].NewID) | |
| 256 | + | res = c.cli(t, "", "mr", "list", "proj", "--state", "all").ok(t) | |
| 257 | + | assert.Regexp(t, `!1\s+merged`, res.stdout) | |
| 258 | + | res = c.cli(t, secret, "mr", "close", "proj", "1") | |
| 259 | + | require.Error(t, res.err) | |
| 260 | + | assert.Contains(t, res.err.Error(), "precondition failed") | |
| 261 | + | ||
| 262 | + | // Squash, with --squash and without --head (the CLI reads the head and says so). | |
| 263 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3) | |
| 264 | + | c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy") | |
| 265 | + | c.writeFile(wt, "two.txt", []byte("two\n")) | |
| 266 | + | c.mustGit(wt, "push", "-q", url, "feat/two") | |
| 267 | + | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t) | |
| 268 | + | w.nextOfType(t, "merge_request_opened") | |
| 269 | + | res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash") | |
| 270 | + | require.NoError(t, res.err, res.stderr) | |
| 271 | + | assert.Contains(t, res.stderr, "no --head given") | |
| 272 | + | assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout) | |
| 273 | + | sq := c.bareGit("proj", "rev-parse", "main") | |
| 274 | + | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^")) | |
| 275 | + | assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq)) | |
| 276 | + | ||
| 277 | + | // Closing a third request. | |
| 278 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/three", head3) | |
| 279 | + | c.writeFile(wt, "three.txt", []byte("3\n")) | |
| 280 | + | c.mustGit(wt, "push", "-q", url, "feat/three") | |
| 281 | + | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/three", "--title", "Third").ok(t) | |
| 282 | + | res = c.cli(t, secret, "mr", "close", "proj", "3", "--json").ok(t) | |
| 283 | + | assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State) | |
| 284 | + | res = c.cli(t, secret, "mr", "close", "proj", "3") | |
| 285 | + | require.Error(t, res.err) | |
| 286 | + | _, stderr, code = c.bin(t, secret, "mr", "comment", "proj", "3", "-m", "late") | |
| 287 | + | requireOneLineError(t, stderr, code, "precondition failed") | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // A private repository: merge requests do not exist for those who cannot read it. | |
| 291 | + | func TestMergeRequestsOfAPrivateRepositoryAreNotFound(t *testing.T) { | |
| 292 | + | c := newCLIHarness(t) | |
| 293 | + | c.cli(t, adminToken, "repo", "create", "secret").ok(t) | |
| 294 | + | wt := c.workTree("main", "README.md") | |
| 295 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat") | |
| 296 | + | c.writeFile(wt, "x.txt", []byte("x\n")) | |
| 297 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/secret.git"), "main", "feat") | |
| 298 | + | c.cli(t, adminToken, "mr", "create", "secret", "--source", "feat", "--title", "t").ok(t) | |
| 299 | + | ||
| 300 | + | unscoped := c.token([]domain.Scope{domain.ScopeMRWrite, domain.ScopeMRMerge}) | |
| 301 | + | otherRepo := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite}, "elsewhere") | |
| 302 | + | for who, secret := range map[string]string{"anonymous": "", "no repo:read": unscoped, "token of another repository": otherRepo} { | |
| 303 | + | for _, args := range [][]string{ | |
| 304 | + | {"mr", "list", "secret"}, | |
| 305 | + | {"mr", "show", "secret", "1"}, | |
| 306 | + | {"mr", "create", "secret", "--source", "feat", "--title", "t"}, | |
| 307 | + | {"mr", "comment", "secret", "1", "-m", "x"}, | |
| 308 | + | {"mr", "close", "secret", "1"}, | |
| 309 | + | {"mr", "merge", "secret", "1", "--head", strings.Repeat("a", 40)}, | |
| 310 | + | } { | |
| 311 | + | _, stderr, code := c.bin(t, secret, args...) | |
| 312 | + | requireOneLineError(t, stderr, code, "not found") | |
| 313 | + | assert.NotContains(t, stderr, "permission denied", "%s: %v", who, args) | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | // And it is still there for the owner. | |
| 317 | + | res := c.cli(t, adminToken, "mr", "list", "secret", "--json").ok(t) | |
| 318 | + | assert.Len(t, decode[[]mrOut](t, res.stdout), 1) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // AC5 with the CLI: `ermite signing-key show` produces the allowed_signers line | |
| 322 | + | // that git verify-commit uses for a squash commit. | |
| 323 | + | func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) { | |
| 324 | + | if _, err := exec.LookPath("ssh-keygen"); err != nil { | |
| 325 | + | if os.Getenv("CI") != "" { | |
| 326 | + | t.Fatal("ssh-keygen not found: CI must have it (package openssh)") | |
| 327 | + | } | |
| 328 | + | t.Skip("ssh-keygen not found: install openssh to verify the signed commits") | |
| 329 | + | } | |
| 330 | + | c := newCLIHarness(t) | |
| 331 | + | c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t) | |
| 332 | + | wt := c.workTree("main", "README.md") | |
| 333 | + | url := c.urlAuth(adminToken, "/proj.git") | |
| 334 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat") | |
| 335 | + | c.writeFile(wt, "x.txt", []byte("x\n")) | |
| 336 | + | c.mustGit(wt, "push", "-q", url, "main", "feat") | |
| 337 | + | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t) | |
| 338 | + | res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash") | |
| 339 | + | require.NoError(t, res.err, res.stderr) | |
| 340 | + | sq := c.bareGit("proj", "rev-parse", "main") | |
| 341 | + | ||
| 342 | + | // No token is needed to read the key. | |
| 343 | + | show := c.cli(t, "", "signing-key", "show").ok(t) | |
| 344 | + | assert.Regexp(t, `(?m)^fingerprint\s+SHA256:`, show.stdout) | |
| 345 | + | assert.Regexp(t, `(?m)^committer\s+ermite <ermite@example.test>$`, show.stdout) | |
| 346 | + | allowed := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--allowed-signers").ok(t).stdout) | |
| 347 | + | pub := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout) | |
| 348 | + | assert.Equal(t, "ermite@example.test "+pub, allowed) | |
| 349 | + | assert.True(t, strings.HasPrefix(pub, "ssh-ed25519 ")) | |
| 350 | + | js := c.cli(t, "", "signing-key", "show", "--json").ok(t) | |
| 351 | + | assert.Equal(t, []string{"allowed_signers", "committer_email", "committer_name", "fingerprint", "public_key"}, keys(t, js.stdout)) | |
| 352 | + | assert.Equal(t, allowed, decode[struct { | |
| 353 | + | AllowedSigners string `json:"allowed_signers"` | |
| 354 | + | }](t, js.stdout).AllowedSigners) | |
| 355 | + | require.Error(t, c.cli(t, "", "signing-key", "show", "--json", "--public-key").err) | |
| 356 | + | require.Error(t, c.cli(t, "", "signing-key", "show", "--public-key", "--allowed-signers").err) | |
| 357 | + | ||
| 358 | + | // The file built from the CLI output verifies the commit with git. | |
| 359 | + | signers := filepath.Join(t.TempDir(), "allowed_signers") | |
| 360 | + | require.NoError(t, os.WriteFile(signers, []byte(allowed+"\n"), 0o600)) | |
| 361 | + | out, err := c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", sq) | |
| 362 | + | require.NoError(t, err, out) | |
| 363 | + | assert.Contains(t, out, `Good "git" signature for ermite@example.test`) | |
| 364 | + | // A commit pushed by a person is not signed by the instance key. | |
| 365 | + | _, err = c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", c.bareGit("proj", "rev-parse", "feat")) | |
| 366 | + | assert.Error(t, err) | |
| 367 | + | // The key is stable across calls. | |
| 368 | + | again := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout) | |
| 369 | + | assert.Equal(t, pub, again) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // repo edit sets the merge settings; repo show reports them. | |
| 373 | + | func TestRepoEditMergeSettingsCLI(t *testing.T) { | |
| 374 | + | c := newCLIHarness(t) | |
| 375 | + | c.seed("proj", domain.VisibilityPublic) | |
| 376 | + | res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t) | |
| 377 | + | repo := decode[struct { | |
| 378 | + | MergeModes []string `json:"merge_modes"` | |
| 379 | + | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 380 | + | }](t, res.stdout) | |
| 381 | + | assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes) | |
| 382 | + | assert.True(t, repo.FailingChecksBlock) | |
| 383 | + | ||
| 384 | + | res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t) | |
| 385 | + | repo = decode[struct { | |
| 386 | + | MergeModes []string `json:"merge_modes"` | |
| 387 | + | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 388 | + | }](t, res.stdout) | |
| 389 | + | assert.Equal(t, []string{"squash"}, repo.MergeModes) | |
| 390 | + | assert.True(t, repo.FailingChecksBlock, "untouched") | |
| 391 | + | res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t) | |
| 392 | + | repo = decode[struct { | |
| 393 | + | MergeModes []string `json:"merge_modes"` | |
| 394 | + | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 395 | + | }](t, res.stdout) | |
| 396 | + | assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched") | |
| 397 | + | assert.False(t, repo.FailingChecksBlock) | |
| 398 | + | res = c.cli(t, adminToken, "repo", "show", "proj").ok(t) | |
| 399 | + | assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout) | |
| 400 | + | assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout) | |
| 401 | + | ||
| 402 | + | require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err) | |
| 403 | + | require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err) | |
| 404 | + | _, stderr, code := c.bin(t, c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRMerge}), "repo", "edit", "proj", "--merge-modes", "squash") | |
| 405 | + | requireOneLineError(t, stderr, code, "permission denied") | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // nextOfType returns the next event of the type, skipping the others (the pushes | |
| 409 | + | // of the test itself). | |
| 410 | + | func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut { | |
| 411 | + | t.Helper() | |
| 412 | + | for range 20 { | |
| 413 | + | ev := decode[eventOut](t, w.next(t, 10*time.Second)) | |
| 414 | + | if ev.Type == typ { | |
| 415 | + | return ev | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | t.Fatalf("no %s event", typ) | |
| 419 | + | return eventOut{} | |
| 420 | + | } | |
| @@ -0,0 +1,420 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/domain" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | type mrOut struct { | |||
| 20 | + | Repo string `json:"repo"` | |||
| 21 | + | Number int64 `json:"number"` | |||
| 22 | + | Title string `json:"title"` | |||
| 23 | + | Description string `json:"description"` | |||
| 24 | + | SourceBranch string `json:"source_branch"` | |||
| 25 | + | TargetBranch string `json:"target_branch"` | |||
| 26 | + | State string `json:"state"` | |||
| 27 | + | Author string `json:"author"` | |||
| 28 | + | CreatedAt *string `json:"created_at"` | |||
| 29 | + | UpdatedAt *string `json:"updated_at"` | |||
| 30 | + | HeadID string `json:"head_id"` | |||
| 31 | + | MergedCommitID string `json:"merged_commit_id"` | |||
| 32 | + | MergeMode string `json:"merge_mode"` | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at"} | |||
| 36 | + | ||||
| 37 | + | type commentOut struct { | |||
| 38 | + | ID string `json:"id"` | |||
| 39 | + | Author string `json:"author"` | |||
| 40 | + | Path string `json:"path"` | |||
| 41 | + | Line int `json:"line"` | |||
| 42 | + | Side string `json:"side"` | |||
| 43 | + | CommitID string `json:"commit_id"` | |||
| 44 | + | Text string `json:"text"` | |||
| 45 | + | Resolved bool `json:"resolved"` | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type showOut struct { | |||
| 49 | + | mrOut | |||
| 50 | + | Commits []struct{ ID, Author, Summary string } | |||
| 51 | + | CommitsTruncated bool `json:"commits_truncated"` | |||
| 52 | + | DiffBaseID string `json:"diff_base_id"` | |||
| 53 | + | Stats struct { | |||
| 54 | + | FilesChanged int `json:"files_changed"` | |||
| 55 | + | Additions int | |||
| 56 | + | Deletions int | |||
| 57 | + | } | |||
| 58 | + | Files []struct { | |||
| 59 | + | OldPath string `json:"old_path"` | |||
| 60 | + | NewPath string `json:"new_path"` | |||
| 61 | + | Status string | |||
| 62 | + | Hunks []struct { | |||
| 63 | + | Lines []struct { | |||
| 64 | + | Kind string | |||
| 65 | + | OldNumber int `json:"old_number"` | |||
| 66 | + | NewNumber int `json:"new_number"` | |||
| 67 | + | Text string | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | Comments []commentOut | |||
| 72 | + | LastReadID string `json:"last_read_id"` | |||
| 73 | + | LastReadLost bool `json:"last_read_lost"` | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // AC3 to AC6 through the CLI against a real forge, with branches pushed by git. | |||
| 77 | + | func TestMergeRequestsCLIEndToEnd(t *testing.T) { | |||
| 78 | + | c := newCLIHarness(t) | |||
| 79 | + | stdout, stderr, code := c.bin(t, adminToken, "token", "create", "daily", "--preset", "cli") | |||
| 80 | + | require.Equal(t, 0, code, stderr) | |||
| 81 | + | secret := strings.TrimSpace(stdout) | |||
| 82 | + | agent := strings.TrimSpace(func() string { | |||
| 83 | + | out, errOut, code := c.bin(t, adminToken, "token", "create", "agent-7", "--preset", "agent", "--scope", "repo:read") | |||
| 84 | + | require.Equal(t, 0, code, errOut) | |||
| 85 | + | return out | |||
| 86 | + | }()) | |||
| 87 | + | ||||
| 88 | + | c.cli(t, secret, "repo", "create", "proj", "--visibility", "public").ok(t) | |||
| 89 | + | wt := c.workTree("main", "README.md") | |||
| 90 | + | url := c.urlAuth(secret, "/proj.git") | |||
| 91 | + | c.mustGit(wt, "push", "-q", url, "main") | |||
| 92 | + | main0 := c.bareGit("proj", "rev-parse", "main") | |||
| 93 | + | ||||
| 94 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/search") | |||
| 95 | + | c.writeFile(wt, "search.go", []byte("package search\n\nfunc Find() {}\n")) | |||
| 96 | + | c.writeFile(wt, "README.md", []byte("# proj\nsearch\n")) | |||
| 97 | + | c.mustGit(wt, "push", "-q", url, "feat/search") | |||
| 98 | + | head1 := c.bareGit("proj", "rev-parse", "feat/search") | |||
| 99 | + | ||||
| 100 | + | w := c.startWatch(t, secret) | |||
| 101 | + | ||||
| 102 | + | // create: needs mr:write; the agent token has it. | |||
| 103 | + | res := c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search", "--description", "Finds things.\nSecond line.", "--json").ok(t) | |||
| 104 | + | assert.Equal(t, mrKeys, keys(t, res.stdout)) | |||
| 105 | + | mr := decode[mrOut](t, res.stdout) | |||
| 106 | + | assert.EqualValues(t, 1, mr.Number) | |||
| 107 | + | assert.Equal(t, "open", mr.State) | |||
| 108 | + | assert.Equal(t, "agent-7", mr.Author) | |||
| 109 | + | assert.Equal(t, "main", mr.TargetBranch, "defaults to the default branch") | |||
| 110 | + | assert.Equal(t, head1, mr.HeadID) | |||
| 111 | + | assert.Empty(t, mr.MergedCommitID) | |||
| 112 | + | assert.Empty(t, mr.MergeMode) | |||
| 113 | + | ev := decode[eventOut](t, w.next(t, 10*time.Second)) | |||
| 114 | + | assert.Equal(t, "merge_request_opened", ev.Type) | |||
| 115 | + | assert.Equal(t, "proj", ev.Repo) | |||
| 116 | + | ||||
| 117 | + | // The human output is one line. | |||
| 118 | + | res = c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search", "--target", "main", "--title", "dup") | |||
| 119 | + | require.Error(t, res.err) | |||
| 120 | + | assert.Contains(t, res.err.Error(), "already exists") | |||
| 121 | + | ||||
| 122 | + | // Rules: every offending field in one message. | |||
| 123 | + | _, stderr, code = c.bin(t, agent, "mr", "create", "proj", "--source", "nope", "--title", "bad\u202etitle") | |||
| 124 | + | requireOneLineError(t, stderr, code, "invalid request") | |||
| 125 | + | _, stderr, code = c.bin(t, agent, "mr", "create", "proj", "--source", "feat/search", "--target", "feat/search", "--title", "t") | |||
| 126 | + | requireOneLineError(t, stderr, code, "invalid request") | |||
| 127 | + | // Missing flags are usage errors that do not reach the server. | |||
| 128 | + | res = c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search") | |||
| 129 | + | require.Error(t, res.err) | |||
| 130 | + | ||||
| 131 | + | // list. | |||
| 132 | + | res = c.cli(t, "", "mr", "list", "proj", "--json").ok(t) | |||
| 133 | + | list := decode[[]mrOut](t, res.stdout) | |||
| 134 | + | require.Len(t, list, 1, "public: anonymous lists") | |||
| 135 | + | assert.Equal(t, "Add search", list[0].Title) | |||
| 136 | + | res = c.cli(t, "", "mr", "list", "proj").ok(t) | |||
| 137 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |||
| 138 | + | require.Len(t, lines, 2) | |||
| 139 | + | assert.Regexp(t, `^NUMBER\s+STATE\s+BRANCHES\s+AUTHOR\s+UPDATED\s+TITLE$`, lines[0]) | |||
| 140 | + | assert.Regexp(t, `^!1\s+open\s+feat/search -> main\s+agent-7\s+\d{4}-\d{2}-\d{2} \d{2}:\d{2}\s+Add search$`, lines[1]) | |||
| 141 | + | res = c.cli(t, "", "mr", "list", "proj", "--state", "merged", "--json").ok(t) | |||
| 142 | + | assert.Equal(t, "[]\n", res.stdout) | |||
| 143 | + | res = c.cli(t, "", "mr", "list", "proj", "--state", "merged").ok(t) | |||
| 144 | + | assert.Equal(t, "ermite: no merge requests\n", res.stderr) | |||
| 145 | + | res = c.cli(t, "", "mr", "list", "proj", "--state", "nonsense") | |||
| 146 | + | require.Error(t, res.err) | |||
| 147 | + | ||||
| 148 | + | // show: the line numbers of the diff are what comment takes. | |||
| 149 | + | res = c.cli(t, "", "mr", "show", "proj", "1", "--diff", "--json").ok(t) | |||
| 150 | + | show := decode[showOut](t, res.stdout) | |||
| 151 | + | assert.Equal(t, head1, show.HeadID) | |||
| 152 | + | assert.Equal(t, main0, show.DiffBaseID) | |||
| 153 | + | require.Len(t, show.Commits, 2) | |||
| 154 | + | assert.Equal(t, 2, show.Stats.FilesChanged) | |||
| 155 | + | paths := map[string]bool{} | |||
| 156 | + | for _, f := range show.Files { | |||
| 157 | + | paths[f.NewPath] = true | |||
| 158 | + | } | |||
| 159 | + | assert.Equal(t, map[string]bool{"README.md": true, "search.go": true}, paths) | |||
| 160 | + | res = c.cli(t, "", "mr", "show", "proj", "!1", "--diff").ok(t) | |||
| 161 | + | assert.Contains(t, res.stdout, "Add search") | |||
| 162 | + | assert.Contains(t, res.stdout, "feat/search -> main") | |||
| 163 | + | assert.Contains(t, res.stdout, "+package search") | |||
| 164 | + | assert.Contains(t, res.stdout, "commits (2)") | |||
| 165 | + | _, stderr, code = c.bin(t, "", "mr", "show", "proj", "7") | |||
| 166 | + | requireOneLineError(t, stderr, code, "not found") | |||
| 167 | + | ||||
| 168 | + | // comment: general, on a line, and on a line that is not in the diff. | |||
| 169 | + | res = c.cli(t, agent, "mr", "comment", "proj", "1", "-m", "Looks fine overall.", "--json").ok(t) | |||
| 170 | + | general := decode[commentOut](t, res.stdout) | |||
| 171 | + | assert.Equal(t, "1", general.ID) | |||
| 172 | + | assert.Empty(t, general.Path) | |||
| 173 | + | assert.Equal(t, "agent-7", general.Author) | |||
| 174 | + | assert.Equal(t, "comment_added", decode[eventOut](t, w.next(t, 10*time.Second)).Type) | |||
| 175 | + | res = c.cli(t, agent, "mr", "comment", "proj", "1", "--path", "search.go", "--line", "3", "-m", "Why empty?", "--json").ok(t) | |||
| 176 | + | online := decode[commentOut](t, res.stdout) | |||
| 177 | + | assert.Equal(t, "search.go", online.Path) | |||
| 178 | + | assert.Equal(t, 3, online.Line) | |||
| 179 | + | assert.Equal(t, "new", online.Side) | |||
| 180 | + | assert.Equal(t, head1, online.CommitID) | |||
| 181 | + | w.next(t, 10*time.Second) | |||
| 182 | + | _, stderr, code = c.bin(t, agent, "mr", "comment", "proj", "1", "--path", "search.go", "--line", "99", "-m", "nowhere") | |||
| 183 | + | requireOneLineError(t, stderr, code, "invalid request", "search.go:99") | |||
| 184 | + | for _, args := range [][]string{ | |||
| 185 | + | {"--path", "search.go", "-m", "x"}, // no line | |||
| 186 | + | {"--line", "3", "-m", "x"}, // no path | |||
| 187 | + | {"--path", "search.go", "--line", "3", "--side", "up", "-m", "x"}, // bad side | |||
| 188 | + | } { | |||
| 189 | + | res = c.cli(t, agent, append([]string{"mr", "comment", "proj", "1"}, args...)...) | |||
| 190 | + | require.Error(t, res.err, "%v", args) | |||
| 191 | + | } | |||
| 192 | + | // stdin. | |||
| 193 | + | res = runCLI(t, c.vars(agent), "from stdin\nwith two lines\n", "mr", "comment", "proj", "1", "-m", "-", "--json").ok(t) | |||
| 194 | + | assert.Equal(t, "from stdin\nwith two lines", decode[commentOut](t, res.stdout).Text) | |||
| 195 | + | w.next(t, 10*time.Second) | |||
| 196 | + | // An agent cannot merge. | |||
| 197 | + | _, stderr, code = c.bin(t, agent, "mr", "merge", "proj", "1", "--head", head1) | |||
| 198 | + | requireOneLineError(t, stderr, code, "permission denied") | |||
| 199 | + | ||||
| 200 | + | // resolve, twice, and undone. | |||
| 201 | + | res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--json").ok(t) | |||
| 202 | + | assert.True(t, decode[commentOut](t, res.stdout).Resolved) | |||
| 203 | + | res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--json").ok(t) | |||
| 204 | + | assert.True(t, decode[commentOut](t, res.stdout).Resolved) | |||
| 205 | + | res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--undo", "--json").ok(t) | |||
| 206 | + | assert.False(t, decode[commentOut](t, res.stdout).Resolved) | |||
| 207 | + | _, stderr, code = c.bin(t, agent, "mr", "resolve", "proj", "1", "42") | |||
| 208 | + | requireOneLineError(t, stderr, code, "not found") | |||
| 209 | + | ||||
| 210 | + | // since-last-read: mark, push more, then only the new change. | |||
| 211 | + | c.cli(t, agent, "mr", "show", "proj", "1", "--mark-read", "--json").ok(t) | |||
| 212 | + | c.writeFile(wt, "extra.txt", []byte("extra\n")) | |||
| 213 | + | c.mustGit(wt, "push", "-q", url, "feat/search") | |||
| 214 | + | head2 := c.bareGit("proj", "rev-parse", "feat/search") | |||
| 215 | + | res = c.cli(t, "", "mr", "show", "proj", "1", "--since-last-read", "--json").ok(t) | |||
| 216 | + | show = decode[showOut](t, res.stdout) | |||
| 217 | + | assert.Equal(t, head1, show.LastReadID) | |||
| 218 | + | assert.Equal(t, head1, show.DiffBaseID) | |||
| 219 | + | require.Len(t, show.Files, 1) | |||
| 220 | + | assert.Equal(t, "extra.txt", show.Files[0].NewPath) | |||
| 221 | + | assert.False(t, show.LastReadLost) | |||
| 222 | + | // A force-push that drops the recorded commit: pruned, the whole change is shown, and said. | |||
| 223 | + | c.mustGit(wt, "reset", "-q", "--hard", main0) | |||
| 224 | + | c.writeFile(wt, "other.txt", []byte("o\n")) | |||
| 225 | + | c.mustGit(wt, "push", "-q", "--force", url, "feat/search") | |||
| 226 | + | c.bareGit("proj", "reflog", "expire", "--expire=now", "--all") | |||
| 227 | + | c.bareGit("proj", "gc", "-q", "--prune=now") | |||
| 228 | + | res = c.cli(t, "", "mr", "show", "proj", "1", "--since-last-read", "--json").ok(t) | |||
| 229 | + | show = decode[showOut](t, res.stdout) | |||
| 230 | + | assert.True(t, show.LastReadLost) | |||
| 231 | + | assert.Equal(t, main0, show.DiffBaseID, "the whole change") | |||
| 232 | + | assert.Contains(t, res.stderr, "head you last read is gone", "the reader is told") | |||
| 233 | + | head3 := c.bareGit("proj", "rev-parse", "feat/search") | |||
| 234 | + | _ = head2 | |||
| 235 | + | ||||
| 236 | + | // Merging a stale head fails and says to read again. | |||
| 237 | + | _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "1", "--head", head1) | |||
| 238 | + | requireOneLineError(t, stderr, code, "precondition failed", "branch moved") | |||
| 239 | + | assert.Equal(t, main0, c.bareGit("proj", "rev-parse", "main")) | |||
| 240 | + | ||||
| 241 | + | // Fast-forward with the reviewed head. | |||
| 242 | + | res = c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head3, "--json").ok(t) | |||
| 243 | + | merged := decode[mrOut](t, res.stdout) | |||
| 244 | + | assert.Equal(t, "merged", merged.State) | |||
| 245 | + | assert.Equal(t, "fast-forward", merged.MergeMode) | |||
| 246 | + | assert.Equal(t, head3, merged.MergedCommitID) | |||
| 247 | + | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", "main")) | |||
| 248 | + | mev := w.nextOfType(t, "merge_request_merged") | |||
| 249 | + | require.NotNil(t, mev.MergeRequest) | |||
| 250 | + | assert.Equal(t, head3, mev.MergeRequest.MergedCommitID) | |||
| 251 | + | pev := decode[eventOut](t, w.next(t, 10*time.Second)) | |||
| 252 | + | assert.Equal(t, "pushed", pev.Type, "the push of the target follows the merge") | |||
| 253 | + | require.Len(t, pev.RefUpdates, 1) | |||
| 254 | + | assert.Equal(t, "refs/heads/main", pev.RefUpdates[0].Ref) | |||
| 255 | + | assert.Equal(t, head3, pev.RefUpdates[0].NewID) | |||
| 256 | + | res = c.cli(t, "", "mr", "list", "proj", "--state", "all").ok(t) | |||
| 257 | + | assert.Regexp(t, `!1\s+merged`, res.stdout) | |||
| 258 | + | res = c.cli(t, secret, "mr", "close", "proj", "1") | |||
| 259 | + | require.Error(t, res.err) | |||
| 260 | + | assert.Contains(t, res.err.Error(), "precondition failed") | |||
| 261 | + | ||||
| 262 | + | // Squash, with --squash and without --head (the CLI reads the head and says so). | |||
| 263 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3) | |||
| 264 | + | c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy") | |||
| 265 | + | c.writeFile(wt, "two.txt", []byte("two\n")) | |||
| 266 | + | c.mustGit(wt, "push", "-q", url, "feat/two") | |||
| 267 | + | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t) | |||
| 268 | + | w.nextOfType(t, "merge_request_opened") | |||
| 269 | + | res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash") | |||
| 270 | + | require.NoError(t, res.err, res.stderr) | |||
| 271 | + | assert.Contains(t, res.stderr, "no --head given") | |||
| 272 | + | assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout) | |||
| 273 | + | sq := c.bareGit("proj", "rev-parse", "main") | |||
| 274 | + | assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^")) | |||
| 275 | + | assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq)) | |||
| 276 | + | ||||
| 277 | + | // Closing a third request. | |||
| 278 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat/three", head3) | |||
| 279 | + | c.writeFile(wt, "three.txt", []byte("3\n")) | |||
| 280 | + | c.mustGit(wt, "push", "-q", url, "feat/three") | |||
| 281 | + | c.cli(t, secret, "mr", "create", "proj", "--source", "feat/three", "--title", "Third").ok(t) | |||
| 282 | + | res = c.cli(t, secret, "mr", "close", "proj", "3", "--json").ok(t) | |||
| 283 | + | assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State) | |||
| 284 | + | res = c.cli(t, secret, "mr", "close", "proj", "3") | |||
| 285 | + | require.Error(t, res.err) | |||
| 286 | + | _, stderr, code = c.bin(t, secret, "mr", "comment", "proj", "3", "-m", "late") | |||
| 287 | + | requireOneLineError(t, stderr, code, "precondition failed") | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // A private repository: merge requests do not exist for those who cannot read it. | |||
| 291 | + | func TestMergeRequestsOfAPrivateRepositoryAreNotFound(t *testing.T) { | |||
| 292 | + | c := newCLIHarness(t) | |||
| 293 | + | c.cli(t, adminToken, "repo", "create", "secret").ok(t) | |||
| 294 | + | wt := c.workTree("main", "README.md") | |||
| 295 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat") | |||
| 296 | + | c.writeFile(wt, "x.txt", []byte("x\n")) | |||
| 297 | + | c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/secret.git"), "main", "feat") | |||
| 298 | + | c.cli(t, adminToken, "mr", "create", "secret", "--source", "feat", "--title", "t").ok(t) | |||
| 299 | + | ||||
| 300 | + | unscoped := c.token([]domain.Scope{domain.ScopeMRWrite, domain.ScopeMRMerge}) | |||
| 301 | + | otherRepo := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite}, "elsewhere") | |||
| 302 | + | for who, secret := range map[string]string{"anonymous": "", "no repo:read": unscoped, "token of another repository": otherRepo} { | |||
| 303 | + | for _, args := range [][]string{ | |||
| 304 | + | {"mr", "list", "secret"}, | |||
| 305 | + | {"mr", "show", "secret", "1"}, | |||
| 306 | + | {"mr", "create", "secret", "--source", "feat", "--title", "t"}, | |||
| 307 | + | {"mr", "comment", "secret", "1", "-m", "x"}, | |||
| 308 | + | {"mr", "close", "secret", "1"}, | |||
| 309 | + | {"mr", "merge", "secret", "1", "--head", strings.Repeat("a", 40)}, | |||
| 310 | + | } { | |||
| 311 | + | _, stderr, code := c.bin(t, secret, args...) | |||
| 312 | + | requireOneLineError(t, stderr, code, "not found") | |||
| 313 | + | assert.NotContains(t, stderr, "permission denied", "%s: %v", who, args) | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | // And it is still there for the owner. | |||
| 317 | + | res := c.cli(t, adminToken, "mr", "list", "secret", "--json").ok(t) | |||
| 318 | + | assert.Len(t, decode[[]mrOut](t, res.stdout), 1) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // AC5 with the CLI: `ermite signing-key show` produces the allowed_signers line | |||
| 322 | + | // that git verify-commit uses for a squash commit. | |||
| 323 | + | func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) { | |||
| 324 | + | if _, err := exec.LookPath("ssh-keygen"); err != nil { | |||
| 325 | + | if os.Getenv("CI") != "" { | |||
| 326 | + | t.Fatal("ssh-keygen not found: CI must have it (package openssh)") | |||
| 327 | + | } | |||
| 328 | + | t.Skip("ssh-keygen not found: install openssh to verify the signed commits") | |||
| 329 | + | } | |||
| 330 | + | c := newCLIHarness(t) | |||
| 331 | + | c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t) | |||
| 332 | + | wt := c.workTree("main", "README.md") | |||
| 333 | + | url := c.urlAuth(adminToken, "/proj.git") | |||
| 334 | + | c.mustGit(wt, "checkout", "-q", "-b", "feat") | |||
| 335 | + | c.writeFile(wt, "x.txt", []byte("x\n")) | |||
| 336 | + | c.mustGit(wt, "push", "-q", url, "main", "feat") | |||
| 337 | + | c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t) | |||
| 338 | + | res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash") | |||
| 339 | + | require.NoError(t, res.err, res.stderr) | |||
| 340 | + | sq := c.bareGit("proj", "rev-parse", "main") | |||
| 341 | + | ||||
| 342 | + | // No token is needed to read the key. | |||
| 343 | + | show := c.cli(t, "", "signing-key", "show").ok(t) | |||
| 344 | + | assert.Regexp(t, `(?m)^fingerprint\s+SHA256:`, show.stdout) | |||
| 345 | + | assert.Regexp(t, `(?m)^committer\s+ermite <ermite@example.test>$`, show.stdout) | |||
| 346 | + | allowed := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--allowed-signers").ok(t).stdout) | |||
| 347 | + | pub := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout) | |||
| 348 | + | assert.Equal(t, "ermite@example.test "+pub, allowed) | |||
| 349 | + | assert.True(t, strings.HasPrefix(pub, "ssh-ed25519 ")) | |||
| 350 | + | js := c.cli(t, "", "signing-key", "show", "--json").ok(t) | |||
| 351 | + | assert.Equal(t, []string{"allowed_signers", "committer_email", "committer_name", "fingerprint", "public_key"}, keys(t, js.stdout)) | |||
| 352 | + | assert.Equal(t, allowed, decode[struct { | |||
| 353 | + | AllowedSigners string `json:"allowed_signers"` | |||
| 354 | + | }](t, js.stdout).AllowedSigners) | |||
| 355 | + | require.Error(t, c.cli(t, "", "signing-key", "show", "--json", "--public-key").err) | |||
| 356 | + | require.Error(t, c.cli(t, "", "signing-key", "show", "--public-key", "--allowed-signers").err) | |||
| 357 | + | ||||
| 358 | + | // The file built from the CLI output verifies the commit with git. | |||
| 359 | + | signers := filepath.Join(t.TempDir(), "allowed_signers") | |||
| 360 | + | require.NoError(t, os.WriteFile(signers, []byte(allowed+"\n"), 0o600)) | |||
| 361 | + | out, err := c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", sq) | |||
| 362 | + | require.NoError(t, err, out) | |||
| 363 | + | assert.Contains(t, out, `Good "git" signature for ermite@example.test`) | |||
| 364 | + | // A commit pushed by a person is not signed by the instance key. | |||
| 365 | + | _, err = c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", c.bareGit("proj", "rev-parse", "feat")) | |||
| 366 | + | assert.Error(t, err) | |||
| 367 | + | // The key is stable across calls. | |||
| 368 | + | again := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout) | |||
| 369 | + | assert.Equal(t, pub, again) | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // repo edit sets the merge settings; repo show reports them. | |||
| 373 | + | func TestRepoEditMergeSettingsCLI(t *testing.T) { | |||
| 374 | + | c := newCLIHarness(t) | |||
| 375 | + | c.seed("proj", domain.VisibilityPublic) | |||
| 376 | + | res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t) | |||
| 377 | + | repo := decode[struct { | |||
| 378 | + | MergeModes []string `json:"merge_modes"` | |||
| 379 | + | FailingChecksBlock bool `json:"failing_checks_block"` | |||
| 380 | + | }](t, res.stdout) | |||
| 381 | + | assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes) | |||
| 382 | + | assert.True(t, repo.FailingChecksBlock) | |||
| 383 | + | ||||
| 384 | + | res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t) | |||
| 385 | + | repo = decode[struct { | |||
| 386 | + | MergeModes []string `json:"merge_modes"` | |||
| 387 | + | FailingChecksBlock bool `json:"failing_checks_block"` | |||
| 388 | + | }](t, res.stdout) | |||
| 389 | + | assert.Equal(t, []string{"squash"}, repo.MergeModes) | |||
| 390 | + | assert.True(t, repo.FailingChecksBlock, "untouched") | |||
| 391 | + | res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t) | |||
| 392 | + | repo = decode[struct { | |||
| 393 | + | MergeModes []string `json:"merge_modes"` | |||
| 394 | + | FailingChecksBlock bool `json:"failing_checks_block"` | |||
| 395 | + | }](t, res.stdout) | |||
| 396 | + | assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched") | |||
| 397 | + | assert.False(t, repo.FailingChecksBlock) | |||
| 398 | + | res = c.cli(t, adminToken, "repo", "show", "proj").ok(t) | |||
| 399 | + | assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout) | |||
| 400 | + | assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout) | |||
| 401 | + | ||||
| 402 | + | require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err) | |||
| 403 | + | require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err) | |||
| 404 | + | _, stderr, code := c.bin(t, c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRMerge}), "repo", "edit", "proj", "--merge-modes", "squash") | |||
| 405 | + | requireOneLineError(t, stderr, code, "permission denied") | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // nextOfType returns the next event of the type, skipping the others (the pushes | |||
| 409 | + | // of the test itself). | |||
| 410 | + | func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut { | |||
| 411 | + | t.Helper() | |||
| 412 | + | for range 20 { | |||
| 413 | + | ev := decode[eventOut](t, w.next(t, 10*time.Second)) | |||
| 414 | + | if ev.Type == typ { | |||
| 415 | + | return ev | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 418 | + | t.Fatalf("no %s event", typ) | |||
| 419 | + | return eventOut{} | |||
| 420 | + | } | |||
A cmd/ermite/cli_mr_test.go +342 -0
| @@ -0,0 +1,342 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | "google.golang.org/protobuf/proto" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var ( | |
| 18 | + | headA = strings.Repeat("a", 40) | |
| 19 | + | mrOne = &ermite.MergeRequest{ | |
| 20 | + | Repo: "alpha", Number: 1, Title: "Add search", Description: "Finds things.", SourceBranch: "feat/x", TargetBranch: "main", | |
| 21 | + | State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Author: "agent-7", | |
| 22 | + | CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), HeadId: headA, | |
| 23 | + | } | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | func TestMRCreateMapsFlags(t *testing.T) { | |
| 27 | + | f := &fakeAPI{createMR: func(*ermite.CreateMergeRequestRequest) (*ermite.CreateMergeRequestResponse, error) { | |
| 28 | + | return &ermite.CreateMergeRequestResponse{MergeRequest: mrOne}, nil | |
| 29 | + | }} | |
| 30 | + | url := f.serve(t) | |
| 31 | + | res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.", | |
| 32 | + | "--source", "refs/heads/feat/x", "--target", " main ").ok(t) | |
| 33 | + | assert.Equal(t, "opened alpha!1 (feat/x into main)\n", res.stdout) | |
| 34 | + | _, reqs := f.snapshot() | |
| 35 | + | got := reqs[0].(*ermite.CreateMergeRequestRequest) | |
| 36 | + | assert.Equal(t, "alpha", got.GetRepo()) | |
| 37 | + | assert.Equal(t, "Add search", got.GetTitle()) | |
| 38 | + | assert.Equal(t, "Finds things.", got.GetDescription()) | |
| 39 | + | assert.Equal(t, "feat/x", got.GetSourceBranch(), "a full ref name is shortened") | |
| 40 | + | assert.Equal(t, "main", got.GetTargetBranch()) | |
| 41 | + | ||
| 42 | + | // The target defaults to nothing, which is the default branch for the server; | |
| 43 | + | // the description comes from stdin with -. | |
| 44 | + | res = runCLI(t, envFor(url), "line one\nline two\n", "mr", "create", "alpha", "--title", "t", "--source", "feat/x", "--description", "-").ok(t) | |
| 45 | + | _, reqs = f.snapshot() | |
| 46 | + | got = reqs[1].(*ermite.CreateMergeRequestRequest) | |
| 47 | + | assert.Empty(t, got.GetTargetBranch()) | |
| 48 | + | assert.Equal(t, "line one\nline two", got.GetDescription()) | |
| 49 | + | _ = res | |
| 50 | + | ||
| 51 | + | res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t) | |
| 52 | + | assert.JSONEq(t, `{"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) | |
| 53 | + | ||
| 54 | + | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | |
| 55 | + | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestMRListTableJSONAndStates(t *testing.T) { | |
| 60 | + | merged := &ermite.MergeRequest{ | |
| 61 | + | Repo: "alpha", Number: 2, Title: "Fix\tthe \x1b[31mbug", SourceBranch: "fix", TargetBranch: "main", | |
| 62 | + | State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, Author: "root", UpdatedAt: ts("2026-10-03T00:00:00Z"), | |
| 63 | + | MergedCommitId: headA, MergeMode: ermite.MergeMode_MERGE_MODE_SQUASH, | |
| 64 | + | } | |
| 65 | + | f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) { | |
| 66 | + | return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{merged, mrOne}}, nil | |
| 67 | + | }} | |
| 68 | + | url := f.serve(t) | |
| 69 | + | res := runCLI(t, envFor(url), "", "mr", "list", "alpha", "--state", "all").ok(t) | |
| 70 | + | assert.Equal(t, ""+ | |
| 71 | + | "NUMBER STATE BRANCHES AUTHOR UPDATED TITLE\n"+ | |
| 72 | + | "!2 merged fix -> main root 2026-10-03 00:00 Fix the ?[31mbug\n"+ | |
| 73 | + | "!1 open feat/x -> main agent-7 2026-10-02 11:30 Add search\n", res.stdout) | |
| 74 | + | assert.NotContains(t, res.stdout, "\x1b", "server text is made safe for a terminal") | |
| 75 | + | ||
| 76 | + | _, reqs := f.snapshot() | |
| 77 | + | assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, reqs[0].(*ermite.ListMergeRequestsRequest).GetState(), "all") | |
| 78 | + | runCLI(t, envFor(url), "", "mr", "list", "alpha").ok(t) | |
| 79 | + | _, reqs = f.snapshot() | |
| 80 | + | assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, reqs[1].(*ermite.ListMergeRequestsRequest).GetState(), "open by default") | |
| 81 | + | runCLI(t, envFor(url), "", "mr", "list", "alpha", "--state", "closed").ok(t) | |
| 82 | + | _, reqs = f.snapshot() | |
| 83 | + | assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, reqs[2].(*ermite.ListMergeRequestsRequest).GetState()) | |
| 84 | + | ||
| 85 | + | res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t) | |
| 86 | + | assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo}) | |
| 87 | + | assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestMRListFollowsPages(t *testing.T) { | |
| 91 | + | f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) { | |
| 92 | + | if r.GetPageToken() == "" { | |
| 93 | + | return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{mrOne}, NextPageToken: "p2"}, nil | |
| 94 | + | } | |
| 95 | + | m := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 96 | + | m.Number = 7 | |
| 97 | + | return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{m}}, nil | |
| 98 | + | }} | |
| 99 | + | res := runCLI(t, envFor(f.serve(t)), "", "mr", "list", "alpha", "--json").ok(t) | |
| 100 | + | got := decode[[]mrOut](t, res.stdout) | |
| 101 | + | require.Len(t, got, 2) | |
| 102 | + | assert.EqualValues(t, 7, got[1].Number) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestMRShow(t *testing.T) { | |
| 106 | + | other := &ermite.Signature{Name: "Bob", Email: "bob@example.org"} | |
| 107 | + | detail := &ermite.GetMergeRequestResponse{ | |
| 108 | + | MergeRequest: mrOne, | |
| 109 | + | Commits: []*ermite.Commit{{Id: headA, Author: other, Summary: "add search"}}, | |
| 110 | + | DiffBaseId: strings.Repeat("b", 40), | |
| 111 | + | Stats: &ermite.DiffStats{FilesChanged: 1, Additions: 2, Deletions: 1}, | |
| 112 | + | Diff: &ermite.Diff{Files: []*ermite.FileDiff{{ | |
| 113 | + | OldPath: "a.txt", NewPath: "a.txt", Status: ermite.FileStatus_FILE_STATUS_MODIFIED, Additions: 2, Deletions: 1, | |
| 114 | + | Hunks: []*ermite.Hunk{{OldStart: 4, OldLines: 2, NewStart: 4, NewLines: 3, Header: "func f()", Lines: []*ermite.Line{ | |
| 115 | + | {Kind: ermite.LineKind_LINE_KIND_CONTEXT, OldNumber: 4, NewNumber: 4, Text: "keep"}, | |
| 116 | + | {Kind: ermite.LineKind_LINE_KIND_DELETION, OldNumber: 5, Text: "old"}, | |
| 117 | + | {Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 5, Text: "new \x1b[0m"}, | |
| 118 | + | {Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 6, Text: "more"}, | |
| 119 | + | }}}, | |
| 120 | + | }}}, | |
| 121 | + | Comments: []*ermite.Comment{ | |
| 122 | + | {Id: "1", Author: "agent-7", Text: "general\nnote", CreatedAt: ts("2026-10-02T09:00:00Z")}, | |
| 123 | + | {Id: "2", Author: "root", Path: "a.txt", Line: 5, Side: ermite.DiffSide_DIFF_SIDE_NEW, Text: "why?", Resolved: true, CreatedAt: ts("2026-10-02T10:00:00Z")}, | |
| 124 | + | }, | |
| 125 | + | LastReadId: strings.Repeat("c", 40), | |
| 126 | + | } | |
| 127 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return detail, nil }} | |
| 128 | + | url := f.serve(t) | |
| 129 | + | ||
| 130 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "!1", "--diff").ok(t) | |
| 131 | + | out := res.stdout | |
| 132 | + | assert.Contains(t, out, "merge request alpha!1") | |
| 133 | + | assert.Contains(t, out, "branches feat/x -> main") | |
| 134 | + | assert.Contains(t, out, "commits (1)") | |
| 135 | + | assert.Contains(t, out, "changes: 1 file(s), +2 -1, from bbbbbbbbbb") | |
| 136 | + | assert.Contains(t, out, "modified +2 -1 a.txt") | |
| 137 | + | assert.Contains(t, out, " #1 agent-7") | |
| 138 | + | assert.Contains(t, out, "\n note\n", "comment lines are indented") | |
| 139 | + | assert.Contains(t, out, "a.txt:5 (new) [resolved]") | |
| 140 | + | assert.Contains(t, out, "\n 5 +new ?[0m\n", "an addition has a new number only") | |
| 141 | + | assert.Contains(t, out, "\n 5 -old\n", "a deletion has an old number only") | |
| 142 | + | assert.Contains(t, out, "\n 4 4 keep\n", "a context line has both") | |
| 143 | + | assert.NotContains(t, out, "\x1b") | |
| 144 | + | assert.Empty(t, res.stderr) | |
| 145 | + | _, reqs := f.snapshot() | |
| 146 | + | assert.EqualValues(t, 1, reqs[0].(*ermite.GetMergeRequestRequest).GetNumber()) | |
| 147 | + | assert.False(t, reqs[0].(*ermite.GetMergeRequestRequest).GetSinceLastRead()) | |
| 148 | + | ||
| 149 | + | // Without --diff the lines are left out of the human and the JSON output. | |
| 150 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 151 | + | assert.NotContains(t, res.stdout, "+new") | |
| 152 | + | js := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |
| 153 | + | assert.NotContains(t, js.stdout, `"hunks"`) | |
| 154 | + | js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t) | |
| 155 | + | show := decode[showOut](t, js.stdout) | |
| 156 | + | require.Len(t, show.Files, 1) | |
| 157 | + | require.Len(t, show.Files[0].Hunks, 1) | |
| 158 | + | assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind) | |
| 159 | + | assert.Equal(t, 5, show.Files[0].Hunks[0].Lines[2].NewNumber) | |
| 160 | + | require.Len(t, show.Comments, 2) | |
| 161 | + | assert.Equal(t, "new", show.Comments[1].Side) | |
| 162 | + | assert.Empty(t, show.Comments[0].Side) | |
| 163 | + | assert.Equal(t, strings.Repeat("c", 40), show.LastReadID) | |
| 164 | + | ||
| 165 | + | // --since-last-read asks for it, and says when the head is lost. | |
| 166 | + | detail.LastReadLost = true | |
| 167 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--since-last-read").ok(t) | |
| 168 | + | _, reqs = f.snapshot() | |
| 169 | + | assert.True(t, reqs[len(reqs)-1].(*ermite.GetMergeRequestRequest).GetSinceLastRead()) | |
| 170 | + | assert.Contains(t, res.stderr, "head you last read is gone") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestMRShowMarkRead(t *testing.T) { | |
| 174 | + | f := &fakeAPI{ | |
| 175 | + | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |
| 176 | + | return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil | |
| 177 | + | }, | |
| 178 | + | markRead: func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) { | |
| 179 | + | return &ermite.MarkReadResponse{}, nil | |
| 180 | + | }, | |
| 181 | + | } | |
| 182 | + | url := f.serve(t) | |
| 183 | + | runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |
| 184 | + | calls, _ := f.snapshot() | |
| 185 | + | assert.Equal(t, []string{"GetMergeRequest"}, calls, "reading does not mark read") | |
| 186 | + | runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--mark-read").ok(t) | |
| 187 | + | calls, reqs := f.snapshot() | |
| 188 | + | assert.Equal(t, []string{"GetMergeRequest", "GetMergeRequest", "MarkRead"}, calls) | |
| 189 | + | assert.Equal(t, headA, reqs[2].(*ermite.MarkReadRequest).GetHeadId(), "marks the head that was shown") | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestMRCommentMapsFlags(t *testing.T) { | |
| 193 | + | f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) { | |
| 194 | + | return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "3", Path: r.GetPath(), Line: r.GetLine(), Side: r.GetSide(), Text: r.GetText()}}, nil | |
| 195 | + | }} | |
| 196 | + | url := f.serve(t) | |
| 197 | + | ||
| 198 | + | res := runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "hello").ok(t) | |
| 199 | + | assert.Equal(t, "comment 3 added to alpha!1\n", res.stdout) | |
| 200 | + | runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "on a line", "--path", "a.txt", "--line", "5").ok(t) | |
| 201 | + | runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "old side", "--path", "a.txt", "--line", "5", "--side", "OLD").ok(t) | |
| 202 | + | runCLI(t, envFor(url), "text\n", "mr", "comment", "alpha", "!1", "-m", "-").ok(t) | |
| 203 | + | _, reqs := f.snapshot() | |
| 204 | + | general := reqs[0].(*ermite.AddCommentRequest) | |
| 205 | + | assert.Empty(t, general.GetPath()) | |
| 206 | + | assert.Zero(t, general.GetLine()) | |
| 207 | + | assert.Equal(t, ermite.DiffSide_DIFF_SIDE_UNSPECIFIED, general.GetSide(), "a general comment has no side, though --side defaults to new") | |
| 208 | + | onNew := reqs[1].(*ermite.AddCommentRequest) | |
| 209 | + | assert.Equal(t, "a.txt", onNew.GetPath()) | |
| 210 | + | assert.EqualValues(t, 5, onNew.GetLine()) | |
| 211 | + | assert.Equal(t, ermite.DiffSide_DIFF_SIDE_NEW, onNew.GetSide(), "new is the default side") | |
| 212 | + | assert.Equal(t, ermite.DiffSide_DIFF_SIDE_OLD, reqs[2].(*ermite.AddCommentRequest).GetSide()) | |
| 213 | + | assert.Equal(t, "text", reqs[3].(*ermite.AddCommentRequest).GetText()) | |
| 214 | + | assert.EqualValues(t, 1, reqs[3].(*ermite.AddCommentRequest).GetNumber()) | |
| 215 | + | ||
| 216 | + | before, _ := f.snapshot() | |
| 217 | + | for _, args := range [][]string{ | |
| 218 | + | {"mr", "comment", "alpha", "1"}, // no -m | |
| 219 | + | {"mr", "comment", "alpha", "x", "-m", "t"}, // bad number | |
| 220 | + | {"mr", "comment", "alpha", "0", "-m", "t"}, // bad number | |
| 221 | + | {"mr", "comment", "alpha", "1", "-m", "t", "--path", "a"}, // no line | |
| 222 | + | {"mr", "comment", "alpha", "1", "-m", "t", "--line", "3"}, // no path | |
| 223 | + | {"mr", "comment", "alpha", "1", "-m", "t", "--side", "old"}, // no path | |
| 224 | + | {"mr", "comment", "alpha", "1", "-m", "t", "--path", "a", "--line", "1", "--side", "left"}, // bad side | |
| 225 | + | } { | |
| 226 | + | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |
| 227 | + | } | |
| 228 | + | after, _ := f.snapshot() | |
| 229 | + | assert.Equal(t, before, after, "nothing reaches the server") | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestMRResolveMergeAndClose(t *testing.T) { | |
| 233 | + | merged := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 234 | + | merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED | |
| 235 | + | merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH | |
| 236 | + | closed := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 237 | + | closed.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED | |
| 238 | + | f := &fakeAPI{ | |
| 239 | + | resolveCmt: func(r *ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error) { | |
| 240 | + | return &ermite.ResolveCommentResponse{Comment: &ermite.Comment{Id: r.GetCommentId(), Resolved: r.GetResolved()}}, nil | |
| 241 | + | }, | |
| 242 | + | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |
| 243 | + | return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil | |
| 244 | + | }, | |
| 245 | + | mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) { | |
| 246 | + | return &ermite.MergeMergeRequestResponse{MergeRequest: merged}, nil | |
| 247 | + | }, | |
| 248 | + | closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) { | |
| 249 | + | return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil | |
| 250 | + | }, | |
| 251 | + | } | |
| 252 | + | url := f.serve(t) | |
| 253 | + | ||
| 254 | + | res := runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "#4").ok(t) | |
| 255 | + | assert.Equal(t, "comment 4 of alpha!1 resolved\n", res.stdout) | |
| 256 | + | res = runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "4", "--undo").ok(t) | |
| 257 | + | assert.Equal(t, "comment 4 of alpha!1 reopened\n", res.stdout) | |
| 258 | + | _, reqs := f.snapshot() | |
| 259 | + | assert.Equal(t, "4", reqs[0].(*ermite.ResolveCommentRequest).GetCommentId(), "a leading # is dropped") | |
| 260 | + | assert.True(t, reqs[0].(*ermite.ResolveCommentRequest).GetResolved()) | |
| 261 | + | assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved()) | |
| 262 | + | ||
| 263 | + | // Merge with an explicit head and mode. | |
| 264 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t) | |
| 265 | + | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout) | |
| 266 | + | assert.Empty(t, res.stderr) | |
| 267 | + | calls, reqs := f.snapshot() | |
| 268 | + | assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first") | |
| 269 | + | m := reqs[2].(*ermite.MergeMergeRequestRequest) | |
| 270 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode()) | |
| 271 | + | assert.Equal(t, headA, m.GetHeadId()) | |
| 272 | + | assert.EqualValues(t, 1, m.GetNumber()) | |
| 273 | + | ||
| 274 | + | // Without --head the CLI reads it, and says so. | |
| 275 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--json").ok(t) | |
| 276 | + | assert.Contains(t, res.stderr, "no --head given") | |
| 277 | + | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | |
| 278 | + | calls, reqs = f.snapshot() | |
| 279 | + | assert.Equal(t, []string{"GetMergeRequest", "MergeMergeRequest"}, calls[3:]) | |
| 280 | + | m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest) | |
| 281 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, m.GetMode(), "fast-forward by default") | |
| 282 | + | assert.Equal(t, headA, m.GetHeadId()) | |
| 283 | + | ||
| 284 | + | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t) | |
| 285 | + | assert.Equal(t, "closed alpha!1 (feat/x into main)\n", res.stdout) | |
| 286 | + | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t) | |
| 287 | + | assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func TestMRMergeWithoutHeadWhenTheBranchIsGone(t *testing.T) { | |
| 291 | + | gone := proto.Clone(mrOne).(*ermite.MergeRequest) | |
| 292 | + | gone.HeadId = "" | |
| 293 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |
| 294 | + | return &ermite.GetMergeRequestResponse{MergeRequest: gone}, nil | |
| 295 | + | }} | |
| 296 | + | res := runCLI(t, envFor(f.serve(t)), "", "mr", "merge", "alpha", "1") | |
| 297 | + | require.Error(t, res.err) | |
| 298 | + | assert.Contains(t, res.err.Error(), "no longer exists") | |
| 299 | + | calls, _ := f.snapshot() | |
| 300 | + | assert.Equal(t, []string{"GetMergeRequest"}, calls) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestMRErrorsAreFriendly(t *testing.T) { | |
| 304 | + | f := &fakeAPI{ | |
| 305 | + | listMR: func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) { | |
| 306 | + | return nil, connect.NewError(connect.CodeUnimplemented, nil) | |
| 307 | + | }, | |
| 308 | + | mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) { | |
| 309 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, assert.AnError) | |
| 310 | + | }, | |
| 311 | + | } | |
| 312 | + | url := f.serve(t) | |
| 313 | + | res := runCLI(t, envFor(url), "", "mr", "list", "alpha") | |
| 314 | + | require.Error(t, res.err) | |
| 315 | + | assert.Contains(t, res.err.Error(), "does not implement") | |
| 316 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA) | |
| 317 | + | require.Error(t, res.err) | |
| 318 | + | assert.Contains(t, res.err.Error(), "precondition failed") | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestSigningKeyShowOutput(t *testing.T) { | |
| 322 | + | key := &ermite.GetSigningKeyResponse{ | |
| 323 | + | PublicKey: "ssh-ed25519 AAAAC3Nz", Fingerprint: "SHA256:abc", CommitterName: "ermite", CommitterEmail: "ermite@git.example.org", | |
| 324 | + | AllowedSigners: "ermite@git.example.org ssh-ed25519 AAAAC3Nz", | |
| 325 | + | } | |
| 326 | + | f := &fakeAPI{getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) { return key, nil }} | |
| 327 | + | url := f.serve(t) | |
| 328 | + | ||
| 329 | + | res := runCLI(t, map[string]string{"ERMITE_URL": url}, "", "signing-key", "show").ok(t) | |
| 330 | + | assert.Equal(t, ""+ | |
| 331 | + | "fingerprint SHA256:abc\n"+ | |
| 332 | + | "committer ermite <ermite@git.example.org>\n"+ | |
| 333 | + | "public key ssh-ed25519 AAAAC3Nz\n"+ | |
| 334 | + | "allowed signers ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout) | |
| 335 | + | assert.Empty(t, f.header[0].Values("Authorization"), "no token is needed") | |
| 336 | + | res = runCLI(t, envFor(url), "", "signing-key", "show", "--public-key").ok(t) | |
| 337 | + | assert.Equal(t, "ssh-ed25519 AAAAC3Nz\n", res.stdout) | |
| 338 | + | res = runCLI(t, envFor(url), "", "signing-key", "show", "--allowed-signers").ok(t) | |
| 339 | + | assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout) | |
| 340 | + | res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t) | |
| 341 | + | assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout) | |
| 342 | + | } | |
| @@ -0,0 +1,342 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | "google.golang.org/protobuf/proto" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var ( | |||
| 18 | + | headA = strings.Repeat("a", 40) | |||
| 19 | + | mrOne = &ermite.MergeRequest{ | |||
| 20 | + | Repo: "alpha", Number: 1, Title: "Add search", Description: "Finds things.", SourceBranch: "feat/x", TargetBranch: "main", | |||
| 21 | + | State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Author: "agent-7", | |||
| 22 | + | CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), HeadId: headA, | |||
| 23 | + | } | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | func TestMRCreateMapsFlags(t *testing.T) { | |||
| 27 | + | f := &fakeAPI{createMR: func(*ermite.CreateMergeRequestRequest) (*ermite.CreateMergeRequestResponse, error) { | |||
| 28 | + | return &ermite.CreateMergeRequestResponse{MergeRequest: mrOne}, nil | |||
| 29 | + | }} | |||
| 30 | + | url := f.serve(t) | |||
| 31 | + | res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.", | |||
| 32 | + | "--source", "refs/heads/feat/x", "--target", " main ").ok(t) | |||
| 33 | + | assert.Equal(t, "opened alpha!1 (feat/x into main)\n", res.stdout) | |||
| 34 | + | _, reqs := f.snapshot() | |||
| 35 | + | got := reqs[0].(*ermite.CreateMergeRequestRequest) | |||
| 36 | + | assert.Equal(t, "alpha", got.GetRepo()) | |||
| 37 | + | assert.Equal(t, "Add search", got.GetTitle()) | |||
| 38 | + | assert.Equal(t, "Finds things.", got.GetDescription()) | |||
| 39 | + | assert.Equal(t, "feat/x", got.GetSourceBranch(), "a full ref name is shortened") | |||
| 40 | + | assert.Equal(t, "main", got.GetTargetBranch()) | |||
| 41 | + | ||||
| 42 | + | // The target defaults to nothing, which is the default branch for the server; | |||
| 43 | + | // the description comes from stdin with -. | |||
| 44 | + | res = runCLI(t, envFor(url), "line one\nline two\n", "mr", "create", "alpha", "--title", "t", "--source", "feat/x", "--description", "-").ok(t) | |||
| 45 | + | _, reqs = f.snapshot() | |||
| 46 | + | got = reqs[1].(*ermite.CreateMergeRequestRequest) | |||
| 47 | + | assert.Empty(t, got.GetTargetBranch()) | |||
| 48 | + | assert.Equal(t, "line one\nline two", got.GetDescription()) | |||
| 49 | + | _ = res | |||
| 50 | + | ||||
| 51 | + | res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t) | |||
| 52 | + | assert.JSONEq(t, `{"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) | |||
| 53 | + | ||||
| 54 | + | for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} { | |||
| 55 | + | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestMRListTableJSONAndStates(t *testing.T) { | |||
| 60 | + | merged := &ermite.MergeRequest{ | |||
| 61 | + | Repo: "alpha", Number: 2, Title: "Fix\tthe \x1b[31mbug", SourceBranch: "fix", TargetBranch: "main", | |||
| 62 | + | State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, Author: "root", UpdatedAt: ts("2026-10-03T00:00:00Z"), | |||
| 63 | + | MergedCommitId: headA, MergeMode: ermite.MergeMode_MERGE_MODE_SQUASH, | |||
| 64 | + | } | |||
| 65 | + | f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) { | |||
| 66 | + | return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{merged, mrOne}}, nil | |||
| 67 | + | }} | |||
| 68 | + | url := f.serve(t) | |||
| 69 | + | res := runCLI(t, envFor(url), "", "mr", "list", "alpha", "--state", "all").ok(t) | |||
| 70 | + | assert.Equal(t, ""+ | |||
| 71 | + | "NUMBER STATE BRANCHES AUTHOR UPDATED TITLE\n"+ | |||
| 72 | + | "!2 merged fix -> main root 2026-10-03 00:00 Fix the ?[31mbug\n"+ | |||
| 73 | + | "!1 open feat/x -> main agent-7 2026-10-02 11:30 Add search\n", res.stdout) | |||
| 74 | + | assert.NotContains(t, res.stdout, "\x1b", "server text is made safe for a terminal") | |||
| 75 | + | ||||
| 76 | + | _, reqs := f.snapshot() | |||
| 77 | + | assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, reqs[0].(*ermite.ListMergeRequestsRequest).GetState(), "all") | |||
| 78 | + | runCLI(t, envFor(url), "", "mr", "list", "alpha").ok(t) | |||
| 79 | + | _, reqs = f.snapshot() | |||
| 80 | + | assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, reqs[1].(*ermite.ListMergeRequestsRequest).GetState(), "open by default") | |||
| 81 | + | runCLI(t, envFor(url), "", "mr", "list", "alpha", "--state", "closed").ok(t) | |||
| 82 | + | _, reqs = f.snapshot() | |||
| 83 | + | assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, reqs[2].(*ermite.ListMergeRequestsRequest).GetState()) | |||
| 84 | + | ||||
| 85 | + | res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t) | |||
| 86 | + | assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo}) | |||
| 87 | + | assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestMRListFollowsPages(t *testing.T) { | |||
| 91 | + | f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) { | |||
| 92 | + | if r.GetPageToken() == "" { | |||
| 93 | + | return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{mrOne}, NextPageToken: "p2"}, nil | |||
| 94 | + | } | |||
| 95 | + | m := proto.Clone(mrOne).(*ermite.MergeRequest) | |||
| 96 | + | m.Number = 7 | |||
| 97 | + | return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{m}}, nil | |||
| 98 | + | }} | |||
| 99 | + | res := runCLI(t, envFor(f.serve(t)), "", "mr", "list", "alpha", "--json").ok(t) | |||
| 100 | + | got := decode[[]mrOut](t, res.stdout) | |||
| 101 | + | require.Len(t, got, 2) | |||
| 102 | + | assert.EqualValues(t, 7, got[1].Number) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestMRShow(t *testing.T) { | |||
| 106 | + | other := &ermite.Signature{Name: "Bob", Email: "bob@example.org"} | |||
| 107 | + | detail := &ermite.GetMergeRequestResponse{ | |||
| 108 | + | MergeRequest: mrOne, | |||
| 109 | + | Commits: []*ermite.Commit{{Id: headA, Author: other, Summary: "add search"}}, | |||
| 110 | + | DiffBaseId: strings.Repeat("b", 40), | |||
| 111 | + | Stats: &ermite.DiffStats{FilesChanged: 1, Additions: 2, Deletions: 1}, | |||
| 112 | + | Diff: &ermite.Diff{Files: []*ermite.FileDiff{{ | |||
| 113 | + | OldPath: "a.txt", NewPath: "a.txt", Status: ermite.FileStatus_FILE_STATUS_MODIFIED, Additions: 2, Deletions: 1, | |||
| 114 | + | Hunks: []*ermite.Hunk{{OldStart: 4, OldLines: 2, NewStart: 4, NewLines: 3, Header: "func f()", Lines: []*ermite.Line{ | |||
| 115 | + | {Kind: ermite.LineKind_LINE_KIND_CONTEXT, OldNumber: 4, NewNumber: 4, Text: "keep"}, | |||
| 116 | + | {Kind: ermite.LineKind_LINE_KIND_DELETION, OldNumber: 5, Text: "old"}, | |||
| 117 | + | {Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 5, Text: "new \x1b[0m"}, | |||
| 118 | + | {Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 6, Text: "more"}, | |||
| 119 | + | }}}, | |||
| 120 | + | }}}, | |||
| 121 | + | Comments: []*ermite.Comment{ | |||
| 122 | + | {Id: "1", Author: "agent-7", Text: "general\nnote", CreatedAt: ts("2026-10-02T09:00:00Z")}, | |||
| 123 | + | {Id: "2", Author: "root", Path: "a.txt", Line: 5, Side: ermite.DiffSide_DIFF_SIDE_NEW, Text: "why?", Resolved: true, CreatedAt: ts("2026-10-02T10:00:00Z")}, | |||
| 124 | + | }, | |||
| 125 | + | LastReadId: strings.Repeat("c", 40), | |||
| 126 | + | } | |||
| 127 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return detail, nil }} | |||
| 128 | + | url := f.serve(t) | |||
| 129 | + | ||||
| 130 | + | res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "!1", "--diff").ok(t) | |||
| 131 | + | out := res.stdout | |||
| 132 | + | assert.Contains(t, out, "merge request alpha!1") | |||
| 133 | + | assert.Contains(t, out, "branches feat/x -> main") | |||
| 134 | + | assert.Contains(t, out, "commits (1)") | |||
| 135 | + | assert.Contains(t, out, "changes: 1 file(s), +2 -1, from bbbbbbbbbb") | |||
| 136 | + | assert.Contains(t, out, "modified +2 -1 a.txt") | |||
| 137 | + | assert.Contains(t, out, " #1 agent-7") | |||
| 138 | + | assert.Contains(t, out, "\n note\n", "comment lines are indented") | |||
| 139 | + | assert.Contains(t, out, "a.txt:5 (new) [resolved]") | |||
| 140 | + | assert.Contains(t, out, "\n 5 +new ?[0m\n", "an addition has a new number only") | |||
| 141 | + | assert.Contains(t, out, "\n 5 -old\n", "a deletion has an old number only") | |||
| 142 | + | assert.Contains(t, out, "\n 4 4 keep\n", "a context line has both") | |||
| 143 | + | assert.NotContains(t, out, "\x1b") | |||
| 144 | + | assert.Empty(t, res.stderr) | |||
| 145 | + | _, reqs := f.snapshot() | |||
| 146 | + | assert.EqualValues(t, 1, reqs[0].(*ermite.GetMergeRequestRequest).GetNumber()) | |||
| 147 | + | assert.False(t, reqs[0].(*ermite.GetMergeRequestRequest).GetSinceLastRead()) | |||
| 148 | + | ||||
| 149 | + | // Without --diff the lines are left out of the human and the JSON output. | |||
| 150 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |||
| 151 | + | assert.NotContains(t, res.stdout, "+new") | |||
| 152 | + | js := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t) | |||
| 153 | + | assert.NotContains(t, js.stdout, `"hunks"`) | |||
| 154 | + | js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t) | |||
| 155 | + | show := decode[showOut](t, js.stdout) | |||
| 156 | + | require.Len(t, show.Files, 1) | |||
| 157 | + | require.Len(t, show.Files[0].Hunks, 1) | |||
| 158 | + | assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind) | |||
| 159 | + | assert.Equal(t, 5, show.Files[0].Hunks[0].Lines[2].NewNumber) | |||
| 160 | + | require.Len(t, show.Comments, 2) | |||
| 161 | + | assert.Equal(t, "new", show.Comments[1].Side) | |||
| 162 | + | assert.Empty(t, show.Comments[0].Side) | |||
| 163 | + | assert.Equal(t, strings.Repeat("c", 40), show.LastReadID) | |||
| 164 | + | ||||
| 165 | + | // --since-last-read asks for it, and says when the head is lost. | |||
| 166 | + | detail.LastReadLost = true | |||
| 167 | + | res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--since-last-read").ok(t) | |||
| 168 | + | _, reqs = f.snapshot() | |||
| 169 | + | assert.True(t, reqs[len(reqs)-1].(*ermite.GetMergeRequestRequest).GetSinceLastRead()) | |||
| 170 | + | assert.Contains(t, res.stderr, "head you last read is gone") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestMRShowMarkRead(t *testing.T) { | |||
| 174 | + | f := &fakeAPI{ | |||
| 175 | + | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |||
| 176 | + | return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil | |||
| 177 | + | }, | |||
| 178 | + | markRead: func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) { | |||
| 179 | + | return &ermite.MarkReadResponse{}, nil | |||
| 180 | + | }, | |||
| 181 | + | } | |||
| 182 | + | url := f.serve(t) | |||
| 183 | + | runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t) | |||
| 184 | + | calls, _ := f.snapshot() | |||
| 185 | + | assert.Equal(t, []string{"GetMergeRequest"}, calls, "reading does not mark read") | |||
| 186 | + | runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--mark-read").ok(t) | |||
| 187 | + | calls, reqs := f.snapshot() | |||
| 188 | + | assert.Equal(t, []string{"GetMergeRequest", "GetMergeRequest", "MarkRead"}, calls) | |||
| 189 | + | assert.Equal(t, headA, reqs[2].(*ermite.MarkReadRequest).GetHeadId(), "marks the head that was shown") | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func TestMRCommentMapsFlags(t *testing.T) { | |||
| 193 | + | f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) { | |||
| 194 | + | return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "3", Path: r.GetPath(), Line: r.GetLine(), Side: r.GetSide(), Text: r.GetText()}}, nil | |||
| 195 | + | }} | |||
| 196 | + | url := f.serve(t) | |||
| 197 | + | ||||
| 198 | + | res := runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "hello").ok(t) | |||
| 199 | + | assert.Equal(t, "comment 3 added to alpha!1\n", res.stdout) | |||
| 200 | + | runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "on a line", "--path", "a.txt", "--line", "5").ok(t) | |||
| 201 | + | runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "old side", "--path", "a.txt", "--line", "5", "--side", "OLD").ok(t) | |||
| 202 | + | runCLI(t, envFor(url), "text\n", "mr", "comment", "alpha", "!1", "-m", "-").ok(t) | |||
| 203 | + | _, reqs := f.snapshot() | |||
| 204 | + | general := reqs[0].(*ermite.AddCommentRequest) | |||
| 205 | + | assert.Empty(t, general.GetPath()) | |||
| 206 | + | assert.Zero(t, general.GetLine()) | |||
| 207 | + | assert.Equal(t, ermite.DiffSide_DIFF_SIDE_UNSPECIFIED, general.GetSide(), "a general comment has no side, though --side defaults to new") | |||
| 208 | + | onNew := reqs[1].(*ermite.AddCommentRequest) | |||
| 209 | + | assert.Equal(t, "a.txt", onNew.GetPath()) | |||
| 210 | + | assert.EqualValues(t, 5, onNew.GetLine()) | |||
| 211 | + | assert.Equal(t, ermite.DiffSide_DIFF_SIDE_NEW, onNew.GetSide(), "new is the default side") | |||
| 212 | + | assert.Equal(t, ermite.DiffSide_DIFF_SIDE_OLD, reqs[2].(*ermite.AddCommentRequest).GetSide()) | |||
| 213 | + | assert.Equal(t, "text", reqs[3].(*ermite.AddCommentRequest).GetText()) | |||
| 214 | + | assert.EqualValues(t, 1, reqs[3].(*ermite.AddCommentRequest).GetNumber()) | |||
| 215 | + | ||||
| 216 | + | before, _ := f.snapshot() | |||
| 217 | + | for _, args := range [][]string{ | |||
| 218 | + | {"mr", "comment", "alpha", "1"}, // no -m | |||
| 219 | + | {"mr", "comment", "alpha", "x", "-m", "t"}, // bad number | |||
| 220 | + | {"mr", "comment", "alpha", "0", "-m", "t"}, // bad number | |||
| 221 | + | {"mr", "comment", "alpha", "1", "-m", "t", "--path", "a"}, // no line | |||
| 222 | + | {"mr", "comment", "alpha", "1", "-m", "t", "--line", "3"}, // no path | |||
| 223 | + | {"mr", "comment", "alpha", "1", "-m", "t", "--side", "old"}, // no path | |||
| 224 | + | {"mr", "comment", "alpha", "1", "-m", "t", "--path", "a", "--line", "1", "--side", "left"}, // bad side | |||
| 225 | + | } { | |||
| 226 | + | require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args) | |||
| 227 | + | } | |||
| 228 | + | after, _ := f.snapshot() | |||
| 229 | + | assert.Equal(t, before, after, "nothing reaches the server") | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestMRResolveMergeAndClose(t *testing.T) { | |||
| 233 | + | merged := proto.Clone(mrOne).(*ermite.MergeRequest) | |||
| 234 | + | merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED | |||
| 235 | + | merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH | |||
| 236 | + | closed := proto.Clone(mrOne).(*ermite.MergeRequest) | |||
| 237 | + | closed.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED | |||
| 238 | + | f := &fakeAPI{ | |||
| 239 | + | resolveCmt: func(r *ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error) { | |||
| 240 | + | return &ermite.ResolveCommentResponse{Comment: &ermite.Comment{Id: r.GetCommentId(), Resolved: r.GetResolved()}}, nil | |||
| 241 | + | }, | |||
| 242 | + | getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |||
| 243 | + | return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil | |||
| 244 | + | }, | |||
| 245 | + | mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) { | |||
| 246 | + | return &ermite.MergeMergeRequestResponse{MergeRequest: merged}, nil | |||
| 247 | + | }, | |||
| 248 | + | closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) { | |||
| 249 | + | return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil | |||
| 250 | + | }, | |||
| 251 | + | } | |||
| 252 | + | url := f.serve(t) | |||
| 253 | + | ||||
| 254 | + | res := runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "#4").ok(t) | |||
| 255 | + | assert.Equal(t, "comment 4 of alpha!1 resolved\n", res.stdout) | |||
| 256 | + | res = runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "4", "--undo").ok(t) | |||
| 257 | + | assert.Equal(t, "comment 4 of alpha!1 reopened\n", res.stdout) | |||
| 258 | + | _, reqs := f.snapshot() | |||
| 259 | + | assert.Equal(t, "4", reqs[0].(*ermite.ResolveCommentRequest).GetCommentId(), "a leading # is dropped") | |||
| 260 | + | assert.True(t, reqs[0].(*ermite.ResolveCommentRequest).GetResolved()) | |||
| 261 | + | assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved()) | |||
| 262 | + | ||||
| 263 | + | // Merge with an explicit head and mode. | |||
| 264 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t) | |||
| 265 | + | assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout) | |||
| 266 | + | assert.Empty(t, res.stderr) | |||
| 267 | + | calls, reqs := f.snapshot() | |||
| 268 | + | assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first") | |||
| 269 | + | m := reqs[2].(*ermite.MergeMergeRequestRequest) | |||
| 270 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode()) | |||
| 271 | + | assert.Equal(t, headA, m.GetHeadId()) | |||
| 272 | + | assert.EqualValues(t, 1, m.GetNumber()) | |||
| 273 | + | ||||
| 274 | + | // Without --head the CLI reads it, and says so. | |||
| 275 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--json").ok(t) | |||
| 276 | + | assert.Contains(t, res.stderr, "no --head given") | |||
| 277 | + | assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State) | |||
| 278 | + | calls, reqs = f.snapshot() | |||
| 279 | + | assert.Equal(t, []string{"GetMergeRequest", "MergeMergeRequest"}, calls[3:]) | |||
| 280 | + | m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest) | |||
| 281 | + | assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, m.GetMode(), "fast-forward by default") | |||
| 282 | + | assert.Equal(t, headA, m.GetHeadId()) | |||
| 283 | + | ||||
| 284 | + | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t) | |||
| 285 | + | assert.Equal(t, "closed alpha!1 (feat/x into main)\n", res.stdout) | |||
| 286 | + | res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t) | |||
| 287 | + | assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func TestMRMergeWithoutHeadWhenTheBranchIsGone(t *testing.T) { | |||
| 291 | + | gone := proto.Clone(mrOne).(*ermite.MergeRequest) | |||
| 292 | + | gone.HeadId = "" | |||
| 293 | + | f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { | |||
| 294 | + | return &ermite.GetMergeRequestResponse{MergeRequest: gone}, nil | |||
| 295 | + | }} | |||
| 296 | + | res := runCLI(t, envFor(f.serve(t)), "", "mr", "merge", "alpha", "1") | |||
| 297 | + | require.Error(t, res.err) | |||
| 298 | + | assert.Contains(t, res.err.Error(), "no longer exists") | |||
| 299 | + | calls, _ := f.snapshot() | |||
| 300 | + | assert.Equal(t, []string{"GetMergeRequest"}, calls) | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func TestMRErrorsAreFriendly(t *testing.T) { | |||
| 304 | + | f := &fakeAPI{ | |||
| 305 | + | listMR: func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) { | |||
| 306 | + | return nil, connect.NewError(connect.CodeUnimplemented, nil) | |||
| 307 | + | }, | |||
| 308 | + | mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) { | |||
| 309 | + | return nil, connect.NewError(connect.CodeFailedPrecondition, assert.AnError) | |||
| 310 | + | }, | |||
| 311 | + | } | |||
| 312 | + | url := f.serve(t) | |||
| 313 | + | res := runCLI(t, envFor(url), "", "mr", "list", "alpha") | |||
| 314 | + | require.Error(t, res.err) | |||
| 315 | + | assert.Contains(t, res.err.Error(), "does not implement") | |||
| 316 | + | res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA) | |||
| 317 | + | require.Error(t, res.err) | |||
| 318 | + | assert.Contains(t, res.err.Error(), "precondition failed") | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func TestSigningKeyShowOutput(t *testing.T) { | |||
| 322 | + | key := &ermite.GetSigningKeyResponse{ | |||
| 323 | + | PublicKey: "ssh-ed25519 AAAAC3Nz", Fingerprint: "SHA256:abc", CommitterName: "ermite", CommitterEmail: "ermite@git.example.org", | |||
| 324 | + | AllowedSigners: "ermite@git.example.org ssh-ed25519 AAAAC3Nz", | |||
| 325 | + | } | |||
| 326 | + | f := &fakeAPI{getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) { return key, nil }} | |||
| 327 | + | url := f.serve(t) | |||
| 328 | + | ||||
| 329 | + | res := runCLI(t, map[string]string{"ERMITE_URL": url}, "", "signing-key", "show").ok(t) | |||
| 330 | + | assert.Equal(t, ""+ | |||
| 331 | + | "fingerprint SHA256:abc\n"+ | |||
| 332 | + | "committer ermite <ermite@git.example.org>\n"+ | |||
| 333 | + | "public key ssh-ed25519 AAAAC3Nz\n"+ | |||
| 334 | + | "allowed signers ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout) | |||
| 335 | + | assert.Empty(t, f.header[0].Values("Authorization"), "no token is needed") | |||
| 336 | + | res = runCLI(t, envFor(url), "", "signing-key", "show", "--public-key").ok(t) | |||
| 337 | + | assert.Equal(t, "ssh-ed25519 AAAAC3Nz\n", res.stdout) | |||
| 338 | + | res = runCLI(t, envFor(url), "", "signing-key", "show", "--allowed-signers").ok(t) | |||
| 339 | + | assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout) | |||
| 340 | + | res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t) | |||
| 341 | + | assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout) | |||
| 342 | + | } | |||
M cmd/ermite/cli_repo.go +58 -12
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "io" | |
| 11 | + | "strconv" | |
| 11 | 12 | "strings" | |
| 12 | 13 | ||
| 13 | 14 | "connectrpc.com/connect" | |
| @@ -31,16 +32,25 @@ type jsonRepo struct { | |||
| 31 | 32 | DefaultBranch string `json:"default_branch"` | |
| 32 | 33 | CreatedAt *string `json:"created_at"` | |
| 33 | 34 | PushedAt *string `json:"pushed_at"` | |
| 35 | + | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | |
| 36 | + | MergeModes []string `json:"merge_modes"` | |
| 37 | + | FailingChecksBlock bool `json:"failing_checks_block"` | |
| 34 | 38 | } | |
| 35 | 39 | ||
| 36 | 40 | func toJSONRepo(r *ermite.Repo) jsonRepo { | |
| 41 | + | modes := []string{} | |
| 42 | + | for _, m := range r.GetSettings().GetAllowedMergeModes() { | |
| 43 | + | modes = append(modes, modeName(m)) | |
| 44 | + | } | |
| 37 | 45 | return jsonRepo{ | |
| 38 | - | Name: r.GetName(), | |
| 39 | - | Description: r.GetDescription(), | |
| 40 | - | Visibility: visibilityName(r.GetVisibility()), | |
| 41 | - | DefaultBranch: r.GetDefaultBranch(), | |
| 42 | - | CreatedAt: jsonTime(r.GetCreatedAt()), | |
| 43 | - | PushedAt: jsonTime(r.GetPushedAt()), | |
| 46 | + | MergeModes: modes, | |
| 47 | + | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), | |
| 48 | + | Name: r.GetName(), | |
| 49 | + | Description: r.GetDescription(), | |
| 50 | + | Visibility: visibilityName(r.GetVisibility()), | |
| 51 | + | DefaultBranch: r.GetDefaultBranch(), | |
| 52 | + | CreatedAt: jsonTime(r.GetCreatedAt()), | |
| 53 | + | PushedAt: jsonTime(r.GetPushedAt()), | |
| 44 | 54 | } | |
| 45 | 55 | } | |
| 46 | 56 | ||
| @@ -69,7 +79,8 @@ func parseVisibility(s string) (ermite.Visibility, error) { | |||
| 69 | 79 | const jsonRepoHelp = ` | |
| 70 | 80 | JSON fields of a repository (--json): name, description, visibility ("public" or | |
| 71 | 81 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | |
| 72 | - | null until the first push).` | |
| 82 | + | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or | |
| 83 | + | "squash") and failing_checks_block (whether a failing check prevents a merge).` | |
| 73 | 84 | ||
| 74 | 85 | func newRepoCmd(env *cliEnv) *cobra.Command { | |
| 75 | 86 | repo := &cobra.Command{ | |
| @@ -171,6 +182,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||
| 171 | 182 | if desc == "" { | |
| 172 | 183 | desc = "-" | |
| 173 | 184 | } | |
| 185 | + | modes := toJSONRepo(r).MergeModes | |
| 174 | 186 | return fields(w, [][2]string{ | |
| 175 | 187 | {"name", r.GetName()}, | |
| 176 | 188 | {"visibility", visibilityName(r.GetVisibility())}, | |
| @@ -178,6 +190,8 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||
| 178 | 190 | {"description", desc}, | |
| 179 | 191 | {"created", shortTime(r.GetCreatedAt(), "-")}, | |
| 180 | 192 | {"last push", shortTime(r.GetPushedAt(), "never")}, | |
| 193 | + | {"merge modes", orDash(strings.Join(modes, ", "))}, | |
| 194 | + | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | |
| 181 | 195 | }) | |
| 182 | 196 | } | |
| 183 | 197 | ||
| @@ -253,21 +267,31 @@ func buildCreateRepo(name, description, visibility string) (*ermite.CreateRepoRe | |||
| 253 | 267 | ||
| 254 | 268 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | |
| 255 | 269 | var description, visibility, defaultBranch string | |
| 270 | + | var mergeModes []string | |
| 271 | + | var failingChecksBlock bool | |
| 256 | 272 | cmd := &cobra.Command{ | |
| 257 | 273 | Use: "edit <name>", | |
| 258 | - | Short: "Change a repository's description, visibility or default branch", | |
| 274 | + | Short: "Change a repository's description, visibility, default branch or merge settings", | |
| 259 | 275 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | |
| 260 | - | "description. The default branch must already exist. Needs the repo:admin scope." + jsonRepoHelp, | |
| 276 | + | "description. The default branch must already exist. --merge-modes lists the modes a merge request\n" + | |
| 277 | + | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |
| 278 | + | "--failing-checks-block says whether a failing check prevents a merge (there are no checks yet).\n" + | |
| 279 | + | "Needs the repo:admin scope." + jsonRepoHelp, | |
| 261 | 280 | Args: cobra.ExactArgs(1), | |
| 262 | 281 | ValidArgsFunction: completeRepoNames(env), | |
| 263 | 282 | } | |
| 264 | 283 | cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)") | |
| 265 | 284 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | |
| 266 | 285 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | |
| 286 | + | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | |
| 287 | + | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | |
| 267 | 288 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | |
| 289 | + | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |
| 290 | + | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | |
| 291 | + | }) | |
| 268 | 292 | asJSON := addJSONFlag(cmd) | |
| 269 | 293 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 270 | - | req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch) | |
| 294 | + | req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch, mergeModes, failingChecksBlock) | |
| 271 | 295 | if err != nil { | |
| 272 | 296 | return err | |
| 273 | 297 | } | |
| @@ -286,7 +310,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||
| 286 | 310 | ||
| 287 | 311 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | |
| 288 | 312 | // the user passed go into the update mask. | |
| 289 | - | func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string) (*ermite.UpdateRepoRequest, error) { | |
| 313 | + | func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) { | |
| 290 | 314 | req := &ermite.UpdateRepoRequest{Repo: name} | |
| 291 | 315 | var paths []string | |
| 292 | 316 | if cmd.Flags().Changed("description") { | |
| @@ -309,8 +333,30 @@ func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultB | |||
| 309 | 333 | req.DefaultBranch = branch | |
| 310 | 334 | paths = append(paths, "default_branch") | |
| 311 | 335 | } | |
| 336 | + | if cmd.Flags().Changed("merge-modes") { | |
| 337 | + | var modes []ermite.MergeMode | |
| 338 | + | for _, m := range mergeModes { | |
| 339 | + | mode, err := parseMode(m) | |
| 340 | + | if err != nil { | |
| 341 | + | return nil, err | |
| 342 | + | } | |
| 343 | + | modes = append(modes, mode) | |
| 344 | + | } | |
| 345 | + | if len(modes) == 0 { | |
| 346 | + | return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash") | |
| 347 | + | } | |
| 348 | + | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | |
| 349 | + | paths = append(paths, "settings.allowed_merge_modes") | |
| 350 | + | } | |
| 351 | + | if cmd.Flags().Changed("failing-checks-block") { | |
| 352 | + | if req.Settings == nil { | |
| 353 | + | req.Settings = &ermite.RepoSettings{} | |
| 354 | + | } | |
| 355 | + | req.Settings.FailingChecksBlock = failingChecksBlock | |
| 356 | + | paths = append(paths, "settings.failing_checks_block") | |
| 357 | + | } | |
| 312 | 358 | if len(paths) == 0 { | |
| 313 | - | return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch") | |
| 359 | + | return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch, --merge-modes, --failing-checks-block") | |
| 314 | 360 | } | |
| 315 | 361 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | |
| 316 | 362 | return req, nil | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "io" | 10 | "io" | ||
| 11 | + | "strconv" | |||
| 11 | "strings" | 12 | "strings" | ||
| 12 | 13 | ||||
| 13 | "connectrpc.com/connect" | 14 | "connectrpc.com/connect" | ||
| @@ -31,16 +32,25 @@ type jsonRepo struct { | |||||
| 31 | DefaultBranch string `json:"default_branch"` | 32 | DefaultBranch string `json:"default_branch"` | ||
| 32 | CreatedAt *string `json:"created_at"` | 33 | CreatedAt *string `json:"created_at"` | ||
| 33 | PushedAt *string `json:"pushed_at"` | 34 | PushedAt *string `json:"pushed_at"` | ||
| 35 | + | // MergeModes are the allowed merge modes ("fast-forward", "squash"). | |||
| 36 | + | MergeModes []string `json:"merge_modes"` | |||
| 37 | + | FailingChecksBlock bool `json:"failing_checks_block"` | |||
| 34 | } | 38 | } | ||
| 35 | 39 | ||||
| 36 | func toJSONRepo(r *ermite.Repo) jsonRepo { | 40 | func toJSONRepo(r *ermite.Repo) jsonRepo { | ||
| 41 | + | modes := []string{} | |||
| 42 | + | for _, m := range r.GetSettings().GetAllowedMergeModes() { | |||
| 43 | + | modes = append(modes, modeName(m)) | |||
| 44 | + | } | |||
| 37 | return jsonRepo{ | 45 | return jsonRepo{ | ||
| 38 | - | Name: r.GetName(), | 46 | + | MergeModes: modes, |
| 39 | - | Description: r.GetDescription(), | 47 | + | FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(), |
| 40 | - | Visibility: visibilityName(r.GetVisibility()), | 48 | + | Name: r.GetName(), |
| 41 | - | DefaultBranch: r.GetDefaultBranch(), | 49 | + | Description: r.GetDescription(), |
| 42 | - | CreatedAt: jsonTime(r.GetCreatedAt()), | 50 | + | Visibility: visibilityName(r.GetVisibility()), |
| 43 | - | PushedAt: jsonTime(r.GetPushedAt()), | 51 | + | DefaultBranch: r.GetDefaultBranch(), |
| 52 | + | CreatedAt: jsonTime(r.GetCreatedAt()), | |||
| 53 | + | PushedAt: jsonTime(r.GetPushedAt()), | |||
| 44 | } | 54 | } | ||
| 45 | } | 55 | } | ||
| 46 | 56 | ||||
| @@ -69,7 +79,8 @@ func parseVisibility(s string) (ermite.Visibility, error) { | |||||
| 69 | const jsonRepoHelp = ` | 79 | const jsonRepoHelp = ` | ||
| 70 | JSON fields of a repository (--json): name, description, visibility ("public" or | 80 | JSON fields of a repository (--json): name, description, visibility ("public" or | ||
| 71 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | 81 | "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is | ||
| 72 | - | null until the first push).` | 82 | + | null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or |
| 83 | + | "squash") and failing_checks_block (whether a failing check prevents a merge).` | |||
| 73 | 84 | ||||
| 74 | func newRepoCmd(env *cliEnv) *cobra.Command { | 85 | func newRepoCmd(env *cliEnv) *cobra.Command { | ||
| 75 | repo := &cobra.Command{ | 86 | repo := &cobra.Command{ | ||
| @@ -171,6 +182,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||||
| 171 | if desc == "" { | 182 | if desc == "" { | ||
| 172 | desc = "-" | 183 | desc = "-" | ||
| 173 | } | 184 | } | ||
| 185 | + | modes := toJSONRepo(r).MergeModes | |||
| 174 | return fields(w, [][2]string{ | 186 | return fields(w, [][2]string{ | ||
| 175 | {"name", r.GetName()}, | 187 | {"name", r.GetName()}, | ||
| 176 | {"visibility", visibilityName(r.GetVisibility())}, | 188 | {"visibility", visibilityName(r.GetVisibility())}, | ||
| @@ -178,6 +190,8 @@ func printRepo(w io.Writer, r *ermite.Repo) error { | |||||
| 178 | {"description", desc}, | 190 | {"description", desc}, | ||
| 179 | {"created", shortTime(r.GetCreatedAt(), "-")}, | 191 | {"created", shortTime(r.GetCreatedAt(), "-")}, | ||
| 180 | {"last push", shortTime(r.GetPushedAt(), "never")}, | 192 | {"last push", shortTime(r.GetPushedAt(), "never")}, | ||
| 193 | + | {"merge modes", orDash(strings.Join(modes, ", "))}, | |||
| 194 | + | {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())}, | |||
| 181 | }) | 195 | }) | ||
| 182 | } | 196 | } | ||
| 183 | 197 | ||||
| @@ -253,21 +267,31 @@ func buildCreateRepo(name, description, visibility string) (*ermite.CreateRepoRe | |||||
| 253 | 267 | ||||
| 254 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | 268 | func newRepoEditCmd(env *cliEnv) *cobra.Command { | ||
| 255 | var description, visibility, defaultBranch string | 269 | var description, visibility, defaultBranch string | ||
| 270 | + | var mergeModes []string | |||
| 271 | + | var failingChecksBlock bool | |||
| 256 | cmd := &cobra.Command{ | 272 | cmd := &cobra.Command{ | ||
| 257 | Use: "edit <name>", | 273 | Use: "edit <name>", | ||
| 258 | - | Short: "Change a repository's description, visibility or default branch", | 274 | + | Short: "Change a repository's description, visibility, default branch or merge settings", |
| 259 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | 275 | Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" + | ||
| 260 | - | "description. The default branch must already exist. Needs the repo:admin scope." + jsonRepoHelp, | 276 | + | "description. The default branch must already exist. --merge-modes lists the modes a merge request\n" + |
| 277 | + | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |||
| 278 | + | "--failing-checks-block says whether a failing check prevents a merge (there are no checks yet).\n" + | |||
| 279 | + | "Needs the repo:admin scope." + jsonRepoHelp, | |||
| 261 | Args: cobra.ExactArgs(1), | 280 | Args: cobra.ExactArgs(1), | ||
| 262 | ValidArgsFunction: completeRepoNames(env), | 281 | ValidArgsFunction: completeRepoNames(env), | ||
| 263 | } | 282 | } | ||
| 264 | cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)") | 283 | cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)") | ||
| 265 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | 284 | cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private") | ||
| 266 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | 285 | cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch") | ||
| 286 | + | cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash") | |||
| 287 | + | cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)") | |||
| 267 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | 288 | _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion) | ||
| 289 | + | _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { | |||
| 290 | + | return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp | |||
| 291 | + | }) | |||
| 268 | asJSON := addJSONFlag(cmd) | 292 | asJSON := addJSONFlag(cmd) | ||
| 269 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | 293 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | ||
| 270 | - | req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch) | 294 | + | req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch, mergeModes, failingChecksBlock) |
| 271 | if err != nil { | 295 | if err != nil { | ||
| 272 | return err | 296 | return err | ||
| 273 | } | 297 | } | ||
| @@ -286,7 +310,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command { | |||||
| 286 | 310 | ||||
| 287 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | 311 | // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags | ||
| 288 | // the user passed go into the update mask. | 312 | // the user passed go into the update mask. | ||
| 289 | - | func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string) (*ermite.UpdateRepoRequest, error) { | 313 | + | func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) { |
| 290 | req := &ermite.UpdateRepoRequest{Repo: name} | 314 | req := &ermite.UpdateRepoRequest{Repo: name} | ||
| 291 | var paths []string | 315 | var paths []string | ||
| 292 | if cmd.Flags().Changed("description") { | 316 | if cmd.Flags().Changed("description") { | ||
| @@ -309,8 +333,30 @@ func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultB | |||||
| 309 | req.DefaultBranch = branch | 333 | req.DefaultBranch = branch | ||
| 310 | paths = append(paths, "default_branch") | 334 | paths = append(paths, "default_branch") | ||
| 311 | } | 335 | } | ||
| 336 | + | if cmd.Flags().Changed("merge-modes") { | |||
| 337 | + | var modes []ermite.MergeMode | |||
| 338 | + | for _, m := range mergeModes { | |||
| 339 | + | mode, err := parseMode(m) | |||
| 340 | + | if err != nil { | |||
| 341 | + | return nil, err | |||
| 342 | + | } | |||
| 343 | + | modes = append(modes, mode) | |||
| 344 | + | } | |||
| 345 | + | if len(modes) == 0 { | |||
| 346 | + | return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash") | |||
| 347 | + | } | |||
| 348 | + | req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes} | |||
| 349 | + | paths = append(paths, "settings.allowed_merge_modes") | |||
| 350 | + | } | |||
| 351 | + | if cmd.Flags().Changed("failing-checks-block") { | |||
| 352 | + | if req.Settings == nil { | |||
| 353 | + | req.Settings = &ermite.RepoSettings{} | |||
| 354 | + | } | |||
| 355 | + | req.Settings.FailingChecksBlock = failingChecksBlock | |||
| 356 | + | paths = append(paths, "settings.failing_checks_block") | |||
| 357 | + | } | |||
| 312 | if len(paths) == 0 { | 358 | if len(paths) == 0 { | ||
| 313 | - | return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch") | 359 | + | return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch, --merge-modes, --failing-checks-block") |
| 314 | } | 360 | } | ||
| 315 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | 361 | req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths} | ||
| 316 | return req, nil | 362 | return req, nil | ||
M cmd/ermite/cli_repo_test.go +29 -10
| @@ -18,10 +18,15 @@ var ( | |||
| 18 | 18 | repoAlpha = &ermite.Repo{ | |
| 19 | 19 | Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, | |
| 20 | 20 | DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"), | |
| 21 | + | Settings: &ermite.RepoSettings{ | |
| 22 | + | AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH}, | |
| 23 | + | FailingChecksBlock: true, | |
| 24 | + | }, | |
| 21 | 25 | } | |
| 22 | 26 | repoBeta = &ermite.Repo{ | |
| 23 | 27 | Name: "beta", Visibility: ermite.Visibility_VISIBILITY_PRIVATE, | |
| 24 | 28 | DefaultBranch: "trunk", CreatedAt: ts("2026-02-02T00:00:00Z"), | |
| 29 | + | Settings: &ermite.RepoSettings{AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH}}, | |
| 25 | 30 | } | |
| 26 | 31 | ) | |
| 27 | 32 | ||
| @@ -49,8 +54,8 @@ func TestRepoListJSON(t *testing.T) { | |||
| 49 | 54 | }} | |
| 50 | 55 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t) | |
| 51 | 56 | assert.JSONEq(t, `[ | |
| 52 | - | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z"}, | |
| 53 | - | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null} | |
| 57 | + | {"name":"alpha","description":"The first one","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}, | |
| 58 | + | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | |
| 54 | 59 | ]`, res.stdout) | |
| 55 | 60 | } | |
| 56 | 61 | ||
| @@ -88,17 +93,19 @@ func TestRepoShow(t *testing.T) { | |||
| 88 | 93 | url := f.serve(t) | |
| 89 | 94 | res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t) | |
| 90 | 95 | assert.Equal(t, ""+ | |
| 91 | - | "name alpha\n"+ | |
| 92 | - | "visibility public\n"+ | |
| 93 | - | "default branch main\n"+ | |
| 94 | - | "description The first one\n"+ | |
| 95 | - | "created 2026-01-02 03:04\n"+ | |
| 96 | - | "last push 2026-03-04 05:06\n", res.stdout) | |
| 96 | + | "name alpha\n"+ | |
| 97 | + | "visibility public\n"+ | |
| 98 | + | "default branch main\n"+ | |
| 99 | + | "description The first one\n"+ | |
| 100 | + | "created 2026-01-02 03:04\n"+ | |
| 101 | + | "last push 2026-03-04 05:06\n"+ | |
| 102 | + | "merge modes fast-forward, squash\n"+ | |
| 103 | + | "failing checks block true\n", res.stdout) | |
| 97 | 104 | _, reqs := f.snapshot() | |
| 98 | 105 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | |
| 99 | 106 | ||
| 100 | 107 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | |
| 101 | - | assert.JSONEq(t, `{"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z"}`, res.stdout) | |
| 108 | + | assert.JSONEq(t, `{"name":"alpha","description":"The first one","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}`, res.stdout) | |
| 102 | 109 | } | |
| 103 | 110 | ||
| 104 | 111 | func TestRepoShowNeedsOneArg(t *testing.T) { | |
| @@ -151,7 +158,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||
| 151 | 158 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | |
| 152 | 159 | }} | |
| 153 | 160 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | |
| 154 | - | assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}`, res.stdout) | |
| 161 | + | assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout) | |
| 155 | 162 | } | |
| 156 | 163 | ||
| 157 | 164 | func TestRepoEditMask(t *testing.T) { | |
| @@ -173,6 +180,16 @@ func TestRepoEditMask(t *testing.T) { | |||
| 173 | 180 | {"default branch", []string{"--default-branch", "refs/heads/dev"}, []string{"default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 174 | 181 | assert.Equal(t, "dev", r.GetDefaultBranch()) | |
| 175 | 182 | }}, | |
| 183 | + | {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 184 | + | assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes()) | |
| 185 | + | }}, | |
| 186 | + | {"failing checks do not block", []string{"--failing-checks-block=false"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 187 | + | assert.False(t, r.GetSettings().GetFailingChecksBlock()) | |
| 188 | + | assert.Empty(t, r.GetSettings().GetAllowedMergeModes()) | |
| 189 | + | }}, | |
| 190 | + | {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |
| 191 | + | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | |
| 192 | + | }}, | |
| 176 | 193 | {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"}, | |
| 177 | 194 | []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}}, | |
| 178 | 195 | } | |
| @@ -202,6 +219,8 @@ func TestRepoEditRejects(t *testing.T) { | |||
| 202 | 219 | {[]string{"repo", "edit", "alpha"}, "nothing to change"}, | |
| 203 | 220 | {[]string{"repo", "edit", "alpha", "--visibility", "x"}, "invalid visibility"}, | |
| 204 | 221 | {[]string{"repo", "edit", "alpha", "--default-branch", " "}, "must not be empty"}, | |
| 222 | + | {[]string{"repo", "edit", "alpha", "--merge-modes", "rebase"}, "invalid merge mode"}, | |
| 223 | + | {[]string{"repo", "edit", "alpha", "--merge-modes", ""}, "at least one mode"}, | |
| 205 | 224 | } { | |
| 206 | 225 | res := runCLI(t, envFor(url), "", tt.args...) | |
| 207 | 226 | require.Error(t, res.err) | |
| @@ -18,10 +18,15 @@ var ( | |||||
| 18 | repoAlpha = &ermite.Repo{ | 18 | repoAlpha = &ermite.Repo{ | ||
| 19 | Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, | 19 | Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC, | ||
| 20 | DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"), | 20 | DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"), | ||
| 21 | + | Settings: &ermite.RepoSettings{ | |||
| 22 | + | AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH}, | |||
| 23 | + | FailingChecksBlock: true, | |||
| 24 | + | }, | |||
| 21 | } | 25 | } | ||
| 22 | repoBeta = &ermite.Repo{ | 26 | repoBeta = &ermite.Repo{ | ||
| 23 | Name: "beta", Visibility: ermite.Visibility_VISIBILITY_PRIVATE, | 27 | Name: "beta", Visibility: ermite.Visibility_VISIBILITY_PRIVATE, | ||
| 24 | DefaultBranch: "trunk", CreatedAt: ts("2026-02-02T00:00:00Z"), | 28 | DefaultBranch: "trunk", CreatedAt: ts("2026-02-02T00:00:00Z"), | ||
| 29 | + | Settings: &ermite.RepoSettings{AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH}}, | |||
| 25 | } | 30 | } | ||
| 26 | ) | 31 | ) | ||
| 27 | 32 | ||||
| @@ -49,8 +54,8 @@ func TestRepoListJSON(t *testing.T) { | |||||
| 49 | }} | 54 | }} | ||
| 50 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t) | 55 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t) | ||
| 51 | assert.JSONEq(t, `[ | 56 | assert.JSONEq(t, `[ | ||
| 52 | - | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z"}, | 57 | + | {"name":"alpha","description":"The first one","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}, |
| 53 | - | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null} | 58 | + | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} |
| 54 | ]`, res.stdout) | 59 | ]`, res.stdout) | ||
| 55 | } | 60 | } | ||
| 56 | 61 | ||||
| @@ -88,17 +93,19 @@ func TestRepoShow(t *testing.T) { | |||||
| 88 | url := f.serve(t) | 93 | url := f.serve(t) | ||
| 89 | res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t) | 94 | res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t) | ||
| 90 | assert.Equal(t, ""+ | 95 | assert.Equal(t, ""+ | ||
| 91 | - | "name alpha\n"+ | 96 | + | "name alpha\n"+ |
| 92 | - | "visibility public\n"+ | 97 | + | "visibility public\n"+ |
| 93 | - | "default branch main\n"+ | 98 | + | "default branch main\n"+ |
| 94 | - | "description The first one\n"+ | 99 | + | "description The first one\n"+ |
| 95 | - | "created 2026-01-02 03:04\n"+ | 100 | + | "created 2026-01-02 03:04\n"+ |
| 96 | - | "last push 2026-03-04 05:06\n", res.stdout) | 101 | + | "last push 2026-03-04 05:06\n"+ |
| 102 | + | "merge modes fast-forward, squash\n"+ | |||
| 103 | + | "failing checks block true\n", res.stdout) | |||
| 97 | _, reqs := f.snapshot() | 104 | _, reqs := f.snapshot() | ||
| 98 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | 105 | assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo()) | ||
| 99 | 106 | ||||
| 100 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | 107 | res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t) | ||
| 101 | - | assert.JSONEq(t, `{"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z"}`, res.stdout) | 108 | + | assert.JSONEq(t, `{"name":"alpha","description":"The first one","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}`, res.stdout) |
| 102 | } | 109 | } | ||
| 103 | 110 | ||||
| 104 | func TestRepoShowNeedsOneArg(t *testing.T) { | 111 | func TestRepoShowNeedsOneArg(t *testing.T) { | ||
| @@ -151,7 +158,7 @@ func TestRepoCreateJSON(t *testing.T) { | |||||
| 151 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | 158 | return &ermite.CreateRepoResponse{Repo: repoBeta}, nil | ||
| 152 | }} | 159 | }} | ||
| 153 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | 160 | res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t) | ||
| 154 | - | assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}`, res.stdout) | 161 | + | assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout) |
| 155 | } | 162 | } | ||
| 156 | 163 | ||||
| 157 | func TestRepoEditMask(t *testing.T) { | 164 | func TestRepoEditMask(t *testing.T) { | ||
| @@ -173,6 +180,16 @@ func TestRepoEditMask(t *testing.T) { | |||||
| 173 | {"default branch", []string{"--default-branch", "refs/heads/dev"}, []string{"default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | 180 | {"default branch", []string{"--default-branch", "refs/heads/dev"}, []string{"default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | ||
| 174 | assert.Equal(t, "dev", r.GetDefaultBranch()) | 181 | assert.Equal(t, "dev", r.GetDefaultBranch()) | ||
| 175 | }}, | 182 | }}, | ||
| 183 | + | {"merge modes", []string{"--merge-modes", "squash,ff"}, []string{"settings.allowed_merge_modes"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |||
| 184 | + | assert.Equal(t, []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH, ermite.MergeMode_MERGE_MODE_FAST_FORWARD}, r.GetSettings().GetAllowedMergeModes()) | |||
| 185 | + | }}, | |||
| 186 | + | {"failing checks do not block", []string{"--failing-checks-block=false"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |||
| 187 | + | assert.False(t, r.GetSettings().GetFailingChecksBlock()) | |||
| 188 | + | assert.Empty(t, r.GetSettings().GetAllowedMergeModes()) | |||
| 189 | + | }}, | |||
| 190 | + | {"failing checks block", []string{"--failing-checks-block"}, []string{"settings.failing_checks_block"}, func(t *testing.T, r *ermite.UpdateRepoRequest) { | |||
| 191 | + | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | |||
| 192 | + | }}, | |||
| 176 | {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"}, | 193 | {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"}, | ||
| 177 | []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}}, | 194 | []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}}, | ||
| 178 | } | 195 | } | ||
| @@ -202,6 +219,8 @@ func TestRepoEditRejects(t *testing.T) { | |||||
| 202 | {[]string{"repo", "edit", "alpha"}, "nothing to change"}, | 219 | {[]string{"repo", "edit", "alpha"}, "nothing to change"}, | ||
| 203 | {[]string{"repo", "edit", "alpha", "--visibility", "x"}, "invalid visibility"}, | 220 | {[]string{"repo", "edit", "alpha", "--visibility", "x"}, "invalid visibility"}, | ||
| 204 | {[]string{"repo", "edit", "alpha", "--default-branch", " "}, "must not be empty"}, | 221 | {[]string{"repo", "edit", "alpha", "--default-branch", " "}, "must not be empty"}, | ||
| 222 | + | {[]string{"repo", "edit", "alpha", "--merge-modes", "rebase"}, "invalid merge mode"}, | |||
| 223 | + | {[]string{"repo", "edit", "alpha", "--merge-modes", ""}, "at least one mode"}, | |||
| 205 | } { | 224 | } { | ||
| 206 | res := runCLI(t, envFor(url), "", tt.args...) | 225 | res := runCLI(t, envFor(url), "", tt.args...) | ||
| 207 | require.Error(t, res.err) | 226 | require.Error(t, res.err) | ||
A cmd/ermite/cli_signing.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "connectrpc.com/connect" | |
| 9 | + | "github.com/spf13/cobra" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // jsonSigningKey is the stable JSON shape of `signing-key show --json`. | |
| 15 | + | type jsonSigningKey struct { | |
| 16 | + | PublicKey string `json:"public_key"` | |
| 17 | + | Fingerprint string `json:"fingerprint"` | |
| 18 | + | CommitterName string `json:"committer_name"` | |
| 19 | + | CommitterEmail string `json:"committer_email"` | |
| 20 | + | AllowedSigners string `json:"allowed_signers"` | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func newSigningKeyCmd(env *cliEnv) *cobra.Command { | |
| 24 | + | key := &cobra.Command{ | |
| 25 | + | Use: "signing-key", | |
| 26 | + | Short: "Show the key that signs the commits the server creates", | |
| 27 | + | Long: "The server signs the commits it creates (squash merges) with its own SSH key, so that they show as\n" + | |
| 28 | + | "verified. Needs a server with the signing_key feature (see `ermite version`).", | |
| 29 | + | } | |
| 30 | + | key.AddCommand(newSigningKeyShowCmd(env)) | |
| 31 | + | return key | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func newSigningKeyShowCmd(env *cliEnv) *cobra.Command { | |
| 35 | + | var allowedSigners, publicKey bool | |
| 36 | + | cmd := &cobra.Command{ | |
| 37 | + | Use: "show", | |
| 38 | + | Short: "Print the public signing key of the server", | |
| 39 | + | Long: "Print the public key of the server, its fingerprint and the committer it records. The key is public:\n" + | |
| 40 | + | "no token is needed. Register it for the committer's email on Forgejo or GitHub, or save the\n" + | |
| 41 | + | "allowed-signers line to a file to verify commits locally:\n\n" + | |
| 42 | + | " ermite signing-key show --allowed-signers >> ~/.config/git/allowed_signers\n" + | |
| 43 | + | " git config gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers\n" + | |
| 44 | + | " git log --show-signature\n\n" + | |
| 45 | + | "--public-key prints only the authorized_keys line, --allowed-signers only the allowed-signers line\n" + | |
| 46 | + | "(\"<committer email> <key>\"), each ready to be redirected into a file.\n\n" + | |
| 47 | + | "JSON fields (--json): public_key, fingerprint (SHA256:...), committer_name, committer_email,\n" + | |
| 48 | + | "allowed_signers.", | |
| 49 | + | Args: cobra.NoArgs, | |
| 50 | + | } | |
| 51 | + | cmd.Flags().BoolVar(&publicKey, "public-key", false, "print only the key as an authorized_keys line") | |
| 52 | + | cmd.Flags().BoolVar(&allowedSigners, "allowed-signers", false, "print only the line for an allowed signers file") | |
| 53 | + | cmd.MarkFlagsMutuallyExclusive("public-key", "allowed-signers") | |
| 54 | + | asJSON := addJSONFlag(cmd) | |
| 55 | + | cmd.MarkFlagsMutuallyExclusive("json", "public-key") | |
| 56 | + | cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers") | |
| 57 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 58 | + | c, err := env.client(cmd, true) | |
| 59 | + | if err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | res, err := c.Server().GetSigningKey(env.ctx(cmd), connect.NewRequest(&ermite.GetSigningKeyRequest{})) | |
| 63 | + | if err != nil { | |
| 64 | + | return c.Describe(err) | |
| 65 | + | } | |
| 66 | + | k := res.Msg | |
| 67 | + | out := cmd.OutOrStdout() | |
| 68 | + | switch { | |
| 69 | + | case *asJSON: | |
| 70 | + | return writeJSON(out, jsonSigningKey{ | |
| 71 | + | PublicKey: k.GetPublicKey(), Fingerprint: k.GetFingerprint(), CommitterName: k.GetCommitterName(), | |
| 72 | + | CommitterEmail: k.GetCommitterEmail(), AllowedSigners: k.GetAllowedSigners(), | |
| 73 | + | }) | |
| 74 | + | case publicKey: | |
| 75 | + | _, err = fmt.Fprintln(out, clean(k.GetPublicKey())) | |
| 76 | + | case allowedSigners: | |
| 77 | + | _, err = fmt.Fprintln(out, clean(k.GetAllowedSigners())) | |
| 78 | + | default: | |
| 79 | + | err = fields(out, [][2]string{ | |
| 80 | + | {"fingerprint", k.GetFingerprint()}, | |
| 81 | + | {"committer", k.GetCommitterName() + " <" + k.GetCommitterEmail() + ">"}, | |
| 82 | + | {"public key", k.GetPublicKey()}, | |
| 83 | + | {"allowed signers", k.GetAllowedSigners()}, | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | return cmd | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "connectrpc.com/connect" | |||
| 9 | + | "github.com/spf13/cobra" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // jsonSigningKey is the stable JSON shape of `signing-key show --json`. | |||
| 15 | + | type jsonSigningKey struct { | |||
| 16 | + | PublicKey string `json:"public_key"` | |||
| 17 | + | Fingerprint string `json:"fingerprint"` | |||
| 18 | + | CommitterName string `json:"committer_name"` | |||
| 19 | + | CommitterEmail string `json:"committer_email"` | |||
| 20 | + | AllowedSigners string `json:"allowed_signers"` | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func newSigningKeyCmd(env *cliEnv) *cobra.Command { | |||
| 24 | + | key := &cobra.Command{ | |||
| 25 | + | Use: "signing-key", | |||
| 26 | + | Short: "Show the key that signs the commits the server creates", | |||
| 27 | + | Long: "The server signs the commits it creates (squash merges) with its own SSH key, so that they show as\n" + | |||
| 28 | + | "verified. Needs a server with the signing_key feature (see `ermite version`).", | |||
| 29 | + | } | |||
| 30 | + | key.AddCommand(newSigningKeyShowCmd(env)) | |||
| 31 | + | return key | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func newSigningKeyShowCmd(env *cliEnv) *cobra.Command { | |||
| 35 | + | var allowedSigners, publicKey bool | |||
| 36 | + | cmd := &cobra.Command{ | |||
| 37 | + | Use: "show", | |||
| 38 | + | Short: "Print the public signing key of the server", | |||
| 39 | + | Long: "Print the public key of the server, its fingerprint and the committer it records. The key is public:\n" + | |||
| 40 | + | "no token is needed. Register it for the committer's email on Forgejo or GitHub, or save the\n" + | |||
| 41 | + | "allowed-signers line to a file to verify commits locally:\n\n" + | |||
| 42 | + | " ermite signing-key show --allowed-signers >> ~/.config/git/allowed_signers\n" + | |||
| 43 | + | " git config gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers\n" + | |||
| 44 | + | " git log --show-signature\n\n" + | |||
| 45 | + | "--public-key prints only the authorized_keys line, --allowed-signers only the allowed-signers line\n" + | |||
| 46 | + | "(\"<committer email> <key>\"), each ready to be redirected into a file.\n\n" + | |||
| 47 | + | "JSON fields (--json): public_key, fingerprint (SHA256:...), committer_name, committer_email,\n" + | |||
| 48 | + | "allowed_signers.", | |||
| 49 | + | Args: cobra.NoArgs, | |||
| 50 | + | } | |||
| 51 | + | cmd.Flags().BoolVar(&publicKey, "public-key", false, "print only the key as an authorized_keys line") | |||
| 52 | + | cmd.Flags().BoolVar(&allowedSigners, "allowed-signers", false, "print only the line for an allowed signers file") | |||
| 53 | + | cmd.MarkFlagsMutuallyExclusive("public-key", "allowed-signers") | |||
| 54 | + | asJSON := addJSONFlag(cmd) | |||
| 55 | + | cmd.MarkFlagsMutuallyExclusive("json", "public-key") | |||
| 56 | + | cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers") | |||
| 57 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |||
| 58 | + | c, err := env.client(cmd, true) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return err | |||
| 61 | + | } | |||
| 62 | + | res, err := c.Server().GetSigningKey(env.ctx(cmd), connect.NewRequest(&ermite.GetSigningKeyRequest{})) | |||
| 63 | + | if err != nil { | |||
| 64 | + | return c.Describe(err) | |||
| 65 | + | } | |||
| 66 | + | k := res.Msg | |||
| 67 | + | out := cmd.OutOrStdout() | |||
| 68 | + | switch { | |||
| 69 | + | case *asJSON: | |||
| 70 | + | return writeJSON(out, jsonSigningKey{ | |||
| 71 | + | PublicKey: k.GetPublicKey(), Fingerprint: k.GetFingerprint(), CommitterName: k.GetCommitterName(), | |||
| 72 | + | CommitterEmail: k.GetCommitterEmail(), AllowedSigners: k.GetAllowedSigners(), | |||
| 73 | + | }) | |||
| 74 | + | case publicKey: | |||
| 75 | + | _, err = fmt.Fprintln(out, clean(k.GetPublicKey())) | |||
| 76 | + | case allowedSigners: | |||
| 77 | + | _, err = fmt.Fprintln(out, clean(k.GetAllowedSigners())) | |||
| 78 | + | default: | |||
| 79 | + | err = fields(out, [][2]string{ | |||
| 80 | + | {"fingerprint", k.GetFingerprint()}, | |||
| 81 | + | {"committer", k.GetCommitterName() + " <" + k.GetCommitterEmail() + ">"}, | |||
| 82 | + | {"public key", k.GetPublicKey()}, | |||
| 83 | + | {"allowed signers", k.GetAllowedSigners()}, | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | return cmd | |||
| 89 | + | } | |||
M cmd/ermite/cli_version.go +4 -2
| @@ -100,6 +100,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||
| 100 | 100 | ||
| 101 | 101 | // featureUse says what breaks without a feature, for the version warning. | |
| 102 | 102 | var featureUse = map[string]string{ | |
| 103 | - | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 104 | - | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 103 | + | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | |
| 104 | + | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 105 | + | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 106 | + | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 105 | 107 | } | |
| @@ -100,6 +100,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command { | |||||
| 100 | 100 | ||||
| 101 | // featureUse says what breaks without a feature, for the version warning. | 101 | // featureUse says what breaks without a feature, for the version warning. | ||
| 102 | var featureUse = map[string]string{ | 102 | var featureUse = map[string]string{ | ||
| 103 | - | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", | 103 | + | client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server", |
| 104 | - | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | 104 | + | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", |
| 105 | + | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |||
| 106 | + | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |||
| 105 | } | 107 | } | ||
M cmd/ermite/cli_version_test.go +9 -6
| @@ -13,6 +13,9 @@ import ( | |||
| 13 | 13 | "bnema.dev/ermite-api/gen/ermite" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | // allFeatures are the features this client uses. | |
| 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key"} | |
| 18 | + | ||
| 16 | 19 | func versionFake(features ...string) *fakeAPI { | |
| 17 | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| 18 | 21 | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: features}, nil | |
| @@ -29,18 +32,18 @@ func TestVersionClientOnlyWithoutURL(t *testing.T) { | |||
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | func TestVersionWithServer(t *testing.T) { | |
| 32 | - | f := versionFake("repo.update", "events.resume") | |
| 35 | + | f := versionFake(allFeatures...) | |
| 33 | 36 | url := f.serve(t) | |
| 34 | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 35 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume\n", res.stdout) | |
| 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key\n", res.stdout) | |
| 36 | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 37 | 40 | ||
| 38 | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 39 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume"],"missing_features":[]}}`, res.stdout) | |
| 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key"],"missing_features":[]}}`, res.stdout) | |
| 40 | 43 | } | |
| 41 | 44 | ||
| 42 | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 43 | - | f := versionFake("repo.update") | |
| 46 | + | f := versionFake("repo.update", "merge_requests", "signing_key") | |
| 44 | 47 | url := f.serve(t) | |
| 45 | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 46 | 49 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -48,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 48 | 51 | assert.NotContains(t, res.stderr, "repo.update") | |
| 49 | 52 | ||
| 50 | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 51 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 52 | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 53 | 56 | ||
| 54 | 57 | f = versionFake() | |
| @@ -68,7 +71,7 @@ func TestVersionServerFailureStillShowsClient(t *testing.T) { | |||
| 68 | 71 | } | |
| 69 | 72 | ||
| 70 | 73 | func TestVersionWorksWithoutToken(t *testing.T) { | |
| 71 | - | f := versionFake("repo.update", "events.resume") | |
| 74 | + | f := versionFake(allFeatures...) | |
| 72 | 75 | runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "version").ok(t) | |
| 73 | 76 | assert.Empty(t, f.header[0].Values("Authorization")) | |
| 74 | 77 | } | |
| @@ -13,6 +13,9 @@ import ( | |||||
| 13 | "bnema.dev/ermite-api/gen/ermite" | 13 | "bnema.dev/ermite-api/gen/ermite" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | // allFeatures are the features this client uses. | |||
| 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key"} | |||
| 18 | + | ||||
| 16 | func versionFake(features ...string) *fakeAPI { | 19 | func versionFake(features ...string) *fakeAPI { | ||
| 17 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | ||
| 18 | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: features}, nil | 21 | return &ermite.GetVersionResponse{Version: "v0.4.0", Features: features}, nil | ||
| @@ -29,18 +32,18 @@ func TestVersionClientOnlyWithoutURL(t *testing.T) { | |||||
| 29 | } | 32 | } | ||
| 30 | 33 | ||||
| 31 | func TestVersionWithServer(t *testing.T) { | 34 | func TestVersionWithServer(t *testing.T) { | ||
| 32 | - | f := versionFake("repo.update", "events.resume") | 35 | + | f := versionFake(allFeatures...) |
| 33 | url := f.serve(t) | 36 | url := f.serve(t) | ||
| 34 | res := runCLI(t, envFor(url), "", "version").ok(t) | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 35 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume\n", res.stdout) | 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key\n", res.stdout) |
| 36 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 37 | 40 | ||||
| 38 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 39 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume"],"missing_features":[]}}`, res.stdout) | 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key"],"missing_features":[]}}`, res.stdout) |
| 40 | } | 43 | } | ||
| 41 | 44 | ||||
| 42 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | ||
| 43 | - | f := versionFake("repo.update") | 46 | + | f := versionFake("repo.update", "merge_requests", "signing_key") |
| 44 | url := f.serve(t) | 47 | url := f.serve(t) | ||
| 45 | res := runCLI(t, envFor(url), "", "version").ok(t) | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 46 | assert.Contains(t, res.stdout, "v0.4.0") | 49 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -48,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 48 | assert.NotContains(t, res.stderr, "repo.update") | 51 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 49 | 52 | ||||
| 50 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 51 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update"],"missing_features":["events.resume"]}}`, res.stdout) | 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key"],"missing_features":["events.resume"]}}`, res.stdout) |
| 52 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 53 | 56 | ||||
| 54 | f = versionFake() | 57 | f = versionFake() | ||
| @@ -68,7 +71,7 @@ func TestVersionServerFailureStillShowsClient(t *testing.T) { | |||||
| 68 | } | 71 | } | ||
| 69 | 72 | ||||
| 70 | func TestVersionWorksWithoutToken(t *testing.T) { | 73 | func TestVersionWorksWithoutToken(t *testing.T) { | ||
| 71 | - | f := versionFake("repo.update", "events.resume") | 74 | + | f := versionFake(allFeatures...) |
| 72 | runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "version").ok(t) | 75 | runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "version").ok(t) | ||
| 73 | assert.Empty(t, f.header[0].Values("Authorization")) | 76 | assert.Empty(t, f.header[0].Values("Authorization")) | ||
| 74 | } | 77 | } | ||
M cmd/ermite/cli_watch.go +34 -4
| @@ -24,6 +24,11 @@ type jsonEvent struct { | |||
| 24 | 24 | Repo string `json:"repo,omitempty"` | |
| 25 | 25 | Repository *jsonRepo `json:"repository,omitempty"` | |
| 26 | 26 | RefUpdates []jsonRefUpdate `json:"ref_updates,omitempty"` | |
| 27 | + | // MergeRequest is set for the merge_request_* events, Number and Comment | |
| 28 | + | // for comment_added. | |
| 29 | + | MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"` | |
| 30 | + | Number int64 `json:"number,omitempty"` | |
| 31 | + | Comment *jsonComment `json:"comment,omitempty"` | |
| 27 | 32 | } | |
| 28 | 33 | ||
| 29 | 34 | type jsonRefUpdate struct { | |
| @@ -39,6 +44,11 @@ const ( | |||
| 39 | 44 | eventRepoDeleted = "repo_deleted" | |
| 40 | 45 | eventPushed = "pushed" | |
| 41 | 46 | eventGap = "gap" | |
| 47 | + | ||
| 48 | + | eventMROpened = "merge_request_opened" | |
| 49 | + | eventMRMerged = "merge_request_merged" | |
| 50 | + | eventMRClosed = "merge_request_closed" | |
| 51 | + | eventCommentAdded = "comment_added" | |
| 42 | 52 | ) | |
| 43 | 53 | ||
| 44 | 54 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | |
| @@ -60,6 +70,18 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |||
| 60 | 70 | for _, u := range k.Pushed.GetRefUpdates() { | |
| 61 | 71 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | |
| 62 | 72 | } | |
| 73 | + | case *ermite.Event_MergeRequestOpened: | |
| 74 | + | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest()) | |
| 75 | + | out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m | |
| 76 | + | case *ermite.Event_MergeRequestMerged: | |
| 77 | + | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest()) | |
| 78 | + | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | |
| 79 | + | case *ermite.Event_MergeRequestClosed: | |
| 80 | + | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest()) | |
| 81 | + | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | |
| 82 | + | case *ermite.Event_CommentAdded: | |
| 83 | + | c := toJSONComment(k.CommentAdded.GetComment()) | |
| 84 | + | out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c | |
| 63 | 85 | case *ermite.Event_Gap: | |
| 64 | 86 | out.Type = eventGap | |
| 65 | 87 | default: | |
| @@ -88,6 +110,10 @@ func eventLine(e jsonEvent) string { | |||
| 88 | 110 | parts = append(parts, refUpdateText(u)) | |
| 89 | 111 | } | |
| 90 | 112 | detail = e.Repo + " " + strings.Join(parts, ", ") | |
| 113 | + | case eventMROpened, eventMRMerged, eventMRClosed: | |
| 114 | + | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | |
| 115 | + | case eventCommentAdded: | |
| 116 | + | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |
| 91 | 117 | case eventGap: | |
| 92 | 118 | detail = "events were lost, reload what you display" | |
| 93 | 119 | } | |
| @@ -115,15 +141,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||
| 115 | 141 | cmd := &cobra.Command{ | |
| 116 | 142 | Use: "watch [repo]", | |
| 117 | 143 | Short: "Print repository events as they happen", | |
| 118 | - | Long: "Print one line per event (repository created, updated or deleted, refs pushed) for every\n" + | |
| 144 | + | Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" + | |
| 145 | + | "opened, merged or closed, comments added) for every\n" + | |
| 119 | 146 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | |
| 120 | 147 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | |
| 121 | 148 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | |
| 122 | 149 | "error.\n\n" + | |
| 123 | 150 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | |
| 124 | - | "(repo_created, repo_updated, repo_deleted, pushed or gap) and, depending on the type, repo (name),\n" + | |
| 125 | - | "repository (the repository fields of `repo show --json`) and ref_updates (array of ref, old_id,\n" + | |
| 126 | - | "new_id; an empty id means the ref was created or deleted). Unknown event types are skipped.", | |
| 151 | + | "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" + | |
| 152 | + | "merge_request_closed, comment_added or gap) and, depending on the type, repo (name),\n" + | |
| 153 | + | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + | |
| 154 | + | "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" + | |
| 155 | + | "`mr show --json`) and, for comment_added, number (of the merge request) and comment. Unknown\n" + | |
| 156 | + | "event types are skipped.", | |
| 127 | 157 | Args: cobra.MaximumNArgs(1), | |
| 128 | 158 | ValidArgsFunction: completeRepoNames(env), | |
| 129 | 159 | } | |
| @@ -24,6 +24,11 @@ type jsonEvent struct { | |||||
| 24 | Repo string `json:"repo,omitempty"` | 24 | Repo string `json:"repo,omitempty"` | ||
| 25 | Repository *jsonRepo `json:"repository,omitempty"` | 25 | Repository *jsonRepo `json:"repository,omitempty"` | ||
| 26 | RefUpdates []jsonRefUpdate `json:"ref_updates,omitempty"` | 26 | RefUpdates []jsonRefUpdate `json:"ref_updates,omitempty"` | ||
| 27 | + | // MergeRequest is set for the merge_request_* events, Number and Comment | |||
| 28 | + | // for comment_added. | |||
| 29 | + | MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"` | |||
| 30 | + | Number int64 `json:"number,omitempty"` | |||
| 31 | + | Comment *jsonComment `json:"comment,omitempty"` | |||
| 27 | } | 32 | } | ||
| 28 | 33 | ||||
| 29 | type jsonRefUpdate struct { | 34 | type jsonRefUpdate struct { | ||
| @@ -39,6 +44,11 @@ const ( | |||||
| 39 | eventRepoDeleted = "repo_deleted" | 44 | eventRepoDeleted = "repo_deleted" | ||
| 40 | eventPushed = "pushed" | 45 | eventPushed = "pushed" | ||
| 41 | eventGap = "gap" | 46 | eventGap = "gap" | ||
| 47 | + | ||||
| 48 | + | eventMROpened = "merge_request_opened" | |||
| 49 | + | eventMRMerged = "merge_request_merged" | |||
| 50 | + | eventMRClosed = "merge_request_closed" | |||
| 51 | + | eventCommentAdded = "comment_added" | |||
| 42 | ) | 52 | ) | ||
| 43 | 53 | ||||
| 44 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | 54 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | ||
| @@ -60,6 +70,18 @@ func toJSONEvent(ev *ermite.Event) (jsonEvent, bool) { | |||||
| 60 | for _, u := range k.Pushed.GetRefUpdates() { | 70 | for _, u := range k.Pushed.GetRefUpdates() { | ||
| 61 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | 71 | out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()}) | ||
| 62 | } | 72 | } | ||
| 73 | + | case *ermite.Event_MergeRequestOpened: | |||
| 74 | + | m := toJSONMergeRequest(k.MergeRequestOpened.GetMergeRequest()) | |||
| 75 | + | out.Type, out.Repo, out.MergeRequest = eventMROpened, m.Repo, &m | |||
| 76 | + | case *ermite.Event_MergeRequestMerged: | |||
| 77 | + | m := toJSONMergeRequest(k.MergeRequestMerged.GetMergeRequest()) | |||
| 78 | + | out.Type, out.Repo, out.MergeRequest = eventMRMerged, m.Repo, &m | |||
| 79 | + | case *ermite.Event_MergeRequestClosed: | |||
| 80 | + | m := toJSONMergeRequest(k.MergeRequestClosed.GetMergeRequest()) | |||
| 81 | + | out.Type, out.Repo, out.MergeRequest = eventMRClosed, m.Repo, &m | |||
| 82 | + | case *ermite.Event_CommentAdded: | |||
| 83 | + | c := toJSONComment(k.CommentAdded.GetComment()) | |||
| 84 | + | out.Type, out.Repo, out.Number, out.Comment = eventCommentAdded, k.CommentAdded.GetRepo(), k.CommentAdded.GetNumber(), &c | |||
| 63 | case *ermite.Event_Gap: | 85 | case *ermite.Event_Gap: | ||
| 64 | out.Type = eventGap | 86 | out.Type = eventGap | ||
| 65 | default: | 87 | default: | ||
| @@ -88,6 +110,10 @@ func eventLine(e jsonEvent) string { | |||||
| 88 | parts = append(parts, refUpdateText(u)) | 110 | parts = append(parts, refUpdateText(u)) | ||
| 89 | } | 111 | } | ||
| 90 | detail = e.Repo + " " + strings.Join(parts, ", ") | 112 | detail = e.Repo + " " + strings.Join(parts, ", ") | ||
| 113 | + | case eventMROpened, eventMRMerged, eventMRClosed: | |||
| 114 | + | detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch) | |||
| 115 | + | case eventCommentAdded: | |||
| 116 | + | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |||
| 91 | case eventGap: | 117 | case eventGap: | ||
| 92 | detail = "events were lost, reload what you display" | 118 | detail = "events were lost, reload what you display" | ||
| 93 | } | 119 | } | ||
| @@ -115,15 +141,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command { | |||||
| 115 | cmd := &cobra.Command{ | 141 | cmd := &cobra.Command{ | ||
| 116 | Use: "watch [repo]", | 142 | Use: "watch [repo]", | ||
| 117 | Short: "Print repository events as they happen", | 143 | Short: "Print repository events as they happen", | ||
| 118 | - | Long: "Print one line per event (repository created, updated or deleted, refs pushed) for every\n" + | 144 | + | Long: "Print one line per event (repository created, updated or deleted, refs pushed, merge requests\n" + |
| 145 | + | "opened, merged or closed, comments added) for every\n" + | |||
| 119 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | 146 | "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" + | ||
| 120 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | 147 | "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" + | ||
| 121 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | 148 | "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" + | ||
| 122 | "error.\n\n" + | 149 | "error.\n\n" + | ||
| 123 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | 150 | "--json prints one JSON object per line (NDJSON). Fields: id, time (RFC 3339 UTC), type\n" + | ||
| 124 | - | "(repo_created, repo_updated, repo_deleted, pushed or gap) and, depending on the type, repo (name),\n" + | 151 | + | "(repo_created, repo_updated, repo_deleted, pushed, merge_request_opened, merge_request_merged,\n" + |
| 125 | - | "repository (the repository fields of `repo show --json`) and ref_updates (array of ref, old_id,\n" + | 152 | + | "merge_request_closed, comment_added or gap) and, depending on the type, repo (name),\n" + |
| 126 | - | "new_id; an empty id means the ref was created or deleted). Unknown event types are skipped.", | 153 | + | "repository (the repository fields of `repo show --json`), ref_updates (array of ref, old_id,\n" + |
| 154 | + | "new_id; an empty id means the ref was created or deleted), merge_request (the fields of\n" + | |||
| 155 | + | "`mr show --json`) and, for comment_added, number (of the merge request) and comment. Unknown\n" + | |||
| 156 | + | "event types are skipped.", | |||
| 127 | Args: cobra.MaximumNArgs(1), | 157 | Args: cobra.MaximumNArgs(1), | ||
| 128 | ValidArgsFunction: completeRepoNames(env), | 158 | ValidArgsFunction: completeRepoNames(env), | ||
| 129 | } | 159 | } | ||
M cmd/ermite/cli_watch_test.go +53 -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":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}}`, lines[0]) | |
| 98 | + | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, 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"}, | |
| @@ -147,3 +147,55 @@ func TestWatchRejectsTooManyArgs(t *testing.T) { | |||
| 147 | 147 | res := runCLI(t, envFor("http://127.0.0.1:1"), "", "watch", "a", "b") | |
| 148 | 148 | require.Error(t, res.err) | |
| 149 | 149 | } | |
| 150 | + | ||
| 151 | + | func TestWatchMergeRequestEvents(t *testing.T) { | |
| 152 | + | cmt := &ermite.Comment{Id: "2", Author: "agent-7", Path: "a.txt", Line: 5, Side: ermite.DiffSide_DIFF_SIDE_NEW, Text: "why?"} | |
| 153 | + | events := []*ermite.WatchResponse{ | |
| 154 | + | evt("1", func(e *ermite.Event) { | |
| 155 | + | e.Kind = &ermite.Event_MergeRequestOpened{MergeRequestOpened: &ermite.MergeRequestOpened{MergeRequest: mrOne}} | |
| 156 | + | }), | |
| 157 | + | evt("2", func(e *ermite.Event) { | |
| 158 | + | e.Kind = &ermite.Event_CommentAdded{CommentAdded: &ermite.CommentAdded{Repo: "alpha", Number: 1, Comment: cmt}} | |
| 159 | + | }), | |
| 160 | + | evt("3", func(e *ermite.Event) { | |
| 161 | + | e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne}} | |
| 162 | + | }), | |
| 163 | + | evt("4", func(e *ermite.Event) { | |
| 164 | + | e.Kind = &ermite.Event_MergeRequestClosed{MergeRequestClosed: &ermite.MergeRequestClosed{MergeRequest: mrOne}} | |
| 165 | + | }), | |
| 166 | + | } | |
| 167 | + | f := &fakeAPI{watch: finishing(events)} | |
| 168 | + | url := f.serve(t) | |
| 169 | + | ||
| 170 | + | res := runCLI(t, envFor(url), "", "watch") | |
| 171 | + | require.Error(t, res.err) | |
| 172 | + | assert.Equal(t, ""+ | |
| 173 | + | "2026-10-10T12:00:00Z merge_request_opened alpha!1 feat/x -> main\n"+ | |
| 174 | + | "2026-10-10T12:00:00Z comment_added alpha!1 by agent-7\n"+ | |
| 175 | + | "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main\n"+ | |
| 176 | + | "2026-10-10T12:00:00Z merge_request_closed alpha!1 feat/x -> main\n", res.stdout) | |
| 177 | + | ||
| 178 | + | res = runCLI(t, envFor(url), "", "watch", "--json") | |
| 179 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |
| 180 | + | require.Len(t, lines, 4) | |
| 181 | + | assert.Equal(t, "merge_request_opened", decode[eventOut](t, lines[0]).Type) | |
| 182 | + | opened := decode[eventOut](t, lines[0]) | |
| 183 | + | assert.Equal(t, "alpha", opened.Repo) | |
| 184 | + | require.NotNil(t, opened.MergeRequest) | |
| 185 | + | assert.EqualValues(t, 1, opened.MergeRequest.Number) | |
| 186 | + | assert.Equal(t, headA, opened.MergeRequest.HeadID) | |
| 187 | + | var added struct { | |
| 188 | + | Type string `json:"type"` | |
| 189 | + | Repo string `json:"repo"` | |
| 190 | + | Number int64 `json:"number"` | |
| 191 | + | Comment commentOut `json:"comment"` | |
| 192 | + | } | |
| 193 | + | require.NoError(t, json.Unmarshal([]byte(lines[1]), &added)) | |
| 194 | + | assert.Equal(t, "comment_added", added.Type) | |
| 195 | + | assert.Equal(t, "alpha", added.Repo) | |
| 196 | + | assert.EqualValues(t, 1, added.Number) | |
| 197 | + | assert.Equal(t, "a.txt", added.Comment.Path) | |
| 198 | + | assert.Equal(t, "new", added.Comment.Side) | |
| 199 | + | assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type) | |
| 200 | + | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | |
| 201 | + | } | |
| @@ -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":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null}}`, lines[0]) | 98 | + | assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, 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"}, | ||
| @@ -147,3 +147,55 @@ func TestWatchRejectsTooManyArgs(t *testing.T) { | |||||
| 147 | res := runCLI(t, envFor("http://127.0.0.1:1"), "", "watch", "a", "b") | 147 | res := runCLI(t, envFor("http://127.0.0.1:1"), "", "watch", "a", "b") | ||
| 148 | require.Error(t, res.err) | 148 | require.Error(t, res.err) | ||
| 149 | } | 149 | } | ||
| 150 | + | ||||
| 151 | + | func TestWatchMergeRequestEvents(t *testing.T) { | |||
| 152 | + | cmt := &ermite.Comment{Id: "2", Author: "agent-7", Path: "a.txt", Line: 5, Side: ermite.DiffSide_DIFF_SIDE_NEW, Text: "why?"} | |||
| 153 | + | events := []*ermite.WatchResponse{ | |||
| 154 | + | evt("1", func(e *ermite.Event) { | |||
| 155 | + | e.Kind = &ermite.Event_MergeRequestOpened{MergeRequestOpened: &ermite.MergeRequestOpened{MergeRequest: mrOne}} | |||
| 156 | + | }), | |||
| 157 | + | evt("2", func(e *ermite.Event) { | |||
| 158 | + | e.Kind = &ermite.Event_CommentAdded{CommentAdded: &ermite.CommentAdded{Repo: "alpha", Number: 1, Comment: cmt}} | |||
| 159 | + | }), | |||
| 160 | + | evt("3", func(e *ermite.Event) { | |||
| 161 | + | e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne}} | |||
| 162 | + | }), | |||
| 163 | + | evt("4", func(e *ermite.Event) { | |||
| 164 | + | e.Kind = &ermite.Event_MergeRequestClosed{MergeRequestClosed: &ermite.MergeRequestClosed{MergeRequest: mrOne}} | |||
| 165 | + | }), | |||
| 166 | + | } | |||
| 167 | + | f := &fakeAPI{watch: finishing(events)} | |||
| 168 | + | url := f.serve(t) | |||
| 169 | + | ||||
| 170 | + | res := runCLI(t, envFor(url), "", "watch") | |||
| 171 | + | require.Error(t, res.err) | |||
| 172 | + | assert.Equal(t, ""+ | |||
| 173 | + | "2026-10-10T12:00:00Z merge_request_opened alpha!1 feat/x -> main\n"+ | |||
| 174 | + | "2026-10-10T12:00:00Z comment_added alpha!1 by agent-7\n"+ | |||
| 175 | + | "2026-10-10T12:00:00Z merge_request_merged alpha!1 feat/x -> main\n"+ | |||
| 176 | + | "2026-10-10T12:00:00Z merge_request_closed alpha!1 feat/x -> main\n", res.stdout) | |||
| 177 | + | ||||
| 178 | + | res = runCLI(t, envFor(url), "", "watch", "--json") | |||
| 179 | + | lines := strings.Split(strings.TrimSpace(res.stdout), "\n") | |||
| 180 | + | require.Len(t, lines, 4) | |||
| 181 | + | assert.Equal(t, "merge_request_opened", decode[eventOut](t, lines[0]).Type) | |||
| 182 | + | opened := decode[eventOut](t, lines[0]) | |||
| 183 | + | assert.Equal(t, "alpha", opened.Repo) | |||
| 184 | + | require.NotNil(t, opened.MergeRequest) | |||
| 185 | + | assert.EqualValues(t, 1, opened.MergeRequest.Number) | |||
| 186 | + | assert.Equal(t, headA, opened.MergeRequest.HeadID) | |||
| 187 | + | var added struct { | |||
| 188 | + | Type string `json:"type"` | |||
| 189 | + | Repo string `json:"repo"` | |||
| 190 | + | Number int64 `json:"number"` | |||
| 191 | + | Comment commentOut `json:"comment"` | |||
| 192 | + | } | |||
| 193 | + | require.NoError(t, json.Unmarshal([]byte(lines[1]), &added)) | |||
| 194 | + | assert.Equal(t, "comment_added", added.Type) | |||
| 195 | + | assert.Equal(t, "alpha", added.Repo) | |||
| 196 | + | assert.EqualValues(t, 1, added.Number) | |||
| 197 | + | assert.Equal(t, "a.txt", added.Comment.Path) | |||
| 198 | + | assert.Equal(t, "new", added.Comment.Side) | |||
| 199 | + | assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type) | |||
| 200 | + | assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type) | |||
| 201 | + | } | |||
M cmd/ermite/root.go +1 -0
| @@ -26,6 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 26 | 26 | root.AddCommand( | |
| 27 | 27 | newServeCmd(), newHookCmd(), | |
| 28 | 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 29 | + | newMRCmd(env), newSigningKeyCmd(env), | |
| 29 | 30 | ) | |
| 30 | 31 | return root | |
| 31 | 32 | } | |
| @@ -26,6 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||||
| 26 | root.AddCommand( | 26 | root.AddCommand( | ||
| 27 | newServeCmd(), newHookCmd(), | 27 | newServeCmd(), newHookCmd(), | ||
| 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | ||
| 29 | + | newMRCmd(env), newSigningKeyCmd(env), | |||
| 29 | ) | 30 | ) | ||
| 30 | return root | 31 | return root | ||
| 31 | } | 32 | } | ||
M internal/adapters/client/client.go +5 -0
| @@ -75,6 +75,11 @@ func (c *Client) Events() ermiteconnect.EventServiceClient { | |||
| 75 | 75 | return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...) | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | // MergeRequests returns the MergeRequestService client. | |
| 79 | + | func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient { | |
| 80 | + | return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...) | |
| 81 | + | } | |
| 82 | + | ||
| 78 | 83 | // Server returns the ServerService client. | |
| 79 | 84 | func (c *Client) Server() ermiteconnect.ServerServiceClient { | |
| 80 | 85 | return ermiteconnect.NewServerServiceClient(c.http, c.base, c.opts...) | |
| @@ -75,6 +75,11 @@ func (c *Client) Events() ermiteconnect.EventServiceClient { | |||||
| 75 | return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...) | 75 | return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...) | ||
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | + | // MergeRequests returns the MergeRequestService client. | |||
| 79 | + | func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient { | |||
| 80 | + | return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 78 | // Server returns the ServerService client. | 83 | // Server returns the ServerService client. | ||
| 79 | func (c *Client) Server() ermiteconnect.ServerServiceClient { | 84 | func (c *Client) Server() ermiteconnect.ServerServiceClient { | ||
| 80 | return ermiteconnect.NewServerServiceClient(c.http, c.base, c.opts...) | 85 | return ermiteconnect.NewServerServiceClient(c.http, c.base, c.opts...) | ||
M internal/adapters/client/client_test.go +6 -1
| @@ -138,7 +138,12 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||
| 138 | 138 | ||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | - | assert.Equal(t, []string{client.FeatureEventsResume}, client.MissingFeatures([]string{client.FeatureRepoUpdate, "other"})) | |
| 141 | + | assert.Equal(t, | |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey}, | |
| 143 | + | client.MissingFeatures([]string{client.FeatureRepoUpdate, "other"})) | |
| 144 | + | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, | |
| 146 | + | })) | |
| 142 | 147 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 143 | 148 | } | |
| 144 | 149 | ||
| @@ -138,7 +138,12 @@ func TestDescribeBoundsLongMessages(t *testing.T) { | |||||
| 138 | 138 | ||||
| 139 | func TestMissingFeatures(t *testing.T) { | 139 | func TestMissingFeatures(t *testing.T) { | ||
| 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | ||
| 141 | - | assert.Equal(t, []string{client.FeatureEventsResume}, client.MissingFeatures([]string{client.FeatureRepoUpdate, "other"})) | 141 | + | assert.Equal(t, |
| 142 | + | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey}, | |||
| 143 | + | client.MissingFeatures([]string{client.FeatureRepoUpdate, "other"})) | |||
| 144 | + | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |||
| 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, | |||
| 146 | + | })) | |||
| 142 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | 147 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | ||
| 143 | } | 148 | } | ||
| 144 | 149 | ||||
M internal/adapters/client/features.go +5 -1
| @@ -13,11 +13,15 @@ const ( | |||
| 13 | 13 | // FeatureEventsResume is EventService.Watch with resume_after, which | |
| 14 | 14 | // `ermite watch` uses to continue after a reconnection. | |
| 15 | 15 | FeatureEventsResume = "events.resume" | |
| 16 | + | // FeatureMergeRequests is MergeRequestService (`ermite mr`). | |
| 17 | + | FeatureMergeRequests = "merge_requests" | |
| 18 | + | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | |
| 19 | + | FeatureSigningKey = "signing_key" | |
| 16 | 20 | ) | |
| 17 | 21 | ||
| 18 | 22 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 19 | 23 | func UsedFeatures() []string { | |
| 20 | - | return []string{FeatureRepoUpdate, FeatureEventsResume} | |
| 24 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey} | |
| 21 | 25 | } | |
| 22 | 26 | ||
| 23 | 27 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
| @@ -13,11 +13,15 @@ const ( | |||||
| 13 | // FeatureEventsResume is EventService.Watch with resume_after, which | 13 | // FeatureEventsResume is EventService.Watch with resume_after, which | ||
| 14 | // `ermite watch` uses to continue after a reconnection. | 14 | // `ermite watch` uses to continue after a reconnection. | ||
| 15 | FeatureEventsResume = "events.resume" | 15 | FeatureEventsResume = "events.resume" | ||
| 16 | + | // FeatureMergeRequests is MergeRequestService (`ermite mr`). | |||
| 17 | + | FeatureMergeRequests = "merge_requests" | |||
| 18 | + | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | |||
| 19 | + | FeatureSigningKey = "signing_key" | |||
| 16 | ) | 20 | ) | ||
| 17 | 21 | ||||
| 18 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | 22 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | ||
| 19 | func UsedFeatures() []string { | 23 | func UsedFeatures() []string { | ||
| 20 | - | return []string{FeatureRepoUpdate, FeatureEventsResume} | 24 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey} |
| 21 | } | 25 | } | ||
| 22 | 26 | ||||
| 23 | // MissingFeatures returns the features the CLI uses that have is lacking. | 27 | // MissingFeatures returns the features the CLI uses that have is lacking. | ||