detached 0e6522c

mergerequests_integration_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"bytes"
7	"context"
8	"crypto/ed25519"
9	"fmt"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"strings"
14	"sync"
15	"testing"
16
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/mock"
19	"github.com/stretchr/testify/require"
20
21	"bnema.dev/ermite/internal/adapters/eventbus"
22	"bnema.dev/ermite/internal/adapters/gitcli"
23	"bnema.dev/ermite/internal/adapters/kvstore"
24	"bnema.dev/ermite/internal/adapters/sshsig"
25	"bnema.dev/ermite/internal/domain"
26	"bnema.dev/ermite/internal/ports"
27	"bnema.dev/ermite/internal/ports/mocks"
28	"bnema.dev/ermite/internal/usecase"
29)
30
31// queuedRepos records the repositories whose maintenance was queued.
32type queuedRepos struct {
33	mu sync.Mutex
34	rs []domain.RepoName
35}
36
37func (q *queuedRepos) add(_ context.Context, n domain.RepoName) {
38	q.mu.Lock()
39	defer q.mu.Unlock()
40	q.rs = append(q.rs, n)
41}
42
43func (q *queuedRepos) names() []domain.RepoName {
44	q.mu.Lock()
45	defer q.mu.Unlock()
46	return append([]domain.RepoName(nil), q.rs...)
47}
48
49// mrRig runs the merge request use cases over the real adapters: a data
50// directory with ermite-kv and bare repositories, and a work tree to push
51// branches from.
52type mrRig struct {
53	t      *testing.T
54	ctx    context.Context
55	data   string
56	repos  *usecase.Repos
57	mrs    *usecase.MergeRequests
58	key    *usecase.SigningKey
59	store  *kvstore.Store
60	git    *gitcli.Git
61	bus    *eventbus.Bus
62	sched  *mocks.MaintenanceScheduler
63	queued *queuedRepos
64	events <-chan domain.Event
65	work   string
66	repo   domain.RepoName
67}
68
69var (
70	root   = domain.Root()
71	author = domain.Identity{Name: "Ada Lovelace", Email: "ada@example.org"}
72)
73
74func newMRRig(t *testing.T) *mrRig {
75	t.Helper()
76	ctx := context.Background()
77	bin, err := exec.LookPath("git")
78	if err != nil {
79		unusable(t, "git not found")
80	}
81	data := t.TempDir()
82	for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} {
83		require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700))
84	}
85	git := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")})
86	if _, err := git.CheckVersion(ctx); err != nil {
87		unusable(t, "git is not usable by ermite: %v", err)
88	}
89	t.Cleanup(func() { _ = git.Close(context.Background()) })
90	store, err := kvstore.Open(ctx, filepath.Join(data, "kv"))
91	require.NoError(t, err)
92	t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
93
94	signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
95	require.NoError(t, err)
96	bus := eventbus.NewWithBuffer(1024)
97	committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
98	sched := mocks.NewMaintenanceScheduler(t)
99	queued := &queuedRepos{}
100	sched.EXPECT().Schedule(mock.Anything, mock.Anything).Run(queued.add).Return().Maybe()
101	r := &mrRig{
102		sched: sched, queued: queued,
103		t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),
104		repos: usecase.NewRepos(store, git, bus, fixedClock{}),
105		key:   usecase.NewSigningKey(signer, committer),
106		work:  t.TempDir(),
107		repo:  name(t, "proj"),
108	}
109	r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
110		Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
111		Committer: committer, Bus: bus, Clock: fixedClock{}, Maint: r.sched,
112	})
113	_, err = r.repos.Create(ctx, root, ports.CreateRepoParams{Name: r.repo, Visibility: domain.VisibilityPrivate})
114	require.NoError(t, err)
115	r.sh("init", "-q", "--initial-branch=main")
116	return r
117}
118
119func (r *mrRig) bare() string { return filepath.Join(r.data, "repos", r.repo.String()+".git") }
120
121func (r *mrRig) env(who domain.Identity, extra ...string) []string {
122	return append([]string{
123		"PATH=" + os.Getenv("PATH"), "HOME=" + r.work, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
124		"GIT_AUTHOR_NAME=" + who.Name, "GIT_AUTHOR_EMAIL=" + who.Email,
125		"GIT_COMMITTER_NAME=" + who.Name, "GIT_COMMITTER_EMAIL=" + who.Email,
126		"LC_ALL=C",
127	}, extra...)
128}
129
130func (r *mrRig) run(dir string, env []string, args ...string) string {
131	r.t.Helper()
132	cmd := exec.Command("git", args...)
133	cmd.Dir, cmd.Env = dir, env
134	out, err := cmd.CombinedOutput()
135	require.NoError(r.t, err, "git %s\n%s", strings.Join(args, " "), out)
136	return strings.TrimSpace(string(out))
137}
138
139// sh runs git in the work tree as the default author.
140func (r *mrRig) sh(args ...string) string { return r.run(r.work, r.env(author), args...) }
141
142// commit writes a file and commits it as who; it returns the commit id.
143func (r *mrRig) commit(who domain.Identity, path, content, msg string) domain.ObjectID {
144	r.t.Helper()
145	full := filepath.Join(r.work, path)
146	require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755))
147	require.NoError(r.t, os.WriteFile(full, []byte(content), 0o644))
148	r.run(r.work, r.env(who), "add", path)
149	r.run(r.work, r.env(who), "commit", "-q", "-m", msg)
150	return r.head()
151}
152
153func (r *mrRig) head() domain.ObjectID {
154	r.t.Helper()
155	return oidFrom(r.t, r.sh("rev-parse", "HEAD"))
156}
157
158func oidFrom(t *testing.T, s string) domain.ObjectID {
159	t.Helper()
160	id, err := domain.ParseObjectID(s)
161	require.NoError(t, err)
162	return id
163}
164
165// push pushes the branch of the work tree to the repository, forcing it.
166func (r *mrRig) push(branch string) {
167	r.t.Helper()
168	r.sh("push", "-q", "--force", r.bare(), branch+":refs/heads/"+branch)
169}
170
171// branchHead reads a branch of the bare repository.
172func (r *mrRig) branchHead(branch string) domain.ObjectID {
173	r.t.Helper()
174	return oidFrom(r.t, r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/"+branch))
175}
176
177// seed pushes main with two files and returns its id.
178func (r *mrRig) seed() domain.ObjectID {
179	r.t.Helper()
180	r.commit(author, "a.txt", "one\ntwo\nthree\n", "add a")
181	id := r.commit(author, "b.txt", "bee\n", "add b")
182	r.push("main")
183	return id
184}
185
186// feature creates the branch feat from main with two commits by two authors
187// and pushes it; it returns its head.
188func (r *mrRig) feature(branch string) domain.ObjectID {
189	r.t.Helper()
190	r.sh("checkout", "-q", "-B", branch, "main")
191	r.commit(author, "a.txt", "one\n2\nthree\n", "change a")
192	bob := domain.Identity{Name: "Bob", Email: "bob@example.org"}
193	id := r.commit(bob, "c.txt", "see\n", "add c")
194	r.push(branch)
195	return id
196}
197
198func (r *mrRig) open(branch string) domain.MergeRequest {
199	r.t.Helper()
200	mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Add " + branch, Source: branch})
201	require.NoError(r.t, err)
202	return mr
203}
204
205// drain returns the events published so far. The use cases publish on the bus
206// from the calling goroutine, and the bus delivers into the buffer of the
207// subscriber before Publish returns: whatever a returned call published is
208// already in the channel.
209func (r *mrRig) drain() []domain.Event {
210	var out []domain.Event
211	for {
212		select {
213		case ev := <-r.events:
214			out = append(out, ev)
215		default:
216			return out
217		}
218	}
219}
220
221func writer(scopes ...domain.Scope) domain.Principal {
222	return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes})
223}
224
225var (
226	// agent is the agent preset: it pushes and writes merge requests, and has
227	// no repo:read.
228	agent  = writer(domain.ScopeRepoWrite, domain.ScopeMRWrite)
229	reader = writer(domain.ScopeRepoRead)
230	mrW    = writer(domain.ScopeRepoRead, domain.ScopeMRWrite)
231	mrM    = writer(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge)
232)
233
234func fieldsOf(t *testing.T, err error) map[string]string {
235	t.Helper()
236	var fe *domain.InvalidFieldsError
237	require.ErrorAs(t, err, &fe, "want a rejection naming the fields")
238	out := map[string]string{}
239	for _, f := range fe.Fields {
240		out[f.Field] = f.Reason
241	}
242	return out
243}
244
245// AC3: the input rules of ADR 0017.
246func TestCreateMergeRequestInputRules(t *testing.T) {
247	r := newMRRig(t)
248	r.seed()
249	r.feature("feat")
250	r.sh("checkout", "-q", "-B", "other", "main")
251	r.commit(author, "o.txt", "o\n", "other")
252	r.push("other")
253
254	tests := []struct {
255		name   string
256		params ports.CreateMergeRequestParams
257		field  string // "" for valid
258	}{
259		{"valid, default target", ports.CreateMergeRequestParams{Title: "t", Source: "feat"}, ""},
260		{"valid, explicit target and description", ports.CreateMergeRequestParams{Title: "t", Description: "a\nb", Source: "other", Target: "main"}, ""},
261		{"empty title", ports.CreateMergeRequestParams{Title: "", Source: "feat", Target: "other"}, "title"},
262		{"two-line title", ports.CreateMergeRequestParams{Title: "a\nb", Source: "feat", Target: "other"}, "title"},
263		{"title too long", ports.CreateMergeRequestParams{Title: strings.Repeat("x", 121), Source: "feat", Target: "other"}, "title"},
264		{"title with a bidi override", ports.CreateMergeRequestParams{Title: "a\u202eb", Source: "feat", Target: "other"}, "title"},
265		{"title with an escape", ports.CreateMergeRequestParams{Title: "\x1b[31m", Source: "feat", Target: "other"}, "title"},
266		{"description with a control character", ports.CreateMergeRequestParams{Title: "t", Description: "a\x07", Source: "feat", Target: "other"}, "description"},
267		{"description with a bidi isolate", ports.CreateMergeRequestParams{Title: "t", Description: "a\u2066b", Source: "feat", Target: "other"}, "description"},
268		{"description too long", ports.CreateMergeRequestParams{Title: "t", Description: strings.Repeat("x", 8001), Source: "feat", Target: "other"}, "description"},
269		{"description not utf-8", ports.CreateMergeRequestParams{Title: "t", Description: "\xff", Source: "feat", Target: "other"}, "description"},
270		{"source missing", ports.CreateMergeRequestParams{Title: "t", Source: ""}, "source_branch"},
271		{"source does not exist", ports.CreateMergeRequestParams{Title: "t", Source: "nope", Target: "main"}, "source_branch"},
272		{"source is not a branch name", ports.CreateMergeRequestParams{Title: "t", Source: "a..b", Target: "main"}, "source_branch"},
273		{"source is a full ref", ports.CreateMergeRequestParams{Title: "t", Source: "refs/heads/feat", Target: "main"}, "source_branch"},
274		{"target does not exist", ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "nope"}, "target_branch"},
275		{"same branch", ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}, "target_branch"},
276	}
277	for _, tc := range tests {
278		t.Run(tc.name, func(t *testing.T) {
279			mr, err := r.mrs.Create(r.ctx, root, r.repo, tc.params)
280			if tc.field == "" {
281				require.NoError(t, err)
282				assert.Equal(t, domain.MRStateOpen, mr.State)
283				assert.Equal(t, "root", mr.Author)
284				assert.Equal(t, "main", mr.Target)
285				assert.NotZero(t, mr.HeadID)
286				return
287			}
288			require.ErrorIs(t, err, domain.ErrInvalid)
289			assert.Contains(t, fieldsOf(t, err), tc.field)
290		})
291	}
292
293	t.Run("a request is rejected whole with every field named", func(t *testing.T) {
294		_, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "a\nb", Description: "\x07", Source: "nope", Target: "feat"})
295		got := fieldsOf(t, err)
296		assert.Contains(t, got, "title")
297		assert.Contains(t, got, "description")
298		assert.Contains(t, got, "source_branch")
299	})
300}
301
302func TestCreateMergeRequestNumbersAndDuplicates(t *testing.T) {
303	r := newMRRig(t)
304	r.seed()
305	r.feature("one")
306	r.feature("two")
307
308	first := r.open("one")
309	assert.Equal(t, int64(1), first.Number)
310	second := r.open("two")
311	assert.Equal(t, int64(2), second.Number)
312
313	_, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "again", Source: "one", Target: "main"})
314	require.ErrorIs(t, err, domain.ErrAlreadyExists, "same open source and target")
315	_, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "again", Source: "one"})
316	require.ErrorIs(t, err, domain.ErrAlreadyExists, "the default target is the same target")
317
318	// Another target is another request.
319	r.sh("checkout", "-q", "-B", "stage", "main")
320	r.push("stage")
321	third, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to stage", Source: "one", Target: "stage"})
322	require.NoError(t, err)
323	assert.Equal(t, int64(3), third.Number)
324
325	// Once the first is closed, the pair is free again, with a new number.
326	_, err = r.mrs.Close(r.ctx, root, r.repo, first.Number)
327	require.NoError(t, err)
328	fourth, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "retry", Source: "one"})
329	require.NoError(t, err)
330	assert.Equal(t, int64(4), fourth.Number, "numbers are never reused")
331}
332
333func TestMergeRequestAccess(t *testing.T) {
334	r := newMRRig(t) // proj is private
335	r.seed()
336	r.feature("feat")
337	mr := r.open("feat")
338	n := mr.Number
339	noScope := writer(domain.ScopeRepoWrite)
340	otherRepo := domain.TokenPrincipal(domain.Token{ID: "o", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge}, Repos: []domain.RepoName{name(t, "else")}})
341
342	reads := map[string]func(p domain.Principal) error{
343		"List": func(p domain.Principal) error {
344			_, err := r.mrs.List(r.ctx, p, r.repo, ports.ListMergeRequestsParams{})
345			return err
346		},
347		"Get": func(p domain.Principal) error {
348			_, err := r.mrs.Get(r.ctx, p, r.repo, n, ports.GetMergeRequestOptions{})
349			return err
350		},
351	}
352	for what, call := range reads {
353		for who, p := range map[string]domain.Principal{"anonymous": domain.Anonymous(), "no repo:read": noScope, "agent, no repo:read": agent, "token of another repo": otherRepo} {
354			assert.ErrorIs(t, call(p), domain.ErrNotFound, "%s by %s on a private repository", what, who)
355		}
356		assert.NoError(t, call(reader), what+" with repo:read")
357		assert.NoError(t, call(root), what+" by root")
358	}
359	_, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, ports.GetMergeRequestOptions{})
360	assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number")
361	_, err = r.mrs.List(r.ctx, root, name(t, "ghost"), ports.ListMergeRequestsParams{})
362	assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository")
363
364	writes := map[string]func(p domain.Principal) error{
365		"Create": func(p domain.Principal) error {
366			_, err := r.mrs.Create(r.ctx, p, r.repo, ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"})
367			return err
368		},
369		"AddComment": func(p domain.Principal) error {
370			_, err := r.mrs.AddComment(r.ctx, p, r.repo, n, ports.AddCommentParams{Text: "x"})
371			return err
372		},
373		"ResolveComment": func(p domain.Principal) error {
374			_, err := r.mrs.ResolveComment(r.ctx, p, r.repo, n, 1, true)
375			return err
376		},
377		"Close": func(p domain.Principal) error {
378			_, err := r.mrs.Close(r.ctx, p, r.repo, n+100)
379			return err
380		},
381		"MarkRead": func(p domain.Principal) error {
382			_, err := r.mrs.MarkRead(r.ctx, p, r.repo, n, domain.ObjectID{})
383			return err
384		},
385		"Merge": func(p domain.Principal) error {
386			_, err := r.mrs.Merge(r.ctx, p, r.repo, n+100, ports.MergeParams{HeadID: mr.HeadID})
387			return err
388		},
389	}
390	for what, call := range writes {
391		scope := domain.ScopeMRWrite
392		if what == "Merge" {
393			scope = domain.ScopeMRMerge
394		}
395		assert.ErrorIs(t, call(domain.Anonymous()), domain.ErrNotFound, "%s: anonymous cannot see the private repository", what)
396		assert.ErrorIs(t, call(noScope), domain.ErrNotFound, "%s: no repo:read and not the scope", what)
397		assert.ErrorIs(t, call(otherRepo), domain.ErrNotFound, "%s: another repository's token", what)
398		assert.ErrorIs(t, call(reader), domain.ErrForbidden, "%s: reading is not writing", what)
399		if scope == domain.ScopeMRMerge {
400			assert.ErrorIs(t, call(mrW), domain.ErrForbidden, "mr:write does not merge")
401			assert.ErrorIs(t, call(agent), domain.ErrNotFound, "no repo:read and not mr:merge: the repository stays hidden")
402			continue
403		}
404		// With the scope the call gets past access (and fails on its subject),
405		// with or without repo:read (GO-001; TestAgentPresetWorksOnItsPrivateRepository
406		// shows the calls that succeed, as some of these fail with not found on
407		// their subject).
408		assert.NotErrorIs(t, call(agent), domain.ErrForbidden, what+": the agent preset")
409		assert.NotErrorIs(t, call(agent), domain.ErrUnauthenticated, what+": the agent preset")
410		assert.NotErrorIs(t, call(mrW), domain.ErrForbidden, what)
411		assert.NotErrorIs(t, call(mrW), domain.ErrUnauthenticated, what)
412	}
413
414	// On a public repository an anonymous caller reads, but writing is
415	// unauthenticated rather than not found.
416	pubName := name(t, "pub")
417	_, err = r.repos.Create(r.ctx, root, ports.CreateRepoParams{Name: pubName, Visibility: domain.VisibilityPublic})
418	require.NoError(t, err)
419	_, err = r.mrs.List(r.ctx, domain.Anonymous(), pubName, ports.ListMergeRequestsParams{})
420	assert.NoError(t, err)
421	_, err = r.mrs.Create(r.ctx, domain.Anonymous(), pubName, ports.CreateMergeRequestParams{Title: "t", Source: "a", Target: "b"})
422	assert.ErrorIs(t, err, domain.ErrUnauthenticated)
423}
424
425func TestListMergeRequests(t *testing.T) {
426	r := newMRRig(t)
427	r.seed()
428	var nums []int64
429	for i := range 5 {
430		b := fmt.Sprintf("f%d", i)
431		r.feature(b)
432		nums = append(nums, r.open(b).Number)
433	}
434	_, err := r.mrs.Close(r.ctx, root, r.repo, 2)
435	require.NoError(t, err)
436	r.sh("checkout", "-q", "main")
437	merged := r.feature("f0")
438	_, err = r.mrs.Merge(r.ctx, root, r.repo, 1, ports.MergeParams{HeadID: merged})
439	require.NoError(t, err)
440	r.sh("branch", "-D", "f4")
441	r.run(r.bare(), r.env(author), "branch", "-D", "f4")
442
443	numbers := func(p ports.MergeRequestPage) []int64 {
444		var out []int64
445		for _, m := range p.MergeRequests {
446			out = append(out, m.Number)
447		}
448		return out
449	}
450	all, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
451	require.NoError(t, err)
452	assert.Equal(t, []int64{5, 4, 3, 2, 1}, numbers(all), "highest number first")
453	assert.Empty(t, all.NextPageToken)
454
455	open, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{State: domain.MRStateOpen})
456	require.NoError(t, err)
457	assert.Equal(t, []int64{5, 4, 3}, numbers(open))
458	for _, m := range open.MergeRequests {
459		if m.Number == 5 {
460			assert.True(t, m.HeadID.IsZero(), "the branch of !5 was deleted")
461		} else {
462			assert.False(t, m.HeadID.IsZero(), "an open request carries its head")
463		}
464	}
465	closed, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{State: domain.MRStateClosed})
466	require.NoError(t, err)
467	assert.Equal(t, []int64{2}, numbers(closed))
468	mergedPage, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{State: domain.MRStateMerged})
469	require.NoError(t, err)
470	assert.Equal(t, []int64{1}, numbers(mergedPage))
471	assert.Equal(t, merged, mergedPage.MergeRequests[0].HeadID, "a merged request keeps the head it had")
472
473	// Paging: the token names the last number.
474	p1, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageSize: 2})
475	require.NoError(t, err)
476	assert.Equal(t, []int64{5, 4}, numbers(p1))
477	require.NotEmpty(t, p1.NextPageToken)
478	// A request created between the pages does not shift them.
479	r.feature("late")
480	r.open("late")
481	p2, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageSize: 2, PageToken: p1.NextPageToken})
482	require.NoError(t, err)
483	assert.Equal(t, []int64{3, 2}, numbers(p2))
484	p3, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageSize: 2, PageToken: p2.NextPageToken})
485	require.NoError(t, err)
486	assert.Equal(t, []int64{1}, numbers(p3))
487	assert.Empty(t, p3.NextPageToken)
488
489	for _, tok := range []string{"garbage", "bW9yZQ"} {
490		_, err = r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageToken: tok})
491		assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
492	}
493	_, err = r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageSize: -1})
494	assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
495	_ = nums
496}
497
498func TestGetMergeRequest(t *testing.T) {
499	r := newMRRig(t)
500	main := r.seed()
501	head := r.feature("feat")
502	mr := r.open("feat")
503	_, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "first"})
504	require.NoError(t, err)
505	_, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "second"})
506	require.NoError(t, err)
507
508	d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
509	require.NoError(t, err)
510	assert.Equal(t, head, d.MergeRequest.HeadID)
511	assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head")
512	require.Len(t, d.Commits, 2)
513	assert.Equal(t, "add c", d.Commits[0].Summary, "newest first")
514	assert.False(t, d.CommitsTruncated)
515	assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, d.Stats)
516	require.Len(t, d.Diff.Files, 2)
517	require.Len(t, d.Comments, 2)
518	assert.Equal(t, "first", d.Comments[0].Text.String(), "oldest first")
519	assert.True(t, d.LastReadID.IsZero())
520	assert.False(t, d.LastReadLost)
521
522	// The target moved on: the base stays the merge base, not the new tip.
523	r.sh("checkout", "-q", "main")
524	r.commit(author, "later.txt", "x\n", "later on main")
525	r.push("main")
526	d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
527	require.NoError(t, err)
528	assert.Equal(t, main, d.DiffBaseID)
529	assert.Len(t, d.Commits, 2, "the commits that main lacks")
530
531	// A deleted source branch: no commits, no diff, an empty head.
532	r.run(r.bare(), r.env(author), "branch", "-D", "feat")
533	d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
534	require.NoError(t, err)
535	assert.True(t, d.MergeRequest.HeadID.IsZero())
536	assert.Empty(t, d.Commits)
537	assert.True(t, d.DiffBaseID.IsZero())
538	assert.Empty(t, d.Diff.Files)
539	assert.Len(t, d.Comments, 2)
540}
541
542func TestGetMergeRequestTruncatesCommits(t *testing.T) {
543	r := newMRRig(t)
544	r.seed()
545	r.sh("checkout", "-q", "-B", "long", "main")
546	for i := range usecase.MaxMergeRequestCommits + 5 {
547		r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i))
548	}
549	r.push("long")
550	mr := r.open("long")
551	d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
552	require.NoError(t, err)
553	assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits)
554	assert.True(t, d.CommitsTruncated)
555	assert.Equal(t, "c104", d.Commits[0].Summary)
556}
557
558// AC3: a comment on a path:line must be a line of the current diff.
559func TestAddComment(t *testing.T) {
560	r := newMRRig(t)
561	r.seed()
562	r.feature("feat")
563	mr := r.open("feat")
564	n := mr.Number
565	// The diff: a.txt line 2 changed (old "two", new "2"); c.txt added, line 1.
566
567	tests := []struct {
568		name   string
569		params ports.AddCommentParams
570		field  string // "" for valid
571	}{
572		{"general", ports.AddCommentParams{Text: "looks good"}, ""},
573		{"multi-line general", ports.AddCommentParams{Text: "a\n\nb"}, ""},
574		{"new side of a changed line", ports.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideNew, Text: "x"}, ""},
575		{"old side of a changed line", ports.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideOld, Text: "x"}, ""},
576		{"context line", ports.AddCommentParams{Path: "a.txt", Line: 3, Side: domain.SideNew, Text: "x"}, ""},
577		{"added file", ports.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideNew, Text: "x"}, ""},
578		{"empty text", ports.AddCommentParams{Text: ""}, "text"},
579		{"blank text", ports.AddCommentParams{Text: " \n "}, "text"},
580		{"text too long", ports.AddCommentParams{Text: strings.Repeat("x", 8001)}, "text"},
581		{"text with a control character", ports.AddCommentParams{Text: "a\x1b[0m"}, "text"},
582		{"text with a tab", ports.AddCommentParams{Text: "a\tb"}, "text"},
583		{"text with a bidi override", ports.AddCommentParams{Text: "a\u202eb"}, "text"},
584		{"line outside the diff", ports.AddCommentParams{Path: "a.txt", Line: 40, Side: domain.SideNew, Text: "x"}, "path"},
585		{"unchanged file", ports.AddCommentParams{Path: "b.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"},
586		{"unknown path", ports.AddCommentParams{Path: "nope.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"},
587		{"deleted line on the wrong side", ports.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideOld, Text: "x"}, "path"},
588		{"path without a line", ports.AddCommentParams{Path: "a.txt", Side: domain.SideNew, Text: "x"}, "line"},
589		{"path without a side", ports.AddCommentParams{Path: "a.txt", Line: 2, Text: "x"}, "side"},
590		{"line without a path", ports.AddCommentParams{Line: 2, Side: domain.SideNew, Text: "x"}, "path"},
591		{"side without a path", ports.AddCommentParams{Side: domain.SideOld, Text: "x"}, "path"},
592		{"negative line", ports.AddCommentParams{Path: "a.txt", Line: -2, Side: domain.SideNew, Text: "x"}, "line"},
593	}
594	for _, tc := range tests {
595		t.Run(tc.name, func(t *testing.T) {
596			c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, tc.params)
597			if tc.field == "" {
598				require.NoError(t, err)
599				assert.Equal(t, "agent-1", c.Author)
600				assert.Equal(t, tc.params.Path, c.Path)
601				assert.Equal(t, tc.params.Line, c.Line)
602				assert.Equal(t, tc.params.Side, c.Side)
603				assert.Equal(t, mr.HeadID, c.CommitID, "the head it was written against")
604				assert.False(t, c.Resolved)
605				return
606			}
607			require.ErrorIs(t, err, domain.ErrInvalid)
608			assert.Contains(t, fieldsOf(t, err), tc.field)
609		})
610	}
611
612	// Ids count 1, 2, ... in order of creation and only the valid ones are kept.
613	d, err := r.mrs.Get(r.ctx, root, r.repo, n, ports.GetMergeRequestOptions{})
614	require.NoError(t, err)
615	var kept int
616	for _, tc := range tests {
617		if tc.field == "" {
618			kept++
619		}
620	}
621	require.Len(t, d.Comments, kept)
622	for i, c := range d.Comments {
623		assert.Equal(t, int64(i+1), c.ID)
624	}
625
626	t.Run("not open", func(t *testing.T) {
627		_, err := r.mrs.Close(r.ctx, mrW, r.repo, n)
628		require.NoError(t, err)
629		_, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Text: "late"})
630		assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
631		_, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, n, 1, true)
632		assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
633	})
634}
635
636func TestConcurrentComments(t *testing.T) {
637	r := newMRRig(t)
638	r.seed()
639	r.feature("feat")
640	mr := r.open("feat")
641	const workers = 10
642	var wg sync.WaitGroup
643	ids := make(chan int64, workers)
644	for i := range workers {
645		wg.Go(func() {
646			c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)})
647			if assert.NoError(t, err) {
648				ids <- c.ID
649			}
650		})
651	}
652	wg.Wait()
653	close(ids)
654	seen := map[int64]bool{}
655	for id := range ids {
656		assert.False(t, seen[id], "id %d handed out twice", id)
657		seen[id] = true
658	}
659	assert.Len(t, seen, workers)
660	d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
661	require.NoError(t, err)
662	assert.Len(t, d.Comments, workers)
663}
664
665func TestResolveComment(t *testing.T) {
666	r := newMRRig(t)
667	r.seed()
668	r.feature("feat")
669	mr := r.open("feat")
670	c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "fix this"})
671	require.NoError(t, err)
672	r.drain()
673
674	got, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true)
675	require.NoError(t, err)
676	assert.True(t, got.Resolved)
677	again, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true)
678	require.NoError(t, err, "resolving twice is not an error")
679	assert.True(t, again.Resolved)
680	undone, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false)
681	require.NoError(t, err)
682	assert.False(t, undone.Resolved)
683	undone, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false)
684	require.NoError(t, err)
685	assert.False(t, undone.Resolved)
686
687	_, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 99, true)
688	assert.ErrorIs(t, err, domain.ErrNotFound)
689	_, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true)
690	assert.ErrorIs(t, err, domain.ErrNotFound)
691
692	events := r.drain()
693	require.Len(t, events, 2, "only state changes publish resolution events")
694	assert.True(t, events[0].(domain.CommentResolved).Comment.Resolved)
695	assert.False(t, events[1].(domain.CommentResolved).Comment.Resolved)
696}
697
698func TestEventsOfAMergeRequest(t *testing.T) {
699	r := newMRRig(t)
700	r.seed()
701	head := r.feature("feat")
702	r.drain()
703
704	mr := r.open("feat")
705	c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "note"})
706	require.NoError(t, err)
707	_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
708	require.NoError(t, err)
709
710	r.feature("other")
711	other := r.open("other")
712	_, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number)
713	require.NoError(t, err)
714
715	evs := r.drain()
716	var kinds []string
717	for _, ev := range evs {
718		kinds = append(kinds, ev.EventKind())
719	}
720	assert.Equal(t, []string{"mr.opened", "mr.comment_added", "mr.merged", "repo.pushed", "mr.opened", "mr.closed"}, kinds)
721
722	opened := evs[0].(domain.MergeRequestOpened)
723	assert.Equal(t, mr.Number, opened.MergeRequest.Number)
724	assert.Equal(t, domain.VisibilityPrivate, opened.Visibility, "the event carries the visibility, so that it is filtered")
725	assert.False(t, opened.CreatedAt.IsZero())
726	added := evs[1].(domain.CommentAdded)
727	assert.Equal(t, c, added.Comment)
728	assert.Equal(t, mr.Number, added.Number)
729	merged := evs[2].(domain.MergeRequestMerged)
730	assert.Equal(t, domain.MRStateMerged, merged.MergeRequest.State)
731	push := evs[3].(domain.PushEvent)
732	assert.Equal(t, []domain.RefUpdate{{Old: r.seedID(), New: head.String(), Ref: "refs/heads/main"}}, push.Updates)
733	assert.Equal(t, domain.VisibilityPrivate, push.Visibility)
734	assert.Equal(t, domain.MRStateClosed, evs[5].(domain.MergeRequestClosed).MergeRequest.State)
735
736	// Failed calls publish nothing.
737	_, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number)
738	require.Error(t, err)
739	_, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "x"})
740	require.Error(t, err)
741	assert.Empty(t, r.drain())
742}
743
744// seedID is the id main had before the merges of the test: the parent of its
745// first feature commit.
746func (r *mrRig) seedID() string {
747	return r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/feat~2")
748}
749
750func TestMarkReadAndSinceLastRead(t *testing.T) {
751	r := newMRRig(t)
752	base := r.seed()
753	head1 := r.feature("feat")
754	mr := r.open("feat")
755	n := mr.Number
756
757	// Nothing recorded: the flag changes nothing.
758	d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
759	require.NoError(t, err)
760	assert.True(t, d.LastReadID.IsZero())
761	assert.False(t, d.LastReadLost)
762	assert.Equal(t, base, d.DiffBaseID)
763
764	// Without a head: the current head.
765	got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{})
766	require.NoError(t, err)
767	assert.Equal(t, head1, got)
768	d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
769	require.NoError(t, err)
770	assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set")
771	assert.Equal(t, base, d.DiffBaseID)
772
773	// New work on the branch: the diff since the last read has only that.
774	r.commit(author, "d.txt", "dee\n", "add d")
775	head2 := r.head()
776	r.push("feat")
777	d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
778	require.NoError(t, err)
779	assert.Equal(t, head1, d.DiffBaseID)
780	assert.Equal(t, head2, d.MergeRequest.HeadID)
781	require.Len(t, d.Diff.Files, 1)
782	assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath)
783	assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, d.Stats)
784	assert.Len(t, d.Commits, 3, "the commits are still those of the target..head range")
785
786	// Marking a commit that is not in the repository is refused.
787	_, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, oidFrom(t, strings.Repeat("1", 40)))
788	require.ErrorIs(t, err, domain.ErrInvalid)
789	assert.Contains(t, fieldsOf(t, err), "head_id")
790	// Neither is a tree.
791	tree := oidFrom(t, r.run(r.bare(), r.env(author), "rev-parse", head1.String()+"^{tree}"))
792	_, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, tree)
793	require.ErrorIs(t, err, domain.ErrInvalid)
794	// An explicit head is recorded as given.
795	_, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base)
796	require.NoError(t, err)
797	d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
798	require.NoError(t, err)
799	assert.Equal(t, base, d.DiffBaseID)
800}
801
802// AC6: the diff since the last read is right after a force-push; when the
803// recorded commit is gone the whole change is shown and the response says so.
804func TestSinceLastReadAfterForcePush(t *testing.T) {
805	r := newMRRig(t)
806	base := r.seed()
807	head1 := r.feature("feat")
808	mr := r.open("feat")
809	_, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, mr.Number, domain.ObjectID{})
810	require.NoError(t, err)
811
812	// Force-push: the last commit is amended (the old one is unreachable but
813	// still stored), and one more file is added.
814	r.sh("checkout", "-q", "feat")
815	require.NoError(t, os.WriteFile(filepath.Join(r.work, "c.txt"), []byte("see, amended\n"), 0o644))
816	r.sh("add", "c.txt")
817	r.sh("commit", "-q", "--amend", "-m", "add c, amended")
818	head2 := r.head()
819	require.NotEqual(t, head1, head2)
820	r.push("feat")
821	assert.Equal(t, head2, r.branchHead("feat"))
822	// head1 is not an ancestor of head2 any more.
823	r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String())
824
825	d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true})
826	require.NoError(t, err)
827	assert.False(t, d.LastReadLost)
828	assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read")
829	require.Len(t, d.Diff.Files, 1, "only what the force-push changed")
830	assert.Equal(t, "c.txt", d.Diff.Files[0].NewPath)
831	var added, removed []string
832	for _, l := range d.Diff.Files[0].Hunks[0].Lines {
833		switch l.Kind {
834		case domain.LineAddition:
835			added = append(added, l.Text)
836		case domain.LineDeletion:
837			removed = append(removed, l.Text)
838		}
839	}
840	assert.Equal(t, []string{"see, amended"}, added)
841	assert.Equal(t, []string{"see"}, removed)
842
843	// The whole change is still available without the flag.
844	d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
845	require.NoError(t, err)
846	assert.Equal(t, base, d.DiffBaseID)
847	assert.Len(t, d.Diff.Files, 2)
848
849	// Once the recorded commit is pruned, the response says so.
850	r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
851	d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true})
852	require.NoError(t, err)
853	assert.True(t, d.LastReadLost, "the recorded head is gone")
854	assert.Equal(t, head1, d.LastReadID, "still reported")
855	assert.Equal(t, base, d.DiffBaseID, "the diff is against the target")
856	assert.Len(t, d.Diff.Files, 2)
857}
858
859func TestMergeFastForward(t *testing.T) {
860	r := newMRRig(t)
861	main := r.seed()
862	head := r.feature("feat")
863	mr := r.open("feat")
864	r.drain()
865
866	merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
867	require.NoError(t, err)
868	assert.Equal(t, domain.MRStateMerged, merged.State)
869	assert.Equal(t, domain.MergeModeFastForward, merged.MergeMode, "the default mode")
870	assert.Equal(t, head, merged.MergedCommitID, "no commit is created")
871	assert.Equal(t, head, merged.HeadID)
872	assert.Equal(t, head, r.branchHead("main"))
873	assert.Equal(t, "root", merged.Author)
874	_ = main
875
876	// The stored request is the merged one, and reads keep a diff.
877	d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
878	require.NoError(t, err)
879	assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
880	assert.Equal(t, head, d.MergeRequest.HeadID)
881	assert.Equal(t, head, d.MergeRequest.MergedCommitID)
882	assert.Equal(t, main, d.DiffBaseID, "the base of the merge stays readable")
883	assert.Len(t, d.Commits, 2, "the commits that were merged")
884	assert.Len(t, d.Diff.Files, 2)
885
886	// Repeating it, or touching a final request, is a failed precondition.
887	_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
888	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
889	_, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
890	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
891	_, err = r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "x"})
892	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
893}
894
895// A draft is not merged until it is marked ready; ready drops the prefix of
896// the title, and only for an open draft. CountOpen counts drafts as open.
897func TestDraftMergeRequest(t *testing.T) {
898	r := newMRRig(t)
899	r.seed()
900	head := r.feature("feat")
901	mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "WIP: add feat", Source: "feat"})
902	require.NoError(t, err)
903
904	_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
905	require.ErrorIs(t, err, domain.ErrPreconditionFailed)
906	assert.Contains(t, err.Error(), "draft")
907
908	n, err := r.mrs.CountOpen(r.ctx, reader, r.repo)
909	require.NoError(t, err)
910	assert.Equal(t, 1, n)
911
912	_, err = r.mrs.MarkReady(r.ctx, reader, r.repo, mr.Number)
913	require.Error(t, err, "mr:write is needed")
914	r.drain()
915	ready, err := r.mrs.MarkReady(r.ctx, mrW, r.repo, mr.Number)
916	require.NoError(t, err)
917	assert.Equal(t, "add feat", ready.Title.String())
918	assert.False(t, ready.IsDraft())
919	evs := r.drain()
920	require.Len(t, evs, 1, "live pages learn the request is ready")
921	assert.Equal(t, "add feat", evs[0].(domain.MergeRequestReady).MergeRequest.Title.String())
922	_, err = r.mrs.MarkReady(r.ctx, mrW, r.repo, mr.Number)
923	require.ErrorIs(t, err, domain.ErrPreconditionFailed, "not a draft any more")
924
925	_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
926	require.NoError(t, err)
927	n, err = r.mrs.CountOpen(r.ctx, reader, r.repo)
928	require.NoError(t, err)
929	assert.Zero(t, n)
930}
931
932// AC4: the checks of a merge.
933func TestMergeRefusals(t *testing.T) {
934	t.Run("a branch that moved since the review", func(t *testing.T) {
935		r := newMRRig(t)
936		r.seed()
937		head := r.feature("feat")
938		mr := r.open("feat")
939		r.commit(author, "more.txt", "m\n", "pushed after the review")
940		r.push("feat")
941		_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
942		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
943		assert.Contains(t, err.Error(), "branch moved, read again")
944		assert.NotEqual(t, head, r.branchHead("main"), "main did not move")
945		// Reading again gives the new head, which merges.
946		d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
947		require.NoError(t, err)
948		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID})
949		require.NoError(t, err)
950	})
951
952	t.Run("a target that is not an ancestor of the head", func(t *testing.T) {
953		r := newMRRig(t)
954		r.seed()
955		head := r.feature("feat")
956		mr := r.open("feat")
957		r.sh("checkout", "-q", "main")
958		r.commit(author, "news.txt", "n\n", "main moved")
959		r.push("main")
960		tip := r.branchHead("main")
961
962		for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
963			_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
964			require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)
965			assert.Contains(t, err.Error(), "rebase", mode)
966		}
967		assert.Equal(t, tip, r.branchHead("main"))
968		d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
969		require.NoError(t, err)
970		assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
971
972		// After a rebase and a push it merges.
973		r.sh("checkout", "-q", "feat")
974		r.sh("rebase", "-q", "main")
975		r.push("feat")
976		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: r.head()})
977		require.NoError(t, err)
978	})
979
980	t.Run("a mode the repository does not allow", func(t *testing.T) {
981		r := newMRRig(t)
982		r.seed()
983		head := r.feature("feat")
984		mr := r.open("feat")
985		modes := []domain.MergeMode{domain.MergeModeFastForward}
986		_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes})
987		require.NoError(t, err)
988
989		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
990		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
991		assert.Contains(t, err.Error(), "squash")
992		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
993		require.NoError(t, err, "fast-forward is allowed")
994	})
995
996	t.Run("only squash is allowed, so the default mode is refused", func(t *testing.T) {
997		r := newMRRig(t)
998		r.seed()
999		head := r.feature("feat")
1000		mr := r.open("feat")
1001		modes := []domain.MergeMode{domain.MergeModeSquash}
1002		_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes})
1003		require.NoError(t, err)
1004		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
1005		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
1006		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
1007		require.NoError(t, err)
1008	})
1009
1010	t.Run("bad arguments", func(t *testing.T) {
1011		r := newMRRig(t)
1012		r.seed()
1013		head := r.feature("feat")
1014		mr := r.open("feat")
1015		_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{})
1016		require.ErrorIs(t, err, domain.ErrInvalid)
1017		assert.Contains(t, fieldsOf(t, err), "head_id")
1018		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: "rebase"})
1019		require.ErrorIs(t, err, domain.ErrInvalid)
1020		assert.Contains(t, fieldsOf(t, err), "mode")
1021		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, ports.MergeParams{HeadID: head})
1022		assert.ErrorIs(t, err, domain.ErrNotFound)
1023	})
1024
1025	t.Run("a deleted source or target, or nothing to merge", func(t *testing.T) {
1026		r := newMRRig(t)
1027		r.seed()
1028		head := r.feature("feat")
1029		mr := r.open("feat")
1030		r.run(r.bare(), r.env(author), "branch", "-D", "feat")
1031		_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
1032		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
1033		assert.Contains(t, err.Error(), "no longer exists")
1034
1035		r.push("feat")
1036		r.run(r.bare(), r.env(author), "update-ref", "refs/heads/main", head.String())
1037		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
1038		require.ErrorIs(t, err, domain.ErrPreconditionFailed, "the target already is at the head")
1039		assert.Contains(t, err.Error(), "nothing to merge")
1040	})
1041}
1042
1043// AC4: merges of one repository run one at a time.
1044func TestConcurrentMergesSerialize(t *testing.T) {
1045	r := newMRRig(t)
1046	r.seed()
1047	const n = 6
1048	heads := map[int64]domain.ObjectID{}
1049	for i := range n {
1050		b := fmt.Sprintf("f%d", i)
1051		heads[r.openFeature(b).Number] = r.branchHead(b)
1052	}
1053
1054	type outcome struct {
1055		number int64
1056		err    error
1057	}
1058	results := make(chan outcome, n*2)
1059	var wg sync.WaitGroup
1060	for number, head := range heads {
1061		for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
1062			if mode == domain.MergeModeSquash && number%2 == 0 {
1063				continue
1064			}
1065			wg.Go(func() {
1066				_, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, ports.MergeParams{HeadID: head, Mode: mode})
1067				results <- outcome{number, err}
1068			})
1069		}
1070	}
1071	wg.Wait()
1072	close(results)
1073
1074	won := map[int64]int{}
1075	for o := range results {
1076		if o.err == nil {
1077			won[o.number]++
1078			continue
1079		}
1080		require.ErrorIs(t, o.err, domain.ErrPreconditionFailed, "a loser fails cleanly: %v", o.err)
1081	}
1082	// All the branches start from the same main: exactly one request can win,
1083	// once, whatever the mode, and main is where it left it.
1084	require.Len(t, won, 1, "exactly one merge request was merged: %v", won)
1085	for number, times := range won {
1086		assert.Equal(t, 1, times, "!%d merged twice", number)
1087		mr, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{})
1088		require.NoError(t, err)
1089		assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State)
1090		assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main"))
1091	}
1092	for number := range heads {
1093		if won[number] == 0 {
1094			d, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{})
1095			require.NoError(t, err)
1096			assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number)
1097		}
1098	}
1099}
1100
1101// openFeature pushes a branch from main with one commit and opens a request.
1102func (r *mrRig) openFeature(branch string) domain.MergeRequest {
1103	r.t.Helper()
1104	r.sh("checkout", "-q", "-B", branch, "main")
1105	r.commit(author, branch+".txt", branch+"\n", "add "+branch)
1106	r.push(branch)
1107	return r.open(branch)
1108}
1109
1110func TestSquashMerge(t *testing.T) {
1111	r := newMRRig(t)
1112	main := r.seed()
1113	// A branch of three commits by two authors; Bob wrote the first one.
1114	bob := domain.Identity{Name: "Bob", Email: "bob@example.org"}
1115	cy := domain.Identity{Name: "Cy", Email: "cy@example.org"}
1116	r.sh("checkout", "-q", "-B", "feat", "main")
1117	r.commit(bob, "x.txt", "x\n", "bob starts")
1118	r.commit(author, "y.txt", "y\n", "ada continues")
1119	r.commit(cy, "z.txt", "z\n", "cy finishes")
1120	r.commit(bob, "w.txt", "w\n", "bob again")
1121	r.push("feat")
1122	head := r.branchHead("feat")
1123	mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Add things", Source: "feat"})
1124	require.NoError(t, err)
1125	r.drain()
1126
1127	merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
1128	require.NoError(t, err)
1129	assert.Equal(t, domain.MergeModeSquash, merged.MergeMode)
1130	require.False(t, merged.MergedCommitID.IsZero())
1131	assert.NotEqual(t, head, merged.MergedCommitID, "a squash creates a commit")
1132	assert.Equal(t, merged.MergedCommitID, r.branchHead("main"))
1133
1134	sq := merged.MergedCommitID.String()
1135	g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
1136	assert.Equal(t, main.String(), g("rev-parse", sq+"^"), "the target is the only parent")
1137	assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", sq+"^{tree}"), "the tree of the head")
1138	assert.Equal(t, "Bob <bob@example.org>", g("log", "-1", "--format=%an <%ae>", sq), "author of the first commit")
1139	assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", sq))
1140	assert.Equal(t, "Add things (!1)\n\n- bob starts\n- ada continues\n- cy finishes\n- bob again\n\n"+
1141		"Co-authored-by: Ada Lovelace <ada@example.org>\nCo-authored-by: Cy <cy@example.org>",
1142		g("log", "-1", "--format=%B", sq))
1143	assert.Contains(t, g("cat-file", "commit", sq), "\ngpgsig -----BEGIN SSH SIGNATURE-----\n")
1144
1145	// Events: merged, then pushed for the target branch.
1146	evs := r.drain()
1147	require.Len(t, evs, 2)
1148	assert.Equal(t, merged.MergedCommitID, evs[0].(domain.MergeRequestMerged).MergeRequest.MergedCommitID)
1149	assert.Equal(t, []domain.RefUpdate{{Old: main.String(), New: sq, Ref: "refs/heads/main"}}, evs[1].(domain.PushEvent).Updates)
1150
1151	// The signature verifies with an allowed_signers file built from the
1152	// public key the server hands out.
1153	r.verifyCommit(sq)
1154}
1155
1156// verifyCommit checks the signature of a commit of the repository with
1157// `git verify-commit` and an allowed signers file made from the signing key
1158// info, and that another key does not verify.
1159func (r *mrRig) verifyCommit(id string) {
1160	r.t.Helper()
1161	if _, err := exec.LookPath("ssh-keygen"); err != nil {
1162		unusable(r.t, "ssh-keygen not found: install openssh to verify the signed commits (CI must have it)")
1163	}
1164	dir := r.t.TempDir()
1165	signers := filepath.Join(dir, "allowed_signers")
1166	require.NoError(r.t, os.WriteFile(signers, []byte(r.key.Info().AllowedSigners()+"\n"), 0o600))
1167	out := r.run(r.bare(), r.env(author), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", "--raw", id)
1168	assert.Contains(r.t, out, "Good \"git\" signature for ermite@git.example.org")
1169
1170	other, err := sshsig.New(bytes.Repeat([]byte{1}, ed25519.SeedSize))
1171	require.NoError(r.t, err)
1172	wrong := filepath.Join(dir, "wrong_signers")
1173	require.NoError(r.t, os.WriteFile(wrong, []byte("ermite@git.example.org "+other.PublicKey().AuthorizedKey+"\n"), 0o600))
1174	cmd := exec.Command("git", "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+wrong, "verify-commit", id)
1175	cmd.Dir, cmd.Env = r.bare(), r.env(author)
1176	assert.Error(r.t, cmd.Run(), "another key must not verify the commit")
1177}
1178
1179func TestSquashSingleCommitHasNoTrailer(t *testing.T) {
1180	r := newMRRig(t)
1181	r.seed()
1182	head := r.feature("solo") // two authors; make a one-author branch instead
1183	_ = head
1184	r.sh("checkout", "-q", "-B", "mine", "main")
1185	r.commit(author, "m.txt", "m\n", "only")
1186	r.push("mine")
1187	mr := r.open("mine")
1188	merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash})
1189	require.NoError(t, err)
1190	msg := r.run(r.bare(), r.env(author), "log", "-1", "--format=%B", merged.MergedCommitID.String())
1191	assert.Equal(t, "Add mine (!1)\n\n- only", msg)
1192	r.verifyCommit(merged.MergedCommitID.String())
1193}
1194
1195func TestUpdateRepoSettings(t *testing.T) {
1196	r := newMRRig(t)
1197	repo, err := r.repos.Get(r.ctx, root, r.repo)
1198	require.NoError(t, err)
1199	assert.Equal(t, domain.DefaultRepoSettings(), repo.Settings, "stored explicitly at creation")
1200
1201	modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
1202	off := false
1203	got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1204	require.NoError(t, err)
1205	assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)
1206	again, err := r.repos.Get(r.ctx, root, r.repo)
1207	require.NoError(t, err)
1208	assert.Equal(t, got.Settings, again.Settings)
1209
1210	// Only the named setting changes.
1211	on := true
1212	got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: &on})
1213	require.NoError(t, err)
1214	assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1215	assert.True(t, got.Settings.FailingChecksBlock)
1216
1217	for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
1218		bad := bad
1219		_, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &bad})
1220		assert.ErrorIs(t, err, domain.ErrInvalid, "%v", bad)
1221	}
1222	again, err = r.repos.Get(r.ctx, root, r.repo)
1223	require.NoError(t, err)
1224	assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
1225
1226	// repo:admin is needed, like for any other field.
1227	_, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{FailingChecksBlock: &off})
1228	assert.ErrorIs(t, err, domain.ErrForbidden)
1229}
1230
1231func TestDeletingARepositoryDropsItsMergeRequests(t *testing.T) {
1232	r := newMRRig(t)
1233	r.seed()
1234	r.feature("feat")
1235	r.open("feat")
1236	require.NoError(t, r.repos.Delete(r.ctx, root, r.repo))
1237
1238	_, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
1239	assert.ErrorIs(t, err, domain.ErrNotFound)
1240
1241	// Created again under the same name, it starts empty.
1242	_, err = r.repos.Create(r.ctx, root, ports.CreateRepoParams{Name: r.repo})
1243	require.NoError(t, err)
1244	page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
1245	require.NoError(t, err)
1246	assert.Empty(t, page.MergeRequests)
1247}
1248
1249func TestSigningKeyInfo(t *testing.T) {
1250	r := newMRRig(t)
1251	info := r.key.Info()
1252	assert.True(t, strings.HasPrefix(info.Key.AuthorizedKey, "ssh-ed25519 "))
1253	assert.True(t, strings.HasPrefix(info.Key.Fingerprint, "SHA256:"))
1254	assert.Equal(t, "ermite@git.example.org "+info.Key.AuthorizedKey, info.AllowedSigners())
1255	assert.Equal(t, domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, info.Committer)
1256}
1257
1258// GO-001: the agent preset (repo:write, mr:write) has no repo:read, yet it
1259// opens, comments on, resolves, marks read and closes merge requests on the
1260// private repository it pushed to. Reading them still needs repo:read.
1261func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) {
1262	r := newMRRig(t) // proj is private
1263	r.seed()
1264	head := r.feature("feat")
1265
1266	mr, err := r.mrs.Create(r.ctx, agent, r.repo, ports.CreateMergeRequestParams{Title: "From the agent", Source: "feat"})
1267	require.NoError(t, err)
1268	assert.Equal(t, head, mr.HeadID)
1269	assert.Equal(t, "agent-1", mr.Author)
1270
1271	c, err := r.mrs.AddComment(r.ctx, agent, r.repo, mr.Number, ports.AddCommentParams{Text: "note"})
1272	require.NoError(t, err)
1273	_, err = r.mrs.ResolveComment(r.ctx, agent, r.repo, mr.Number, c.ID, true)
1274	require.NoError(t, err)
1275	_, err = r.mrs.MarkRead(r.ctx, agent, r.repo, mr.Number, domain.ObjectID{})
1276	require.NoError(t, err)
1277
1278	// repo:write alone does not read merge requests.
1279	_, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{})
1280	assert.ErrorIs(t, err, domain.ErrNotFound)
1281	_, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, ports.GetMergeRequestOptions{})
1282	assert.ErrorIs(t, err, domain.ErrNotFound)
1283
1284	closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number)
1285	require.NoError(t, err)
1286	assert.Equal(t, domain.MRStateClosed, closed.State)
1287
1288	// An unknown repository is not found whatever the scopes.
1289	_, err = r.mrs.Create(r.ctx, agent, name(t, "ghost"), ports.CreateMergeRequestParams{Title: "t", Source: "feat"})
1290	assert.ErrorIs(t, err, domain.ErrNotFound)
1291	// A token limited to other repositories does not get the scope here.
1292	elsewhere := domain.TokenPrincipal(domain.Token{ID: "e", Scopes: []domain.Scope{domain.ScopeRepoWrite, domain.ScopeMRWrite}, Repos: []domain.RepoName{name(t, "else")}})
1293	_, err = r.mrs.Create(r.ctx, elsewhere, r.repo, ports.CreateMergeRequestParams{Title: "t", Source: "feat"})
1294	assert.ErrorIs(t, err, domain.ErrNotFound)
1295}
1296
1297// GO-006: a merge moves the target branch, so it records pushed_at and queues
1298// the maintenance of the repository, as a push does.
1299func TestMergeRecordsAPush(t *testing.T) {
1300	for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
1301		t.Run(string(mode), func(t *testing.T) {
1302			r := newMRRig(t)
1303			r.seed()
1304			head := r.feature("feat")
1305			mr := r.open("feat")
1306			before, err := r.repos.Get(r.ctx, root, r.repo)
1307			require.NoError(t, err)
1308			require.True(t, before.PushedAt.IsZero(), "no push recorded yet")
1309			assert.Empty(t, r.queued.names())
1310
1311			_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: head})
1312			require.NoError(t, err)
1313
1314			after, err := r.repos.Get(r.ctx, root, r.repo)
1315			require.NoError(t, err)
1316			assert.Equal(t, now, after.PushedAt)
1317			assert.Equal(t, []domain.RepoName{r.repo}, r.queued.names(), "maintenance queued")
1318		})
1319	}
1320}
1321
1322// GO-008: a merge request holds at most MaxCommentsPerMergeRequest comments.
1323func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
1324	r := newMRRig(t)
1325	r.seed()
1326	r.feature("feat")
1327	mr := r.open("feat")
1328	for i := range usecase.MaxCommentsPerMergeRequest {
1329		_, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)})
1330		require.NoError(t, err, "comment %d", i+1)
1331	}
1332	_, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"})
1333	assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1334	d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
1335	require.NoError(t, err)
1336	assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest)
1337
1338	// Resolving still works, and another request has its own budget.
1339	_, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 1, true)
1340	require.NoError(t, err)
1341	r.sh("checkout", "-q", "-B", "other", "main")
1342	r.commit(author, "o.txt", "o\n", "other")
1343	r.push("other")
1344	other := r.open("other")
1345	_, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "fine"})
1346	require.NoError(t, err)
1347}
1348
1349func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
1350	t.Run("start outside diff", func(t *testing.T) {
1351		r := newMRRig(t)
1352		r.seed()
1353		before := strings.Repeat("unchanged\n", 30)
1354		r.commit(author, "long.txt", before, "long base")
1355		r.push("main")
1356		r.sh("checkout", "-q", "-B", "range", "main")
1357		r.commit(author, "long.txt", before+"changed\n", "late hunk")
1358		r.push("range")
1359		mr := r.open("range")
1360		_, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Path: "long.txt", Line: 31, Side: domain.SideNew, Text: "valid"})
1361		require.NoError(t, err)
1362		_, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Path: "long.txt", StartLine: 1, Line: 31, Side: domain.SideNew, Text: "invalid range"})
1363		require.ErrorIs(t, err, domain.ErrInvalid)
1364		assert.Contains(t, fieldsOf(t, err), "start_line")
1365	})
1366	r := newMRRig(t)
1367	r.seed()
1368	head := r.feature("feat")
1369	mr := r.open("feat")
1370	add := func(p domain.Principal, params ports.AddCommentParams) (domain.Comment, error) {
1371		return r.mrs.AddComment(r.ctx, p, r.repo, mr.Number, params)
1372	}
1373	rootNote, err := add(root, ports.AddCommentParams{Path: "a.txt", StartLine: 1, Line: 3, Side: domain.SideNew, Text: "range"})
1374	require.NoError(t, err)
1375	reply, err := add(mrW, ports.AddCommentParams{ReplyTo: rootNote.ID, Text: "reply"})
1376	require.NoError(t, err)
1377	assert.Equal(t, rootNote.Path, reply.Path)
1378	assert.Equal(t, rootNote.StartLine, reply.StartLine)
1379	assert.Equal(t, rootNote.Line, reply.Line)
1380	assert.Equal(t, rootNote.Side, reply.Side)
1381	assert.Equal(t, head, reply.CommitID)
1382	assert.False(t, reply.Resolved)
1383	for _, params := range []ports.AddCommentParams{
1384		{ReplyTo: reply.ID, Text: "x"}, {ReplyTo: 999, Text: "x"}, {ReplyTo: rootNote.ID, Path: "a.txt", Text: "x"}, {ReplyTo: rootNote.ID, Line: 1, Text: "x"}, {ReplyTo: rootNote.ID, Side: domain.SideNew, Text: "x"}, {ReplyTo: rootNote.ID, StartLine: 1, Text: "x"}, {ReplyTo: rootNote.ID, Verdict: domain.VerdictApprove},
1385		{StartLine: 1, Text: "x"}, {Path: "a.txt", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}, {Path: "a.txt", StartLine: -1, Line: 3, Side: domain.SideNew, Text: "x"}, {Path: "a.txt", StartLine: 1, Line: 99, Side: domain.SideNew, Text: "x"},
1386	} {
1387		_, err := add(root, params)
1388		assert.ErrorIs(t, err, domain.ErrInvalid, "%+v", params)
1389	}
1390	_, err = r.mrs.ResolveComment(r.ctx, root, r.repo, mr.Number, reply.ID, true)
1391	assert.ErrorIs(t, err, domain.ErrInvalid)
1392	assert.ErrorContains(t, err, fmt.Sprintf("root ^%d", rootNote.ID))
1393	_, err = add(mrW, ports.AddCommentParams{Verdict: domain.VerdictApprove})
1394	assert.ErrorIs(t, err, domain.ErrForbidden)
1395	assert.ErrorContains(t, err, "this needs the mr:review scope")
1396	reviewer := domain.TokenPrincipal(domain.Token{Label: "reviewer", Scopes: []domain.Scope{domain.ScopeMRWrite, domain.ScopeMRReview}})
1397	approve, err := add(reviewer, ports.AddCommentParams{Verdict: domain.VerdictApprove})
1398	require.NoError(t, err)
1399	assert.True(t, approve.Resolved)
1400	changes, err := add(root, ports.AddCommentParams{Verdict: domain.VerdictChangesRequested})
1401	require.NoError(t, err)
1402	assert.False(t, changes.Resolved)
1403	_, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, approve.ID, "no")
1404	assert.ErrorIs(t, err, domain.ErrForbidden)
1405	_, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, approve.ID)
1406	assert.ErrorIs(t, err, domain.ErrForbidden)
1407	r.drain()
1408	edited, err := r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "updated")
1409	require.NoError(t, err)
1410	assert.False(t, edited.EditedAt.IsZero())
1411	assert.Equal(t, approve.Verdict, edited.Verdict)
1412	_, err = r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "updated")
1413	require.NoError(t, err)
1414	events := r.drain()
1415	require.Len(t, events, 1)
1416	assert.IsType(t, domain.CommentEdited{}, events[0])
1417	deleted, err := r.mrs.DeleteComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID)
1418	require.NoError(t, err)
1419	assert.True(t, deleted.Deleted)
1420	assert.Equal(t, "", deleted.Text.String())
1421	assert.Equal(t, approve.Verdict, deleted.Verdict)
1422	again, err := r.mrs.DeleteComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID)
1423	require.NoError(t, err)
1424	deleted.Version = again.Version // SaveComment assigns the new version on the next read.
1425	assert.Equal(t, deleted, again)
1426	events = r.drain()
1427	require.Len(t, events, 1)
1428	assert.IsType(t, domain.CommentDeleted{}, events[0])
1429	_, err = r.mrs.EditComment(r.ctx, reviewer, r.repo, mr.Number, approve.ID, "x")
1430	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1431	_, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, rootNote.ID, "")
1432	assert.ErrorIs(t, err, domain.ErrInvalid)
1433	_, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, rootNote.ID)
1434	require.NoError(t, err)
1435	_, err = add(root, ports.AddCommentParams{ReplyTo: rootNote.ID, Text: "x"})
1436	assert.ErrorIs(t, err, domain.ErrInvalid)
1437	r.commit(author, "a.txt", "changed\n2\nthree\n", "advance")
1438	r.push("feat")
1439	detail, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
1440	require.NoError(t, err)
1441	require.Len(t, detail.Verdicts, 2)
1442	assert.True(t, detail.Verdicts[0].Stale)
1443	assert.False(t, detail.Verdicts[1].Stale)
1444	_, err = r.mrs.Close(r.ctx, root, r.repo, mr.Number)
1445	require.NoError(t, err)
1446	_, err = r.mrs.EditComment(r.ctx, root, r.repo, mr.Number, changes.ID, "x")
1447	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1448	_, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
1449	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1450}

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