main 1fb2421

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close