v0.11.0 0633e44

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, false)
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, false)
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, false)
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, false)
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, false)
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, false)
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, false)
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, false)
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, 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, false)
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, 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, 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, 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, false)
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, 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, false)
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// AC4: the checks of a merge.
893func TestMergeRefusals(t *testing.T) {
894	t.Run("a branch that moved since the review", func(t *testing.T) {
895		r := newMRRig(t)
896		r.seed()
897		head := r.feature("feat")
898		mr := r.open("feat")
899		r.commit(author, "more.txt", "m\n", "pushed after the review")
900		r.push("feat")
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(), "branch moved, read again")
904		assert.NotEqual(t, head, r.branchHead("main"), "main did not move")
905		// Reading again gives the new head, which merges.
906		d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false)
907		require.NoError(t, err)
908		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID})
909		require.NoError(t, err)
910	})
911
912	t.Run("a target that is not an ancestor of the head", func(t *testing.T) {
913		r := newMRRig(t)
914		r.seed()
915		head := r.feature("feat")
916		mr := r.open("feat")
917		r.sh("checkout", "-q", "main")
918		r.commit(author, "news.txt", "n\n", "main moved")
919		r.push("main")
920		tip := r.branchHead("main")
921
922		for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
923			_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
924			require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)
925			assert.Contains(t, err.Error(), "rebase", mode)
926		}
927		assert.Equal(t, tip, r.branchHead("main"))
928		d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false)
929		require.NoError(t, err)
930		assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
931
932		// After a rebase and a push it merges.
933		r.sh("checkout", "-q", "feat")
934		r.sh("rebase", "-q", "main")
935		r.push("feat")
936		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: r.head()})
937		require.NoError(t, err)
938	})
939
940	t.Run("a mode the repository does not allow", func(t *testing.T) {
941		r := newMRRig(t)
942		r.seed()
943		head := r.feature("feat")
944		mr := r.open("feat")
945		modes := []domain.MergeMode{domain.MergeModeFastForward}
946		_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes})
947		require.NoError(t, err)
948
949		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
950		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
951		assert.Contains(t, err.Error(), "squash")
952		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
953		require.NoError(t, err, "fast-forward is allowed")
954	})
955
956	t.Run("only squash is allowed, so the default mode is refused", func(t *testing.T) {
957		r := newMRRig(t)
958		r.seed()
959		head := r.feature("feat")
960		mr := r.open("feat")
961		modes := []domain.MergeMode{domain.MergeModeSquash}
962		_, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes})
963		require.NoError(t, err)
964		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
965		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
966		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
967		require.NoError(t, err)
968	})
969
970	t.Run("bad arguments", func(t *testing.T) {
971		r := newMRRig(t)
972		r.seed()
973		head := r.feature("feat")
974		mr := r.open("feat")
975		_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{})
976		require.ErrorIs(t, err, domain.ErrInvalid)
977		assert.Contains(t, fieldsOf(t, err), "head_id")
978		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: "rebase"})
979		require.ErrorIs(t, err, domain.ErrInvalid)
980		assert.Contains(t, fieldsOf(t, err), "mode")
981		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, ports.MergeParams{HeadID: head})
982		assert.ErrorIs(t, err, domain.ErrNotFound)
983	})
984
985	t.Run("a deleted source or target, or nothing to merge", func(t *testing.T) {
986		r := newMRRig(t)
987		r.seed()
988		head := r.feature("feat")
989		mr := r.open("feat")
990		r.run(r.bare(), r.env(author), "branch", "-D", "feat")
991		_, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
992		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
993		assert.Contains(t, err.Error(), "no longer exists")
994
995		r.push("feat")
996		r.run(r.bare(), r.env(author), "update-ref", "refs/heads/main", head.String())
997		_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
998		require.ErrorIs(t, err, domain.ErrPreconditionFailed, "the target already is at the head")
999		assert.Contains(t, err.Error(), "nothing to merge")
1000	})
1001}
1002
1003// AC4: merges of one repository run one at a time.
1004func TestConcurrentMergesSerialize(t *testing.T) {
1005	r := newMRRig(t)
1006	r.seed()
1007	const n = 6
1008	heads := map[int64]domain.ObjectID{}
1009	for i := range n {
1010		b := fmt.Sprintf("f%d", i)
1011		heads[r.openFeature(b).Number] = r.branchHead(b)
1012	}
1013
1014	type outcome struct {
1015		number int64
1016		err    error
1017	}
1018	results := make(chan outcome, n*2)
1019	var wg sync.WaitGroup
1020	for number, head := range heads {
1021		for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
1022			if mode == domain.MergeModeSquash && number%2 == 0 {
1023				continue
1024			}
1025			wg.Go(func() {
1026				_, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, ports.MergeParams{HeadID: head, Mode: mode})
1027				results <- outcome{number, err}
1028			})
1029		}
1030	}
1031	wg.Wait()
1032	close(results)
1033
1034	won := map[int64]int{}
1035	for o := range results {
1036		if o.err == nil {
1037			won[o.number]++
1038			continue
1039		}
1040		require.ErrorIs(t, o.err, domain.ErrPreconditionFailed, "a loser fails cleanly: %v", o.err)
1041	}
1042	// All the branches start from the same main: exactly one request can win,
1043	// once, whatever the mode, and main is where it left it.
1044	require.Len(t, won, 1, "exactly one merge request was merged: %v", won)
1045	for number, times := range won {
1046		assert.Equal(t, 1, times, "!%d merged twice", number)
1047		mr, err := r.mrs.Get(r.ctx, root, r.repo, number, false)
1048		require.NoError(t, err)
1049		assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State)
1050		assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main"))
1051	}
1052	for number := range heads {
1053		if won[number] == 0 {
1054			d, err := r.mrs.Get(r.ctx, root, r.repo, number, false)
1055			require.NoError(t, err)
1056			assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number)
1057		}
1058	}
1059}
1060
1061// openFeature pushes a branch from main with one commit and opens a request.
1062func (r *mrRig) openFeature(branch string) domain.MergeRequest {
1063	r.t.Helper()
1064	r.sh("checkout", "-q", "-B", branch, "main")
1065	r.commit(author, branch+".txt", branch+"\n", "add "+branch)
1066	r.push(branch)
1067	return r.open(branch)
1068}
1069
1070func TestSquashMerge(t *testing.T) {
1071	r := newMRRig(t)
1072	main := r.seed()
1073	// A branch of three commits by two authors; Bob wrote the first one.
1074	bob := domain.Identity{Name: "Bob", Email: "bob@example.org"}
1075	cy := domain.Identity{Name: "Cy", Email: "cy@example.org"}
1076	r.sh("checkout", "-q", "-B", "feat", "main")
1077	r.commit(bob, "x.txt", "x\n", "bob starts")
1078	r.commit(author, "y.txt", "y\n", "ada continues")
1079	r.commit(cy, "z.txt", "z\n", "cy finishes")
1080	r.commit(bob, "w.txt", "w\n", "bob again")
1081	r.push("feat")
1082	head := r.branchHead("feat")
1083	mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Add things", Source: "feat"})
1084	require.NoError(t, err)
1085	r.drain()
1086
1087	merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
1088	require.NoError(t, err)
1089	assert.Equal(t, domain.MergeModeSquash, merged.MergeMode)
1090	require.False(t, merged.MergedCommitID.IsZero())
1091	assert.NotEqual(t, head, merged.MergedCommitID, "a squash creates a commit")
1092	assert.Equal(t, merged.MergedCommitID, r.branchHead("main"))
1093
1094	sq := merged.MergedCommitID.String()
1095	g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
1096	assert.Equal(t, main.String(), g("rev-parse", sq+"^"), "the target is the only parent")
1097	assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", sq+"^{tree}"), "the tree of the head")
1098	assert.Equal(t, "Bob <bob@example.org>", g("log", "-1", "--format=%an <%ae>", sq), "author of the first commit")
1099	assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", sq))
1100	assert.Equal(t, "Add things (!1)\n\n- bob starts\n- ada continues\n- cy finishes\n- bob again\n\n"+
1101		"Co-authored-by: Ada Lovelace <ada@example.org>\nCo-authored-by: Cy <cy@example.org>",
1102		g("log", "-1", "--format=%B", sq))
1103	assert.Contains(t, g("cat-file", "commit", sq), "\ngpgsig -----BEGIN SSH SIGNATURE-----\n")
1104
1105	// Events: merged, then pushed for the target branch.
1106	evs := r.drain()
1107	require.Len(t, evs, 2)
1108	assert.Equal(t, merged.MergedCommitID, evs[0].(domain.MergeRequestMerged).MergeRequest.MergedCommitID)
1109	assert.Equal(t, []domain.RefUpdate{{Old: main.String(), New: sq, Ref: "refs/heads/main"}}, evs[1].(domain.PushEvent).Updates)
1110
1111	// The signature verifies with an allowed_signers file built from the
1112	// public key the server hands out.
1113	r.verifyCommit(sq)
1114}
1115
1116// verifyCommit checks the signature of a commit of the repository with
1117// `git verify-commit` and an allowed signers file made from the signing key
1118// info, and that another key does not verify.
1119func (r *mrRig) verifyCommit(id string) {
1120	r.t.Helper()
1121	if _, err := exec.LookPath("ssh-keygen"); err != nil {
1122		unusable(r.t, "ssh-keygen not found: install openssh to verify the signed commits (CI must have it)")
1123	}
1124	dir := r.t.TempDir()
1125	signers := filepath.Join(dir, "allowed_signers")
1126	require.NoError(r.t, os.WriteFile(signers, []byte(r.key.Info().AllowedSigners()+"\n"), 0o600))
1127	out := r.run(r.bare(), r.env(author), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", "--raw", id)
1128	assert.Contains(r.t, out, "Good \"git\" signature for ermite@git.example.org")
1129
1130	other, err := sshsig.New(bytes.Repeat([]byte{1}, ed25519.SeedSize))
1131	require.NoError(r.t, err)
1132	wrong := filepath.Join(dir, "wrong_signers")
1133	require.NoError(r.t, os.WriteFile(wrong, []byte("ermite@git.example.org "+other.PublicKey().AuthorizedKey+"\n"), 0o600))
1134	cmd := exec.Command("git", "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+wrong, "verify-commit", id)
1135	cmd.Dir, cmd.Env = r.bare(), r.env(author)
1136	assert.Error(r.t, cmd.Run(), "another key must not verify the commit")
1137}
1138
1139func TestSquashSingleCommitHasNoTrailer(t *testing.T) {
1140	r := newMRRig(t)
1141	r.seed()
1142	head := r.feature("solo") // two authors; make a one-author branch instead
1143	_ = head
1144	r.sh("checkout", "-q", "-B", "mine", "main")
1145	r.commit(author, "m.txt", "m\n", "only")
1146	r.push("mine")
1147	mr := r.open("mine")
1148	merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash})
1149	require.NoError(t, err)
1150	msg := r.run(r.bare(), r.env(author), "log", "-1", "--format=%B", merged.MergedCommitID.String())
1151	assert.Equal(t, "Add mine (!1)\n\n- only", msg)
1152	r.verifyCommit(merged.MergedCommitID.String())
1153}
1154
1155func TestUpdateRepoSettings(t *testing.T) {
1156	r := newMRRig(t)
1157	repo, err := r.repos.Get(r.ctx, root, r.repo)
1158	require.NoError(t, err)
1159	assert.Equal(t, domain.DefaultRepoSettings(), repo.Settings, "stored explicitly at creation")
1160
1161	modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
1162	off := false
1163	got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1164	require.NoError(t, err)
1165	assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)
1166	again, err := r.repos.Get(r.ctx, root, r.repo)
1167	require.NoError(t, err)
1168	assert.Equal(t, got.Settings, again.Settings)
1169
1170	// Only the named setting changes.
1171	on := true
1172	got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: &on})
1173	require.NoError(t, err)
1174	assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1175	assert.True(t, got.Settings.FailingChecksBlock)
1176
1177	for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
1178		bad := bad
1179		_, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &bad})
1180		assert.ErrorIs(t, err, domain.ErrInvalid, "%v", bad)
1181	}
1182	again, err = r.repos.Get(r.ctx, root, r.repo)
1183	require.NoError(t, err)
1184	assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
1185
1186	// repo:admin is needed, like for any other field.
1187	_, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{FailingChecksBlock: &off})
1188	assert.ErrorIs(t, err, domain.ErrForbidden)
1189}
1190
1191func TestDeletingARepositoryDropsItsMergeRequests(t *testing.T) {
1192	r := newMRRig(t)
1193	r.seed()
1194	r.feature("feat")
1195	r.open("feat")
1196	require.NoError(t, r.repos.Delete(r.ctx, root, r.repo))
1197
1198	_, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
1199	assert.ErrorIs(t, err, domain.ErrNotFound)
1200
1201	// Created again under the same name, it starts empty.
1202	_, err = r.repos.Create(r.ctx, root, ports.CreateRepoParams{Name: r.repo})
1203	require.NoError(t, err)
1204	page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
1205	require.NoError(t, err)
1206	assert.Empty(t, page.MergeRequests)
1207}
1208
1209func TestSigningKeyInfo(t *testing.T) {
1210	r := newMRRig(t)
1211	info := r.key.Info()
1212	assert.True(t, strings.HasPrefix(info.Key.AuthorizedKey, "ssh-ed25519 "))
1213	assert.True(t, strings.HasPrefix(info.Key.Fingerprint, "SHA256:"))
1214	assert.Equal(t, "ermite@git.example.org "+info.Key.AuthorizedKey, info.AllowedSigners())
1215	assert.Equal(t, domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, info.Committer)
1216}
1217
1218// GO-001: the agent preset (repo:write, mr:write) has no repo:read, yet it
1219// opens, comments on, resolves, marks read and closes merge requests on the
1220// private repository it pushed to. Reading them still needs repo:read.
1221func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) {
1222	r := newMRRig(t) // proj is private
1223	r.seed()
1224	head := r.feature("feat")
1225
1226	mr, err := r.mrs.Create(r.ctx, agent, r.repo, ports.CreateMergeRequestParams{Title: "From the agent", Source: "feat"})
1227	require.NoError(t, err)
1228	assert.Equal(t, head, mr.HeadID)
1229	assert.Equal(t, "agent-1", mr.Author)
1230
1231	c, err := r.mrs.AddComment(r.ctx, agent, r.repo, mr.Number, ports.AddCommentParams{Text: "note"})
1232	require.NoError(t, err)
1233	_, err = r.mrs.ResolveComment(r.ctx, agent, r.repo, mr.Number, c.ID, true)
1234	require.NoError(t, err)
1235	_, err = r.mrs.MarkRead(r.ctx, agent, r.repo, mr.Number, domain.ObjectID{})
1236	require.NoError(t, err)
1237
1238	// repo:write alone does not read merge requests.
1239	_, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{})
1240	assert.ErrorIs(t, err, domain.ErrNotFound)
1241	_, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, false)
1242	assert.ErrorIs(t, err, domain.ErrNotFound)
1243
1244	closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number)
1245	require.NoError(t, err)
1246	assert.Equal(t, domain.MRStateClosed, closed.State)
1247
1248	// An unknown repository is not found whatever the scopes.
1249	_, err = r.mrs.Create(r.ctx, agent, name(t, "ghost"), ports.CreateMergeRequestParams{Title: "t", Source: "feat"})
1250	assert.ErrorIs(t, err, domain.ErrNotFound)
1251	// A token limited to other repositories does not get the scope here.
1252	elsewhere := domain.TokenPrincipal(domain.Token{ID: "e", Scopes: []domain.Scope{domain.ScopeRepoWrite, domain.ScopeMRWrite}, Repos: []domain.RepoName{name(t, "else")}})
1253	_, err = r.mrs.Create(r.ctx, elsewhere, r.repo, ports.CreateMergeRequestParams{Title: "t", Source: "feat"})
1254	assert.ErrorIs(t, err, domain.ErrNotFound)
1255}
1256
1257// GO-006: a merge moves the target branch, so it records pushed_at and queues
1258// the maintenance of the repository, as a push does.
1259func TestMergeRecordsAPush(t *testing.T) {
1260	for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
1261		t.Run(string(mode), func(t *testing.T) {
1262			r := newMRRig(t)
1263			r.seed()
1264			head := r.feature("feat")
1265			mr := r.open("feat")
1266			before, err := r.repos.Get(r.ctx, root, r.repo)
1267			require.NoError(t, err)
1268			require.True(t, before.PushedAt.IsZero(), "no push recorded yet")
1269			assert.Empty(t, r.queued.names())
1270
1271			_, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: head})
1272			require.NoError(t, err)
1273
1274			after, err := r.repos.Get(r.ctx, root, r.repo)
1275			require.NoError(t, err)
1276			assert.Equal(t, now, after.PushedAt)
1277			assert.Equal(t, []domain.RepoName{r.repo}, r.queued.names(), "maintenance queued")
1278		})
1279	}
1280}
1281
1282// GO-008: a merge request holds at most MaxCommentsPerMergeRequest comments.
1283func TestCommentsPerMergeRequestAreBounded(t *testing.T) {
1284	r := newMRRig(t)
1285	r.seed()
1286	r.feature("feat")
1287	mr := r.open("feat")
1288	for i := range usecase.MaxCommentsPerMergeRequest {
1289		_, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)})
1290		require.NoError(t, err, "comment %d", i+1)
1291	}
1292	_, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"})
1293	assert.ErrorIs(t, err, domain.ErrResourceExhausted)
1294	d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
1295	require.NoError(t, err)
1296	assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest)
1297
1298	// Resolving still works, and another request has its own budget.
1299	_, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 1, true)
1300	require.NoError(t, err)
1301	r.sh("checkout", "-q", "-B", "other", "main")
1302	r.commit(author, "o.txt", "o\n", "other")
1303	r.push("other")
1304	other := r.open("other")
1305	_, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "fine"})
1306	require.NoError(t, err)
1307}

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