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