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