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}