1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "encoding/base64"
8 "errors"
9 "fmt"
10 "math"
11 "runtime/debug"
12 "slices"
13 "strconv"
14 "strings"
15 "sync"
16
17 "github.com/bnema/zerowrap"
18
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21)
22
23// MaxMergeRequestCommits is the most commits GetMergeRequest returns.
24const MaxMergeRequestCommits = ports.MaxMergeRequestCommits
25
26// maxWriteRetries bounds the retries of a write that loses a race with another
27// write of the same merge request. Each lost race means another writer won, so
28// a writer loses at most as many times as there are other writers at once; the
29// retry is a read and a write of ermite-kv, cheap enough for a bound this high.
30const maxWriteRetries = 32
31
32// MaxCommentsPerMergeRequest is the most comments a merge request holds. Past
33// it AddComment fails with domain.ErrResourceExhausted (resource_exhausted in
34// the API). Comments are never deleted, so the limit counts the ones ever added.
35const MaxCommentsPerMergeRequest = 1000
36
37const mrTokenPrefix = "mp1:"
38
39// MergeRequests are the merge request use cases (ADR 0017): review, comment,
40// close and merge a branch. Reading needs the access of reading the
41// repository: a repository the principal may not read, a missing one and an
42// unknown number all answer domain.ErrNotFound. Writing needs mr:write, and
43// merging mr:merge, on the repository (see scopeAccess), and that scope also
44// makes a private repository visible for the action (see writable).
45type MergeRequests struct {
46 links ports.IssueLinkTrigger
47 meta ports.RepoMetaStore
48 store ports.MergeRequestStore
49 repos ports.GitRepos
50 git ports.MergeGit
51 signer ports.CommitSigner
52 committer domain.Identity
53 bus ports.EventBus
54 clock ports.Clock
55 maint ports.MaintenanceScheduler
56 ci ports.WorkflowTrigger // nil: no CI
57 checks ports.CIChecksReader // nil: no CI, so no check blocks a merge
58 pulls ports.PullRequestTrigger // nil: no pull_request workflows
59 tips ports.CIRefTipStore // nil: who merges is not recorded
60 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
61 mergeab MergeabilityHook // nil: no mergeability
62 autoMerge ports.AutoMergeTrigger
63 mirror ports.MirrorTrigger // nil: no outbound mirror
64
65 mu sync.Mutex
66 locks map[domain.RepoName]chan struct{}
67
68 // after tracks the post-merge side effects running off the request path.
69 after sync.WaitGroup
70 // afterGate, when set by a test, runs first in the post-merge goroutine.
71 afterGate func()
72}
73
74var (
75 _ ports.MergeRequestService = (*MergeRequests)(nil)
76 _ ports.PullGate = (*MergeRequests)(nil)
77)
78
79// MergeRequestsConfig holds what the merge request use cases need. Every field
80// is required.
81type MergeRequestsConfig struct {
82 Links ports.IssueLinkTrigger // Optional derived issue links.
83 Meta ports.RepoMetaStore
84 Store ports.MergeRequestStore
85 // Repos gives the default branch of a repository.
86 Repos ports.GitRepos
87 Git ports.MergeGit
88 // Signer signs the squash commits.
89 Signer ports.CommitSigner
90 // Committer is recorded as the committer of the squash commits.
91 Committer domain.Identity
92 Bus ports.EventBus
93 Clock ports.Clock
94 // Maint queues the maintenance that follows a merge, as it does a push.
95 Maint ports.MaintenanceScheduler
96 // CI starts the workflows a merge triggers (GitHub runs "push" workflows
97 // on a merge) and Checks gives the checks the merge rule reads. Both are
98 // optional: without them there is no CI.
99 CI ports.WorkflowTrigger
100 Checks ports.CIChecksReader
101 // PullRequests starts the pull_request workflows when a request is opened,
102 // closed or merged (the push of its source branch reaches CI instead).
103 // Optional.
104 PullRequests ports.PullRequestTrigger
105 // RefTips records who moves the target branch (and deletes the record of a
106 // deleted source branch) before the merge does, as a push does
107 // (domain.CIRefTip). Optional.
108 RefTips ports.CIRefTipStore
109 // Mergeability is queued when a request is opened, closed or merged and
110 // read by Get and List. Optional: without it Mergeability stays zero.
111 Mergeability MergeabilityHook
112}
113
114// NewMergeRequests builds the merge request use cases.
115func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
116 return &MergeRequests{
117 links: cfg.Links, meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
118 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
119 locks: make(map[domain.RepoName]chan struct{}),
120 }
121}
122
123// WithMirror makes a merge notify outbound mirrors. Call before the first merge.
124func (m *MergeRequests) WithMirror(t ports.MirrorTrigger) *MergeRequests {
125 m.mirror = t
126 return m
127}
128
129// WithVerifier makes GetMergeRequest fill the signature state of the commits
130// it lists (ADR 0030).
131func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
132 m.verifier = v
133 return m
134}
135
136// Wait waits for the post-merge side effects still running (source branch
137// deletion, events, CI and mergeability hooks), or for ctx to end. Call it
138// before closing what they use.
139func (m *MergeRequests) Wait(ctx context.Context) {
140 done := make(chan struct{})
141 go func() {
142 m.after.Wait()
143 close(done)
144 }()
145 select {
146 case <-done:
147 case <-ctx.Done():
148 }
149}
150
151// lockRepo takes the merge lock of a repository, waiting until ctx ends. The
152// merges of one repository, and the creation of its merge requests, run one at
153// a time. The returned function releases the lock.
154// Lock order is merge lock → store (meta.Mutate / kv). Nothing holding a
155// store transaction or a CI lock may take this lock. MergeRequests never
156// calls Repos.Update.
157func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
158 m.mu.Lock()
159 ch, ok := m.locks[name]
160 if !ok {
161 ch = make(chan struct{}, 1)
162 m.locks[name] = ch
163 }
164 m.mu.Unlock()
165 select {
166 case ch <- struct{}{}:
167 return func() { <-ch }, nil
168 case <-ctx.Done():
169 return nil, ctx.Err()
170 }
171}
172
173// readable looks the repository up and applies the read rule: reading merge
174// requests needs the access of reading the repository (canRead). repo:write
175// alone does not read them, as it does not fetch a private repository either.
176func (m *MergeRequests) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
177 repo, err := lookupRepo(ctx, m.meta, name)
178 if err != nil {
179 return domain.Repo{}, err
180 }
181 if !canRead(p, repo) {
182 return domain.Repo{}, domain.ErrNotFound
183 }
184 return repo, nil
185}
186
187// writable looks the repository up for an action that needs a merge request
188// scope. A principal whose token holds that scope on the repository sees the
189// repository for this action even without repo:read, so that the agent preset
190// (repo:write, mr:write) can open a request on the private repository it just
191// pushed. A principal with neither repo:read nor the scope, and an unknown
192// repository, still get domain.ErrNotFound; one that can read the repository
193// but lacks the scope gets the error of scopeAccess.
194func (m *MergeRequests) writable(ctx context.Context, p domain.Principal, name domain.RepoName, scope domain.Scope) (domain.Repo, error) {
195 repo, err := lookupRepo(ctx, m.meta, name)
196 if err != nil {
197 return domain.Repo{}, err
198 }
199 if !canRead(p, repo) && !p.Allows(scope, name) {
200 return domain.Repo{}, domain.ErrNotFound
201 }
202 if err := scopeAccess(p, repo, scope); err != nil {
203 return domain.Repo{}, err
204 }
205 return repo, nil
206}
207
208// withHead fills HeadID of an open request from its source branch: zero when
209// the branch is gone. Merged and closed requests keep the head they stored.
210func (m *MergeRequests) withHead(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
211 if !mr.IsOpen() {
212 return mr, nil
213 }
214 head, err := m.git.BranchHead(ctx, mr.Repo, mr.Source)
215 switch {
216 case errors.Is(err, domain.ErrNotFound):
217 mr.HeadID = domain.ObjectID{}
218 case err != nil:
219 return domain.MergeRequest{}, err
220 default:
221 mr.HeadID = head
222 }
223 return mr, nil
224}
225
226// branchOrZero returns the head of a branch, the zero id when it is gone.
227func (m *MergeRequests) branchOrZero(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
228 id, err := m.git.BranchHead(ctx, repo, branch)
229 if errors.Is(err, domain.ErrNotFound) {
230 return domain.ObjectID{}, nil
231 }
232 return id, err
233}
234
235// Create opens a merge request and publishes MergeRequestOpened. The principal
236// needs mr:write. A request that breaks a rule of ADR 0017 (the texts, a
237// branch that does not exist, source and target the same) is rejected whole
238// with an *domain.InvalidFieldsError naming the fields. An open request with
239// the same source and target gives domain.ErrAlreadyExists.
240func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error) {
241 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
242 if err != nil {
243 return domain.MergeRequest{}, err
244 }
245 var v domain.Violations
246 title, err := domain.ParseMRTitle(params.Title)
247 if err != nil {
248 v.AddErr("title", err)
249 }
250 desc, err := domain.ParsePlainText(params.Description)
251 if err != nil {
252 v.AddErr("description", err)
253 }
254 // Under the lock, so that two requests for the same pair cannot both pass
255 // the check for an existing one, and a merge that deletes its source
256 // cannot remove a branch between its check here and the creation.
257 unlock, err := m.lockRepo(ctx, name)
258 if err != nil {
259 return domain.MergeRequest{}, err
260 }
261 defer unlock()
262 source, target, err := m.checkBranches(ctx, name, params, &v)
263 if err != nil {
264 return domain.MergeRequest{}, err
265 }
266 if err := v.Err(); err != nil {
267 return domain.MergeRequest{}, err
268 }
269 existing, err := m.store.List(ctx, name)
270 if err != nil {
271 return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err)
272 }
273 for _, e := range existing {
274 if e.IsOpen() && e.Source == source && e.Target == target {
275 return domain.MergeRequest{}, fmt.Errorf("%w: !%d already merges %s into %s", domain.ErrAlreadyExists, e.Number, source, target)
276 }
277 }
278
279 now := m.clock.Now().UTC()
280 mr, err := m.store.Create(ctx, domain.MergeRequest{
281 Repo: name, Title: title, Description: desc, Source: source, Target: target,
282 State: domain.MRStateOpen, Author: domain.AuthorName(p), CreatedAt: now, UpdatedAt: now,
283 })
284 if err != nil {
285 return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err)
286 }
287 log := zerowrap.FromCtx(ctx)
288 log.Info().Str("repo", name.String()).Int64("number", mr.Number).
289 Str("source", source).Str("target", target).
290 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request opened")
291 mr, err = m.withHead(ctx, mr)
292 if err != nil {
293 return domain.MergeRequest{}, err
294 }
295 recordActivity(ctx, m.meta, repo.Name, now)
296 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
297 if m.links != nil {
298 m.links.OnMergeRequestChanged(ctx, repo, mr.Number)
299 }
300 if m.mergeab != nil {
301 m.mergeab.OnOpened(ctx, mr)
302 }
303 if m.pulls != nil {
304 // The commits the opening is for are the ones of now.
305 base, _ := m.branchOrZero(ctx, name, target)
306 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullOpened, Head: mr.HeadID, Base: base}, p)
307 }
308 return mr, nil
309}
310
311// checkBranches validates the branches of a new request and returns them, with
312// the default branch filled in for the target.
313//
314// Only a branch that does not exist is a violation. Any other failure to read
315// Git is returned as an error, so that it is logged and masked, not shown in
316// the field violations.
317func (m *MergeRequests) checkBranches(ctx context.Context, name domain.RepoName, params ports.CreateMergeRequestParams, v *domain.Violations) (source, target string, err error) {
318 source, target = params.Source, params.Target
319 if source == "" {
320 v.Add("source_branch", "is required")
321 } else if _, err := domain.ParseBranchName(source); err != nil {
322 v.Add("source_branch", "is not a valid branch name")
323 source = ""
324 }
325 if target == "" {
326 def, exists, err := m.repos.DefaultBranch(ctx, name)
327 switch {
328 case err != nil: // includes domain.ErrNotFound: the repository is gone
329 return "", "", fmt.Errorf("read the default branch of %q: %w", name, err)
330 case !exists:
331 v.Add("target_branch", fmt.Sprintf("is empty and the default branch %q does not exist", def))
332 default:
333 target = def
334 }
335 } else if _, err := domain.ParseBranchName(target); err != nil {
336 v.Add("target_branch", "is not a valid branch name")
337 target = ""
338 }
339 if source != "" && source == target {
340 v.Add("target_branch", "must differ from source_branch")
341 return source, target, nil
342 }
343 for _, b := range []struct{ field, name string }{{"source_branch", source}, {"target_branch", target}} {
344 if b.name == "" {
345 continue
346 }
347 _, err := m.git.BranchHead(ctx, name, b.name)
348 if errors.Is(err, domain.ErrNotFound) {
349 v.Add(b.field, fmt.Sprintf("branch %q does not exist", b.name))
350 } else if err != nil {
351 return "", "", fmt.Errorf("read branch %q of %q: %w", b.name, name, err)
352 }
353 }
354 return source, target, nil
355}
356
357// List returns a page of the merge requests of a repository, highest number
358// first. The open ones carry the head of their source branch. The page token
359// names the last number of the previous page, so requests created between two
360// pages never shift the others.
361func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListMergeRequestsParams) (ports.MergeRequestPage, error) {
362 size, err := pageSize(params.PageSize)
363 if err != nil {
364 return ports.MergeRequestPage{}, err
365 }
366 after, err := decodeMRToken(params.PageToken)
367 if err != nil {
368 return ports.MergeRequestPage{}, err
369 }
370 if _, err := m.readable(ctx, p, name); err != nil {
371 return ports.MergeRequestPage{}, err
372 }
373 all, err := m.store.List(ctx, name)
374 if err != nil {
375 return ports.MergeRequestPage{}, fmt.Errorf("list merge requests of %q: %w", name, err)
376 }
377 var page ports.MergeRequestPage
378 page.MergeRequests, page.NextPageToken = pageAfter(all, size,
379 func(mr domain.MergeRequest) bool {
380 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
381 },
382 encodeMRToken)
383 tips := map[string]domain.ObjectID{} // one BranchHead per distinct target
384 for i, mr := range page.MergeRequests {
385 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
386 return ports.MergeRequestPage{}, err
387 }
388 if m.mergeab == nil || !mr.IsOpen() {
389 continue
390 }
391 tip, ok := tips[mr.Target]
392 if !ok {
393 if tip, err = m.branchOrZero(ctx, name, mr.Target); err != nil {
394 return ports.MergeRequestPage{}, err
395 }
396 tips[mr.Target] = tip
397 }
398 if page.Mergeability == nil {
399 page.Mergeability = map[int64]domain.Mergeability{}
400 }
401 page.Mergeability[mr.Number] = m.mergeab.Read(ctx, page.MergeRequests[i], tip)
402 }
403 return page, nil
404}
405
406// MarkReady implements ports.MergeRequestService: the title of an open draft
407// loses its draft prefix, so the request can be merged. The principal needs
408// mr:write. A request that is not open, or not a draft, gives
409// domain.ErrPreconditionFailed.
410func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
411 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
412 if err != nil {
413 return domain.MergeRequest{}, err
414 }
415 unlock, err := m.lockRepo(ctx, name) // a merge sees the request as it is after
416 if err != nil {
417 return domain.MergeRequest{}, err
418 }
419 defer unlock()
420 for range maxWriteRetries {
421 mr, err := m.store.Get(ctx, name, number)
422 if err != nil {
423 return domain.MergeRequest{}, err
424 }
425 if !mr.IsOpen() {
426 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
427 }
428 title, ok := domain.ReadyTitle(mr.Title)
429 if !ok {
430 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is not a draft", domain.ErrPreconditionFailed, number)
431 }
432 mr.Title, mr.UpdatedAt = title, m.clock.Now().UTC()
433 err = m.store.Update(ctx, mr)
434 if errors.Is(err, domain.ErrConflict) {
435 continue
436 }
437 if err != nil {
438 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
439 }
440 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
441 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
442 if m.links != nil {
443 m.links.OnMergeRequestChanged(ctx, repo, number)
444 }
445 return m.withHead(ctx, mr)
446 }
447 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
448}
449
450// CountOpen implements ports.MergeRequestService.
451func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
452 if _, err := m.readable(ctx, p, name); err != nil {
453 return 0, err
454 }
455 n, err := m.store.CountOpen(ctx, name)
456 if err != nil {
457 return 0, fmt.Errorf("count open merge requests of %q: %w", name, err)
458 }
459 return n, nil
460}
461
462// Mergeability implements ports.MergeRequestService: the mergeability of an
463// open merge request for the current tips, queued when there is no up-to-date
464// result. It is the zero value for a request that is not open, or without a
465// mergeability service.
466func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
467 _, mr, err := m.get(ctx, p, name, number)
468 if err != nil {
469 return domain.Mergeability{}, err
470 }
471 if m.mergeab == nil || !mr.IsOpen() {
472 return domain.Mergeability{}, nil
473 }
474 if mr, err = m.withHead(ctx, mr); err != nil {
475 return domain.Mergeability{}, err
476 }
477 tip, err := m.branchOrZero(ctx, name, mr.Target)
478 if err != nil {
479 return domain.Mergeability{}, err
480 }
481 return m.mergeab.Read(ctx, mr, tip), nil
482}
483
484func encodeMRToken(last domain.MergeRequest) string {
485 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
486}
487
488// decodeMRToken returns 0 for the empty token.
489func decodeMRToken(tok string) (int64, error) {
490 if tok == "" {
491 return 0, nil
492 }
493 raw, ok := decodeToken(tok, mrTokenPrefix)
494 if ok {
495 if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 {
496 return n, nil
497 }
498 }
499 return 0, fmt.Errorf("%w: merge requests", domain.ErrInvalidPageToken)
500}
501
502// get reads a merge request of a repository the principal can read.
503func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Repo, domain.MergeRequest, error) {
504 repo, err := m.readable(ctx, p, name)
505 if err != nil {
506 return domain.Repo{}, domain.MergeRequest{}, err
507 }
508 mr, err := m.store.Get(ctx, name, number)
509 if err != nil {
510 return domain.Repo{}, domain.MergeRequest{}, err
511 }
512 return repo, mr, nil
513}
514
515// windowLimits is the diff limits of a read: nil gives
516// domain.DefaultDiffLimits (the API contract, generated files read); a window
517// lists every file (MaxFiles 0), reads From..To and skips generated files; a
518// Full window reads the one file From with domain.OneFileDiffLimits. An invalid
519// window (negative, To < From, Full without To == From+1) is domain.ErrInvalid.
520func windowLimits(w *ports.FileWindow) (domain.DiffLimits, error) {
521 if w == nil {
522 return domain.DefaultDiffLimits(), nil
523 }
524 if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) || (w.Full && w.To != w.From+1) {
525 return domain.DiffLimits{}, fmt.Errorf("%w: file window [%d, %d) full=%t", domain.ErrInvalid, w.From, w.To, w.Full)
526 }
527 if w.Full {
528 return domain.OneFileDiffLimits(w.From), nil
529 }
530 l := domain.DefaultDiffLimits()
531 l.MaxFiles, l.From, l.To, l.SkipGenerated = 0, w.From, w.To, true
532 return l, nil
533}
534
535// Get returns a merge request with its commits, its diff, its comments and the
536// read position. The diff compares the merge base of the target and the head
537// with the head ("target...head"), within domain.DefaultDiffLimits. With
538// opts.SinceLastRead it compares the head recorded by MarkRead with the
539// current head instead, which works after a force-push as long as the recorded
540// commit still exists; when none was recorded the diff is the default one, and
541// when the recorded commit is gone too, with LastReadLost set. A merged or
542// closed request keeps the head and the base it had.
543//
544// opts.Files lists every changed file and reads the hunks of a window of them
545// only; opts.Base and opts.Head make Get fail with domain.ErrConflict when the
546// diff is no longer the one the caller shows (before any diff is read);
547// opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.
548func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
549 limits, err := windowLimits(opts.Files)
550 if err != nil {
551 return ports.MergeRequestDetail{}, err
552 }
553 _, mr, err := m.get(ctx, p, name, number)
554 if err != nil {
555 return ports.MergeRequestDetail{}, err
556 }
557 if mr, err = m.withHead(ctx, mr); err != nil {
558 return ports.MergeRequestDetail{}, err
559 }
560 if !opts.Head.IsZero() && opts.Head != mr.HeadID {
561 return ports.MergeRequestDetail{}, fmt.Errorf("%w: the head of !%d moved to %s", domain.ErrConflict, number, mr.HeadID)
562 }
563 // checkBase fails when the caller shows another diff base than base.
564 checkBase := func(base domain.ObjectID) error {
565 if !opts.Base.IsZero() && opts.Base != base {
566 return fmt.Errorf("%w: the diff base of !%d is now %s", domain.ErrConflict, number, base)
567 }
568 return nil
569 }
570 d := ports.MergeRequestDetail{MergeRequest: mr}
571 if m.mergeab != nil && mr.IsOpen() {
572 tip, err := m.branchOrZero(ctx, name, mr.Target)
573 if err != nil {
574 return ports.MergeRequestDetail{}, err
575 }
576 d.Mergeability = m.mergeab.Read(ctx, mr, tip)
577 }
578 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
579 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
580 }
581 d.Verdicts = domain.CurrentVerdicts(d.Comments, mr.HeadID)
582 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
583 return ports.MergeRequestDetail{}, err
584 }
585 d.HeadUnread = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID
586
587 exclude, base, err := m.rangeOf(ctx, mr)
588 if err != nil {
589 return ports.MergeRequestDetail{}, err
590 }
591 head := mr.HeadID
592 if head.IsZero() {
593 return d, checkBase(domain.ObjectID{}) // the source branch is gone: no commits, no diff
594 }
595 if !mr.IsOpen() {
596 // After a squash the reviewed commits are only reachable from the
597 // source branch; once it is deleted, gc may prune them.
598 switch ok, err := m.git.HasCommit(ctx, name, head); {
599 case err != nil:
600 return ports.MergeRequestDetail{}, err
601 case !ok:
602 // The reviewed commits are gone: no commits, no diff.
603 if err := checkBase(domain.ObjectID{}); err != nil {
604 return ports.MergeRequestDetail{}, err
605 }
606 return d, nil
607 }
608 }
609 if !opts.DiffOnly {
610 if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
611 return ports.MergeRequestDetail{}, err
612 }
613 verifyCommits(ctx, m.verifier, name, d.Commits)
614 if !d.LastReadID.IsZero() {
615 for i, c := range d.Commits {
616 if c.ID == d.LastReadID {
617 d.NewCommits = i
618 break
619 }
620 }
621 }
622 }
623 if opts.SinceLastRead && !d.LastReadID.IsZero() {
624 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
625 case err != nil:
626 return ports.MergeRequestDetail{}, err
627 case ok:
628 base = d.LastReadID
629 default:
630 d.LastReadLost = true
631 }
632 }
633 if err := checkBase(base); err != nil {
634 return ports.MergeRequestDetail{}, err
635 }
636 if base.IsZero() {
637 return d, nil
638 }
639 d.DiffBaseID = base
640 if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil {
641 return ports.MergeRequestDetail{}, err
642 }
643 // The page of a client without scripts reads one collapsed file in full.
644 if i := opts.Load; i != nil && *i >= 0 && *i < len(d.Diff.Files) && d.Diff.Files[*i].Omitted.Collapsed() {
645 _, one, err := m.git.DiffCommits(ctx, name, base, head, domain.OneFileDiffLimits(*i))
646 if err != nil {
647 return ports.MergeRequestDetail{}, err
648 }
649 if *i < len(one.Files) {
650 d.Diff.Files[*i] = one.Files[*i]
651 }
652 }
653 return d, nil
654}
655
656// rangeOf returns what the review of a request compares: the commit whose
657// history the request's commits exclude, and the base of its diff. For an open
658// request they are the tip of the target and the merge base of the target and
659// the head; both are zero when the target is gone or shares no history with
660// the head. A merged or closed request keeps the base it stored for both.
661func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
662 return mrRange(ctx, m.git, mr)
663}
664
665func mrRange(ctx context.Context, git ports.MergeGit, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
666 if !mr.IsOpen() {
667 return mr.BaseID, mr.BaseID, nil
668 }
669 if mr.HeadID.IsZero() {
670 return domain.ObjectID{}, domain.ObjectID{}, nil
671 }
672 tip, err := git.BranchHead(ctx, mr.Repo, mr.Target)
673 if errors.Is(err, domain.ErrNotFound) {
674 return domain.ObjectID{}, domain.ObjectID{}, nil
675 }
676 if err != nil || tip.IsZero() {
677 return domain.ObjectID{}, domain.ObjectID{}, err
678 }
679 base, err = git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
680 if errors.Is(err, domain.ErrNotFound) {
681 return tip, domain.ObjectID{}, nil
682 }
683 return tip, base, err
684}
685
686// AddComment adds a comment and publishes CommentAdded. The principal needs
687// mr:write. The text must follow the rules of ADR 0032 and not be blank unless carrying a verdict; a
688// comment on a line must name a line of the current diff of the request (see
689// domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request
690// that is not open gives domain.ErrPreconditionFailed, and one that already
691// holds MaxCommentsPerMergeRequest comments domain.ErrResourceExhausted.
692func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.AddCommentParams) (domain.Comment, error) {
693 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
694 if err != nil {
695 return domain.Comment{}, err
696 }
697 mr, err := m.store.Get(ctx, name, number)
698 if err != nil {
699 return domain.Comment{}, err
700 }
701 var v domain.Violations
702 text, err := domain.ParsePlainText(params.Text)
703 switch {
704 case err != nil:
705 v.AddErr("text", err)
706 case text.IsBlank() && params.Verdict == domain.VerdictNone:
707 v.Add("text", "is required")
708 }
709 general := params.Path == ""
710 switch {
711 case general && (params.Line != 0 || params.Side != domain.SideNone || params.StartLine != 0):
712 v.Add("path", "is required with line and side")
713 case !general && params.Line < 1:
714 v.Add("line", "must be 1 or more with a path")
715 case !general && params.Side != domain.SideOld && params.Side != domain.SideNew:
716 v.Add("side", "is required with a path")
717 }
718 if params.StartLine != 0 && (params.StartLine < 1 || params.StartLine >= params.Line) {
719 v.Add("start_line", "must be 1 or more and less than line")
720 }
721 if params.Verdict > domain.VerdictChangesRequested {
722 v.Add("verdict", "is unknown")
723 }
724 if params.ReplyTo != 0 {
725 if params.Path != "" || params.Line != 0 || params.StartLine != 0 || params.Side != domain.SideNone || params.Verdict != domain.VerdictNone {
726 v.Add("reply_to", "a reply cannot specify an anchor or verdict")
727 }
728 }
729 if err := v.Err(); err != nil {
730 return domain.Comment{}, err
731 }
732 if params.ReplyTo != 0 {
733 root, err := m.replyRoot(ctx, name, number, params.ReplyTo)
734 if err != nil {
735 return domain.Comment{}, err
736 }
737 params.Path, params.Line, params.StartLine, params.Side = root.Path, root.Line, root.StartLine, root.Side
738 }
739 if params.Verdict != domain.VerdictNone {
740 if err := scopeAccess(p, repo, domain.ScopeMRReview); err != nil {
741 return domain.Comment{}, err
742 }
743 }
744 if !mr.IsOpen() {
745 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
746 }
747
748 if mr, err = m.withHead(ctx, mr); err != nil {
749 return domain.Comment{}, err
750 }
751 if !general && params.ReplyTo == 0 {
752 if err := m.checkLine(ctx, mr, params); err != nil {
753 return domain.Comment{}, err
754 }
755 }
756
757 var c domain.Comment
758 for range maxWriteRetries {
759 if mr.CommentSeq >= MaxCommentsPerMergeRequest {
760 return domain.Comment{}, fmt.Errorf("%w: !%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerMergeRequest)
761 }
762 c = domain.Comment{
763 ID: mr.CommentSeq + 1, Author: domain.AuthorName(p), Path: params.Path, Line: params.Line, Side: params.Side,
764 ReplyTo: params.ReplyTo, StartLine: params.StartLine, Verdict: params.Verdict, Resolved: params.Verdict == domain.VerdictApprove,
765 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
766 }
767 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
768 err = m.store.SaveComment(ctx, mr, c)
769 if !errors.Is(err, domain.ErrConflict) {
770 break
771 }
772 // Another write got in between: read the request again.
773 fresh, gerr := m.store.Get(ctx, name, number)
774 if gerr != nil {
775 return domain.Comment{}, gerr
776 }
777 if !fresh.IsOpen() {
778 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
779 }
780 if params.ReplyTo != 0 {
781 // The root may have been deleted by the write that got in between.
782 if _, rerr := m.replyRoot(ctx, name, number, params.ReplyTo); rerr != nil {
783 return domain.Comment{}, rerr
784 }
785 }
786 fresh.HeadID = mr.HeadID
787 mr = fresh
788 }
789 if err != nil {
790 return domain.Comment{}, fmt.Errorf("save comment on !%d of %q: %w", number, name, err)
791 }
792 log := zerowrap.FromCtx(ctx)
793 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
794 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
795 recordActivity(ctx, m.meta, name, c.CreatedAt)
796 m.bus.Publish(ctx, domain.CommentAdded{
797 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
798 })
799 if m.links != nil {
800 m.links.OnMergeRequestChanged(ctx, repo, number)
801 }
802 return c, nil
803}
804
805// checkLine verifies that a comment designates a line of the current diff of
806// the request.
807func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, params ports.AddCommentParams) error {
808 var v domain.Violations
809 _, base, err := m.rangeOf(ctx, mr)
810 if err != nil {
811 return err
812 }
813 if mr.HeadID.IsZero() || base.IsZero() {
814 v.Add("path", "the merge request has no diff to comment on")
815 return v.Err()
816 }
817 // A comment may be on a file past the 300th that the web view lists, so
818 // the file is looked up among all the files (MaxFiles 0). Reading every
819 // file's hunks within the default total limits would reject a comment on a
820 // file the earlier ones left no budget for. So a first read lists the files
821 // with no hunks (From past the end: cheap, git is stopped at once), and a
822 // second loads the one file in full (a Full window: the byte guard and
823 // domain.MaxOneFileLines only, generated files included), as the web view
824 // does when it loads a collapsed file: any line the page can show can be
825 // commented on, and a file over those limits takes no line comment.
826 list := domain.DefaultDiffLimits()
827 list.MaxFiles, list.From = 0, math.MaxInt
828 _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)
829 if err != nil {
830 return err
831 }
832 idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return f.CommentPath() == params.Path })
833 if idx >= 0 {
834 one, err := windowLimits(&ports.FileWindow{From: idx, To: idx + 1, Full: true})
835 if err != nil {
836 return err
837 }
838 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
839 return err
840 }
841 }
842 if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) {
843 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
844 }
845 if params.StartLine != 0 && !diff.HasLine(params.Path, params.Side, params.StartLine) {
846 v.Add("start_line", "is not a line of the diff")
847 }
848 return v.Err()
849}
850
851// ResolveComment marks a comment resolved or unresolved. The principal needs
852// mr:write. An unknown comment gives domain.ErrNotFound and a request that is
853// not open domain.ErrPreconditionFailed. Setting the state the comment already
854// has succeeds and writes nothing.
855func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) {
856 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
857 if err != nil {
858 return domain.Comment{}, err
859 }
860 for range maxWriteRetries {
861 mr, err := m.store.Get(ctx, name, number)
862 if err != nil {
863 return domain.Comment{}, err
864 }
865 c, err := m.store.GetComment(ctx, name, number, commentID)
866 if err != nil {
867 return domain.Comment{}, err
868 }
869 if !mr.IsOpen() {
870 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
871 }
872 if c.IsReply() {
873 var v domain.Violations
874 v.Add("comment_id", fmt.Sprintf("resolve the thread on its root ^%d", c.ReplyTo))
875 return domain.Comment{}, v.Err()
876 }
877 if c.Resolved == resolved {
878 return c, nil
879 }
880 c.Resolved = resolved
881 mr.UpdatedAt = m.clock.Now().UTC()
882 err = m.store.SaveComment(ctx, mr, c)
883 if errors.Is(err, domain.ErrConflict) {
884 continue
885 }
886 if err != nil {
887 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err)
888 }
889 m.bus.Publish(ctx, domain.CommentResolved{Repo: name, Number: number, Comment: c, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
890 return c, nil
891 }
892 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
893}
894
895// Close closes an open merge request without merging it and publishes
896// MergeRequestClosed. The principal needs mr:write. A request that is not open
897// gives domain.ErrPreconditionFailed. The request keeps the head it had and
898// its merge base, so that its diff stays readable.
899func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
900 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
901 if err != nil {
902 return domain.MergeRequest{}, err
903 }
904 unlock, err := m.lockRepo(ctx, name) // a merge and a close of one request exclude each other
905 if err != nil {
906 return domain.MergeRequest{}, err
907 }
908 defer unlock()
909 for range maxWriteRetries {
910 mr, err := m.store.Get(ctx, name, number)
911 if err != nil {
912 return domain.MergeRequest{}, err
913 }
914 if !mr.IsOpen() {
915 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
916 }
917 if mr, err = m.withHead(ctx, mr); err != nil {
918 return domain.MergeRequest{}, err
919 }
920 _, base, err := m.rangeOf(ctx, mr)
921 if err != nil {
922 return domain.MergeRequest{}, err
923 }
924 hadAutoMerge := mr.AutoMerge != nil
925 mr.AutoMerge = nil
926 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
927 err = m.store.Update(ctx, mr)
928 if errors.Is(err, domain.ErrConflict) {
929 continue
930 }
931 if err != nil {
932 return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, err)
933 }
934 log := zerowrap.FromCtx(ctx)
935 log.Info().Str("repo", name.String()).Int64("number", number).
936 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
937 recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
938 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
939 if hadAutoMerge {
940 m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: "closed", At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
941 }
942 if m.links != nil {
943 m.links.OnMergeRequestSettled(ctx, repo, mr)
944 }
945 if m.pulls != nil {
946 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
947 }
948 return mr, nil
949 }
950 return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, domain.ErrConflict)
951}
952
953// MarkRead records the head the reviewer has read, for the since-last-read diff
954// of Get. The principal needs mr:write. A zero head means the current head of
955// the source branch (the stored one for a merged or closed request); one that
956// is not a commit of the repository gives domain.ErrInvalid. It returns the
957// head recorded.
958func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error) {
959 if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
960 return domain.ObjectID{}, err
961 }
962 mr, err := m.store.Get(ctx, name, number)
963 if err != nil {
964 return domain.ObjectID{}, err
965 }
966 if head.IsZero() {
967 if mr, err = m.withHead(ctx, mr); err != nil {
968 return domain.ObjectID{}, err
969 }
970 if head = mr.HeadID; head.IsZero() {
971 return domain.ObjectID{}, fmt.Errorf("%w: the source branch %q no longer exists, give a head", domain.ErrPreconditionFailed, mr.Source)
972 }
973 } else {
974 var v domain.Violations
975 switch ok, err := m.git.HasCommit(ctx, name, head); {
976 case err != nil:
977 return domain.ObjectID{}, err
978 case !ok:
979 v.Add("head_id", fmt.Sprintf("%s is not a commit of the repository", head))
980 return domain.ObjectID{}, v.Err()
981 }
982 }
983 if err := m.store.SetRead(ctx, name, number, head); err != nil {
984 return domain.ObjectID{}, err
985 }
986 return head, nil
987}
988
989// Merge merges an open merge request, publishes MergeRequestMerged then a
990// PushEvent for the target branch, and returns the merged request. The
991// principal needs mr:merge. Merges of one repository run one at a time.
992//
993// It fails with domain.ErrInvalid for a zero head, and with
994// domain.ErrPreconditionFailed when the request is not open, the mode is not
995// allowed by the settings of the repository, the source branch no longer
996// points to the head ("branch moved, read again") or is gone, the target is
997// gone, the mode's own condition does not hold (see below), or the branch
998// brings nothing. Checks come with CI (CI-1): there are none yet, so the
999// setting that lets failing checks block the merge has nothing to act on.
1000//
1001// The conditions are always checked with Git, under the lock, never from the
1002// stored mergeability. A fast-forward needs the target to be an ancestor of
1003// the head (the author rebases and pushes again) and moves the target to the
1004// head, with the old value as a precondition. A squash and a merge commit need
1005// the head to merge cleanly into the target (a behind branch is fine). A squash
1006// writes one commit with the merged tree, the target as parent and the author
1007// and message of domain.NewSquashCommit; a merge commit has two parents (the
1008// target, then the head) and the message of domain.NewMergeCommit. Both are
1009// signed by the instance key and move the target the same way.
1010//
1011// With DeleteSource the merge is also refused, before anything changes, when
1012// the source is the default branch or the source or target of another open
1013// request.
1014//
1015// Only the merge itself runs before Merge returns, under the lock. Afterwards,
1016// off the request path and without the lock: the deletion of the source branch
1017// (if it still points to the head; a failure is logged and leaves the branch),
1018// MergeRequestMerged then a PushEvent (which carries the deletion too), and
1019// the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
1020// only says that the deletion was requested.
1021func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
1022 mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
1023 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
1024}
1025
1026// maxConflictPathsShown is how many conflicting paths a refusal lists.
1027const maxConflictPathsShown = 5
1028
1029// checkMode is the authoritative per-mode check of a merge, done with Git. It
1030// returns the tree a squash or merge commit gets (zero for a fast-forward).
1031func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
1032 if mode == domain.MergeModeFastForward {
1033 ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head)
1034 if err != nil {
1035 return domain.ObjectID{}, err
1036 }
1037 if !ok {
1038 return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed)
1039 }
1040 return domain.ObjectID{}, nil
1041 }
1042 res, err := m.git.MergeTree(ctx, mr.Repo, tip, head)
1043 if err != nil {
1044 return domain.ObjectID{}, err
1045 }
1046 res = res.Bounded()
1047 if !res.Clean {
1048 what := "no common history"
1049 if len(res.ConflictPaths) > 0 {
1050 shown := res.ConflictPaths
1051 if len(shown) > maxConflictPathsShown {
1052 shown = shown[:maxConflictPathsShown]
1053 }
1054 what = strings.Join(shown, ", ")
1055 if len(shown) < len(res.ConflictPaths) || res.Truncated {
1056 what += ", ..."
1057 }
1058 }
1059 return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what)
1060 }
1061 return res.Tree, nil
1062}
1063
1064type mergeOpts struct {
1065 auto *autoMergeOpts
1066}
1067
1068type autoMergeOpts struct {
1069 intent *domain.AutoMerge
1070 tokens ports.TokenStore
1071 activity ports.CIActivity
1072}
1073
1074var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1075
1076func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1077 if opts.auto == nil {
1078 if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
1079 return domain.MergeRequest{}, err
1080 }
1081 }
1082 mode := params.Mode
1083 if mode == "" {
1084 mode = domain.MergeModeFastForward
1085 }
1086 var v domain.Violations
1087 if _, err := domain.ParseMergeMode(mode.String()); err != nil {
1088 v.Add("mode", "is not a merge mode")
1089 }
1090 if params.HeadID.IsZero() {
1091 v.Add("head_id", "is required")
1092 }
1093 if err := v.Err(); err != nil {
1094 return domain.MergeRequest{}, err
1095 }
1096
1097 unlock, err := m.lockRepo(ctx, name)
1098 if err != nil {
1099 return domain.MergeRequest{}, err
1100 }
1101 var once sync.Once
1102 release := func() { once.Do(unlock) }
1103 defer release()
1104
1105 mr, err := m.store.Get(ctx, name, number)
1106 if err != nil {
1107 return domain.MergeRequest{}, err
1108 }
1109 if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) {
1110 return domain.MergeRequest{}, errAutoMergeWait
1111 }
1112 if opts.auto != nil {
1113 p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now())
1114 if err != nil {
1115 return domain.MergeRequest{}, err
1116 }
1117 }
1118 // A merge moves the target and may delete the source: a token limited to
1119 // branch prefixes may do so only within them.
1120 if !p.AllowsRef("refs/heads/" + mr.Target) {
1121 return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
1122 }
1123 if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
1124 return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
1125 }
1126 if !mr.IsOpen() {
1127 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1128 }
1129 if mr.IsDraft() {
1130 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
1131 }
1132 // The settings are read again under the lock: a change applies at once.
1133 repo, err := m.meta.Get(ctx, name)
1134 if err != nil {
1135 if errors.Is(err, domain.ErrNotFound) {
1136 return domain.MergeRequest{}, err // deleted meanwhile
1137 }
1138 return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
1139 }
1140 settings := repo.EffectiveSettings()
1141 if !settings.Allows(mode) {
1142 return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
1143 }
1144
1145 head, err := m.branchOrZero(ctx, name, mr.Source)
1146 switch {
1147 case err != nil:
1148 return domain.MergeRequest{}, err
1149 case head.IsZero():
1150 return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
1151 case head != params.HeadID:
1152 return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
1153 }
1154 tip, err := m.branchOrZero(ctx, name, mr.Target)
1155 switch {
1156 case err != nil:
1157 return domain.MergeRequest{}, err
1158 case tip.IsZero():
1159 return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
1160 case tip == head:
1161 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
1162 }
1163 // A head behind the target (or at it) has nothing to merge, in any mode.
1164 if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil {
1165 return domain.MergeRequest{}, err
1166 } else if behind {
1167 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1168 }
1169 if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) {
1170 return domain.MergeRequest{}, errAutoMergeWait
1171 }
1172 tree, err := m.checkMode(ctx, mr, mode, tip, head)
1173 if err != nil {
1174 if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1175 return domain.MergeRequest{}, errAutoMergeWait
1176 }
1177 return domain.MergeRequest{}, err
1178 }
1179 if opts.auto != nil && !settings.AllowAutoMerge {
1180 return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1181 }
1182 if settings.FailingChecksBlock || opts.auto != nil {
1183 checks, err := m.readMergeChecks(ctx, name, head)
1184 if err == nil {
1185 err = checksPolicy(checks)
1186 }
1187 if err != nil {
1188 if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1189 return domain.MergeRequest{}, errAutoMergeWait
1190 }
1191 return domain.MergeRequest{}, err
1192 }
1193 if opts.auto != nil && !hasSuccessfulCheck(checks) {
1194 return domain.MergeRequest{}, errAutoMergeWait
1195 }
1196 }
1197 if params.DeleteSource {
1198 if err := m.checkDeletable(ctx, name, mr); err != nil {
1199 return domain.MergeRequest{}, err
1200 }
1201 }
1202
1203 merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p)
1204 if err != nil {
1205 return domain.MergeRequest{}, err
1206 }
1207 m.recordPush(ctx, name) // the target moved, whatever happens next
1208 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil)
1209 if err != nil {
1210 return domain.MergeRequest{}, err
1211 }
1212 log := zerowrap.FromCtx(ctx)
1213 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
1214 Str("target", mr.Target).Str("commit", merged.String()).
1215 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
1216
1217 // The merge is done and recorded. The rest runs without the lock and after
1218 // the caller got its answer.
1219 release()
1220 bg := context.WithoutCancel(ctx)
1221 m.after.Add(1)
1222 go func() {
1223 defer m.after.Done()
1224 defer func() {
1225 if r := recover(); r != nil {
1226 log := zerowrap.FromCtx(bg)
1227 log.Error().Str("repo", name.String()).Int64("number", number).
1228 Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked")
1229 }
1230 }()
1231 if m.afterGate != nil {
1232 m.afterGate()
1233 }
1234 m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource)
1235 }()
1236 return mr, nil
1237}
1238
1239// afterMerge is what follows a merge off the request path: errors are logged,
1240// never returned. mr is the merged request, tip the old target and merged the
1241// new one.
1242func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1243 if m.links != nil {
1244 m.links.OnMerged(ctx, p, repo, mr)
1245 }
1246 name := repo.Name
1247 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1248 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
1249 updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
1250 }
1251 at := mr.UpdatedAt
1252 recordActivity(ctx, m.meta, repo.Name, at)
1253 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1254 m.bus.Publish(ctx, domain.PushEvent{
1255 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1256 })
1257 if m.mirror != nil {
1258 m.mirror.OnPush(ctx, name, updates) // marks dirty only; never blocks
1259 }
1260 if m.pulls != nil {
1261 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1262 }
1263 if m.ci != nil {
1264 // GitHub runs the "push" workflows of a merge, with the owner as actor;
1265 // the run is trusted with what the merging principal may read.
1266 m.ci.OnMerge(ctx, name, updates, p)
1267 }
1268 if m.mergeab != nil {
1269 m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1270 }
1271 if m.links != nil {
1272 m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved
1273 }
1274}
1275
1276// deleteSource deletes the source branch of a merged request if it is still
1277// head and still unused, and reports whether it did. The eligibility is checked
1278// again and the branch deleted in one critical section of the merge lock: a
1279// request opened on the branch since the merge, or a push to it, keeps it. The
1280// record of who pushed the branch goes only after a deletion, and only if it
1281// still names head.
1282func (m *MergeRequests) deleteSource(ctx context.Context, p domain.Principal, name domain.RepoName, mr domain.MergeRequest, head domain.ObjectID) bool {
1283 log := zerowrap.FromCtx(ctx).With().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source).Logger()
1284 unlock, err := m.lockRepo(ctx, name)
1285 if err != nil {
1286 log.Warn().Err(err).Msg("source branch kept after the merge")
1287 return false
1288 }
1289 defer unlock()
1290 if err := m.checkDeletable(ctx, name, mr); err != nil {
1291 log.Warn().Err(err).Msg("source branch kept after the merge")
1292 return false
1293 }
1294 if err := m.git.DeleteBranch(ctx, name, mr.Source, head); err != nil {
1295 log.Warn().Err(err).Msg("source branch kept after the merge")
1296 return false
1297 }
1298 if m.tips != nil {
1299 if err := m.tips.DeleteRefTipIf(ctx, name, "refs/heads/"+mr.Source, head.String()); err != nil && !errors.Is(err, domain.ErrNotFound) {
1300 log.Warn().Err(err).Msg("the record of the source branch was kept")
1301 }
1302 }
1303 log.Info().Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1304 return true
1305}
1306
1307// WhileOpen implements ports.PullGate: fn runs under the merge lock, so a
1308// close or a merge of the request cannot complete in the middle of it, and not
1309// at all when the request is no longer open.
1310func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, number int64, fn func() error) error {
1311 unlock, err := m.lockRepo(ctx, name)
1312 if err != nil {
1313 return err
1314 }
1315 defer unlock()
1316 mr, err := m.store.Get(ctx, name, number)
1317 if err != nil {
1318 return err
1319 }
1320 if !mr.IsOpen() {
1321 return fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1322 }
1323 return fn()
1324}
1325
1326// readMergeChecks reads one snapshot, classifying an incomplete result as a
1327// failed precondition rather than treating it as a passing set of checks.
1328func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) {
1329 if m.checks == nil {
1330 return nil, nil
1331 }
1332 checks, err := m.checks.CommitChecks(ctx, name, head.String())
1333 if errors.Is(err, domain.ErrResourceExhausted) {
1334 return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
1335 }
1336 if err != nil {
1337 return nil, fmt.Errorf("read the checks of %s: %w", head, err)
1338 }
1339 return checks, nil
1340}
1341
1342func hasSuccessfulCheck(checks []domain.CICheck) bool {
1343 for _, check := range checks {
1344 if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1345 return true
1346 }
1347 }
1348 return false
1349}
1350
1351// checksPolicy requires every check to have ended as success, skipped or
1352// neutral. Queued and running checks block too; an empty set is allowed.
1353func checksPolicy(checks []domain.CICheck) error {
1354 var failing, pending []string
1355 for _, c := range checks {
1356 switch {
1357 case c.Status != domain.CICompleted:
1358 pending = append(pending, c.Name)
1359 case !c.Conclusion.PassesMerge():
1360 failing = append(failing, c.Name)
1361 }
1362 }
1363 switch {
1364 case len(failing) > 0:
1365 return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1366 case len(pending) > 0:
1367 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
1368 }
1369 return nil
1370}
1371
1372// checkDeletable refuses to delete the source branch of mr when it is the
1373// default branch or the source or target of another open request. It runs
1374// under the merge lock, which Create also holds while it checks its branches
1375// and stores the request.
1376func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {
1377 def, _, err := m.repos.DefaultBranch(ctx, name)
1378 if err != nil {
1379 return fmt.Errorf("default branch of %q: %w", name, err)
1380 }
1381 if mr.Source == def {
1382 return fmt.Errorf("%w: %q is the default branch of %s: it cannot be deleted", domain.ErrPreconditionFailed, mr.Source, name)
1383 }
1384 all, err := m.store.List(ctx, name)
1385 if err != nil {
1386 return fmt.Errorf("list merge requests of %q: %w", name, err)
1387 }
1388 for _, o := range all {
1389 if o.Number != mr.Number && o.IsOpen() && (o.Source == mr.Source || o.Target == mr.Source) {
1390 return fmt.Errorf("%w: !%d still uses %q: it cannot be deleted", domain.ErrPreconditionFailed, o.Number, mr.Source)
1391 }
1392 }
1393 return nil
1394}
1395
1396// recordPush does for a merge what Pushes.Record does for a push, as the merge
1397// moved a branch: it sets pushed_at and queues the maintenance of the
1398// repository. The target already moved, so a failure is logged, not returned,
1399// and the caller giving up does not stop it.
1400func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
1401 ctx = context.WithoutCancel(ctx)
1402 if _, err := touchPushedAt(ctx, m.meta, name, m.clock.Now().UTC()); err != nil {
1403 log := zerowrap.FromCtx(ctx)
1404 log.Error().Err(err).Str("repo", name.String()).Msg("record pushed_at after a merge")
1405 }
1406 m.maint.Schedule(ctx, name)
1407}
1408
1409// moveTarget moves the target branch from tip to the head (fast-forward) or to
1410// a new signed squash or merge commit with the given tree, and returns the new tip.
1411func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
1412 newTip := head
1413 switch mode {
1414 case domain.MergeModeMerge:
1415 commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now())
1416 armored, err := m.signer.Sign(commit.Payload())
1417 if err != nil {
1418 return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1419 }
1420 if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1421 return domain.ObjectID{}, err
1422 }
1423 case domain.MergeModeSquash:
1424 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
1425 if err != nil {
1426 return domain.ObjectID{}, err
1427 }
1428 if more {
1429 return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
1430 }
1431 commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
1432 armored, err := m.signer.Sign(commit.Payload())
1433 if err != nil {
1434 return domain.ObjectID{}, fmt.Errorf("sign the squash commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1435 }
1436 if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1437 return domain.ObjectID{}, err
1438 }
1439 }
1440 // The target is the merger's from the moment it moves: recorded before it
1441 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
1442 // merge's push does on GitHub.
1443 if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},
1444 domain.AuthorName(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
1445 return domain.ObjectID{}, err
1446 }
1447 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
1448 if errors.Is(err, domain.ErrConflict) {
1449 return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target)
1450 }
1451 if err != nil {
1452 return domain.ObjectID{}, err
1453 }
1454 return newTip, nil
1455}
1456
1457// markMerged records the merge. The target already moved, so a write that
1458// loses a race (a comment added meanwhile) is repeated on the fresh request.
1459func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) {
1460 // The merge must be recorded even when the caller gives up.
1461 ctx = context.WithoutCancel(ctx)
1462 for range maxWriteRetries {
1463 mr, err := m.store.Get(ctx, name, number)
1464 if err != nil {
1465 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1466 }
1467 mr.AutoMerge, mr.AutoMerged = nil, auto
1468 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
1469 mr.UpdatedAt = m.clock.Now().UTC()
1470 err = m.store.Update(ctx, mr)
1471 if errors.Is(err, domain.ErrConflict) {
1472 continue
1473 }
1474 if err != nil {
1475 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1476 }
1477 return mr, nil
1478 }
1479 log := zerowrap.FromCtx(ctx)
1480 log.Error().Str("repo", name.String()).Int64("number", number).Str("commit", merged.String()).
1481 Msg("the target branch moved but the merge request could not be marked merged")
1482 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
1483}
1484
1485// EditComment changes only the author's text, preserving the immutable verdict.
1486func (m *MergeRequests) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error) {
1487 return m.changeComment(ctx, p, name, number, commentID, text, false)
1488}
1489
1490// DeleteComment removes text but retains the anchor, ID and verdict. Repeated deletion is a no-op.
1491func (m *MergeRequests) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error) {
1492 return m.changeComment(ctx, p, name, number, commentID, "", true)
1493}
1494
1495func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string, remove bool) (domain.Comment, error) {
1496 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
1497 if err != nil {
1498 return domain.Comment{}, err
1499 }
1500 for range maxWriteRetries {
1501 mr, err := m.store.Get(ctx, name, number)
1502 if err != nil {
1503 return domain.Comment{}, err
1504 }
1505 c, err := m.store.GetComment(ctx, name, number, commentID)
1506 if err != nil {
1507 return domain.Comment{}, err
1508 }
1509 if !mr.IsOpen() {
1510 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1511 }
1512 if domain.AuthorName(p) != c.Author {
1513 return domain.Comment{}, fmt.Errorf("%w: only the author of a note can edit or delete it", domain.ErrForbidden)
1514 }
1515 if c.Deleted {
1516 if remove {
1517 return c, nil
1518 }
1519 return domain.Comment{}, fmt.Errorf("%w: the note was deleted", domain.ErrPreconditionFailed)
1520 }
1521 parsed, err := domain.ParsePlainText(text)
1522 var v domain.Violations
1523 if err != nil {
1524 v.AddErr("text", err)
1525 } else if !remove && parsed.IsBlank() && c.Verdict == domain.VerdictNone {
1526 v.Add("text", "is required")
1527 }
1528 if err := v.Err(); err != nil {
1529 return domain.Comment{}, err
1530 }
1531 if !remove && parsed == c.Text {
1532 return c, nil
1533 }
1534 c.Text, c.Deleted, c.EditedAt = parsed, remove, m.clock.Now().UTC()
1535 mr.UpdatedAt = c.EditedAt
1536 if err := m.store.SaveComment(ctx, mr, c); errors.Is(err, domain.ErrConflict) {
1537 continue
1538 } else if err != nil {
1539 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, err)
1540 }
1541 log := zerowrap.FromCtx(ctx)
1542 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).Bool("deleted", remove).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment changed")
1543 if remove {
1544 m.bus.Publish(ctx, domain.CommentDeleted{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1545 } else {
1546 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1547 }
1548 if m.links != nil {
1549 m.links.OnMergeRequestChanged(ctx, repo, number)
1550 }
1551 return c, nil
1552 }
1553 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
1554}
1555
1556// replyRoot returns the note a reply targets, or a violation when it is
1557// missing, itself a reply, or deleted.
1558func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, number, id int64) (domain.Comment, error) {
1559 root, err := m.store.GetComment(ctx, name, number, id)
1560 var v domain.Violations
1561 switch {
1562 case errors.Is(err, domain.ErrNotFound):
1563 v.Add("reply_to", fmt.Sprintf("no note ^%d", id))
1564 case err != nil:
1565 return domain.Comment{}, err
1566 case root.IsReply() || root.Deleted:
1567 v.Add("reply_to", "must name a non-deleted root note")
1568 }
1569 if err := v.Err(); err != nil {
1570 return domain.Comment{}, err
1571 }
1572 return root, nil
1573}
1574
1575// Compare lists at most ports.MaxCompareCommits source commits absent from target. Unlike bounded
1576// history intersection, ancestry remains accurate even for deep histories.
1577func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) {
1578 if _, err := m.readable(ctx, p, repo); err != nil {
1579 return ports.CompareResult{}, err
1580 }
1581 if target == source {
1582 return ports.CompareResult{Exact: true}, nil
1583 }
1584 ancestor, err := m.git.IsAncestor(ctx, repo, target, source)
1585 if err != nil {
1586 return ports.CompareResult{}, err
1587 }
1588 commits, more, err := m.git.ListRange(ctx, repo, target, source, ports.MaxCompareCommits)
1589 if err != nil {
1590 return ports.CompareResult{}, err
1591 }
1592 verifyCommits(ctx, m.verifier, repo, commits)
1593 return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil
1594}
1595
1596// PushSummary detects rewrites by ancestry, not log ordering, and counts at
1597// most ports.MaxPushSummaryCommits new commits. More distinguishes a longer range from a rewrite.
1598func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) {
1599 if _, err := m.readable(ctx, p, repo); err != nil {
1600 return ports.PushSummary{}, err
1601 }
1602 if oldHead == newTip {
1603 return ports.PushSummary{}, nil
1604 }
1605 ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip)
1606 if err != nil {
1607 return ports.PushSummary{}, err
1608 }
1609 if !ancestor {
1610 return ports.PushSummary{Rewritten: true}, nil
1611 }
1612 commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, ports.MaxPushSummaryCommits)
1613 if err != nil {
1614 return ports.PushSummary{}, err
1615 }
1616 return ports.PushSummary{Count: len(commits), More: more}, nil
1617}
1618
1619// WithMergeLock serializes fn with repository merges and closes.
1620func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
1621 unlock, err := m.lockRepo(ctx, repo)
1622 if err != nil {
1623 return err
1624 }
1625 defer unlock()
1626 return fn()
1627}