detached 388d3be

cli_mr_e2e_test.go RawHistoryFolder

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	c.bareGit("proj", "reflog", "expire", "--expire=now", "--all")
228	c.bareGit("proj", "gc", "-q", "--prune=now")
229	res = c.cli(t, "", "mr", "show", "proj", "1", "--since-last-read", "--json").ok(t)
230	show = decode[showOut](t, res.stdout)
231	assert.True(t, show.LastReadLost)
232	assert.Equal(t, main0, show.DiffBaseID, "the whole change")
233	assert.Contains(t, res.stderr, "head you last read is gone", "the reader is told")
234	head3 := c.bareGit("proj", "rev-parse", "feat/search")
235	_ = head2
236
237	// Merging a stale head fails and says to read again.
238	_, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "1", "--head", head1)
239	requireOneLineError(t, stderr, code, "precondition failed", "branch moved")
240	assert.Equal(t, main0, c.bareGit("proj", "rev-parse", "main"))
241
242	// Fast-forward with the reviewed head.
243	res = c.cli(t, secret, "mr", "merge", "proj", "1", "--head", head3, "--json").ok(t)
244	merged := decode[mrOut](t, res.stdout)
245	assert.Equal(t, "merged", merged.State)
246	assert.Equal(t, "fast-forward", merged.MergeMode)
247	assert.Equal(t, head3, merged.MergedCommitID)
248	assert.Equal(t, head3, c.bareGit("proj", "rev-parse", "main"))
249	mev := w.nextOfType(t, "merge_request_merged")
250	require.NotNil(t, mev.MergeRequest)
251	assert.Equal(t, head3, mev.MergeRequest.MergedCommitID)
252	pev := decode[eventOut](t, w.next(t, 10*time.Second))
253	assert.Equal(t, "pushed", pev.Type, "the push of the target follows the merge")
254	require.Len(t, pev.RefUpdates, 1)
255	assert.Equal(t, "refs/heads/main", pev.RefUpdates[0].Ref)
256	assert.Equal(t, head3, pev.RefUpdates[0].NewID)
257	res = c.cli(t, "", "mr", "list", "proj", "--state", "all").ok(t)
258	assert.Regexp(t, `!1\s+merged`, res.stdout)
259	res = c.cli(t, secret, "mr", "close", "proj", "1")
260	require.Error(t, res.err)
261	assert.Contains(t, res.err.Error(), "precondition failed")
262
263	// Squash, with --squash. Without --head the merge is refused before it reaches the server.
264	c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
265	c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
266	c.writeFile(wt, "two.txt", []byte("two\n"))
267	c.mustGit(wt, "push", "-q", url, "feat/two")
268	c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
269	w.nextOfType(t, "merge_request_opened")
270	_, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash")
271	requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
272	head4 := c.bareGit("proj", "rev-parse", "feat/two")
273	res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4)
274	require.NoError(t, res.err, res.stderr)
275	assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)
276	sq := c.bareGit("proj", "rev-parse", "main")
277	assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
278	assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))
279
280	// Closing a third request.
281	c.mustGit(wt, "checkout", "-q", "-b", "feat/three", head3)
282	c.writeFile(wt, "three.txt", []byte("3\n"))
283	c.mustGit(wt, "push", "-q", url, "feat/three")
284	c.cli(t, secret, "mr", "create", "proj", "--source", "feat/three", "--title", "Third").ok(t)
285	res = c.cli(t, secret, "mr", "close", "proj", "3", "--json").ok(t)
286	assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
287	res = c.cli(t, secret, "mr", "close", "proj", "3")
288	require.Error(t, res.err)
289	_, stderr, code = c.bin(t, secret, "mr", "comment", "proj", "3", "-m", "late")
290	requireOneLineError(t, stderr, code, "precondition failed")
291}
292
293// A private repository: merge requests do not exist for those who cannot read it.
294func TestMergeRequestsOfAPrivateRepositoryAreNotFound(t *testing.T) {
295	c := newCLIHarness(t)
296	c.cli(t, adminToken, "repo", "create", "secret").ok(t)
297	wt := c.workTree("main", "README.md")
298	c.mustGit(wt, "checkout", "-q", "-b", "feat")
299	c.writeFile(wt, "x.txt", []byte("x\n"))
300	c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/secret.git"), "main", "feat")
301	c.cli(t, adminToken, "mr", "create", "secret", "--source", "feat", "--title", "t").ok(t)
302
303	unscoped := c.token([]domain.Scope{domain.ScopeRepoWrite})
304	otherRepo := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite}, "elsewhere")
305	for who, secret := range map[string]string{"anonymous": "", "no repo:read": unscoped, "token of another repository": otherRepo} {
306		for _, args := range [][]string{
307			{"mr", "list", "secret"},
308			{"mr", "show", "secret", "1"},
309			{"mr", "create", "secret", "--source", "feat", "--title", "t"},
310			{"mr", "comment", "secret", "1", "-m", "x"},
311			{"mr", "close", "secret", "1"},
312			{"mr", "merge", "secret", "1", "--head", strings.Repeat("a", 40)},
313		} {
314			_, stderr, code := c.bin(t, secret, args...)
315			requireOneLineError(t, stderr, code, "not found")
316			assert.NotContains(t, stderr, "permission denied", "%s: %v", who, args)
317		}
318	}
319	// And it is still there for the owner.
320	res := c.cli(t, adminToken, "mr", "list", "secret", "--json").ok(t)
321	assert.Len(t, decode[[]mrOut](t, res.stdout), 1)
322}
323
324// AC5 with the CLI: `ermite signing-key show` produces the allowed_signers line
325// that git verify-commit uses for a squash commit.
326func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
327	if _, err := exec.LookPath("ssh-keygen"); err != nil {
328		if os.Getenv("CI") != "" {
329			t.Fatal("ssh-keygen not found: CI must have it (package openssh)")
330		}
331		t.Skip("ssh-keygen not found: install openssh to verify the signed commits")
332	}
333	c := newCLIHarness(t)
334	c.cli(t, adminToken, "repo", "create", "proj", "--visibility", "public").ok(t)
335	wt := c.workTree("main", "README.md")
336	url := c.urlAuth(adminToken, "/proj.git")
337	c.mustGit(wt, "checkout", "-q", "-b", "feat")
338	c.writeFile(wt, "x.txt", []byte("x\n"))
339	c.mustGit(wt, "push", "-q", url, "main", "feat")
340	c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
341	res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
342	require.NoError(t, res.err, res.stderr)
343	sq := c.bareGit("proj", "rev-parse", "main")
344
345	// No token is needed to read the key.
346	show := c.cli(t, "", "signing-key", "show").ok(t)
347	assert.Regexp(t, `(?m)^fingerprint\s+SHA256:`, show.stdout)
348	assert.Regexp(t, `(?m)^committer\s+ermite <ermite@example.test>$`, show.stdout)
349	allowed := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--allowed-signers").ok(t).stdout)
350	pub := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout)
351	assert.Equal(t, "ermite@example.test "+pub, allowed)
352	assert.True(t, strings.HasPrefix(pub, "ssh-ed25519 "))
353	js := c.cli(t, "", "signing-key", "show", "--json").ok(t)
354	assert.Equal(t, []string{"allowed_signers", "committer_email", "committer_name", "fingerprint", "public_key"}, keys(t, js.stdout))
355	assert.Equal(t, allowed, decode[struct {
356		AllowedSigners string `json:"allowed_signers"`
357	}](t, js.stdout).AllowedSigners)
358	require.Error(t, c.cli(t, "", "signing-key", "show", "--json", "--public-key").err)
359	require.Error(t, c.cli(t, "", "signing-key", "show", "--public-key", "--allowed-signers").err)
360
361	// The file built from the CLI output verifies the commit with git.
362	signers := filepath.Join(t.TempDir(), "allowed_signers")
363	require.NoError(t, os.WriteFile(signers, []byte(allowed+"\n"), 0o600))
364	out, err := c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", sq)
365	require.NoError(t, err, out)
366	assert.Contains(t, out, `Good "git" signature for ermite@example.test`)
367	// A commit pushed by a person is not signed by the instance key.
368	_, err = c.gitEnv(nil, c.repoDir("proj"), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", c.bareGit("proj", "rev-parse", "feat"))
369	assert.Error(t, err)
370	// The key is stable across calls.
371	again := strings.TrimSpace(c.cli(t, "", "signing-key", "show", "--public-key").ok(t).stdout)
372	assert.Equal(t, pub, again)
373}
374
375// repo edit sets the merge settings; repo show reports them.
376func TestRepoEditMergeSettingsCLI(t *testing.T) {
377	c := newCLIHarness(t)
378	c.seed("proj", domain.VisibilityPublic)
379	res := c.cli(t, adminToken, "repo", "show", "proj", "--json").ok(t)
380	repo := decode[struct {
381		MergeModes         []string `json:"merge_modes"`
382		FailingChecksBlock bool     `json:"failing_checks_block"`
383	}](t, res.stdout)
384	assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes)
385	assert.True(t, repo.FailingChecksBlock)
386
387	res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
388	repo = decode[struct {
389		MergeModes         []string `json:"merge_modes"`
390		FailingChecksBlock bool     `json:"failing_checks_block"`
391	}](t, res.stdout)
392	assert.Equal(t, []string{"squash"}, repo.MergeModes)
393	assert.True(t, repo.FailingChecksBlock, "untouched")
394	res = c.cli(t, adminToken, "repo", "edit", "proj", "--failing-checks-block=false", "--json").ok(t)
395	repo = decode[struct {
396		MergeModes         []string `json:"merge_modes"`
397		FailingChecksBlock bool     `json:"failing_checks_block"`
398	}](t, res.stdout)
399	assert.Equal(t, []string{"squash"}, repo.MergeModes, "untouched")
400	assert.False(t, repo.FailingChecksBlock)
401	res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
402	assert.Regexp(t, `(?m)^merge modes\s+squash$`, res.stdout)
403	assert.Regexp(t, `(?m)^failing checks block\s+false$`, res.stdout)
404
405	require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "rebase").err)
406	require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "").err)
407	_, stderr, code := c.bin(t, c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRMerge}), "repo", "edit", "proj", "--merge-modes", "squash")
408	requireOneLineError(t, stderr, code, "permission denied")
409}
410
411// nextOfType returns the next event of the type, skipping the others (the pushes
412// of the test itself).
413func (w *watchProc) nextOfType(t *testing.T, typ string) eventOut {
414	t.Helper()
415	for range 20 {
416		ev := decode[eventOut](t, w.next(t, 10*time.Second))
417		if ev.Type == typ {
418			return ev
419		}
420	}
421	t.Fatalf("no %s event", typ)
422	return eventOut{}
423}
424
425func TestMergeRequestNotesCLIEndToEnd(t *testing.T) {
426	c := newCLIHarness(t)
427	c.cli(t, adminToken, "repo", "create", "notes", "--visibility", "public").ok(t)
428	wt := c.workTree("main", "README.md")
429	c.mustGit(wt, "checkout", "-q", "-b", "feat/notes")
430	c.writeFile(wt, "notes.txt", []byte("one\ntwo\nthree\nfour\n"))
431	c.mustGit(wt, "push", "-q", c.urlAuth(adminToken, "/notes.git"), "main", "feat/notes")
432	c.cli(t, adminToken, "mr", "create", "notes", "--source", "feat/notes", "--title", "Notes").ok(t)
433	w := c.startWatch(t, adminToken)
434	res := c.cli(t, adminToken, "mr", "comment", "notes", "1", "--path", "notes.txt", "--line", "1-3", "-m", "**range**", "--json").ok(t)
435	root := decode[jsonComment](t, res.stdout)
436	assert.EqualValues(t, 1, root.StartLine)
437	assert.EqualValues(t, 3, root.Line)
438	w.nextOfType(t, "comment_added")
439	res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", "^"+root.ID, "-m", "reply to ^"+root.ID, "--json").ok(t)
440	reply := decode[jsonComment](t, res.stdout)
441	assert.Equal(t, root.ID, reply.ReplyTo)
442	assert.Equal(t, root.Path, reply.Path)
443	assert.Equal(t, root.StartLine, reply.StartLine)
444	assert.Equal(t, root.Line, reply.Line)
445	w.nextOfType(t, "comment_added")
446	require.Error(t, c.cli(t, adminToken, "mr", "comment", "notes", "1", "--reply", reply.ID, "-m", "nested").err)
447	require.Error(t, c.cli(t, adminToken, "mr", "resolve", "notes", "1", reply.ID).err)
448	writer := c.token([]domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite})
449	denied := c.cli(t, writer, "mr", "comment", "notes", "1", "--approve")
450	require.Error(t, denied.err)
451	assert.Contains(t, denied.err.Error(), "mr:review")
452	res = c.cli(t, adminToken, "mr", "comment", "notes", "1", "--approve", "--json").ok(t)
453	verdict := decode[jsonComment](t, res.stdout)
454	assert.Equal(t, "approve", verdict.Verdict)
455	assert.True(t, verdict.Resolved)
456	assert.Empty(t, verdict.Text)
457	w.nextOfType(t, "comment_added")
458	require.Error(t, c.cli(t, writer, "mr", "edit-comment", "notes", "1", verdict.ID, "-m", "not mine").err)
459	res = runCLI(t, c.vars(adminToken), "edited from stdin\n", "mr", "edit-comment", "notes", "1", "#"+reply.ID, "-m", "-", "--json").ok(t)
460	edited := decode[jsonComment](t, res.stdout)
461	assert.Equal(t, "edited from stdin", edited.Text)
462	require.NotNil(t, edited.EditedAt)
463	w.nextOfType(t, "comment_edited")
464	res = c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID, "--json").ok(t)
465	deleted := decode[jsonComment](t, res.stdout)
466	assert.True(t, deleted.Deleted)
467	assert.Equal(t, "approve", deleted.Verdict)
468	assert.Empty(t, deleted.Text)
469	require.NotNil(t, deleted.EditedAt)
470	w.nextOfType(t, "comment_deleted")
471	c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", verdict.ID).ok(t)
472	c.cli(t, adminToken, "mr", "resolve", "notes", "1", root.ID).ok(t)
473	w.nextOfType(t, "comment_resolved")
474	res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
475	detail := decode[jsonMRDetail](t, res.stdout)
476	require.Len(t, detail.Comments, 3)
477	require.Len(t, detail.Verdicts, 1)
478	assert.Equal(t, verdict.ID, detail.Verdicts[0].CommentID)
479	assert.Equal(t, "approve", detail.Verdicts[0].Verdict)
480	assert.False(t, detail.Verdicts[0].Stale)
481	res = c.cli(t, "", "mr", "show", "notes", "1").ok(t)
482	assert.Contains(t, res.stdout, "lines 1-3")
483	assert.Contains(t, res.stdout, "(edited)")
484	assert.Contains(t, res.stdout, "[deleted]")
485	assert.Contains(t, res.stdout, "approved")
486	assert.Contains(t, res.stdout, "      #"+reply.ID)
487	c.cli(t, adminToken, "mr", "comment", "notes", "1", "--request-changes", "-m", "needs work").ok(t)
488	res = c.cli(t, "", "mr", "show", "notes", "1", "--json").ok(t)
489	detail = decode[jsonMRDetail](t, res.stdout)
490	require.Len(t, detail.Verdicts, 1)
491	assert.Equal(t, "changes_requested", detail.Verdicts[0].Verdict)
492	c.cli(t, adminToken, "mr", "close", "notes", "1").ok(t)
493	require.Error(t, c.cli(t, adminToken, "mr", "edit-comment", "notes", "1", root.ID, "-m", "late").err)
494	require.Error(t, c.cli(t, adminToken, "mr", "delete-comment", "notes", "1", root.ID).err)
495}

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