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 on the head must pass: none may be failed, queued
999// or running; a head with no checks may be merged. params.IgnoreChecks bypasses
1000// this requirement and needs mr:force-merge.
1001//
1002// The conditions are always checked with Git, under the lock, never from the
1003// stored mergeability. A fast-forward needs the target to be an ancestor of
1004// the head (the author rebases and pushes again) and moves the target to the
1005// head, with the old value as a precondition. A squash and a merge commit need
1006// the head to merge cleanly into the target (a behind branch is fine). A squash
1007// writes one commit with the merged tree, the target as parent and the author
1008// and message of domain.NewSquashCommit; a merge commit has two parents (the
1009// target, then the head) and the message of domain.NewMergeCommit. Both are
1010// signed by the instance key and move the target the same way.
1011//
1012// With DeleteSource the merge is also refused, before anything changes, when
1013// the source is the default branch or the source or target of another open
1014// request.
1015//
1016// Only the merge itself runs before Merge returns, under the lock. Afterwards,
1017// off the request path and without the lock: the deletion of the source branch
1018// (if it still points to the head; a failure is logged and leaves the branch),
1019// MergeRequestMerged then a PushEvent (which carries the deletion too), and
1020// the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
1021// only says that the deletion was requested.
1022func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
1023 mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
1024 return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
1025}
1026
1027// maxConflictPathsShown is how many conflicting paths a refusal lists.
1028const maxConflictPathsShown = 5
1029
1030// checkMode is the authoritative per-mode check of a merge, done with Git. It
1031// returns the tree a squash or merge commit gets (zero for a fast-forward).
1032func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
1033 if mode == domain.MergeModeFastForward {
1034 ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head)
1035 if err != nil {
1036 return domain.ObjectID{}, err
1037 }
1038 if !ok {
1039 return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed)
1040 }
1041 return domain.ObjectID{}, nil
1042 }
1043 res, err := m.git.MergeTree(ctx, mr.Repo, tip, head)
1044 if err != nil {
1045 return domain.ObjectID{}, err
1046 }
1047 res = res.Bounded()
1048 if !res.Clean {
1049 what := "no common history"
1050 if len(res.ConflictPaths) > 0 {
1051 shown := res.ConflictPaths
1052 if len(shown) > maxConflictPathsShown {
1053 shown = shown[:maxConflictPathsShown]
1054 }
1055 what = strings.Join(shown, ", ")
1056 if len(shown) < len(res.ConflictPaths) || res.Truncated {
1057 what += ", ..."
1058 }
1059 }
1060 return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what)
1061 }
1062 return res.Tree, nil
1063}
1064
1065type mergeOpts struct {
1066 auto *autoMergeOpts
1067}
1068
1069type autoMergeOpts struct {
1070 intent *domain.AutoMerge
1071 tokens ports.TokenStore
1072 activity ports.CIActivity
1073}
1074
1075var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1076
1077func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1078 if opts.auto == nil {
1079 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
1080 if err != nil {
1081 return domain.MergeRequest{}, err
1082 }
1083 if params.IgnoreChecks {
1084 if err := scopeAccess(p, repo, domain.ScopeMRForceMerge); err != nil {
1085 return domain.MergeRequest{}, err
1086 }
1087 }
1088 }
1089 mode := params.Mode
1090 if mode == "" {
1091 mode = domain.MergeModeFastForward
1092 }
1093 var v domain.Violations
1094 if _, err := domain.ParseMergeMode(mode.String()); err != nil {
1095 v.Add("mode", "is not a merge mode")
1096 }
1097 if params.HeadID.IsZero() {
1098 v.Add("head_id", "is required")
1099 }
1100 if err := v.Err(); err != nil {
1101 return domain.MergeRequest{}, err
1102 }
1103
1104 unlock, err := m.lockRepo(ctx, name)
1105 if err != nil {
1106 return domain.MergeRequest{}, err
1107 }
1108 var once sync.Once
1109 release := func() { once.Do(unlock) }
1110 defer release()
1111
1112 mr, err := m.store.Get(ctx, name, number)
1113 if err != nil {
1114 return domain.MergeRequest{}, err
1115 }
1116 if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) {
1117 return domain.MergeRequest{}, errAutoMergeWait
1118 }
1119 if opts.auto != nil {
1120 p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now())
1121 if err != nil {
1122 return domain.MergeRequest{}, err
1123 }
1124 }
1125 // A merge moves the target and may delete the source: a token limited to
1126 // branch prefixes may do so only within them.
1127 if !p.AllowsRef("refs/heads/" + mr.Target) {
1128 return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
1129 }
1130 if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
1131 return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
1132 }
1133 if !mr.IsOpen() {
1134 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1135 }
1136 if mr.IsDraft() {
1137 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
1138 }
1139 // The settings are read again under the lock: a change applies at once.
1140 repo, err := m.meta.Get(ctx, name)
1141 if err != nil {
1142 if errors.Is(err, domain.ErrNotFound) {
1143 return domain.MergeRequest{}, err // deleted meanwhile
1144 }
1145 return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
1146 }
1147 settings := repo.EffectiveSettings()
1148 if !settings.Allows(mode) {
1149 return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
1150 }
1151
1152 head, err := m.branchOrZero(ctx, name, mr.Source)
1153 switch {
1154 case err != nil:
1155 return domain.MergeRequest{}, err
1156 case head.IsZero():
1157 return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
1158 case head != params.HeadID:
1159 return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
1160 }
1161 tip, err := m.branchOrZero(ctx, name, mr.Target)
1162 switch {
1163 case err != nil:
1164 return domain.MergeRequest{}, err
1165 case tip.IsZero():
1166 return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
1167 case tip == head:
1168 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
1169 }
1170 // A head behind the target (or at it) has nothing to merge, in any mode.
1171 if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil {
1172 return domain.MergeRequest{}, err
1173 } else if behind {
1174 return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1175 }
1176 if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) {
1177 return domain.MergeRequest{}, errAutoMergeWait
1178 }
1179 tree, err := m.checkMode(ctx, mr, mode, tip, head)
1180 if err != nil {
1181 if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1182 return domain.MergeRequest{}, errAutoMergeWait
1183 }
1184 return domain.MergeRequest{}, err
1185 }
1186 if opts.auto != nil && !settings.AllowAutoMerge {
1187 return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1188 }
1189 if !params.IgnoreChecks || opts.auto != nil {
1190 checks, err := m.readMergeChecks(ctx, name, head)
1191 if err == nil {
1192 err = checksPolicy(checks)
1193 }
1194 if err != nil {
1195 if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1196 return domain.MergeRequest{}, errAutoMergeWait
1197 }
1198 return domain.MergeRequest{}, err
1199 }
1200 if opts.auto != nil && !hasSuccessfulCheck(checks) {
1201 return domain.MergeRequest{}, errAutoMergeWait
1202 }
1203 }
1204 if params.DeleteSource {
1205 if err := m.checkDeletable(ctx, name, mr); err != nil {
1206 return domain.MergeRequest{}, err
1207 }
1208 }
1209
1210 merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p)
1211 if err != nil {
1212 return domain.MergeRequest{}, err
1213 }
1214 m.recordPush(ctx, name) // the target moved, whatever happens next
1215 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil)
1216 if err != nil {
1217 return domain.MergeRequest{}, err
1218 }
1219 log := zerowrap.FromCtx(ctx)
1220 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
1221 Str("target", mr.Target).Str("commit", merged.String()).
1222 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
1223
1224 // The merge is done and recorded. The rest runs without the lock and after
1225 // the caller got its answer.
1226 release()
1227 bg := context.WithoutCancel(ctx)
1228 m.after.Add(1)
1229 go func() {
1230 defer m.after.Done()
1231 defer func() {
1232 if r := recover(); r != nil {
1233 log := zerowrap.FromCtx(bg)
1234 log.Error().Str("repo", name.String()).Int64("number", number).
1235 Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked")
1236 }
1237 }()
1238 if m.afterGate != nil {
1239 m.afterGate()
1240 }
1241 m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource)
1242 }()
1243 return mr, nil
1244}
1245
1246// afterMerge is what follows a merge off the request path: errors are logged,
1247// never returned. mr is the merged request, tip the old target and merged the
1248// new one.
1249func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1250 if m.links != nil {
1251 m.links.OnMerged(ctx, p, repo, mr)
1252 }
1253 name := repo.Name
1254 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1255 if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
1256 updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
1257 }
1258 at := mr.UpdatedAt
1259 recordActivity(ctx, m.meta, repo.Name, at)
1260 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1261 m.bus.Publish(ctx, domain.PushEvent{
1262 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1263 })
1264 if m.mirror != nil {
1265 m.mirror.OnPush(ctx, name, updates) // marks dirty only; never blocks
1266 }
1267 if m.pulls != nil {
1268 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1269 }
1270 if m.ci != nil {
1271 // GitHub runs the "push" workflows of a merge, with the owner as actor;
1272 // the run is trusted with what the merging principal may read.
1273 m.ci.OnMerge(ctx, name, updates, p)
1274 }
1275 if m.mergeab != nil {
1276 m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1277 }
1278 if m.links != nil {
1279 m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved
1280 }
1281}
1282
1283// deleteSource deletes the source branch of a merged request if it is still
1284// head and still unused, and reports whether it did. The eligibility is checked
1285// again and the branch deleted in one critical section of the merge lock: a
1286// request opened on the branch since the merge, or a push to it, keeps it. The
1287// record of who pushed the branch goes only after a deletion, and only if it
1288// still names head.
1289func (m *MergeRequests) deleteSource(ctx context.Context, p domain.Principal, name domain.RepoName, mr domain.MergeRequest, head domain.ObjectID) bool {
1290 log := zerowrap.FromCtx(ctx).With().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source).Logger()
1291 unlock, err := m.lockRepo(ctx, name)
1292 if err != nil {
1293 log.Warn().Err(err).Msg("source branch kept after the merge")
1294 return false
1295 }
1296 defer unlock()
1297 if err := m.checkDeletable(ctx, name, mr); err != nil {
1298 log.Warn().Err(err).Msg("source branch kept after the merge")
1299 return false
1300 }
1301 if err := m.git.DeleteBranch(ctx, name, mr.Source, head); err != nil {
1302 log.Warn().Err(err).Msg("source branch kept after the merge")
1303 return false
1304 }
1305 if m.tips != nil {
1306 if err := m.tips.DeleteRefTipIf(ctx, name, "refs/heads/"+mr.Source, head.String()); err != nil && !errors.Is(err, domain.ErrNotFound) {
1307 log.Warn().Err(err).Msg("the record of the source branch was kept")
1308 }
1309 }
1310 log.Info().Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1311 return true
1312}
1313
1314// WhileOpen implements ports.PullGate: fn runs under the merge lock, so a
1315// close or a merge of the request cannot complete in the middle of it, and not
1316// at all when the request is no longer open.
1317func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, number int64, fn func() error) error {
1318 unlock, err := m.lockRepo(ctx, name)
1319 if err != nil {
1320 return err
1321 }
1322 defer unlock()
1323 mr, err := m.store.Get(ctx, name, number)
1324 if err != nil {
1325 return err
1326 }
1327 if !mr.IsOpen() {
1328 return fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1329 }
1330 return fn()
1331}
1332
1333// readMergeChecks reads one snapshot, classifying an incomplete result as a
1334// failed precondition rather than treating it as a passing set of checks.
1335func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) {
1336 if m.checks == nil {
1337 return nil, nil
1338 }
1339 checks, err := m.checks.CommitChecks(ctx, name, head.String())
1340 if errors.Is(err, domain.ErrResourceExhausted) {
1341 return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
1342 }
1343 if err != nil {
1344 return nil, fmt.Errorf("read the checks of %s: %w", head, err)
1345 }
1346 return checks, nil
1347}
1348
1349func hasSuccessfulCheck(checks []domain.CICheck) bool {
1350 for _, check := range checks {
1351 if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1352 return true
1353 }
1354 }
1355 return false
1356}
1357
1358// checksPolicy requires every check to have ended as success, skipped or
1359// neutral. Queued and running checks block too; an empty set is allowed.
1360func checksPolicy(checks []domain.CICheck) error {
1361 var failing, pending []string
1362 for _, c := range checks {
1363 switch {
1364 case c.Status != domain.CICompleted:
1365 pending = append(pending, c.Name)
1366 case !c.Conclusion.PassesMerge():
1367 failing = append(failing, c.Name)
1368 }
1369 }
1370 switch {
1371 case len(failing) > 0:
1372 return fmt.Errorf("%w: failing checks block the merge: %s (merge with --ignore-checks, needs the mr:force-merge scope)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1373 case len(pending) > 0:
1374 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
1375 }
1376 return nil
1377}
1378
1379// checkDeletable refuses to delete the source branch of mr when it is the
1380// default branch or the source or target of another open request. It runs
1381// under the merge lock, which Create also holds while it checks its branches
1382// and stores the request.
1383func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {
1384 def, _, err := m.repos.DefaultBranch(ctx, name)
1385 if err != nil {
1386 return fmt.Errorf("default branch of %q: %w", name, err)
1387 }
1388 if mr.Source == def {
1389 return fmt.Errorf("%w: %q is the default branch of %s: it cannot be deleted", domain.ErrPreconditionFailed, mr.Source, name)
1390 }
1391 all, err := m.store.List(ctx, name)
1392 if err != nil {
1393 return fmt.Errorf("list merge requests of %q: %w", name, err)
1394 }
1395 for _, o := range all {
1396 if o.Number != mr.Number && o.IsOpen() && (o.Source == mr.Source || o.Target == mr.Source) {
1397 return fmt.Errorf("%w: !%d still uses %q: it cannot be deleted", domain.ErrPreconditionFailed, o.Number, mr.Source)
1398 }
1399 }
1400 return nil
1401}
1402
1403// recordPush does for a merge what Pushes.Record does for a push, as the merge
1404// moved a branch: it sets pushed_at and queues the maintenance of the
1405// repository. The target already moved, so a failure is logged, not returned,
1406// and the caller giving up does not stop it.
1407func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
1408 ctx = context.WithoutCancel(ctx)
1409 if _, err := touchPushedAt(ctx, m.meta, name, m.clock.Now().UTC()); err != nil {
1410 log := zerowrap.FromCtx(ctx)
1411 log.Error().Err(err).Str("repo", name.String()).Msg("record pushed_at after a merge")
1412 }
1413 m.maint.Schedule(ctx, name)
1414}
1415
1416// moveTarget moves the target branch from tip to the head (fast-forward) or to
1417// a new signed squash or merge commit with the given tree, and returns the new tip.
1418func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
1419 newTip := head
1420 switch mode {
1421 case domain.MergeModeMerge:
1422 commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now())
1423 armored, err := m.signer.Sign(commit.Payload())
1424 if err != nil {
1425 return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1426 }
1427 if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1428 return domain.ObjectID{}, err
1429 }
1430 case domain.MergeModeSquash:
1431 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
1432 if err != nil {
1433 return domain.ObjectID{}, err
1434 }
1435 if more {
1436 return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
1437 }
1438 commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
1439 armored, err := m.signer.Sign(commit.Payload())
1440 if err != nil {
1441 return domain.ObjectID{}, fmt.Errorf("sign the squash commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1442 }
1443 if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1444 return domain.ObjectID{}, err
1445 }
1446 }
1447 // The target is the merger's from the moment it moves: recorded before it
1448 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
1449 // merge's push does on GitHub.
1450 if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},
1451 domain.AuthorName(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
1452 return domain.ObjectID{}, err
1453 }
1454 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
1455 if errors.Is(err, domain.ErrConflict) {
1456 return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target)
1457 }
1458 if err != nil {
1459 return domain.ObjectID{}, err
1460 }
1461 return newTip, nil
1462}
1463
1464// markMerged records the merge. The target already moved, so a write that
1465// loses a race (a comment added meanwhile) is repeated on the fresh request.
1466func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) {
1467 // The merge must be recorded even when the caller gives up.
1468 ctx = context.WithoutCancel(ctx)
1469 for range maxWriteRetries {
1470 mr, err := m.store.Get(ctx, name, number)
1471 if err != nil {
1472 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1473 }
1474 mr.AutoMerge, mr.AutoMerged = nil, auto
1475 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
1476 mr.UpdatedAt = m.clock.Now().UTC()
1477 err = m.store.Update(ctx, mr)
1478 if errors.Is(err, domain.ErrConflict) {
1479 continue
1480 }
1481 if err != nil {
1482 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1483 }
1484 return mr, nil
1485 }
1486 log := zerowrap.FromCtx(ctx)
1487 log.Error().Str("repo", name.String()).Int64("number", number).Str("commit", merged.String()).
1488 Msg("the target branch moved but the merge request could not be marked merged")
1489 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
1490}
1491
1492// EditComment changes only the author's text, preserving the immutable verdict.
1493func (m *MergeRequests) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error) {
1494 return m.changeComment(ctx, p, name, number, commentID, text, false)
1495}
1496
1497// DeleteComment removes text but retains the anchor, ID and verdict. Repeated deletion is a no-op.
1498func (m *MergeRequests) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error) {
1499 return m.changeComment(ctx, p, name, number, commentID, "", true)
1500}
1501
1502func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string, remove bool) (domain.Comment, error) {
1503 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
1504 if err != nil {
1505 return domain.Comment{}, err
1506 }
1507 for range maxWriteRetries {
1508 mr, err := m.store.Get(ctx, name, number)
1509 if err != nil {
1510 return domain.Comment{}, err
1511 }
1512 c, err := m.store.GetComment(ctx, name, number, commentID)
1513 if err != nil {
1514 return domain.Comment{}, err
1515 }
1516 if !mr.IsOpen() {
1517 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1518 }
1519 if domain.AuthorName(p) != c.Author {
1520 return domain.Comment{}, fmt.Errorf("%w: only the author of a note can edit or delete it", domain.ErrForbidden)
1521 }
1522 if c.Deleted {
1523 if remove {
1524 return c, nil
1525 }
1526 return domain.Comment{}, fmt.Errorf("%w: the note was deleted", domain.ErrPreconditionFailed)
1527 }
1528 parsed, err := domain.ParsePlainText(text)
1529 var v domain.Violations
1530 if err != nil {
1531 v.AddErr("text", err)
1532 } else if !remove && parsed.IsBlank() && c.Verdict == domain.VerdictNone {
1533 v.Add("text", "is required")
1534 }
1535 if err := v.Err(); err != nil {
1536 return domain.Comment{}, err
1537 }
1538 if !remove && parsed == c.Text {
1539 return c, nil
1540 }
1541 c.Text, c.Deleted, c.EditedAt = parsed, remove, m.clock.Now().UTC()
1542 mr.UpdatedAt = c.EditedAt
1543 if err := m.store.SaveComment(ctx, mr, c); errors.Is(err, domain.ErrConflict) {
1544 continue
1545 } else if err != nil {
1546 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, err)
1547 }
1548 log := zerowrap.FromCtx(ctx)
1549 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")
1550 if remove {
1551 m.bus.Publish(ctx, domain.CommentDeleted{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1552 } else {
1553 m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1554 }
1555 if m.links != nil {
1556 m.links.OnMergeRequestChanged(ctx, repo, number)
1557 }
1558 return c, nil
1559 }
1560 return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
1561}
1562
1563// replyRoot returns the note a reply targets, or a violation when it is
1564// missing, itself a reply, or deleted.
1565func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, number, id int64) (domain.Comment, error) {
1566 root, err := m.store.GetComment(ctx, name, number, id)
1567 var v domain.Violations
1568 switch {
1569 case errors.Is(err, domain.ErrNotFound):
1570 v.Add("reply_to", fmt.Sprintf("no note ^%d", id))
1571 case err != nil:
1572 return domain.Comment{}, err
1573 case root.IsReply() || root.Deleted:
1574 v.Add("reply_to", "must name a non-deleted root note")
1575 }
1576 if err := v.Err(); err != nil {
1577 return domain.Comment{}, err
1578 }
1579 return root, nil
1580}
1581
1582// Compare lists at most ports.MaxCompareCommits source commits absent from target. Unlike bounded
1583// history intersection, ancestry remains accurate even for deep histories.
1584func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) {
1585 if _, err := m.readable(ctx, p, repo); err != nil {
1586 return ports.CompareResult{}, err
1587 }
1588 if target == source {
1589 return ports.CompareResult{Exact: true}, nil
1590 }
1591 ancestor, err := m.git.IsAncestor(ctx, repo, target, source)
1592 if err != nil {
1593 return ports.CompareResult{}, err
1594 }
1595 commits, more, err := m.git.ListRange(ctx, repo, target, source, ports.MaxCompareCommits)
1596 if err != nil {
1597 return ports.CompareResult{}, err
1598 }
1599 verifyCommits(ctx, m.verifier, repo, commits)
1600 return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil
1601}
1602
1603// PushSummary detects rewrites by ancestry, not log ordering, and counts at
1604// most ports.MaxPushSummaryCommits new commits. More distinguishes a longer range from a rewrite.
1605func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) {
1606 if _, err := m.readable(ctx, p, repo); err != nil {
1607 return ports.PushSummary{}, err
1608 }
1609 if oldHead == newTip {
1610 return ports.PushSummary{}, nil
1611 }
1612 ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip)
1613 if err != nil {
1614 return ports.PushSummary{}, err
1615 }
1616 if !ancestor {
1617 return ports.PushSummary{Rewritten: true}, nil
1618 }
1619 commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, ports.MaxPushSummaryCommits)
1620 if err != nil {
1621 return ports.PushSummary{}, err
1622 }
1623 return ports.PushSummary{Count: len(commits), More: more}, nil
1624}
1625
1626// WithMergeLock serializes fn with repository merges and closes.
1627func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
1628 unlock, err := m.lockRepo(ctx, repo)
1629 if err != nil {
1630 return err
1631 }
1632 defer unlock()
1633 return fn()
1634}