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