detached 224c22d

feat(app): add the merge request use cases and the signing key

Create, list, get (commits, diff against the merge base, diff since the last
read with a fallback when the recorded head is gone), comment on a line of
the diff, resolve, close, mark read and merge by fast-forward or signed
squash. Merges and creations of one repository run one at a time; the merge
checks the head the caller reviewed, that the target is an ancestor, and the
allowed modes of the repository settings, and moves the target with the old
value as a precondition. Repository settings are set at creation and changed
by Update. Merge request events go through the event log, filtered by the
visibility of the repository like the other repository events.
commit
224c22d9e1c17471dc7e34e6c5e98fc6d50286ba
author
brice · · 2026-10-10 16:57 UTC
parent
4023100
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +2144 -10
M internal/app/access.go +32 -1
@@ -2,7 +2,11 @@
22
33 package app
44
5-import "bnema.dev/ermite/internal/domain"
5+import (
6+ "fmt"
7+
8+ "bnema.dev/ermite/internal/domain"
9+)
610
711 // canRead is the access rule for reading a repository, the single place that
812 // decides it: root reads everything, anyone reads a public repository, and a
@@ -55,3 +59,30 @@ func canManageTokens(p domain.Principal) bool {
5559 func canAdmin(p domain.Principal, name domain.RepoName) bool {
5660 return p.Allows(domain.ScopeRepoAdmin, name)
5761 }
62+
63+// scopeAccess is the access rule for an action that needs a scope on a
64+// repository the caller can already read, such as mr:write or mr:merge:
65+// unauthenticated for an anonymous caller, forbidden for one without the scope
66+// on this repository.
67+func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error {
68+ switch {
69+ case p.Allows(scope, repo.Name):
70+ return nil
71+ case p.IsAnonymous():
72+ return domain.ErrUnauthenticated
73+ }
74+ return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, scope, repo.Name)
75+}
76+
77+// authorOf names who acts, for the author of a merge request or a comment: the
78+// label of the token, "root" for the root token, "owner" for any other caller
79+// (a web session).
80+func authorOf(p domain.Principal) string {
81+ switch {
82+ case p.IsRoot():
83+ return "root"
84+ case p.TokenLabel() != "":
85+ return p.TokenLabel()
86+ }
87+ return "owner"
88+}
@@ -2,7 +2,11 @@
2 2
3 package app3 package app
4 4
5-import "bnema.dev/ermite/internal/domain"5+import (
6+ "fmt"
7+
8+ "bnema.dev/ermite/internal/domain"
9+)
6 10
7 // canRead is the access rule for reading a repository, the single place that11 // canRead is the access rule for reading a repository, the single place that
8 // decides it: root reads everything, anyone reads a public repository, and a12 // decides it: root reads everything, anyone reads a public repository, and a
@@ -55,3 +59,30 @@ func canManageTokens(p domain.Principal) bool {
55 func canAdmin(p domain.Principal, name domain.RepoName) bool {59 func canAdmin(p domain.Principal, name domain.RepoName) bool {
56 return p.Allows(domain.ScopeRepoAdmin, name)60 return p.Allows(domain.ScopeRepoAdmin, name)
57 }61 }
62+
63+// scopeAccess is the access rule for an action that needs a scope on a
64+// repository the caller can already read, such as mr:write or mr:merge:
65+// unauthenticated for an anonymous caller, forbidden for one without the scope
66+// on this repository.
67+func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error {
68+ switch {
69+ case p.Allows(scope, repo.Name):
70+ return nil
71+ case p.IsAnonymous():
72+ return domain.ErrUnauthenticated
73+ }
74+ return fmt.Errorf("%w: this needs the %s scope on %s", domain.ErrForbidden, scope, repo.Name)
75+}
76+
77+// authorOf names who acts, for the author of a merge request or a comment: the
78+// label of the token, "root" for the root token, "owner" for any other caller
79+// (a web session).
80+func authorOf(p domain.Principal) string {
81+ switch {
82+ case p.IsRoot():
83+ return "root"
84+ case p.TokenLabel() != "":
85+ return p.TokenLabel()
86+ }
87+ return "owner"
88+}
M internal/app/events.go +29 -1
@@ -166,10 +166,28 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
166166 return bufferedEvent{} // repository unknown at publish time: nobody is told
167167 }
168168 return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
169+ case domain.MergeRequestOpened:
170+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
171+ case domain.MergeRequestMerged:
172+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
173+ case domain.MergeRequestClosed:
174+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
175+ case domain.CommentAdded:
176+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
169177 }
170178 return bufferedEvent{known: true}
171179 }
172180
181+// repoSnapshot is the snapshot of an event that carries the visibility and the
182+// creation time of its repository; a zero creation time means the repository
183+// was unknown when it was published, and nobody is told.
184+func repoSnapshot(vis domain.Visibility, created time.Time) bufferedEvent {
185+ if created.IsZero() {
186+ return bufferedEvent{}
187+ }
188+ return bufferedEvent{known: true, vis: vis, created: created}
189+}
190+
173191 func (e *Events) append(ctx context.Context, ev domain.Event) {
174192 be := e.snapshot(ctx, ev) // outside the lock: it reads the store
175193 e.mu.Lock()
@@ -253,7 +271,9 @@ type WatchEvent struct {
253271 // Gap tells that events were lost: the caller must reload what it shows.
254272 // Event is nil then.
255273 Gap bool
256- // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted or PushEvent.
274+ // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,
275+ // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or
276+ // CommentAdded.
257277 Event domain.Event
258278 // Repo is the state of the repository at delivery, for RepoCreated and
259279 // RepoUpdated.
@@ -483,6 +503,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
483503 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
484504 case domain.PushEvent:
485505 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
506+ case domain.MergeRequestOpened:
507+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
508+ case domain.MergeRequestMerged:
509+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
510+ case domain.MergeRequestClosed:
511+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
512+ case domain.CommentAdded:
513+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
486514 }
487515 return nil // an event this version does not stream
488516 }
@@ -166,10 +166,28 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
166 return bufferedEvent{} // repository unknown at publish time: nobody is told166 return bufferedEvent{} // repository unknown at publish time: nobody is told
167 }167 }
168 return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}168 return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
169+ case domain.MergeRequestOpened:
170+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
171+ case domain.MergeRequestMerged:
172+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
173+ case domain.MergeRequestClosed:
174+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
175+ case domain.CommentAdded:
176+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
169 }177 }
170 return bufferedEvent{known: true}178 return bufferedEvent{known: true}
171 }179 }
172 180
181+// repoSnapshot is the snapshot of an event that carries the visibility and the
182+// creation time of its repository; a zero creation time means the repository
183+// was unknown when it was published, and nobody is told.
184+func repoSnapshot(vis domain.Visibility, created time.Time) bufferedEvent {
185+ if created.IsZero() {
186+ return bufferedEvent{}
187+ }
188+ return bufferedEvent{known: true, vis: vis, created: created}
189+}
190+
173 func (e *Events) append(ctx context.Context, ev domain.Event) {191 func (e *Events) append(ctx context.Context, ev domain.Event) {
174 be := e.snapshot(ctx, ev) // outside the lock: it reads the store192 be := e.snapshot(ctx, ev) // outside the lock: it reads the store
175 e.mu.Lock()193 e.mu.Lock()
@@ -253,7 +271,9 @@ type WatchEvent struct {
253 // Gap tells that events were lost: the caller must reload what it shows.271 // Gap tells that events were lost: the caller must reload what it shows.
254 // Event is nil then.272 // Event is nil then.
255 Gap bool273 Gap bool
256- // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted or PushEvent.274+ // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,
275+ // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or
276+ // CommentAdded.
257 Event domain.Event277 Event domain.Event
258 // Repo is the state of the repository at delivery, for RepoCreated and278 // Repo is the state of the repository at delivery, for RepoCreated and
259 // RepoUpdated.279 // RepoUpdated.
@@ -483,6 +503,14 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
483 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)503 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
484 case domain.PushEvent:504 case domain.PushEvent:
485 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)505 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
506+ case domain.MergeRequestOpened:
507+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
508+ case domain.MergeRequestMerged:
509+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
510+ case domain.MergeRequestClosed:
511+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
512+ case domain.CommentAdded:
513+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
486 }514 }
487 return nil // an event this version does not stream515 return nil // an event this version does not stream
488 }516 }
A internal/app/mergerequests.go +845 -0
@@ -0,0 +1,845 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "strconv"
11+ "sync"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// MaxMergeRequestCommits is the most commits GetMergeRequest returns.
20+const MaxMergeRequestCommits = 100
21+
22+// maxWriteRetries bounds the retries of a write that loses a race with another
23+// write of the same merge request. Each lost race means another writer won, so
24+// a writer loses at most as many times as there are other writers at once; the
25+// retry is a read and a write of ermite-kv, cheap enough for a bound this high.
26+const maxWriteRetries = 32
27+
28+const mrTokenPrefix = "mp1:"
29+
30+// MergeRequests are the merge request use cases (ADR 0017): review, comment,
31+// close and merge a branch. Reading needs the access of reading the
32+// repository: a repository the principal may not read, a missing one and an
33+// unknown number all answer domain.ErrNotFound. Writing needs mr:write, and
34+// merging mr:merge, on the repository (see scopeAccess).
35+type MergeRequests struct {
36+ meta ports.RepoMetaStore
37+ store ports.MergeRequestStore
38+ repos ports.GitRepos
39+ git ports.MergeGit
40+ signer ports.CommitSigner
41+ committer domain.Identity
42+ bus ports.EventBus
43+ clock ports.Clock
44+
45+ mu sync.Mutex
46+ locks map[domain.RepoName]chan struct{}
47+}
48+
49+// MergeRequestsConfig holds what the merge request use cases need. Every field
50+// is required.
51+type MergeRequestsConfig struct {
52+ Meta ports.RepoMetaStore
53+ Store ports.MergeRequestStore
54+ // Repos gives the default branch of a repository.
55+ Repos ports.GitRepos
56+ Git ports.MergeGit
57+ // Signer signs the squash commits.
58+ Signer ports.CommitSigner
59+ // Committer is recorded as the committer of the squash commits.
60+ Committer domain.Identity
61+ Bus ports.EventBus
62+ Clock ports.Clock
63+}
64+
65+// NewMergeRequests builds the merge request use cases.
66+func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
67+ return &MergeRequests{
68+ meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
69+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock,
70+ locks: make(map[domain.RepoName]chan struct{}),
71+ }
72+}
73+
74+// lockRepo takes the merge lock of a repository, waiting until ctx ends. The
75+// merges of one repository, and the creation of its merge requests, run one at
76+// a time. The returned function releases the lock.
77+func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
78+ m.mu.Lock()
79+ ch, ok := m.locks[name]
80+ if !ok {
81+ ch = make(chan struct{}, 1)
82+ m.locks[name] = ch
83+ }
84+ m.mu.Unlock()
85+ select {
86+ case ch <- struct{}{}:
87+ return func() { <-ch }, nil
88+ case <-ctx.Done():
89+ return nil, ctx.Err()
90+ }
91+}
92+
93+// readable looks the repository up and applies the read rule.
94+func (m *MergeRequests) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
95+ repo, err := lookupRepo(ctx, m.meta, name)
96+ if err != nil {
97+ return domain.Repo{}, err
98+ }
99+ if !canRead(p, repo) {
100+ return domain.Repo{}, domain.ErrNotFound
101+ }
102+ return repo, nil
103+}
104+
105+// writable is readable plus the scope.
106+func (m *MergeRequests) writable(ctx context.Context, p domain.Principal, name domain.RepoName, scope domain.Scope) (domain.Repo, error) {
107+ repo, err := m.readable(ctx, p, name)
108+ if err != nil {
109+ return domain.Repo{}, err
110+ }
111+ if err := scopeAccess(p, repo, scope); err != nil {
112+ return domain.Repo{}, err
113+ }
114+ return repo, nil
115+}
116+
117+// withHead fills HeadID of an open request from its source branch: zero when
118+// the branch is gone. Merged and closed requests keep the head they stored.
119+func (m *MergeRequests) withHead(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
120+ if !mr.IsOpen() {
121+ return mr, nil
122+ }
123+ head, err := m.git.BranchHead(ctx, mr.Repo, mr.Source)
124+ switch {
125+ case errors.Is(err, domain.ErrNotFound):
126+ mr.HeadID = domain.ObjectID{}
127+ case err != nil:
128+ return domain.MergeRequest{}, err
129+ default:
130+ mr.HeadID = head
131+ }
132+ return mr, nil
133+}
134+
135+// branchOrZero returns the head of a branch, the zero id when it is gone.
136+func (m *MergeRequests) branchOrZero(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
137+ id, err := m.git.BranchHead(ctx, repo, branch)
138+ if errors.Is(err, domain.ErrNotFound) {
139+ return domain.ObjectID{}, nil
140+ }
141+ return id, err
142+}
143+
144+// CreateMergeRequestParams describes the merge request to open.
145+type CreateMergeRequestParams struct {
146+ Title string
147+ Description string
148+ // Source is required; Target defaults to the default branch.
149+ Source, Target string
150+}
151+
152+// Create opens a merge request and publishes MergeRequestOpened. The principal
153+// needs mr:write. A request that breaks a rule of ADR 0017 (the texts, a
154+// branch that does not exist, source and target the same) is rejected whole
155+// with an *domain.InvalidFieldsError naming the fields. An open request with
156+// the same source and target gives domain.ErrAlreadyExists.
157+func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) {
158+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
159+ if err != nil {
160+ return domain.MergeRequest{}, err
161+ }
162+ var v domain.Violations
163+ title, err := domain.ParseMRTitle(params.Title)
164+ if err != nil {
165+ v.AddErr("title", err)
166+ }
167+ desc, err := domain.ParsePlainText(params.Description)
168+ if err != nil {
169+ v.AddErr("description", err)
170+ }
171+ source, target := m.checkBranches(ctx, name, params, &v)
172+ if err := v.Err(); err != nil {
173+ return domain.MergeRequest{}, err
174+ }
175+
176+ // Under the lock, so that two requests for the same pair cannot both pass
177+ // the check for an existing one.
178+ unlock, err := m.lockRepo(ctx, name)
179+ if err != nil {
180+ return domain.MergeRequest{}, err
181+ }
182+ defer unlock()
183+ existing, err := m.store.List(ctx, name)
184+ if err != nil {
185+ return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err)
186+ }
187+ for _, e := range existing {
188+ if e.IsOpen() && e.Source == source && e.Target == target {
189+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d already merges %s into %s", domain.ErrAlreadyExists, e.Number, source, target)
190+ }
191+ }
192+
193+ now := m.clock.Now().UTC()
194+ mr, err := m.store.Create(ctx, domain.MergeRequest{
195+ Repo: name, Title: title, Description: desc, Source: source, Target: target,
196+ State: domain.MRStateOpen, Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
197+ })
198+ if err != nil {
199+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err)
200+ }
201+ log := zerowrap.FromCtx(ctx)
202+ log.Info().Str("repo", name.String()).Int64("number", mr.Number).
203+ Str("source", source).Str("target", target).
204+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request opened")
205+ mr, err = m.withHead(ctx, mr)
206+ if err != nil {
207+ return domain.MergeRequest{}, err
208+ }
209+ m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
210+ return mr, nil
211+}
212+
213+// checkBranches validates the branches of a new request and returns them, with
214+// the default branch filled in for the target.
215+func (m *MergeRequests) checkBranches(ctx context.Context, name domain.RepoName, params CreateMergeRequestParams, v *domain.Violations) (source, target string) {
216+ source, target = params.Source, params.Target
217+ if source == "" {
218+ v.Add("source_branch", "is required")
219+ } else if _, err := domain.ParseBranchName(source); err != nil {
220+ v.Add("source_branch", "is not a valid branch name")
221+ source = ""
222+ }
223+ if target == "" {
224+ def, exists, err := m.repos.DefaultBranch(ctx, name)
225+ switch {
226+ case err != nil:
227+ v.Add("target_branch", "cannot be read: "+err.Error())
228+ case !exists:
229+ v.Add("target_branch", fmt.Sprintf("is empty and the default branch %q does not exist", def))
230+ default:
231+ target = def
232+ }
233+ } else if _, err := domain.ParseBranchName(target); err != nil {
234+ v.Add("target_branch", "is not a valid branch name")
235+ target = ""
236+ }
237+ if source != "" && source == target {
238+ v.Add("target_branch", "must differ from source_branch")
239+ return source, target
240+ }
241+ for _, b := range []struct{ field, name string }{{"source_branch", source}, {"target_branch", target}} {
242+ if b.name == "" {
243+ continue
244+ }
245+ _, err := m.git.BranchHead(ctx, name, b.name)
246+ if errors.Is(err, domain.ErrNotFound) {
247+ v.Add(b.field, fmt.Sprintf("branch %q does not exist", b.name))
248+ } else if err != nil {
249+ v.Add(b.field, "cannot be read: "+err.Error())
250+ }
251+ }
252+ return source, target
253+}
254+
255+// ListMergeRequestsParams selects a page of merge requests.
256+type ListMergeRequestsParams struct {
257+ // State restricts the result; empty means every state.
258+ State domain.MRState
259+ // PageSize is 0 for the default, and lowered to MaxPageSize.
260+ PageSize int
261+ // PageToken is the NextPageToken of the previous page, or empty.
262+ PageToken string
263+}
264+
265+// MergeRequestPage is a page of merge requests, highest number first.
266+type MergeRequestPage struct {
267+ MergeRequests []domain.MergeRequest
268+ // NextPageToken is empty on the last page.
269+ NextPageToken string
270+}
271+
272+// List returns a page of the merge requests of a repository, highest number
273+// first. The open ones carry the head of their source branch. The page token
274+// names the last number of the previous page, so requests created between two
275+// pages never shift the others.
276+func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) {
277+ size, err := pageSize(params.PageSize)
278+ if err != nil {
279+ return MergeRequestPage{}, err
280+ }
281+ after, err := decodeMRToken(params.PageToken)
282+ if err != nil {
283+ return MergeRequestPage{}, err
284+ }
285+ if _, err := m.readable(ctx, p, name); err != nil {
286+ return MergeRequestPage{}, err
287+ }
288+ all, err := m.store.List(ctx, name)
289+ if err != nil {
290+ return MergeRequestPage{}, fmt.Errorf("list merge requests of %q: %w", name, err)
291+ }
292+ var page MergeRequestPage
293+ page.MergeRequests, page.NextPageToken = pageAfter(all, size,
294+ func(mr domain.MergeRequest) bool {
295+ return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
296+ },
297+ encodeMRToken)
298+ for i, mr := range page.MergeRequests {
299+ if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
300+ return MergeRequestPage{}, err
301+ }
302+ }
303+ return page, nil
304+}
305+
306+func encodeMRToken(last domain.MergeRequest) string {
307+ return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
308+}
309+
310+// decodeMRToken returns 0 for the empty token.
311+func decodeMRToken(tok string) (int64, error) {
312+ if tok == "" {
313+ return 0, nil
314+ }
315+ raw, ok := decodeToken(tok, mrTokenPrefix)
316+ if ok {
317+ if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 {
318+ return n, nil
319+ }
320+ }
321+ return 0, fmt.Errorf("%w: merge requests", domain.ErrInvalidPageToken)
322+}
323+
324+// get reads a merge request of a repository the principal can read.
325+func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Repo, domain.MergeRequest, error) {
326+ repo, err := m.readable(ctx, p, name)
327+ if err != nil {
328+ return domain.Repo{}, domain.MergeRequest{}, err
329+ }
330+ mr, err := m.store.Get(ctx, name, number)
331+ if err != nil {
332+ return domain.Repo{}, domain.MergeRequest{}, err
333+ }
334+ return repo, mr, nil
335+}
336+
337+// MergeRequestDetail is a merge request with its review data.
338+type MergeRequestDetail struct {
339+ MergeRequest domain.MergeRequest
340+ // Commits are the commits of the head that the target lacks, newest
341+ // first, at most MaxMergeRequestCommits.
342+ Commits []domain.Commit
343+ CommitsTruncated bool
344+ // DiffBaseID is the commit the diff compares from; zero when the source
345+ // branch is gone, then the diff is empty.
346+ DiffBaseID domain.ObjectID
347+ Stats domain.DiffStats
348+ Diff domain.Diff
349+ // Comments are the comments, oldest first.
350+ Comments []domain.Comment
351+ // LastReadID is the head recorded by MarkRead; zero when none.
352+ LastReadID domain.ObjectID
353+ // LastReadLost tells that since-last-read was asked for, a head was
354+ // recorded, but that commit is gone: the diff is against the target.
355+ LastReadLost bool
356+}
357+
358+// Get returns a merge request with its commits, its diff, its comments and the
359+// read position. The diff compares the merge base of the target and the head
360+// with the head ("target...head"), within domain.DefaultDiffLimits. With
361+// sinceLastRead it compares the head recorded by MarkRead with the current
362+// head instead, which works after a force-push as long as the recorded commit
363+// still exists; when none was recorded the diff is the default one, and when
364+// the recorded commit is gone too, with LastReadLost set. A merged or closed
365+// request keeps the head and the base it had.
366+func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (MergeRequestDetail, error) {
367+ _, mr, err := m.get(ctx, p, name, number)
368+ if err != nil {
369+ return MergeRequestDetail{}, err
370+ }
371+ if mr, err = m.withHead(ctx, mr); err != nil {
372+ return MergeRequestDetail{}, err
373+ }
374+ d := MergeRequestDetail{MergeRequest: mr}
375+ if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
376+ return MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
377+ }
378+ if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
379+ return MergeRequestDetail{}, err
380+ }
381+
382+ exclude, base, err := m.rangeOf(ctx, mr)
383+ if err != nil {
384+ return MergeRequestDetail{}, err
385+ }
386+ head := mr.HeadID
387+ if head.IsZero() {
388+ return d, nil // the source branch is gone: no commits, no diff
389+ }
390+ if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
391+ return MergeRequestDetail{}, err
392+ }
393+ if sinceLastRead && !d.LastReadID.IsZero() {
394+ switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
395+ case err != nil:
396+ return MergeRequestDetail{}, err
397+ case ok:
398+ base = d.LastReadID
399+ default:
400+ d.LastReadLost = true
401+ }
402+ }
403+ if base.IsZero() {
404+ return d, nil
405+ }
406+ d.DiffBaseID = base
407+ if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, domain.DefaultDiffLimits()); err != nil {
408+ return MergeRequestDetail{}, err
409+ }
410+ return d, nil
411+}
412+
413+// rangeOf returns what the review of a request compares: the commit whose
414+// history the request's commits exclude, and the base of its diff. For an open
415+// request they are the tip of the target and the merge base of the target and
416+// the head; both are zero when the target is gone or shares no history with
417+// the head. A merged or closed request keeps the base it stored for both.
418+func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
419+ if !mr.IsOpen() {
420+ return mr.BaseID, mr.BaseID, nil
421+ }
422+ if mr.HeadID.IsZero() {
423+ return domain.ObjectID{}, domain.ObjectID{}, nil
424+ }
425+ tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)
426+ if err != nil || tip.IsZero() {
427+ return domain.ObjectID{}, domain.ObjectID{}, err
428+ }
429+ base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
430+ if errors.Is(err, domain.ErrNotFound) {
431+ return tip, domain.ObjectID{}, nil
432+ }
433+ return tip, base, err
434+}
435+
436+// AddCommentParams describes the comment to add.
437+type AddCommentParams struct {
438+ // Path, Line and Side designate a line of the diff; all empty for a
439+ // general comment.
440+ Path string
441+ Line int
442+ Side domain.DiffSide
443+ Text string
444+}
445+
446+// AddComment adds a comment and publishes CommentAdded. The principal needs
447+// mr:write. The text must follow the rules of ADR 0017 and not be blank; a
448+// comment on a line must name a line of the current diff of the request (see
449+// domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request
450+// that is not open gives domain.ErrPreconditionFailed.
451+func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error) {
452+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
453+ if err != nil {
454+ return domain.Comment{}, err
455+ }
456+ mr, err := m.store.Get(ctx, name, number)
457+ if err != nil {
458+ return domain.Comment{}, err
459+ }
460+ var v domain.Violations
461+ text, err := domain.ParsePlainText(params.Text)
462+ switch {
463+ case err != nil:
464+ v.AddErr("text", err)
465+ case text.IsBlank():
466+ v.Add("text", "is required")
467+ }
468+ general := params.Path == ""
469+ switch {
470+ case general && (params.Line != 0 || params.Side != domain.SideNone):
471+ v.Add("path", "is required with line and side")
472+ case !general && params.Line < 1:
473+ v.Add("line", "must be 1 or more with a path")
474+ case !general && params.Side != domain.SideOld && params.Side != domain.SideNew:
475+ v.Add("side", "is required with a path")
476+ }
477+ if err := v.Err(); err != nil {
478+ return domain.Comment{}, err
479+ }
480+ if !mr.IsOpen() {
481+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
482+ }
483+
484+ if mr, err = m.withHead(ctx, mr); err != nil {
485+ return domain.Comment{}, err
486+ }
487+ if !general {
488+ if err := m.checkLine(ctx, mr, params); err != nil {
489+ return domain.Comment{}, err
490+ }
491+ }
492+
493+ var c domain.Comment
494+ for range maxWriteRetries {
495+ c = domain.Comment{
496+ ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side,
497+ CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
498+ }
499+ mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
500+ err = m.store.SaveComment(ctx, mr, c)
501+ if !errors.Is(err, domain.ErrConflict) {
502+ break
503+ }
504+ // Another write got in between: read the request again.
505+ fresh, gerr := m.store.Get(ctx, name, number)
506+ if gerr != nil {
507+ return domain.Comment{}, gerr
508+ }
509+ if !fresh.IsOpen() {
510+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
511+ }
512+ fresh.HeadID = mr.HeadID
513+ mr = fresh
514+ }
515+ if err != nil {
516+ return domain.Comment{}, fmt.Errorf("save comment on !%d of %q: %w", number, name, err)
517+ }
518+ log := zerowrap.FromCtx(ctx)
519+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
520+ Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
521+ m.bus.Publish(ctx, domain.CommentAdded{
522+ Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
523+ })
524+ return c, nil
525+}
526+
527+// checkLine verifies that a comment designates a line of the current diff of
528+// the request.
529+func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, params AddCommentParams) error {
530+ var v domain.Violations
531+ _, base, err := m.rangeOf(ctx, mr)
532+ if err != nil {
533+ return err
534+ }
535+ if mr.HeadID.IsZero() || base.IsZero() {
536+ v.Add("path", "the merge request has no diff to comment on")
537+ return v.Err()
538+ }
539+ _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, domain.DefaultDiffLimits())
540+ if err != nil {
541+ return err
542+ }
543+ if !diff.HasLine(params.Path, params.Side, params.Line) {
544+ v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
545+ }
546+ return v.Err()
547+}
548+
549+// ResolveComment marks a comment resolved or unresolved. The principal needs
550+// mr:write. An unknown comment gives domain.ErrNotFound and a request that is
551+// not open domain.ErrPreconditionFailed. Setting the state the comment already
552+// has succeeds and writes nothing.
553+func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) {
554+ if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
555+ return domain.Comment{}, err
556+ }
557+ for range maxWriteRetries {
558+ mr, err := m.store.Get(ctx, name, number)
559+ if err != nil {
560+ return domain.Comment{}, err
561+ }
562+ c, err := m.store.GetComment(ctx, name, number, commentID)
563+ if err != nil {
564+ return domain.Comment{}, err
565+ }
566+ if !mr.IsOpen() {
567+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
568+ }
569+ if c.Resolved == resolved {
570+ return c, nil
571+ }
572+ c.Resolved = resolved
573+ mr.UpdatedAt = m.clock.Now().UTC()
574+ err = m.store.SaveComment(ctx, mr, c)
575+ if errors.Is(err, domain.ErrConflict) {
576+ continue
577+ }
578+ if err != nil {
579+ return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err)
580+ }
581+ return c, nil
582+ }
583+ return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
584+}
585+
586+// Close closes an open merge request without merging it and publishes
587+// MergeRequestClosed. The principal needs mr:write. A request that is not open
588+// gives domain.ErrPreconditionFailed. The request keeps the head it had and
589+// its merge base, so that its diff stays readable.
590+func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
591+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
592+ if err != nil {
593+ return domain.MergeRequest{}, err
594+ }
595+ unlock, err := m.lockRepo(ctx, name) // a merge and a close of one request exclude each other
596+ if err != nil {
597+ return domain.MergeRequest{}, err
598+ }
599+ defer unlock()
600+ for range maxWriteRetries {
601+ mr, err := m.store.Get(ctx, name, number)
602+ if err != nil {
603+ return domain.MergeRequest{}, err
604+ }
605+ if !mr.IsOpen() {
606+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
607+ }
608+ if mr, err = m.withHead(ctx, mr); err != nil {
609+ return domain.MergeRequest{}, err
610+ }
611+ _, base, err := m.rangeOf(ctx, mr)
612+ if err != nil {
613+ return domain.MergeRequest{}, err
614+ }
615+ mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
616+ err = m.store.Update(ctx, mr)
617+ if errors.Is(err, domain.ErrConflict) {
618+ continue
619+ }
620+ if err != nil {
621+ return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, err)
622+ }
623+ log := zerowrap.FromCtx(ctx)
624+ log.Info().Str("repo", name.String()).Int64("number", number).
625+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
626+ m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
627+ return mr, nil
628+ }
629+ return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, domain.ErrConflict)
630+}
631+
632+// MarkRead records the head the reviewer has read, for the since-last-read diff
633+// of Get. The principal needs mr:write. A zero head means the current head of
634+// the source branch (the stored one for a merged or closed request); one that
635+// is not a commit of the repository gives domain.ErrInvalid. It returns the
636+// head recorded.
637+func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error) {
638+ if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
639+ return domain.ObjectID{}, err
640+ }
641+ mr, err := m.store.Get(ctx, name, number)
642+ if err != nil {
643+ return domain.ObjectID{}, err
644+ }
645+ if head.IsZero() {
646+ if mr, err = m.withHead(ctx, mr); err != nil {
647+ return domain.ObjectID{}, err
648+ }
649+ if head = mr.HeadID; head.IsZero() {
650+ return domain.ObjectID{}, fmt.Errorf("%w: the source branch %q no longer exists, give a head", domain.ErrPreconditionFailed, mr.Source)
651+ }
652+ } else {
653+ var v domain.Violations
654+ switch ok, err := m.git.HasCommit(ctx, name, head); {
655+ case err != nil:
656+ return domain.ObjectID{}, err
657+ case !ok:
658+ v.Add("head_id", fmt.Sprintf("%s is not a commit of the repository", head))
659+ return domain.ObjectID{}, v.Err()
660+ }
661+ }
662+ if err := m.store.SetRead(ctx, name, number, head); err != nil {
663+ return domain.ObjectID{}, err
664+ }
665+ return head, nil
666+}
667+
668+// MergeParams describes the merge to perform.
669+type MergeParams struct {
670+ // Mode defaults to fast-forward.
671+ Mode domain.MergeMode
672+ // HeadID is the commit the caller reviewed; the source branch must still
673+ // point to it.
674+ HeadID domain.ObjectID
675+}
676+
677+// Merge merges an open merge request, publishes MergeRequestMerged then a
678+// PushEvent for the target branch, and returns the merged request. The
679+// principal needs mr:merge. Merges of one repository run one at a time.
680+//
681+// It fails with domain.ErrInvalid for a zero head, and with
682+// domain.ErrPreconditionFailed when the request is not open, the mode is not
683+// allowed by the settings of the repository, the source branch no longer
684+// points to the head ("branch moved, read again") or is gone, the target is
685+// gone or is not an ancestor of the head (the author rebases and pushes
686+// again), or the branch brings nothing. Checks come with CI (CI-1): there are
687+// none yet, so the setting that lets failing checks block the merge has
688+// nothing to act on.
689+//
690+// A fast-forward moves the target to the head, with the old value as a
691+// precondition. A squash writes one commit with the tree of the head, the
692+// target as parent and the author and message of domain.NewSquashCommit,
693+// signed by the instance key, and moves the target to it the same way.
694+func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (domain.MergeRequest, error) {
695+ repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
696+ if err != nil {
697+ return domain.MergeRequest{}, err
698+ }
699+ mode := params.Mode
700+ if mode == "" {
701+ mode = domain.MergeModeFastForward
702+ }
703+ var v domain.Violations
704+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
705+ v.Add("mode", "is not a merge mode")
706+ }
707+ if params.HeadID.IsZero() {
708+ v.Add("head_id", "is required")
709+ }
710+ if err := v.Err(); err != nil {
711+ return domain.MergeRequest{}, err
712+ }
713+
714+ unlock, err := m.lockRepo(ctx, name)
715+ if err != nil {
716+ return domain.MergeRequest{}, err
717+ }
718+ defer unlock()
719+
720+ mr, err := m.store.Get(ctx, name, number)
721+ if err != nil {
722+ return domain.MergeRequest{}, err
723+ }
724+ if !mr.IsOpen() {
725+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
726+ }
727+ // The settings are read again under the lock: a change applies at once.
728+ if current, err := m.meta.Get(ctx, name); err == nil {
729+ repo = current
730+ }
731+ settings := repo.Settings
732+ if settings.IsZero() {
733+ settings = domain.DefaultRepoSettings()
734+ }
735+ if !settings.Allows(mode) {
736+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
737+ }
738+
739+ head, err := m.branchOrZero(ctx, name, mr.Source)
740+ switch {
741+ case err != nil:
742+ return domain.MergeRequest{}, err
743+ case head.IsZero():
744+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
745+ case head != params.HeadID:
746+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
747+ }
748+ tip, err := m.branchOrZero(ctx, name, mr.Target)
749+ switch {
750+ case err != nil:
751+ return domain.MergeRequest{}, err
752+ case tip.IsZero():
753+ return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
754+ case tip == head:
755+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
756+ }
757+ switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {
758+ case err != nil:
759+ return domain.MergeRequest{}, err
760+ case !ok:
761+ return domain.MergeRequest{}, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target)
762+ }
763+
764+ merged, err := m.moveTarget(ctx, mr, mode, tip, head)
765+ if err != nil {
766+ return domain.MergeRequest{}, err
767+ }
768+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
769+ if err != nil {
770+ return domain.MergeRequest{}, err
771+ }
772+ log := zerowrap.FromCtx(ctx)
773+ log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
774+ Str("target", mr.Target).Str("commit", merged.String()).
775+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
776+ at := mr.UpdatedAt
777+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
778+ m.bus.Publish(ctx, domain.PushEvent{
779+ Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
780+ Updates: []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}},
781+ })
782+ return mr, nil
783+}
784+
785+// moveTarget moves the target branch from tip to the head (fast-forward) or to
786+// a new signed squash commit, and returns the new tip.
787+func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
788+ newTip := head
789+ if mode == domain.MergeModeSquash {
790+ commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
791+ if err != nil {
792+ return domain.ObjectID{}, err
793+ }
794+ if more {
795+ return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
796+ }
797+ tree, err := m.git.CommitTree(ctx, mr.Repo, head)
798+ if err != nil {
799+ return domain.ObjectID{}, err
800+ }
801+ commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
802+ armored, err := m.signer.Sign(commit.Payload())
803+ if err != nil {
804+ return domain.ObjectID{}, fmt.Errorf("sign the squash commit of !%d in %q: %w", mr.Number, mr.Repo, err)
805+ }
806+ if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
807+ return domain.ObjectID{}, err
808+ }
809+ }
810+ err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
811+ if errors.Is(err, domain.ErrConflict) {
812+ return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target)
813+ }
814+ if err != nil {
815+ return domain.ObjectID{}, err
816+ }
817+ return newTip, nil
818+}
819+
820+// markMerged records the merge. The target already moved, so a write that
821+// loses a race (a comment added meanwhile) is repeated on the fresh request.
822+func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {
823+ // The merge must be recorded even when the caller gives up.
824+ ctx = context.WithoutCancel(ctx)
825+ for range maxWriteRetries {
826+ mr, err := m.store.Get(ctx, name, number)
827+ if err != nil {
828+ return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
829+ }
830+ mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
831+ mr.UpdatedAt = m.clock.Now().UTC()
832+ err = m.store.Update(ctx, mr)
833+ if errors.Is(err, domain.ErrConflict) {
834+ continue
835+ }
836+ if err != nil {
837+ return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
838+ }
839+ return mr, nil
840+ }
841+ log := zerowrap.FromCtx(ctx)
842+ log.Error().Str("repo", name.String()).Int64("number", number).Str("commit", merged.String()).
843+ Msg("the target branch moved but the merge request could not be marked merged")
844+ return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
845+}
@@ -0,0 +1,845 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "strconv"
11+ "sync"
12+
13+ "github.com/bnema/zerowrap"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// MaxMergeRequestCommits is the most commits GetMergeRequest returns.
20+const MaxMergeRequestCommits = 100
21+
22+// maxWriteRetries bounds the retries of a write that loses a race with another
23+// write of the same merge request. Each lost race means another writer won, so
24+// a writer loses at most as many times as there are other writers at once; the
25+// retry is a read and a write of ermite-kv, cheap enough for a bound this high.
26+const maxWriteRetries = 32
27+
28+const mrTokenPrefix = "mp1:"
29+
30+// MergeRequests are the merge request use cases (ADR 0017): review, comment,
31+// close and merge a branch. Reading needs the access of reading the
32+// repository: a repository the principal may not read, a missing one and an
33+// unknown number all answer domain.ErrNotFound. Writing needs mr:write, and
34+// merging mr:merge, on the repository (see scopeAccess).
35+type MergeRequests struct {
36+ meta ports.RepoMetaStore
37+ store ports.MergeRequestStore
38+ repos ports.GitRepos
39+ git ports.MergeGit
40+ signer ports.CommitSigner
41+ committer domain.Identity
42+ bus ports.EventBus
43+ clock ports.Clock
44+
45+ mu sync.Mutex
46+ locks map[domain.RepoName]chan struct{}
47+}
48+
49+// MergeRequestsConfig holds what the merge request use cases need. Every field
50+// is required.
51+type MergeRequestsConfig struct {
52+ Meta ports.RepoMetaStore
53+ Store ports.MergeRequestStore
54+ // Repos gives the default branch of a repository.
55+ Repos ports.GitRepos
56+ Git ports.MergeGit
57+ // Signer signs the squash commits.
58+ Signer ports.CommitSigner
59+ // Committer is recorded as the committer of the squash commits.
60+ Committer domain.Identity
61+ Bus ports.EventBus
62+ Clock ports.Clock
63+}
64+
65+// NewMergeRequests builds the merge request use cases.
66+func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
67+ return &MergeRequests{
68+ meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
69+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock,
70+ locks: make(map[domain.RepoName]chan struct{}),
71+ }
72+}
73+
74+// lockRepo takes the merge lock of a repository, waiting until ctx ends. The
75+// merges of one repository, and the creation of its merge requests, run one at
76+// a time. The returned function releases the lock.
77+func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
78+ m.mu.Lock()
79+ ch, ok := m.locks[name]
80+ if !ok {
81+ ch = make(chan struct{}, 1)
82+ m.locks[name] = ch
83+ }
84+ m.mu.Unlock()
85+ select {
86+ case ch <- struct{}{}:
87+ return func() { <-ch }, nil
88+ case <-ctx.Done():
89+ return nil, ctx.Err()
90+ }
91+}
92+
93+// readable looks the repository up and applies the read rule.
94+func (m *MergeRequests) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
95+ repo, err := lookupRepo(ctx, m.meta, name)
96+ if err != nil {
97+ return domain.Repo{}, err
98+ }
99+ if !canRead(p, repo) {
100+ return domain.Repo{}, domain.ErrNotFound
101+ }
102+ return repo, nil
103+}
104+
105+// writable is readable plus the scope.
106+func (m *MergeRequests) writable(ctx context.Context, p domain.Principal, name domain.RepoName, scope domain.Scope) (domain.Repo, error) {
107+ repo, err := m.readable(ctx, p, name)
108+ if err != nil {
109+ return domain.Repo{}, err
110+ }
111+ if err := scopeAccess(p, repo, scope); err != nil {
112+ return domain.Repo{}, err
113+ }
114+ return repo, nil
115+}
116+
117+// withHead fills HeadID of an open request from its source branch: zero when
118+// the branch is gone. Merged and closed requests keep the head they stored.
119+func (m *MergeRequests) withHead(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
120+ if !mr.IsOpen() {
121+ return mr, nil
122+ }
123+ head, err := m.git.BranchHead(ctx, mr.Repo, mr.Source)
124+ switch {
125+ case errors.Is(err, domain.ErrNotFound):
126+ mr.HeadID = domain.ObjectID{}
127+ case err != nil:
128+ return domain.MergeRequest{}, err
129+ default:
130+ mr.HeadID = head
131+ }
132+ return mr, nil
133+}
134+
135+// branchOrZero returns the head of a branch, the zero id when it is gone.
136+func (m *MergeRequests) branchOrZero(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
137+ id, err := m.git.BranchHead(ctx, repo, branch)
138+ if errors.Is(err, domain.ErrNotFound) {
139+ return domain.ObjectID{}, nil
140+ }
141+ return id, err
142+}
143+
144+// CreateMergeRequestParams describes the merge request to open.
145+type CreateMergeRequestParams struct {
146+ Title string
147+ Description string
148+ // Source is required; Target defaults to the default branch.
149+ Source, Target string
150+}
151+
152+// Create opens a merge request and publishes MergeRequestOpened. The principal
153+// needs mr:write. A request that breaks a rule of ADR 0017 (the texts, a
154+// branch that does not exist, source and target the same) is rejected whole
155+// with an *domain.InvalidFieldsError naming the fields. An open request with
156+// the same source and target gives domain.ErrAlreadyExists.
157+func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) {
158+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
159+ if err != nil {
160+ return domain.MergeRequest{}, err
161+ }
162+ var v domain.Violations
163+ title, err := domain.ParseMRTitle(params.Title)
164+ if err != nil {
165+ v.AddErr("title", err)
166+ }
167+ desc, err := domain.ParsePlainText(params.Description)
168+ if err != nil {
169+ v.AddErr("description", err)
170+ }
171+ source, target := m.checkBranches(ctx, name, params, &v)
172+ if err := v.Err(); err != nil {
173+ return domain.MergeRequest{}, err
174+ }
175+
176+ // Under the lock, so that two requests for the same pair cannot both pass
177+ // the check for an existing one.
178+ unlock, err := m.lockRepo(ctx, name)
179+ if err != nil {
180+ return domain.MergeRequest{}, err
181+ }
182+ defer unlock()
183+ existing, err := m.store.List(ctx, name)
184+ if err != nil {
185+ return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err)
186+ }
187+ for _, e := range existing {
188+ if e.IsOpen() && e.Source == source && e.Target == target {
189+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d already merges %s into %s", domain.ErrAlreadyExists, e.Number, source, target)
190+ }
191+ }
192+
193+ now := m.clock.Now().UTC()
194+ mr, err := m.store.Create(ctx, domain.MergeRequest{
195+ Repo: name, Title: title, Description: desc, Source: source, Target: target,
196+ State: domain.MRStateOpen, Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
197+ })
198+ if err != nil {
199+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err)
200+ }
201+ log := zerowrap.FromCtx(ctx)
202+ log.Info().Str("repo", name.String()).Int64("number", mr.Number).
203+ Str("source", source).Str("target", target).
204+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request opened")
205+ mr, err = m.withHead(ctx, mr)
206+ if err != nil {
207+ return domain.MergeRequest{}, err
208+ }
209+ m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
210+ return mr, nil
211+}
212+
213+// checkBranches validates the branches of a new request and returns them, with
214+// the default branch filled in for the target.
215+func (m *MergeRequests) checkBranches(ctx context.Context, name domain.RepoName, params CreateMergeRequestParams, v *domain.Violations) (source, target string) {
216+ source, target = params.Source, params.Target
217+ if source == "" {
218+ v.Add("source_branch", "is required")
219+ } else if _, err := domain.ParseBranchName(source); err != nil {
220+ v.Add("source_branch", "is not a valid branch name")
221+ source = ""
222+ }
223+ if target == "" {
224+ def, exists, err := m.repos.DefaultBranch(ctx, name)
225+ switch {
226+ case err != nil:
227+ v.Add("target_branch", "cannot be read: "+err.Error())
228+ case !exists:
229+ v.Add("target_branch", fmt.Sprintf("is empty and the default branch %q does not exist", def))
230+ default:
231+ target = def
232+ }
233+ } else if _, err := domain.ParseBranchName(target); err != nil {
234+ v.Add("target_branch", "is not a valid branch name")
235+ target = ""
236+ }
237+ if source != "" && source == target {
238+ v.Add("target_branch", "must differ from source_branch")
239+ return source, target
240+ }
241+ for _, b := range []struct{ field, name string }{{"source_branch", source}, {"target_branch", target}} {
242+ if b.name == "" {
243+ continue
244+ }
245+ _, err := m.git.BranchHead(ctx, name, b.name)
246+ if errors.Is(err, domain.ErrNotFound) {
247+ v.Add(b.field, fmt.Sprintf("branch %q does not exist", b.name))
248+ } else if err != nil {
249+ v.Add(b.field, "cannot be read: "+err.Error())
250+ }
251+ }
252+ return source, target
253+}
254+
255+// ListMergeRequestsParams selects a page of merge requests.
256+type ListMergeRequestsParams struct {
257+ // State restricts the result; empty means every state.
258+ State domain.MRState
259+ // PageSize is 0 for the default, and lowered to MaxPageSize.
260+ PageSize int
261+ // PageToken is the NextPageToken of the previous page, or empty.
262+ PageToken string
263+}
264+
265+// MergeRequestPage is a page of merge requests, highest number first.
266+type MergeRequestPage struct {
267+ MergeRequests []domain.MergeRequest
268+ // NextPageToken is empty on the last page.
269+ NextPageToken string
270+}
271+
272+// List returns a page of the merge requests of a repository, highest number
273+// first. The open ones carry the head of their source branch. The page token
274+// names the last number of the previous page, so requests created between two
275+// pages never shift the others.
276+func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) {
277+ size, err := pageSize(params.PageSize)
278+ if err != nil {
279+ return MergeRequestPage{}, err
280+ }
281+ after, err := decodeMRToken(params.PageToken)
282+ if err != nil {
283+ return MergeRequestPage{}, err
284+ }
285+ if _, err := m.readable(ctx, p, name); err != nil {
286+ return MergeRequestPage{}, err
287+ }
288+ all, err := m.store.List(ctx, name)
289+ if err != nil {
290+ return MergeRequestPage{}, fmt.Errorf("list merge requests of %q: %w", name, err)
291+ }
292+ var page MergeRequestPage
293+ page.MergeRequests, page.NextPageToken = pageAfter(all, size,
294+ func(mr domain.MergeRequest) bool {
295+ return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
296+ },
297+ encodeMRToken)
298+ for i, mr := range page.MergeRequests {
299+ if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
300+ return MergeRequestPage{}, err
301+ }
302+ }
303+ return page, nil
304+}
305+
306+func encodeMRToken(last domain.MergeRequest) string {
307+ return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
308+}
309+
310+// decodeMRToken returns 0 for the empty token.
311+func decodeMRToken(tok string) (int64, error) {
312+ if tok == "" {
313+ return 0, nil
314+ }
315+ raw, ok := decodeToken(tok, mrTokenPrefix)
316+ if ok {
317+ if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 {
318+ return n, nil
319+ }
320+ }
321+ return 0, fmt.Errorf("%w: merge requests", domain.ErrInvalidPageToken)
322+}
323+
324+// get reads a merge request of a repository the principal can read.
325+func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Repo, domain.MergeRequest, error) {
326+ repo, err := m.readable(ctx, p, name)
327+ if err != nil {
328+ return domain.Repo{}, domain.MergeRequest{}, err
329+ }
330+ mr, err := m.store.Get(ctx, name, number)
331+ if err != nil {
332+ return domain.Repo{}, domain.MergeRequest{}, err
333+ }
334+ return repo, mr, nil
335+}
336+
337+// MergeRequestDetail is a merge request with its review data.
338+type MergeRequestDetail struct {
339+ MergeRequest domain.MergeRequest
340+ // Commits are the commits of the head that the target lacks, newest
341+ // first, at most MaxMergeRequestCommits.
342+ Commits []domain.Commit
343+ CommitsTruncated bool
344+ // DiffBaseID is the commit the diff compares from; zero when the source
345+ // branch is gone, then the diff is empty.
346+ DiffBaseID domain.ObjectID
347+ Stats domain.DiffStats
348+ Diff domain.Diff
349+ // Comments are the comments, oldest first.
350+ Comments []domain.Comment
351+ // LastReadID is the head recorded by MarkRead; zero when none.
352+ LastReadID domain.ObjectID
353+ // LastReadLost tells that since-last-read was asked for, a head was
354+ // recorded, but that commit is gone: the diff is against the target.
355+ LastReadLost bool
356+}
357+
358+// Get returns a merge request with its commits, its diff, its comments and the
359+// read position. The diff compares the merge base of the target and the head
360+// with the head ("target...head"), within domain.DefaultDiffLimits. With
361+// sinceLastRead it compares the head recorded by MarkRead with the current
362+// head instead, which works after a force-push as long as the recorded commit
363+// still exists; when none was recorded the diff is the default one, and when
364+// the recorded commit is gone too, with LastReadLost set. A merged or closed
365+// request keeps the head and the base it had.
366+func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (MergeRequestDetail, error) {
367+ _, mr, err := m.get(ctx, p, name, number)
368+ if err != nil {
369+ return MergeRequestDetail{}, err
370+ }
371+ if mr, err = m.withHead(ctx, mr); err != nil {
372+ return MergeRequestDetail{}, err
373+ }
374+ d := MergeRequestDetail{MergeRequest: mr}
375+ if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
376+ return MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
377+ }
378+ if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
379+ return MergeRequestDetail{}, err
380+ }
381+
382+ exclude, base, err := m.rangeOf(ctx, mr)
383+ if err != nil {
384+ return MergeRequestDetail{}, err
385+ }
386+ head := mr.HeadID
387+ if head.IsZero() {
388+ return d, nil // the source branch is gone: no commits, no diff
389+ }
390+ if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
391+ return MergeRequestDetail{}, err
392+ }
393+ if sinceLastRead && !d.LastReadID.IsZero() {
394+ switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
395+ case err != nil:
396+ return MergeRequestDetail{}, err
397+ case ok:
398+ base = d.LastReadID
399+ default:
400+ d.LastReadLost = true
401+ }
402+ }
403+ if base.IsZero() {
404+ return d, nil
405+ }
406+ d.DiffBaseID = base
407+ if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, domain.DefaultDiffLimits()); err != nil {
408+ return MergeRequestDetail{}, err
409+ }
410+ return d, nil
411+}
412+
413+// rangeOf returns what the review of a request compares: the commit whose
414+// history the request's commits exclude, and the base of its diff. For an open
415+// request they are the tip of the target and the merge base of the target and
416+// the head; both are zero when the target is gone or shares no history with
417+// the head. A merged or closed request keeps the base it stored for both.
418+func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
419+ if !mr.IsOpen() {
420+ return mr.BaseID, mr.BaseID, nil
421+ }
422+ if mr.HeadID.IsZero() {
423+ return domain.ObjectID{}, domain.ObjectID{}, nil
424+ }
425+ tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)
426+ if err != nil || tip.IsZero() {
427+ return domain.ObjectID{}, domain.ObjectID{}, err
428+ }
429+ base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
430+ if errors.Is(err, domain.ErrNotFound) {
431+ return tip, domain.ObjectID{}, nil
432+ }
433+ return tip, base, err
434+}
435+
436+// AddCommentParams describes the comment to add.
437+type AddCommentParams struct {
438+ // Path, Line and Side designate a line of the diff; all empty for a
439+ // general comment.
440+ Path string
441+ Line int
442+ Side domain.DiffSide
443+ Text string
444+}
445+
446+// AddComment adds a comment and publishes CommentAdded. The principal needs
447+// mr:write. The text must follow the rules of ADR 0017 and not be blank; a
448+// comment on a line must name a line of the current diff of the request (see
449+// domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request
450+// that is not open gives domain.ErrPreconditionFailed.
451+func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error) {
452+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
453+ if err != nil {
454+ return domain.Comment{}, err
455+ }
456+ mr, err := m.store.Get(ctx, name, number)
457+ if err != nil {
458+ return domain.Comment{}, err
459+ }
460+ var v domain.Violations
461+ text, err := domain.ParsePlainText(params.Text)
462+ switch {
463+ case err != nil:
464+ v.AddErr("text", err)
465+ case text.IsBlank():
466+ v.Add("text", "is required")
467+ }
468+ general := params.Path == ""
469+ switch {
470+ case general && (params.Line != 0 || params.Side != domain.SideNone):
471+ v.Add("path", "is required with line and side")
472+ case !general && params.Line < 1:
473+ v.Add("line", "must be 1 or more with a path")
474+ case !general && params.Side != domain.SideOld && params.Side != domain.SideNew:
475+ v.Add("side", "is required with a path")
476+ }
477+ if err := v.Err(); err != nil {
478+ return domain.Comment{}, err
479+ }
480+ if !mr.IsOpen() {
481+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
482+ }
483+
484+ if mr, err = m.withHead(ctx, mr); err != nil {
485+ return domain.Comment{}, err
486+ }
487+ if !general {
488+ if err := m.checkLine(ctx, mr, params); err != nil {
489+ return domain.Comment{}, err
490+ }
491+ }
492+
493+ var c domain.Comment
494+ for range maxWriteRetries {
495+ c = domain.Comment{
496+ ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side,
497+ CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
498+ }
499+ mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
500+ err = m.store.SaveComment(ctx, mr, c)
501+ if !errors.Is(err, domain.ErrConflict) {
502+ break
503+ }
504+ // Another write got in between: read the request again.
505+ fresh, gerr := m.store.Get(ctx, name, number)
506+ if gerr != nil {
507+ return domain.Comment{}, gerr
508+ }
509+ if !fresh.IsOpen() {
510+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
511+ }
512+ fresh.HeadID = mr.HeadID
513+ mr = fresh
514+ }
515+ if err != nil {
516+ return domain.Comment{}, fmt.Errorf("save comment on !%d of %q: %w", number, name, err)
517+ }
518+ log := zerowrap.FromCtx(ctx)
519+ log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
520+ Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
521+ m.bus.Publish(ctx, domain.CommentAdded{
522+ Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
523+ })
524+ return c, nil
525+}
526+
527+// checkLine verifies that a comment designates a line of the current diff of
528+// the request.
529+func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, params AddCommentParams) error {
530+ var v domain.Violations
531+ _, base, err := m.rangeOf(ctx, mr)
532+ if err != nil {
533+ return err
534+ }
535+ if mr.HeadID.IsZero() || base.IsZero() {
536+ v.Add("path", "the merge request has no diff to comment on")
537+ return v.Err()
538+ }
539+ _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, domain.DefaultDiffLimits())
540+ if err != nil {
541+ return err
542+ }
543+ if !diff.HasLine(params.Path, params.Side, params.Line) {
544+ v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
545+ }
546+ return v.Err()
547+}
548+
549+// ResolveComment marks a comment resolved or unresolved. The principal needs
550+// mr:write. An unknown comment gives domain.ErrNotFound and a request that is
551+// not open domain.ErrPreconditionFailed. Setting the state the comment already
552+// has succeeds and writes nothing.
553+func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) {
554+ if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
555+ return domain.Comment{}, err
556+ }
557+ for range maxWriteRetries {
558+ mr, err := m.store.Get(ctx, name, number)
559+ if err != nil {
560+ return domain.Comment{}, err
561+ }
562+ c, err := m.store.GetComment(ctx, name, number, commentID)
563+ if err != nil {
564+ return domain.Comment{}, err
565+ }
566+ if !mr.IsOpen() {
567+ return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
568+ }
569+ if c.Resolved == resolved {
570+ return c, nil
571+ }
572+ c.Resolved = resolved
573+ mr.UpdatedAt = m.clock.Now().UTC()
574+ err = m.store.SaveComment(ctx, mr, c)
575+ if errors.Is(err, domain.ErrConflict) {
576+ continue
577+ }
578+ if err != nil {
579+ return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err)
580+ }
581+ return c, nil
582+ }
583+ return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
584+}
585+
586+// Close closes an open merge request without merging it and publishes
587+// MergeRequestClosed. The principal needs mr:write. A request that is not open
588+// gives domain.ErrPreconditionFailed. The request keeps the head it had and
589+// its merge base, so that its diff stays readable.
590+func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
591+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
592+ if err != nil {
593+ return domain.MergeRequest{}, err
594+ }
595+ unlock, err := m.lockRepo(ctx, name) // a merge and a close of one request exclude each other
596+ if err != nil {
597+ return domain.MergeRequest{}, err
598+ }
599+ defer unlock()
600+ for range maxWriteRetries {
601+ mr, err := m.store.Get(ctx, name, number)
602+ if err != nil {
603+ return domain.MergeRequest{}, err
604+ }
605+ if !mr.IsOpen() {
606+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
607+ }
608+ if mr, err = m.withHead(ctx, mr); err != nil {
609+ return domain.MergeRequest{}, err
610+ }
611+ _, base, err := m.rangeOf(ctx, mr)
612+ if err != nil {
613+ return domain.MergeRequest{}, err
614+ }
615+ mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
616+ err = m.store.Update(ctx, mr)
617+ if errors.Is(err, domain.ErrConflict) {
618+ continue
619+ }
620+ if err != nil {
621+ return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, err)
622+ }
623+ log := zerowrap.FromCtx(ctx)
624+ log.Info().Str("repo", name.String()).Int64("number", number).
625+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
626+ m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
627+ return mr, nil
628+ }
629+ return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, domain.ErrConflict)
630+}
631+
632+// MarkRead records the head the reviewer has read, for the since-last-read diff
633+// of Get. The principal needs mr:write. A zero head means the current head of
634+// the source branch (the stored one for a merged or closed request); one that
635+// is not a commit of the repository gives domain.ErrInvalid. It returns the
636+// head recorded.
637+func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error) {
638+ if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
639+ return domain.ObjectID{}, err
640+ }
641+ mr, err := m.store.Get(ctx, name, number)
642+ if err != nil {
643+ return domain.ObjectID{}, err
644+ }
645+ if head.IsZero() {
646+ if mr, err = m.withHead(ctx, mr); err != nil {
647+ return domain.ObjectID{}, err
648+ }
649+ if head = mr.HeadID; head.IsZero() {
650+ return domain.ObjectID{}, fmt.Errorf("%w: the source branch %q no longer exists, give a head", domain.ErrPreconditionFailed, mr.Source)
651+ }
652+ } else {
653+ var v domain.Violations
654+ switch ok, err := m.git.HasCommit(ctx, name, head); {
655+ case err != nil:
656+ return domain.ObjectID{}, err
657+ case !ok:
658+ v.Add("head_id", fmt.Sprintf("%s is not a commit of the repository", head))
659+ return domain.ObjectID{}, v.Err()
660+ }
661+ }
662+ if err := m.store.SetRead(ctx, name, number, head); err != nil {
663+ return domain.ObjectID{}, err
664+ }
665+ return head, nil
666+}
667+
668+// MergeParams describes the merge to perform.
669+type MergeParams struct {
670+ // Mode defaults to fast-forward.
671+ Mode domain.MergeMode
672+ // HeadID is the commit the caller reviewed; the source branch must still
673+ // point to it.
674+ HeadID domain.ObjectID
675+}
676+
677+// Merge merges an open merge request, publishes MergeRequestMerged then a
678+// PushEvent for the target branch, and returns the merged request. The
679+// principal needs mr:merge. Merges of one repository run one at a time.
680+//
681+// It fails with domain.ErrInvalid for a zero head, and with
682+// domain.ErrPreconditionFailed when the request is not open, the mode is not
683+// allowed by the settings of the repository, the source branch no longer
684+// points to the head ("branch moved, read again") or is gone, the target is
685+// gone or is not an ancestor of the head (the author rebases and pushes
686+// again), or the branch brings nothing. Checks come with CI (CI-1): there are
687+// none yet, so the setting that lets failing checks block the merge has
688+// nothing to act on.
689+//
690+// A fast-forward moves the target to the head, with the old value as a
691+// precondition. A squash writes one commit with the tree of the head, the
692+// target as parent and the author and message of domain.NewSquashCommit,
693+// signed by the instance key, and moves the target to it the same way.
694+func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (domain.MergeRequest, error) {
695+ repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
696+ if err != nil {
697+ return domain.MergeRequest{}, err
698+ }
699+ mode := params.Mode
700+ if mode == "" {
701+ mode = domain.MergeModeFastForward
702+ }
703+ var v domain.Violations
704+ if _, err := domain.ParseMergeMode(mode.String()); err != nil {
705+ v.Add("mode", "is not a merge mode")
706+ }
707+ if params.HeadID.IsZero() {
708+ v.Add("head_id", "is required")
709+ }
710+ if err := v.Err(); err != nil {
711+ return domain.MergeRequest{}, err
712+ }
713+
714+ unlock, err := m.lockRepo(ctx, name)
715+ if err != nil {
716+ return domain.MergeRequest{}, err
717+ }
718+ defer unlock()
719+
720+ mr, err := m.store.Get(ctx, name, number)
721+ if err != nil {
722+ return domain.MergeRequest{}, err
723+ }
724+ if !mr.IsOpen() {
725+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
726+ }
727+ // The settings are read again under the lock: a change applies at once.
728+ if current, err := m.meta.Get(ctx, name); err == nil {
729+ repo = current
730+ }
731+ settings := repo.Settings
732+ if settings.IsZero() {
733+ settings = domain.DefaultRepoSettings()
734+ }
735+ if !settings.Allows(mode) {
736+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
737+ }
738+
739+ head, err := m.branchOrZero(ctx, name, mr.Source)
740+ switch {
741+ case err != nil:
742+ return domain.MergeRequest{}, err
743+ case head.IsZero():
744+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
745+ case head != params.HeadID:
746+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
747+ }
748+ tip, err := m.branchOrZero(ctx, name, mr.Target)
749+ switch {
750+ case err != nil:
751+ return domain.MergeRequest{}, err
752+ case tip.IsZero():
753+ return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
754+ case tip == head:
755+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
756+ }
757+ switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {
758+ case err != nil:
759+ return domain.MergeRequest{}, err
760+ case !ok:
761+ return domain.MergeRequest{}, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target)
762+ }
763+
764+ merged, err := m.moveTarget(ctx, mr, mode, tip, head)
765+ if err != nil {
766+ return domain.MergeRequest{}, err
767+ }
768+ mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
769+ if err != nil {
770+ return domain.MergeRequest{}, err
771+ }
772+ log := zerowrap.FromCtx(ctx)
773+ log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
774+ Str("target", mr.Target).Str("commit", merged.String()).
775+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
776+ at := mr.UpdatedAt
777+ m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
778+ m.bus.Publish(ctx, domain.PushEvent{
779+ Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
780+ Updates: []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}},
781+ })
782+ return mr, nil
783+}
784+
785+// moveTarget moves the target branch from tip to the head (fast-forward) or to
786+// a new signed squash commit, and returns the new tip.
787+func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
788+ newTip := head
789+ if mode == domain.MergeModeSquash {
790+ commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
791+ if err != nil {
792+ return domain.ObjectID{}, err
793+ }
794+ if more {
795+ return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
796+ }
797+ tree, err := m.git.CommitTree(ctx, mr.Repo, head)
798+ if err != nil {
799+ return domain.ObjectID{}, err
800+ }
801+ commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
802+ armored, err := m.signer.Sign(commit.Payload())
803+ if err != nil {
804+ return domain.ObjectID{}, fmt.Errorf("sign the squash commit of !%d in %q: %w", mr.Number, mr.Repo, err)
805+ }
806+ if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
807+ return domain.ObjectID{}, err
808+ }
809+ }
810+ err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
811+ if errors.Is(err, domain.ErrConflict) {
812+ return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target)
813+ }
814+ if err != nil {
815+ return domain.ObjectID{}, err
816+ }
817+ return newTip, nil
818+}
819+
820+// markMerged records the merge. The target already moved, so a write that
821+// loses a race (a comment added meanwhile) is repeated on the fresh request.
822+func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {
823+ // The merge must be recorded even when the caller gives up.
824+ ctx = context.WithoutCancel(ctx)
825+ for range maxWriteRetries {
826+ mr, err := m.store.Get(ctx, name, number)
827+ if err != nil {
828+ return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
829+ }
830+ mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
831+ mr.UpdatedAt = m.clock.Now().UTC()
832+ err = m.store.Update(ctx, mr)
833+ if errors.Is(err, domain.ErrConflict) {
834+ continue
835+ }
836+ if err != nil {
837+ return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
838+ }
839+ return mr, nil
840+ }
841+ log := zerowrap.FromCtx(ctx)
842+ log.Error().Str("repo", name.String()).Int64("number", number).Str("commit", merged.String()).
843+ Msg("the target branch moved but the merge request could not be marked merged")
844+ return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
845+}
A internal/app/mergerequests_integration_test.go +1178 -0
@@ -0,0 +1,1178 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/ed25519"
9+ "fmt"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/adapters/eventbus"
22+ "bnema.dev/ermite/internal/adapters/gitcli"
23+ "bnema.dev/ermite/internal/adapters/kvstore"
24+ "bnema.dev/ermite/internal/adapters/sshsig"
25+ "bnema.dev/ermite/internal/app"
26+ "bnema.dev/ermite/internal/domain"
27+)
28+
29+// mrRig runs the merge request use cases over the real adapters: a data
30+// directory with ermite-kv and bare repositories, and a work tree to push
31+// branches from.
32+type mrRig struct {
33+ t *testing.T
34+ ctx context.Context
35+ data string
36+ repos *app.Repos
37+ mrs *app.MergeRequests
38+ key *app.SigningKey
39+ store *kvstore.Store
40+ git *gitcli.Git
41+ bus *eventbus.Bus
42+ events <-chan domain.Event
43+ work string
44+ repo domain.RepoName
45+}
46+
47+var (
48+ root = domain.Root()
49+ author = domain.Identity{Name: "Ada Lovelace", Email: "ada@example.org"}
50+)
51+
52+func newMRRig(t *testing.T) *mrRig {
53+ t.Helper()
54+ ctx := context.Background()
55+ bin, err := exec.LookPath("git")
56+ if err != nil {
57+ unusable(t, "git not found")
58+ }
59+ data := t.TempDir()
60+ for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} {
61+ require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700))
62+ }
63+ git := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")})
64+ if _, err := git.CheckVersion(ctx); err != nil {
65+ unusable(t, "git is not usable by ermite: %v", err)
66+ }
67+ t.Cleanup(func() { _ = git.Close(context.Background()) })
68+ store, err := kvstore.Open(ctx, filepath.Join(data, "kv"))
69+ require.NoError(t, err)
70+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
71+
72+ signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
73+ require.NoError(t, err)
74+ bus := eventbus.NewWithBuffer(1024)
75+ committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
76+ r := &mrRig{
77+ t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),
78+ repos: app.NewRepos(store, git, bus, fixedClock{}),
79+ key: app.NewSigningKey(signer, committer),
80+ work: t.TempDir(),
81+ repo: name(t, "proj"),
82+ }
83+ r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
84+ Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
85+ Committer: committer, Bus: bus, Clock: fixedClock{},
86+ })
87+ _, err = r.repos.Create(ctx, root, app.CreateParams{Name: r.repo, Visibility: domain.VisibilityPrivate})
88+ require.NoError(t, err)
89+ r.sh("init", "-q", "--initial-branch=main")
90+ return r
91+}
92+
93+func (r *mrRig) bare() string { return filepath.Join(r.data, "repos", r.repo.String()+".git") }
94+
95+func (r *mrRig) env(who domain.Identity, extra ...string) []string {
96+ return append([]string{
97+ "PATH=" + os.Getenv("PATH"), "HOME=" + r.work, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
98+ "GIT_AUTHOR_NAME=" + who.Name, "GIT_AUTHOR_EMAIL=" + who.Email,
99+ "GIT_COMMITTER_NAME=" + who.Name, "GIT_COMMITTER_EMAIL=" + who.Email,
100+ "LC_ALL=C",
101+ }, extra...)
102+}
103+
104+func (r *mrRig) run(dir string, env []string, args ...string) string {
105+ r.t.Helper()
106+ cmd := exec.Command("git", args...)
107+ cmd.Dir, cmd.Env = dir, env
108+ out, err := cmd.CombinedOutput()
109+ require.NoError(r.t, err, "git %s\n%s", strings.Join(args, " "), out)
110+ return strings.TrimSpace(string(out))
111+}
112+
113+// sh runs git in the work tree as the default author.
114+func (r *mrRig) sh(args ...string) string { return r.run(r.work, r.env(author), args...) }
115+
116+// commit writes a file and commits it as who; it returns the commit id.
117+func (r *mrRig) commit(who domain.Identity, path, content, msg string) domain.ObjectID {
118+ r.t.Helper()
119+ full := filepath.Join(r.work, path)
120+ require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755))
121+ require.NoError(r.t, os.WriteFile(full, []byte(content), 0o644))
122+ r.run(r.work, r.env(who), "add", path)
123+ r.run(r.work, r.env(who), "commit", "-q", "-m", msg)
124+ return r.head()
125+}
126+
127+func (r *mrRig) head() domain.ObjectID {
128+ r.t.Helper()
129+ return oidFrom(r.t, r.sh("rev-parse", "HEAD"))
130+}
131+
132+func oidFrom(t *testing.T, s string) domain.ObjectID {
133+ t.Helper()
134+ id, err := domain.ParseObjectID(s)
135+ require.NoError(t, err)
136+ return id
137+}
138+
139+// push pushes the branch of the work tree to the repository, forcing it.
140+func (r *mrRig) push(branch string) {
141+ r.t.Helper()
142+ r.sh("push", "-q", "--force", r.bare(), branch+":refs/heads/"+branch)
143+}
144+
145+// branchHead reads a branch of the bare repository.
146+func (r *mrRig) branchHead(branch string) domain.ObjectID {
147+ r.t.Helper()
148+ return oidFrom(r.t, r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/"+branch))
149+}
150+
151+// seed pushes main with two files and returns its id.
152+func (r *mrRig) seed() domain.ObjectID {
153+ r.t.Helper()
154+ r.commit(author, "a.txt", "one\ntwo\nthree\n", "add a")
155+ id := r.commit(author, "b.txt", "bee\n", "add b")
156+ r.push("main")
157+ return id
158+}
159+
160+// feature creates the branch feat from main with two commits by two authors
161+// and pushes it; it returns its head.
162+func (r *mrRig) feature(branch string) domain.ObjectID {
163+ r.t.Helper()
164+ r.sh("checkout", "-q", "-B", branch, "main")
165+ r.commit(author, "a.txt", "one\n2\nthree\n", "change a")
166+ bob := domain.Identity{Name: "Bob", Email: "bob@example.org"}
167+ id := r.commit(bob, "c.txt", "see\n", "add c")
168+ r.push(branch)
169+ return id
170+}
171+
172+func (r *mrRig) open(branch string) domain.MergeRequest {
173+ r.t.Helper()
174+ mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add " + branch, Source: branch})
175+ require.NoError(r.t, err)
176+ return mr
177+}
178+
179+// drain returns the events published so far.
180+func (r *mrRig) drain() []domain.Event {
181+ var out []domain.Event
182+ for {
183+ select {
184+ case ev := <-r.events:
185+ out = append(out, ev)
186+ case <-time.After(20 * time.Millisecond):
187+ return out
188+ }
189+ }
190+}
191+
192+func writer(scopes ...domain.Scope) domain.Principal {
193+ return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes})
194+}
195+
196+var (
197+ reader = writer(domain.ScopeRepoRead)
198+ mrW = writer(domain.ScopeRepoRead, domain.ScopeMRWrite)
199+ mrM = writer(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge)
200+)
201+
202+func fieldsOf(t *testing.T, err error) map[string]string {
203+ t.Helper()
204+ var fe *domain.InvalidFieldsError
205+ require.ErrorAs(t, err, &fe, "want a rejection naming the fields")
206+ out := map[string]string{}
207+ for _, f := range fe.Fields {
208+ out[f.Field] = f.Reason
209+ }
210+ return out
211+}
212+
213+// AC3: the input rules of ADR 0017.
214+func TestCreateMergeRequestInputRules(t *testing.T) {
215+ r := newMRRig(t)
216+ r.seed()
217+ r.feature("feat")
218+ r.sh("checkout", "-q", "-B", "other", "main")
219+ r.commit(author, "o.txt", "o\n", "other")
220+ r.push("other")
221+
222+ tests := []struct {
223+ name string
224+ params app.CreateMergeRequestParams
225+ field string // "" for valid
226+ }{
227+ {"valid, default target", app.CreateMergeRequestParams{Title: "t", Source: "feat"}, ""},
228+ {"valid, explicit target and description", app.CreateMergeRequestParams{Title: "t", Description: "a\nb", Source: "other", Target: "main"}, ""},
229+ {"empty title", app.CreateMergeRequestParams{Title: "", Source: "feat", Target: "other"}, "title"},
230+ {"two-line title", app.CreateMergeRequestParams{Title: "a\nb", Source: "feat", Target: "other"}, "title"},
231+ {"title too long", app.CreateMergeRequestParams{Title: strings.Repeat("x", 121), Source: "feat", Target: "other"}, "title"},
232+ {"title with a bidi override", app.CreateMergeRequestParams{Title: "a\u202eb", Source: "feat", Target: "other"}, "title"},
233+ {"title with an escape", app.CreateMergeRequestParams{Title: "\x1b[31m", Source: "feat", Target: "other"}, "title"},
234+ {"description with a control character", app.CreateMergeRequestParams{Title: "t", Description: "a\x07", Source: "feat", Target: "other"}, "description"},
235+ {"description with a bidi isolate", app.CreateMergeRequestParams{Title: "t", Description: "a\u2066b", Source: "feat", Target: "other"}, "description"},
236+ {"description too long", app.CreateMergeRequestParams{Title: "t", Description: strings.Repeat("x", 8001), Source: "feat", Target: "other"}, "description"},
237+ {"description not utf-8", app.CreateMergeRequestParams{Title: "t", Description: "\xff", Source: "feat", Target: "other"}, "description"},
238+ {"source missing", app.CreateMergeRequestParams{Title: "t", Source: ""}, "source_branch"},
239+ {"source does not exist", app.CreateMergeRequestParams{Title: "t", Source: "nope", Target: "main"}, "source_branch"},
240+ {"source is not a branch name", app.CreateMergeRequestParams{Title: "t", Source: "a..b", Target: "main"}, "source_branch"},
241+ {"source is a full ref", app.CreateMergeRequestParams{Title: "t", Source: "refs/heads/feat", Target: "main"}, "source_branch"},
242+ {"target does not exist", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "nope"}, "target_branch"},
243+ {"same branch", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}, "target_branch"},
244+ }
245+ for _, tc := range tests {
246+ t.Run(tc.name, func(t *testing.T) {
247+ mr, err := r.mrs.Create(r.ctx, root, r.repo, tc.params)
248+ if tc.field == "" {
249+ require.NoError(t, err)
250+ assert.Equal(t, domain.MRStateOpen, mr.State)
251+ assert.Equal(t, "root", mr.Author)
252+ assert.Equal(t, "main", mr.Target)
253+ assert.NotZero(t, mr.HeadID)
254+ return
255+ }
256+ require.ErrorIs(t, err, domain.ErrInvalid)
257+ assert.Contains(t, fieldsOf(t, err), tc.field)
258+ })
259+ }
260+
261+ t.Run("a request is rejected whole with every field named", func(t *testing.T) {
262+ _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "a\nb", Description: "\x07", Source: "nope", Target: "feat"})
263+ got := fieldsOf(t, err)
264+ assert.Contains(t, got, "title")
265+ assert.Contains(t, got, "description")
266+ assert.Contains(t, got, "source_branch")
267+ })
268+}
269+
270+func TestCreateMergeRequestNumbersAndDuplicates(t *testing.T) {
271+ r := newMRRig(t)
272+ r.seed()
273+ r.feature("one")
274+ r.feature("two")
275+
276+ first := r.open("one")
277+ assert.Equal(t, int64(1), first.Number)
278+ second := r.open("two")
279+ assert.Equal(t, int64(2), second.Number)
280+
281+ _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one", Target: "main"})
282+ require.ErrorIs(t, err, domain.ErrAlreadyExists, "same open source and target")
283+ _, err = r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one"})
284+ require.ErrorIs(t, err, domain.ErrAlreadyExists, "the default target is the same target")
285+
286+ // Another target is another request.
287+ r.sh("checkout", "-q", "-B", "stage", "main")
288+ r.push("stage")
289+ third, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "to stage", Source: "one", Target: "stage"})
290+ require.NoError(t, err)
291+ assert.Equal(t, int64(3), third.Number)
292+
293+ // Once the first is closed, the pair is free again, with a new number.
294+ _, err = r.mrs.Close(r.ctx, root, r.repo, first.Number)
295+ require.NoError(t, err)
296+ fourth, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "retry", Source: "one"})
297+ require.NoError(t, err)
298+ assert.Equal(t, int64(4), fourth.Number, "numbers are never reused")
299+}
300+
301+func TestMergeRequestAccess(t *testing.T) {
302+ r := newMRRig(t) // proj is private
303+ r.seed()
304+ r.feature("feat")
305+ mr := r.open("feat")
306+ n := mr.Number
307+ noScope := writer(domain.ScopeRepoWrite)
308+ otherRepo := domain.TokenPrincipal(domain.Token{ID: "o", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge}, Repos: []domain.RepoName{name(t, "else")}})
309+
310+ reads := map[string]func(p domain.Principal) error{
311+ "List": func(p domain.Principal) error {
312+ _, err := r.mrs.List(r.ctx, p, r.repo, app.ListMergeRequestsParams{})
313+ return err
314+ },
315+ "Get": func(p domain.Principal) error {
316+ _, err := r.mrs.Get(r.ctx, p, r.repo, n, false)
317+ return err
318+ },
319+ }
320+ for what, call := range reads {
321+ for who, p := range map[string]domain.Principal{"anonymous": domain.Anonymous(), "no repo:read": noScope, "token of another repo": otherRepo} {
322+ assert.ErrorIs(t, call(p), domain.ErrNotFound, "%s by %s on a private repository", what, who)
323+ }
324+ assert.NoError(t, call(reader), what+" with repo:read")
325+ assert.NoError(t, call(root), what+" by root")
326+ }
327+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, false)
328+ assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number")
329+ _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), app.ListMergeRequestsParams{})
330+ assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository")
331+
332+ writes := map[string]func(p domain.Principal) error{
333+ "Create": func(p domain.Principal) error {
334+ _, err := r.mrs.Create(r.ctx, p, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"})
335+ return err
336+ },
337+ "AddComment": func(p domain.Principal) error {
338+ _, err := r.mrs.AddComment(r.ctx, p, r.repo, n, app.AddCommentParams{Text: "x"})
339+ return err
340+ },
341+ "ResolveComment": func(p domain.Principal) error {
342+ _, err := r.mrs.ResolveComment(r.ctx, p, r.repo, n, 1, true)
343+ return err
344+ },
345+ "Close": func(p domain.Principal) error {
346+ _, err := r.mrs.Close(r.ctx, p, r.repo, n+100)
347+ return err
348+ },
349+ "MarkRead": func(p domain.Principal) error {
350+ _, err := r.mrs.MarkRead(r.ctx, p, r.repo, n, domain.ObjectID{})
351+ return err
352+ },
353+ "Merge": func(p domain.Principal) error {
354+ _, err := r.mrs.Merge(r.ctx, p, r.repo, n+100, app.MergeParams{HeadID: mr.HeadID})
355+ return err
356+ },
357+ }
358+ for what, call := range writes {
359+ scope := domain.ScopeMRWrite
360+ if what == "Merge" {
361+ scope = domain.ScopeMRMerge
362+ }
363+ assert.ErrorIs(t, call(domain.Anonymous()), domain.ErrNotFound, "%s: anonymous cannot see the private repository", what)
364+ assert.ErrorIs(t, call(noScope), domain.ErrNotFound, "%s: no repo:read", what)
365+ assert.ErrorIs(t, call(otherRepo), domain.ErrNotFound, "%s: another repository's token", what)
366+ assert.ErrorIs(t, call(reader), domain.ErrForbidden, "%s: reading is not writing", what)
367+ if scope == domain.ScopeMRMerge {
368+ assert.ErrorIs(t, call(mrW), domain.ErrForbidden, "mr:write does not merge")
369+ continue
370+ }
371+ // With the scope the call gets past access (and fails on its subject).
372+ assert.NotErrorIs(t, call(mrW), domain.ErrForbidden, what)
373+ assert.NotErrorIs(t, call(mrW), domain.ErrUnauthenticated, what)
374+ }
375+
376+ // On a public repository an anonymous caller reads, but writing is
377+ // unauthenticated rather than not found.
378+ pubName := name(t, "pub")
379+ _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: pubName, Visibility: domain.VisibilityPublic})
380+ require.NoError(t, err)
381+ _, err = r.mrs.List(r.ctx, domain.Anonymous(), pubName, app.ListMergeRequestsParams{})
382+ assert.NoError(t, err)
383+ _, err = r.mrs.Create(r.ctx, domain.Anonymous(), pubName, app.CreateMergeRequestParams{Title: "t", Source: "a", Target: "b"})
384+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
385+}
386+
387+func TestListMergeRequests(t *testing.T) {
388+ r := newMRRig(t)
389+ r.seed()
390+ var nums []int64
391+ for i := range 5 {
392+ b := fmt.Sprintf("f%d", i)
393+ r.feature(b)
394+ nums = append(nums, r.open(b).Number)
395+ }
396+ _, err := r.mrs.Close(r.ctx, root, r.repo, 2)
397+ require.NoError(t, err)
398+ r.sh("checkout", "-q", "main")
399+ merged := r.feature("f0")
400+ _, err = r.mrs.Merge(r.ctx, root, r.repo, 1, app.MergeParams{HeadID: merged})
401+ require.NoError(t, err)
402+ r.sh("branch", "-D", "f4")
403+ r.run(r.bare(), r.env(author), "branch", "-D", "f4")
404+
405+ numbers := func(p app.MergeRequestPage) []int64 {
406+ var out []int64
407+ for _, m := range p.MergeRequests {
408+ out = append(out, m.Number)
409+ }
410+ return out
411+ }
412+ all, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{})
413+ require.NoError(t, err)
414+ assert.Equal(t, []int64{5, 4, 3, 2, 1}, numbers(all), "highest number first")
415+ assert.Empty(t, all.NextPageToken)
416+
417+ open, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateOpen})
418+ require.NoError(t, err)
419+ assert.Equal(t, []int64{5, 4, 3}, numbers(open))
420+ for _, m := range open.MergeRequests {
421+ if m.Number == 5 {
422+ assert.True(t, m.HeadID.IsZero(), "the branch of !5 was deleted")
423+ } else {
424+ assert.False(t, m.HeadID.IsZero(), "an open request carries its head")
425+ }
426+ }
427+ closed, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateClosed})
428+ require.NoError(t, err)
429+ assert.Equal(t, []int64{2}, numbers(closed))
430+ mergedPage, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateMerged})
431+ require.NoError(t, err)
432+ assert.Equal(t, []int64{1}, numbers(mergedPage))
433+ assert.Equal(t, merged, mergedPage.MergeRequests[0].HeadID, "a merged request keeps the head it had")
434+
435+ // Paging: the token names the last number.
436+ p1, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2})
437+ require.NoError(t, err)
438+ assert.Equal(t, []int64{5, 4}, numbers(p1))
439+ require.NotEmpty(t, p1.NextPageToken)
440+ // A request created between the pages does not shift them.
441+ r.feature("late")
442+ r.open("late")
443+ p2, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p1.NextPageToken})
444+ require.NoError(t, err)
445+ assert.Equal(t, []int64{3, 2}, numbers(p2))
446+ p3, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p2.NextPageToken})
447+ require.NoError(t, err)
448+ assert.Equal(t, []int64{1}, numbers(p3))
449+ assert.Empty(t, p3.NextPageToken)
450+
451+ for _, tok := range []string{"garbage", "bW9yZQ"} {
452+ _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageToken: tok})
453+ assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
454+ }
455+ _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: -1})
456+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
457+ _ = nums
458+}
459+
460+func TestGetMergeRequest(t *testing.T) {
461+ r := newMRRig(t)
462+ main := r.seed()
463+ head := r.feature("feat")
464+ mr := r.open("feat")
465+ _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "first"})
466+ require.NoError(t, err)
467+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "second"})
468+ require.NoError(t, err)
469+
470+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
471+ require.NoError(t, err)
472+ assert.Equal(t, head, d.MergeRequest.HeadID)
473+ assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head")
474+ require.Len(t, d.Commits, 2)
475+ assert.Equal(t, "add c", d.Commits[0].Summary, "newest first")
476+ assert.False(t, d.CommitsTruncated)
477+ assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, d.Stats)
478+ require.Len(t, d.Diff.Files, 2)
479+ require.Len(t, d.Comments, 2)
480+ assert.Equal(t, "first", d.Comments[0].Text.String(), "oldest first")
481+ assert.True(t, d.LastReadID.IsZero())
482+ assert.False(t, d.LastReadLost)
483+
484+ // The target moved on: the base stays the merge base, not the new tip.
485+ r.sh("checkout", "-q", "main")
486+ r.commit(author, "later.txt", "x\n", "later on main")
487+ r.push("main")
488+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
489+ require.NoError(t, err)
490+ assert.Equal(t, main, d.DiffBaseID)
491+ assert.Len(t, d.Commits, 2, "the commits that main lacks")
492+
493+ // A deleted source branch: no commits, no diff, an empty head.
494+ r.run(r.bare(), r.env(author), "branch", "-D", "feat")
495+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
496+ require.NoError(t, err)
497+ assert.True(t, d.MergeRequest.HeadID.IsZero())
498+ assert.Empty(t, d.Commits)
499+ assert.True(t, d.DiffBaseID.IsZero())
500+ assert.Empty(t, d.Diff.Files)
501+ assert.Len(t, d.Comments, 2)
502+}
503+
504+func TestGetMergeRequestTruncatesCommits(t *testing.T) {
505+ r := newMRRig(t)
506+ r.seed()
507+ r.sh("checkout", "-q", "-B", "long", "main")
508+ for i := range app.MaxMergeRequestCommits + 5 {
509+ r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i))
510+ }
511+ r.push("long")
512+ mr := r.open("long")
513+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
514+ require.NoError(t, err)
515+ assert.Len(t, d.Commits, app.MaxMergeRequestCommits)
516+ assert.True(t, d.CommitsTruncated)
517+ assert.Equal(t, "c104", d.Commits[0].Summary)
518+}
519+
520+// AC3: a comment on a path:line must be a line of the current diff.
521+func TestAddComment(t *testing.T) {
522+ r := newMRRig(t)
523+ r.seed()
524+ r.feature("feat")
525+ mr := r.open("feat")
526+ n := mr.Number
527+ // The diff: a.txt line 2 changed (old "two", new "2"); c.txt added, line 1.
528+
529+ tests := []struct {
530+ name string
531+ params app.AddCommentParams
532+ field string // "" for valid
533+ }{
534+ {"general", app.AddCommentParams{Text: "looks good"}, ""},
535+ {"multi-line general", app.AddCommentParams{Text: "a\n\nb"}, ""},
536+ {"new side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideNew, Text: "x"}, ""},
537+ {"old side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideOld, Text: "x"}, ""},
538+ {"context line", app.AddCommentParams{Path: "a.txt", Line: 3, Side: domain.SideNew, Text: "x"}, ""},
539+ {"added file", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideNew, Text: "x"}, ""},
540+ {"empty text", app.AddCommentParams{Text: ""}, "text"},
541+ {"blank text", app.AddCommentParams{Text: " \n "}, "text"},
542+ {"text too long", app.AddCommentParams{Text: strings.Repeat("x", 8001)}, "text"},
543+ {"text with a control character", app.AddCommentParams{Text: "a\x1b[0m"}, "text"},
544+ {"text with a tab", app.AddCommentParams{Text: "a\tb"}, "text"},
545+ {"text with a bidi override", app.AddCommentParams{Text: "a\u202eb"}, "text"},
546+ {"line outside the diff", app.AddCommentParams{Path: "a.txt", Line: 40, Side: domain.SideNew, Text: "x"}, "path"},
547+ {"unchanged file", app.AddCommentParams{Path: "b.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"},
548+ {"unknown path", app.AddCommentParams{Path: "nope.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"},
549+ {"deleted line on the wrong side", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideOld, Text: "x"}, "path"},
550+ {"path without a line", app.AddCommentParams{Path: "a.txt", Side: domain.SideNew, Text: "x"}, "line"},
551+ {"path without a side", app.AddCommentParams{Path: "a.txt", Line: 2, Text: "x"}, "side"},
552+ {"line without a path", app.AddCommentParams{Line: 2, Side: domain.SideNew, Text: "x"}, "path"},
553+ {"side without a path", app.AddCommentParams{Side: domain.SideOld, Text: "x"}, "path"},
554+ {"negative line", app.AddCommentParams{Path: "a.txt", Line: -2, Side: domain.SideNew, Text: "x"}, "line"},
555+ }
556+ for _, tc := range tests {
557+ t.Run(tc.name, func(t *testing.T) {
558+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, tc.params)
559+ if tc.field == "" {
560+ require.NoError(t, err)
561+ assert.Equal(t, "agent-1", c.Author)
562+ assert.Equal(t, tc.params.Path, c.Path)
563+ assert.Equal(t, tc.params.Line, c.Line)
564+ assert.Equal(t, tc.params.Side, c.Side)
565+ assert.Equal(t, mr.HeadID, c.CommitID, "the head it was written against")
566+ assert.False(t, c.Resolved)
567+ return
568+ }
569+ require.ErrorIs(t, err, domain.ErrInvalid)
570+ assert.Contains(t, fieldsOf(t, err), tc.field)
571+ })
572+ }
573+
574+ // Ids count 1, 2, ... in order of creation and only the valid ones are kept.
575+ d, err := r.mrs.Get(r.ctx, root, r.repo, n, false)
576+ require.NoError(t, err)
577+ var kept int
578+ for _, tc := range tests {
579+ if tc.field == "" {
580+ kept++
581+ }
582+ }
583+ require.Len(t, d.Comments, kept)
584+ for i, c := range d.Comments {
585+ assert.Equal(t, int64(i+1), c.ID)
586+ }
587+
588+ t.Run("not open", func(t *testing.T) {
589+ _, err := r.mrs.Close(r.ctx, mrW, r.repo, n)
590+ require.NoError(t, err)
591+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, app.AddCommentParams{Text: "late"})
592+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
593+ _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, n, 1, true)
594+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
595+ })
596+}
597+
598+func TestConcurrentComments(t *testing.T) {
599+ r := newMRRig(t)
600+ r.seed()
601+ r.feature("feat")
602+ mr := r.open("feat")
603+ const workers = 10
604+ var wg sync.WaitGroup
605+ ids := make(chan int64, workers)
606+ for i := range workers {
607+ wg.Go(func() {
608+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: fmt.Sprintf("c%d", i)})
609+ if assert.NoError(t, err) {
610+ ids <- c.ID
611+ }
612+ })
613+ }
614+ wg.Wait()
615+ close(ids)
616+ seen := map[int64]bool{}
617+ for id := range ids {
618+ assert.False(t, seen[id], "id %d handed out twice", id)
619+ seen[id] = true
620+ }
621+ assert.Len(t, seen, workers)
622+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
623+ require.NoError(t, err)
624+ assert.Len(t, d.Comments, workers)
625+}
626+
627+func TestResolveComment(t *testing.T) {
628+ r := newMRRig(t)
629+ r.seed()
630+ r.feature("feat")
631+ mr := r.open("feat")
632+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "fix this"})
633+ require.NoError(t, err)
634+ r.drain()
635+
636+ got, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true)
637+ require.NoError(t, err)
638+ assert.True(t, got.Resolved)
639+ again, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true)
640+ require.NoError(t, err, "resolving twice is not an error")
641+ assert.True(t, again.Resolved)
642+ undone, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false)
643+ require.NoError(t, err)
644+ assert.False(t, undone.Resolved)
645+ undone, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false)
646+ require.NoError(t, err)
647+ assert.False(t, undone.Resolved)
648+
649+ _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 99, true)
650+ assert.ErrorIs(t, err, domain.ErrNotFound)
651+ _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true)
652+ assert.ErrorIs(t, err, domain.ErrNotFound)
653+
654+ assert.Empty(t, r.drain(), "a resolved comment has no event")
655+}
656+
657+func TestEventsOfAMergeRequest(t *testing.T) {
658+ r := newMRRig(t)
659+ r.seed()
660+ head := r.feature("feat")
661+ r.drain()
662+
663+ mr := r.open("feat")
664+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "note"})
665+ require.NoError(t, err)
666+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
667+ require.NoError(t, err)
668+
669+ r.feature("other")
670+ other := r.open("other")
671+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number)
672+ require.NoError(t, err)
673+
674+ evs := r.drain()
675+ var kinds []string
676+ for _, ev := range evs {
677+ kinds = append(kinds, ev.EventKind())
678+ }
679+ assert.Equal(t, []string{"mr.opened", "mr.comment_added", "mr.merged", "repo.pushed", "mr.opened", "mr.closed"}, kinds)
680+
681+ opened := evs[0].(domain.MergeRequestOpened)
682+ assert.Equal(t, mr.Number, opened.MergeRequest.Number)
683+ assert.Equal(t, domain.VisibilityPrivate, opened.Visibility, "the event carries the visibility, so that it is filtered")
684+ assert.False(t, opened.CreatedAt.IsZero())
685+ added := evs[1].(domain.CommentAdded)
686+ assert.Equal(t, c, added.Comment)
687+ assert.Equal(t, mr.Number, added.Number)
688+ merged := evs[2].(domain.MergeRequestMerged)
689+ assert.Equal(t, domain.MRStateMerged, merged.MergeRequest.State)
690+ push := evs[3].(domain.PushEvent)
691+ assert.Equal(t, []domain.RefUpdate{{Old: r.seedID(), New: head.String(), Ref: "refs/heads/main"}}, push.Updates)
692+ assert.Equal(t, domain.VisibilityPrivate, push.Visibility)
693+ assert.Equal(t, domain.MRStateClosed, evs[5].(domain.MergeRequestClosed).MergeRequest.State)
694+
695+ // Failed calls publish nothing.
696+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number)
697+ require.Error(t, err)
698+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, app.AddCommentParams{Text: "x"})
699+ require.Error(t, err)
700+ assert.Empty(t, r.drain())
701+}
702+
703+// seedID is the id main had before the merges of the test: the parent of its
704+// first feature commit.
705+func (r *mrRig) seedID() string {
706+ return r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/feat~2")
707+}
708+
709+func TestMarkReadAndSinceLastRead(t *testing.T) {
710+ r := newMRRig(t)
711+ base := r.seed()
712+ head1 := r.feature("feat")
713+ mr := r.open("feat")
714+ n := mr.Number
715+
716+ // Nothing recorded: the flag changes nothing.
717+ d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, true)
718+ require.NoError(t, err)
719+ assert.True(t, d.LastReadID.IsZero())
720+ assert.False(t, d.LastReadLost)
721+ assert.Equal(t, base, d.DiffBaseID)
722+
723+ // Without a head: the current head.
724+ got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{})
725+ require.NoError(t, err)
726+ assert.Equal(t, head1, got)
727+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, false)
728+ require.NoError(t, err)
729+ assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set")
730+ assert.Equal(t, base, d.DiffBaseID)
731+
732+ // New work on the branch: the diff since the last read has only that.
733+ r.commit(author, "d.txt", "dee\n", "add d")
734+ head2 := r.head()
735+ r.push("feat")
736+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true)
737+ require.NoError(t, err)
738+ assert.Equal(t, head1, d.DiffBaseID)
739+ assert.Equal(t, head2, d.MergeRequest.HeadID)
740+ require.Len(t, d.Diff.Files, 1)
741+ assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath)
742+ assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, d.Stats)
743+ assert.Len(t, d.Commits, 3, "the commits are still those of the target..head range")
744+
745+ // Marking a commit that is not in the repository is refused.
746+ _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, oidFrom(t, strings.Repeat("1", 40)))
747+ require.ErrorIs(t, err, domain.ErrInvalid)
748+ assert.Contains(t, fieldsOf(t, err), "head_id")
749+ // Neither is a tree.
750+ tree := oidFrom(t, r.run(r.bare(), r.env(author), "rev-parse", head1.String()+"^{tree}"))
751+ _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, tree)
752+ require.ErrorIs(t, err, domain.ErrInvalid)
753+ // An explicit head is recorded as given.
754+ _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base)
755+ require.NoError(t, err)
756+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true)
757+ require.NoError(t, err)
758+ assert.Equal(t, base, d.DiffBaseID)
759+}
760+
761+// AC6: the diff since the last read is right after a force-push; when the
762+// recorded commit is gone the whole change is shown and the response says so.
763+func TestSinceLastReadAfterForcePush(t *testing.T) {
764+ r := newMRRig(t)
765+ base := r.seed()
766+ head1 := r.feature("feat")
767+ mr := r.open("feat")
768+ _, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, mr.Number, domain.ObjectID{})
769+ require.NoError(t, err)
770+
771+ // Force-push: the last commit is amended (the old one is unreachable but
772+ // still stored), and one more file is added.
773+ r.sh("checkout", "-q", "feat")
774+ require.NoError(t, os.WriteFile(filepath.Join(r.work, "c.txt"), []byte("see, amended\n"), 0o644))
775+ r.sh("add", "c.txt")
776+ r.sh("commit", "-q", "--amend", "-m", "add c, amended")
777+ head2 := r.head()
778+ require.NotEqual(t, head1, head2)
779+ r.push("feat")
780+ assert.Equal(t, head2, r.branchHead("feat"))
781+ // head1 is not an ancestor of head2 any more.
782+ r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String())
783+
784+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true)
785+ require.NoError(t, err)
786+ assert.False(t, d.LastReadLost)
787+ assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read")
788+ require.Len(t, d.Diff.Files, 1, "only what the force-push changed")
789+ assert.Equal(t, "c.txt", d.Diff.Files[0].NewPath)
790+ var added, removed []string
791+ for _, l := range d.Diff.Files[0].Hunks[0].Lines {
792+ switch l.Kind {
793+ case domain.LineAddition:
794+ added = append(added, l.Text)
795+ case domain.LineDeletion:
796+ removed = append(removed, l.Text)
797+ }
798+ }
799+ assert.Equal(t, []string{"see, amended"}, added)
800+ assert.Equal(t, []string{"see"}, removed)
801+
802+ // The whole change is still available without the flag.
803+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
804+ require.NoError(t, err)
805+ assert.Equal(t, base, d.DiffBaseID)
806+ assert.Len(t, d.Diff.Files, 2)
807+
808+ // Once the recorded commit is pruned, the response says so.
809+ r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
810+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true)
811+ require.NoError(t, err)
812+ assert.True(t, d.LastReadLost, "the recorded head is gone")
813+ assert.Equal(t, head1, d.LastReadID, "still reported")
814+ assert.Equal(t, base, d.DiffBaseID, "the diff is against the target")
815+ assert.Len(t, d.Diff.Files, 2)
816+}
817+
818+func TestMergeFastForward(t *testing.T) {
819+ r := newMRRig(t)
820+ main := r.seed()
821+ head := r.feature("feat")
822+ mr := r.open("feat")
823+ r.drain()
824+
825+ merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
826+ require.NoError(t, err)
827+ assert.Equal(t, domain.MRStateMerged, merged.State)
828+ assert.Equal(t, domain.MergeModeFastForward, merged.MergeMode, "the default mode")
829+ assert.Equal(t, head, merged.MergedCommitID, "no commit is created")
830+ assert.Equal(t, head, merged.HeadID)
831+ assert.Equal(t, head, r.branchHead("main"))
832+ assert.Equal(t, "root", merged.Author)
833+ _ = main
834+
835+ // The stored request is the merged one, and reads keep a diff.
836+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
837+ require.NoError(t, err)
838+ assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
839+ assert.Equal(t, head, d.MergeRequest.HeadID)
840+ assert.Equal(t, head, d.MergeRequest.MergedCommitID)
841+ assert.Equal(t, main, d.DiffBaseID, "the base of the merge stays readable")
842+ assert.Len(t, d.Commits, 2, "the commits that were merged")
843+ assert.Len(t, d.Diff.Files, 2)
844+
845+ // Repeating it, or touching a final request, is a failed precondition.
846+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
847+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
848+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
849+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
850+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "x"})
851+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
852+}
853+
854+// AC4: the checks of a merge.
855+func TestMergeRefusals(t *testing.T) {
856+ t.Run("a branch that moved since the review", func(t *testing.T) {
857+ r := newMRRig(t)
858+ r.seed()
859+ head := r.feature("feat")
860+ mr := r.open("feat")
861+ r.commit(author, "more.txt", "m\n", "pushed after the review")
862+ r.push("feat")
863+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
864+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
865+ assert.Contains(t, err.Error(), "branch moved, read again")
866+ assert.NotEqual(t, head, r.branchHead("main"), "main did not move")
867+ // Reading again gives the new head, which merges.
868+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false)
869+ require.NoError(t, err)
870+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: d.MergeRequest.HeadID})
871+ require.NoError(t, err)
872+ })
873+
874+ t.Run("a target that is not an ancestor of the head", func(t *testing.T) {
875+ r := newMRRig(t)
876+ r.seed()
877+ head := r.feature("feat")
878+ mr := r.open("feat")
879+ r.sh("checkout", "-q", "main")
880+ r.commit(author, "news.txt", "n\n", "main moved")
881+ r.push("main")
882+ tip := r.branchHead("main")
883+
884+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
885+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: mode})
886+ require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)
887+ assert.Contains(t, err.Error(), "rebase", mode)
888+ }
889+ assert.Equal(t, tip, r.branchHead("main"))
890+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false)
891+ require.NoError(t, err)
892+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
893+
894+ // After a rebase and a push it merges.
895+ r.sh("checkout", "-q", "feat")
896+ r.sh("rebase", "-q", "main")
897+ r.push("feat")
898+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.head()})
899+ require.NoError(t, err)
900+ })
901+
902+ t.Run("a mode the repository does not allow", func(t *testing.T) {
903+ r := newMRRig(t)
904+ r.seed()
905+ head := r.feature("feat")
906+ mr := r.open("feat")
907+ modes := []domain.MergeMode{domain.MergeModeFastForward}
908+ _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes})
909+ require.NoError(t, err)
910+
911+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
912+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
913+ assert.Contains(t, err.Error(), "squash")
914+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
915+ require.NoError(t, err, "fast-forward is allowed")
916+ })
917+
918+ t.Run("only squash is allowed, so the default mode is refused", func(t *testing.T) {
919+ r := newMRRig(t)
920+ r.seed()
921+ head := r.feature("feat")
922+ mr := r.open("feat")
923+ modes := []domain.MergeMode{domain.MergeModeSquash}
924+ _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes})
925+ require.NoError(t, err)
926+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
927+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
928+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
929+ require.NoError(t, err)
930+ })
931+
932+ t.Run("bad arguments", func(t *testing.T) {
933+ r := newMRRig(t)
934+ r.seed()
935+ head := r.feature("feat")
936+ mr := r.open("feat")
937+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{})
938+ require.ErrorIs(t, err, domain.ErrInvalid)
939+ assert.Contains(t, fieldsOf(t, err), "head_id")
940+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: "rebase"})
941+ require.ErrorIs(t, err, domain.ErrInvalid)
942+ assert.Contains(t, fieldsOf(t, err), "mode")
943+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, app.MergeParams{HeadID: head})
944+ assert.ErrorIs(t, err, domain.ErrNotFound)
945+ })
946+
947+ t.Run("a deleted source or target, or nothing to merge", func(t *testing.T) {
948+ r := newMRRig(t)
949+ r.seed()
950+ head := r.feature("feat")
951+ mr := r.open("feat")
952+ r.run(r.bare(), r.env(author), "branch", "-D", "feat")
953+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
954+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
955+ assert.Contains(t, err.Error(), "no longer exists")
956+
957+ r.push("feat")
958+ r.run(r.bare(), r.env(author), "update-ref", "refs/heads/main", head.String())
959+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
960+ require.ErrorIs(t, err, domain.ErrPreconditionFailed, "the target already is at the head")
961+ assert.Contains(t, err.Error(), "nothing to merge")
962+ })
963+}
964+
965+// AC4: merges of one repository run one at a time.
966+func TestConcurrentMergesSerialize(t *testing.T) {
967+ r := newMRRig(t)
968+ r.seed()
969+ const n = 6
970+ heads := map[int64]domain.ObjectID{}
971+ for i := range n {
972+ b := fmt.Sprintf("f%d", i)
973+ heads[r.openFeature(b).Number] = r.branchHead(b)
974+ }
975+
976+ type outcome struct {
977+ number int64
978+ err error
979+ }
980+ results := make(chan outcome, n*2)
981+ var wg sync.WaitGroup
982+ for number, head := range heads {
983+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
984+ if mode == domain.MergeModeSquash && number%2 == 0 {
985+ continue
986+ }
987+ wg.Go(func() {
988+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, app.MergeParams{HeadID: head, Mode: mode})
989+ results <- outcome{number, err}
990+ })
991+ }
992+ }
993+ wg.Wait()
994+ close(results)
995+
996+ won := map[int64]int{}
997+ for o := range results {
998+ if o.err == nil {
999+ won[o.number]++
1000+ continue
1001+ }
1002+ require.ErrorIs(t, o.err, domain.ErrPreconditionFailed, "a loser fails cleanly: %v", o.err)
1003+ }
1004+ // All the branches start from the same main: exactly one request can win,
1005+ // once, whatever the mode, and main is where it left it.
1006+ require.Len(t, won, 1, "exactly one merge request was merged: %v", won)
1007+ for number, times := range won {
1008+ assert.Equal(t, 1, times, "!%d merged twice", number)
1009+ mr, err := r.mrs.Get(r.ctx, root, r.repo, number, false)
1010+ require.NoError(t, err)
1011+ assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State)
1012+ assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main"))
1013+ }
1014+ for number := range heads {
1015+ if won[number] == 0 {
1016+ d, err := r.mrs.Get(r.ctx, root, r.repo, number, false)
1017+ require.NoError(t, err)
1018+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number)
1019+ }
1020+ }
1021+}
1022+
1023+// openFeature pushes a branch from main with one commit and opens a request.
1024+func (r *mrRig) openFeature(branch string) domain.MergeRequest {
1025+ r.t.Helper()
1026+ r.sh("checkout", "-q", "-B", branch, "main")
1027+ r.commit(author, branch+".txt", branch+"\n", "add "+branch)
1028+ r.push(branch)
1029+ return r.open(branch)
1030+}
1031+
1032+func TestSquashMerge(t *testing.T) {
1033+ r := newMRRig(t)
1034+ main := r.seed()
1035+ // A branch of three commits by two authors; Bob wrote the first one.
1036+ bob := domain.Identity{Name: "Bob", Email: "bob@example.org"}
1037+ cy := domain.Identity{Name: "Cy", Email: "cy@example.org"}
1038+ r.sh("checkout", "-q", "-B", "feat", "main")
1039+ r.commit(bob, "x.txt", "x\n", "bob starts")
1040+ r.commit(author, "y.txt", "y\n", "ada continues")
1041+ r.commit(cy, "z.txt", "z\n", "cy finishes")
1042+ r.commit(bob, "w.txt", "w\n", "bob again")
1043+ r.push("feat")
1044+ head := r.branchHead("feat")
1045+ mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add things", Source: "feat"})
1046+ require.NoError(t, err)
1047+ r.drain()
1048+
1049+ merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
1050+ require.NoError(t, err)
1051+ assert.Equal(t, domain.MergeModeSquash, merged.MergeMode)
1052+ require.False(t, merged.MergedCommitID.IsZero())
1053+ assert.NotEqual(t, head, merged.MergedCommitID, "a squash creates a commit")
1054+ assert.Equal(t, merged.MergedCommitID, r.branchHead("main"))
1055+
1056+ sq := merged.MergedCommitID.String()
1057+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
1058+ assert.Equal(t, main.String(), g("rev-parse", sq+"^"), "the target is the only parent")
1059+ assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", sq+"^{tree}"), "the tree of the head")
1060+ assert.Equal(t, "Bob <bob@example.org>", g("log", "-1", "--format=%an <%ae>", sq), "author of the first commit")
1061+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", sq))
1062+ assert.Equal(t, "Add things (!1)\n\n- bob starts\n- ada continues\n- cy finishes\n- bob again\n\n"+
1063+ "Co-authored-by: Ada Lovelace <ada@example.org>\nCo-authored-by: Cy <cy@example.org>",
1064+ g("log", "-1", "--format=%B", sq))
1065+ assert.Contains(t, g("cat-file", "commit", sq), "\ngpgsig -----BEGIN SSH SIGNATURE-----\n")
1066+
1067+ // Events: merged, then pushed for the target branch.
1068+ evs := r.drain()
1069+ require.Len(t, evs, 2)
1070+ assert.Equal(t, merged.MergedCommitID, evs[0].(domain.MergeRequestMerged).MergeRequest.MergedCommitID)
1071+ assert.Equal(t, []domain.RefUpdate{{Old: main.String(), New: sq, Ref: "refs/heads/main"}}, evs[1].(domain.PushEvent).Updates)
1072+
1073+ // The signature verifies with an allowed_signers file built from the
1074+ // public key the server hands out.
1075+ r.verifyCommit(sq)
1076+}
1077+
1078+// verifyCommit checks the signature of a commit of the repository with
1079+// `git verify-commit` and an allowed signers file made from the signing key
1080+// info, and that another key does not verify.
1081+func (r *mrRig) verifyCommit(id string) {
1082+ r.t.Helper()
1083+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
1084+ unusable(r.t, "ssh-keygen not found: install openssh to verify the signed commits (CI must have it)")
1085+ }
1086+ dir := r.t.TempDir()
1087+ signers := filepath.Join(dir, "allowed_signers")
1088+ require.NoError(r.t, os.WriteFile(signers, []byte(r.key.Info().AllowedSigners()+"\n"), 0o600))
1089+ out := r.run(r.bare(), r.env(author), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", "--raw", id)
1090+ assert.Contains(r.t, out, "Good \"git\" signature for ermite@git.example.org")
1091+
1092+ other, err := sshsig.New(bytes.Repeat([]byte{1}, ed25519.SeedSize))
1093+ require.NoError(r.t, err)
1094+ wrong := filepath.Join(dir, "wrong_signers")
1095+ require.NoError(r.t, os.WriteFile(wrong, []byte("ermite@git.example.org "+other.PublicKey().AuthorizedKey+"\n"), 0o600))
1096+ cmd := exec.Command("git", "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+wrong, "verify-commit", id)
1097+ cmd.Dir, cmd.Env = r.bare(), r.env(author)
1098+ assert.Error(r.t, cmd.Run(), "another key must not verify the commit")
1099+}
1100+
1101+func TestSquashSingleCommitHasNoTrailer(t *testing.T) {
1102+ r := newMRRig(t)
1103+ r.seed()
1104+ head := r.feature("solo") // two authors; make a one-author branch instead
1105+ _ = head
1106+ r.sh("checkout", "-q", "-B", "mine", "main")
1107+ r.commit(author, "m.txt", "m\n", "only")
1108+ r.push("mine")
1109+ mr := r.open("mine")
1110+ merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash})
1111+ require.NoError(t, err)
1112+ msg := r.run(r.bare(), r.env(author), "log", "-1", "--format=%B", merged.MergedCommitID.String())
1113+ assert.Equal(t, "Add mine (!1)\n\n- only", msg)
1114+ r.verifyCommit(merged.MergedCommitID.String())
1115+}
1116+
1117+func TestUpdateRepoSettings(t *testing.T) {
1118+ r := newMRRig(t)
1119+ repo, err := r.repos.Get(r.ctx, root, r.repo)
1120+ require.NoError(t, err)
1121+ assert.Equal(t, domain.DefaultRepoSettings(), repo.Settings, "stored explicitly at creation")
1122+
1123+ modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
1124+ off := false
1125+ got, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1126+ require.NoError(t, err)
1127+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)
1128+ again, err := r.repos.Get(r.ctx, root, r.repo)
1129+ require.NoError(t, err)
1130+ assert.Equal(t, got.Settings, again.Settings)
1131+
1132+ // Only the named setting changes.
1133+ on := true
1134+ got, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{FailingChecksBlock: &on})
1135+ require.NoError(t, err)
1136+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1137+ assert.True(t, got.Settings.FailingChecksBlock)
1138+
1139+ for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
1140+ bad := bad
1141+ _, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &bad})
1142+ assert.ErrorIs(t, err, domain.ErrInvalid, "%v", bad)
1143+ }
1144+ again, err = r.repos.Get(r.ctx, root, r.repo)
1145+ require.NoError(t, err)
1146+ assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
1147+
1148+ // repo:admin is needed, like for any other field.
1149+ _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, app.UpdateParams{FailingChecksBlock: &off})
1150+ assert.ErrorIs(t, err, domain.ErrForbidden)
1151+}
1152+
1153+func TestDeletingARepositoryDropsItsMergeRequests(t *testing.T) {
1154+ r := newMRRig(t)
1155+ r.seed()
1156+ r.feature("feat")
1157+ r.open("feat")
1158+ require.NoError(t, r.repos.Delete(r.ctx, root, r.repo))
1159+
1160+ _, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{})
1161+ assert.ErrorIs(t, err, domain.ErrNotFound)
1162+
1163+ // Created again under the same name, it starts empty.
1164+ _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: r.repo})
1165+ require.NoError(t, err)
1166+ page, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{})
1167+ require.NoError(t, err)
1168+ assert.Empty(t, page.MergeRequests)
1169+}
1170+
1171+func TestSigningKeyInfo(t *testing.T) {
1172+ r := newMRRig(t)
1173+ info := r.key.Info()
1174+ assert.True(t, strings.HasPrefix(info.Key.AuthorizedKey, "ssh-ed25519 "))
1175+ assert.True(t, strings.HasPrefix(info.Key.Fingerprint, "SHA256:"))
1176+ assert.Equal(t, "ermite@git.example.org "+info.Key.AuthorizedKey, info.AllowedSigners())
1177+ assert.Equal(t, domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, info.Committer)
1178+}
@@ -0,0 +1,1178 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/ed25519"
9+ "fmt"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/adapters/eventbus"
22+ "bnema.dev/ermite/internal/adapters/gitcli"
23+ "bnema.dev/ermite/internal/adapters/kvstore"
24+ "bnema.dev/ermite/internal/adapters/sshsig"
25+ "bnema.dev/ermite/internal/app"
26+ "bnema.dev/ermite/internal/domain"
27+)
28+
29+// mrRig runs the merge request use cases over the real adapters: a data
30+// directory with ermite-kv and bare repositories, and a work tree to push
31+// branches from.
32+type mrRig struct {
33+ t *testing.T
34+ ctx context.Context
35+ data string
36+ repos *app.Repos
37+ mrs *app.MergeRequests
38+ key *app.SigningKey
39+ store *kvstore.Store
40+ git *gitcli.Git
41+ bus *eventbus.Bus
42+ events <-chan domain.Event
43+ work string
44+ repo domain.RepoName
45+}
46+
47+var (
48+ root = domain.Root()
49+ author = domain.Identity{Name: "Ada Lovelace", Email: "ada@example.org"}
50+)
51+
52+func newMRRig(t *testing.T) *mrRig {
53+ t.Helper()
54+ ctx := context.Background()
55+ bin, err := exec.LookPath("git")
56+ if err != nil {
57+ unusable(t, "git not found")
58+ }
59+ data := t.TempDir()
60+ for _, d := range []string{"repos/.staging", "trash", "run/git-home", "kv"} {
61+ require.NoError(t, os.MkdirAll(filepath.Join(data, d), 0o700))
62+ }
63+ git := gitcli.New(gitcli.Config{Bin: bin, DataDir: data, Path: os.Getenv("PATH")})
64+ if _, err := git.CheckVersion(ctx); err != nil {
65+ unusable(t, "git is not usable by ermite: %v", err)
66+ }
67+ t.Cleanup(func() { _ = git.Close(context.Background()) })
68+ store, err := kvstore.Open(ctx, filepath.Join(data, "kv"))
69+ require.NoError(t, err)
70+ t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
71+
72+ signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
73+ require.NoError(t, err)
74+ bus := eventbus.NewWithBuffer(1024)
75+ committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
76+ r := &mrRig{
77+ t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx),
78+ repos: app.NewRepos(store, git, bus, fixedClock{}),
79+ key: app.NewSigningKey(signer, committer),
80+ work: t.TempDir(),
81+ repo: name(t, "proj"),
82+ }
83+ r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{
84+ Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
85+ Committer: committer, Bus: bus, Clock: fixedClock{},
86+ })
87+ _, err = r.repos.Create(ctx, root, app.CreateParams{Name: r.repo, Visibility: domain.VisibilityPrivate})
88+ require.NoError(t, err)
89+ r.sh("init", "-q", "--initial-branch=main")
90+ return r
91+}
92+
93+func (r *mrRig) bare() string { return filepath.Join(r.data, "repos", r.repo.String()+".git") }
94+
95+func (r *mrRig) env(who domain.Identity, extra ...string) []string {
96+ return append([]string{
97+ "PATH=" + os.Getenv("PATH"), "HOME=" + r.work, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
98+ "GIT_AUTHOR_NAME=" + who.Name, "GIT_AUTHOR_EMAIL=" + who.Email,
99+ "GIT_COMMITTER_NAME=" + who.Name, "GIT_COMMITTER_EMAIL=" + who.Email,
100+ "LC_ALL=C",
101+ }, extra...)
102+}
103+
104+func (r *mrRig) run(dir string, env []string, args ...string) string {
105+ r.t.Helper()
106+ cmd := exec.Command("git", args...)
107+ cmd.Dir, cmd.Env = dir, env
108+ out, err := cmd.CombinedOutput()
109+ require.NoError(r.t, err, "git %s\n%s", strings.Join(args, " "), out)
110+ return strings.TrimSpace(string(out))
111+}
112+
113+// sh runs git in the work tree as the default author.
114+func (r *mrRig) sh(args ...string) string { return r.run(r.work, r.env(author), args...) }
115+
116+// commit writes a file and commits it as who; it returns the commit id.
117+func (r *mrRig) commit(who domain.Identity, path, content, msg string) domain.ObjectID {
118+ r.t.Helper()
119+ full := filepath.Join(r.work, path)
120+ require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755))
121+ require.NoError(r.t, os.WriteFile(full, []byte(content), 0o644))
122+ r.run(r.work, r.env(who), "add", path)
123+ r.run(r.work, r.env(who), "commit", "-q", "-m", msg)
124+ return r.head()
125+}
126+
127+func (r *mrRig) head() domain.ObjectID {
128+ r.t.Helper()
129+ return oidFrom(r.t, r.sh("rev-parse", "HEAD"))
130+}
131+
132+func oidFrom(t *testing.T, s string) domain.ObjectID {
133+ t.Helper()
134+ id, err := domain.ParseObjectID(s)
135+ require.NoError(t, err)
136+ return id
137+}
138+
139+// push pushes the branch of the work tree to the repository, forcing it.
140+func (r *mrRig) push(branch string) {
141+ r.t.Helper()
142+ r.sh("push", "-q", "--force", r.bare(), branch+":refs/heads/"+branch)
143+}
144+
145+// branchHead reads a branch of the bare repository.
146+func (r *mrRig) branchHead(branch string) domain.ObjectID {
147+ r.t.Helper()
148+ return oidFrom(r.t, r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/"+branch))
149+}
150+
151+// seed pushes main with two files and returns its id.
152+func (r *mrRig) seed() domain.ObjectID {
153+ r.t.Helper()
154+ r.commit(author, "a.txt", "one\ntwo\nthree\n", "add a")
155+ id := r.commit(author, "b.txt", "bee\n", "add b")
156+ r.push("main")
157+ return id
158+}
159+
160+// feature creates the branch feat from main with two commits by two authors
161+// and pushes it; it returns its head.
162+func (r *mrRig) feature(branch string) domain.ObjectID {
163+ r.t.Helper()
164+ r.sh("checkout", "-q", "-B", branch, "main")
165+ r.commit(author, "a.txt", "one\n2\nthree\n", "change a")
166+ bob := domain.Identity{Name: "Bob", Email: "bob@example.org"}
167+ id := r.commit(bob, "c.txt", "see\n", "add c")
168+ r.push(branch)
169+ return id
170+}
171+
172+func (r *mrRig) open(branch string) domain.MergeRequest {
173+ r.t.Helper()
174+ mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add " + branch, Source: branch})
175+ require.NoError(r.t, err)
176+ return mr
177+}
178+
179+// drain returns the events published so far.
180+func (r *mrRig) drain() []domain.Event {
181+ var out []domain.Event
182+ for {
183+ select {
184+ case ev := <-r.events:
185+ out = append(out, ev)
186+ case <-time.After(20 * time.Millisecond):
187+ return out
188+ }
189+ }
190+}
191+
192+func writer(scopes ...domain.Scope) domain.Principal {
193+ return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes})
194+}
195+
196+var (
197+ reader = writer(domain.ScopeRepoRead)
198+ mrW = writer(domain.ScopeRepoRead, domain.ScopeMRWrite)
199+ mrM = writer(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge)
200+)
201+
202+func fieldsOf(t *testing.T, err error) map[string]string {
203+ t.Helper()
204+ var fe *domain.InvalidFieldsError
205+ require.ErrorAs(t, err, &fe, "want a rejection naming the fields")
206+ out := map[string]string{}
207+ for _, f := range fe.Fields {
208+ out[f.Field] = f.Reason
209+ }
210+ return out
211+}
212+
213+// AC3: the input rules of ADR 0017.
214+func TestCreateMergeRequestInputRules(t *testing.T) {
215+ r := newMRRig(t)
216+ r.seed()
217+ r.feature("feat")
218+ r.sh("checkout", "-q", "-B", "other", "main")
219+ r.commit(author, "o.txt", "o\n", "other")
220+ r.push("other")
221+
222+ tests := []struct {
223+ name string
224+ params app.CreateMergeRequestParams
225+ field string // "" for valid
226+ }{
227+ {"valid, default target", app.CreateMergeRequestParams{Title: "t", Source: "feat"}, ""},
228+ {"valid, explicit target and description", app.CreateMergeRequestParams{Title: "t", Description: "a\nb", Source: "other", Target: "main"}, ""},
229+ {"empty title", app.CreateMergeRequestParams{Title: "", Source: "feat", Target: "other"}, "title"},
230+ {"two-line title", app.CreateMergeRequestParams{Title: "a\nb", Source: "feat", Target: "other"}, "title"},
231+ {"title too long", app.CreateMergeRequestParams{Title: strings.Repeat("x", 121), Source: "feat", Target: "other"}, "title"},
232+ {"title with a bidi override", app.CreateMergeRequestParams{Title: "a\u202eb", Source: "feat", Target: "other"}, "title"},
233+ {"title with an escape", app.CreateMergeRequestParams{Title: "\x1b[31m", Source: "feat", Target: "other"}, "title"},
234+ {"description with a control character", app.CreateMergeRequestParams{Title: "t", Description: "a\x07", Source: "feat", Target: "other"}, "description"},
235+ {"description with a bidi isolate", app.CreateMergeRequestParams{Title: "t", Description: "a\u2066b", Source: "feat", Target: "other"}, "description"},
236+ {"description too long", app.CreateMergeRequestParams{Title: "t", Description: strings.Repeat("x", 8001), Source: "feat", Target: "other"}, "description"},
237+ {"description not utf-8", app.CreateMergeRequestParams{Title: "t", Description: "\xff", Source: "feat", Target: "other"}, "description"},
238+ {"source missing", app.CreateMergeRequestParams{Title: "t", Source: ""}, "source_branch"},
239+ {"source does not exist", app.CreateMergeRequestParams{Title: "t", Source: "nope", Target: "main"}, "source_branch"},
240+ {"source is not a branch name", app.CreateMergeRequestParams{Title: "t", Source: "a..b", Target: "main"}, "source_branch"},
241+ {"source is a full ref", app.CreateMergeRequestParams{Title: "t", Source: "refs/heads/feat", Target: "main"}, "source_branch"},
242+ {"target does not exist", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "nope"}, "target_branch"},
243+ {"same branch", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}, "target_branch"},
244+ }
245+ for _, tc := range tests {
246+ t.Run(tc.name, func(t *testing.T) {
247+ mr, err := r.mrs.Create(r.ctx, root, r.repo, tc.params)
248+ if tc.field == "" {
249+ require.NoError(t, err)
250+ assert.Equal(t, domain.MRStateOpen, mr.State)
251+ assert.Equal(t, "root", mr.Author)
252+ assert.Equal(t, "main", mr.Target)
253+ assert.NotZero(t, mr.HeadID)
254+ return
255+ }
256+ require.ErrorIs(t, err, domain.ErrInvalid)
257+ assert.Contains(t, fieldsOf(t, err), tc.field)
258+ })
259+ }
260+
261+ t.Run("a request is rejected whole with every field named", func(t *testing.T) {
262+ _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "a\nb", Description: "\x07", Source: "nope", Target: "feat"})
263+ got := fieldsOf(t, err)
264+ assert.Contains(t, got, "title")
265+ assert.Contains(t, got, "description")
266+ assert.Contains(t, got, "source_branch")
267+ })
268+}
269+
270+func TestCreateMergeRequestNumbersAndDuplicates(t *testing.T) {
271+ r := newMRRig(t)
272+ r.seed()
273+ r.feature("one")
274+ r.feature("two")
275+
276+ first := r.open("one")
277+ assert.Equal(t, int64(1), first.Number)
278+ second := r.open("two")
279+ assert.Equal(t, int64(2), second.Number)
280+
281+ _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one", Target: "main"})
282+ require.ErrorIs(t, err, domain.ErrAlreadyExists, "same open source and target")
283+ _, err = r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one"})
284+ require.ErrorIs(t, err, domain.ErrAlreadyExists, "the default target is the same target")
285+
286+ // Another target is another request.
287+ r.sh("checkout", "-q", "-B", "stage", "main")
288+ r.push("stage")
289+ third, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "to stage", Source: "one", Target: "stage"})
290+ require.NoError(t, err)
291+ assert.Equal(t, int64(3), third.Number)
292+
293+ // Once the first is closed, the pair is free again, with a new number.
294+ _, err = r.mrs.Close(r.ctx, root, r.repo, first.Number)
295+ require.NoError(t, err)
296+ fourth, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "retry", Source: "one"})
297+ require.NoError(t, err)
298+ assert.Equal(t, int64(4), fourth.Number, "numbers are never reused")
299+}
300+
301+func TestMergeRequestAccess(t *testing.T) {
302+ r := newMRRig(t) // proj is private
303+ r.seed()
304+ r.feature("feat")
305+ mr := r.open("feat")
306+ n := mr.Number
307+ noScope := writer(domain.ScopeRepoWrite)
308+ otherRepo := domain.TokenPrincipal(domain.Token{ID: "o", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge}, Repos: []domain.RepoName{name(t, "else")}})
309+
310+ reads := map[string]func(p domain.Principal) error{
311+ "List": func(p domain.Principal) error {
312+ _, err := r.mrs.List(r.ctx, p, r.repo, app.ListMergeRequestsParams{})
313+ return err
314+ },
315+ "Get": func(p domain.Principal) error {
316+ _, err := r.mrs.Get(r.ctx, p, r.repo, n, false)
317+ return err
318+ },
319+ }
320+ for what, call := range reads {
321+ for who, p := range map[string]domain.Principal{"anonymous": domain.Anonymous(), "no repo:read": noScope, "token of another repo": otherRepo} {
322+ assert.ErrorIs(t, call(p), domain.ErrNotFound, "%s by %s on a private repository", what, who)
323+ }
324+ assert.NoError(t, call(reader), what+" with repo:read")
325+ assert.NoError(t, call(root), what+" by root")
326+ }
327+ _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, false)
328+ assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number")
329+ _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), app.ListMergeRequestsParams{})
330+ assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository")
331+
332+ writes := map[string]func(p domain.Principal) error{
333+ "Create": func(p domain.Principal) error {
334+ _, err := r.mrs.Create(r.ctx, p, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"})
335+ return err
336+ },
337+ "AddComment": func(p domain.Principal) error {
338+ _, err := r.mrs.AddComment(r.ctx, p, r.repo, n, app.AddCommentParams{Text: "x"})
339+ return err
340+ },
341+ "ResolveComment": func(p domain.Principal) error {
342+ _, err := r.mrs.ResolveComment(r.ctx, p, r.repo, n, 1, true)
343+ return err
344+ },
345+ "Close": func(p domain.Principal) error {
346+ _, err := r.mrs.Close(r.ctx, p, r.repo, n+100)
347+ return err
348+ },
349+ "MarkRead": func(p domain.Principal) error {
350+ _, err := r.mrs.MarkRead(r.ctx, p, r.repo, n, domain.ObjectID{})
351+ return err
352+ },
353+ "Merge": func(p domain.Principal) error {
354+ _, err := r.mrs.Merge(r.ctx, p, r.repo, n+100, app.MergeParams{HeadID: mr.HeadID})
355+ return err
356+ },
357+ }
358+ for what, call := range writes {
359+ scope := domain.ScopeMRWrite
360+ if what == "Merge" {
361+ scope = domain.ScopeMRMerge
362+ }
363+ assert.ErrorIs(t, call(domain.Anonymous()), domain.ErrNotFound, "%s: anonymous cannot see the private repository", what)
364+ assert.ErrorIs(t, call(noScope), domain.ErrNotFound, "%s: no repo:read", what)
365+ assert.ErrorIs(t, call(otherRepo), domain.ErrNotFound, "%s: another repository's token", what)
366+ assert.ErrorIs(t, call(reader), domain.ErrForbidden, "%s: reading is not writing", what)
367+ if scope == domain.ScopeMRMerge {
368+ assert.ErrorIs(t, call(mrW), domain.ErrForbidden, "mr:write does not merge")
369+ continue
370+ }
371+ // With the scope the call gets past access (and fails on its subject).
372+ assert.NotErrorIs(t, call(mrW), domain.ErrForbidden, what)
373+ assert.NotErrorIs(t, call(mrW), domain.ErrUnauthenticated, what)
374+ }
375+
376+ // On a public repository an anonymous caller reads, but writing is
377+ // unauthenticated rather than not found.
378+ pubName := name(t, "pub")
379+ _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: pubName, Visibility: domain.VisibilityPublic})
380+ require.NoError(t, err)
381+ _, err = r.mrs.List(r.ctx, domain.Anonymous(), pubName, app.ListMergeRequestsParams{})
382+ assert.NoError(t, err)
383+ _, err = r.mrs.Create(r.ctx, domain.Anonymous(), pubName, app.CreateMergeRequestParams{Title: "t", Source: "a", Target: "b"})
384+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
385+}
386+
387+func TestListMergeRequests(t *testing.T) {
388+ r := newMRRig(t)
389+ r.seed()
390+ var nums []int64
391+ for i := range 5 {
392+ b := fmt.Sprintf("f%d", i)
393+ r.feature(b)
394+ nums = append(nums, r.open(b).Number)
395+ }
396+ _, err := r.mrs.Close(r.ctx, root, r.repo, 2)
397+ require.NoError(t, err)
398+ r.sh("checkout", "-q", "main")
399+ merged := r.feature("f0")
400+ _, err = r.mrs.Merge(r.ctx, root, r.repo, 1, app.MergeParams{HeadID: merged})
401+ require.NoError(t, err)
402+ r.sh("branch", "-D", "f4")
403+ r.run(r.bare(), r.env(author), "branch", "-D", "f4")
404+
405+ numbers := func(p app.MergeRequestPage) []int64 {
406+ var out []int64
407+ for _, m := range p.MergeRequests {
408+ out = append(out, m.Number)
409+ }
410+ return out
411+ }
412+ all, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{})
413+ require.NoError(t, err)
414+ assert.Equal(t, []int64{5, 4, 3, 2, 1}, numbers(all), "highest number first")
415+ assert.Empty(t, all.NextPageToken)
416+
417+ open, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateOpen})
418+ require.NoError(t, err)
419+ assert.Equal(t, []int64{5, 4, 3}, numbers(open))
420+ for _, m := range open.MergeRequests {
421+ if m.Number == 5 {
422+ assert.True(t, m.HeadID.IsZero(), "the branch of !5 was deleted")
423+ } else {
424+ assert.False(t, m.HeadID.IsZero(), "an open request carries its head")
425+ }
426+ }
427+ closed, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateClosed})
428+ require.NoError(t, err)
429+ assert.Equal(t, []int64{2}, numbers(closed))
430+ mergedPage, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateMerged})
431+ require.NoError(t, err)
432+ assert.Equal(t, []int64{1}, numbers(mergedPage))
433+ assert.Equal(t, merged, mergedPage.MergeRequests[0].HeadID, "a merged request keeps the head it had")
434+
435+ // Paging: the token names the last number.
436+ p1, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2})
437+ require.NoError(t, err)
438+ assert.Equal(t, []int64{5, 4}, numbers(p1))
439+ require.NotEmpty(t, p1.NextPageToken)
440+ // A request created between the pages does not shift them.
441+ r.feature("late")
442+ r.open("late")
443+ p2, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p1.NextPageToken})
444+ require.NoError(t, err)
445+ assert.Equal(t, []int64{3, 2}, numbers(p2))
446+ p3, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p2.NextPageToken})
447+ require.NoError(t, err)
448+ assert.Equal(t, []int64{1}, numbers(p3))
449+ assert.Empty(t, p3.NextPageToken)
450+
451+ for _, tok := range []string{"garbage", "bW9yZQ"} {
452+ _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageToken: tok})
453+ assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
454+ }
455+ _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: -1})
456+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
457+ _ = nums
458+}
459+
460+func TestGetMergeRequest(t *testing.T) {
461+ r := newMRRig(t)
462+ main := r.seed()
463+ head := r.feature("feat")
464+ mr := r.open("feat")
465+ _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "first"})
466+ require.NoError(t, err)
467+ _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "second"})
468+ require.NoError(t, err)
469+
470+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
471+ require.NoError(t, err)
472+ assert.Equal(t, head, d.MergeRequest.HeadID)
473+ assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head")
474+ require.Len(t, d.Commits, 2)
475+ assert.Equal(t, "add c", d.Commits[0].Summary, "newest first")
476+ assert.False(t, d.CommitsTruncated)
477+ assert.Equal(t, domain.DiffStats{FilesChanged: 2, Additions: 2, Deletions: 1}, d.Stats)
478+ require.Len(t, d.Diff.Files, 2)
479+ require.Len(t, d.Comments, 2)
480+ assert.Equal(t, "first", d.Comments[0].Text.String(), "oldest first")
481+ assert.True(t, d.LastReadID.IsZero())
482+ assert.False(t, d.LastReadLost)
483+
484+ // The target moved on: the base stays the merge base, not the new tip.
485+ r.sh("checkout", "-q", "main")
486+ r.commit(author, "later.txt", "x\n", "later on main")
487+ r.push("main")
488+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
489+ require.NoError(t, err)
490+ assert.Equal(t, main, d.DiffBaseID)
491+ assert.Len(t, d.Commits, 2, "the commits that main lacks")
492+
493+ // A deleted source branch: no commits, no diff, an empty head.
494+ r.run(r.bare(), r.env(author), "branch", "-D", "feat")
495+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
496+ require.NoError(t, err)
497+ assert.True(t, d.MergeRequest.HeadID.IsZero())
498+ assert.Empty(t, d.Commits)
499+ assert.True(t, d.DiffBaseID.IsZero())
500+ assert.Empty(t, d.Diff.Files)
501+ assert.Len(t, d.Comments, 2)
502+}
503+
504+func TestGetMergeRequestTruncatesCommits(t *testing.T) {
505+ r := newMRRig(t)
506+ r.seed()
507+ r.sh("checkout", "-q", "-B", "long", "main")
508+ for i := range app.MaxMergeRequestCommits + 5 {
509+ r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i))
510+ }
511+ r.push("long")
512+ mr := r.open("long")
513+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
514+ require.NoError(t, err)
515+ assert.Len(t, d.Commits, app.MaxMergeRequestCommits)
516+ assert.True(t, d.CommitsTruncated)
517+ assert.Equal(t, "c104", d.Commits[0].Summary)
518+}
519+
520+// AC3: a comment on a path:line must be a line of the current diff.
521+func TestAddComment(t *testing.T) {
522+ r := newMRRig(t)
523+ r.seed()
524+ r.feature("feat")
525+ mr := r.open("feat")
526+ n := mr.Number
527+ // The diff: a.txt line 2 changed (old "two", new "2"); c.txt added, line 1.
528+
529+ tests := []struct {
530+ name string
531+ params app.AddCommentParams
532+ field string // "" for valid
533+ }{
534+ {"general", app.AddCommentParams{Text: "looks good"}, ""},
535+ {"multi-line general", app.AddCommentParams{Text: "a\n\nb"}, ""},
536+ {"new side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideNew, Text: "x"}, ""},
537+ {"old side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideOld, Text: "x"}, ""},
538+ {"context line", app.AddCommentParams{Path: "a.txt", Line: 3, Side: domain.SideNew, Text: "x"}, ""},
539+ {"added file", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideNew, Text: "x"}, ""},
540+ {"empty text", app.AddCommentParams{Text: ""}, "text"},
541+ {"blank text", app.AddCommentParams{Text: " \n "}, "text"},
542+ {"text too long", app.AddCommentParams{Text: strings.Repeat("x", 8001)}, "text"},
543+ {"text with a control character", app.AddCommentParams{Text: "a\x1b[0m"}, "text"},
544+ {"text with a tab", app.AddCommentParams{Text: "a\tb"}, "text"},
545+ {"text with a bidi override", app.AddCommentParams{Text: "a\u202eb"}, "text"},
546+ {"line outside the diff", app.AddCommentParams{Path: "a.txt", Line: 40, Side: domain.SideNew, Text: "x"}, "path"},
547+ {"unchanged file", app.AddCommentParams{Path: "b.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"},
548+ {"unknown path", app.AddCommentParams{Path: "nope.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"},
549+ {"deleted line on the wrong side", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideOld, Text: "x"}, "path"},
550+ {"path without a line", app.AddCommentParams{Path: "a.txt", Side: domain.SideNew, Text: "x"}, "line"},
551+ {"path without a side", app.AddCommentParams{Path: "a.txt", Line: 2, Text: "x"}, "side"},
552+ {"line without a path", app.AddCommentParams{Line: 2, Side: domain.SideNew, Text: "x"}, "path"},
553+ {"side without a path", app.AddCommentParams{Side: domain.SideOld, Text: "x"}, "path"},
554+ {"negative line", app.AddCommentParams{Path: "a.txt", Line: -2, Side: domain.SideNew, Text: "x"}, "line"},
555+ }
556+ for _, tc := range tests {
557+ t.Run(tc.name, func(t *testing.T) {
558+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, tc.params)
559+ if tc.field == "" {
560+ require.NoError(t, err)
561+ assert.Equal(t, "agent-1", c.Author)
562+ assert.Equal(t, tc.params.Path, c.Path)
563+ assert.Equal(t, tc.params.Line, c.Line)
564+ assert.Equal(t, tc.params.Side, c.Side)
565+ assert.Equal(t, mr.HeadID, c.CommitID, "the head it was written against")
566+ assert.False(t, c.Resolved)
567+ return
568+ }
569+ require.ErrorIs(t, err, domain.ErrInvalid)
570+ assert.Contains(t, fieldsOf(t, err), tc.field)
571+ })
572+ }
573+
574+ // Ids count 1, 2, ... in order of creation and only the valid ones are kept.
575+ d, err := r.mrs.Get(r.ctx, root, r.repo, n, false)
576+ require.NoError(t, err)
577+ var kept int
578+ for _, tc := range tests {
579+ if tc.field == "" {
580+ kept++
581+ }
582+ }
583+ require.Len(t, d.Comments, kept)
584+ for i, c := range d.Comments {
585+ assert.Equal(t, int64(i+1), c.ID)
586+ }
587+
588+ t.Run("not open", func(t *testing.T) {
589+ _, err := r.mrs.Close(r.ctx, mrW, r.repo, n)
590+ require.NoError(t, err)
591+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, app.AddCommentParams{Text: "late"})
592+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
593+ _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, n, 1, true)
594+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
595+ })
596+}
597+
598+func TestConcurrentComments(t *testing.T) {
599+ r := newMRRig(t)
600+ r.seed()
601+ r.feature("feat")
602+ mr := r.open("feat")
603+ const workers = 10
604+ var wg sync.WaitGroup
605+ ids := make(chan int64, workers)
606+ for i := range workers {
607+ wg.Go(func() {
608+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: fmt.Sprintf("c%d", i)})
609+ if assert.NoError(t, err) {
610+ ids <- c.ID
611+ }
612+ })
613+ }
614+ wg.Wait()
615+ close(ids)
616+ seen := map[int64]bool{}
617+ for id := range ids {
618+ assert.False(t, seen[id], "id %d handed out twice", id)
619+ seen[id] = true
620+ }
621+ assert.Len(t, seen, workers)
622+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false)
623+ require.NoError(t, err)
624+ assert.Len(t, d.Comments, workers)
625+}
626+
627+func TestResolveComment(t *testing.T) {
628+ r := newMRRig(t)
629+ r.seed()
630+ r.feature("feat")
631+ mr := r.open("feat")
632+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "fix this"})
633+ require.NoError(t, err)
634+ r.drain()
635+
636+ got, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true)
637+ require.NoError(t, err)
638+ assert.True(t, got.Resolved)
639+ again, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, true)
640+ require.NoError(t, err, "resolving twice is not an error")
641+ assert.True(t, again.Resolved)
642+ undone, err := r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false)
643+ require.NoError(t, err)
644+ assert.False(t, undone.Resolved)
645+ undone, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, c.ID, false)
646+ require.NoError(t, err)
647+ assert.False(t, undone.Resolved)
648+
649+ _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 99, true)
650+ assert.ErrorIs(t, err, domain.ErrNotFound)
651+ _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 0, true)
652+ assert.ErrorIs(t, err, domain.ErrNotFound)
653+
654+ assert.Empty(t, r.drain(), "a resolved comment has no event")
655+}
656+
657+func TestEventsOfAMergeRequest(t *testing.T) {
658+ r := newMRRig(t)
659+ r.seed()
660+ head := r.feature("feat")
661+ r.drain()
662+
663+ mr := r.open("feat")
664+ c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "note"})
665+ require.NoError(t, err)
666+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
667+ require.NoError(t, err)
668+
669+ r.feature("other")
670+ other := r.open("other")
671+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number)
672+ require.NoError(t, err)
673+
674+ evs := r.drain()
675+ var kinds []string
676+ for _, ev := range evs {
677+ kinds = append(kinds, ev.EventKind())
678+ }
679+ assert.Equal(t, []string{"mr.opened", "mr.comment_added", "mr.merged", "repo.pushed", "mr.opened", "mr.closed"}, kinds)
680+
681+ opened := evs[0].(domain.MergeRequestOpened)
682+ assert.Equal(t, mr.Number, opened.MergeRequest.Number)
683+ assert.Equal(t, domain.VisibilityPrivate, opened.Visibility, "the event carries the visibility, so that it is filtered")
684+ assert.False(t, opened.CreatedAt.IsZero())
685+ added := evs[1].(domain.CommentAdded)
686+ assert.Equal(t, c, added.Comment)
687+ assert.Equal(t, mr.Number, added.Number)
688+ merged := evs[2].(domain.MergeRequestMerged)
689+ assert.Equal(t, domain.MRStateMerged, merged.MergeRequest.State)
690+ push := evs[3].(domain.PushEvent)
691+ assert.Equal(t, []domain.RefUpdate{{Old: r.seedID(), New: head.String(), Ref: "refs/heads/main"}}, push.Updates)
692+ assert.Equal(t, domain.VisibilityPrivate, push.Visibility)
693+ assert.Equal(t, domain.MRStateClosed, evs[5].(domain.MergeRequestClosed).MergeRequest.State)
694+
695+ // Failed calls publish nothing.
696+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number)
697+ require.Error(t, err)
698+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, app.AddCommentParams{Text: "x"})
699+ require.Error(t, err)
700+ assert.Empty(t, r.drain())
701+}
702+
703+// seedID is the id main had before the merges of the test: the parent of its
704+// first feature commit.
705+func (r *mrRig) seedID() string {
706+ return r.run(r.bare(), r.env(author), "rev-parse", "refs/heads/feat~2")
707+}
708+
709+func TestMarkReadAndSinceLastRead(t *testing.T) {
710+ r := newMRRig(t)
711+ base := r.seed()
712+ head1 := r.feature("feat")
713+ mr := r.open("feat")
714+ n := mr.Number
715+
716+ // Nothing recorded: the flag changes nothing.
717+ d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, true)
718+ require.NoError(t, err)
719+ assert.True(t, d.LastReadID.IsZero())
720+ assert.False(t, d.LastReadLost)
721+ assert.Equal(t, base, d.DiffBaseID)
722+
723+ // Without a head: the current head.
724+ got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{})
725+ require.NoError(t, err)
726+ assert.Equal(t, head1, got)
727+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, false)
728+ require.NoError(t, err)
729+ assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set")
730+ assert.Equal(t, base, d.DiffBaseID)
731+
732+ // New work on the branch: the diff since the last read has only that.
733+ r.commit(author, "d.txt", "dee\n", "add d")
734+ head2 := r.head()
735+ r.push("feat")
736+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true)
737+ require.NoError(t, err)
738+ assert.Equal(t, head1, d.DiffBaseID)
739+ assert.Equal(t, head2, d.MergeRequest.HeadID)
740+ require.Len(t, d.Diff.Files, 1)
741+ assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath)
742+ assert.Equal(t, domain.DiffStats{FilesChanged: 1, Additions: 1}, d.Stats)
743+ assert.Len(t, d.Commits, 3, "the commits are still those of the target..head range")
744+
745+ // Marking a commit that is not in the repository is refused.
746+ _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, oidFrom(t, strings.Repeat("1", 40)))
747+ require.ErrorIs(t, err, domain.ErrInvalid)
748+ assert.Contains(t, fieldsOf(t, err), "head_id")
749+ // Neither is a tree.
750+ tree := oidFrom(t, r.run(r.bare(), r.env(author), "rev-parse", head1.String()+"^{tree}"))
751+ _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, tree)
752+ require.ErrorIs(t, err, domain.ErrInvalid)
753+ // An explicit head is recorded as given.
754+ _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base)
755+ require.NoError(t, err)
756+ d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true)
757+ require.NoError(t, err)
758+ assert.Equal(t, base, d.DiffBaseID)
759+}
760+
761+// AC6: the diff since the last read is right after a force-push; when the
762+// recorded commit is gone the whole change is shown and the response says so.
763+func TestSinceLastReadAfterForcePush(t *testing.T) {
764+ r := newMRRig(t)
765+ base := r.seed()
766+ head1 := r.feature("feat")
767+ mr := r.open("feat")
768+ _, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, mr.Number, domain.ObjectID{})
769+ require.NoError(t, err)
770+
771+ // Force-push: the last commit is amended (the old one is unreachable but
772+ // still stored), and one more file is added.
773+ r.sh("checkout", "-q", "feat")
774+ require.NoError(t, os.WriteFile(filepath.Join(r.work, "c.txt"), []byte("see, amended\n"), 0o644))
775+ r.sh("add", "c.txt")
776+ r.sh("commit", "-q", "--amend", "-m", "add c, amended")
777+ head2 := r.head()
778+ require.NotEqual(t, head1, head2)
779+ r.push("feat")
780+ assert.Equal(t, head2, r.branchHead("feat"))
781+ // head1 is not an ancestor of head2 any more.
782+ r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String())
783+
784+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true)
785+ require.NoError(t, err)
786+ assert.False(t, d.LastReadLost)
787+ assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read")
788+ require.Len(t, d.Diff.Files, 1, "only what the force-push changed")
789+ assert.Equal(t, "c.txt", d.Diff.Files[0].NewPath)
790+ var added, removed []string
791+ for _, l := range d.Diff.Files[0].Hunks[0].Lines {
792+ switch l.Kind {
793+ case domain.LineAddition:
794+ added = append(added, l.Text)
795+ case domain.LineDeletion:
796+ removed = append(removed, l.Text)
797+ }
798+ }
799+ assert.Equal(t, []string{"see, amended"}, added)
800+ assert.Equal(t, []string{"see"}, removed)
801+
802+ // The whole change is still available without the flag.
803+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
804+ require.NoError(t, err)
805+ assert.Equal(t, base, d.DiffBaseID)
806+ assert.Len(t, d.Diff.Files, 2)
807+
808+ // Once the recorded commit is pruned, the response says so.
809+ r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now")
810+ d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true)
811+ require.NoError(t, err)
812+ assert.True(t, d.LastReadLost, "the recorded head is gone")
813+ assert.Equal(t, head1, d.LastReadID, "still reported")
814+ assert.Equal(t, base, d.DiffBaseID, "the diff is against the target")
815+ assert.Len(t, d.Diff.Files, 2)
816+}
817+
818+func TestMergeFastForward(t *testing.T) {
819+ r := newMRRig(t)
820+ main := r.seed()
821+ head := r.feature("feat")
822+ mr := r.open("feat")
823+ r.drain()
824+
825+ merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
826+ require.NoError(t, err)
827+ assert.Equal(t, domain.MRStateMerged, merged.State)
828+ assert.Equal(t, domain.MergeModeFastForward, merged.MergeMode, "the default mode")
829+ assert.Equal(t, head, merged.MergedCommitID, "no commit is created")
830+ assert.Equal(t, head, merged.HeadID)
831+ assert.Equal(t, head, r.branchHead("main"))
832+ assert.Equal(t, "root", merged.Author)
833+ _ = main
834+
835+ // The stored request is the merged one, and reads keep a diff.
836+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
837+ require.NoError(t, err)
838+ assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
839+ assert.Equal(t, head, d.MergeRequest.HeadID)
840+ assert.Equal(t, head, d.MergeRequest.MergedCommitID)
841+ assert.Equal(t, main, d.DiffBaseID, "the base of the merge stays readable")
842+ assert.Len(t, d.Commits, 2, "the commits that were merged")
843+ assert.Len(t, d.Diff.Files, 2)
844+
845+ // Repeating it, or touching a final request, is a failed precondition.
846+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
847+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
848+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
849+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
850+ _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "x"})
851+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
852+}
853+
854+// AC4: the checks of a merge.
855+func TestMergeRefusals(t *testing.T) {
856+ t.Run("a branch that moved since the review", func(t *testing.T) {
857+ r := newMRRig(t)
858+ r.seed()
859+ head := r.feature("feat")
860+ mr := r.open("feat")
861+ r.commit(author, "more.txt", "m\n", "pushed after the review")
862+ r.push("feat")
863+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
864+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
865+ assert.Contains(t, err.Error(), "branch moved, read again")
866+ assert.NotEqual(t, head, r.branchHead("main"), "main did not move")
867+ // Reading again gives the new head, which merges.
868+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false)
869+ require.NoError(t, err)
870+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: d.MergeRequest.HeadID})
871+ require.NoError(t, err)
872+ })
873+
874+ t.Run("a target that is not an ancestor of the head", func(t *testing.T) {
875+ r := newMRRig(t)
876+ r.seed()
877+ head := r.feature("feat")
878+ mr := r.open("feat")
879+ r.sh("checkout", "-q", "main")
880+ r.commit(author, "news.txt", "n\n", "main moved")
881+ r.push("main")
882+ tip := r.branchHead("main")
883+
884+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
885+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: mode})
886+ require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)
887+ assert.Contains(t, err.Error(), "rebase", mode)
888+ }
889+ assert.Equal(t, tip, r.branchHead("main"))
890+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false)
891+ require.NoError(t, err)
892+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
893+
894+ // After a rebase and a push it merges.
895+ r.sh("checkout", "-q", "feat")
896+ r.sh("rebase", "-q", "main")
897+ r.push("feat")
898+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.head()})
899+ require.NoError(t, err)
900+ })
901+
902+ t.Run("a mode the repository does not allow", func(t *testing.T) {
903+ r := newMRRig(t)
904+ r.seed()
905+ head := r.feature("feat")
906+ mr := r.open("feat")
907+ modes := []domain.MergeMode{domain.MergeModeFastForward}
908+ _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes})
909+ require.NoError(t, err)
910+
911+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
912+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
913+ assert.Contains(t, err.Error(), "squash")
914+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
915+ require.NoError(t, err, "fast-forward is allowed")
916+ })
917+
918+ t.Run("only squash is allowed, so the default mode is refused", func(t *testing.T) {
919+ r := newMRRig(t)
920+ r.seed()
921+ head := r.feature("feat")
922+ mr := r.open("feat")
923+ modes := []domain.MergeMode{domain.MergeModeSquash}
924+ _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes})
925+ require.NoError(t, err)
926+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
927+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
928+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
929+ require.NoError(t, err)
930+ })
931+
932+ t.Run("bad arguments", func(t *testing.T) {
933+ r := newMRRig(t)
934+ r.seed()
935+ head := r.feature("feat")
936+ mr := r.open("feat")
937+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{})
938+ require.ErrorIs(t, err, domain.ErrInvalid)
939+ assert.Contains(t, fieldsOf(t, err), "head_id")
940+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: "rebase"})
941+ require.ErrorIs(t, err, domain.ErrInvalid)
942+ assert.Contains(t, fieldsOf(t, err), "mode")
943+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, app.MergeParams{HeadID: head})
944+ assert.ErrorIs(t, err, domain.ErrNotFound)
945+ })
946+
947+ t.Run("a deleted source or target, or nothing to merge", func(t *testing.T) {
948+ r := newMRRig(t)
949+ r.seed()
950+ head := r.feature("feat")
951+ mr := r.open("feat")
952+ r.run(r.bare(), r.env(author), "branch", "-D", "feat")
953+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
954+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
955+ assert.Contains(t, err.Error(), "no longer exists")
956+
957+ r.push("feat")
958+ r.run(r.bare(), r.env(author), "update-ref", "refs/heads/main", head.String())
959+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head})
960+ require.ErrorIs(t, err, domain.ErrPreconditionFailed, "the target already is at the head")
961+ assert.Contains(t, err.Error(), "nothing to merge")
962+ })
963+}
964+
965+// AC4: merges of one repository run one at a time.
966+func TestConcurrentMergesSerialize(t *testing.T) {
967+ r := newMRRig(t)
968+ r.seed()
969+ const n = 6
970+ heads := map[int64]domain.ObjectID{}
971+ for i := range n {
972+ b := fmt.Sprintf("f%d", i)
973+ heads[r.openFeature(b).Number] = r.branchHead(b)
974+ }
975+
976+ type outcome struct {
977+ number int64
978+ err error
979+ }
980+ results := make(chan outcome, n*2)
981+ var wg sync.WaitGroup
982+ for number, head := range heads {
983+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
984+ if mode == domain.MergeModeSquash && number%2 == 0 {
985+ continue
986+ }
987+ wg.Go(func() {
988+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, app.MergeParams{HeadID: head, Mode: mode})
989+ results <- outcome{number, err}
990+ })
991+ }
992+ }
993+ wg.Wait()
994+ close(results)
995+
996+ won := map[int64]int{}
997+ for o := range results {
998+ if o.err == nil {
999+ won[o.number]++
1000+ continue
1001+ }
1002+ require.ErrorIs(t, o.err, domain.ErrPreconditionFailed, "a loser fails cleanly: %v", o.err)
1003+ }
1004+ // All the branches start from the same main: exactly one request can win,
1005+ // once, whatever the mode, and main is where it left it.
1006+ require.Len(t, won, 1, "exactly one merge request was merged: %v", won)
1007+ for number, times := range won {
1008+ assert.Equal(t, 1, times, "!%d merged twice", number)
1009+ mr, err := r.mrs.Get(r.ctx, root, r.repo, number, false)
1010+ require.NoError(t, err)
1011+ assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State)
1012+ assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main"))
1013+ }
1014+ for number := range heads {
1015+ if won[number] == 0 {
1016+ d, err := r.mrs.Get(r.ctx, root, r.repo, number, false)
1017+ require.NoError(t, err)
1018+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number)
1019+ }
1020+ }
1021+}
1022+
1023+// openFeature pushes a branch from main with one commit and opens a request.
1024+func (r *mrRig) openFeature(branch string) domain.MergeRequest {
1025+ r.t.Helper()
1026+ r.sh("checkout", "-q", "-B", branch, "main")
1027+ r.commit(author, branch+".txt", branch+"\n", "add "+branch)
1028+ r.push(branch)
1029+ return r.open(branch)
1030+}
1031+
1032+func TestSquashMerge(t *testing.T) {
1033+ r := newMRRig(t)
1034+ main := r.seed()
1035+ // A branch of three commits by two authors; Bob wrote the first one.
1036+ bob := domain.Identity{Name: "Bob", Email: "bob@example.org"}
1037+ cy := domain.Identity{Name: "Cy", Email: "cy@example.org"}
1038+ r.sh("checkout", "-q", "-B", "feat", "main")
1039+ r.commit(bob, "x.txt", "x\n", "bob starts")
1040+ r.commit(author, "y.txt", "y\n", "ada continues")
1041+ r.commit(cy, "z.txt", "z\n", "cy finishes")
1042+ r.commit(bob, "w.txt", "w\n", "bob again")
1043+ r.push("feat")
1044+ head := r.branchHead("feat")
1045+ mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add things", Source: "feat"})
1046+ require.NoError(t, err)
1047+ r.drain()
1048+
1049+ merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
1050+ require.NoError(t, err)
1051+ assert.Equal(t, domain.MergeModeSquash, merged.MergeMode)
1052+ require.False(t, merged.MergedCommitID.IsZero())
1053+ assert.NotEqual(t, head, merged.MergedCommitID, "a squash creates a commit")
1054+ assert.Equal(t, merged.MergedCommitID, r.branchHead("main"))
1055+
1056+ sq := merged.MergedCommitID.String()
1057+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
1058+ assert.Equal(t, main.String(), g("rev-parse", sq+"^"), "the target is the only parent")
1059+ assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", sq+"^{tree}"), "the tree of the head")
1060+ assert.Equal(t, "Bob <bob@example.org>", g("log", "-1", "--format=%an <%ae>", sq), "author of the first commit")
1061+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", sq))
1062+ assert.Equal(t, "Add things (!1)\n\n- bob starts\n- ada continues\n- cy finishes\n- bob again\n\n"+
1063+ "Co-authored-by: Ada Lovelace <ada@example.org>\nCo-authored-by: Cy <cy@example.org>",
1064+ g("log", "-1", "--format=%B", sq))
1065+ assert.Contains(t, g("cat-file", "commit", sq), "\ngpgsig -----BEGIN SSH SIGNATURE-----\n")
1066+
1067+ // Events: merged, then pushed for the target branch.
1068+ evs := r.drain()
1069+ require.Len(t, evs, 2)
1070+ assert.Equal(t, merged.MergedCommitID, evs[0].(domain.MergeRequestMerged).MergeRequest.MergedCommitID)
1071+ assert.Equal(t, []domain.RefUpdate{{Old: main.String(), New: sq, Ref: "refs/heads/main"}}, evs[1].(domain.PushEvent).Updates)
1072+
1073+ // The signature verifies with an allowed_signers file built from the
1074+ // public key the server hands out.
1075+ r.verifyCommit(sq)
1076+}
1077+
1078+// verifyCommit checks the signature of a commit of the repository with
1079+// `git verify-commit` and an allowed signers file made from the signing key
1080+// info, and that another key does not verify.
1081+func (r *mrRig) verifyCommit(id string) {
1082+ r.t.Helper()
1083+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
1084+ unusable(r.t, "ssh-keygen not found: install openssh to verify the signed commits (CI must have it)")
1085+ }
1086+ dir := r.t.TempDir()
1087+ signers := filepath.Join(dir, "allowed_signers")
1088+ require.NoError(r.t, os.WriteFile(signers, []byte(r.key.Info().AllowedSigners()+"\n"), 0o600))
1089+ out := r.run(r.bare(), r.env(author), "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+signers, "verify-commit", "--raw", id)
1090+ assert.Contains(r.t, out, "Good \"git\" signature for ermite@git.example.org")
1091+
1092+ other, err := sshsig.New(bytes.Repeat([]byte{1}, ed25519.SeedSize))
1093+ require.NoError(r.t, err)
1094+ wrong := filepath.Join(dir, "wrong_signers")
1095+ require.NoError(r.t, os.WriteFile(wrong, []byte("ermite@git.example.org "+other.PublicKey().AuthorizedKey+"\n"), 0o600))
1096+ cmd := exec.Command("git", "-c", "gpg.format=ssh", "-c", "gpg.ssh.allowedSignersFile="+wrong, "verify-commit", id)
1097+ cmd.Dir, cmd.Env = r.bare(), r.env(author)
1098+ assert.Error(r.t, cmd.Run(), "another key must not verify the commit")
1099+}
1100+
1101+func TestSquashSingleCommitHasNoTrailer(t *testing.T) {
1102+ r := newMRRig(t)
1103+ r.seed()
1104+ head := r.feature("solo") // two authors; make a one-author branch instead
1105+ _ = head
1106+ r.sh("checkout", "-q", "-B", "mine", "main")
1107+ r.commit(author, "m.txt", "m\n", "only")
1108+ r.push("mine")
1109+ mr := r.open("mine")
1110+ merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash})
1111+ require.NoError(t, err)
1112+ msg := r.run(r.bare(), r.env(author), "log", "-1", "--format=%B", merged.MergedCommitID.String())
1113+ assert.Equal(t, "Add mine (!1)\n\n- only", msg)
1114+ r.verifyCommit(merged.MergedCommitID.String())
1115+}
1116+
1117+func TestUpdateRepoSettings(t *testing.T) {
1118+ r := newMRRig(t)
1119+ repo, err := r.repos.Get(r.ctx, root, r.repo)
1120+ require.NoError(t, err)
1121+ assert.Equal(t, domain.DefaultRepoSettings(), repo.Settings, "stored explicitly at creation")
1122+
1123+ modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash}
1124+ off := false
1125+ got, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes, FailingChecksBlock: &off})
1126+ require.NoError(t, err)
1127+ assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings)
1128+ again, err := r.repos.Get(r.ctx, root, r.repo)
1129+ require.NoError(t, err)
1130+ assert.Equal(t, got.Settings, again.Settings)
1131+
1132+ // Only the named setting changes.
1133+ on := true
1134+ got, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{FailingChecksBlock: &on})
1135+ require.NoError(t, err)
1136+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes)
1137+ assert.True(t, got.Settings.FailingChecksBlock)
1138+
1139+ for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} {
1140+ bad := bad
1141+ _, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &bad})
1142+ assert.ErrorIs(t, err, domain.ErrInvalid, "%v", bad)
1143+ }
1144+ again, err = r.repos.Get(r.ctx, root, r.repo)
1145+ require.NoError(t, err)
1146+ assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing")
1147+
1148+ // repo:admin is needed, like for any other field.
1149+ _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, app.UpdateParams{FailingChecksBlock: &off})
1150+ assert.ErrorIs(t, err, domain.ErrForbidden)
1151+}
1152+
1153+func TestDeletingARepositoryDropsItsMergeRequests(t *testing.T) {
1154+ r := newMRRig(t)
1155+ r.seed()
1156+ r.feature("feat")
1157+ r.open("feat")
1158+ require.NoError(t, r.repos.Delete(r.ctx, root, r.repo))
1159+
1160+ _, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{})
1161+ assert.ErrorIs(t, err, domain.ErrNotFound)
1162+
1163+ // Created again under the same name, it starts empty.
1164+ _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: r.repo})
1165+ require.NoError(t, err)
1166+ page, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{})
1167+ require.NoError(t, err)
1168+ assert.Empty(t, page.MergeRequests)
1169+}
1170+
1171+func TestSigningKeyInfo(t *testing.T) {
1172+ r := newMRRig(t)
1173+ info := r.key.Info()
1174+ assert.True(t, strings.HasPrefix(info.Key.AuthorizedKey, "ssh-ed25519 "))
1175+ assert.True(t, strings.HasPrefix(info.Key.Fingerprint, "SHA256:"))
1176+ assert.Equal(t, "ermite@git.example.org "+info.Key.AuthorizedKey, info.AllowedSigners())
1177+ assert.Equal(t, domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, info.Committer)
1178+}
M internal/app/pushes_test.go +1 -1
@@ -112,7 +112,7 @@ func TestPushesOpen(t *testing.T) {
112112 f := newPushFixture(t)
113113 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
114114 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
115- want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now}
115+ want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
116116 f.meta.EXPECT().Create(ctx, want).Return(nil)
117117 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
118118 f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()
@@ -112,7 +112,7 @@ func TestPushesOpen(t *testing.T) {
112 f := newPushFixture(t)112 f := newPushFixture(t)
113 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)113 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
114 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)114 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
115- want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now}115+ want := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
116 f.meta.EXPECT().Create(ctx, want).Return(nil)116 f.meta.EXPECT().Create(ctx, want).Return(nil)
117 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)117 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
118 f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()118 f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return()
M internal/app/repos.go +2 -0
@@ -70,6 +70,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar
7070 Description: params.Description,
7171 Visibility: vis,
7272 CreatedAt: r.clock.Now().UTC(),
73+ Settings: domain.DefaultRepoSettings(),
7374 }
7475
7576 log := zerowrap.FromCtx(ctx)
@@ -200,6 +201,7 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
200201 Name: name,
201202 Visibility: domain.VisibilityPrivate,
202203 CreatedAt: r.clock.Now().UTC(),
204+ Settings: domain.DefaultRepoSettings(),
203205 }
204206 if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) {
205207 return rep, fmt.Errorf("register repository %q: %w", name, err)
@@ -70,6 +70,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreatePar
70 Description: params.Description,70 Description: params.Description,
71 Visibility: vis,71 Visibility: vis,
72 CreatedAt: r.clock.Now().UTC(),72 CreatedAt: r.clock.Now().UTC(),
73+ Settings: domain.DefaultRepoSettings(),
73 }74 }
74 75
75 log := zerowrap.FromCtx(ctx)76 log := zerowrap.FromCtx(ctx)
@@ -200,6 +201,7 @@ func (r *Repos) Reconcile(ctx context.Context) (ReconcileReport, error) {
200 Name: name,201 Name: name,
201 Visibility: domain.VisibilityPrivate,202 Visibility: domain.VisibilityPrivate,
202 CreatedAt: r.clock.Now().UTC(),203 CreatedAt: r.clock.Now().UTC(),
204+ Settings: domain.DefaultRepoSettings(),
203 }205 }
204 if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) {206 if err := r.meta.Create(ctx, repo); err != nil && !errors.Is(err, domain.ErrAlreadyExists) {
205 return rep, fmt.Errorf("register repository %q: %w", name, err)207 return rep, fmt.Errorf("register repository %q: %w", name, err)
M internal/app/repos_test.go +2 -2
@@ -55,7 +55,7 @@ func TestCreate(t *testing.T) {
5555 t.Run("success is private by default", func(t *testing.T) {
5656 f := newFixture(t)
5757 n := name(t, "tools")
58- want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now}
58+ want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
5959 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
6060 f.meta.EXPECT().Create(ctx, want).Return(nil)
6161 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
@@ -275,7 +275,7 @@ func TestReconcile(t *testing.T) {
275275 {Name: ghost, Visibility: domain.VisibilityPublic},
276276 {Name: kept, Visibility: domain.VisibilityPublic},
277277 }, nil)
278- f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now}).Return(nil)
278+ f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}).Return(nil)
279279
280280 rep, err := f.repos.Reconcile(ctx)
281281 require.NoError(t, err)
@@ -55,7 +55,7 @@ func TestCreate(t *testing.T) {
55 t.Run("success is private by default", func(t *testing.T) {55 t.Run("success is private by default", func(t *testing.T) {
56 f := newFixture(t)56 f := newFixture(t)
57 n := name(t, "tools")57 n := name(t, "tools")
58- want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now}58+ want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
59 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)59 f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
60 f.meta.EXPECT().Create(ctx, want).Return(nil)60 f.meta.EXPECT().Create(ctx, want).Return(nil)
61 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)61 f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
@@ -275,7 +275,7 @@ func TestReconcile(t *testing.T) {
275 {Name: ghost, Visibility: domain.VisibilityPublic},275 {Name: ghost, Visibility: domain.VisibilityPublic},
276 {Name: kept, Visibility: domain.VisibilityPublic},276 {Name: kept, Visibility: domain.VisibilityPublic},
277 }, nil)277 }, nil)
278- f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now}).Return(nil)278+ f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}).Return(nil)
279 279
280 rep, err := f.repos.Reconcile(ctx)280 rep, err := f.repos.Reconcile(ctx)
281 require.NoError(t, err)281 require.NoError(t, err)
M internal/app/repos_write.go +27 -4
@@ -56,10 +56,15 @@ type UpdateParams struct {
5656 Description *string
5757 Visibility *domain.Visibility
5858 DefaultBranch *string
59+ // AllowedMergeModes replaces the allowed merge modes; it must hold at
60+ // least one known mode.
61+ AllowedMergeModes *[]domain.MergeMode
62+ // FailingChecksBlock replaces the setting of that name.
63+ FailingChecksBlock *bool
5964 }
6065
61-// Update changes the description, the visibility and the default branch of a
62-// repository, and publishes RepoUpdated. The principal needs repo:admin on it
66+// Update changes the description, the visibility, the default branch and the
67+// merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
6368 // (see adminAccess for what the others learn). At least one field must be set,
6469 // else domain.ErrInvalid. A default branch that does not exist gives
6570 // domain.ErrPreconditionFailed; nothing is written before every check passed.
@@ -75,9 +80,17 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
7580 return domain.Repo{}, err
7681 }
7782 before := repo
78- if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil {
83+ if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&
84+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
7985 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
8086 }
87+ if params.AllowedMergeModes != nil {
88+ modes, err := domain.NormalizeMergeModes(*params.AllowedMergeModes)
89+ if err != nil {
90+ return domain.Repo{}, err
91+ }
92+ params.AllowedMergeModes = &modes
93+ }
8194 if params.Description != nil {
8295 if err := validateDescription(*params.Description); err != nil {
8396 return domain.Repo{}, err
@@ -108,7 +121,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
108121 // branch was checked above, so the remaining causes are a vanished
109122 // repository or an I/O failure.
110123 metaDone := false
111- if params.Description != nil || params.Visibility != nil {
124+ if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
112125 if repo, err = r.updateMeta(ctx, name, params); err != nil {
113126 return domain.Repo{}, err
114127 }
@@ -130,6 +143,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
130143 Bool("description", params.Description != nil).
131144 Bool("visibility", params.Visibility != nil).
132145 Bool("default_branch", params.DefaultBranch != nil).
146+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
133147 Str("principal", p.Label()).Str("token_id", p.TokenID()).
134148 Msg("repository updated")
135149 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -156,6 +170,15 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params Upd
156170 if params.Visibility != nil {
157171 repo.Visibility = *params.Visibility
158172 }
173+ if repo.Settings.IsZero() {
174+ repo.Settings = domain.DefaultRepoSettings()
175+ }
176+ if params.AllowedMergeModes != nil {
177+ repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
178+ }
179+ if params.FailingChecksBlock != nil {
180+ repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
181+ }
159182 err = r.meta.Update(ctx, repo)
160183 if errors.Is(err, domain.ErrConflict) {
161184 continue
@@ -56,10 +56,15 @@ type UpdateParams struct {
56 Description *string56 Description *string
57 Visibility *domain.Visibility57 Visibility *domain.Visibility
58 DefaultBranch *string58 DefaultBranch *string
59+ // AllowedMergeModes replaces the allowed merge modes; it must hold at
60+ // least one known mode.
61+ AllowedMergeModes *[]domain.MergeMode
62+ // FailingChecksBlock replaces the setting of that name.
63+ FailingChecksBlock *bool
59 }64 }
60 65
61-// Update changes the description, the visibility and the default branch of a66+// Update changes the description, the visibility, the default branch and the
62-// repository, and publishes RepoUpdated. The principal needs repo:admin on it67+// merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
63 // (see adminAccess for what the others learn). At least one field must be set,68 // (see adminAccess for what the others learn). At least one field must be set,
64 // else domain.ErrInvalid. A default branch that does not exist gives69 // else domain.ErrInvalid. A default branch that does not exist gives
65 // domain.ErrPreconditionFailed; nothing is written before every check passed.70 // domain.ErrPreconditionFailed; nothing is written before every check passed.
@@ -75,9 +80,17 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
75 return domain.Repo{}, err80 return domain.Repo{}, err
76 }81 }
77 before := repo82 before := repo
78- if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil {83+ if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&
84+ params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
79 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)85 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
80 }86 }
87+ if params.AllowedMergeModes != nil {
88+ modes, err := domain.NormalizeMergeModes(*params.AllowedMergeModes)
89+ if err != nil {
90+ return domain.Repo{}, err
91+ }
92+ params.AllowedMergeModes = &modes
93+ }
81 if params.Description != nil {94 if params.Description != nil {
82 if err := validateDescription(*params.Description); err != nil {95 if err := validateDescription(*params.Description); err != nil {
83 return domain.Repo{}, err96 return domain.Repo{}, err
@@ -108,7 +121,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
108 // branch was checked above, so the remaining causes are a vanished121 // branch was checked above, so the remaining causes are a vanished
109 // repository or an I/O failure.122 // repository or an I/O failure.
110 metaDone := false123 metaDone := false
111- if params.Description != nil || params.Visibility != nil {124+ if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
112 if repo, err = r.updateMeta(ctx, name, params); err != nil {125 if repo, err = r.updateMeta(ctx, name, params); err != nil {
113 return domain.Repo{}, err126 return domain.Repo{}, err
114 }127 }
@@ -130,6 +143,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
130 Bool("description", params.Description != nil).143 Bool("description", params.Description != nil).
131 Bool("visibility", params.Visibility != nil).144 Bool("visibility", params.Visibility != nil).
132 Bool("default_branch", params.DefaultBranch != nil).145 Bool("default_branch", params.DefaultBranch != nil).
146+ Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
133 Str("principal", p.Label()).Str("token_id", p.TokenID()).147 Str("principal", p.Label()).Str("token_id", p.TokenID()).
134 Msg("repository updated")148 Msg("repository updated")
135 r.bus.Publish(ctx, domain.RepoUpdated{149 r.bus.Publish(ctx, domain.RepoUpdated{
@@ -156,6 +170,15 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params Upd
156 if params.Visibility != nil {170 if params.Visibility != nil {
157 repo.Visibility = *params.Visibility171 repo.Visibility = *params.Visibility
158 }172 }
173+ if repo.Settings.IsZero() {
174+ repo.Settings = domain.DefaultRepoSettings()
175+ }
176+ if params.AllowedMergeModes != nil {
177+ repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
178+ }
179+ if params.FailingChecksBlock != nil {
180+ repo.Settings.FailingChecksBlock = *params.FailingChecksBlock
181+ }
159 err = r.meta.Update(ctx, repo)182 err = r.meta.Update(ctx, repo)
160 if errors.Is(err, domain.ErrConflict) {183 if errors.Is(err, domain.ErrConflict) {
161 continue184 continue
M internal/app/repos_write_test.go +1 -1
@@ -27,7 +27,7 @@ func TestUpdate(t *testing.T) {
2727 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
2828 f := newFixture(t)
2929 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
30- want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"}
30+ want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
3131 f.meta.EXPECT().Update(ctx, want).Return(nil)
3232 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
3333 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
@@ -27,7 +27,7 @@ func TestUpdate(t *testing.T) {
27 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {27 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
28 f := newFixture(t)28 f := newFixture(t)
29 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()29 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
30- want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new"}30+ want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
31 f.meta.EXPECT().Update(ctx, want).Return(nil)31 f.meta.EXPECT().Update(ctx, want).Return(nil)
32 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{32 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
33 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,33 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
A internal/app/signing.go +27 -0
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bnema.dev/ermite/internal/domain"
7+ "bnema.dev/ermite/internal/ports"
8+)
9+
10+// SigningKey is the public side of the key that signs the commits ermite
11+// creates (ADR 0022).
12+type SigningKey struct {
13+ signer ports.CommitSigner
14+ committer domain.Identity
15+}
16+
17+// NewSigningKey builds the use case over the signer and the identity recorded
18+// as the committer of the signed commits.
19+func NewSigningKey(signer ports.CommitSigner, committer domain.Identity) *SigningKey {
20+ return &SigningKey{signer: signer, committer: committer}
21+}
22+
23+// Info returns what a verifier needs. It is public: no access rule applies,
24+// and the private key never leaves the signer.
25+func (s *SigningKey) Info() domain.SigningKeyInfo {
26+ return domain.SigningKeyInfo{Key: s.signer.PublicKey(), Committer: s.committer}
27+}
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bnema.dev/ermite/internal/domain"
7+ "bnema.dev/ermite/internal/ports"
8+)
9+
10+// SigningKey is the public side of the key that signs the commits ermite
11+// creates (ADR 0022).
12+type SigningKey struct {
13+ signer ports.CommitSigner
14+ committer domain.Identity
15+}
16+
17+// NewSigningKey builds the use case over the signer and the identity recorded
18+// as the committer of the signed commits.
19+func NewSigningKey(signer ports.CommitSigner, committer domain.Identity) *SigningKey {
20+ return &SigningKey{signer: signer, committer: committer}
21+}
22+
23+// Info returns what a verifier needs. It is public: no access rule applies,
24+// and the private key never leaves the signer.
25+func (s *SigningKey) Info() domain.SigningKeyInfo {
26+ return domain.SigningKeyInfo{Key: s.signer.PublicKey(), Committer: s.committer}
27+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close