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