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