v0.5.0 bfab294

mergerequests_integration_test.go RawHistoryFolder

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

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