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