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 "slices"
12 "strconv"
13 "strings"
14 "sync"
15
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// MaxMergeRequestCommits is the most commits GetMergeRequest returns.
23const MaxMergeRequestCommits = 100
24
25// maxWriteRetries bounds the retries of a write that loses a race with another
26// write of the same merge request. Each lost race means another writer won, so
27// a writer loses at most as many times as there are other writers at once; the
28// retry is a read and a write of ermite-kv, cheap enough for a bound this high.
29const maxWriteRetries = 32
30
31// MaxCommentsPerMergeRequest is the most comments a merge request holds. Past
32// it AddComment fails with domain.ErrResourceExhausted (resource_exhausted in
33// the API). Comments are never deleted, so the limit counts the ones ever added.
34const MaxCommentsPerMergeRequest = 1000
35
36const mrTokenPrefix = "mp1:"
37
38// MergeRequests are the merge request use cases (ADR 0017): review, comment,
39// close and merge a branch. Reading needs the access of reading the
40// repository: a repository the principal may not read, a missing one and an
41// unknown number all answer domain.ErrNotFound. Writing needs mr:write, and
42// merging mr:merge, on the repository (see scopeAccess), and that scope also
43// makes a private repository visible for the action (see writable).
44type MergeRequests struct {
45 meta ports.RepoMetaStore
46 store ports.MergeRequestStore
47 repos ports.GitRepos
48 git ports.MergeGit
49 signer ports.CommitSigner
50 committer domain.Identity
51 bus ports.EventBus
52 clock ports.Clock
53 maint ports.MaintenanceScheduler
54 ci ports.WorkflowTrigger // nil: no CI
55 checks ports.CIChecksReader // nil: no CI, so no check blocks a merge
56 pulls ports.PullRequestTrigger // nil: no pull_request workflows
57 tips ports.CIRefTipStore // nil: who merges is not recorded
58
59 mu sync.Mutex
60 locks map[domain.RepoName]chan struct{}
61}
62
63var (
64 _ ports.MergeRequestService = (*MergeRequests)(nil)
65 _ ports.PullGate = (*MergeRequests)(nil)
66)
67
68// MergeRequestsConfig holds what the merge request use cases need. Every field
69// is required.
70type MergeRequestsConfig struct {
71 Meta ports.RepoMetaStore
72 Store ports.MergeRequestStore
73 // Repos gives the default branch of a repository.
74 Repos ports.GitRepos
75 Git ports.MergeGit
76 // Signer signs the squash commits.
77 Signer ports.CommitSigner
78 // Committer is recorded as the committer of the squash commits.
79 Committer domain.Identity
80 Bus ports.EventBus
81 Clock ports.Clock
82 // Maint queues the maintenance that follows a merge, as it does a push.
83 Maint ports.MaintenanceScheduler
84 // CI starts the workflows a merge triggers (GitHub runs "push" workflows
85 // on a merge) and Checks gives the checks the merge rule reads. Both are
86 // optional: without them there is no CI.
87 CI ports.WorkflowTrigger
88 Checks ports.CIChecksReader
89 // PullRequests starts the pull_request workflows when a request is opened,
90 // closed or merged (the push of its source branch reaches CI instead).
91 // Optional.
92 PullRequests ports.PullRequestTrigger
93 // RefTips records who moves the target branch (and deletes the record of a
94 // deleted source branch) before the merge does, as a push does
95 // (domain.CIRefTip). Optional.
96 RefTips ports.CIRefTipStore
97}
98
99// NewMergeRequests builds the merge request use cases.
100func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
101 return &MergeRequests{
102 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
103 committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips,
104 locks: make(map[domain.RepoName]chan struct{}),
105 }
106}
107
108// lockRepo takes the merge lock of a repository, waiting until ctx ends. The
109// merges of one repository, and the creation of its merge requests, run one at
110// a time. The returned function releases the lock.
111func (m *MergeRequests) lockRepo(ctx context.Context, name domain.RepoName) (unlock func(), err error) {
112 m.mu.Lock()
113 ch, ok := m.locks[name]
114 if !ok {
115 ch = make(chan struct{}, 1)
116 m.locks[name] = ch
117 }
118 m.mu.Unlock()
119 select {
120 case ch <- struct{}{}:
121 return func() { <-ch }, nil
122 case <-ctx.Done():
123 return nil, ctx.Err()
124 }
125}
126
127// readable looks the repository up and applies the read rule: reading merge
128// requests needs the access of reading the repository (canRead). repo:write
129// alone does not read them, as it does not fetch a private repository either.
130func (m *MergeRequests) readable(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
131 repo, err := lookupRepo(ctx, m.meta, name)
132 if err != nil {
133 return domain.Repo{}, err
134 }
135 if !canRead(p, repo) {
136 return domain.Repo{}, domain.ErrNotFound
137 }
138 return repo, nil
139}
140
141// writable looks the repository up for an action that needs a merge request
142// scope. A principal whose token holds that scope on the repository sees the
143// repository for this action even without repo:read, so that the agent preset
144// (repo:write, mr:write) can open a request on the private repository it just
145// pushed. A principal with neither repo:read nor the scope, and an unknown
146// repository, still get domain.ErrNotFound; one that can read the repository
147// but lacks the scope gets the error of scopeAccess.
148func (m *MergeRequests) writable(ctx context.Context, p domain.Principal, name domain.RepoName, scope domain.Scope) (domain.Repo, error) {
149 repo, err := lookupRepo(ctx, m.meta, name)
150 if err != nil {
151 return domain.Repo{}, err
152 }
153 if !canRead(p, repo) && !p.Allows(scope, name) {
154 return domain.Repo{}, domain.ErrNotFound
155 }
156 if err := scopeAccess(p, repo, scope); err != nil {
157 return domain.Repo{}, err
158 }
159 return repo, nil
160}
161
162// withHead fills HeadID of an open request from its source branch: zero when
163// the branch is gone. Merged and closed requests keep the head they stored.
164func (m *MergeRequests) withHead(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
165 if !mr.IsOpen() {
166 return mr, nil
167 }
168 head, err := m.git.BranchHead(ctx, mr.Repo, mr.Source)
169 switch {
170 case errors.Is(err, domain.ErrNotFound):
171 mr.HeadID = domain.ObjectID{}
172 case err != nil:
173 return domain.MergeRequest{}, err
174 default:
175 mr.HeadID = head
176 }
177 return mr, nil
178}
179
180// branchOrZero returns the head of a branch, the zero id when it is gone.
181func (m *MergeRequests) branchOrZero(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
182 id, err := m.git.BranchHead(ctx, repo, branch)
183 if errors.Is(err, domain.ErrNotFound) {
184 return domain.ObjectID{}, nil
185 }
186 return id, err
187}
188
189// Create opens a merge request and publishes MergeRequestOpened. The principal
190// needs mr:write. A request that breaks a rule of ADR 0017 (the texts, a
191// branch that does not exist, source and target the same) is rejected whole
192// with an *domain.InvalidFieldsError naming the fields. An open request with
193// the same source and target gives domain.ErrAlreadyExists.
194func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error) {
195 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
196 if err != nil {
197 return domain.MergeRequest{}, err
198 }
199 var v domain.Violations
200 title, err := domain.ParseMRTitle(params.Title)
201 if err != nil {
202 v.AddErr("title", err)
203 }
204 desc, err := domain.ParsePlainText(params.Description)
205 if err != nil {
206 v.AddErr("description", err)
207 }
208 // Under the lock, so that two requests for the same pair cannot both pass
209 // the check for an existing one, and a merge that deletes its source
210 // cannot remove a branch between its check here and the creation.
211 unlock, err := m.lockRepo(ctx, name)
212 if err != nil {
213 return domain.MergeRequest{}, err
214 }
215 defer unlock()
216 source, target, err := m.checkBranches(ctx, name, params, &v)
217 if err != nil {
218 return domain.MergeRequest{}, err
219 }
220 if err := v.Err(); err != nil {
221 return domain.MergeRequest{}, err
222 }
223 existing, err := m.store.List(ctx, name)
224 if err != nil {
225 return domain.MergeRequest{}, fmt.Errorf("list merge requests of %q: %w", name, err)
226 }
227 for _, e := range existing {
228 if e.IsOpen() && e.Source == source && e.Target == target {
229 return domain.MergeRequest{}, fmt.Errorf("%w: !%d already merges %s into %s", domain.ErrAlreadyExists, e.Number, source, target)
230 }
231 }
232
233 now := m.clock.Now().UTC()
234 mr, err := m.store.Create(ctx, domain.MergeRequest{
235 Repo: name, Title: title, Description: desc, Source: source, Target: target,
236 State: domain.MRStateOpen, Author: authorOf(p), CreatedAt: now, UpdatedAt: now,
237 })
238 if err != nil {
239 return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", name, err)
240 }
241 log := zerowrap.FromCtx(ctx)
242 log.Info().Str("repo", name.String()).Int64("number", mr.Number).
243 Str("source", source).Str("target", target).
244 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request opened")
245 mr, err = m.withHead(ctx, mr)
246 if err != nil {
247 return domain.MergeRequest{}, err
248 }
249 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
250 if m.pulls != nil {
251 // The commits the opening is for are the ones of now.
252 base, _ := m.branchOrZero(ctx, name, target)
253 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullOpened, Head: mr.HeadID, Base: base}, p)
254 }
255 return mr, nil
256}
257
258// checkBranches validates the branches of a new request and returns them, with
259// the default branch filled in for the target.
260//
261// Only a branch that does not exist is a violation. Any other failure to read
262// Git is returned as an error, so that it is logged and masked, not shown in
263// the field violations.
264func (m *MergeRequests) checkBranches(ctx context.Context, name domain.RepoName, params ports.CreateMergeRequestParams, v *domain.Violations) (source, target string, err error) {
265 source, target = params.Source, params.Target
266 if source == "" {
267 v.Add("source_branch", "is required")
268 } else if _, err := domain.ParseBranchName(source); err != nil {
269 v.Add("source_branch", "is not a valid branch name")
270 source = ""
271 }
272 if target == "" {
273 def, exists, err := m.repos.DefaultBranch(ctx, name)
274 switch {
275 case err != nil: // includes domain.ErrNotFound: the repository is gone
276 return "", "", fmt.Errorf("read the default branch of %q: %w", name, err)
277 case !exists:
278 v.Add("target_branch", fmt.Sprintf("is empty and the default branch %q does not exist", def))
279 default:
280 target = def
281 }
282 } else if _, err := domain.ParseBranchName(target); err != nil {
283 v.Add("target_branch", "is not a valid branch name")
284 target = ""
285 }
286 if source != "" && source == target {
287 v.Add("target_branch", "must differ from source_branch")
288 return source, target, nil
289 }
290 for _, b := range []struct{ field, name string }{{"source_branch", source}, {"target_branch", target}} {
291 if b.name == "" {
292 continue
293 }
294 _, err := m.git.BranchHead(ctx, name, b.name)
295 if errors.Is(err, domain.ErrNotFound) {
296 v.Add(b.field, fmt.Sprintf("branch %q does not exist", b.name))
297 } else if err != nil {
298 return "", "", fmt.Errorf("read branch %q of %q: %w", b.name, name, err)
299 }
300 }
301 return source, target, nil
302}
303
304// List returns a page of the merge requests of a repository, highest number
305// first. The open ones carry the head of their source branch. The page token
306// names the last number of the previous page, so requests created between two
307// pages never shift the others.
308func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListMergeRequestsParams) (ports.MergeRequestPage, error) {
309 size, err := pageSize(params.PageSize)
310 if err != nil {
311 return ports.MergeRequestPage{}, err
312 }
313 after, err := decodeMRToken(params.PageToken)
314 if err != nil {
315 return ports.MergeRequestPage{}, err
316 }
317 if _, err := m.readable(ctx, p, name); err != nil {
318 return ports.MergeRequestPage{}, err
319 }
320 all, err := m.store.List(ctx, name)
321 if err != nil {
322 return ports.MergeRequestPage{}, fmt.Errorf("list merge requests of %q: %w", name, err)
323 }
324 var page ports.MergeRequestPage
325 page.MergeRequests, page.NextPageToken = pageAfter(all, size,
326 func(mr domain.MergeRequest) bool {
327 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
328 },
329 encodeMRToken)
330 for i, mr := range page.MergeRequests {
331 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
332 return ports.MergeRequestPage{}, err
333 }
334 }
335 return page, nil
336}
337
338// MarkReady implements ports.MergeRequestService: the title of an open draft
339// loses its draft prefix, so the request can be merged. The principal needs
340// mr:write. A request that is not open, or not a draft, gives
341// domain.ErrPreconditionFailed.
342func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
343 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
344 if err != nil {
345 return domain.MergeRequest{}, err
346 }
347 unlock, err := m.lockRepo(ctx, name) // a merge sees the request as it is after
348 if err != nil {
349 return domain.MergeRequest{}, err
350 }
351 defer unlock()
352 for range maxWriteRetries {
353 mr, err := m.store.Get(ctx, name, number)
354 if err != nil {
355 return domain.MergeRequest{}, err
356 }
357 if !mr.IsOpen() {
358 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
359 }
360 title, ok := domain.ReadyTitle(mr.Title)
361 if !ok {
362 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is not a draft", domain.ErrPreconditionFailed, number)
363 }
364 mr.Title, mr.UpdatedAt = title, m.clock.Now().UTC()
365 err = m.store.Update(ctx, mr)
366 if errors.Is(err, domain.ErrConflict) {
367 continue
368 }
369 if err != nil {
370 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
371 }
372 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
373 return m.withHead(ctx, mr)
374 }
375 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
376}
377
378// CountOpen implements ports.MergeRequestService.
379func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
380 if _, err := m.readable(ctx, p, name); err != nil {
381 return 0, err
382 }
383 n, err := m.store.CountOpen(ctx, name)
384 if err != nil {
385 return 0, fmt.Errorf("count open merge requests of %q: %w", name, err)
386 }
387 return n, nil
388}
389
390func encodeMRToken(last domain.MergeRequest) string {
391 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
392}
393
394// decodeMRToken returns 0 for the empty token.
395func decodeMRToken(tok string) (int64, error) {
396 if tok == "" {
397 return 0, nil
398 }
399 raw, ok := decodeToken(tok, mrTokenPrefix)
400 if ok {
401 if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 {
402 return n, nil
403 }
404 }
405 return 0, fmt.Errorf("%w: merge requests", domain.ErrInvalidPageToken)
406}
407
408// get reads a merge request of a repository the principal can read.
409func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Repo, domain.MergeRequest, error) {
410 repo, err := m.readable(ctx, p, name)
411 if err != nil {
412 return domain.Repo{}, domain.MergeRequest{}, err
413 }
414 mr, err := m.store.Get(ctx, name, number)
415 if err != nil {
416 return domain.Repo{}, domain.MergeRequest{}, err
417 }
418 return repo, mr, nil
419}
420
421// Get returns a merge request with its commits, its diff, its comments and the
422// read position. The diff compares the merge base of the target and the head
423// with the head ("target...head"), within domain.DefaultDiffLimits. With
424// opts.SinceLastRead it compares the head recorded by MarkRead with the
425// current head instead, which works after a force-push as long as the recorded
426// commit still exists; when none was recorded the diff is the default one, and
427// when the recorded commit is gone too, with LastReadLost set. A merged or
428// closed request keeps the head and the base it had.
429//
430// opts.Files lists every changed file and reads the hunks of a window of them
431// only; opts.Base and opts.Head make Get fail with domain.ErrConflict when the
432// diff is no longer the one the caller shows (before any diff is read);
433// opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid.
434func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) {
435 limits := domain.DefaultDiffLimits()
436 if w := opts.Files; w != nil {
437 if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) {
438 return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To)
439 }
440 limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To
441 }
442 _, mr, err := m.get(ctx, p, name, number)
443 if err != nil {
444 return ports.MergeRequestDetail{}, err
445 }
446 if mr, err = m.withHead(ctx, mr); err != nil {
447 return ports.MergeRequestDetail{}, err
448 }
449 if !opts.Head.IsZero() && opts.Head != mr.HeadID {
450 return ports.MergeRequestDetail{}, fmt.Errorf("%w: the head of !%d moved to %s", domain.ErrConflict, number, mr.HeadID)
451 }
452 // checkBase fails when the caller shows another diff base than base.
453 checkBase := func(base domain.ObjectID) error {
454 if !opts.Base.IsZero() && opts.Base != base {
455 return fmt.Errorf("%w: the diff base of !%d is now %s", domain.ErrConflict, number, base)
456 }
457 return nil
458 }
459 d := ports.MergeRequestDetail{MergeRequest: mr}
460 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
461 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
462 }
463 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
464 return ports.MergeRequestDetail{}, err
465 }
466
467 exclude, base, err := m.rangeOf(ctx, mr)
468 if err != nil {
469 return ports.MergeRequestDetail{}, err
470 }
471 head := mr.HeadID
472 if head.IsZero() {
473 return d, checkBase(domain.ObjectID{}) // the source branch is gone: no commits, no diff
474 }
475 if !mr.IsOpen() {
476 // After a squash the reviewed commits are only reachable from the
477 // source branch; once it is deleted, gc may prune them.
478 switch ok, err := m.git.HasCommit(ctx, name, head); {
479 case err != nil:
480 return ports.MergeRequestDetail{}, err
481 case !ok:
482 // The reviewed commits are gone: no commits, no diff.
483 if err := checkBase(domain.ObjectID{}); err != nil {
484 return ports.MergeRequestDetail{}, err
485 }
486 return d, nil
487 }
488 }
489 if !opts.DiffOnly {
490 if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil {
491 return ports.MergeRequestDetail{}, err
492 }
493 }
494 if opts.SinceLastRead && !d.LastReadID.IsZero() {
495 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
496 case err != nil:
497 return ports.MergeRequestDetail{}, err
498 case ok:
499 base = d.LastReadID
500 default:
501 d.LastReadLost = true
502 }
503 }
504 if err := checkBase(base); err != nil {
505 return ports.MergeRequestDetail{}, err
506 }
507 if base.IsZero() {
508 return d, nil
509 }
510 d.DiffBaseID = base
511 if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil {
512 return ports.MergeRequestDetail{}, err
513 }
514 return d, nil
515}
516
517// rangeOf returns what the review of a request compares: the commit whose
518// history the request's commits exclude, and the base of its diff. For an open
519// request they are the tip of the target and the merge base of the target and
520// the head; both are zero when the target is gone or shares no history with
521// the head. A merged or closed request keeps the base it stored for both.
522func (m *MergeRequests) rangeOf(ctx context.Context, mr domain.MergeRequest) (exclude, base domain.ObjectID, err error) {
523 if !mr.IsOpen() {
524 return mr.BaseID, mr.BaseID, nil
525 }
526 if mr.HeadID.IsZero() {
527 return domain.ObjectID{}, domain.ObjectID{}, nil
528 }
529 tip, err := m.branchOrZero(ctx, mr.Repo, mr.Target)
530 if err != nil || tip.IsZero() {
531 return domain.ObjectID{}, domain.ObjectID{}, err
532 }
533 base, err = m.git.MergeBase(ctx, mr.Repo, tip, mr.HeadID)
534 if errors.Is(err, domain.ErrNotFound) {
535 return tip, domain.ObjectID{}, nil
536 }
537 return tip, base, err
538}
539
540// AddComment adds a comment and publishes CommentAdded. The principal needs
541// mr:write. The text must follow the rules of ADR 0017 and not be blank; a
542// comment on a line must name a line of the current diff of the request (see
543// domain.Diff.HasLine), else the call fails with domain.ErrInvalid. A request
544// that is not open gives domain.ErrPreconditionFailed, and one that already
545// holds MaxCommentsPerMergeRequest comments domain.ErrResourceExhausted.
546func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.AddCommentParams) (domain.Comment, error) {
547 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
548 if err != nil {
549 return domain.Comment{}, err
550 }
551 mr, err := m.store.Get(ctx, name, number)
552 if err != nil {
553 return domain.Comment{}, err
554 }
555 var v domain.Violations
556 text, err := domain.ParsePlainText(params.Text)
557 switch {
558 case err != nil:
559 v.AddErr("text", err)
560 case text.IsBlank():
561 v.Add("text", "is required")
562 }
563 general := params.Path == ""
564 switch {
565 case general && (params.Line != 0 || params.Side != domain.SideNone):
566 v.Add("path", "is required with line and side")
567 case !general && params.Line < 1:
568 v.Add("line", "must be 1 or more with a path")
569 case !general && params.Side != domain.SideOld && params.Side != domain.SideNew:
570 v.Add("side", "is required with a path")
571 }
572 if err := v.Err(); err != nil {
573 return domain.Comment{}, err
574 }
575 if !mr.IsOpen() {
576 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
577 }
578
579 if mr, err = m.withHead(ctx, mr); err != nil {
580 return domain.Comment{}, err
581 }
582 if !general {
583 if err := m.checkLine(ctx, mr, params); err != nil {
584 return domain.Comment{}, err
585 }
586 }
587
588 var c domain.Comment
589 for range maxWriteRetries {
590 if mr.CommentSeq >= MaxCommentsPerMergeRequest {
591 return domain.Comment{}, fmt.Errorf("%w: !%d already has %d comments", domain.ErrResourceExhausted, number, MaxCommentsPerMergeRequest)
592 }
593 c = domain.Comment{
594 ID: mr.CommentSeq + 1, Author: authorOf(p), Path: params.Path, Line: params.Line, Side: params.Side,
595 CommitID: mr.HeadID, Text: text, CreatedAt: m.clock.Now().UTC(),
596 }
597 mr.CommentSeq, mr.UpdatedAt = c.ID, c.CreatedAt
598 err = m.store.SaveComment(ctx, mr, c)
599 if !errors.Is(err, domain.ErrConflict) {
600 break
601 }
602 // Another write got in between: read the request again.
603 fresh, gerr := m.store.Get(ctx, name, number)
604 if gerr != nil {
605 return domain.Comment{}, gerr
606 }
607 if !fresh.IsOpen() {
608 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, fresh.State)
609 }
610 fresh.HeadID = mr.HeadID
611 mr = fresh
612 }
613 if err != nil {
614 return domain.Comment{}, fmt.Errorf("save comment on !%d of %q: %w", number, name, err)
615 }
616 log := zerowrap.FromCtx(ctx)
617 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
618 Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
619 m.bus.Publish(ctx, domain.CommentAdded{
620 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
621 })
622 return c, nil
623}
624
625// checkLine verifies that a comment designates a line of the current diff of
626// the request.
627func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, params ports.AddCommentParams) error {
628 var v domain.Violations
629 _, base, err := m.rangeOf(ctx, mr)
630 if err != nil {
631 return err
632 }
633 if mr.HeadID.IsZero() || base.IsZero() {
634 v.Add("path", "the merge request has no diff to comment on")
635 return v.Err()
636 }
637 // A comment may be on a file past the 300th that the web view lists, so
638 // the file is looked up among all the files (MaxFiles 0). Reading every
639 // file's hunks within the default total limits would reject a comment on a
640 // file the earlier ones left no budget for. So a first read lists the files
641 // with no hunks (From past the end: cheap, git is stopped at once), and a
642 // second reads the hunks of the one file within the default per-file limit
643 // and total limits, as the web view does for a window holding it.
644 list := domain.DefaultDiffLimits()
645 list.MaxFiles, list.From = 0, math.MaxInt
646 _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list)
647 if err != nil {
648 return err
649 }
650 idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path })
651 if idx >= 0 {
652 one := domain.DefaultDiffLimits()
653 one.MaxFiles, one.From, one.To = 0, idx, idx+1
654 if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil {
655 return err
656 }
657 }
658 if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) {
659 v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side))
660 }
661 return v.Err()
662}
663
664// diffFileName is the path a comment designates for f, as Diff.HasLine reads it.
665func diffFileName(f domain.FileDiff) string {
666 if f.Status == domain.FileDeleted {
667 return f.OldPath
668 }
669 return f.NewPath
670}
671
672// ResolveComment marks a comment resolved or unresolved. The principal needs
673// mr:write. An unknown comment gives domain.ErrNotFound and a request that is
674// not open domain.ErrPreconditionFailed. Setting the state the comment already
675// has succeeds and writes nothing.
676func (m *MergeRequests) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) {
677 if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
678 return domain.Comment{}, err
679 }
680 for range maxWriteRetries {
681 mr, err := m.store.Get(ctx, name, number)
682 if err != nil {
683 return domain.Comment{}, err
684 }
685 c, err := m.store.GetComment(ctx, name, number, commentID)
686 if err != nil {
687 return domain.Comment{}, err
688 }
689 if !mr.IsOpen() {
690 return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
691 }
692 if c.Resolved == resolved {
693 return c, nil
694 }
695 c.Resolved = resolved
696 mr.UpdatedAt = m.clock.Now().UTC()
697 err = m.store.SaveComment(ctx, mr, c)
698 if errors.Is(err, domain.ErrConflict) {
699 continue
700 }
701 if err != nil {
702 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, err)
703 }
704 return c, nil
705 }
706 return domain.Comment{}, fmt.Errorf("resolve comment %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
707}
708
709// Close closes an open merge request without merging it and publishes
710// MergeRequestClosed. The principal needs mr:write. A request that is not open
711// gives domain.ErrPreconditionFailed. The request keeps the head it had and
712// its merge base, so that its diff stays readable.
713func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
714 repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
715 if err != nil {
716 return domain.MergeRequest{}, err
717 }
718 unlock, err := m.lockRepo(ctx, name) // a merge and a close of one request exclude each other
719 if err != nil {
720 return domain.MergeRequest{}, err
721 }
722 defer unlock()
723 for range maxWriteRetries {
724 mr, err := m.store.Get(ctx, name, number)
725 if err != nil {
726 return domain.MergeRequest{}, err
727 }
728 if !mr.IsOpen() {
729 return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
730 }
731 if mr, err = m.withHead(ctx, mr); err != nil {
732 return domain.MergeRequest{}, err
733 }
734 _, base, err := m.rangeOf(ctx, mr)
735 if err != nil {
736 return domain.MergeRequest{}, err
737 }
738 mr.State, mr.BaseID, mr.UpdatedAt = domain.MRStateClosed, base, m.clock.Now().UTC()
739 err = m.store.Update(ctx, mr)
740 if errors.Is(err, domain.ErrConflict) {
741 continue
742 }
743 if err != nil {
744 return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, err)
745 }
746 log := zerowrap.FromCtx(ctx)
747 log.Info().Str("repo", name.String()).Int64("number", number).
748 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
749 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
750 if m.pulls != nil {
751 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
752 }
753 return mr, nil
754 }
755 return domain.MergeRequest{}, fmt.Errorf("close !%d of %q: %w", number, name, domain.ErrConflict)
756}
757
758// MarkRead records the head the reviewer has read, for the since-last-read diff
759// of Get. The principal needs mr:write. A zero head means the current head of
760// the source branch (the stored one for a merged or closed request); one that
761// is not a commit of the repository gives domain.ErrInvalid. It returns the
762// head recorded.
763func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error) {
764 if _, err := m.writable(ctx, p, name, domain.ScopeMRWrite); err != nil {
765 return domain.ObjectID{}, err
766 }
767 mr, err := m.store.Get(ctx, name, number)
768 if err != nil {
769 return domain.ObjectID{}, err
770 }
771 if head.IsZero() {
772 if mr, err = m.withHead(ctx, mr); err != nil {
773 return domain.ObjectID{}, err
774 }
775 if head = mr.HeadID; head.IsZero() {
776 return domain.ObjectID{}, fmt.Errorf("%w: the source branch %q no longer exists, give a head", domain.ErrPreconditionFailed, mr.Source)
777 }
778 } else {
779 var v domain.Violations
780 switch ok, err := m.git.HasCommit(ctx, name, head); {
781 case err != nil:
782 return domain.ObjectID{}, err
783 case !ok:
784 v.Add("head_id", fmt.Sprintf("%s is not a commit of the repository", head))
785 return domain.ObjectID{}, v.Err()
786 }
787 }
788 if err := m.store.SetRead(ctx, name, number, head); err != nil {
789 return domain.ObjectID{}, err
790 }
791 return head, nil
792}
793
794// Merge merges an open merge request, publishes MergeRequestMerged then a
795// PushEvent for the target branch, and returns the merged request. The
796// principal needs mr:merge. Merges of one repository run one at a time.
797//
798// It fails with domain.ErrInvalid for a zero head, and with
799// domain.ErrPreconditionFailed when the request is not open, the mode is not
800// allowed by the settings of the repository, the source branch no longer
801// points to the head ("branch moved, read again") or is gone, the target is
802// gone or is not an ancestor of the head (the author rebases and pushes
803// again), or the branch brings nothing. Checks come with CI (CI-1): there are
804// none yet, so the setting that lets failing checks block the merge has
805// nothing to act on.
806//
807// A fast-forward moves the target to the head, with the old value as a
808// precondition. A squash writes one commit with the tree of the head, the
809// target as parent and the author and message of domain.NewSquashCommit,
810// signed by the instance key, and moves the target to it the same way.
811//
812// With DeleteSource the merge is also refused, before anything changes, when
813// the source is the default branch or the source or target of another open
814// request. After the merge the source is deleted if it still points to the
815// head; the PushEvent then carries the deletion too. A failed deletion is
816// logged and leaves the branch: the merge stands and SourceDeleted is false.
817func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
818 mr, deleted, err := m.merge(ctx, p, name, number, params)
819 return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err
820}
821
822func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, bool, error) {
823 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
824 if err != nil {
825 return domain.MergeRequest{}, false, err
826 }
827 mode := params.Mode
828 if mode == "" {
829 mode = domain.MergeModeFastForward
830 }
831 var v domain.Violations
832 if _, err := domain.ParseMergeMode(mode.String()); err != nil {
833 v.Add("mode", "is not a merge mode")
834 }
835 if params.HeadID.IsZero() {
836 v.Add("head_id", "is required")
837 }
838 if err := v.Err(); err != nil {
839 return domain.MergeRequest{}, false, err
840 }
841
842 unlock, err := m.lockRepo(ctx, name)
843 if err != nil {
844 return domain.MergeRequest{}, false, err
845 }
846 defer unlock()
847
848 mr, err := m.store.Get(ctx, name, number)
849 if err != nil {
850 return domain.MergeRequest{}, false, err
851 }
852 // A merge moves the target and may delete the source: a token limited to
853 // branch prefixes may do so only within them.
854 if !p.AllowsRef("refs/heads/" + mr.Target) {
855 return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
856 }
857 if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
858 return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
859 }
860 if !mr.IsOpen() {
861 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
862 }
863 if mr.IsDraft() {
864 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
865 }
866 // The settings are read again under the lock: a change applies at once.
867 repo, err = m.meta.Get(ctx, name)
868 if err != nil {
869 if errors.Is(err, domain.ErrNotFound) {
870 return domain.MergeRequest{}, false, err // deleted meanwhile
871 }
872 return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err)
873 }
874 settings := repo.Settings
875 if settings.IsZero() {
876 settings = domain.DefaultRepoSettings()
877 }
878 if !settings.Allows(mode) {
879 return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
880 }
881
882 head, err := m.branchOrZero(ctx, name, mr.Source)
883 switch {
884 case err != nil:
885 return domain.MergeRequest{}, false, err
886 case head.IsZero():
887 return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
888 case head != params.HeadID:
889 return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
890 }
891 tip, err := m.branchOrZero(ctx, name, mr.Target)
892 switch {
893 case err != nil:
894 return domain.MergeRequest{}, false, err
895 case tip.IsZero():
896 return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
897 case tip == head:
898 return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
899 }
900 switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {
901 case err != nil:
902 return domain.MergeRequest{}, false, err
903 case !ok:
904 return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target)
905 }
906 if settings.FailingChecksBlock {
907 if err := m.checksAllow(ctx, name, head); err != nil {
908 return domain.MergeRequest{}, false, err
909 }
910 }
911 if params.DeleteSource {
912 if err := m.checkDeletable(ctx, name, mr); err != nil {
913 return domain.MergeRequest{}, false, err
914 }
915 }
916
917 merged, err := m.moveTarget(ctx, mr, mode, tip, head, p)
918 if err != nil {
919 return domain.MergeRequest{}, false, err
920 }
921 m.recordPush(ctx, name) // the target moved, whatever happens next
922 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
923 if err != nil {
924 return domain.MergeRequest{}, false, err
925 }
926 log := zerowrap.FromCtx(ctx)
927 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
928 Str("target", mr.Target).Str("commit", merged.String()).
929 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
930 updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
931 deleted := false
932 if params.DeleteSource {
933 // The merge is done and recorded: a failure here keeps the branch. Who
934 // pushed the source goes first (the branch is gone either way for a
935 // schedule; a kept branch merely has no record: not trusted).
936 if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil {
937 log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept")
938 }
939 switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); {
940 case err != nil:
941 log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
942 Msg("source branch kept after the merge")
943 default:
944 deleted = true
945 updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
946 log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
947 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
948 }
949 }
950 at := mr.UpdatedAt
951 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
952 m.bus.Publish(ctx, domain.PushEvent{
953 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
954 })
955 if m.pulls != nil {
956 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
957 }
958 if m.ci != nil {
959 // GitHub runs the "push" workflows of a merge, with the owner as actor;
960 // the run is trusted with what the merging principal may read.
961 m.ci.OnMerge(ctx, name, updates, p)
962 }
963 return mr, deleted, nil
964}
965
966// WhileOpen implements ports.PullGate: fn runs under the merge lock, so a
967// close or a merge of the request cannot complete in the middle of it, and not
968// at all when the request is no longer open.
969func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, number int64, fn func() error) error {
970 unlock, err := m.lockRepo(ctx, name)
971 if err != nil {
972 return err
973 }
974 defer unlock()
975 mr, err := m.store.Get(ctx, name, number)
976 if err != nil {
977 return err
978 }
979 if !mr.IsOpen() {
980 return fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
981 }
982 return fn()
983}
984
985// checksAllow is the merge rule of FailingChecksBlock: every check of the
986// head must have ended as success, skipped or neutral. A check still queued or
987// running blocks too (the merge could land code that then fails), and a head
988// without any check merges.
989func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error {
990 if m.checks == nil {
991 return nil
992 }
993 checks, err := m.checks.CommitChecks(ctx, name, head.String())
994 if errors.Is(err, domain.ErrResourceExhausted) {
995 return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
996 }
997 if err != nil {
998 return fmt.Errorf("read the checks of %s: %w", head, err)
999 }
1000 var failing, pending []string
1001 for _, c := range checks {
1002 switch {
1003 case c.Status != domain.CICompleted:
1004 pending = append(pending, c.Name)
1005 case !c.Conclusion.PassesMerge():
1006 failing = append(failing, c.Name)
1007 }
1008 }
1009 switch {
1010 case len(failing) > 0:
1011 return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1012 case len(pending) > 0:
1013 return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
1014 }
1015 return nil
1016}
1017
1018// checkDeletable refuses to delete the source branch of mr when it is the
1019// default branch or the source or target of another open request. It runs
1020// under the merge lock, which Create also holds while it checks its branches
1021// and stores the request.
1022func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {
1023 def, _, err := m.repos.DefaultBranch(ctx, name)
1024 if err != nil {
1025 return fmt.Errorf("default branch of %q: %w", name, err)
1026 }
1027 if mr.Source == def {
1028 return fmt.Errorf("%w: %q is the default branch of %s: it cannot be deleted", domain.ErrPreconditionFailed, mr.Source, name)
1029 }
1030 all, err := m.store.List(ctx, name)
1031 if err != nil {
1032 return fmt.Errorf("list merge requests of %q: %w", name, err)
1033 }
1034 for _, o := range all {
1035 if o.Number != mr.Number && o.IsOpen() && (o.Source == mr.Source || o.Target == mr.Source) {
1036 return fmt.Errorf("%w: !%d still uses %q: it cannot be deleted", domain.ErrPreconditionFailed, o.Number, mr.Source)
1037 }
1038 }
1039 return nil
1040}
1041
1042// recordPush does for a merge what Pushes.Record does for a push, as the merge
1043// moved a branch: it sets pushed_at and queues the maintenance of the
1044// repository. The target already moved, so a failure is logged, not returned,
1045// and the caller giving up does not stop it.
1046func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
1047 ctx = context.WithoutCancel(ctx)
1048 if _, err := touchPushedAt(ctx, m.meta, name, m.clock.Now().UTC()); err != nil {
1049 log := zerowrap.FromCtx(ctx)
1050 log.Error().Err(err).Str("repo", name.String()).Msg("record pushed_at after a merge")
1051 }
1052 m.maint.Schedule(ctx, name)
1053}
1054
1055// moveTarget moves the target branch from tip to the head (fast-forward) or to
1056// a new signed squash commit, and returns the new tip.
1057func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
1058 newTip := head
1059 if mode == domain.MergeModeSquash {
1060 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
1061 if err != nil {
1062 return domain.ObjectID{}, err
1063 }
1064 if more {
1065 return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
1066 }
1067 tree, err := m.git.CommitTree(ctx, mr.Repo, head)
1068 if err != nil {
1069 return domain.ObjectID{}, err
1070 }
1071 commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
1072 armored, err := m.signer.Sign(commit.Payload())
1073 if err != nil {
1074 return domain.ObjectID{}, fmt.Errorf("sign the squash commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1075 }
1076 if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1077 return domain.ObjectID{}, err
1078 }
1079 }
1080 // The target is the merger's from the moment it moves: recorded before it
1081 // does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
1082 // merge's push does on GitHub.
1083 if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},
1084 authorOf(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
1085 return domain.ObjectID{}, err
1086 }
1087 err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
1088 if errors.Is(err, domain.ErrConflict) {
1089 return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target)
1090 }
1091 if err != nil {
1092 return domain.ObjectID{}, err
1093 }
1094 return newTip, nil
1095}
1096
1097// markMerged records the merge. The target already moved, so a write that
1098// loses a race (a comment added meanwhile) is repeated on the fresh request.
1099func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID) (domain.MergeRequest, error) {
1100 // The merge must be recorded even when the caller gives up.
1101 ctx = context.WithoutCancel(ctx)
1102 for range maxWriteRetries {
1103 mr, err := m.store.Get(ctx, name, number)
1104 if err != nil {
1105 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1106 }
1107 mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
1108 mr.UpdatedAt = m.clock.Now().UTC()
1109 err = m.store.Update(ctx, mr)
1110 if errors.Is(err, domain.ErrConflict) {
1111 continue
1112 }
1113 if err != nil {
1114 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1115 }
1116 return mr, nil
1117 }
1118 log := zerowrap.FromCtx(ctx)
1119 log.Error().Str("repo", name.String()).Int64("number", number).Str("commit", merged.String()).
1120 Msg("the target branch moved but the merge request could not be marked merged")
1121 return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
1122}