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 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
35var mrKeys = []string{"author", "created_at", "description", "head_id", "merge_mode", "merged_commit_id", "number", "repo", "source_branch", "state", "target_branch", "title", "updated_at"}
36
37type 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
48type 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.
77func 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")
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. Without --head the merge is refused before it reaches the server.
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 _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash")
270 requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
271 head4 := c.bareGit("proj", "rev-parse", "feat/two")
272 res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4)
273 require.NoError(t, res.err, res.stderr)
274 assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)
275 sq := c.bareGit("proj", "rev-parse", "main")
276 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
277 assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))
278
279 // Closing a third request.
280 c.mustGit(wt, "checkout", "-q", "-b", "feat/three", head3)
281 c.writeFile(wt, "three.txt", []byte("3\n"))
282 c.mustGit(wt, "push", "-q", url, "feat/three")
283 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/three", "--title", "Third").ok(t)
284 res = c.cli(t, secret, "mr", "close", "proj", "3", "--json").ok(t)
285 assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
286 res = c.cli(t, secret, "mr", "close", "proj", "3")
287 require.Error(t, res.err)
288 _, stderr, code = c.bin(t, secret, "mr", "comment", "proj", "3", "-m", "late")
289 requireOneLineError(t, stderr, code, "precondition failed")
290}
291
292// A private repository: merge requests do not exist for those who cannot read it.
293func TestMergeRequestsOfAPrivateRepositoryAreNotFound(t *testing.T) {
294 c := newCLIHarness(t)
295 c.cli(t, adminToken, "repo", "create", "secret").ok(t)
296 wt := c.workTree("main", "README.md")
297 c.mustGit(wt, "checkout", "-q", "-b", "feat")
298 c.writeFile(wt, "x.txt", []byte("x\n"))
299 c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/secret.git"), "main", "feat")
300 c.cli(t, adminToken, "mr", "create", "secret", "--source", "feat", "--title", "t").ok(t)
301
302 unscoped := c.token([]domain.Scope{domain.ScopeRepoWrite})
303 otherRepo := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite}, "elsewhere")
304 for who, secret := range map[string]string{"anonymous": "", "no repo:read": unscoped, "token of another repository": otherRepo} {
305 for _, args := range [][]string{
306 {"mr", "list", "secret"},
307 {"mr", "show", "secret", "1"},
308 {"mr", "create", "secret", "--source", "feat", "--title", "t"},
309 {"mr", "comment", "secret", "1", "-m", "x"},
310 {"mr", "close", "secret", "1"},
311 {"mr", "merge", "secret", "1", "--head", strings.Repeat("a", 40)},
312 } {
313 _, stderr, code := c.bin(t, secret, args...)
314 requireOneLineError(t, stderr, code, "not found")
315 assert.NotContains(t, stderr, "permission denied", "%s: %v", who, args)
316 }
317 }
318 // And it is still there for the owner.
319 res := c.cli(t, adminToken, "mr", "list", "secret", "--json").ok(t)
320 assert.Len(t, decode[[]mrOut](t, res.stdout), 1)
321}
322
323// AC5 with the CLI: `ermite signing-key show` produces the allowed_signers line
324// that git verify-commit uses for a squash commit.
325func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
326 if _, err := exec.LookPath("ssh-keygen"); err != nil {
327 if os.Getenv("CI") != "" {
328 t.Fatal("ssh-keygen not found: CI must have it (package openssh)")
329 }
330 t.Skip("ssh-keygen not found: install openssh to verify the signed commits")
331 }
332 c := newCLIHarness(t)
333 c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t)
334 wt := c.workTree("main", "README.md")
335 url := c.urlAuth(adminToken, "/proj.git")
336 c.mustGit(wt, "checkout", "-q", "-b", "feat")
337 c.writeFile(wt, "x.txt", []byte("x\n"))
338 c.mustGit(wt, "push", "-q", url, "main", "feat")
339 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
340 res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
341 require.NoError(t, res.err, res.stderr)
342 sq := c.bareGit("proj", "rev-parse", "main")
343
344 // No token is needed to read the key.
345 show := c.cli(t, "", "signing-key", "show").ok(t)
346 assert.Regexp(t, `(?m)^fingerprint\s+SHA256:`, show.stdout)
347 assert.Regexp(t, `(?m)^committer\s+ermite <ermite@example.test>$`, show.stdout)
348 allowed := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--allowed-signers").ok(t).stdout)
349 pub := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout)
350 assert.Equal(t, "ermite@example.test "+pub, allowed)
351 assert.True(t, strings.HasPrefix(pub, "ssh-ed25519 "))
352 js := c.cli(t, "", "signing-key", "show", "--json").ok(t)
353 assert.Equal(t, []string{"allowed_signers", "committer_email", "committer_name", "fingerprint", "public_key"}, keys(t, js.stdout))
354 assert.Equal(t, allowed, decode[struct {
355 AllowedSigners string `json:"allowed_signers"`
356 }](t, js.stdout).AllowedSigners)
357 require.Error(t, c.cli(t, "", "signing-key", "show", "--json", "--public-key").err)
358 require.Error(t, c.cli(t, "", "signing-key", "show", "--public-key", "--allowed-signers").err)
359
360 // The file built from the CLI output verifies the commit with git.
361 signers := filepath.Join(t.TempDir(), "allowed_signers")
362 require.NoError(t, os.WriteFile(signers, []byte(allowed+"\n"), 0o600))
363 out, err := c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", sq)
364 require.NoError(t, err, out)
365 assert.Contains(t, out, `Good "git" signature for ermite@example.test`)
366 // A commit pushed by a person is not signed by the instance key.
367 _, err = c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", c.bareGit("proj", "rev-parse", "feat"))
368 assert.Error(t, err)
369 // The key is stable across calls.
370 again := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout)
371 assert.Equal(t, pub, again)
372}
373
374// repo edit sets the merge settings; repo show reports them.
375func TestRepoEditMergeSettingsCLI(t *testing.T) {
376 c := newCLIHarness(t)
377 c.seed("proj", domain.VisibilityPublic)
378 res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
379 repo := decode[struct {
380 MergeModes []string `json:"merge_modes"`
381 FailingChecksBlock bool `json:"failing_checks_block"`
382 }](t, res.stdout)
383 assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes)
384 assert.True(t, repo.FailingChecksBlock)
385
386 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
387 repo = decode[struct {
388 MergeModes []string `json:"merge_modes"`
389 FailingChecksBlock bool `json:"failing_checks_block"`
390 }](t, res.stdout)
391 assert.Equal(t, []string{"squash"}, repo.MergeModes)
392 assert.True(t, repo.FailingChecksBlock, "untouched")
393 res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t)
394 repo = decode[struct {
395 MergeModes []string `json:"merge_modes"`
396 FailingChecksBlock bool `json:"failing_checks_block"`
397 }](t, res.stdout)
398 assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched")
399 assert.False(t, repo.FailingChecksBlock)
400 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
401 assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)
402 assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout)
403
404 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)
405 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)
406 _, stderr, code := c.bin(t, c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRMerge}), "repo", "edit", "proj", "--merge-modes", "squash")
407 requireOneLineError(t, stderr, code, "permission denied")
408}
409
410// nextOfType returns the next event of the type, skipping the others (the pushes
411// of the test itself).
412func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
413 t.Helper()
414 for range 20 {
415 ev := decode[eventOut](t, w.next(t, 10*time.Second))
416 if ev.Type == typ {
417 return ev
418 }
419 }
420 t.Fatalf("no %s event", typ)
421 return eventOut{}
422}