detached 3e5f81b

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
3030 token ermiteconnect.TokenServiceClient
3131 event ermiteconnect.EventServiceClient
3232 server ermiteconnect.ServerServiceClient
33+ mr ermiteconnect.MergeRequestServiceClient
3334 }
3435
3536 // bearerTransport adds the Authorization header of a secret, if any.
@@ -75,6 +76,7 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
7576 token: ermiteconnect.NewTokenServiceClient(hc, base, opts...),
7677 event: ermiteconnect.NewEventServiceClient(hc, base, opts...),
7778 server: ermiteconnect.NewServerServiceClient(hc, base, opts...),
79+ mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
7880 }
7981 }
8082
@@ -30,6 +30,7 @@ type clients struct {
30 token ermiteconnect.TokenServiceClient30 token ermiteconnect.TokenServiceClient
31 event ermiteconnect.EventServiceClient31 event ermiteconnect.EventServiceClient
32 server ermiteconnect.ServerServiceClient32 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 {
187187 OldID string `json:"old_id"`
188188 NewID string `json:"new_id"`
189189 } `json:"ref_updates"`
190+ MergeRequest *mrOut `json:"merge_request"`
190191 }
191192
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"}
193194
194195 // AC6: the CLI end to end against a real forge.
195196 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -323,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
323324 ver := decode[versionOut](t, res.stdout)
324325 assert.Equal(t, c.url, ver.Server.URL)
325326 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)
327328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
328329 stdout, stderr, code = c.bin(t, secret, "version")
329330 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 {
2828 ermiteconnect.UnimplementedTokenServiceHandler
2929 ermiteconnect.UnimplementedServerServiceHandler
3030 ermiteconnect.UnimplementedEventServiceHandler
31+ ermiteconnect.UnimplementedMergeRequestServiceHandler
3132
3233 mu sync.Mutex
3334 calls []string // procedure names, in order
@@ -44,6 +45,16 @@ type fakeAPI struct {
4445 revokeToken func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error)
4546 getVersion func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
4647 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)
4758 }
4859
4960 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
99110 func (f *fakeAPI) GetVersion(_ context.Context, r *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) {
100111 return respond(f, "GetVersion", r, f.getVersion)
101112 }
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+}
102140 func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
103141 f.record("Watch", r.Header(), r.Msg)
104142 if f.watch == nil {
@@ -120,6 +158,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
120158 mount(p, h)
121159 p, h = ermiteconnect.NewEventServiceHandler(f)
122160 mount(p, h)
161+ p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
162+ mount(p, h)
123163 srv := httptest.NewServer(mux)
124164 t.Cleanup(srv.Close)
125165 return srv.URL
@@ -28,6 +28,7 @@ type fakeAPI struct {
28 ermiteconnect.UnimplementedTokenServiceHandler28 ermiteconnect.UnimplementedTokenServiceHandler
29 ermiteconnect.UnimplementedServerServiceHandler29 ermiteconnect.UnimplementedServerServiceHandler
30 ermiteconnect.UnimplementedEventServiceHandler30 ermiteconnect.UnimplementedEventServiceHandler
31+ ermiteconnect.UnimplementedMergeRequestServiceHandler
31 32
32 mu sync.Mutex33 mu sync.Mutex
33 calls []string // procedure names, in order34 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]) error47 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.URL165 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 (
88 "errors"
99 "fmt"
1010 "io"
11+ "strconv"
1112 "strings"
1213
1314 "connectrpc.com/connect"
@@ -31,16 +32,25 @@ type jsonRepo struct {
3132 DefaultBranch string `json:"default_branch"`
3233 CreatedAt *string `json:"created_at"`
3334 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"`
3438 }
3539
3640 func toJSONRepo(r *ermite.Repo) jsonRepo {
41+ modes := []string{}
42+ for _, m := range r.GetSettings().GetAllowedMergeModes() {
43+ modes = append(modes, modeName(m))
44+ }
3745 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()),
4454 }
4555 }
4656
@@ -69,7 +79,8 @@ func parseVisibility(s string) (ermite.Visibility, error) {
6979 const jsonRepoHelp = `
7080 JSON fields of a repository (--json): name, description, visibility ("public" or
7181 "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).`
7384
7485 func newRepoCmd(env *cliEnv) *cobra.Command {
7586 repo := &cobra.Command{
@@ -171,6 +182,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
171182 if desc == "" {
172183 desc = "-"
173184 }
185+ modes := toJSONRepo(r).MergeModes
174186 return fields(w, [][2]string{
175187 {"name", r.GetName()},
176188 {"visibility", visibilityName(r.GetVisibility())},
@@ -178,6 +190,8 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
178190 {"description", desc},
179191 {"created", shortTime(r.GetCreatedAt(), "-")},
180192 {"last push", shortTime(r.GetPushedAt(), "never")},
193+ {"merge modes", orDash(strings.Join(modes, ", "))},
194+ {"failing checks block", strconv.FormatBool(r.GetSettings().GetFailingChecksBlock())},
181195 })
182196 }
183197
@@ -253,21 +267,31 @@ func buildCreateRepo(name, description, visibility string) (*ermite.CreateRepoRe
253267
254268 func newRepoEditCmd(env *cliEnv) *cobra.Command {
255269 var description, visibility, defaultBranch string
270+ var mergeModes []string
271+ var failingChecksBlock bool
256272 cmd := &cobra.Command{
257273 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",
259275 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,
261280 Args: cobra.ExactArgs(1),
262281 ValidArgsFunction: completeRepoNames(env),
263282 }
264283 cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
265284 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
266285 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)")
267288 _ = 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+ })
268292 asJSON := addJSONFlag(cmd)
269293 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)
271295 if err != nil {
272296 return err
273297 }
@@ -286,7 +310,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
286310
287311 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
288312 // 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) {
290314 req := &ermite.UpdateRepoRequest{Repo: name}
291315 var paths []string
292316 if cmd.Flags().Changed("description") {
@@ -309,8 +333,30 @@ func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultB
309333 req.DefaultBranch = branch
310334 paths = append(paths, "default_branch")
311335 }
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+ }
312358 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")
314360 }
315361 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
316362 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" or80 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 is81 "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 string269 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 err296 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 flags311 // 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 []string315 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 = branch333 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, nil362 return req, nil
M cmd/ermite/cli_repo_test.go +29 -10
@@ -18,10 +18,15 @@ var (
1818 repoAlpha = &ermite.Repo{
1919 Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
2020 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+ },
2125 }
2226 repoBeta = &ermite.Repo{
2327 Name: "beta", Visibility: ermite.Visibility_VISIBILITY_PRIVATE,
2428 DefaultBranch: "trunk", CreatedAt: ts("2026-02-02T00:00:00Z"),
29+ Settings: &ermite.RepoSettings{AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_SQUASH}},
2530 }
2631 )
2732
@@ -49,8 +54,8 @@ func TestRepoListJSON(t *testing.T) {
4954 }}
5055 res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t)
5156 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}
5459 ]`, res.stdout)
5560 }
5661
@@ -88,17 +93,19 @@ func TestRepoShow(t *testing.T) {
8893 url := f.serve(t)
8994 res := runCLI(t, envFor(url), "", "repo", "show", "alpha").ok(t)
9095 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)
97104 _, reqs := f.snapshot()
98105 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
99106
100107 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)
102109 }
103110
104111 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -151,7 +158,7 @@ func TestRepoCreateJSON(t *testing.T) {
151158 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
152159 }}
153160 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)
155162 }
156163
157164 func TestRepoEditMask(t *testing.T) {
@@ -173,6 +180,16 @@ func TestRepoEditMask(t *testing.T) {
173180 {"default branch", []string{"--default-branch", "refs/heads/dev"}, []string{"default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
174181 assert.Equal(t, "dev", r.GetDefaultBranch())
175182 }},
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+ }},
176193 {"all", []string{"--default-branch", "dev", "--visibility", "private", "--description", "d"},
177194 []string{"description", "visibility", "default_branch"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {}},
178195 }
@@ -202,6 +219,8 @@ func TestRepoEditRejects(t *testing.T) {
202219 {[]string{"repo", "edit", "alpha"}, "nothing to change"},
203220 {[]string{"repo", "edit", "alpha", "--visibility", "x"}, "invalid visibility"},
204221 {[]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"},
205224 } {
206225 res := runCLI(t, envFor(url), "", tt.args...)
207226 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}, nil158 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 {
100100
101101 // featureUse says what breaks without a feature, for the version warning.
102102 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",
105107 }
@@ -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 (
1313 "bnema.dev/ermite-api/gen/ermite"
1414 )
1515
16+// allFeatures are the features this client uses.
17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key"}
18+
1619 func versionFake(features ...string) *fakeAPI {
1720 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
1821 return &ermite.GetVersionResponse{Version: "v0.4.0", Features: features}, nil
@@ -29,18 +32,18 @@ func TestVersionClientOnlyWithoutURL(t *testing.T) {
2932 }
3033
3134 func TestVersionWithServer(t *testing.T) {
32- f := versionFake("repo.update", "events.resume")
35+ f := versionFake(allFeatures...)
3336 url := f.serve(t)
3437 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)
3639 assert.Empty(t, res.stderr, "no warning when every feature is there")
3740
3841 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)
4043 }
4144
4245 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
43- f := versionFake("repo.update")
46+ f := versionFake("repo.update", "merge_requests", "signing_key")
4447 url := f.serve(t)
4548 res := runCLI(t, envFor(url), "", "version").ok(t)
4649 assert.Contains(t, res.stdout, "v0.4.0")
@@ -48,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
4851 assert.NotContains(t, res.stderr, "repo.update")
4952
5053 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)
5255 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5356
5457 f = versionFake()
@@ -68,7 +71,7 @@ func TestVersionServerFailureStillShowsClient(t *testing.T) {
6871 }
6972
7073 func TestVersionWorksWithoutToken(t *testing.T) {
71- f := versionFake("repo.update", "events.resume")
74+ f := versionFake(allFeatures...)
7275 runCLI(t, map[string]string{"ERMITE_URL": f.serve(t)}, "", "version").ok(t)
7376 assert.Empty(t, f.header[0].Values("Authorization"))
7477 }
@@ -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}, nil21 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 {
2424 Repo string `json:"repo,omitempty"`
2525 Repository *jsonRepo `json:"repository,omitempty"`
2626 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"`
2732 }
2833
2934 type jsonRefUpdate struct {
@@ -39,6 +44,11 @@ const (
3944 eventRepoDeleted = "repo_deleted"
4045 eventPushed = "pushed"
4146 eventGap = "gap"
47+
48+ eventMROpened = "merge_request_opened"
49+ eventMRMerged = "merge_request_merged"
50+ eventMRClosed = "merge_request_closed"
51+ eventCommentAdded = "comment_added"
4252 )
4353
4454 // 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) {
6070 for _, u := range k.Pushed.GetRefUpdates() {
6171 out.RefUpdates = append(out.RefUpdates, jsonRefUpdate{Ref: u.GetRef(), OldID: u.GetOldId(), NewID: u.GetNewId()})
6272 }
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
6385 case *ermite.Event_Gap:
6486 out.Type = eventGap
6587 default:
@@ -88,6 +110,10 @@ func eventLine(e jsonEvent) string {
88110 parts = append(parts, refUpdateText(u))
89111 }
90112 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)
91117 case eventGap:
92118 detail = "events were lost, reload what you display"
93119 }
@@ -115,15 +141,19 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
115141 cmd := &cobra.Command{
116142 Use: "watch [repo]",
117143 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" +
119146 "repository you can see, or only for [repo]. It runs until interrupted. When the connection drops it\n" +
120147 "reconnects with a growing delay and resumes after the last event it printed; if the server no longer\n" +
121148 "has that event it prints a gap event: reload what you display. Connection notices go to standard\n" +
122149 "error.\n\n" +
123150 "--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.",
127157 Args: cobra.MaximumNArgs(1),
128158 ValidArgsFunction: completeRepoNames(env),
129159 }
@@ -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 = eventGap86 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) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","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])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
@@ -147,3 +147,55 @@ func TestWatchRejectsTooManyArgs(t *testing.T) {
147147 res := runCLI(t, envFor("http://127.0.0.1:1"), "", "watch", "a", "b")
148148 require.Error(t, res.err)
149149 }
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 {
2626 root.AddCommand(
2727 newServeCmd(), newHookCmd(),
2828 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29+ newMRCmd(env), newSigningKeyCmd(env),
2930 )
3031 return root
3132 }
@@ -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 root31 return root
31 }32 }
M internal/adapters/client/client.go +5 -0
@@ -75,6 +75,11 @@ func (c *Client) Events() ermiteconnect.EventServiceClient {
7575 return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...)
7676 }
7777
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+
7883 // Server returns the ServerService client.
7984 func (c *Client) Server() ermiteconnect.ServerServiceClient {
8085 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) {
138138
139139 func TestMissingFeatures(t *testing.T) {
140140 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+ }))
142147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
143148 }
144149
@@ -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 (
1313 // FeatureEventsResume is EventService.Watch with resume_after, which
1414 // `ermite watch` uses to continue after a reconnection.
1515 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"
1620 )
1721
1822 // UsedFeatures lists the features the CLI uses, in the order they are reported.
1923 func UsedFeatures() []string {
20- return []string{FeatureRepoUpdate, FeatureEventsResume}
24+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey}
2125 }
2226
2327 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -13,11 +13,15 @@ const (
13 // FeatureEventsResume is EventService.Watch with resume_after, which13 // 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.

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close