1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
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
17var (
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
26func 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"+url+"/alpha/merge-requests/1\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, `{"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)
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
59func 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 for i, m := range decode[[]mrOut](t, res.stdout) {
89 assert.Equal(t, mrWebURL(url, "alpha", int64(2-i)), m.URL)
90 }
91}
92
93func TestMRListFollowsPages(t *testing.T) {
94 f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
95 if r.GetPageToken() == "" {
96 return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{mrOne}, NextPageToken: "p2"}, nil
97 }
98 m := proto.Clone(mrOne).(*ermite.MergeRequest)
99 m.Number = 7
100 return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{m}}, nil
101 }}
102 res := runCLI(t, envFor(f.serve(t)), "", "mr", "list", "alpha", "--json").ok(t)
103 got := decode[[]mrOut](t, res.stdout)
104 require.Len(t, got, 2)
105 assert.EqualValues(t, 7, got[1].Number)
106}
107
108func TestMRShow(t *testing.T) {
109 other := &ermite.Signature{Name: "Bob", Email: "bob@example.org"}
110 detail := &ermite.GetMergeRequestResponse{
111 MergeRequest: mrOne,
112 Commits: []*ermite.Commit{{Id: headA, Author: other, Summary: "add search"}},
113 DiffBaseId: strings.Repeat("b", 40),
114 Stats: &ermite.DiffStats{FilesChanged: 1, Additions: 2, Deletions: 1},
115 Diff: &ermite.Diff{Files: []*ermite.FileDiff{{
116 OldPath: "a.txt", NewPath: "a.txt", Status: ermite.FileStatus_FILE_STATUS_MODIFIED, Additions: 2, Deletions: 1,
117 Hunks: []*ermite.Hunk{{OldStart: 4, OldLines: 2, NewStart: 4, NewLines: 3, Header: "func f()", Lines: []*ermite.Line{
118 {Kind: ermite.LineKind_LINE_KIND_CONTEXT, OldNumber: 4, NewNumber: 4, Text: "keep"},
119 {Kind: ermite.LineKind_LINE_KIND_DELETION, OldNumber: 5, Text: "old"},
120 {Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 5, Text: "new \x1b[0m"},
121 {Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 6, Text: "more"},
122 }}},
123 }}},
124 Comments: []*ermite.Comment{
125 {Id: "1", Author: "agent-7", Text: "general\nnote", CreatedAt: ts("2026-10-02T09:00:00Z")},
126 {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")},
127 },
128 LastReadId: strings.Repeat("c", 40),
129 }
130 f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return detail, nil }}
131 url := f.serve(t)
132
133 res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "!1", "--diff").ok(t)
134 out := res.stdout
135 assert.Contains(t, out, "merge request alpha!1")
136 assert.Contains(t, out, "branches feat/x -> main")
137 assert.Contains(t, out, "commits (1)")
138 assert.Contains(t, out, "changes: 1 file(s), +2 -1, from bbbbbbbbbb")
139 assert.Contains(t, out, "modified +2 -1 a.txt")
140 assert.Contains(t, out, " #1 agent-7")
141 assert.Contains(t, out, "\n note\n", "comment lines are indented")
142 assert.Contains(t, out, "a.txt:5 (new) [resolved]")
143 assert.Contains(t, out, "\n 5 +new ?[0m\n", "an addition has a new number only")
144 assert.Contains(t, out, "\n 5 -old\n", "a deletion has an old number only")
145 assert.Contains(t, out, "\n 4 4 keep\n", "a context line has both")
146 assert.NotContains(t, out, "\x1b")
147 assert.Empty(t, res.stderr)
148 _, reqs := f.snapshot()
149 assert.EqualValues(t, 1, reqs[0].(*ermite.GetMergeRequestRequest).GetNumber())
150 assert.False(t, reqs[0].(*ermite.GetMergeRequestRequest).GetSinceLastRead())
151
152 // Without --diff the lines are left out of the human and the JSON output.
153 res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
154 assert.NotContains(t, res.stdout, "+new")
155 js := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
156 assert.NotContains(t, js.stdout, `"hunks"`)
157 js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t)
158 show := decode[showOut](t, js.stdout)
159 assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, js.stdout).URL)
160 require.Len(t, show.Files, 1)
161 require.Len(t, show.Files[0].Hunks, 1)
162 assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind)
163 assert.Equal(t, 5, show.Files[0].Hunks[0].Lines[2].NewNumber)
164 require.Len(t, show.Comments, 2)
165 assert.Equal(t, "new", show.Comments[1].Side)
166 assert.Empty(t, show.Comments[0].Side)
167 assert.Equal(t, strings.Repeat("c", 40), show.LastReadID)
168
169 // --since-last-read asks for it, and says when the head is lost.
170 detail.LastReadLost = true
171 res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--since-last-read").ok(t)
172 _, reqs = f.snapshot()
173 assert.True(t, reqs[len(reqs)-1].(*ermite.GetMergeRequestRequest).GetSinceLastRead())
174 assert.Contains(t, res.stderr, "head you last read is gone")
175}
176
177func TestMRShowMarkRead(t *testing.T) {
178 f := &fakeAPI{
179 getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
180 return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil
181 },
182 markRead: func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) {
183 return &ermite.MarkReadResponse{}, nil
184 },
185 }
186 url := f.serve(t)
187 runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
188 calls, _ := f.snapshot()
189 assert.Equal(t, []string{"GetMergeRequest"}, calls, "reading does not mark read")
190 runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--mark-read").ok(t)
191 calls, reqs := f.snapshot()
192 assert.Equal(t, []string{"GetMergeRequest", "GetMergeRequest", "MarkRead"}, calls)
193 assert.Equal(t, headA, reqs[2].(*ermite.MarkReadRequest).GetHeadId(), "marks the head that was shown")
194}
195
196func TestMRCommentMapsFlags(t *testing.T) {
197 f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
198 return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "3", Path: r.GetPath(), Line: r.GetLine(), Side: r.GetSide(), Text: r.GetText()}}, nil
199 }}
200 url := f.serve(t)
201
202 res := runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "hello").ok(t)
203 assert.Equal(t, "comment 3 added to alpha!1\n", res.stdout)
204 runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "on a line", "--path", "a.txt", "--line", "5").ok(t)
205 runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "old side", "--path", "a.txt", "--line", "5", "--side", "OLD").ok(t)
206 runCLI(t, envFor(url), "text\n", "mr", "comment", "alpha", "!1", "-m", "-").ok(t)
207 _, reqs := f.snapshot()
208 general := reqs[0].(*ermite.AddCommentRequest)
209 assert.Empty(t, general.GetPath())
210 assert.Zero(t, general.GetLine())
211 assert.Equal(t, ermite.DiffSide_DIFF_SIDE_UNSPECIFIED, general.GetSide(), "a general comment has no side, though --side defaults to new")
212 onNew := reqs[1].(*ermite.AddCommentRequest)
213 assert.Equal(t, "a.txt", onNew.GetPath())
214 assert.EqualValues(t, 5, onNew.GetLine())
215 assert.Equal(t, ermite.DiffSide_DIFF_SIDE_NEW, onNew.GetSide(), "new is the default side")
216 assert.Equal(t, ermite.DiffSide_DIFF_SIDE_OLD, reqs[2].(*ermite.AddCommentRequest).GetSide())
217 assert.Equal(t, "text", reqs[3].(*ermite.AddCommentRequest).GetText())
218 assert.EqualValues(t, 1, reqs[3].(*ermite.AddCommentRequest).GetNumber())
219
220 before, _ := f.snapshot()
221 for _, args := range [][]string{
222 {"mr", "comment", "alpha", "1"}, // no -m
223 {"mr", "comment", "alpha", "x", "-m", "t"}, // bad number
224 {"mr", "comment", "alpha", "0", "-m", "t"}, // bad number
225 {"mr", "comment", "alpha", "1", "-m", "t", "--path", "a"}, // no line
226 {"mr", "comment", "alpha", "1", "-m", "t", "--line", "3"}, // no path
227 {"mr", "comment", "alpha", "1", "-m", "t", "--side", "old"}, // no path
228 {"mr", "comment", "alpha", "1", "-m", "t", "--path", "a", "--line", "1", "--side", "left"}, // bad side
229 } {
230 require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
231 }
232 after, _ := f.snapshot()
233 assert.Equal(t, before, after, "nothing reaches the server")
234}
235
236func TestMRResolveMergeAndClose(t *testing.T) {
237 merged := proto.Clone(mrOne).(*ermite.MergeRequest)
238 merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED
239 merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH
240 closed := proto.Clone(mrOne).(*ermite.MergeRequest)
241 closed.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED
242 f := &fakeAPI{
243 resolveCmt: func(r *ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error) {
244 return &ermite.ResolveCommentResponse{Comment: &ermite.Comment{Id: r.GetCommentId(), Resolved: r.GetResolved()}}, nil
245 },
246 getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
247 return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil
248 },
249 mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
250 return &ermite.MergeMergeRequestResponse{MergeRequest: merged}, nil
251 },
252 closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {
253 return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil
254 },
255 }
256 url := f.serve(t)
257
258 res := runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "#4").ok(t)
259 assert.Equal(t, "comment 4 of alpha!1 resolved\n", res.stdout)
260 res = runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "4", "--undo").ok(t)
261 assert.Equal(t, "comment 4 of alpha!1 reopened\n", res.stdout)
262 _, reqs := f.snapshot()
263 assert.Equal(t, "4", reqs[0].(*ermite.ResolveCommentRequest).GetCommentId(), "a leading # is dropped")
264 assert.True(t, reqs[0].(*ermite.ResolveCommentRequest).GetResolved())
265 assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())
266
267 // Merge with an explicit head and mode.
268 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t)
269 assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout)
270 assert.Empty(t, res.stderr)
271 calls, reqs := f.snapshot()
272 assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first")
273 m := reqs[2].(*ermite.MergeMergeRequestRequest)
274 assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())
275 assert.Equal(t, headA, m.GetHeadId())
276 assert.EqualValues(t, 1, m.GetNumber())
277
278 // The default mode is a fast-forward.
279 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
280 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
281 assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
282 calls, reqs = f.snapshot()
283 assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
284 m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
285 assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, m.GetMode(), "fast-forward by default")
286 assert.Equal(t, headA, m.GetHeadId())
287
288 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t)
289 assert.Equal(t, "closed alpha!1 (feat/x into main)\n"+url+"/alpha/merge-requests/1\n", res.stdout)
290 res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t)
291 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
292 assert.Equal(t, url+"/alpha/merge-requests/1", decode[mrOut](t, res.stdout).URL)
293}
294
295// ADR 0017: the merge is of the commit that was reviewed, so --head is
296// required, and the CLI does not fill it in from the live head.
297func TestMRMergeNeedsTheHead(t *testing.T) {
298 f := &fakeAPI{}
299 url := f.serve(t)
300 for _, args := range [][]string{
301 {"mr", "merge", "alpha", "1"},
302 {"mr", "merge", "alpha", "1", "--squash"},
303 {"mr", "merge", "alpha", "1", "--head", " "},
304 } {
305 res := runCLI(t, envFor(url), "", args...)
306 require.Error(t, res.err, args)
307 assert.Contains(t, res.err.Error(), "--head is required")
308 assert.Contains(t, res.err.Error(), "ermite mr show alpha 1", "tells where to get the head")
309 }
310 calls, _ := f.snapshot()
311 assert.Empty(t, calls, "nothing reaches the server")
312
313 res := runCLI(t, envFor(url), "", "mr", "merge", "--help").ok(t)
314 assert.Contains(t, res.stdout, "(required)")
315 assert.NotContains(t, res.stdout, "default: the current head")
316}
317
318func TestMRMergeDeleteBranch(t *testing.T) {
319 merged := proto.Clone(mrOne).(*ermite.MergeRequest)
320 merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED
321 merged.MergedCommitId, merged.MergeMode = headA, ermite.MergeMode_MERGE_MODE_FAST_FORWARD
322 deleted := true
323 f := &fakeAPI{mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
324 return &ermite.MergeMergeRequestResponse{MergeRequest: merged, SourceBranchDeleted: deleted}, nil
325 }}
326 url := f.serve(t)
327
328 res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
329 assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout)
330 assert.Empty(t, res.stderr)
331 _, reqs := f.snapshot()
332 assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
333
334 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
335 assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
336
337 // The merge stands when the branch is kept: a warning, no failure.
338 deleted = false
339 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
340 assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
341 assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept")
342
343 // Without the flag nothing is asked and nothing is said.
344 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
345 assert.Empty(t, res.stderr)
346 _, reqs = f.snapshot()
347 assert.False(t, reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
348}
349
350func TestMRErrorsAreFriendly(t *testing.T) {
351 f := &fakeAPI{
352 listMR: func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
353 return nil, connect.NewError(connect.CodeUnimplemented, nil)
354 },
355 mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
356 return nil, connect.NewError(connect.CodeFailedPrecondition, assert.AnError)
357 },
358 }
359 url := f.serve(t)
360 res := runCLI(t, envFor(url), "", "mr", "list", "alpha")
361 require.Error(t, res.err)
362 assert.Contains(t, res.err.Error(), "does not implement")
363 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA)
364 require.Error(t, res.err)
365 assert.Contains(t, res.err.Error(), "precondition failed")
366}
367
368func TestSigningKeyShowOutput(t *testing.T) {
369 key := &ermite.GetSigningKeyResponse{
370 PublicKey: "ssh-ed25519 AAAAC3Nz", Fingerprint: "SHA256:abc", CommitterName: "ermite", CommitterEmail: "ermite@git.example.org",
371 AllowedSigners: "ermite@git.example.org ssh-ed25519 AAAAC3Nz",
372 }
373 f := &fakeAPI{getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) { return key, nil }}
374 url := f.serve(t)
375
376 res := runCLI(t, map[string]string{"ERMITE_URL": url}, "", "signing-key", "show").ok(t)
377 assert.Equal(t, ""+
378 "fingerprint SHA256:abc\n"+
379 "committer ermite <ermite@git.example.org>\n"+
380 "public key ssh-ed25519 AAAAC3Nz\n"+
381 "allowed signers ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout)
382 assert.Empty(t, f.header[0].Values("Authorization"), "no token is needed")
383 res = runCLI(t, envFor(url), "", "signing-key", "show", "--public-key").ok(t)
384 assert.Equal(t, "ssh-ed25519 AAAAC3Nz\n", res.stdout)
385 res = runCLI(t, envFor(url), "", "signing-key", "show", "--allowed-signers").ok(t)
386 assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout)
387 res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)
388 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)
389}
390
391func TestNoteRangesAndIDs(t *testing.T) {
392 for _, tc := range []struct {
393 input string
394 start, end int32
395 }{{"1", 0, 1}, {"10-24", 10, 24}, {"2147483647", 0, 2147483647}} {
396 start, end, err := parseLineRange(tc.input)
397 require.NoError(t, err)
398 assert.Equal(t, tc.start, start)
399 assert.Equal(t, tc.end, end)
400 }
401 for _, input := range []string{"", "0", "-1", "1-1", "24-10", "0-2", "1-2-3", "2147483648", "a", "1-"} {
402 _, _, err := parseLineRange(input)
403 require.Error(t, err, input)
404 }
405 for _, input := range []string{"12", "^12", "#12"} {
406 got, err := parseNoteID(input)
407 require.NoError(t, err)
408 assert.Equal(t, "12", got)
409 }
410}
411
412func TestNoteFlags(t *testing.T) {
413 f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
414 return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "1", ReplyTo: r.GetReplyTo(), StartLine: r.GetStartLine(), Line: r.GetLine(), Verdict: r.GetVerdict()}}, nil
415 }}
416 url := f.serve(t)
417 for _, tc := range []struct {
418 flags []string
419 reply string
420 start int32
421 verdict string
422 }{
423 {[]string{"--reply", "^12", "-m", "reply"}, "12", 0, ""},
424 {[]string{"--path", "x", "--line", "10-24", "-m", "range"}, "", 10, ""},
425 {[]string{"--approve"}, "", 0, "approve"},
426 {[]string{"--request-changes"}, "", 0, "changes_requested"},
427 } {
428 res := runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "--json"}, tc.flags...)...).ok(t)
429 got := decode[jsonComment](t, res.stdout)
430 assert.Equal(t, tc.reply, got.ReplyTo)
431 assert.Equal(t, tc.start, got.StartLine)
432 assert.Equal(t, tc.verdict, got.Verdict)
433 }
434 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"}} {
435 require.Error(t, runCLI(t, envFor(url), "", append([]string{"mr", "comment", "alpha", "1", "-m", "x"}, flags...)...).err)
436 }
437}
438
439func TestNoteDetailOutput(t *testing.T) {
440 d := &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Comments: []*ermite.Comment{
441 {Id: "1", Path: "x", StartLine: 10, Line: 24, Text: "root"},
442 {Id: "3", Text: "other", Deleted: true},
443 {Id: "2", ReplyTo: "1", Text: "reply", EditedAt: ts("2026-10-10T12:00:00Z")},
444 }, Verdicts: []*ermite.AuthorVerdict{
445 {Author: "owner", Verdict: ermite.Verdict_VERDICT_APPROVE, CommentId: "1", Stale: true},
446 {Author: "bot", Verdict: ermite.Verdict_VERDICT_CHANGES_REQUESTED, CommentId: "3"},
447 }}
448 var out strings.Builder
449 require.NoError(t, printMRDetail(&out, d, false, false))
450 text := out.String()
451 assert.Contains(t, text, "owner approved (stale), bot changes requested")
452 assert.Contains(t, text, "lines 10-24")
453 assert.Contains(t, text, "(edited)")
454 assert.Contains(t, text, "[deleted]")
455 assert.Less(t, strings.Index(text, " #2"), strings.Index(text, " #3"))
456 got := toJSONDetail(d, false, "https://git.example.org")
457 require.Len(t, got.Verdicts, 2)
458 assert.True(t, got.Verdicts[0].Stale)
459 assert.Equal(t, "changes_requested", got.Verdicts[1].Verdict)
460 assert.Equal(t, "1", got.Comments[2].ReplyTo)
461 assert.NotNil(t, got.Comments[2].EditedAt)
462}
463
464func TestUnknownVerdictDetailOutput(t *testing.T) {
465 var out strings.Builder
466 require.NoError(t, printMRDetail(&out, &ermite.GetMergeRequestResponse{MergeRequest: mrOne, Verdicts: []*ermite.AuthorVerdict{{Author: "future", Verdict: ermite.Verdict(99)}}}, false, false))
467 assert.Contains(t, out.String(), "future verdict 99")
468 assert.NotContains(t, out.String(), "changes requested")
469 assert.NotContains(t, out.String(), "approved")
470}
471
472func TestMRReviewScopeCompletion(t *testing.T) {
473 cmd := newTokenCreateCmd(&cliEnv{})
474 complete, ok := cmd.GetFlagCompletionFunc("scope")
475 require.True(t, ok)
476 values, _ := complete(cmd, nil, "")
477 assert.Contains(t, values, "mr:review")
478}
479
480func TestMRWebURL(t *testing.T) {
481 for _, tt := range []struct{ name, base, repo, want string }{
482 {"plain", "https://git.example.org", "my-repo", "https://git.example.org/my-repo/merge-requests/42"},
483 {"trailing slash", "https://git.example.org/", "alpha", "https://git.example.org/alpha/merge-requests/42"},
484 {"base path", "https://git.example.org/forge///", "alpha", "https://git.example.org/forge/alpha/merge-requests/42"},
485 {"escaped segment", "https://git.example.org", "team/repo #?%", "https://git.example.org/team%2Frepo%20%23%3F%25/merge-requests/42"},
486 } {
487 t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, mrWebURL(tt.base, tt.repo, 42)) })
488 }
489}