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

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