detached 83e495f

cli_mr_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"encoding/json"
7	"regexp"
8	"strings"
9	"testing"
10
11	"connectrpc.com/connect"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14	"google.golang.org/protobuf/proto"
15
16	"bnema.dev/ermite-api/gen/ermite"
17)
18
19var (
20	headA = strings.Repeat("a", 40)
21	mrOne = &ermite.MergeRequest{
22		Repo: "alpha", Number: 1, Title: "Add search", Description: "Finds things.", SourceBranch: "feat/x", TargetBranch: "main",
23		State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Author: "agent-7",
24		CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), HeadId: headA,
25	}
26)
27
28func TestMRCreateMapsFlags(t *testing.T) {
29	f := &fakeAPI{createMR: func(*ermite.CreateMergeRequestRequest) (*ermite.CreateMergeRequestResponse, error) {
30		return &ermite.CreateMergeRequestResponse{MergeRequest: mrOne}, nil
31	}}
32	url := f.serve(t)
33	res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.",
34		"--source", "refs/heads/feat/x", "--target", " main ").ok(t)
35	assert.Equal(t, "opened alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
36	_, reqs := f.snapshot()
37	got := reqs[0].(*ermite.CreateMergeRequestRequest)
38	assert.Equal(t, "alpha", got.GetRepo())
39	assert.Equal(t, "Add search", got.GetTitle())
40	assert.Equal(t, "Finds things.", got.GetDescription())
41	assert.Equal(t, "feat/x", got.GetSourceBranch(), "a full ref name is shortened")
42	assert.Equal(t, "main", got.GetTargetBranch())
43
44	// The target defaults to nothing, which is the default branch for the server;
45	// the description comes from stdin with -.
46	res = runCLI(t, envFor(url), "line one\nline two\n", "mr", "create", "alpha", "--title", "t", "--source", "feat/x", "--description", "-").ok(t)
47	_, reqs = f.snapshot()
48	got = reqs[1].(*ermite.CreateMergeRequestRequest)
49	assert.Empty(t, got.GetTargetBranch())
50	assert.Equal(t, "line one\nline two", got.GetDescription())
51	_ = res
52
53	res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)
54	assert.JSONEq(t, `{"url":"`+url+`/alpha/merge-requests/1","repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
55
56	for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
57		require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
58	}
59}
60
61func TestMRListTableJSONAndStates(t *testing.T) {
62	merged := &ermite.MergeRequest{
63		Repo: "alpha", Number: 2, Title: "Fix\tthe \x1b[31mbug", SourceBranch: "fix", TargetBranch: "main",
64		State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, Author: "root", UpdatedAt: ts("2026-10-03T00:00:00Z"),
65		MergedCommitId: headA, MergeMode: ermite.MergeMode_MERGE_MODE_SQUASH,
66	}
67	f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
68		return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{merged, mrOne}}, nil
69	}}
70	url := f.serve(t)
71	res := runCLI(t, envFor(url), "", "mr", "list", "alpha", "--state", "all").ok(t)
72	assert.Equal(t, ""+
73		"NUMBER  STATE   BRANCHES        AUTHOR   UPDATED           TITLE\n"+
74		"!2      merged  fix -> main     root     2026-10-03 00:00  Fix the ?[31mbug\n"+
75		"!1      open    feat/x -> main  agent-7  2026-10-02 11:30  Add search\n", res.stdout)
76	assert.NotContains(t, res.stdout, "\x1b", "server text is made safe for a terminal")
77
78	_, reqs := f.snapshot()
79	assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, reqs[0].(*ermite.ListMergeRequestsRequest).GetState(), "all")
80	runCLI(t, envFor(url), "", "mr", "list", "alpha").ok(t)
81	_, reqs = f.snapshot()
82	assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, reqs[1].(*ermite.ListMergeRequestsRequest).GetState(), "open by default")
83	runCLI(t, envFor(url), "", "mr", "list", "alpha", "--state", "closed").ok(t)
84	_, reqs = f.snapshot()
85	assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, reqs[2].(*ermite.ListMergeRequestsRequest).GetState())
86
87	res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t)
88	assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo})
89	assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode)
90	for i, m := range decode[[]mrOut](t, res.stdout) {
91		assert.Equal(t, mrWebURL(url, "alpha", int64(2-i)), m.URL)
92	}
93}
94
95func TestMRListFollowsPages(t *testing.T) {
96	f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
97		if r.GetPageToken() == "" {
98			return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{mrOne}, NextPageToken: "p2"}, nil
99		}
100		m := proto.Clone(mrOne).(*ermite.MergeRequest)
101		m.Number = 7
102		return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{m}}, nil
103	}}
104	res := runCLI(t, envFor(f.serve(t)), "", "mr", "list", "alpha", "--json").ok(t)
105	got := decode[[]mrOut](t, res.stdout)
106	require.Len(t, got, 2)
107	assert.EqualValues(t, 7, got[1].Number)
108}
109
110func TestMRShow(t *testing.T) {
111	other := &ermite.Signature{Name: "Bob", Email: "bob@example.org"}
112	detail := &ermite.GetMergeRequestResponse{
113		MergeRequest: mrOne,
114		Commits:      []*ermite.Commit{{Id: headA, Author: other, Summary: "add search"}},
115		DiffBaseId:   strings.Repeat("b", 40),
116		Stats:        &ermite.DiffStats{FilesChanged: 1, Additions: 2, Deletions: 1},
117		Diff: &ermite.Diff{Files: []*ermite.FileDiff{{
118			OldPath: "a.txt", NewPath: "a.txt", Status: ermite.FileStatus_FILE_STATUS_MODIFIED, Additions: 2, Deletions: 1,
119			Hunks: []*ermite.Hunk{{OldStart: 4, OldLines: 2, NewStart: 4, NewLines: 3, Header: "func f()", Lines: []*ermite.Line{
120				{Kind: ermite.LineKind_LINE_KIND_CONTEXT, OldNumber: 4, NewNumber: 4, Text: "keep"},
121				{Kind: ermite.LineKind_LINE_KIND_DELETION, OldNumber: 5, Text: "old"},
122				{Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 5, Text: "new \x1b[0m"},
123				{Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 6, Text: "more"},
124			}}},
125		}}},
126		Comments: []*ermite.Comment{
127			{Id: "1", Author: "agent-7", Text: "general\nnote", CreatedAt: ts("2026-10-02T09:00:00Z")},
128			{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")},
129		},
130		LastReadId: strings.Repeat("c", 40),
131	}
132	f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return detail, nil }}
133	url := f.serve(t)
134
135	res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "!1", "--diff").ok(t)
136	out := res.stdout
137	assert.Contains(t, out, "merge request  alpha!1")
138	assert.Contains(t, out, "branches       feat/x -> main")
139	assert.Contains(t, out, "commits (1)")
140	assert.Contains(t, out, "changes: 1 file(s), +2 -1, from bbbbbbbbbb")
141	assert.Contains(t, out, "modified +2 -1  a.txt")
142	assert.Contains(t, out, "  #1 agent-7")
143	assert.Contains(t, out, "\n    note\n", "comment lines are indented")
144	assert.Contains(t, out, "a.txt:5 (new) [resolved]")
145	assert.Contains(t, out, "\n          5 +new ?[0m\n", "an addition has a new number only")
146	assert.Contains(t, out, "\n    5       -old\n", "a deletion has an old number only")
147	assert.Contains(t, out, "\n    4     4  keep\n", "a context line has both")
148	assert.NotContains(t, out, "\x1b")
149	assert.Empty(t, res.stderr)
150	_, reqs := f.snapshot()
151	assert.EqualValues(t, 1, reqs[0].(*ermite.GetMergeRequestRequest).GetNumber())
152	assert.False(t, reqs[0].(*ermite.GetMergeRequestRequest).GetSinceLastRead())
153
154	// Without --diff the lines are left out of the human and the JSON output.
155	res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
156	assert.NotContains(t, res.stdout, "+new")
157	js := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
158	assert.NotContains(t, js.stdout, `"hunks"`)
159	js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t)
160	show := decode[showOut](t, js.stdout)
161	assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, js.stdout).URL)
162	require.Len(t, show.Files, 1)
163	require.Len(t, show.Files[0].Hunks, 1)
164	assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind)
165	assert.Equal(t, 5, show.Files[0].Hunks[0].Lines[2].NewNumber)
166	require.Len(t, show.Comments, 2)
167	assert.Equal(t, "new", show.Comments[1].Side)
168	assert.Empty(t, show.Comments[0].Side)
169	assert.Equal(t, strings.Repeat("c", 40), show.LastReadID)
170
171	// --since-last-read asks for it, and says when the head is lost.
172	detail.LastReadLost = true
173	res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--since-last-read").ok(t)
174	_, reqs = f.snapshot()
175	assert.True(t, reqs[len(reqs)-1].(*ermite.GetMergeRequestRequest).GetSinceLastRead())
176	assert.Contains(t, res.stderr, "head you last read is gone")
177}
178
179// The mergeability of an open request is a line of `mr show` and a field of
180// its JSON.
181func TestMRShowMergeability(t *testing.T) {
182	withState := func(mg *ermite.Mergeability) *ermite.GetMergeRequestResponse {
183		m := proto.Clone(mrOne).(*ermite.MergeRequest)
184		m.Mergeability = mg
185		return &ermite.GetMergeRequestResponse{MergeRequest: m}
186	}
187	var cur *ermite.GetMergeRequestResponse
188	f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return cur, nil }}
189	url := f.serve(t)
190	for _, tt := range []struct {
191		mg   *ermite.Mergeability
192		line string
193	}{
194		{&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE}, "up to date"},
195		{&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 1000, BehindCapped: true}, "behind by 1000+"},
196		{&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b/c.go"}, ConflictPathsTruncated: true}, "conflict (a.txt, b/c.go, ...)"},
197		{&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING}, "checking"},
198	} {
199		cur = withState(tt.mg)
200		res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
201		assert.Regexp(t, `(?m)^mergeability\s+.*`+regexp.QuoteMeta(tt.line), res.stdout)
202	}
203	cur = withState(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2, TargetId: headA})
204	res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
205	var got struct {
206		Mergeability *struct {
207			State    string `json:"state"`
208			Behind   int    `json:"behind"`
209			TargetID string `json:"target_id"`
210		} `json:"mergeability"`
211	}
212	require.NoError(t, json.Unmarshal([]byte(res.stdout), &got))
213	require.NotNil(t, got.Mergeability)
214	assert.Equal(t, "behind", got.Mergeability.State)
215	assert.Equal(t, 2, got.Mergeability.Behind)
216	assert.Equal(t, headA, got.Mergeability.TargetID)
217
218	// No mergeability (a merged request, or an older server): no line, null.
219	cur = withState(nil)
220	res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
221	assert.NotContains(t, res.stdout, "mergeability")
222	res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
223	assert.Regexp(t, `"mergeability":\s*null`, res.stdout)
224}
225
226func TestMRShowMarkRead(t *testing.T) {
227	f := &fakeAPI{
228		getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
229			return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil
230		},
231		markRead: func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) {
232			return &ermite.MarkReadResponse{}, nil
233		},
234	}
235	url := f.serve(t)
236	runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
237	calls, _ := f.snapshot()
238	assert.Equal(t, []string{"GetMergeRequest"}, calls, "reading does not mark read")
239	runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--mark-read").ok(t)
240	calls, reqs := f.snapshot()
241	assert.Equal(t, []string{"GetMergeRequest", "GetMergeRequest", "MarkRead"}, calls)
242	assert.Equal(t, headA, reqs[2].(*ermite.MarkReadRequest).GetHeadId(), "marks the head that was shown")
243}
244
245func TestMRCommentMapsFlags(t *testing.T) {
246	f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
247		return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "3", Path: r.GetPath(), Line: r.GetLine(), Side: r.GetSide(), Text: r.GetText()}}, nil
248	}}
249	url := f.serve(t)
250
251	res := runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "hello").ok(t)
252	assert.Equal(t, "comment 3 added to alpha!1\n", res.stdout)
253	runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "on a line", "--path", "a.txt", "--line", "5").ok(t)
254	runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "old side", "--path", "a.txt", "--line", "5", "--side", "OLD").ok(t)
255	runCLI(t, envFor(url), "text\n", "mr", "comment", "alpha", "!1", "-m", "-").ok(t)
256	_, reqs := f.snapshot()
257	general := reqs[0].(*ermite.AddCommentRequest)
258	assert.Empty(t, general.GetPath())
259	assert.Zero(t, general.GetLine())
260	assert.Equal(t, ermite.DiffSide_DIFF_SIDE_UNSPECIFIED, general.GetSide(), "a general comment has no side, though --side defaults to new")
261	onNew := reqs[1].(*ermite.AddCommentRequest)
262	assert.Equal(t, "a.txt", onNew.GetPath())
263	assert.EqualValues(t, 5, onNew.GetLine())
264	assert.Equal(t, ermite.DiffSide_DIFF_SIDE_NEW, onNew.GetSide(), "new is the default side")
265	assert.Equal(t, ermite.DiffSide_DIFF_SIDE_OLD, reqs[2].(*ermite.AddCommentRequest).GetSide())
266	assert.Equal(t, "text", reqs[3].(*ermite.AddCommentRequest).GetText())
267	assert.EqualValues(t, 1, reqs[3].(*ermite.AddCommentRequest).GetNumber())
268
269	before, _ := f.snapshot()
270	for _, args := range [][]string{
271		{"mr", "comment", "alpha", "1"},                                                            // no -m
272		{"mr", "comment", "alpha", "x", "-m", "t"},                                                 // bad number
273		{"mr", "comment", "alpha", "0", "-m", "t"},                                                 // bad number
274		{"mr", "comment", "alpha", "1", "-m", "t", "--path", "a"},                                  // no line
275		{"mr", "comment", "alpha", "1", "-m", "t", "--line", "3"},                                  // no path
276		{"mr", "comment", "alpha", "1", "-m", "t", "--side", "old"},                                // no path
277		{"mr", "comment", "alpha", "1", "-m", "t", "--path", "a", "--line", "1", "--side", "left"}, // bad side
278	} {
279		require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
280	}
281	after, _ := f.snapshot()
282	assert.Equal(t, before, after, "nothing reaches the server")
283}
284
285func TestMRResolveMergeAndClose(t *testing.T) {
286	merged := proto.Clone(mrOne).(*ermite.MergeRequest)
287	merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED
288	merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH
289	closed := proto.Clone(mrOne).(*ermite.MergeRequest)
290	closed.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED
291	f := &fakeAPI{
292		resolveCmt: func(r *ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error) {
293			return &ermite.ResolveCommentResponse{Comment: &ermite.Comment{Id: r.GetCommentId(), Resolved: r.GetResolved()}}, nil
294		},
295		getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
296			return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil
297		},
298		mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
299			return &ermite.MergeMergeRequestResponse{MergeRequest: merged}, nil
300		},
301		closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {
302			return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil
303		},
304		getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) {
305			return &ermite.GetSigningKeyResponse{Fingerprint: "SHA256:abc"}, nil
306		},
307		getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
308			return &ermite.GetVersionResponse{Version: "test", Features: []string{"merge_requests.mergeability"}}, nil
309		},
310	}
311	url := f.serve(t)
312
313	res := runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "#4").ok(t)
314	assert.Equal(t, "comment 4 of alpha!1 resolved\n", res.stdout)
315	res = runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "4", "--undo").ok(t)
316	assert.Equal(t, "comment 4 of alpha!1 reopened\n", res.stdout)
317	_, reqs := f.snapshot()
318	assert.Equal(t, "4", reqs[0].(*ermite.ResolveCommentRequest).GetCommentId(), "a leading # is dropped")
319	assert.True(t, reqs[0].(*ermite.ResolveCommentRequest).GetResolved())
320	assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())
321
322	// Merge with an explicit head and mode.
323	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t)
324	assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n"+
325		"the commit is signed by the server's key SHA256:abc; `ermite signing-key show` prints the key to verify it\n", res.stdout)
326	assert.Empty(t, res.stderr)
327	calls, reqs := f.snapshot()
328	assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest", "GetSigningKey"}, calls, "an explicit head needs no read first; the signing key is for the note")
329	m := reqs[2].(*ermite.MergeMergeRequestRequest)
330	assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())
331	assert.Equal(t, headA, m.GetHeadId())
332	assert.EqualValues(t, 1, m.GetNumber())
333
334	// A merge commit, and a mode that does not exist.
335	merged.MergeMode = ermite.MergeMode_MERGE_MODE_MERGE_COMMIT
336	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "merge", "--head", headA).ok(t)
337	assert.Contains(t, res.stdout, "merged alpha!1 into main (merge) at dddddddddd\n")
338	assert.Contains(t, res.stdout, "signed by the server's key SHA256:abc")
339	_, reqs = f.snapshot()
340	var lastMerge *ermite.MergeMergeRequestRequest
341	for _, r := range reqs {
342		if mr, ok := r.(*ermite.MergeMergeRequestRequest); ok {
343			lastMerge = mr
344		}
345	}
346	require.NotNil(t, lastMerge)
347	assert.Equal(t, ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, lastMerge.GetMode())
348	bad := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "rebase", "--head", headA)
349	require.Error(t, bad.err)
350	assert.Contains(t, bad.err.Error(), "invalid merge mode")
351	assert.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).err, "--squash is gone")
352	merged.MergeMode = ermite.MergeMode_MERGE_MODE_SQUASH
353
354	// The default mode is a fast-forward.
355	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
356	assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
357	assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
358	calls, reqs = f.snapshot()
359	assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
360	m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
361	assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, m.GetMode(), "fast-forward by default")
362	assert.Equal(t, headA, m.GetHeadId())
363
364	res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t)
365	assert.Equal(t, "closed alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
366	res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t)
367	assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
368	assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
369}
370
371// ADR 0017: the merge is of the commit that was reviewed, so --head is
372// required, and the CLI does not fill it in from the live head.
373func TestMRMergeNeedsTheHead(t *testing.T) {
374	f := &fakeAPI{}
375	url := f.serve(t)
376	for _, args := range [][]string{
377		{"mr", "merge", "alpha", "1"},
378		{"mr", "merge", "alpha", "1", "--mode", "squash"},
379		{"mr", "merge", "alpha", "1", "--head", "  "},
380	} {
381		res := runCLI(t, envFor(url), "", args...)
382		require.Error(t, res.err, args)
383		assert.Contains(t, res.err.Error(), "--head is required")
384		assert.Contains(t, res.err.Error(), "ermite mr show alpha 1", "tells where to get the head")
385	}
386	calls, _ := f.snapshot()
387	assert.Empty(t, calls, "nothing reaches the server")
388
389	res := runCLI(t, envFor(url), "", "mr", "merge", "--help").ok(t)
390	assert.Contains(t, res.stdout, "(required)")
391	assert.NotContains(t, res.stdout, "default: the current head")
392}
393
394func TestMRMergeDeleteBranch(t *testing.T) {
395	merged := proto.Clone(mrOne).(*ermite.MergeRequest)
396	merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED
397	merged.MergedCommitId, merged.MergeMode = headA, ermite.MergeMode_MERGE_MODE_FAST_FORWARD
398	deleted := true
399	f := &fakeAPI{mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
400		return &ermite.MergeMergeRequestResponse{MergeRequest: merged, SourceBranchDeleted: deleted}, nil
401	}}
402	url := f.serve(t)
403
404	res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
405	assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleting branch feat/x (done by the server after the merge)\n", res.stdout)
406	assert.Empty(t, res.stderr)
407	_, reqs := f.snapshot()
408	assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
409
410	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
411	assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
412
413	// Deletion is asynchronous: the CLI makes no claim when the server did not
414	// request it.
415	deleted = false
416	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
417	assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
418
419	// Without the flag nothing is asked and nothing is said.
420	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
421	assert.Empty(t, res.stderr)
422	_, reqs = f.snapshot()
423	assert.False(t, reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
424}
425
426func TestMRErrorsAreFriendly(t *testing.T) {
427	f := &fakeAPI{
428		listMR: func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
429			return nil, connect.NewError(connect.CodeUnimplemented, nil)
430		},
431		mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
432			return nil, connect.NewError(connect.CodeFailedPrecondition, assert.AnError)
433		},
434	}
435	url := f.serve(t)
436	res := runCLI(t, envFor(url), "", "mr", "list", "alpha")
437	require.Error(t, res.err)
438	assert.Contains(t, res.err.Error(), "does not implement")
439	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA)
440	require.Error(t, res.err)
441	assert.Contains(t, res.err.Error(), "precondition failed")
442}
443
444func TestSigningKeyShowOutput(t *testing.T) {
445	key := &ermite.GetSigningKeyResponse{
446		PublicKey: "ssh-ed25519 AAAAC3Nz", Fingerprint: "SHA256:abc", CommitterName: "ermite", CommitterEmail: "ermite@git.example.org",
447		AllowedSigners: "ermite@git.example.org ssh-ed25519 AAAAC3Nz",
448	}
449	f := &fakeAPI{getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) { return key, nil }}
450	url := f.serve(t)
451
452	res := runCLI(t, map[string]string{"ERMITE_URL": url}, "", "signing-key", "show").ok(t)
453	assert.Equal(t, ""+
454		"fingerprint      SHA256:abc\n"+
455		"committer        ermite <ermite@git.example.org>\n"+
456		"public key       ssh-ed25519 AAAAC3Nz\n"+
457		"allowed signers  ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout)
458	assert.Empty(t, f.header[0].Values("Authorization"), "no token is needed")
459	res = runCLI(t, envFor(url), "", "signing-key", "show", "--public-key").ok(t)
460	assert.Equal(t, "ssh-ed25519 AAAAC3Nz\n", res.stdout)
461	res = runCLI(t, envFor(url), "", "signing-key", "show", "--allowed-signers").ok(t)
462	assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout)
463	res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)
464	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)
465}
466
467func TestNoteRangesAndIDs(t *testing.T) {
468	for _, tc := range []struct {
469		input      string
470		start, end int32
471	}{{"1", 0, 1}, {"10-24", 10, 24}, {"2147483647", 0, 2147483647}} {
472		start, end, err := parseLineRange(tc.input)
473		require.NoError(t, err)
474		assert.Equal(t, tc.start, start)
475		assert.Equal(t, tc.end, end)
476	}
477	for _, input := range []string{"", "0", "-1", "1-1", "24-10", "0-2", "1-2-3", "2147483648", "a", "1-"} {
478		_, _, err := parseLineRange(input)
479		require.Error(t, err, input)
480	}
481	for _, input := range []string{"12", "^12", "#12"} {
482		got, err := parseNoteID(input)
483		require.NoError(t, err)
484		assert.Equal(t, "12", got)
485	}
486}
487
488func TestNoteFlags(t *testing.T) {
489	f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
490		return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "1", ReplyTo: r.GetReplyTo(), StartLine: r.GetStartLine(), Line: r.GetLine(), Verdict: r.GetVerdict()}}, nil
491	}}
492	url := f.serve(t)
493	for _, tc := range []struct {
494		flags   []string
495		reply   string
496		start   int32
497		verdict string
498	}{
499		{[]string{"--reply", "^12", "-m", "reply"}, "12", 0, ""},
500		{[]string{"--path", "x", "--line", "10-24", "-m", "range"}, "", 10, ""},
501		{[]string{"--approve"}, "", 0, "approve"},
502		{[]string{"--request-changes"}, "", 0, "changes_requested"},
503	} {
504		res := runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "--json"}, tc.flags...)...).ok(t)
505		got := decode[jsonComment](t, res.stdout)
506		assert.Equal(t, tc.reply, got.ReplyTo)
507		assert.Equal(t, tc.start, got.StartLine)
508		assert.Equal(t, tc.verdict, got.Verdict)
509	}
510	for _, flags := range [][]string{{"--approve", "--request-changes"}, {"--reply", "1", "--path", "x"}, {"--reply", "1", "--line", "2"}, {"--reply", "1", "--side", "new"}, {"--reply", "1", "--approve"}, {"--reply", "1", "--request-changes"}} {
511		require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "-m", "x"}, flags...)...).err)
512	}
513}
514
515func TestNoteDetailOutput(t *testing.T) {
516	d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Comments: []*ermite.Comment{
517		{Id: "1", Path: "x", StartLine: 10, Line: 24, Text: "root"},
518		{Id: "3", Text: "other", Deleted: true},
519		{Id: "2", ReplyTo: "1", Text: "reply", EditedAt: ts("2026-10-10T12:00:00Z")},
520	}, Verdicts: []*ermite.AuthorVerdict{
521		{Author: "owner", Verdict: ermite.Verdict_VERDICT_APPROVE, CommentId: "1", Stale: true},
522		{Author: "bot", Verdict: ermite.Verdict_VERDICT_CHANGES_REQUESTED, CommentId: "3"},
523	}}
524	var out strings.Builder
525	require.NoError(t, printMRDetail(&out, d, false, false))
526	text := out.String()
527	assert.Contains(t, text, "owner approved (stale), bot changes requested")
528	assert.Contains(t, text, "lines 10-24")
529	assert.Contains(t, text, "(edited)")
530	assert.Contains(t, text, "[deleted]")
531	assert.Less(t, strings.Index(text, "      #2"), strings.Index(text, "  #3"))
532	got := toJSONDetail(d, false, "https://git.example.org")
533	require.Len(t, got.Verdicts, 2)
534	assert.True(t, got.Verdicts[0].Stale)
535	assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict)
536	assert.Equal(t, "1", got.Comments[2].ReplyTo)
537	assert.NotNil(t, got.Comments[2].EditedAt)
538}
539
540func TestUnknownVerdictDetailOutput(t *testing.T) {
541	var out strings.Builder
542	require.NoError(t, printMRDetail(&out, &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Verdicts: []*ermite.AuthorVerdict{{Author: "future", Verdict: ermite.Verdict(99)}}}, false, false))
543	assert.Contains(t, out.String(), "future verdict 99")
544	assert.NotContains(t, out.String(), "changes requested")
545	assert.NotContains(t, out.String(), "approved")
546}
547
548func TestMRReviewScopeCompletion(t *testing.T) {
549	cmd := newTokenCreateCmd(&cliEnv{})
550	complete, ok := cmd.GetFlagCompletionFunc("scope")
551	require.True(t, ok)
552	values, _ := complete(cmd, nil, "")
553	assert.Contains(t, values, "mr:review")
554}
555
556func TestMRWebURL(t *testing.T) {
557	for _, tt := range []struct{ name, base, repo, want string }{
558		{"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"},
559		{"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"},
560		{"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"},
561		{"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"},
562	} {
563		t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
564	}
565}
566
567func TestJSONFileOmitted(t *testing.T) {
568	d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Diff: &ermite.Diff{Files: []*ermite.FileDiff{
569		{NewPath: "a"},
570		{NewPath: "go.sum", Omitted: ermite.FileOmission_FILE_OMISSION_GENERATED, Truncated: true},
571		{NewPath: "b", Omitted: ermite.FileOmission_FILE_OMISSION_UNAVAILABLE, Truncated: true},
572	}}}
573	got := toJSONDetail(d, false, "https://git.example.org")
574	assert.Equal(t, []string{"", "generated", "unavailable"}, []string{got.Files[0].Omitted, got.Files[1].Omitted, got.Files[2].Omitted})
575	raw, err := json.Marshal(got.Files)
576	require.NoError(t, err)
577	assert.Contains(t, string(raw), `"omitted":"generated"`)
578	assert.Contains(t, string(raw), `"truncated":true`)
579	assert.Equal(t, 2, strings.Count(string(raw), `"omitted"`))
580}

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 4

Tags 45

↑↓ moveenter openesc close