v0.31.0 d5e0505

mergerequests.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close