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