detached 388d3be

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

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