v0.36.0 1fb2421

mergerequests_auto.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9
10	"bnema.dev/ermite/internal/domain"
11	"bnema.dev/ermite/internal/ports"
12)
13
14// WithAutoMerge installs the asynchronous evaluator before serving requests.
15func (m *MergeRequests) WithAutoMerge(t ports.AutoMergeTrigger) *MergeRequests {
16	m.autoMerge = t
17	return m
18}
19
20// EnableAutoMerge records the actor and the head the caller saw, which must be
21// the current head of the source branch; re-enabling replaces the
22// previous intent rather than preserving an older actor's authority.
23func (m *MergeRequests) EnableAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.EnableAutoMergeParams) (domain.MergeRequest, error) {
24	if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
25		return domain.MergeRequest{}, err
26	}
27	if m.checks == nil {
28		return domain.MergeRequest{}, fmt.Errorf("%w: this server has no CI", domain.ErrPreconditionFailed)
29	}
30	mode := params.Mode
31	if mode == "" {
32		mode = domain.MergeModeFastForward
33	}
34	var v domain.Violations
35	if _, err := domain.ParseMergeMode(mode.String()); err != nil {
36		v.Add("mode", "is not a merge mode")
37	}
38	if params.HeadID.IsZero() {
39		v.Add("head_id", "is required")
40	}
41	if err := v.Err(); err != nil {
42		return domain.MergeRequest{}, err
43	}
44	unlock, err := m.lockRepo(ctx, name)
45	if err != nil {
46		return domain.MergeRequest{}, err
47	}
48	defer unlock()
49	for range maxWriteRetries {
50		mr, err := m.store.Get(ctx, name, number)
51		if err != nil {
52			return domain.MergeRequest{}, err
53		}
54		if !mr.IsOpen() {
55			return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
56		}
57		if mr.IsDraft() {
58			return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
59		}
60		if !p.AllowsRef("refs/heads/" + mr.Target) {
61			return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
62		}
63		repo, err := m.meta.Get(ctx, name)
64		if err != nil {
65			return domain.MergeRequest{}, err
66		}
67		settings := repo.EffectiveSettings()
68		if !settings.AllowAutoMerge {
69			return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is off in %s: turn it on with `ermite repo edit --allow-auto-merge`", domain.ErrPreconditionFailed, name)
70		}
71		if !settings.Allows(mode) {
72			return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
73		}
74		head, err := m.branchOrZero(ctx, name, mr.Source)
75		if err != nil {
76			return domain.MergeRequest{}, err
77		}
78		if head.IsZero() {
79			return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
80		}
81		if head != params.HeadID {
82			return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
83		}
84		mr.UpdatedAt = m.clock.Now().UTC()
85		mr.AutoMerge = &domain.AutoMerge{Mode: mode, HeadID: head, EnabledBy: domain.PusherOf(p), EnablerLabel: domain.AuthorName(p), EnabledAt: mr.UpdatedAt}
86		if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
87			continue
88		} else if err != nil {
89			return domain.MergeRequest{}, err
90		}
91		recordActivity(ctx, m.meta, name, mr.UpdatedAt)
92		m.bus.Publish(ctx, domain.MergeRequestAutoMergeEnabled{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
93		if m.autoMerge != nil {
94			m.autoMerge.OnAutoMergeEnabled(ctx, name)
95		}
96		return m.withHead(ctx, mr)
97	}
98	return domain.MergeRequest{}, fmt.Errorf("enable auto-merge: %w", domain.ErrConflict)
99}
100
101// CancelAutoMerge clears an open request's intent with the same target access
102// required to enable it.
103func (m *MergeRequests) CancelAutoMerge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
104	if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil {
105		return domain.MergeRequest{}, err
106	}
107	unlock, err := m.lockRepo(ctx, name)
108	if err != nil {
109		return domain.MergeRequest{}, err
110	}
111	defer unlock()
112	mr, err := m.store.Get(ctx, name, number)
113	if err != nil {
114		return domain.MergeRequest{}, err
115	}
116	if !mr.IsOpen() {
117		return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
118	}
119	if !p.AllowsRef("refs/heads/" + mr.Target) {
120		return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
121	}
122	if mr.AutoMerge == nil {
123		return domain.MergeRequest{}, fmt.Errorf("%w: auto-merge is not enabled", domain.ErrPreconditionFailed)
124	}
125	mr, err = m.cancelAutoLocked(ctx, mr, "cancelled by "+domain.AuthorName(p))
126	if err != nil {
127		return domain.MergeRequest{}, err
128	}
129	return m.withHead(ctx, mr)
130}
131
132// cancelAuto takes the merge lock and only cancels the intent evaluated by the
133// caller. A concurrent re-enable, close or merge must not be undone.
134func (m *MergeRequests) cancelAuto(ctx context.Context, mr domain.MergeRequest, reason string) error {
135	unlock, err := m.lockRepo(ctx, mr.Repo)
136	if err != nil {
137		return err
138	}
139	defer unlock()
140	_, err = m.cancelAutoLocked(ctx, mr, reason)
141	return err
142}
143
144func (m *MergeRequests) cancelAutoLocked(ctx context.Context, expected domain.MergeRequest, reason string) (domain.MergeRequest, error) {
145	for range maxWriteRetries {
146		mr, err := m.store.Get(ctx, expected.Repo, expected.Number)
147		if err != nil {
148			return domain.MergeRequest{}, err
149		}
150		if !mr.IsOpen() || mr.AutoMerge == nil || expected.AutoMerge == nil || *mr.AutoMerge != *expected.AutoMerge {
151			return mr, nil
152		}
153		repo, err := m.meta.Get(ctx, mr.Repo)
154		if err != nil {
155			return domain.MergeRequest{}, err
156		}
157		mr.AutoMerge, mr.UpdatedAt = nil, m.clock.Now().UTC()
158		if err := m.store.Update(ctx, mr); errors.Is(err, domain.ErrConflict) {
159			continue
160		} else if err != nil {
161			return domain.MergeRequest{}, err
162		}
163		recordActivity(ctx, m.meta, mr.Repo, mr.UpdatedAt)
164		m.bus.Publish(ctx, domain.MergeRequestAutoMergeCancelled{MergeRequest: mr, Reason: reason, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
165		return mr, nil
166	}
167	return domain.MergeRequest{}, fmt.Errorf("cancel auto-merge: %w", domain.ErrConflict)
168}

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