1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
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
19type mrOut struct {
20 Repo string `json:"repo"`
21 URL string `json:"url"`
22 Number int64 `json:"number"`
23 Title string `json:"title"`
24 Description string `json:"description"`
25 SourceBranch string `json:"source_branch"`
26 TargetBranch string `json:"target_branch"`
27 State string `json:"state"`
28 Author string `json:"author"`
29 CreatedAt *string `json:"created_at"`
30 UpdatedAt *string `json:"updated_at"`
31 HeadID string `json:"head_id"`
32 MergedCommitID string `json:"merged_commit_id"`
33 MergeMode string `json:"merge_mode"`
34}
35
36var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at", "url"}
37
38type commentOut struct {
39 ID string `json:"id"`
40 Author string `json:"author"`
41 Path string `json:"path"`
42 Line int `json:"line"`
43 Side string `json:"side"`
44 CommitID string `json:"commit_id"`
45 Text string `json:"text"`
46 Resolved bool `json:"resolved"`
47}
48
49type showOut struct {
50 mrOut
51 Commits []struct{ ID, Author, Summary string }
52 CommitsTruncated bool `json:"commits_truncated"`
53 DiffBaseID string `json:"diff_base_id"`
54 Stats struct {
55 FilesChanged int `json:"files_changed"`
56 Additions int
57 Deletions int
58 }
59 Files []struct {
60 OldPath string `json:"old_path"`
61 NewPath string `json:"new_path"`
62 Status string
63 Hunks []struct {
64 Lines []struct {
65 Kind string
66 OldNumber int `json:"old_number"`
67 NewNumber int `json:"new_number"`
68 Text string
69 }
70 }
71 }
72 Comments []commentOut
73 LastReadID string `json:"last_read_id"`
74 LastReadLost bool `json:"last_read_lost"`
75}
76
77// AC3 to AC6 through the CLI against a real forge, with branches pushed by git.
78func TestMergeRequestsCLIEndToEnd(t *testing.T) {
79 c := newCLIHarness(t)
80 stdout, stderr, code := c.bin(t, adminToken, "token", "create", "daily", "--preset", "cli")
81 require.Equal(t, 0, code, stderr)
82 secret := strings.TrimSpace(stdout)
83 agent := strings.TrimSpace(func() string {
84 out, errOut, code := c.bin(t, adminToken, "token", "create", "agent-7", "--preset", "agent")
85 require.Equal(t, 0, code, errOut)
86 return out
87 }())
88
89 c.cli(t, secret, "repo", "create", "proj", "--visibility", "public").ok(t)
90 wt := c.workTree("main", "README.md")
91 url := c.urlAuth(secret, "/proj.git")
92 c.mustGit(wt, "push", "-q", url, "main")
93 main0 := c.bareGit("proj", "rev-parse", "main")
94
95 c.mustGit(wt, "checkout", "-q", "-b", "feat/search")
96 c.writeFile(wt, "search.go", []byte("package search\n\nfunc Find() {}\n"))
97 c.writeFile(wt, "README.md", []byte("# proj\nsearch\n"))
98 c.mustGit(wt, "push", "-q", url, "feat/search")
99 head1 := c.bareGit("proj", "rev-parse", "feat/search")
100
101 w := c.startWatch(t, secret)
102
103 // create: needs mr:write; the agent token has it.
104 res := c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search", "--description", "Finds things.\nSecond line.", "--json").ok(t)
105 assert.Equal(t, mrKeys, keys(t, res.stdout))
106 mr := decode[mrOut](t, res.stdout)
107 assert.EqualValues(t, 1, mr.Number)
108 assert.Equal(t, "open", mr.State)
109 assert.Equal(t, "agent-7", mr.Author)
110 assert.Equal(t, "main", mr.TargetBranch, "defaults to the default branch")
111 assert.Equal(t, head1, mr.HeadID)
112 assert.Empty(t, mr.MergedCommitID)
113 assert.Empty(t, mr.MergeMode)
114 ev := decode[eventOut](t, w.next(t, 10*time.Second))
115 assert.Equal(t, "merge_request_opened", ev.Type)
116 assert.Equal(t, "proj", ev.Repo)
117
118 // The human output is one line.
119 res = c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search", "--target", "main", "--title", "dup")
120 require.Error(t, res.err)
121 assert.Contains(t, res.err.Error(), "already exists")
122
123 // Rules: every offending field in one message.
124 _, stderr, code = c.bin(t, agent, "mr", "create", "proj", "--source", "nope", "--title", "bad\u202etitle")
125 requireOneLineError(t, stderr, code, "invalid request")
126 _, stderr, code = c.bin(t, agent, "mr", "create", "proj", "--source", "feat/search", "--target", "feat/search", "--title", "t")
127 requireOneLineError(t, stderr, code, "invalid request")
128 // Missing flags are usage errors that do not reach the server.
129 res = c.cli(t, agent, "mr", "create", "proj", "--source", "feat/search")
130 require.Error(t, res.err)
131
132 // list.
133 res = c.cli(t, "", "mr", "list", "proj", "--json").ok(t)
134 list := decode[[]mrOut](t, res.stdout)
135 require.Len(t, list, 1, "public: anonymous lists")
136 assert.Equal(t, "Add search", list[0].Title)
137 res = c.cli(t, "", "mr", "list", "proj").ok(t)
138 lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
139 require.Len(t, lines, 2)
140 assert.Regexp(t, `^NUMBER\s+STATE\s+BRANCHES\s+AUTHOR\s+UPDATED\s+TITLE$`, lines[0])
141 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])
142 res = c.cli(t, "", "mr", "list", "proj", "--state", "merged", "--json").ok(t)
143 assert.Equal(t, "[]\n", res.stdout)
144 res = c.cli(t, "", "mr", "list", "proj", "--state", "merged").ok(t)
145 assert.Equal(t, "ermite: no merge requests\n", res.stderr)
146 res = c.cli(t, "", "mr", "list", "proj", "--state", "nonsense")
147 require.Error(t, res.err)
148
149 // show: the line numbers of the diff are what comment takes.
150 res = c.cli(t, "", "mr", "show", "proj", "1", "--diff", "--json").ok(t)
151 show := decode[showOut](t, res.stdout)
152 assert.Equal(t, head1, show.HeadID)
153 assert.Equal(t, main0, show.DiffBaseID)
154 require.Len(t, show.Commits, 2)
155 assert.Equal(t, 2, show.Stats.FilesChanged)
156 paths := map[string]bool{}
157 for _, f := range show.Files {
158 paths[f.NewPath] = true
159 }
160 assert.Equal(t, map[string]bool{"README.md": true, "search.go": true}, paths)
161 res = c.cli(t, "", "mr", "show", "proj", "!1", "--diff").ok(t)
162 assert.Contains(t, res.stdout, "Add search")
163 assert.Contains(t, res.stdout, "feat/search -> main")
164 assert.Contains(t, res.stdout, "+package search")
165 assert.Contains(t, res.stdout, "commits (2)")
166 _, stderr, code = c.bin(t, "", "mr", "show", "proj", "7")
167 requireOneLineError(t, stderr, code, "not found")
168
169 // comment: general, on a line, and on a line that is not in the diff.
170 res = c.cli(t, agent, "mr", "comment", "proj", "1", "-m", "Looks fine overall.", "--json").ok(t)
171 general := decode[commentOut](t, res.stdout)
172 assert.Equal(t, "1", general.ID)
173 assert.Empty(t, general.Path)
174 assert.Equal(t, "agent-7", general.Author)
175 assert.Equal(t, "comment_added", decode[eventOut](t, w.next(t, 10*time.Second)).Type)
176 res = c.cli(t, agent, "mr", "comment", "proj", "1", "--path", "search.go", "--line", "3", "-m", "Why empty?", "--json").ok(t)
177 online := decode[commentOut](t, res.stdout)
178 assert.Equal(t, "search.go", online.Path)
179 assert.Equal(t, 3, online.Line)
180 assert.Equal(t, "new", online.Side)
181 assert.Equal(t, head1, online.CommitID)
182 w.next(t, 10*time.Second)
183 _, stderr, code = c.bin(t, agent, "mr", "comment", "proj", "1", "--path", "search.go", "--line", "99", "-m", "nowhere")
184 requireOneLineError(t, stderr, code, "invalid request", "search.go:99")
185 for _, args := range [][]string{
186 {"--path", "search.go", "-m", "x"}, // no line
187 {"--line", "3", "-m", "x"}, // no path
188 {"--path", "search.go", "--line", "3", "--side", "up", "-m", "x"}, // bad side
189 } {
190 res = c.cli(t, agent, append([]string{"mr", "comment", "proj", "1"}, args...)...)
191 require.Error(t, res.err, "%v", args)
192 }
193 // stdin.
194 res = runCLI(t, c.vars(agent), "from stdin\nwith two lines\n", "mr", "comment", "proj", "1", "-m", "-", "--json").ok(t)
195 assert.Equal(t, "from stdin\nwith two lines", decode[commentOut](t, res.stdout).Text)
196 w.next(t, 10*time.Second)
197 // An agent cannot merge.
198 _, stderr, code = c.bin(t, agent, "mr", "merge", "proj", "1", "--head", head1)
199 requireOneLineError(t, stderr, code, "permission denied")
200
201 // resolve, twice, and undone.
202 res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--json").ok(t)
203 assert.True(t, decode[commentOut](t, res.stdout).Resolved)
204 res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--json").ok(t)
205 assert.True(t, decode[commentOut](t, res.stdout).Resolved)
206 res = c.cli(t, agent, "mr", "resolve", "proj", "1", "2", "--undo", "--json").ok(t)
207 assert.False(t, decode[commentOut](t, res.stdout).Resolved)
208 _, stderr, code = c.bin(t, agent, "mr", "resolve", "proj", "1", "42")
209 requireOneLineError(t, stderr, code, "not found")
210
211 // since-last-read: mark, push more, then only the new change.
212 c.cli(t, agent, "mr", "show", "proj", "1", "--mark-read", "--json").ok(t)
213 c.writeFile(wt, "extra.txt", []byte("extra\n"))
214 c.mustGit(wt, "push", "-q", url, "feat/search")
215 head2 := c.bareGit("proj", "rev-parse", "feat/search")
216 res = c.cli(t, "", "mr", "show", "proj", "1", "--since-last-read", "--json").ok(t)
217 show = decode[showOut](t, res.stdout)
218 assert.Equal(t, head1, show.LastReadID)
219 assert.Equal(t, head1, show.DiffBaseID)
220 require.Len(t, show.Files, 1)
221 assert.Equal(t, "extra.txt", show.Files[0].NewPath)
222 assert.False(t, show.LastReadLost)
223 // A force-push that drops the recorded commit: pruned, the whole change is shown, and said.
224 c.mustGit(wt, "reset", "-q", "--hard", main0)
225 c.writeFile(wt, "other.txt", []byte("o\n"))
226 c.mustGit(wt, "push", "-q", "--force", url, "feat/search")
227 // Push refreshes the pull refs asynchronously. Reconcile them synchronously
228 // before GC, or the old pull head/merge can still retain the read commit.
229 require.NoError(t, c.forge.CI.RescanPulls(c.ctx, c.name("proj")))
230 require.NotContains(t, strings.Fields(c.bareGit("proj", "rev-list", "--all")), head1)
231 c.bareGit("proj", "reflog", "expire", "--expire=now", "--all")
232 c.bareGit("proj", "gc", "-q", "--prune=now")
233 res = c.cli(t, "", "mr", "show", "proj", "1", "--since-last-read", "--json").ok(t)
234 show = decode[showOut](t, res.stdout)
235 assert.True(t, show.LastReadLost)
236 assert.Equal(t, main0, show.DiffBaseID, "the whole change")
237 assert.Contains(t, res.stderr, "head you last read is gone", "the reader is told")
238 head3 := c.bareGit("proj", "rev-parse", "feat/search")
239 _ = head2
240
241 // Merging a stale head fails and says to read again.
242 _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "1", "--head", head1)
243 requireOneLineError(t, stderr, code, "precondition failed", "branch moved")
244 assert.Equal(t, main0, c.bareGit("proj", "rev-parse", "main"))
245
246 // Fast-forward with the reviewed head.
247 res = c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head3, "--json").ok(t)
248 merged := decode[mrOut](t, res.stdout)
249 assert.Equal(t, "merged", merged.State)
250 assert.Equal(t, "fast-forward", merged.MergeMode)
251 assert.Equal(t, head3, merged.MergedCommitID)
252 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", "main"))
253 mev := w.nextOfType(t, "merge_request_merged")
254 require.NotNil(t, mev.MergeRequest)
255 assert.Equal(t, head3, mev.MergeRequest.MergedCommitID)
256 pev := decode[eventOut](t, w.next(t, 10*time.Second))
257 assert.Equal(t, "pushed", pev.Type, "the push of the target follows the merge")
258 require.Len(t, pev.RefUpdates, 1)
259 assert.Equal(t, "refs/heads/main", pev.RefUpdates[0].Ref)
260 assert.Equal(t, head3, pev.RefUpdates[0].NewID)
261 res = c.cli(t, "", "mr", "list", "proj", "--state", "all").ok(t)
262 assert.Regexp(t, `!1\s+merged`, res.stdout)
263 res = c.cli(t, secret, "mr", "close", "proj", "1")
264 require.Error(t, res.err)
265 assert.Contains(t, res.err.Error(), "precondition failed")
266
267 // Squash, with --mode squash. Without --head the merge is refused before it reaches the server.
268 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
269 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
270 c.writeFile(wt, "two.txt", []byte("two\n"))
271 c.mustGit(wt, "push", "-q", url, "feat/two")
272 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
273 w.nextOfType(t, "merge_request_opened")
274 _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--mode", "squash")
275 requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
276 head4 := c.bareGit("proj", "rev-parse", "feat/two")
277 res = c.cli(t, secret, "mr", "merge", "proj", "2", "--mode", "squash", "--head", head4)
278 require.NoError(t, res.err, res.stderr)
279 assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\nthe commit is signed by the server's key SHA256:\S+; .ermite signing-key show. prints the key to verify it\n$`, res.stdout)
280 sq := c.bareGit("proj", "rev-parse", "main")
281 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
282 assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))
283
284 // Closing a third request.
285 c.mustGit(wt, "checkout", "-q", "-b", "feat/three", head3)
286 c.writeFile(wt, "three.txt", []byte("3\n"))
287 c.mustGit(wt, "push", "-q", url, "feat/three")
288 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/three", "--title", "Third").ok(t)
289 res = c.cli(t, secret, "mr", "close", "proj", "3", "--json").ok(t)
290 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
291 res = c.cli(t, secret, "mr", "close", "proj", "3")
292 require.Error(t, res.err)
293 _, stderr, code = c.bin(t, secret, "mr", "comment", "proj", "3", "-m", "late")
294 requireOneLineError(t, stderr, code, "precondition failed")
295}
296
297// A private repository: merge requests do not exist for those who cannot read it.
298func TestMergeRequestsOfAPrivateRepositoryAreNotFound(t *testing.T) {
299 c := newCLIHarness(t)
300 c.cli(t, adminToken, "repo", "create", "secret").ok(t)
301 wt := c.workTree("main", "README.md")
302 c.mustGit(wt, "checkout", "-q", "-b", "feat")
303 c.writeFile(wt, "x.txt", []byte("x\n"))
304 c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/secret.git"), "main", "feat")
305 c.cli(t, adminToken, "mr", "create", "secret", "--source", "feat", "--title", "t").ok(t)
306
307 unscoped := c.token([]domain.Scope{domain.ScopeRepoWrite})
308 otherRepo := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite}, "elsewhere")
309 for who, secret := range map[string]string{"anonymous": "", "no repo:read": unscoped, "token of another repository": otherRepo} {
310 for _, args := range [][]string{
311 {"mr", "list", "secret"},
312 {"mr", "show", "secret", "1"},
313 {"mr", "create", "secret", "--source", "feat", "--title", "t"},
314 {"mr", "comment", "secret", "1", "-m", "x"},
315 {"mr", "close", "secret", "1"},
316 {"mr", "merge", "secret", "1", "--head", strings.Repeat("a", 40)},
317 } {
318 _, stderr, code := c.bin(t, secret, args...)
319 requireOneLineError(t, stderr, code, "not found")
320 assert.NotContains(t, stderr, "permission denied", "%s: %v", who, args)
321 }
322 }
323 // And it is still there for the owner.
324 res := c.cli(t, adminToken, "mr", "list", "secret", "--json").ok(t)
325 assert.Len(t, decode[[]mrOut](t, res.stdout), 1)
326}
327
328// AC5 with the CLI: `ermite signing-key show` produces the allowed_signers line
329// that git verify-commit uses for a squash commit.
330func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
331 if _, err := exec.LookPath("ssh-keygen"); err != nil {
332 if os.Getenv("CI") != "" {
333 t.Fatal("ssh-keygen not found: CI must have it (package openssh)")
334 }
335 t.Skip("ssh-keygen not found: install openssh to verify the signed commits")
336 }
337 c := newCLIHarness(t)
338 c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t)
339 wt := c.workTree("main", "README.md")
340 url := c.urlAuth(adminToken, "/proj.git")
341 c.mustGit(wt, "checkout", "-q", "-b", "feat")
342 c.writeFile(wt, "x.txt", []byte("x\n"))
343 c.mustGit(wt, "push", "-q", url, "main", "feat")
344 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
345 res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--mode", "squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
346 require.NoError(t, res.err, res.stderr)
347 sq := c.bareGit("proj", "rev-parse", "main")
348
349 // No token is needed to read the key.
350 show := c.cli(t, "", "signing-key", "show").ok(t)
351 assert.Regexp(t, `(?m)^fingerprint\s+SHA256:`, show.stdout)
352 assert.Regexp(t, `(?m)^committer\s+ermite <ermite@example.test>$`, show.stdout)
353 allowed := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--allowed-signers").ok(t).stdout)
354 pub := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout)
355 assert.Equal(t, "ermite@example.test "+pub, allowed)
356 assert.True(t, strings.HasPrefix(pub, "ssh-ed25519 "))
357 js := c.cli(t, "", "signing-key", "show", "--json").ok(t)
358 assert.Equal(t, []string{"allowed_signers", "committer_email", "committer_name", "fingerprint", "public_key"}, keys(t, js.stdout))
359 assert.Equal(t, allowed, decode[struct {
360 AllowedSigners string `json:"allowed_signers"`
361 }](t, js.stdout).AllowedSigners)
362 require.Error(t, c.cli(t, "", "signing-key", "show", "--json", "--public-key").err)
363 require.Error(t, c.cli(t, "", "signing-key", "show", "--public-key", "--allowed-signers").err)
364
365 // The file built from the CLI output verifies the commit with git.
366 signers := filepath.Join(t.TempDir(), "allowed_signers")
367 require.NoError(t, os.WriteFile(signers, []byte(allowed+"\n"), 0o600))
368 out, err := c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", sq)
369 require.NoError(t, err, out)
370 assert.Contains(t, out, `Good "git" signature for ermite@example.test`)
371 // A commit pushed by a person is not signed by the instance key.
372 _, err = c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", c.bareGit("proj", "rev-parse", "feat"))
373 assert.Error(t, err)
374 // The key is stable across calls.
375 again := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout)
376 assert.Equal(t, pub, again)
377}
378
379// repo edit sets the merge settings; repo show reports them.
380func TestRepoEditMergeSettingsCLI(t *testing.T) {
381 c := newCLIHarness(t)
382 c.seed("proj", domain.VisibilityPublic)
383 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
384 repo := decode[struct {
385 MergeModes []string `json:"merge_modes"`
386 FailingChecksBlock bool `json:"failing_checks_block"`
387 }](t, res.stdout)
388 assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
389 assert.True(t, repo.FailingChecksBlock)
390
391 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--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)
397 assert.True(t, repo.FailingChecksBlock, "untouched")
398 res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t)
399 repo = decode[struct {
400 MergeModes []string `json:"merge_modes"`
401 FailingChecksBlock bool `json:"failing_checks_block"`
402 }](t, res.stdout)
403 assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched")
404 assert.False(t, repo.FailingChecksBlock)
405 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
406 assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)
407 assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout)
408
409 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)
410 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)
411 _, stderr, code := c.bin(t, c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRMerge}), "repo", "edit", "proj", "--merge-modes", "squash")
412 requireOneLineError(t, stderr, code, "permission denied")
413}
414
415// nextOfType returns the next event of the type, skipping the others (the pushes
416// of the test itself).
417func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
418 t.Helper()
419 for range 20 {
420 ev := decode[eventOut](t, w.next(t, 10*time.Second))
421 if ev.Type == typ {
422 return ev
423 }
424 }
425 t.Fatalf("no %s event", typ)
426 return eventOut{}
427}
428
429func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
430 c := newCLIHarness(t)
431 c.cli(t, adminToken, "repo", "create", "notes", "--visibility", "public").ok(t)
432 wt := c.workTree("main", "README.md")
433 c.mustGit(wt, "checkout", "-q", "-b", "feat/notes")
434 c.writeFile(wt, "notes.txt", []byte("one\ntwo\nthree\nfour\n"))
435 c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/notes.git"), "main", "feat/notes")
436 c.cli(t, adminToken, "mr", "create", "notes", "--source", "feat/notes", "--title", "Notes").ok(t)
437 w := c.startWatch(t, adminToken)
438 res := c.cli(t, adminToken, "mr", "comment", "notes", "1", "--path", "notes.txt", "--line", "1-3", "-m", "**range**", "--json").ok(t)
439 root := decode[jsonComment](t, res.stdout)
440 assert.EqualValues(t, 1, root.StartLine)
441 assert.EqualValues(t, 3, root.Line)
442 w.nextOfType(t, "comment_added")
443 res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", "^"+root.ID, "-m", "reply to ^"+root.ID, "--json").ok(t)
444 reply := decode[jsonComment](t, res.stdout)
445 assert.Equal(t, root.ID, reply.ReplyTo)
446 assert.Equal(t, root.Path, reply.Path)
447 assert.Equal(t, root.StartLine, reply.StartLine)
448 assert.Equal(t, root.Line, reply.Line)
449 w.nextOfType(t, "comment_added")
450 require.Error(t, c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", reply.ID, "-m", "nested").err)
451 require.Error(t, c.cli(t, adminToken, "mr", "resolve", "notes", "1", reply.ID).err)
452 writer := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite})
453 denied := c.cli(t, writer, "mr", "comment", "notes", "1", "--approve")
454 require.Error(t, denied.err)
455 assert.Contains(t, denied.err.Error(), "mr:review")
456 res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--approve", "--json").ok(t)
457 verdict := decode[jsonComment](t, res.stdout)
458 assert.Equal(t, "approve", verdict.Verdict)
459 assert.True(t, verdict.Resolved)
460 assert.Empty(t, verdict.Text)
461 w.nextOfType(t, "comment_added")
462 require.Error(t, c.cli(t, writer, "mr", "edit-comment", "notes", "1", verdict.ID, "-m", "not mine").err)
463 res = runCLI(t, c.vars(adminToken), "edited from stdin\n", "mr", "edit-comment", "notes", "1", "#"+reply.ID, "-m", "-", "--json").ok(t)
464 edited := decode[jsonComment](t, res.stdout)
465 assert.Equal(t, "edited from stdin", edited.Text)
466 require.NotNil(t, edited.EditedAt)
467 w.nextOfType(t, "comment_edited")
468 res = c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID, "--json").ok(t)
469 deleted := decode[jsonComment](t, res.stdout)
470 assert.True(t, deleted.Deleted)
471 assert.Equal(t, "approve", deleted.Verdict)
472 assert.Empty(t, deleted.Text)
473 require.NotNil(t, deleted.EditedAt)
474 w.nextOfType(t, "comment_deleted")
475 c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID).ok(t)
476 c.cli(t, adminToken, "mr", "resolve", "notes", "1", root.ID).ok(t)
477 w.nextOfType(t, "comment_resolved")
478 res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
479 detail := decode[jsonMRDetail](t, res.stdout)
480 require.Len(t, detail.Comments, 3)
481 require.Len(t, detail.Verdicts, 1)
482 assert.Equal(t, verdict.ID, detail.Verdicts[0].CommentID)
483 assert.Equal(t, "approve", detail.Verdicts[0].Verdict)
484 assert.False(t, detail.Verdicts[0].Stale)
485 res = c.cli(t, "", "mr", "show", "notes", "1").ok(t)
486 assert.Contains(t, res.stdout, "lines 1-3")
487 assert.Contains(t, res.stdout, "(edited)")
488 assert.Contains(t, res.stdout, "[deleted]")
489 assert.Contains(t, res.stdout, "approved")
490 assert.Contains(t, res.stdout, " #"+reply.ID)
491 c.cli(t, adminToken, "mr", "comment", "notes", "1", "--request-changes", "-m", "needs work").ok(t)
492 res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
493 detail = decode[jsonMRDetail](t, res.stdout)
494 require.Len(t, detail.Verdicts, 1)
495 assert.Equal(t, "changes_requested", detail.Verdicts[0].Verdict)
496 c.cli(t, adminToken, "mr", "close", "notes", "1").ok(t)
497 require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
498 require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
499}