v0.36.0 1fb2421

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 on the head must pass: none may be failed, queued
999// or running; a head with no checks may be merged. params.IgnoreChecks bypasses
1000// this requirement and needs mr:force-merge.
1001//
1002// The conditions are always checked with Git, under the lock, never from the
1003// stored mergeability. A fast-forward needs the target to be an ancestor of
1004// the head (the author rebases and pushes again) and moves the target to the
1005// head, with the old value as a precondition. A squash and a merge commit need
1006// the head to merge cleanly into the target (a behind branch is fine). A squash
1007// writes one commit with the merged tree, the target as parent and the author
1008// and message of domain.NewSquashCommit; a merge commit has two parents (the
1009// target, then the head) and the message of domain.NewMergeCommit. Both are
1010// signed by the instance key and move the target the same way.
1011//
1012// With DeleteSource the merge is also refused, before anything changes, when
1013// the source is the default branch or the source or target of another open
1014// request.
1015//
1016// Only the merge itself runs before Merge returns, under the lock. Afterwards,
1017// off the request path and without the lock: the deletion of the source branch
1018// (if it still points to the head; a failure is logged and leaves the branch),
1019// MergeRequestMerged then a PushEvent (which carries the deletion too), and
1020// the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
1021// only says that the deletion was requested.
1022func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
1023	mr, err := m.mergeAs(ctx, p, name, number, params, mergeOpts{})
1024	return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
1025}
1026
1027// maxConflictPathsShown is how many conflicting paths a refusal lists.
1028const maxConflictPathsShown = 5
1029
1030// checkMode is the authoritative per-mode check of a merge, done with Git. It
1031// returns the tree a squash or merge commit gets (zero for a fast-forward).
1032func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
1033	if mode == domain.MergeModeFastForward {
1034		ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head)
1035		if err != nil {
1036			return domain.ObjectID{}, err
1037		}
1038		if !ok {
1039			return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed)
1040		}
1041		return domain.ObjectID{}, nil
1042	}
1043	res, err := m.git.MergeTree(ctx, mr.Repo, tip, head)
1044	if err != nil {
1045		return domain.ObjectID{}, err
1046	}
1047	res = res.Bounded()
1048	if !res.Clean {
1049		what := "no common history"
1050		if len(res.ConflictPaths) > 0 {
1051			shown := res.ConflictPaths
1052			if len(shown) > maxConflictPathsShown {
1053				shown = shown[:maxConflictPathsShown]
1054			}
1055			what = strings.Join(shown, ", ")
1056			if len(shown) < len(res.ConflictPaths) || res.Truncated {
1057				what += ", ..."
1058			}
1059		}
1060		return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what)
1061	}
1062	return res.Tree, nil
1063}
1064
1065type mergeOpts struct {
1066	auto *autoMergeOpts
1067}
1068
1069type autoMergeOpts struct {
1070	intent   *domain.AutoMerge
1071	tokens   ports.TokenStore
1072	activity ports.CIActivity
1073}
1074
1075var errAutoMergeWait = errors.New("auto-merge is waiting for checks")
1076
1077func (m *MergeRequests) mergeAs(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams, opts mergeOpts) (domain.MergeRequest, error) {
1078	if opts.auto == nil {
1079		repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
1080		if err != nil {
1081			return domain.MergeRequest{}, err
1082		}
1083		if params.IgnoreChecks {
1084			if err := scopeAccess(p, repo, domain.ScopeMRForceMerge); err != nil {
1085				return domain.MergeRequest{}, err
1086			}
1087		}
1088	}
1089	mode := params.Mode
1090	if mode == "" {
1091		mode = domain.MergeModeFastForward
1092	}
1093	var v domain.Violations
1094	if _, err := domain.ParseMergeMode(mode.String()); err != nil {
1095		v.Add("mode", "is not a merge mode")
1096	}
1097	if params.HeadID.IsZero() {
1098		v.Add("head_id", "is required")
1099	}
1100	if err := v.Err(); err != nil {
1101		return domain.MergeRequest{}, err
1102	}
1103
1104	unlock, err := m.lockRepo(ctx, name)
1105	if err != nil {
1106		return domain.MergeRequest{}, err
1107	}
1108	var once sync.Once
1109	release := func() { once.Do(unlock) }
1110	defer release()
1111
1112	mr, err := m.store.Get(ctx, name, number)
1113	if err != nil {
1114		return domain.MergeRequest{}, err
1115	}
1116	if opts.auto != nil && (mr.AutoMerge == nil || opts.auto.intent == nil || *mr.AutoMerge != *opts.auto.intent) {
1117		return domain.MergeRequest{}, errAutoMergeWait
1118	}
1119	if opts.auto != nil {
1120		p, err = enablerCanMerge(ctx, opts.auto.tokens, opts.auto.intent, mr, m.clock.Now())
1121		if err != nil {
1122			return domain.MergeRequest{}, err
1123		}
1124	}
1125	// A merge moves the target and may delete the source: a token limited to
1126	// branch prefixes may do so only within them.
1127	if !p.AllowsRef("refs/heads/" + mr.Target) {
1128		return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
1129	}
1130	if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
1131		return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
1132	}
1133	if !mr.IsOpen() {
1134		return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1135	}
1136	if mr.IsDraft() {
1137		return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
1138	}
1139	// The settings are read again under the lock: a change applies at once.
1140	repo, err := m.meta.Get(ctx, name)
1141	if err != nil {
1142		if errors.Is(err, domain.ErrNotFound) {
1143			return domain.MergeRequest{}, err // deleted meanwhile
1144		}
1145		return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
1146	}
1147	settings := repo.EffectiveSettings()
1148	if !settings.Allows(mode) {
1149		return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
1150	}
1151
1152	head, err := m.branchOrZero(ctx, name, mr.Source)
1153	switch {
1154	case err != nil:
1155		return domain.MergeRequest{}, err
1156	case head.IsZero():
1157		return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
1158	case head != params.HeadID:
1159		return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
1160	}
1161	tip, err := m.branchOrZero(ctx, name, mr.Target)
1162	switch {
1163	case err != nil:
1164		return domain.MergeRequest{}, err
1165	case tip.IsZero():
1166		return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
1167	case tip == head:
1168		return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
1169	}
1170	// A head behind the target (or at it) has nothing to merge, in any mode.
1171	if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil {
1172		return domain.MergeRequest{}, err
1173	} else if behind {
1174		return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1175	}
1176	if opts.auto != nil && opts.auto.activity != nil && !opts.auto.activity.Settled(ctx, name, number) {
1177		return domain.MergeRequest{}, errAutoMergeWait
1178	}
1179	tree, err := m.checkMode(ctx, mr, mode, tip, head)
1180	if err != nil {
1181		if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1182			return domain.MergeRequest{}, errAutoMergeWait
1183		}
1184		return domain.MergeRequest{}, err
1185	}
1186	if opts.auto != nil && !settings.AllowAutoMerge {
1187		return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s", domain.ErrPreconditionFailed, name)
1188	}
1189	if !params.IgnoreChecks || opts.auto != nil {
1190		checks, err := m.readMergeChecks(ctx, name, head)
1191		if err == nil {
1192			err = checksPolicy(checks)
1193		}
1194		if err != nil {
1195			if opts.auto != nil && errors.Is(err, domain.ErrPreconditionFailed) {
1196				return domain.MergeRequest{}, errAutoMergeWait
1197			}
1198			return domain.MergeRequest{}, err
1199		}
1200		if opts.auto != nil && !hasSuccessfulCheck(checks) {
1201			return domain.MergeRequest{}, errAutoMergeWait
1202		}
1203	}
1204	if params.DeleteSource {
1205		if err := m.checkDeletable(ctx, name, mr); err != nil {
1206			return domain.MergeRequest{}, err
1207		}
1208	}
1209
1210	merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p)
1211	if err != nil {
1212		return domain.MergeRequest{}, err
1213	}
1214	m.recordPush(ctx, name) // the target moved, whatever happens next
1215	mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged, opts.auto != nil)
1216	if err != nil {
1217		return domain.MergeRequest{}, err
1218	}
1219	log := zerowrap.FromCtx(ctx)
1220	log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
1221		Str("target", mr.Target).Str("commit", merged.String()).
1222		Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
1223
1224	// The merge is done and recorded. The rest runs without the lock and after
1225	// the caller got its answer.
1226	release()
1227	bg := context.WithoutCancel(ctx)
1228	m.after.Add(1)
1229	go func() {
1230		defer m.after.Done()
1231		defer func() {
1232			if r := recover(); r != nil {
1233				log := zerowrap.FromCtx(bg)
1234				log.Error().Str("repo", name.String()).Int64("number", number).
1235					Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked")
1236			}
1237		}()
1238		if m.afterGate != nil {
1239			m.afterGate()
1240		}
1241		m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource)
1242	}()
1243	return mr, nil
1244}
1245
1246// afterMerge is what follows a merge off the request path: errors are logged,
1247// never returned. mr is the merged request, tip the old target and merged the
1248// new one.
1249func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1250	if m.links != nil {
1251		m.links.OnMerged(ctx, p, repo, mr)
1252	}
1253	name := repo.Name
1254	updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1255	if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
1256		updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
1257	}
1258	at := mr.UpdatedAt
1259	recordActivity(ctx, m.meta, repo.Name, at)
1260	m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, Auto: mr.AutoMerged, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1261	m.bus.Publish(ctx, domain.PushEvent{
1262		Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
1263	})
1264	if m.mirror != nil {
1265		m.mirror.OnPush(ctx, name, updates) // marks dirty only; never blocks
1266	}
1267	if m.pulls != nil {
1268		m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1269	}
1270	if m.ci != nil {
1271		// GitHub runs the "push" workflows of a merge, with the owner as actor;
1272		// the run is trusted with what the merging principal may read.
1273		m.ci.OnMerge(ctx, name, updates, p)
1274	}
1275	if m.mergeab != nil {
1276		m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1277	}
1278	if m.links != nil {
1279		m.links.OnBranchesMoved(ctx, name, updates) // open requests whose source moved
1280	}
1281}
1282
1283// deleteSource deletes the source branch of a merged request if it is still
1284// head and still unused, and reports whether it did. The eligibility is checked
1285// again and the branch deleted in one critical section of the merge lock: a
1286// request opened on the branch since the merge, or a push to it, keeps it. The
1287// record of who pushed the branch goes only after a deletion, and only if it
1288// still names head.
1289func (m *MergeRequests) deleteSource(ctx context.Context, p domain.Principal, name domain.RepoName, mr domain.MergeRequest, head domain.ObjectID) bool {
1290	log := zerowrap.FromCtx(ctx).With().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source).Logger()
1291	unlock, err := m.lockRepo(ctx, name)
1292	if err != nil {
1293		log.Warn().Err(err).Msg("source branch kept after the merge")
1294		return false
1295	}
1296	defer unlock()
1297	if err := m.checkDeletable(ctx, name, mr); err != nil {
1298		log.Warn().Err(err).Msg("source branch kept after the merge")
1299		return false
1300	}
1301	if err := m.git.DeleteBranch(ctx, name, mr.Source, head); err != nil {
1302		log.Warn().Err(err).Msg("source branch kept after the merge")
1303		return false
1304	}
1305	if m.tips != nil {
1306		if err := m.tips.DeleteRefTipIf(ctx, name, "refs/heads/"+mr.Source, head.String()); err != nil && !errors.Is(err, domain.ErrNotFound) {
1307			log.Warn().Err(err).Msg("the record of the source branch was kept")
1308		}
1309	}
1310	log.Info().Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1311	return true
1312}
1313
1314// WhileOpen implements ports.PullGate: fn runs under the merge lock, so a
1315// close or a merge of the request cannot complete in the middle of it, and not
1316// at all when the request is no longer open.
1317func (m *MergeRequests) WhileOpen(ctx context.Context, name domain.RepoName, number int64, fn func() error) error {
1318	unlock, err := m.lockRepo(ctx, name)
1319	if err != nil {
1320		return err
1321	}
1322	defer unlock()
1323	mr, err := m.store.Get(ctx, name, number)
1324	if err != nil {
1325		return err
1326	}
1327	if !mr.IsOpen() {
1328		return fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1329	}
1330	return fn()
1331}
1332
1333// readMergeChecks reads one snapshot, classifying an incomplete result as a
1334// failed precondition rather than treating it as a passing set of checks.
1335func (m *MergeRequests) readMergeChecks(ctx context.Context, name domain.RepoName, head domain.ObjectID) ([]domain.CICheck, error) {
1336	if m.checks == nil {
1337		return nil, nil
1338	}
1339	checks, err := m.checks.CommitChecks(ctx, name, head.String())
1340	if errors.Is(err, domain.ErrResourceExhausted) {
1341		return nil, fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
1342	}
1343	if err != nil {
1344		return nil, fmt.Errorf("read the checks of %s: %w", head, err)
1345	}
1346	return checks, nil
1347}
1348
1349func hasSuccessfulCheck(checks []domain.CICheck) bool {
1350	for _, check := range checks {
1351		if check.Status == domain.CICompleted && check.Conclusion == domain.CISuccess {
1352			return true
1353		}
1354	}
1355	return false
1356}
1357
1358// checksPolicy requires every check to have ended as success, skipped or
1359// neutral. Queued and running checks block too; an empty set is allowed.
1360func checksPolicy(checks []domain.CICheck) error {
1361	var failing, pending []string
1362	for _, c := range checks {
1363		switch {
1364		case c.Status != domain.CICompleted:
1365			pending = append(pending, c.Name)
1366		case !c.Conclusion.PassesMerge():
1367			failing = append(failing, c.Name)
1368		}
1369	}
1370	switch {
1371	case len(failing) > 0:
1372		return fmt.Errorf("%w: failing checks block the merge: %s (merge with --ignore-checks, needs the mr:force-merge scope)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
1373	case len(pending) > 0:
1374		return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
1375	}
1376	return nil
1377}
1378
1379// checkDeletable refuses to delete the source branch of mr when it is the
1380// default branch or the source or target of another open request. It runs
1381// under the merge lock, which Create also holds while it checks its branches
1382// and stores the request.
1383func (m *MergeRequests) checkDeletable(ctx context.Context, name domain.RepoName, mr domain.MergeRequest) error {
1384	def, _, err := m.repos.DefaultBranch(ctx, name)
1385	if err != nil {
1386		return fmt.Errorf("default branch of %q: %w", name, err)
1387	}
1388	if mr.Source == def {
1389		return fmt.Errorf("%w: %q is the default branch of %s: it cannot be deleted", domain.ErrPreconditionFailed, mr.Source, name)
1390	}
1391	all, err := m.store.List(ctx, name)
1392	if err != nil {
1393		return fmt.Errorf("list merge requests of %q: %w", name, err)
1394	}
1395	for _, o := range all {
1396		if o.Number != mr.Number && o.IsOpen() && (o.Source == mr.Source || o.Target == mr.Source) {
1397			return fmt.Errorf("%w: !%d still uses %q: it cannot be deleted", domain.ErrPreconditionFailed, o.Number, mr.Source)
1398		}
1399	}
1400	return nil
1401}
1402
1403// recordPush does for a merge what Pushes.Record does for a push, as the merge
1404// moved a branch: it sets pushed_at and queues the maintenance of the
1405// repository. The target already moved, so a failure is logged, not returned,
1406// and the caller giving up does not stop it.
1407func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
1408	ctx = context.WithoutCancel(ctx)
1409	if _, err := touchPushedAt(ctx, m.meta, name, m.clock.Now().UTC()); err != nil {
1410		log := zerowrap.FromCtx(ctx)
1411		log.Error().Err(err).Str("repo", name.String()).Msg("record pushed_at after a merge")
1412	}
1413	m.maint.Schedule(ctx, name)
1414}
1415
1416// moveTarget moves the target branch from tip to the head (fast-forward) or to
1417// a new signed squash or merge commit with the given tree, and returns the new tip.
1418func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
1419	newTip := head
1420	switch mode {
1421	case domain.MergeModeMerge:
1422		commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now())
1423		armored, err := m.signer.Sign(commit.Payload())
1424		if err != nil {
1425			return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1426		}
1427		if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1428			return domain.ObjectID{}, err
1429		}
1430	case domain.MergeModeSquash:
1431		commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
1432		if err != nil {
1433			return domain.ObjectID{}, err
1434		}
1435		if more {
1436			return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
1437		}
1438		commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
1439		armored, err := m.signer.Sign(commit.Payload())
1440		if err != nil {
1441			return domain.ObjectID{}, fmt.Errorf("sign the squash commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1442		}
1443		if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1444			return domain.ObjectID{}, err
1445		}
1446	}
1447	// The target is the merger's from the moment it moves: recorded before it
1448	// does, under the merge lock (domain.CIRefTip). The run shows the owner, as a
1449	// merge's push does on GitHub.
1450	if err := recordRefTips(ctx, m.tips, mr.Repo, []domain.RefUpdate{{Old: tip.String(), New: newTip.String(), Ref: "refs/heads/" + mr.Target}},
1451		domain.AuthorName(domain.Owner()), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
1452		return domain.ObjectID{}, err
1453	}
1454	err := m.git.UpdateBranch(ctx, mr.Repo, mr.Target, tip, newTip)
1455	if errors.Is(err, domain.ErrConflict) {
1456		return domain.ObjectID{}, fmt.Errorf("%w: the target branch %q moved during the merge, try again", domain.ErrPreconditionFailed, mr.Target)
1457	}
1458	if err != nil {
1459		return domain.ObjectID{}, err
1460	}
1461	return newTip, nil
1462}
1463
1464// markMerged records the merge. The target already moved, so a write that
1465// loses a race (a comment added meanwhile) is repeated on the fresh request.
1466func (m *MergeRequests) markMerged(ctx context.Context, name domain.RepoName, number int64, mode domain.MergeMode, base, head, merged domain.ObjectID, auto bool) (domain.MergeRequest, error) {
1467	// The merge must be recorded even when the caller gives up.
1468	ctx = context.WithoutCancel(ctx)
1469	for range maxWriteRetries {
1470		mr, err := m.store.Get(ctx, name, number)
1471		if err != nil {
1472			return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1473		}
1474		mr.AutoMerge, mr.AutoMerged = nil, auto
1475		mr.State, mr.HeadID, mr.BaseID, mr.MergedCommitID, mr.MergeMode = domain.MRStateMerged, head, base, merged, mode
1476		mr.UpdatedAt = m.clock.Now().UTC()
1477		err = m.store.Update(ctx, mr)
1478		if errors.Is(err, domain.ErrConflict) {
1479			continue
1480		}
1481		if err != nil {
1482			return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, err)
1483		}
1484		return mr, nil
1485	}
1486	log := zerowrap.FromCtx(ctx)
1487	log.Error().Str("repo", name.String()).Int64("number", number).Str("commit", merged.String()).
1488		Msg("the target branch moved but the merge request could not be marked merged")
1489	return domain.MergeRequest{}, fmt.Errorf("record the merge of !%d in %q: %w", number, name, domain.ErrConflict)
1490}
1491
1492// EditComment changes only the author's text, preserving the immutable verdict.
1493func (m *MergeRequests) EditComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string) (domain.Comment, error) {
1494	return m.changeComment(ctx, p, name, number, commentID, text, false)
1495}
1496
1497// DeleteComment removes text but retains the anchor, ID and verdict. Repeated deletion is a no-op.
1498func (m *MergeRequests) DeleteComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64) (domain.Comment, error) {
1499	return m.changeComment(ctx, p, name, number, commentID, "", true)
1500}
1501
1502func (m *MergeRequests) changeComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, text string, remove bool) (domain.Comment, error) {
1503	repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
1504	if err != nil {
1505		return domain.Comment{}, err
1506	}
1507	for range maxWriteRetries {
1508		mr, err := m.store.Get(ctx, name, number)
1509		if err != nil {
1510			return domain.Comment{}, err
1511		}
1512		c, err := m.store.GetComment(ctx, name, number, commentID)
1513		if err != nil {
1514			return domain.Comment{}, err
1515		}
1516		if !mr.IsOpen() {
1517			return domain.Comment{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1518		}
1519		if domain.AuthorName(p) != c.Author {
1520			return domain.Comment{}, fmt.Errorf("%w: only the author of a note can edit or delete it", domain.ErrForbidden)
1521		}
1522		if c.Deleted {
1523			if remove {
1524				return c, nil
1525			}
1526			return domain.Comment{}, fmt.Errorf("%w: the note was deleted", domain.ErrPreconditionFailed)
1527		}
1528		parsed, err := domain.ParsePlainText(text)
1529		var v domain.Violations
1530		if err != nil {
1531			v.AddErr("text", err)
1532		} else if !remove && parsed.IsBlank() && c.Verdict == domain.VerdictNone {
1533			v.Add("text", "is required")
1534		}
1535		if err := v.Err(); err != nil {
1536			return domain.Comment{}, err
1537		}
1538		if !remove && parsed == c.Text {
1539			return c, nil
1540		}
1541		c.Text, c.Deleted, c.EditedAt = parsed, remove, m.clock.Now().UTC()
1542		mr.UpdatedAt = c.EditedAt
1543		if err := m.store.SaveComment(ctx, mr, c); errors.Is(err, domain.ErrConflict) {
1544			continue
1545		} else if err != nil {
1546			return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, err)
1547		}
1548		log := zerowrap.FromCtx(ctx)
1549		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")
1550		if remove {
1551			m.bus.Publish(ctx, domain.CommentDeleted{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1552		} else {
1553			m.bus.Publish(ctx, domain.CommentEdited{Repo: name, Number: number, Comment: c, At: c.EditedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1554		}
1555		if m.links != nil {
1556			m.links.OnMergeRequestChanged(ctx, repo, number)
1557		}
1558		return c, nil
1559	}
1560	return domain.Comment{}, fmt.Errorf("save note %d of !%d in %q: %w", commentID, number, name, domain.ErrConflict)
1561}
1562
1563// replyRoot returns the note a reply targets, or a violation when it is
1564// missing, itself a reply, or deleted.
1565func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, number, id int64) (domain.Comment, error) {
1566	root, err := m.store.GetComment(ctx, name, number, id)
1567	var v domain.Violations
1568	switch {
1569	case errors.Is(err, domain.ErrNotFound):
1570		v.Add("reply_to", fmt.Sprintf("no note ^%d", id))
1571	case err != nil:
1572		return domain.Comment{}, err
1573	case root.IsReply() || root.Deleted:
1574		v.Add("reply_to", "must name a non-deleted root note")
1575	}
1576	if err := v.Err(); err != nil {
1577		return domain.Comment{}, err
1578	}
1579	return root, nil
1580}
1581
1582// Compare lists at most ports.MaxCompareCommits source commits absent from target. Unlike bounded
1583// history intersection, ancestry remains accurate even for deep histories.
1584func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) {
1585	if _, err := m.readable(ctx, p, repo); err != nil {
1586		return ports.CompareResult{}, err
1587	}
1588	if target == source {
1589		return ports.CompareResult{Exact: true}, nil
1590	}
1591	ancestor, err := m.git.IsAncestor(ctx, repo, target, source)
1592	if err != nil {
1593		return ports.CompareResult{}, err
1594	}
1595	commits, more, err := m.git.ListRange(ctx, repo, target, source, ports.MaxCompareCommits)
1596	if err != nil {
1597		return ports.CompareResult{}, err
1598	}
1599	verifyCommits(ctx, m.verifier, repo, commits)
1600	return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil
1601}
1602
1603// PushSummary detects rewrites by ancestry, not log ordering, and counts at
1604// most ports.MaxPushSummaryCommits new commits. More distinguishes a longer range from a rewrite.
1605func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) {
1606	if _, err := m.readable(ctx, p, repo); err != nil {
1607		return ports.PushSummary{}, err
1608	}
1609	if oldHead == newTip {
1610		return ports.PushSummary{}, nil
1611	}
1612	ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip)
1613	if err != nil {
1614		return ports.PushSummary{}, err
1615	}
1616	if !ancestor {
1617		return ports.PushSummary{Rewritten: true}, nil
1618	}
1619	commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, ports.MaxPushSummaryCommits)
1620	if err != nil {
1621		return ports.PushSummary{}, err
1622	}
1623	return ports.PushSummary{Count: len(commits), More: more}, nil
1624}
1625
1626// WithMergeLock serializes fn with repository merges and closes.
1627func (m *MergeRequests) WithMergeLock(ctx context.Context, repo domain.RepoName, fn func() error) error {
1628	unlock, err := m.lockRepo(ctx, repo)
1629	if err != nil {
1630		return err
1631	}
1632	defer unlock()
1633	return fn()
1634}

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 4

Tags 45

↑↓ moveenter openesc close