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