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