v0.25.0 4492065

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	assert.Empty(t, r.drain(), "a resolved comment has no event")
693}
694
695func TestEventsOfAMergeRequest(t *testing.T) {
696	r := newMRRig(t)
697	r.seed()
698	head := r.feature("feat")
699	r.drain()
700
701	mr := r.open("feat")
702	c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "note"})
703	require.NoError(t, err)
704	_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
705	require.NoError(t, err)
706
707	r.feature("other")
708	other := r.open("other")
709	_, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number)
710	require.NoError(t, err)
711
712	evs := r.drain()
713	var kinds []string
714	for _, ev := range evs {
715		kinds = append(kinds, ev.EventKind())
716	}
717	assert.Equal(t, []string{"mr.opened", "mr.comment_added", "mr.merged", "repo.pushed", "mr.opened", "mr.closed"}, kinds)
718
719	opened := evs[0].(domain.MergeRequestOpened)
720	assert.Equal(t, mr.Number, opened.MergeRequest.Number)
721	assert.Equal(t, domain.VisibilityPrivate, opened.Visibility, "the event carries the visibility, so that it is filtered")
722	assert.False(t, opened.CreatedAt.IsZero())
723	added := evs[1].(domain.CommentAdded)
724	assert.Equal(t, c, added.Comment)
725	assert.Equal(t, mr.Number, added.Number)
726	merged := evs[2].(domain.MergeRequestMerged)
727	assert.Equal(t, domain.MRStateMerged, merged.MergeRequest.State)
728	push := evs[3].(domain.PushEvent)
729	assert.Equal(t, []domain.RefUpdate{{Old: r.seedID(), New: head.String(), Ref: "refs/heads/main"}}, push.Updates)
730	assert.Equal(t, domain.VisibilityPrivate, push.Visibility)
731	assert.Equal(t, domain.MRStateClosed, evs[5].(domain.MergeRequestClosed).MergeRequest.State)
732
733	// Failed calls publish nothing.
734	_, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number)
735	require.Error(t, err)
736	_, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "x"})
737	require.Error(t, err)
738	assert.Empty(t, r.drain())
739}
740
741// seedID is the id main had before the merges of the test: the parent of its
742// first feature commit.
743func (r *mrRig) seedID() string {
744	return r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/feat~2")
745}
746
747func TestMarkReadAndSinceLastRead(t *testing.T) {
748	r := newMRRig(t)
749	base := r.seed()
750	head1 := r.feature("feat")
751	mr := r.open("feat")
752	n := mr.Number
753
754	// Nothing recorded: the flag changes nothing.
755	d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
756	require.NoError(t, err)
757	assert.True(t, d.LastReadID.IsZero())
758	assert.False(t, d.LastReadLost)
759	assert.Equal(t, base, d.DiffBaseID)
760
761	// Without a head: the current head.
762	got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{})
763	require.NoError(t, err)
764	assert.Equal(t, head1, got)
765	d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
766	require.NoError(t, err)
767	assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set")
768	assert.Equal(t, base, d.DiffBaseID)
769
770	// New work on the branch: the diff since the last read has only that.
771	r.commit(author, "d.txt", "dee\n", "add d")
772	head2 := r.head()
773	r.push("feat")
774	d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
775	require.NoError(t, err)
776	assert.Equal(t, head1, d.DiffBaseID)
777	assert.Equal(t, head2, d.MergeRequest.HeadID)
778	require.Len(t, d.Diff.Files, 1)
779	assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath)
780	assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, d.Stats)
781	assert.Len(t, d.Commits, 3, "the commits are still those of the target..head range")
782
783	// Marking a commit that is not in the repository is refused.
784	_, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, oidFrom(t, strings.Repeat("1", 40)))
785	require.ErrorIs(t, err, domain.ErrInvalid)
786	assert.Contains(t, fieldsOf(t, err), "head_id")
787	// Neither is a tree.
788	tree := oidFrom(t, r.run(r.bare(), r.env(author), "rev-parse", head1.String()+"^{tree}"))
789	_, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, tree)
790	require.ErrorIs(t, err, domain.ErrInvalid)
791	// An explicit head is recorded as given.
792	_, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base)
793	require.NoError(t, err)
794	d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true})
795	require.NoError(t, err)
796	assert.Equal(t, base, d.DiffBaseID)
797}
798
799// AC6: the diff since the last read is right after a force-push; when the
800// recorded commit is gone the whole change is shown and the response says so.
801func TestSinceLastReadAfterForcePush(t *testing.T) {
802	r := newMRRig(t)
803	base := r.seed()
804	head1 := r.feature("feat")
805	mr := r.open("feat")
806	_, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, mr.Number, domain.ObjectID{})
807	require.NoError(t, err)
808
809	// Force-push: the last commit is amended (the old one is unreachable but
810	// still stored), and one more file is added.
811	r.sh("checkout", "-q", "feat")
812	require.NoError(t, os.WriteFile(filepath.Join(r.work, "c.txt"), []byte("see, amended\n"), 0o644))
813	r.sh("add", "c.txt")
814	r.sh("commit", "-q", "--amend", "-m", "add c, amended")
815	head2 := r.head()
816	require.NotEqual(t, head1, head2)
817	r.push("feat")
818	assert.Equal(t, head2, r.branchHead("feat"))
819	// head1 is not an ancestor of head2 any more.
820	r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String())
821
822	d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true})
823	require.NoError(t, err)
824	assert.False(t, d.LastReadLost)
825	assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read")
826	require.Len(t, d.Diff.Files, 1, "only what the force-push changed")
827	assert.Equal(t, "c.txt", d.Diff.Files[0].NewPath)
828	var added, removed []string
829	for _, l := range d.Diff.Files[0].Hunks[0].Lines {
830		switch l.Kind {
831		case domain.LineAddition:
832			added = append(added, l.Text)
833		case domain.LineDeletion:
834			removed = append(removed, l.Text)
835		}
836	}
837	assert.Equal(t, []string{"see, amended"}, added)
838	assert.Equal(t, []string{"see"}, removed)
839
840	// The whole change is still available without the flag.
841	d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
842	require.NoError(t, err)
843	assert.Equal(t, base, d.DiffBaseID)
844	assert.Len(t, d.Diff.Files, 2)
845
846	// Once the recorded commit is pruned, the response says so.
847	r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
848	d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true})
849	require.NoError(t, err)
850	assert.True(t, d.LastReadLost, "the recorded head is gone")
851	assert.Equal(t, head1, d.LastReadID, "still reported")
852	assert.Equal(t, base, d.DiffBaseID, "the diff is against the target")
853	assert.Len(t, d.Diff.Files, 2)
854}
855
856func TestMergeFastForward(t *testing.T) {
857	r := newMRRig(t)
858	main := r.seed()
859	head := r.feature("feat")
860	mr := r.open("feat")
861	r.drain()
862
863	merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
864	require.NoError(t, err)
865	assert.Equal(t, domain.MRStateMerged, merged.State)
866	assert.Equal(t, domain.MergeModeFastForward, merged.MergeMode, "the default mode")
867	assert.Equal(t, head, merged.MergedCommitID, "no commit is created")
868	assert.Equal(t, head, merged.HeadID)
869	assert.Equal(t, head, r.branchHead("main"))
870	assert.Equal(t, "root", merged.Author)
871	_ = main
872
873	// The stored request is the merged one, and reads keep a diff.
874	d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
875	require.NoError(t, err)
876	assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
877	assert.Equal(t, head, d.MergeRequest.HeadID)
878	assert.Equal(t, head, d.MergeRequest.MergedCommitID)
879	assert.Equal(t, main, d.DiffBaseID, "the base of the merge stays readable")
880	assert.Len(t, d.Commits, 2, "the commits that were merged")
881	assert.Len(t, d.Diff.Files, 2)
882
883	// Repeating it, or touching a final request, is a failed precondition.
884	_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
885	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
886	_, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
887	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
888	_, err = r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "x"})
889	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
890}
891
892// A draft is not merged until it is marked ready; ready drops the prefix of
893// the title, and only for an open draft. CountOpen counts drafts as open.
894func TestDraftMergeRequest(t *testing.T) {
895	r := newMRRig(t)
896	r.seed()
897	head := r.feature("feat")
898	mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "WIP: add feat", Source: "feat"})
899	require.NoError(t, err)
900
901	_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
902	require.ErrorIs(t, err, domain.ErrPreconditionFailed)
903	assert.Contains(t, err.Error(), "draft")
904
905	n, err := r.mrs.CountOpen(r.ctx, reader, r.repo)
906	require.NoError(t, err)
907	assert.Equal(t, 1, n)
908
909	_, err = r.mrs.MarkReady(r.ctx, reader, r.repo, mr.Number)
910	require.Error(t, err, "mr:write is needed")
911	r.drain()
912	ready, err := r.mrs.MarkReady(r.ctx, mrW, r.repo, mr.Number)
913	require.NoError(t, err)
914	assert.Equal(t, "add feat", ready.Title.String())
915	assert.False(t, ready.IsDraft())
916	evs := r.drain()
917	require.Len(t, evs, 1, "live pages learn the request is ready")
918	assert.Equal(t, "add feat", evs[0].(domain.MergeRequestReady).MergeRequest.Title.String())
919	_, err = r.mrs.MarkReady(r.ctx, mrW, r.repo, mr.Number)
920	require.ErrorIs(t, err, domain.ErrPreconditionFailed, "not a draft any more")
921
922	_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
923	require.NoError(t, err)
924	n, err = r.mrs.CountOpen(r.ctx, reader, r.repo)
925	require.NoError(t, err)
926	assert.Zero(t, n)
927}
928
929// AC4: the checks of a merge.
930func TestMergeRefusals(t *testing.T) {
931	t.Run("a branch that moved since the review", func(t *testing.T) {
932		r := newMRRig(t)
933		r.seed()
934		head := r.feature("feat")
935		mr := r.open("feat")
936		r.commit(author, "more.txt", "m\n", "pushed after the review")
937		r.push("feat")
938		_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
939		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
940		assert.Contains(t, err.Error(), "branch moved, read again")
941		assert.NotEqual(t, head, r.branchHead("main"), "main did not move")
942		// Reading again gives the new head, which merges.
943		d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
944		require.NoError(t, err)
945		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID})
946		require.NoError(t, err)
947	})
948
949	t.Run("a target that is not an ancestor of the head", func(t *testing.T) {
950		r := newMRRig(t)
951		r.seed()
952		head := r.feature("feat")
953		mr := r.open("feat")
954		r.sh("checkout", "-q", "main")
955		r.commit(author, "news.txt", "n\n", "main moved")
956		r.push("main")
957		tip := r.branchHead("main")
958
959		for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
960			_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
961			require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)
962			assert.Contains(t, err.Error(), "rebase", mode)
963		}
964		assert.Equal(t, tip, r.branchHead("main"))
965		d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
966		require.NoError(t, err)
967		assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
968
969		// After a rebase and a push it merges.
970		r.sh("checkout", "-q", "feat")
971		r.sh("rebase", "-q", "main")
972		r.push("feat")
973		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: r.head()})
974		require.NoError(t, err)
975	})
976
977	t.Run("a mode the repository does not allow", func(t *testing.T) {
978		r := newMRRig(t)
979		r.seed()
980		head := r.feature("feat")
981		mr := r.open("feat")
982		modes := []domain.MergeMode{domain.MergeModeFastForward}
983		_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes})
984		require.NoError(t, err)
985
986		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
987		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
988		assert.Contains(t, err.Error(), "squash")
989		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
990		require.NoError(t, err, "fast-forward is allowed")
991	})
992
993	t.Run("only squash is allowed, so the default mode is refused", func(t *testing.T) {
994		r := newMRRig(t)
995		r.seed()
996		head := r.feature("feat")
997		mr := r.open("feat")
998		modes := []domain.MergeMode{domain.MergeModeSquash}
999		_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes})
1000		require.NoError(t, err)
1001		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
1002		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
1003		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
1004		require.NoError(t, err)
1005	})
1006
1007	t.Run("bad arguments", func(t *testing.T) {
1008		r := newMRRig(t)
1009		r.seed()
1010		head := r.feature("feat")
1011		mr := r.open("feat")
1012		_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{})
1013		require.ErrorIs(t, err, domain.ErrInvalid)
1014		assert.Contains(t, fieldsOf(t, err), "head_id")
1015		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: "rebase"})
1016		require.ErrorIs(t, err, domain.ErrInvalid)
1017		assert.Contains(t, fieldsOf(t, err), "mode")
1018		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, ports.MergeParams{HeadID: head})
1019		assert.ErrorIs(t, err, domain.ErrNotFound)
1020	})
1021
1022	t.Run("a deleted source or target, or nothing to merge", func(t *testing.T) {
1023		r := newMRRig(t)
1024		r.seed()
1025		head := r.feature("feat")
1026		mr := r.open("feat")
1027		r.run(r.bare(), r.env(author), "branch", "-D", "feat")
1028		_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
1029		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
1030		assert.Contains(t, err.Error(), "no longer exists")
1031
1032		r.push("feat")
1033		r.run(r.bare(), r.env(author), "update-ref", "refs/heads/main", head.String())
1034		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
1035		require.ErrorIs(t, err, domain.ErrPreconditionFailed, "the target already is at the head")
1036		assert.Contains(t, err.Error(), "nothing to merge")
1037	})
1038}
1039
1040// AC4: merges of one repository run one at a time.
1041func TestConcurrentMergesSerialize(t *testing.T) {
1042	r := newMRRig(t)
1043	r.seed()
1044	const n = 6
1045	heads := map[int64]domain.ObjectID{}
1046	for i := range n {
1047		b := fmt.Sprintf("f%d", i)
1048		heads[r.openFeature(b).Number] = r.branchHead(b)
1049	}
1050
1051	type outcome struct {
1052		number int64
1053		err    error
1054	}
1055	results := make(chan outcome, n*2)
1056	var wg sync.WaitGroup
1057	for number, head := range heads {
1058		for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
1059			if mode == domain.MergeModeSquash && number%2 == 0 {
1060				continue
1061			}
1062			wg.Go(func() {
1063				_, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, ports.MergeParams{HeadID: head, Mode: mode})
1064				results <- outcome{number, err}
1065			})
1066		}
1067	}
1068	wg.Wait()
1069	close(results)
1070
1071	won := map[int64]int{}
1072	for o := range results {
1073		if o.err == nil {
1074			won[o.number]++
1075			continue
1076		}
1077		require.ErrorIs(t, o.err, domain.ErrPreconditionFailed, "a loser fails cleanly: %v", o.err)
1078	}
1079	// All the branches start from the same main: exactly one request can win,
1080	// once, whatever the mode, and main is where it left it.
1081	require.Len(t, won, 1, "exactly one merge request was merged: %v", won)
1082	for number, times := range won {
1083		assert.Equal(t, 1, times, "!%d merged twice", number)
1084		mr, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{})
1085		require.NoError(t, err)
1086		assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State)
1087		assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main"))
1088	}
1089	for number := range heads {
1090		if won[number] == 0 {
1091			d, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{})
1092			require.NoError(t, err)
1093			assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number)
1094		}
1095	}
1096}
1097
1098// openFeature pushes a branch from main with one commit and opens a request.
1099func (r *mrRig) openFeature(branch string) domain.MergeRequest {
1100	r.t.Helper()
1101	r.sh("checkout", "-q", "-B", branch, "main")
1102	r.commit(author, branch+".txt", branch+"\n", "add "+branch)
1103	r.push(branch)
1104	return r.open(branch)
1105}
1106
1107func TestSquashMerge(t *testing.T) {
1108	r := newMRRig(t)
1109	main := r.seed()
1110	// A branch of three commits by two authors; Bob wrote the first one.
1111	bob := domain.Identity{Name: "Bob", Email: "bob@example.org"}
1112	cy := domain.Identity{Name: "Cy", Email: "cy@example.org"}
1113	r.sh("checkout", "-q", "-B", "feat", "main")
1114	r.commit(bob, "x.txt", "x\n", "bob starts")
1115	r.commit(author, "y.txt", "y\n", "ada continues")
1116	r.commit(cy, "z.txt", "z\n", "cy finishes")
1117	r.commit(bob, "w.txt", "w\n", "bob again")
1118	r.push("feat")
1119	head := r.branchHead("feat")
1120	mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Add things", Source: "feat"})
1121	require.NoError(t, err)
1122	r.drain()
1123
1124	merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
1125	require.NoError(t, err)
1126	assert.Equal(t, domain.MergeModeSquash, merged.MergeMode)
1127	require.False(t, merged.MergedCommitID.IsZero())
1128	assert.NotEqual(t, head, merged.MergedCommitID, "a squash creates a commit")
1129	assert.Equal(t, merged.MergedCommitID, r.branchHead("main"))
1130
1131	sq := merged.MergedCommitID.String()
1132	g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
1133	assert.Equal(t, main.String(), g("rev-parse", sq+"^"), "the target is the only parent")
1134	assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", sq+"^{tree}"), "the tree of the head")
1135	assert.Equal(t, "Bob <bob@example.org>", g("log", "-1", "--format=%an <%ae>", sq), "author of the first commit")
1136	assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", sq))
1137	assert.Equal(t, "Add things (!1)\n\n- bob starts\n- ada continues\n- cy finishes\n- bob again\n\n"+
1138		"Co-authored-by: Ada Lovelace <ada@example.org>\nCo-authored-by: Cy <cy@example.org>",
1139		g("log", "-1", "--format=%B", sq))
1140	assert.Contains(t, g("cat-file", "commit", sq), "\ngpgsig -----BEGIN SSH SIGNATURE-----\n")
1141
1142	// Events: merged, then pushed for the target branch.
1143	evs := r.drain()
1144	require.Len(t, evs, 2)
1145	assert.Equal(t, merged.MergedCommitID, evs[0].(domain.MergeRequestMerged).MergeRequest.MergedCommitID)
1146	assert.Equal(t, []domain.RefUpdate{{Old: main.String(), New: sq, Ref: "refs/heads/main"}}, evs[1].(domain.PushEvent).Updates)
1147
1148	// The signature verifies with an allowed_signers file built from the
1149	// public key the server hands out.
1150	r.verifyCommit(sq)
1151}
1152
1153// verifyCommit checks the signature of a commit of the repository with
1154// `git verify-commit` and an allowed signers file made from the signing key
1155// info, and that another key does not verify.
1156func (r *mrRig) verifyCommit(id string) {
1157	r.t.Helper()
1158	if _, err := exec.LookPath("ssh-keygen"); err != nil {
1159		unusable(r.t, "ssh-keygen not found: install openssh to verify the signed commits (CI must have it)")
1160	}
1161	dir := r.t.TempDir()
1162	signers := filepath.Join(dir, "allowed_signers")
1163	require.NoError(r.t, os.WriteFile(signers, []byte(r.key.Info().AllowedSigners()+"\n"), 0o600))
1164	out := r.run(r.bare(), r.env(author), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", "--raw", id)
1165	assert.Contains(r.t, out, "Good \"git\" signature for ermite@git.example.org")
1166
1167	other, err := sshsig.New(bytes.Repeat([]byte{1}, ed25519.SeedSize))
1168	require.NoError(r.t, err)
1169	wrong := filepath.Join(dir, "wrong_signers")
1170	require.NoError(r.t, os.WriteFile(wrong, []byte("ermite@git.example.org "+other.PublicKey().AuthorizedKey+"\n"), 0o600))
1171	cmd := exec.Command("git", "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+wrong, "verify-commit", id)
1172	cmd.Dir, cmd.Env = r.bare(), r.env(author)
1173	assert.Error(r.t, cmd.Run(), "another key must not verify the commit")
1174}
1175
1176func TestSquashSingleCommitHasNoTrailer(t *testing.T) {
1177	r := newMRRig(t)
1178	r.seed()
1179	head := r.feature("solo") // two authors; make a one-author branch instead
1180	_ = head
1181	r.sh("checkout", "-q", "-B", "mine", "main")
1182	r.commit(author, "m.txt", "m\n", "only")
1183	r.push("mine")
1184	mr := r.open("mine")
1185	merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash})
1186	require.NoError(t, err)
1187	msg := r.run(r.bare(), r.env(author), "log", "-1", "--format=%B", merged.MergedCommitID.String())
1188	assert.Equal(t, "Add mine (!1)\n\n- only", msg)
1189	r.verifyCommit(merged.MergedCommitID.String())
1190}
1191
1192func TestUpdateRepoSettings(t *testing.T) {
1193	r := newMRRig(t)
1194	repo, err := r.repos.Get(r.ctx, root, r.repo)
1195	require.NoError(t, err)
1196	assert.Equal(t, domain.DefaultRepoSettings(), repo.Settings, "stored explicitly at creation")
1197
1198	modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
1199	off := false
1200	got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1201	require.NoError(t, err)
1202	assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)
1203	again, err := r.repos.Get(r.ctx, root, r.repo)
1204	require.NoError(t, err)
1205	assert.Equal(t, got.Settings, again.Settings)
1206
1207	// Only the named setting changes.
1208	on := true
1209	got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: &on})
1210	require.NoError(t, err)
1211	assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1212	assert.True(t, got.Settings.FailingChecksBlock)
1213
1214	for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
1215		bad := bad
1216		_, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &bad})
1217		assert.ErrorIs(t, err, domain.ErrInvalid, "%v", bad)
1218	}
1219	again, err = r.repos.Get(r.ctx, root, r.repo)
1220	require.NoError(t, err)
1221	assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
1222
1223	// repo:admin is needed, like for any other field.
1224	_, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{FailingChecksBlock: &off})
1225	assert.ErrorIs(t, err, domain.ErrForbidden)
1226}
1227
1228func TestDeletingARepositoryDropsItsMergeRequests(t *testing.T) {
1229	r := newMRRig(t)
1230	r.seed()
1231	r.feature("feat")
1232	r.open("feat")
1233	require.NoError(t, r.repos.Delete(r.ctx, root, r.repo))
1234
1235	_, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
1236	assert.ErrorIs(t, err, domain.ErrNotFound)
1237
1238	// Created again under the same name, it starts empty.
1239	_, err = r.repos.Create(r.ctx, root, ports.CreateRepoParams{Name: r.repo})
1240	require.NoError(t, err)
1241	page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
1242	require.NoError(t, err)
1243	assert.Empty(t, page.MergeRequests)
1244}
1245
1246func TestSigningKeyInfo(t *testing.T) {
1247	r := newMRRig(t)
1248	info := r.key.Info()
1249	assert.True(t, strings.HasPrefix(info.Key.AuthorizedKey, "ssh-ed25519 "))
1250	assert.True(t, strings.HasPrefix(info.Key.Fingerprint, "SHA256:"))
1251	assert.Equal(t, "ermite@git.example.org "+info.Key.AuthorizedKey, info.AllowedSigners())
1252	assert.Equal(t, domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, info.Committer)
1253}
1254
1255// GO-001: the agent preset (repo:write, mr:write) has no repo:read, yet it
1256// opens, comments on, resolves, marks read and closes merge requests on the
1257// private repository it pushed to. Reading them still needs repo:read.
1258func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) {
1259	r := newMRRig(t) // proj is private
1260	r.seed()
1261	head := r.feature("feat")
1262
1263	mr, err := r.mrs.Create(r.ctx, agent, r.repo, ports.CreateMergeRequestParams{Title: "From the agent", Source: "feat"})
1264	require.NoError(t, err)
1265	assert.Equal(t, head, mr.HeadID)
1266	assert.Equal(t, "agent-1", mr.Author)
1267
1268	c, err := r.mrs.AddComment(r.ctx, agent, r.repo, mr.Number, ports.AddCommentParams{Text: "note"})
1269	require.NoError(t, err)
1270	_, err = r.mrs.ResolveComment(r.ctx, agent, r.repo, mr.Number, c.ID, true)
1271	require.NoError(t, err)
1272	_, err = r.mrs.MarkRead(r.ctx, agent, r.repo, mr.Number, domain.ObjectID{})
1273	require.NoError(t, err)
1274
1275	// repo:write alone does not read merge requests.
1276	_, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{})
1277	assert.ErrorIs(t, err, domain.ErrNotFound)
1278	_, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, ports.GetMergeRequestOptions{})
1279	assert.ErrorIs(t, err, domain.ErrNotFound)
1280
1281	closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number)
1282	require.NoError(t, err)
1283	assert.Equal(t, domain.MRStateClosed, closed.State)
1284
1285	// An unknown repository is not found whatever the scopes.
1286	_, err = r.mrs.Create(r.ctx, agent, name(t, "ghost"), ports.CreateMergeRequestParams{Title: "t", Source: "feat"})
1287	assert.ErrorIs(t, err, domain.ErrNotFound)
1288	// A token limited to other repositories does not get the scope here.
1289	elsewhere := domain.TokenPrincipal(domain.Token{ID: "e", Scopes: []domain.Scope{domain.ScopeRepoWrite, domain.ScopeMRWrite}, Repos: []domain.RepoName{name(t, "else")}})
1290	_, err = r.mrs.Create(r.ctx, elsewhere, r.repo, ports.CreateMergeRequestParams{Title: "t", Source: "feat"})
1291	assert.ErrorIs(t, err, domain.ErrNotFound)
1292}
1293
1294// GO-006: a merge moves the target branch, so it records pushed_at and queues
1295// the maintenance of the repository, as a push does.
1296func TestMergeRecordsAPush(t *testing.T) {
1297	for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
1298		t.Run(string(mode), func(t *testing.T) {
1299			r := newMRRig(t)
1300			r.seed()
1301			head := r.feature("feat")
1302			mr := r.open("feat")
1303			before, err := r.repos.Get(r.ctx, root, r.repo)
1304			require.NoError(t, err)
1305			require.True(t, before.PushedAt.IsZero(), "no push recorded yet")
1306			assert.Empty(t, r.queued.names())
1307
1308			_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: head})
1309			require.NoError(t, err)
1310
1311			after, err := r.repos.Get(r.ctx, root, r.repo)
1312			require.NoError(t, err)
1313			assert.Equal(t, now, after.PushedAt)
1314			assert.Equal(t, []domain.RepoName{r.repo}, r.queued.names(), "maintenance queued")
1315		})
1316	}
1317}
1318
1319// GO-008: a merge request holds at most MaxCommentsPerMergeRequest comments.
1320func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
1321	r := newMRRig(t)
1322	r.seed()
1323	r.feature("feat")
1324	mr := r.open("feat")
1325	for i := range usecase.MaxCommentsPerMergeRequest {
1326		_, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)})
1327		require.NoError(t, err, "comment %d", i+1)
1328	}
1329	_, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"})
1330	assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1331	d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
1332	require.NoError(t, err)
1333	assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest)
1334
1335	// Resolving still works, and another request has its own budget.
1336	_, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 1, true)
1337	require.NoError(t, err)
1338	r.sh("checkout", "-q", "-B", "other", "main")
1339	r.commit(author, "o.txt", "o\n", "other")
1340	r.push("other")
1341	other := r.open("other")
1342	_, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "fine"})
1343	require.NoError(t, err)
1344}

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 2

Tags 44

↑↓ moveenter openesc close