detached 70c00ad

#7 feat(mr): live mergeability, merge commit mode and inline merge errors

state
merged · 70c00ad · fast-forward
branch
feat/mr-mergeability → main
opened
bnema ·
updated

Merge requests show live mergeability and gain a signed merge-commit mode.

  • Mergeability: a background check per repository (merge-tree) reports checking, up to date, behind N or conflict with paths. It is stored, exposed in the API (feature merge_requests.mergeability), published as a Watch event for every settled result on new branch tips, and shown live in the CLI and the web merge box. Failed checks retry per repository with backoff (1 s to 1 min, 5 attempts).
  • Merge modes: fast-forward (requires up to date), squash (uses the merge-tree tree, so a behind but clean branch can be squashed) and merge commit (two parents, signed by the instance key, see 'ermite signing-key show'). Every mode is allowed by default; existing repos are upgraded once. Restrict with 'ermite repo edit --merge-modes'.
  • Merge answer: a refused merge re-renders the page with an inline error (409), never an error page. Post-merge side effects (source branch deletion, events) run after the response.
  • Requires bnema.dev/ermite-api v0.10.0.

Checks: build, vet, hexcheck -fail-on warn, go test -count=1 ./..., race on usecase and app, JS tests.

CommitSubjectAuthorDateSignatureStatus
70c00adtest: use Store.Mutate for the merge-mode upgrade test and skip id-only events in the inbox stream testbriceGPG Verified
bda2a5efix(mr): wait for the async source deletion before pruning in the squash test; simplify the retry pop conditionbriceGPG Verified
1475ed7build(deps): require ermite-api v0.10.0briceGPG Verified
fb5ff06test(mr): cover stale retry timers, branch disappearance and CAS conflict; drop unused clockbriceGPG Verified
c22da12fix(mr): keep replacement retries from stale timers and log reconcile failuresbriceGPG Verified
1036507refactor(mr): reconcile mergeability once per repositorybriceGPG Verified
0019f14refactor(mr): retry failed mergeability work per repository onlybriceGPG Verified
99b00c3fix(mr): count retry attempts only for the keys that wait in the schedulerbriceGPG Verified
33a90cdfix(mr): arm one retry timer per earliest retry in the mergeability schedulerbriceGPG Verified
41023defix(mr): bound mergeability retries with one scheduler and retry failed publishesbriceGPG Verified
0e2e418feat(cli): report the mergeability featurebriceGPG Verified
bfa2dcdfix(rpc): report checking mergeability on open merge requestsbriceGPG Verified
5ad3fbefix(events): deliver mergeability events on owner topicsbriceGPG Verified
f09b4fbfix(mr): bound conflict paths with exact-sized copiesbriceGPG Verified
f8b1d20fix(mr): retry failed mergeability checks with backoffbriceGPG Verified
f8bb86dfix(mr): alternate pending and dirty work in the mergeability queuebriceGPG Verified
a79212efix(mr): refresh live-event goldens and guard shared issue numbersbriceGPG Verified
5d95127fix(web): enable the merge button after choosing a mode by keyboardbriceGPG Verified
1b45752fix(web): keep the merge box inside its popover and the MR URL after a refused mergebriceGPG Verified
b5b4bf9feat(web,cli): live mergeability in the merge box, merge modes and inline merge errorsbriceGPG Verified
fc9623bfix(mr): safe async source deletion, single-run mergeability queue and domain-side merge-tree boundsbriceGPG Verified
bf27c54feat(mr): per-mode merge checks, signed merge commits and side effects after the responsebriceGPG Verified
b707720refactor(kvstore): decode the mergeability record without mutating itbriceGPG Verified
23c66c0feat(mr): background mergeability checks, their Watch event and API fieldbriceGPG Verified
8f36a2ffeat(mr): mergeability types, merge-tree checks, storage record and merge commit modebriceGPG Verified

Checks 8

89 files · +6371 -1025
8/8
M README.md +1 -1
M docs/cli.md +5 -4
M docs/commit-signatures.md +1 -1
M docs/configuration.md +2 -2
M docs/merge-requests.md +45 -7
M gen/ermite/storage/merge_request.pb.go +216 -24
M go.mod +1 -1
M go.sum +2 -2
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +4 -1
M internal/adapters/gitcli/git.go +12 -2
M internal/adapters/gitcli/merge_test.go +83 -0
A internal/adapters/gitcli/mergeability.go +87 -0
M internal/adapters/kvstore/ci_tip.go +29 -0
A internal/adapters/kvstore/ci_tip_test.go +34 -0
M internal/adapters/kvstore/mergerequests.go +150 -5
M internal/adapters/kvstore/mergerequests_open_test.go +25 -0
M internal/adapters/kvstore/mergerequests_test.go +120 -0
M internal/adapters/rpc/api.go +1 -1
M internal/adapters/rpc/convert.go +7 -0
M internal/adapters/rpc/convert_test.go +23 -1
M internal/adapters/rpc/mergerequest.go +47 -3
M internal/adapters/rpc/mergerequest_test.go +1 -0
M internal/adapters/web/events.go +6 -2
M internal/adapters/web/fragments.go +58 -8
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/fragments_test.go +1 -0
M internal/adapters/web/mr.go +156 -18
M internal/adapters/web/mr.templ +92 -4
M internal/adapters/web/mr_post.go +55 -3
M internal/adapters/web/mr_templ.go +1185 -737
A internal/adapters/web/mr_test.go +130 -0
M internal/adapters/web/static/css/60-mr.css +21 -0
M internal/adapters/web/static/js/mr.js +10 -1
M internal/adapters/web/web.go +4 -0
M internal/app/api_helpers_test.go +14 -1
M internal/app/api_test.go +1 -1
M internal/app/ci_schedule_trust_test.go +6 -2
M internal/app/cli_e2e_test.go +14 -2
M internal/app/cli_mr.go +150 -24
M internal/app/cli_mr_e2e_test.go +6 -6
M internal/app/cli_mr_test.go +83 -7
M internal/app/cli_repo.go +9 -7
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch.go +30 -0
M internal/app/cli_watch_test.go +43 -0
M internal/app/forge.go +14 -1
M internal/app/mr_api_test.go +3 -2
M internal/app/testdata/web/commits-root.html +1 -1
@@ -972,7 +972,7 @@
972972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
973973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974974 htmx:sse:before:connection -> ermiteLive.gate(event);
975- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
975+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976976 gap -> ermiteLive.reload()">
977977 </span>
978978 </div>
@@ -972,7 +972,7 @@
972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
973 htmx:sse:error -> ermiteLive.error(event.detail.status);973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974 htmx:sse:before:connection -> ermiteLive.gate(event);974 htmx:sse:before:connection -> ermiteLive.gate(event);
975- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);975+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976 gap -> ermiteLive.reload()">976 gap -> ermiteLive.reload()">
977 </span>977 </span>
978 </div>978 </div>
M internal/app/testdata/web/commits-small.html +1 -1
@@ -317,7 +317,7 @@
317317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
318318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319319 htmx:sse:before:connection -> ermiteLive.gate(event);
320- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
320+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321321 gap -> ermiteLive.reload()">
322322 </span>
323323 </div>
@@ -317,7 +317,7 @@
317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
318 htmx:sse:error -> ermiteLive.error(event.detail.status);318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319 htmx:sse:before:connection -> ermiteLive.gate(event);319 htmx:sse:before:connection -> ermiteLive.gate(event);
320- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);320+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321 gap -> ermiteLive.reload()">321 gap -> ermiteLive.reload()">
322 </span>322 </span>
323 </div>323 </div>
M internal/app/testdata/web/home.html +1 -1
@@ -193,7 +193,7 @@
193193 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
194194 htmx:sse:error -> ermiteLive.error(event.detail.status);
195195 htmx:sse:before:connection -> ermiteLive.gate(event);
196- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
196+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
197197 gap -> ermiteLive.reload()">
198198 </span>
199199 </div>
@@ -193,7 +193,7 @@
193 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();193 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
194 htmx:sse:error -> ermiteLive.error(event.detail.status);194 htmx:sse:error -> ermiteLive.error(event.detail.status);
195 htmx:sse:before:connection -> ermiteLive.gate(event);195 htmx:sse:before:connection -> ermiteLive.gate(event);
196- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);196+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
197 gap -> ermiteLive.reload()">197 gap -> ermiteLive.reload()">
198 </span>198 </span>
199 </div>199 </div>
M internal/app/testdata/web/refs.html +1 -1
@@ -199,7 +199,7 @@
199199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
200200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201201 htmx:sse:before:connection -> ermiteLive.gate(event);
202- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
202+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203203 gap -> ermiteLive.reload()">
204204 </span>
205205 </div>
@@ -199,7 +199,7 @@
199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
200 htmx:sse:error -> ermiteLive.error(event.detail.status);200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201 htmx:sse:before:connection -> ermiteLive.gate(event);201 htmx:sse:before:connection -> ermiteLive.gate(event);
202- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);202+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203 gap -> ermiteLive.reload()">203 gap -> ermiteLive.reload()">
204 </span>204 </span>
205 </div>205 </div>
M internal/app/testdata/web/summary.html +1 -1
@@ -225,7 +225,7 @@
225225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
226226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227227 htmx:sse:before:connection -> ermiteLive.gate(event);
228- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
228+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229229 gap -> ermiteLive.reload()">
230230 </span>
231231 </div>
@@ -225,7 +225,7 @@
225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
226 htmx:sse:error -> ermiteLive.error(event.detail.status);226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227 htmx:sse:before:connection -> ermiteLive.gate(event);227 htmx:sse:before:connection -> ermiteLive.gate(event);
228- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);228+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229 gap -> ermiteLive.reload()">229 gap -> ermiteLive.reload()">
230 </span>230 </span>
231 </div>231 </div>
M internal/app/web.go +5 -0
@@ -35,6 +35,10 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
3535 if f.Passkeys != nil {
3636 passkeys = webPasskeys{f.Passkeys}
3737 }
38+ var signing ports.SigningKeyService
39+ if f.SigningKey != nil {
40+ signing = f.SigningKey
41+ }
3842 // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section
3943 // for them and no download route.
4044 var artifacts ports.ArtifactService
@@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
5054 Inbox: f.Inbox,
5155 IssueForm: f.IssueSubmissions,
5256 Moderation: f.IssueModeration,
57+ SigningKey: signing,
5358 Runs: f.CI,
5459 Artifacts: artifacts,
5560 Events: f.EventLog,
@@ -35,6 +35,10 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
35 if f.Passkeys != nil {35 if f.Passkeys != nil {
36 passkeys = webPasskeys{f.Passkeys}36 passkeys = webPasskeys{f.Passkeys}
37 }37 }
38+ var signing ports.SigningKeyService
39+ if f.SigningKey != nil {
40+ signing = f.SigningKey
41+ }
38 // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section42 // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section
39 // for them and no download route.43 // for them and no download route.
40 var artifacts ports.ArtifactService44 var artifacts ports.ArtifactService
@@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
50 Inbox: f.Inbox,54 Inbox: f.Inbox,
51 IssueForm: f.IssueSubmissions,55 IssueForm: f.IssueSubmissions,
52 Moderation: f.IssueModeration,56 Moderation: f.IssueModeration,
57+ SigningKey: signing,
53 Runs: f.CI,58 Runs: f.CI,
54 Artifacts: artifacts,59 Artifacts: artifacts,
55 Events: f.EventLog,60 Events: f.EventLog,
M internal/app/web_events_test.go +9 -0
@@ -37,6 +37,12 @@ type sseStream struct {
3737 // background. It returns once the server answered, which it does when the
3838 // watch is registered: what happens after is not missed.
3939 func (h *harness) sse(query, lastID string) *sseStream {
40+ h.t.Helper()
41+ return h.sseAs(query, lastID, nil)
42+}
43+
44+// sseAs opens the stream with the headers given (the owner's cookie).
45+func (h *harness) sseAs(query, lastID string, headers map[string]string) *sseStream {
4046 h.t.Helper()
4147 ctx, cancel := context.WithCancel(h.ctx)
4248 h.t.Cleanup(cancel)
@@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream {
4551 if lastID != "" {
4652 req.Header.Set("Last-Event-ID", lastID)
4753 }
54+ for k, v := range headers {
55+ req.Header.Set(k, v)
56+ }
4857 resp, err := http.DefaultClient.Do(req)
4958 require.NoError(h.t, err)
5059 t := h.t
@@ -37,6 +37,12 @@ type sseStream struct {
37 // background. It returns once the server answered, which it does when the37 // background. It returns once the server answered, which it does when the
38 // watch is registered: what happens after is not missed.38 // watch is registered: what happens after is not missed.
39 func (h *harness) sse(query, lastID string) *sseStream {39 func (h *harness) sse(query, lastID string) *sseStream {
40+ h.t.Helper()
41+ return h.sseAs(query, lastID, nil)
42+}
43+
44+// sseAs opens the stream with the headers given (the owner's cookie).
45+func (h *harness) sseAs(query, lastID string, headers map[string]string) *sseStream {
40 h.t.Helper()46 h.t.Helper()
41 ctx, cancel := context.WithCancel(h.ctx)47 ctx, cancel := context.WithCancel(h.ctx)
42 h.t.Cleanup(cancel)48 h.t.Cleanup(cancel)
@@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream {
45 if lastID != "" {51 if lastID != "" {
46 req.Header.Set("Last-Event-ID", lastID)52 req.Header.Set("Last-Event-ID", lastID)
47 }53 }
54+ for k, v := range headers {
55+ req.Header.Set(k, v)
56+ }
48 resp, err := http.DefaultClient.Do(req)57 resp, err := http.DefaultClient.Do(req)
49 require.NoError(h.t, err)58 require.NoError(h.t, err)
50 t := h.t59 t := h.t
M internal/app/web_inbox_test.go +15 -3
@@ -48,17 +48,17 @@ func TestWebInboxEndToEnd(t *testing.T) {
4848 root := domain.Root()
4949 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
5050 require.NoError(t, err)
51- ev := stream.next(t, watchWait)
51+ ev := nextFragment(t, stream)
5252 assert.Equal(t, "inbox", ev.name)
5353 assert.Contains(t, ev.data, `id="inbox-counts"`)
5454 for range 4 { // the other fragments of this event
55- stream.next(t, watchWait)
55+ nextFragment(t, stream)
5656 }
5757 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
5858 require.NoError(t, err)
5959 var seen strings.Builder
6060 for range 5 { // the five fragments of this event
61- seen.WriteString(stream.next(t, watchWait).data)
61+ seen.WriteString(nextFragment(t, stream).data)
6262 }
6363 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
6464 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"})
@@ -109,6 +109,18 @@ func TestWebInboxEndToEnd(t *testing.T) {
109109 assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status)
110110 }
111111
112+// nextFragment returns the next event that carries a fragment. Events the
113+// inbox does not show (a mergeability check) still send their id alone, so a
114+// resumed stream starts after them; they are skipped.
115+func nextFragment(t *testing.T, s *sseStream) sseEvent {
116+ t.Helper()
117+ for {
118+ if ev := s.next(t, watchWait); ev.name != "" {
119+ return ev
120+ }
121+ }
122+}
123+
112124 // ownerStream opens the owner topic with the session cookie.
113125 func ownerStream(t *testing.T, h *harness, token string) *sseStream {
114126 t.Helper()
@@ -48,17 +48,17 @@ func TestWebInboxEndToEnd(t *testing.T) {
48 root := domain.Root()48 root := domain.Root()
49 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})49 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
50 require.NoError(t, err)50 require.NoError(t, err)
51- ev := stream.next(t, watchWait)51+ ev := nextFragment(t, stream)
52 assert.Equal(t, "inbox", ev.name)52 assert.Equal(t, "inbox", ev.name)
53 assert.Contains(t, ev.data, `id="inbox-counts"`)53 assert.Contains(t, ev.data, `id="inbox-counts"`)
54 for range 4 { // the other fragments of this event54 for range 4 { // the other fragments of this event
55- stream.next(t, watchWait)55+ nextFragment(t, stream)
56 }56 }
57 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})57 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
58 require.NoError(t, err)58 require.NoError(t, err)
59 var seen strings.Builder59 var seen strings.Builder
60 for range 5 { // the five fragments of this event60 for range 5 { // the five fragments of this event
61- seen.WriteString(stream.next(t, watchWait).data)61+ seen.WriteString(nextFragment(t, stream).data)
62 }62 }
63 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")63 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
64 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"})64 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("other"), ports.CreateMergeRequestParams{Title: "other work", Source: "feat/search", Target: "main"})
@@ -109,6 +109,18 @@ func TestWebInboxEndToEnd(t *testing.T) {
109 assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status)109 assert.Equal(t, unknown.status, b.get("/-/events?topic=owner&view=inbox", map[string]string{"Authorization": "Bearer " + adminToken}).status)
110 }110 }
111 111
112+// nextFragment returns the next event that carries a fragment. Events the
113+// inbox does not show (a mergeability check) still send their id alone, so a
114+// resumed stream starts after them; they are skipped.
115+func nextFragment(t *testing.T, s *sseStream) sseEvent {
116+ t.Helper()
117+ for {
118+ if ev := s.next(t, watchWait); ev.name != "" {
119+ return ev
120+ }
121+ }
122+}
123+
112 // ownerStream opens the owner topic with the session cookie.124 // ownerStream opens the owner topic with the session cookie.
113 func ownerStream(t *testing.T, h *harness, token string) *sseStream {125 func ownerStream(t *testing.T, h *harness, token string) *sseStream {
114 t.Helper()126 t.Helper()
M internal/app/web_mr_test.go +202 -10
@@ -10,6 +10,7 @@ import (
1010 "regexp"
1111 "strings"
1212 "testing"
13+ "time"
1314
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
@@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
165166 assert.Equal(t, head, formHead(t, own.body))
166167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
167168 assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff")
168- assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default")
169- assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`)
169+ assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default")
170+ assert.Contains(t, own.body, `name="mode" value="squash"`)
171+ assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default")
170172 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
171173 assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)
172174 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
173- assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")
175+ assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash")
176+ assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge")
177+ assert.NotContains(t, own.body, "--squash")
174178 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
175179 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
176180 assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")
@@ -394,6 +398,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
394398 assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
395399 res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
396400 require.Equal(t, http.StatusSeeOther, res.status, res.body)
401+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
397402 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
398403 merged := r.headOf("main")
399404 assert.NotEqual(t, head, merged, "squash makes a new commit")
@@ -463,6 +468,9 @@ func TestWebMergeRequestDraft(t *testing.T) {
463468
464469 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
465470 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
471+ assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address")
472+ assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline")
473+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker")
466474
467475 res = b.form("/proj/merge-requests/1/ready", token, nil)
468476 require.Equal(t, http.StatusSeeOther, res.status, res.body)
@@ -513,6 +521,7 @@ func TestWebMergeDeleteBranchBox(t *testing.T) {
513521 one := b.get("/proj/merge-requests/1", as(token))
514522 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}})
515523 require.Equal(t, http.StatusSeeOther, res.status, res.body)
524+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
516525 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
517526 done := b.get("/proj/merge-requests/1", nil)
518527 assert.Equal(t, 200, done.status, "the merged page reads without its branch")
@@ -571,8 +580,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
571580 r := newMRRepo(t, domain.VisibilityPublic)
572581 list := r.sse("topic=repo:proj&view=mrs", "")
573582 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
574- ev := list.next(t, watchWait)
575- assert.Equal(t, "mr-notice", ev.name)
583+ ev := nextNamed(t, list, "mr-notice")
576584 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
577585 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
578586 assert.NotContains(t, ev.data, "Secret")
@@ -580,19 +588,18 @@ func TestWebMergeRequestEvents(t *testing.T) {
580588 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
581589 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
582590 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
583- ev = one.next(t, watchWait)
584- assert.Equal(t, "mr-notice", ev.name)
591+ ev = nextNamed(t, one, "mr-notice")
585592 assert.Contains(t, ev.data, "a comment was added")
586593 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
587594 assert.NotContains(t, ev.data, "private words")
588595
589596 r.writeFile(r.wt, "more.txt", []byte("more\n"))
590597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
591- ev = one.next(t, watchWait)
598+ ev = nextNamed(t, one, "mr-notice")
592599 assert.Contains(t, ev.data, "the branch changed")
593600
594601 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
595- assert.Contains(t, one.next(t, watchWait).data, "was closed")
602+ assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
596603
597604 // A page of another request hears nothing of this one.
598605 for drained := false; !drained; {
@@ -605,7 +612,20 @@ func TestWebMergeRequestEvents(t *testing.T) {
605612 drained = true
606613 }
607614 }
608- assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`"`)
615+ assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`&amp;target=main"`)
616+}
617+
618+// nextNamed returns the next event that carries the fragment event: the
619+// events that change nothing on the page (an id alone) are skipped.
620+func nextNamed(t *testing.T, s *sseStream, name string) sseEvent {
621+ t.Helper()
622+ for range 10 {
623+ if ev := s.next(t, watchWait); ev.name == name {
624+ return ev
625+ }
626+ }
627+ t.Fatalf("no %s event", name)
628+ return sseEvent{}
609629 }
610630
611631 func (r *mrRepo) get1(path string) string {
@@ -724,3 +744,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) {
724744 }
725745
726746 var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
747+
748+// advanceMain pushes a commit to main that the feature branch lacks. file is
749+// the file it adds (give search.go to conflict with the feature branch).
750+func (r *mrRepo) advanceMain(file, content string) {
751+ r.t.Helper()
752+ r.mustGit(r.wt, "checkout", "-q", "main")
753+ r.writeFile(r.wt, file, []byte(content))
754+ r.mustGit(r.wt, "push", "-q", r.url, "main")
755+ r.mustGit(r.wt, "checkout", "-q", "feat/search")
756+}
757+
758+// mrPageWith polls the owner's page of !1 until it contains want.
759+func (r *mrRepo) mrPageWith(token, want string) string {
760+ r.t.Helper()
761+ var body string
762+ require.Eventually(r.t, func() bool {
763+ body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body
764+ return strings.Contains(body, want)
765+ }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want)
766+ return body
767+}
768+
769+// The merge box shows the mergeability of the request: ready, then behind
770+// (fast-forward is disabled with its reason, the other modes are not), then
771+// in conflict (no mode is possible and the button says why).
772+func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
773+ r := newMRRepo(t, domain.VisibilityPublic)
774+ token := r.signIn(t)
775+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
776+
777+ body := r.mrPageWith(token, "Ready to merge")
778+ assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
779+ assert.Contains(t, body, `data-state="up_to_date"`)
780+ assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
781+ assert.NotContains(t, body, `value="fast-forward" checked disabled`)
782+ assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
783+ assert.Contains(t, body, "signed with ermite's own key (generated on first start)")
784+ assert.Contains(t, body, "<code>ermite signing-key show</code>")
785+ assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
786+ assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
787+ assertNoInline(t, body)
788+
789+ // Main moves on: the branch is behind by one commit and merges cleanly.
790+ r.advanceMain("other.txt", "other\n")
791+ body = r.mrPageWith(token, "Behind main by 1 commit")
792+ assert.Contains(t, body, "merges without conflicts")
793+ assert.Contains(t, body, `data-ff-ok="false"`)
794+ assert.Contains(t, body, `data-merge-ok="true"`)
795+ assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body)
796+ assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`)
797+ assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked")
798+ assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body)
799+
800+ // A conflicting change to main: every mode is disabled and the files are listed.
801+ r.mustGit(r.wt, "checkout", "-q", "main")
802+ r.writeFile(r.wt, "search.go", []byte("package other\n"))
803+ r.mustGit(r.wt, "push", "-q", r.url, "main")
804+ body = r.mrPageWith(token, "Conflicts with main")
805+ assert.Contains(t, body, "rebase needed")
806+ assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`)
807+ assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body)
808+ assert.NotContains(t, body, `name="mode" value="squash" checked`)
809+
810+ // The server refuses it too, and says so in the box of the page.
811+ res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}})
812+ assert.Equal(t, http.StatusConflict, res.status)
813+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
814+ assert.Contains(t, res.body, "conflicts with main")
815+}
816+
817+// A merge the server refuses (the target moved after the page was read) shows
818+// the page of the request again, 409, with the reason in its merge box.
819+func TestWebMergeRequestInlineMergeError(t *testing.T) {
820+ r := newMRRepo(t, domain.VisibilityPublic)
821+ b := browser{r.harness}
822+ token := r.signIn(t)
823+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
824+ body := r.mrPageWith(token, "Ready to merge")
825+ head := formHead(t, body)
826+
827+ r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward
828+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
829+ assert.Equal(t, http.StatusConflict, res.status)
830+ assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
831+ assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page")
832+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
833+ assert.Contains(t, res.body, "the target has moved")
834+ assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error")
835+ assert.Equal(t, head, r.headOf("feat/search"))
836+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error")
837+
838+ // A head that moved is refused the same way.
839+ r.writeFile(r.wt, "more.txt", []byte("more\n"))
840+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
841+ res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
842+ assert.Equal(t, http.StatusConflict, res.status)
843+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
844+ assert.Contains(t, res.body, "branch moved")
845+
846+ // Other refusals keep the state page; a mode that does not exist is a 400.
847+ assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status)
848+}
849+
850+// A merge commit through the page; with the branch deletion requested the
851+// merged page says the branch is being deleted, not that it is gone.
852+func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) {
853+ r := newMRRepo(t, domain.VisibilityPublic)
854+ b := browser{r.harness}
855+ token := r.signIn(t)
856+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
857+ r.advanceMain("other.txt", "other\n")
858+ body := r.mrPageWith(token, "Behind main by 1 commit")
859+ head := formHead(t, body)
860+ main0 := r.headOf("main")
861+
862+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}})
863+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
864+ assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location"))
865+ merged := r.headOf("main")
866+ assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1"))
867+ assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2"))
868+ assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig")
869+ page := b.get("/proj/merge-requests/1?deleting=1", as(token))
870+ assert.Contains(t, page.body, "merge commit")
871+ if strings.Contains(page.body, "is being deleted") {
872+ assert.Contains(t, page.body, "feat/search is being deleted")
873+ }
874+ assert.NotContains(t, page.body, "is deleted")
875+ r.forge.MergeRequests.Wait(r.ctx)
876+ assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
877+ assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted")
878+}
879+
880+// The badge follows the request live: a push to the target turns the fragment
881+// of the page to "checking" or "behind", and the settled result follows.
882+func TestWebMergeRequestMergeabilityLive(t *testing.T) {
883+ r := newMRRepo(t, domain.VisibilityPublic)
884+ token := r.signIn(t)
885+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
886+ r.mrPageWith(token, "Ready to merge")
887+
888+ one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token))
889+ anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "")
890+ r.advanceMain("other.txt", "other\n")
891+ deadline := time.After(watchWait)
892+ for done := false; !done; {
893+ ev := nextNamed(t, one, "mr-mergeability")
894+ select {
895+ case <-deadline:
896+ t.Fatal("the badge never showed the result")
897+ default:
898+ }
899+ if strings.Contains(ev.data, "Behind main by 1 commit") {
900+ assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
901+ assert.Contains(t, ev.data, `data-ff-ok="false"`)
902+ assert.Contains(t, ev.data, `data-merge-ok="true"`)
903+ done = true
904+ } else {
905+ assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks")
906+ }
907+ }
908+ // The box is the owner's: a visitor's stream carries no badge.
909+ for {
910+ select {
911+ case ev := <-anon.events:
912+ assert.NotEqual(t, "mr-mergeability", ev.name)
913+ continue
914+ default:
915+ }
916+ break
917+ }
918+}
@@ -10,6 +10,7 @@ import (
10 "regexp"10 "regexp"
11 "strings"11 "strings"
12 "testing"12 "testing"
13+ "time"
13 14
14 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
@@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
165 assert.Equal(t, head, formHead(t, own.body))166 assert.Equal(t, head, formHead(t, own.body))
166 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
167 assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff")168 assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff")
168- assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default")169+ assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default")
169- assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`)170+ assert.Contains(t, own.body, `name="mode" value="squash"`)
171+ assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default")
170 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)172 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
171 assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)173 assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)
172 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")174 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
173- assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")175+ assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash")
176+ assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge")
177+ assert.NotContains(t, own.body, "--squash")
174 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)178 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
175 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)179 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
176 assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")180 assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")
@@ -394,6 +398,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
394 assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")398 assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
395 res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})399 res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
396 require.Equal(t, http.StatusSeeOther, res.status, res.body)400 require.Equal(t, http.StatusSeeOther, res.status, res.body)
401+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
397 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")402 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
398 merged := r.headOf("main")403 merged := r.headOf("main")
399 assert.NotEqual(t, head, merged, "squash makes a new commit")404 assert.NotEqual(t, head, merged, "squash makes a new commit")
@@ -463,6 +468,9 @@ func TestWebMergeRequestDraft(t *testing.T) {
463 468
464 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})469 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
465 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")470 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
471+ assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address")
472+ assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline")
473+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker")
466 474
467 res = b.form("/proj/merge-requests/1/ready", token, nil)475 res = b.form("/proj/merge-requests/1/ready", token, nil)
468 require.Equal(t, http.StatusSeeOther, res.status, res.body)476 require.Equal(t, http.StatusSeeOther, res.status, res.body)
@@ -513,6 +521,7 @@ func TestWebMergeDeleteBranchBox(t *testing.T) {
513 one := b.get("/proj/merge-requests/1", as(token))521 one := b.get("/proj/merge-requests/1", as(token))
514 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}})522 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}})
515 require.Equal(t, http.StatusSeeOther, res.status, res.body)523 require.Equal(t, http.StatusSeeOther, res.status, res.body)
524+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
516 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))525 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
517 done := b.get("/proj/merge-requests/1", nil)526 done := b.get("/proj/merge-requests/1", nil)
518 assert.Equal(t, 200, done.status, "the merged page reads without its branch")527 assert.Equal(t, 200, done.status, "the merged page reads without its branch")
@@ -571,8 +580,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
571 r := newMRRepo(t, domain.VisibilityPublic)580 r := newMRRepo(t, domain.VisibilityPublic)
572 list := r.sse("topic=repo:proj&view=mrs", "")581 list := r.sse("topic=repo:proj&view=mrs", "")
573 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)582 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
574- ev := list.next(t, watchWait)583+ ev := nextNamed(t, list, "mr-notice")
575- assert.Equal(t, "mr-notice", ev.name)
576 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)584 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
577 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)585 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
578 assert.NotContains(t, ev.data, "Secret")586 assert.NotContains(t, ev.data, "Secret")
@@ -580,19 +588,18 @@ func TestWebMergeRequestEvents(t *testing.T) {
580 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")588 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
581 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")589 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
582 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)590 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
583- ev = one.next(t, watchWait)591+ ev = nextNamed(t, one, "mr-notice")
584- assert.Equal(t, "mr-notice", ev.name)
585 assert.Contains(t, ev.data, "a comment was added")592 assert.Contains(t, ev.data, "a comment was added")
586 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)593 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
587 assert.NotContains(t, ev.data, "private words")594 assert.NotContains(t, ev.data, "private words")
588 595
589 r.writeFile(r.wt, "more.txt", []byte("more\n"))596 r.writeFile(r.wt, "more.txt", []byte("more\n"))
590 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
591- ev = one.next(t, watchWait)598+ ev = nextNamed(t, one, "mr-notice")
592 assert.Contains(t, ev.data, "the branch changed")599 assert.Contains(t, ev.data, "the branch changed")
593 600
594 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)601 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
595- assert.Contains(t, one.next(t, watchWait).data, "was closed")602+ assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
596 603
597 // A page of another request hears nothing of this one.604 // A page of another request hears nothing of this one.
598 for drained := false; !drained; {605 for drained := false; !drained; {
@@ -605,7 +612,20 @@ func TestWebMergeRequestEvents(t *testing.T) {
605 drained = true612 drained = true
606 }613 }
607 }614 }
608- assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`"`)615+ assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`&amp;target=main"`)
616+}
617+
618+// nextNamed returns the next event that carries the fragment event: the
619+// events that change nothing on the page (an id alone) are skipped.
620+func nextNamed(t *testing.T, s *sseStream, name string) sseEvent {
621+ t.Helper()
622+ for range 10 {
623+ if ev := s.next(t, watchWait); ev.name == name {
624+ return ev
625+ }
626+ }
627+ t.Fatalf("no %s event", name)
628+ return sseEvent{}
609 }629 }
610 630
611 func (r *mrRepo) get1(path string) string {631 func (r *mrRepo) get1(path string) string {
@@ -724,3 +744,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) {
724 }744 }
725 745
726 var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)746 var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
747+
748+// advanceMain pushes a commit to main that the feature branch lacks. file is
749+// the file it adds (give search.go to conflict with the feature branch).
750+func (r *mrRepo) advanceMain(file, content string) {
751+ r.t.Helper()
752+ r.mustGit(r.wt, "checkout", "-q", "main")
753+ r.writeFile(r.wt, file, []byte(content))
754+ r.mustGit(r.wt, "push", "-q", r.url, "main")
755+ r.mustGit(r.wt, "checkout", "-q", "feat/search")
756+}
757+
758+// mrPageWith polls the owner's page of !1 until it contains want.
759+func (r *mrRepo) mrPageWith(token, want string) string {
760+ r.t.Helper()
761+ var body string
762+ require.Eventually(r.t, func() bool {
763+ body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body
764+ return strings.Contains(body, want)
765+ }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want)
766+ return body
767+}
768+
769+// The merge box shows the mergeability of the request: ready, then behind
770+// (fast-forward is disabled with its reason, the other modes are not), then
771+// in conflict (no mode is possible and the button says why).
772+func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
773+ r := newMRRepo(t, domain.VisibilityPublic)
774+ token := r.signIn(t)
775+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
776+
777+ body := r.mrPageWith(token, "Ready to merge")
778+ assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
779+ assert.Contains(t, body, `data-state="up_to_date"`)
780+ assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
781+ assert.NotContains(t, body, `value="fast-forward" checked disabled`)
782+ assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
783+ assert.Contains(t, body, "signed with ermite's own key (generated on first start)")
784+ assert.Contains(t, body, "<code>ermite signing-key show</code>")
785+ assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
786+ assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
787+ assertNoInline(t, body)
788+
789+ // Main moves on: the branch is behind by one commit and merges cleanly.
790+ r.advanceMain("other.txt", "other\n")
791+ body = r.mrPageWith(token, "Behind main by 1 commit")
792+ assert.Contains(t, body, "merges without conflicts")
793+ assert.Contains(t, body, `data-ff-ok="false"`)
794+ assert.Contains(t, body, `data-merge-ok="true"`)
795+ assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body)
796+ assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`)
797+ assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked")
798+ assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body)
799+
800+ // A conflicting change to main: every mode is disabled and the files are listed.
801+ r.mustGit(r.wt, "checkout", "-q", "main")
802+ r.writeFile(r.wt, "search.go", []byte("package other\n"))
803+ r.mustGit(r.wt, "push", "-q", r.url, "main")
804+ body = r.mrPageWith(token, "Conflicts with main")
805+ assert.Contains(t, body, "rebase needed")
806+ assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`)
807+ assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body)
808+ assert.NotContains(t, body, `name="mode" value="squash" checked`)
809+
810+ // The server refuses it too, and says so in the box of the page.
811+ res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}})
812+ assert.Equal(t, http.StatusConflict, res.status)
813+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
814+ assert.Contains(t, res.body, "conflicts with main")
815+}
816+
817+// A merge the server refuses (the target moved after the page was read) shows
818+// the page of the request again, 409, with the reason in its merge box.
819+func TestWebMergeRequestInlineMergeError(t *testing.T) {
820+ r := newMRRepo(t, domain.VisibilityPublic)
821+ b := browser{r.harness}
822+ token := r.signIn(t)
823+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
824+ body := r.mrPageWith(token, "Ready to merge")
825+ head := formHead(t, body)
826+
827+ r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward
828+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
829+ assert.Equal(t, http.StatusConflict, res.status)
830+ assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
831+ assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page")
832+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
833+ assert.Contains(t, res.body, "the target has moved")
834+ assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error")
835+ assert.Equal(t, head, r.headOf("feat/search"))
836+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error")
837+
838+ // A head that moved is refused the same way.
839+ r.writeFile(r.wt, "more.txt", []byte("more\n"))
840+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
841+ res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
842+ assert.Equal(t, http.StatusConflict, res.status)
843+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
844+ assert.Contains(t, res.body, "branch moved")
845+
846+ // Other refusals keep the state page; a mode that does not exist is a 400.
847+ assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status)
848+}
849+
850+// A merge commit through the page; with the branch deletion requested the
851+// merged page says the branch is being deleted, not that it is gone.
852+func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) {
853+ r := newMRRepo(t, domain.VisibilityPublic)
854+ b := browser{r.harness}
855+ token := r.signIn(t)
856+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
857+ r.advanceMain("other.txt", "other\n")
858+ body := r.mrPageWith(token, "Behind main by 1 commit")
859+ head := formHead(t, body)
860+ main0 := r.headOf("main")
861+
862+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}})
863+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
864+ assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location"))
865+ merged := r.headOf("main")
866+ assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1"))
867+ assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2"))
868+ assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig")
869+ page := b.get("/proj/merge-requests/1?deleting=1", as(token))
870+ assert.Contains(t, page.body, "merge commit")
871+ if strings.Contains(page.body, "is being deleted") {
872+ assert.Contains(t, page.body, "feat/search is being deleted")
873+ }
874+ assert.NotContains(t, page.body, "is deleted")
875+ r.forge.MergeRequests.Wait(r.ctx)
876+ assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
877+ assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted")
878+}
879+
880+// The badge follows the request live: a push to the target turns the fragment
881+// of the page to "checking" or "behind", and the settled result follows.
882+func TestWebMergeRequestMergeabilityLive(t *testing.T) {
883+ r := newMRRepo(t, domain.VisibilityPublic)
884+ token := r.signIn(t)
885+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
886+ r.mrPageWith(token, "Ready to merge")
887+
888+ one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token))
889+ anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "")
890+ r.advanceMain("other.txt", "other\n")
891+ deadline := time.After(watchWait)
892+ for done := false; !done; {
893+ ev := nextNamed(t, one, "mr-mergeability")
894+ select {
895+ case <-deadline:
896+ t.Fatal("the badge never showed the result")
897+ default:
898+ }
899+ if strings.Contains(ev.data, "Behind main by 1 commit") {
900+ assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
901+ assert.Contains(t, ev.data, `data-ff-ok="false"`)
902+ assert.Contains(t, ev.data, `data-merge-ok="true"`)
903+ done = true
904+ } else {
905+ assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks")
906+ }
907+ }
908+ // The box is the owner's: a visitor's stream carries no badge.
909+ for {
910+ select {
911+ case ev := <-anon.events:
912+ assert.NotEqual(t, "mr-mergeability", ev.name)
913+ continue
914+ default:
915+ }
916+ break
917+ }
918+}
M internal/domain/event.go +14 -0
@@ -284,6 +284,20 @@ type CommentResolved struct {
284284 // EventKind implements Event.
285285 func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286286
287+// MergeRequestMergeability is published when the mergeability of an open merge
288+// request changed.
289+type MergeRequestMergeability struct {
290+ Repo RepoName
291+ Number int64
292+ Mergeability Mergeability
293+ At time.Time
294+ Visibility Visibility
295+ CreatedAt time.Time
296+}
297+
298+// EventKind implements Event.
299+func (MergeRequestMergeability) EventKind() string { return "mr.mergeability" }
300+
287301 // EventGap tells a subscriber that it missed events because it was too slow;
288302 // it must refresh its whole view.
289303 type EventGap struct{}
@@ -284,6 +284,20 @@ type CommentResolved struct {
284 // EventKind implements Event.284 // EventKind implements Event.
285 func (CommentResolved) EventKind() string { return "mr.comment_resolved" }285 func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286 286
287+// MergeRequestMergeability is published when the mergeability of an open merge
288+// request changed.
289+type MergeRequestMergeability struct {
290+ Repo RepoName
291+ Number int64
292+ Mergeability Mergeability
293+ At time.Time
294+ Visibility Visibility
295+ CreatedAt time.Time
296+}
297+
298+// EventKind implements Event.
299+func (MergeRequestMergeability) EventKind() string { return "mr.mergeability" }
300+
287 // EventGap tells a subscriber that it missed events because it was too slow;301 // EventGap tells a subscriber that it missed events because it was too slow;
288 // it must refresh its whole view.302 // it must refresh its whole view.
289 type EventGap struct{}303 type EventGap struct{}
M internal/domain/mergerequest.go +155 -16
@@ -18,18 +18,23 @@ const (
1818 MergeModeFastForward MergeMode = "fast-forward"
1919 // MergeModeSquash makes one new signed commit with the tree of the head.
2020 MergeModeSquash MergeMode = "squash"
21+ // MergeModeMerge makes one new signed commit with two parents: the target
22+ // tip and the head.
23+ MergeModeMerge MergeMode = "merge"
2124 )
2225
23-// MergeModes returns the two modes in their canonical order.
24-func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} }
26+// MergeModes returns the modes in their canonical order.
27+func MergeModes() []MergeMode {
28+ return []MergeMode{MergeModeFastForward, MergeModeSquash, MergeModeMerge}
29+}
2530
26-// ParseMergeMode parses "fast-forward" or "squash".
31+// ParseMergeMode parses "fast-forward", "squash" or "merge".
2732 func ParseMergeMode(s string) (MergeMode, error) {
2833 m := MergeMode(s)
2934 if slices.Contains(MergeModes(), m) {
3035 return m, nil
3136 }
32- return "", fmt.Errorf("%w: merge mode %q (want fast-forward or squash)", ErrInvalid, s)
37+ return "", fmt.Errorf("%w: merge mode %q (want fast-forward, squash or merge)", ErrInvalid, s)
3338 }
3439
3540 // String returns the mode as text.
@@ -47,7 +52,7 @@ type RepoSettings struct {
4752 Issues IssuesMode
4853 }
4954
50-// DefaultRepoSettings are the settings of a new repository: both modes, and
55+// DefaultRepoSettings are the settings of a new repository: every mode, and
5156 // failing checks block the merge.
5257 func DefaultRepoSettings() RepoSettings {
5358 return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
@@ -249,10 +254,10 @@ type Identity struct {
249254 Name, Email string
250255 }
251256
252-// SquashCommit is the commit a squash merge creates: the tree of the head on
253-// top of the target.
254-type SquashCommit struct {
255- Tree, Parent ObjectID
257+// ServerCommit is a commit the server creates: a squash or a merge commit.
258+type ServerCommit struct {
259+ Tree ObjectID
260+ Parents []ObjectID
256261 Author, Committer Signature
257262 Message string
258263 }
@@ -267,7 +272,7 @@ const MaxSquashCommits = 1000
267272 // trailers, in order of appearance. The message is the title, " (!<number>)",
268273 // a blank line, the summaries of the commits oldest first as a list, and the
269274 // trailers.
270-func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit {
275+func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) ServerCommit {
271276 oldest := slices.Clone(commits)
272277 slices.Reverse(oldest)
273278 var first Signature
@@ -293,26 +298,41 @@ func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, c
293298 if len(trailers) > 0 {
294299 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
295300 }
296- return SquashCommit{
297- Tree: tree, Parent: parent,
301+ return ServerCommit{
302+ Tree: tree, Parents: []ObjectID{parent},
298303 Author: first,
299304 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
300305 Message: msg.String(),
301306 }
302307 }
303308
309+// NewMergeCommit builds the merge commit of a merge request: the tree of the
310+// merge, the target tip and the head as parents, and the server identity as
311+// author and committer.
312+func NewMergeCommit(mr MergeRequest, tree, target, head ObjectID, committer Identity, at time.Time) ServerCommit {
313+ sig := Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}
314+ return ServerCommit{
315+ Tree: tree, Parents: []ObjectID{target, head},
316+ Author: sig, Committer: sig,
317+ Message: fmt.Sprintf("Merge branch '%s' into %s (!%d)\n\n%s\n",
318+ singleLine(mr.Source), singleLine(mr.Target), mr.Number, singleLine(mr.Title.String())),
319+ }
320+}
321+
304322 // Payload returns the text of the commit object without a signature, which is
305323 // what the signature covers.
306-func (c SquashCommit) Payload() []byte { return c.encode("") }
324+func (c ServerCommit) Payload() []byte { return c.encode("") }
307325
308326 // Signed returns the text of the commit object with the armored SSH signature
309327 // in its gpgsig header.
310-func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) }
328+func (c ServerCommit) Signed(armored string) []byte { return c.encode(armored) }
311329
312-func (c SquashCommit) encode(armored string) []byte {
330+func (c ServerCommit) encode(armored string) []byte {
313331 var b strings.Builder
314332 b.WriteString("tree " + c.Tree.String() + "\n")
315- b.WriteString("parent " + c.Parent.String() + "\n")
333+ for _, p := range c.Parents {
334+ b.WriteString("parent " + p.String() + "\n")
335+ }
316336 b.WriteString("author " + signatureLine(c.Author) + "\n")
317337 b.WriteString("committer " + signatureLine(c.Committer) + "\n")
318338 if armored != "" {
@@ -430,3 +450,122 @@ func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
430450 })
431451 return out
432452 }
453+
454+// Limits of a stored Mergeability.
455+const (
456+ // MaxBehindCount caps the commits counted as behind.
457+ MaxBehindCount = 1000
458+ // MaxConflictPaths caps the conflicted paths listed.
459+ MaxConflictPaths = 20
460+)
461+
462+// MergeabilityState is the outcome of a mergeability check.
463+type MergeabilityState string
464+
465+// Mergeability states.
466+const (
467+ // MergeabilityChecking means there is no up-to-date result yet; it is never
468+ // stored.
469+ MergeabilityChecking MergeabilityState = "checking"
470+ // MergeabilityUpToDate means the target is an ancestor of the head.
471+ MergeabilityUpToDate MergeabilityState = "up_to_date"
472+ // MergeabilityBehind means the head lacks commits of the target but merges
473+ // cleanly.
474+ MergeabilityBehind MergeabilityState = "behind"
475+ // MergeabilityConflict means the head does not merge cleanly.
476+ MergeabilityConflict MergeabilityState = "conflict"
477+)
478+
479+// Mergeability is whether an open merge request merges with its target, for
480+// given tips.
481+type Mergeability struct {
482+ Repo RepoName
483+ Number int64
484+ State MergeabilityState
485+ // TargetID and HeadID are the tips the result is for; zero for a derived
486+ // "checking".
487+ TargetID, HeadID ObjectID
488+ // Behind counts the commits of the target not in the head (behind only),
489+ // capped at MaxBehindCount.
490+ Behind int
491+ BehindCapped bool
492+ // ConflictPaths holds at most MaxConflictPaths paths (conflict only).
493+ ConflictPaths []string
494+ ConflictPathsTruncated bool
495+ CheckedAt time.Time
496+ // Version is set by the store on read, like MergeRequest.Version.
497+ Version uint64
498+}
499+
500+// Allows reports whether the mode may be used given the state, and if not,
501+// why.
502+func (m Mergeability) Allows(mode MergeMode) (bool, string) {
503+ switch mode {
504+ case MergeModeFastForward:
505+ if m.State == MergeabilityUpToDate {
506+ return true, ""
507+ }
508+ if m.State == MergeabilityBehind {
509+ return false, "behind the target: rebase or use squash/merge"
510+ }
511+ case MergeModeSquash, MergeModeMerge:
512+ if m.State != MergeabilityConflict && m.State != MergeabilityChecking && m.State != "" {
513+ return true, ""
514+ }
515+ }
516+ switch m.State {
517+ case MergeabilityConflict:
518+ return false, "conflicts with the target"
519+ case MergeabilityChecking, "":
520+ return false, "mergeability is being checked"
521+ }
522+ return false, "mode not allowed"
523+}
524+
525+// IsFor reports whether the result is for these tips, that is not stale.
526+func (m Mergeability) IsFor(target, head ObjectID) bool {
527+ return m.State != "" && m.State != MergeabilityChecking && m.TargetID == target && m.HeadID == head
528+}
529+
530+// MergeTreeResult is the outcome of merging two commits without a commit.
531+type MergeTreeResult struct {
532+ // Tree is the merged tree; zero when not clean.
533+ Tree ObjectID
534+ Clean bool
535+ ConflictPaths []string
536+ Truncated bool
537+}
538+
539+// Bounded returns r with at most MaxConflictPaths conflicted paths, Truncated
540+// set when some were dropped. Adapters return the raw result; the bound is
541+// the domain's.
542+func (r MergeTreeResult) Bounded() MergeTreeResult {
543+ kept := r.ConflictPaths
544+ if len(kept) > MaxConflictPaths {
545+ kept = kept[:MaxConflictPaths]
546+ r.Truncated = true
547+ }
548+ if kept != nil {
549+ // Exact-sized copies: neither the backing array nor the git output the
550+ // paths were cut from stays referenced.
551+ out := make([]string, len(kept))
552+ for i, p := range kept {
553+ out[i] = strings.Clone(p)
554+ }
555+ r.ConflictPaths = out
556+ }
557+ return r
558+}
559+
560+// BehindLimit is the limit to give a count of commits behind so that
561+// BoundBehind can tell whether MaxBehindCount was exceeded.
562+const BehindLimit = MaxBehindCount + 1
563+
564+// BoundBehind caps a raw count of commits behind (counted up to BehindLimit)
565+// at MaxBehindCount; capped is true when the count was over it.
566+func BoundBehind(n int) (count int, capped bool) {
567+ if n > MaxBehindCount {
568+ return MaxBehindCount, true
569+ }
570+ return n, false
571+}
@@ -18,18 +18,23 @@ const (
18 MergeModeFastForward MergeMode = "fast-forward"18 MergeModeFastForward MergeMode = "fast-forward"
19 // MergeModeSquash makes one new signed commit with the tree of the head.19 // MergeModeSquash makes one new signed commit with the tree of the head.
20 MergeModeSquash MergeMode = "squash"20 MergeModeSquash MergeMode = "squash"
21+ // MergeModeMerge makes one new signed commit with two parents: the target
22+ // tip and the head.
23+ MergeModeMerge MergeMode = "merge"
21 )24 )
22 25
23-// MergeModes returns the two modes in their canonical order.26+// MergeModes returns the modes in their canonical order.
24-func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} }27+func MergeModes() []MergeMode {
28+ return []MergeMode{MergeModeFastForward, MergeModeSquash, MergeModeMerge}
29+}
25 30
26-// ParseMergeMode parses "fast-forward" or "squash".31+// ParseMergeMode parses "fast-forward", "squash" or "merge".
27 func ParseMergeMode(s string) (MergeMode, error) {32 func ParseMergeMode(s string) (MergeMode, error) {
28 m := MergeMode(s)33 m := MergeMode(s)
29 if slices.Contains(MergeModes(), m) {34 if slices.Contains(MergeModes(), m) {
30 return m, nil35 return m, nil
31 }36 }
32- return "", fmt.Errorf("%w: merge mode %q (want fast-forward or squash)", ErrInvalid, s)37+ return "", fmt.Errorf("%w: merge mode %q (want fast-forward, squash or merge)", ErrInvalid, s)
33 }38 }
34 39
35 // String returns the mode as text.40 // String returns the mode as text.
@@ -47,7 +52,7 @@ type RepoSettings struct {
47 Issues IssuesMode52 Issues IssuesMode
48 }53 }
49 54
50-// DefaultRepoSettings are the settings of a new repository: both modes, and55+// DefaultRepoSettings are the settings of a new repository: every mode, and
51 // failing checks block the merge.56 // failing checks block the merge.
52 func DefaultRepoSettings() RepoSettings {57 func DefaultRepoSettings() RepoSettings {
53 return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}58 return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
@@ -249,10 +254,10 @@ type Identity struct {
249 Name, Email string254 Name, Email string
250 }255 }
251 256
252-// SquashCommit is the commit a squash merge creates: the tree of the head on257+// ServerCommit is a commit the server creates: a squash or a merge commit.
253-// top of the target.258+type ServerCommit struct {
254-type SquashCommit struct {259+ Tree ObjectID
255- Tree, Parent ObjectID260+ Parents []ObjectID
256 Author, Committer Signature261 Author, Committer Signature
257 Message string262 Message string
258 }263 }
@@ -267,7 +272,7 @@ const MaxSquashCommits = 1000
267 // trailers, in order of appearance. The message is the title, " (!<number>)",272 // trailers, in order of appearance. The message is the title, " (!<number>)",
268 // a blank line, the summaries of the commits oldest first as a list, and the273 // a blank line, the summaries of the commits oldest first as a list, and the
269 // trailers.274 // trailers.
270-func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit {275+func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) ServerCommit {
271 oldest := slices.Clone(commits)276 oldest := slices.Clone(commits)
272 slices.Reverse(oldest)277 slices.Reverse(oldest)
273 var first Signature278 var first Signature
@@ -293,26 +298,41 @@ func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, c
293 if len(trailers) > 0 {298 if len(trailers) > 0 {
294 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")299 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
295 }300 }
296- return SquashCommit{301+ return ServerCommit{
297- Tree: tree, Parent: parent,302+ Tree: tree, Parents: []ObjectID{parent},
298 Author: first,303 Author: first,
299 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},304 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
300 Message: msg.String(),305 Message: msg.String(),
301 }306 }
302 }307 }
303 308
309+// NewMergeCommit builds the merge commit of a merge request: the tree of the
310+// merge, the target tip and the head as parents, and the server identity as
311+// author and committer.
312+func NewMergeCommit(mr MergeRequest, tree, target, head ObjectID, committer Identity, at time.Time) ServerCommit {
313+ sig := Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}
314+ return ServerCommit{
315+ Tree: tree, Parents: []ObjectID{target, head},
316+ Author: sig, Committer: sig,
317+ Message: fmt.Sprintf("Merge branch '%s' into %s (!%d)\n\n%s\n",
318+ singleLine(mr.Source), singleLine(mr.Target), mr.Number, singleLine(mr.Title.String())),
319+ }
320+}
321+
304 // Payload returns the text of the commit object without a signature, which is322 // Payload returns the text of the commit object without a signature, which is
305 // what the signature covers.323 // what the signature covers.
306-func (c SquashCommit) Payload() []byte { return c.encode("") }324+func (c ServerCommit) Payload() []byte { return c.encode("") }
307 325
308 // Signed returns the text of the commit object with the armored SSH signature326 // Signed returns the text of the commit object with the armored SSH signature
309 // in its gpgsig header.327 // in its gpgsig header.
310-func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) }328+func (c ServerCommit) Signed(armored string) []byte { return c.encode(armored) }
311 329
312-func (c SquashCommit) encode(armored string) []byte {330+func (c ServerCommit) encode(armored string) []byte {
313 var b strings.Builder331 var b strings.Builder
314 b.WriteString("tree " + c.Tree.String() + "\n")332 b.WriteString("tree " + c.Tree.String() + "\n")
315- b.WriteString("parent " + c.Parent.String() + "\n")333+ for _, p := range c.Parents {
334+ b.WriteString("parent " + p.String() + "\n")
335+ }
316 b.WriteString("author " + signatureLine(c.Author) + "\n")336 b.WriteString("author " + signatureLine(c.Author) + "\n")
317 b.WriteString("committer " + signatureLine(c.Committer) + "\n")337 b.WriteString("committer " + signatureLine(c.Committer) + "\n")
318 if armored != "" {338 if armored != "" {
@@ -430,3 +450,122 @@ func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
430 })450 })
431 return out451 return out
432 }452 }
453+
454+// Limits of a stored Mergeability.
455+const (
456+ // MaxBehindCount caps the commits counted as behind.
457+ MaxBehindCount = 1000
458+ // MaxConflictPaths caps the conflicted paths listed.
459+ MaxConflictPaths = 20
460+)
461+
462+// MergeabilityState is the outcome of a mergeability check.
463+type MergeabilityState string
464+
465+// Mergeability states.
466+const (
467+ // MergeabilityChecking means there is no up-to-date result yet; it is never
468+ // stored.
469+ MergeabilityChecking MergeabilityState = "checking"
470+ // MergeabilityUpToDate means the target is an ancestor of the head.
471+ MergeabilityUpToDate MergeabilityState = "up_to_date"
472+ // MergeabilityBehind means the head lacks commits of the target but merges
473+ // cleanly.
474+ MergeabilityBehind MergeabilityState = "behind"
475+ // MergeabilityConflict means the head does not merge cleanly.
476+ MergeabilityConflict MergeabilityState = "conflict"
477+)
478+
479+// Mergeability is whether an open merge request merges with its target, for
480+// given tips.
481+type Mergeability struct {
482+ Repo RepoName
483+ Number int64
484+ State MergeabilityState
485+ // TargetID and HeadID are the tips the result is for; zero for a derived
486+ // "checking".
487+ TargetID, HeadID ObjectID
488+ // Behind counts the commits of the target not in the head (behind only),
489+ // capped at MaxBehindCount.
490+ Behind int
491+ BehindCapped bool
492+ // ConflictPaths holds at most MaxConflictPaths paths (conflict only).
493+ ConflictPaths []string
494+ ConflictPathsTruncated bool
495+ CheckedAt time.Time
496+ // Version is set by the store on read, like MergeRequest.Version.
497+ Version uint64
498+}
499+
500+// Allows reports whether the mode may be used given the state, and if not,
501+// why.
502+func (m Mergeability) Allows(mode MergeMode) (bool, string) {
503+ switch mode {
504+ case MergeModeFastForward:
505+ if m.State == MergeabilityUpToDate {
506+ return true, ""
507+ }
508+ if m.State == MergeabilityBehind {
509+ return false, "behind the target: rebase or use squash/merge"
510+ }
511+ case MergeModeSquash, MergeModeMerge:
512+ if m.State != MergeabilityConflict && m.State != MergeabilityChecking && m.State != "" {
513+ return true, ""
514+ }
515+ }
516+ switch m.State {
517+ case MergeabilityConflict:
518+ return false, "conflicts with the target"
519+ case MergeabilityChecking, "":
520+ return false, "mergeability is being checked"
521+ }
522+ return false, "mode not allowed"
523+}
524+
525+// IsFor reports whether the result is for these tips, that is not stale.
526+func (m Mergeability) IsFor(target, head ObjectID) bool {
527+ return m.State != "" && m.State != MergeabilityChecking && m.TargetID == target && m.HeadID == head
528+}
529+
530+// MergeTreeResult is the outcome of merging two commits without a commit.
531+type MergeTreeResult struct {
532+ // Tree is the merged tree; zero when not clean.
533+ Tree ObjectID
534+ Clean bool
535+ ConflictPaths []string
536+ Truncated bool
537+}
538+
539+// Bounded returns r with at most MaxConflictPaths conflicted paths, Truncated
540+// set when some were dropped. Adapters return the raw result; the bound is
541+// the domain's.
542+func (r MergeTreeResult) Bounded() MergeTreeResult {
543+ kept := r.ConflictPaths
544+ if len(kept) > MaxConflictPaths {
545+ kept = kept[:MaxConflictPaths]
546+ r.Truncated = true
547+ }
548+ if kept != nil {
549+ // Exact-sized copies: neither the backing array nor the git output the
550+ // paths were cut from stays referenced.
551+ out := make([]string, len(kept))
552+ for i, p := range kept {
553+ out[i] = strings.Clone(p)
554+ }
555+ r.ConflictPaths = out
556+ }
557+ return r
558+}
559+
560+// BehindLimit is the limit to give a count of commits behind so that
561+// BoundBehind can tell whether MaxBehindCount was exceeded.
562+const BehindLimit = MaxBehindCount + 1
563+
564+// BoundBehind caps a raw count of commits behind (counted up to BehindLimit)
565+// at MaxBehindCount; capped is true when the count was over it.
566+func BoundBehind(n int) (count int, capped bool) {
567+ if n > MaxBehindCount {
568+ return MaxBehindCount, true
569+ }
570+ return n, false
571+}
M internal/domain/mergerequest_test.go +50 -3
@@ -3,6 +3,7 @@
33 package domain_test
44
55 import (
6+ "strconv"
67 "strings"
78 "testing"
89 "time"
@@ -23,7 +24,7 @@ func oid(t *testing.T, c byte) domain.ObjectID {
2324 func TestDefaultRepoSettings(t *testing.T) {
2425 t.Parallel()
2526 s := domain.DefaultRepoSettings()
26- assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)
27+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
2728 assert.True(t, s.FailingChecksBlock)
2829 assert.True(t, s.Allows(domain.MergeModeSquash))
2930 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
@@ -39,7 +40,7 @@ func TestNormalizeMergeModes(t *testing.T) {
3940 t.Parallel()
4041 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
4142 require.NoError(t, err)
42- assert.Equal(t, domain.MergeModes(), got)
43+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, got)
4344
4445 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
4546 require.NoError(t, err)
@@ -56,7 +57,10 @@ func TestParseMergeMode(t *testing.T) {
5657 m, err := domain.ParseMergeMode("fast-forward")
5758 require.NoError(t, err)
5859 assert.Equal(t, domain.MergeModeFastForward, m)
59- _, err = domain.ParseMergeMode("merge")
60+ m, err = domain.ParseMergeMode("merge")
61+ require.NoError(t, err)
62+ assert.Equal(t, domain.MergeModeMerge, m)
63+ _, err = domain.ParseMergeMode("rebase")
6064 assert.ErrorIs(t, err, domain.ErrInvalid)
6165 }
6266
@@ -283,3 +287,46 @@ func TestReviewScopeOwnerRights(t *testing.T) {
283287 assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284288 }
285289 }
290+
291+func TestNewMergeCommit(t *testing.T) {
292+ t.Parallel()
293+ title, err := domain.ParseMRTitle("Add things")
294+ require.NoError(t, err)
295+ mr := domain.MergeRequest{Number: 7, Title: title, Source: "feat/x", Target: "main"}
296+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
297+ c := domain.NewMergeCommit(mr, oid(t, 'a'), oid(t, 'b'), oid(t, 'c'), domain.Identity{Name: "ermite", Email: "e@x"}, at)
298+ assert.Equal(t, []domain.ObjectID{oid(t, 'b'), oid(t, 'c')}, c.Parents)
299+ assert.Equal(t, c.Author, c.Committer)
300+ want := "tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nparent " + strings.Repeat("c", 40) + "\n" +
301+ "author ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\ncommitter ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\n\n" +
302+ "Merge branch 'feat/x' into main (!7)\n\nAdd things\n"
303+ assert.Equal(t, want, string(c.Payload()))
304+ assert.Contains(t, string(c.Signed("-----BEGIN-----\nabc\n-----END-----\n")), "gpgsig -----BEGIN-----\n abc\n -----END-----\n")
305+}
306+
307+func TestMergeabilityAllowsAndIsFor(t *testing.T) {
308+ t.Parallel()
309+ for _, tc := range []struct {
310+ state domain.MergeabilityState
311+ ff, squash, mg bool
312+ }{
313+ {domain.MergeabilityUpToDate, true, true, true},
314+ {domain.MergeabilityBehind, false, true, true},
315+ {domain.MergeabilityConflict, false, false, false},
316+ {domain.MergeabilityChecking, false, false, false},
317+ } {
318+ m := domain.Mergeability{State: tc.state}
319+ ok, reason := m.Allows(domain.MergeModeFastForward)
320+ assert.Equal(t, tc.ff, ok, tc.state)
321+ assert.Equal(t, !ok, reason != "")
322+ ok, _ = m.Allows(domain.MergeModeSquash)
323+ assert.Equal(t, tc.squash, ok, tc.state)
324+ ok, _ = m.Allows(domain.MergeModeMerge)
325+ assert.Equal(t, tc.mg, ok, tc.state)
326+ }
327+ m := domain.Mergeability{State: domain.MergeabilityBehind, TargetID: oid(t, 'a'), HeadID: oid(t, 'b')}
328+ assert.True(t, m.IsFor(oid(t, 'a'), oid(t, 'b')))
329+ assert.False(t, m.IsFor(oid(t, 'c'), oid(t, 'b')))
330+ assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331+ assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332+}
@@ -3,6 +3,7 @@
3 package domain_test3 package domain_test
4 4
5 import (5 import (
6+ "strconv"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -23,7 +24,7 @@ func oid(t *testing.T, c byte) domain.ObjectID {
23 func TestDefaultRepoSettings(t *testing.T) {24 func TestDefaultRepoSettings(t *testing.T) {
24 t.Parallel()25 t.Parallel()
25 s := domain.DefaultRepoSettings()26 s := domain.DefaultRepoSettings()
26- assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)27+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
27 assert.True(t, s.FailingChecksBlock)28 assert.True(t, s.FailingChecksBlock)
28 assert.True(t, s.Allows(domain.MergeModeSquash))29 assert.True(t, s.Allows(domain.MergeModeSquash))
29 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))30 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
@@ -39,7 +40,7 @@ func TestNormalizeMergeModes(t *testing.T) {
39 t.Parallel()40 t.Parallel()
40 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})41 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
41 require.NoError(t, err)42 require.NoError(t, err)
42- assert.Equal(t, domain.MergeModes(), got)43+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, got)
43 44
44 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})45 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
45 require.NoError(t, err)46 require.NoError(t, err)
@@ -56,7 +57,10 @@ func TestParseMergeMode(t *testing.T) {
56 m, err := domain.ParseMergeMode("fast-forward")57 m, err := domain.ParseMergeMode("fast-forward")
57 require.NoError(t, err)58 require.NoError(t, err)
58 assert.Equal(t, domain.MergeModeFastForward, m)59 assert.Equal(t, domain.MergeModeFastForward, m)
59- _, err = domain.ParseMergeMode("merge")60+ m, err = domain.ParseMergeMode("merge")
61+ require.NoError(t, err)
62+ assert.Equal(t, domain.MergeModeMerge, m)
63+ _, err = domain.ParseMergeMode("rebase")
60 assert.ErrorIs(t, err, domain.ErrInvalid)64 assert.ErrorIs(t, err, domain.ErrInvalid)
61 }65 }
62 66
@@ -283,3 +287,46 @@ func TestReviewScopeOwnerRights(t *testing.T) {
283 assert.Equal(t, tc.want, domain.AuthorName(tc.p))287 assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284 }288 }
285 }289 }
290+
291+func TestNewMergeCommit(t *testing.T) {
292+ t.Parallel()
293+ title, err := domain.ParseMRTitle("Add things")
294+ require.NoError(t, err)
295+ mr := domain.MergeRequest{Number: 7, Title: title, Source: "feat/x", Target: "main"}
296+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
297+ c := domain.NewMergeCommit(mr, oid(t, 'a'), oid(t, 'b'), oid(t, 'c'), domain.Identity{Name: "ermite", Email: "e@x"}, at)
298+ assert.Equal(t, []domain.ObjectID{oid(t, 'b'), oid(t, 'c')}, c.Parents)
299+ assert.Equal(t, c.Author, c.Committer)
300+ want := "tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nparent " + strings.Repeat("c", 40) + "\n" +
301+ "author ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\ncommitter ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\n\n" +
302+ "Merge branch 'feat/x' into main (!7)\n\nAdd things\n"
303+ assert.Equal(t, want, string(c.Payload()))
304+ assert.Contains(t, string(c.Signed("-----BEGIN-----\nabc\n-----END-----\n")), "gpgsig -----BEGIN-----\n abc\n -----END-----\n")
305+}
306+
307+func TestMergeabilityAllowsAndIsFor(t *testing.T) {
308+ t.Parallel()
309+ for _, tc := range []struct {
310+ state domain.MergeabilityState
311+ ff, squash, mg bool
312+ }{
313+ {domain.MergeabilityUpToDate, true, true, true},
314+ {domain.MergeabilityBehind, false, true, true},
315+ {domain.MergeabilityConflict, false, false, false},
316+ {domain.MergeabilityChecking, false, false, false},
317+ } {
318+ m := domain.Mergeability{State: tc.state}
319+ ok, reason := m.Allows(domain.MergeModeFastForward)
320+ assert.Equal(t, tc.ff, ok, tc.state)
321+ assert.Equal(t, !ok, reason != "")
322+ ok, _ = m.Allows(domain.MergeModeSquash)
323+ assert.Equal(t, tc.squash, ok, tc.state)
324+ ok, _ = m.Allows(domain.MergeModeMerge)
325+ assert.Equal(t, tc.mg, ok, tc.state)
326+ }
327+ m := domain.Mergeability{State: domain.MergeabilityBehind, TargetID: oid(t, 'a'), HeadID: oid(t, 'b')}
328+ assert.True(t, m.IsFor(oid(t, 'a'), oid(t, 'b')))
329+ assert.False(t, m.IsFor(oid(t, 'c'), oid(t, 'b')))
330+ assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331+ assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332+}
A internal/domain/mergetree_bound_test.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strconv"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestMergeTreeResultBounded(t *testing.T) {
16+ var paths []string
17+ for i := 0; i < domain.MaxConflictPaths+2; i++ {
18+ paths = append(paths, strconv.Itoa(i))
19+ }
20+ got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded()
21+ assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
22+ assert.True(t, got.Truncated)
23+
24+ got = domain.MergeTreeResult{ConflictPaths: paths[:domain.MaxConflictPaths]}.Bounded()
25+ assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
26+ assert.False(t, got.Truncated)
27+}
28+
29+func TestBoundBehind(t *testing.T) {
30+ n, capped := domain.BoundBehind(domain.MaxBehindCount)
31+ assert.Equal(t, domain.MaxBehindCount, n)
32+ assert.False(t, capped)
33+ n, capped = domain.BoundBehind(domain.BehindLimit)
34+ assert.Equal(t, domain.MaxBehindCount, n)
35+ assert.True(t, capped)
36+}
37+
38+func TestMergeTreeResultBoundedCopiesThePaths(t *testing.T) {
39+ var paths []string
40+ for i := 0; i < domain.MaxConflictPaths+2; i++ {
41+ paths = append(paths, strconv.Itoa(i))
42+ }
43+ got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded()
44+ require.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
45+ assert.Equal(t, len(got.ConflictPaths), cap(got.ConflictPaths))
46+ assert.NotSame(t, &paths[0], &got.ConflictPaths[0], "a new array, not the git output's")
47+ assert.Equal(t, paths[:domain.MaxConflictPaths], got.ConflictPaths)
48+
49+ got = domain.MergeTreeResult{ConflictPaths: paths[:3:10]}.Bounded()
50+ assert.Equal(t, 3, cap(got.ConflictPaths))
51+ assert.False(t, got.Truncated)
52+ assert.Nil(t, domain.MergeTreeResult{}.Bounded().ConflictPaths)
53+}
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strconv"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestMergeTreeResultBounded(t *testing.T) {
16+ var paths []string
17+ for i := 0; i < domain.MaxConflictPaths+2; i++ {
18+ paths = append(paths, strconv.Itoa(i))
19+ }
20+ got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded()
21+ assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
22+ assert.True(t, got.Truncated)
23+
24+ got = domain.MergeTreeResult{ConflictPaths: paths[:domain.MaxConflictPaths]}.Bounded()
25+ assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
26+ assert.False(t, got.Truncated)
27+}
28+
29+func TestBoundBehind(t *testing.T) {
30+ n, capped := domain.BoundBehind(domain.MaxBehindCount)
31+ assert.Equal(t, domain.MaxBehindCount, n)
32+ assert.False(t, capped)
33+ n, capped = domain.BoundBehind(domain.BehindLimit)
34+ assert.Equal(t, domain.MaxBehindCount, n)
35+ assert.True(t, capped)
36+}
37+
38+func TestMergeTreeResultBoundedCopiesThePaths(t *testing.T) {
39+ var paths []string
40+ for i := 0; i < domain.MaxConflictPaths+2; i++ {
41+ paths = append(paths, strconv.Itoa(i))
42+ }
43+ got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded()
44+ require.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
45+ assert.Equal(t, len(got.ConflictPaths), cap(got.ConflictPaths))
46+ assert.NotSame(t, &paths[0], &got.ConflictPaths[0], "a new array, not the git output's")
47+ assert.Equal(t, paths[:domain.MaxConflictPaths], got.ConflictPaths)
48+
49+ got = domain.MergeTreeResult{ConflictPaths: paths[:3:10]}.Bounded()
50+ assert.Equal(t, 3, cap(got.ConflictPaths))
51+ assert.False(t, got.Truncated)
52+ assert.Nil(t, domain.MergeTreeResult{}.Bounded().ConflictPaths)
53+}
M internal/ports/ci.go +3 -0
@@ -93,6 +93,9 @@ type CIRefTipStore interface {
9393 GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error)
9494 // DeleteRefTip removes the record of a ref; a missing one is not an error.
9595 DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error
96+ // DeleteRefTipIf removes the record of a ref only when it still names
97+ // sha; a missing or different one is left alone and is not an error.
98+ DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error
9699 }
97100
98101 // CIWorkflowStore keeps, per repository, the workflow files that are disabled
@@ -93,6 +93,9 @@ type CIRefTipStore interface {
93 GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error)93 GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error)
94 // DeleteRefTip removes the record of a ref; a missing one is not an error.94 // DeleteRefTip removes the record of a ref; a missing one is not an error.
95 DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error95 DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error
96+ // DeleteRefTipIf removes the record of a ref only when it still names
97+ // sha; a missing or different one is left alone and is not an error.
98+ DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error
96 }99 }
97 100
98 // CIWorkflowStore keeps, per repository, the workflow files that are disabled101 // CIWorkflowStore keeps, per repository, the workflow files that are disabled
M internal/ports/dto.go +8 -2
@@ -198,6 +198,9 @@ type MergeRequestPage struct {
198198 MergeRequests []domain.MergeRequest
199199 // NextPageToken is empty on the last page.
200200 NextPageToken string
201+ // Mergeability holds, by number, the mergeability of the open requests of
202+ // the page (nil when the server does not compute it).
203+ Mergeability map[int64]domain.Mergeability
201204 }
202205
203206 // ListIssuesParams selects a page of issues from an IssueStore.
@@ -262,6 +265,8 @@ type MergeRequestDetail struct {
262265 Comments []domain.Comment
263266 // Verdicts are the latest verdict per author in note ID order.
264267 Verdicts []domain.AuthorVerdict
268+ // Mergeability is zero unless the request is open.
269+ Mergeability domain.Mergeability
265270 // LastReadID is the head recorded by MarkRead; zero when none.
266271 LastReadID domain.ObjectID
267272 // LastReadLost tells that since-last-read was asked for, a head was
@@ -319,8 +324,9 @@ type MergeParams struct {
319324 // MergeResult is a merged request and what happened to its source branch.
320325 type MergeResult struct {
321326 domain.MergeRequest
322- // SourceDeleted is true when DeleteSource was asked and the branch is
323- // gone.
327+ // SourceDeleted is true when DeleteSource was asked: the deletion is
328+ // requested, and done asynchronously after the merge returned (it is
329+ // skipped if the branch moved meanwhile).
324330 SourceDeleted bool
325331 }
326332
@@ -198,6 +198,9 @@ type MergeRequestPage struct {
198 MergeRequests []domain.MergeRequest198 MergeRequests []domain.MergeRequest
199 // NextPageToken is empty on the last page.199 // NextPageToken is empty on the last page.
200 NextPageToken string200 NextPageToken string
201+ // Mergeability holds, by number, the mergeability of the open requests of
202+ // the page (nil when the server does not compute it).
203+ Mergeability map[int64]domain.Mergeability
201 }204 }
202 205
203 // ListIssuesParams selects a page of issues from an IssueStore.206 // ListIssuesParams selects a page of issues from an IssueStore.
@@ -262,6 +265,8 @@ type MergeRequestDetail struct {
262 Comments []domain.Comment265 Comments []domain.Comment
263 // Verdicts are the latest verdict per author in note ID order.266 // Verdicts are the latest verdict per author in note ID order.
264 Verdicts []domain.AuthorVerdict267 Verdicts []domain.AuthorVerdict
268+ // Mergeability is zero unless the request is open.
269+ Mergeability domain.Mergeability
265 // LastReadID is the head recorded by MarkRead; zero when none.270 // LastReadID is the head recorded by MarkRead; zero when none.
266 LastReadID domain.ObjectID271 LastReadID domain.ObjectID
267 // LastReadLost tells that since-last-read was asked for, a head was272 // LastReadLost tells that since-last-read was asked for, a head was
@@ -319,8 +324,9 @@ type MergeParams struct {
319 // MergeResult is a merged request and what happened to its source branch.324 // MergeResult is a merged request and what happened to its source branch.
320 type MergeResult struct {325 type MergeResult struct {
321 domain.MergeRequest326 domain.MergeRequest
322- // SourceDeleted is true when DeleteSource was asked and the branch is327+ // SourceDeleted is true when DeleteSource was asked: the deletion is
323- // gone.328+ // requested, and done asynchronously after the merge returned (it is
329+ // skipped if the branch moved meanwhile).
324 SourceDeleted bool330 SourceDeleted bool
325 }331 }
326 332
M internal/ports/mocks/CIRefTipStore.go +69 -0
@@ -110,6 +110,75 @@ func (_c *CIRefTipStore_DeleteRefTip_Call) RunAndReturn(run func(ctx context.Con
110110 return _c
111111 }
112112
113+// DeleteRefTipIf provides a mock function for the type CIRefTipStore
114+func (_mock *CIRefTipStore) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref string, sha string) error {
115+ ret := _mock.Called(ctx, repo, ref, sha)
116+
117+ if len(ret) == 0 {
118+ panic("no return value specified for DeleteRefTipIf")
119+ }
120+
121+ var r0 error
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, string) error); ok {
123+ r0 = returnFunc(ctx, repo, ref, sha)
124+ } else {
125+ r0 = ret.Error(0)
126+ }
127+ return r0
128+}
129+
130+// CIRefTipStore_DeleteRefTipIf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRefTipIf'
131+type CIRefTipStore_DeleteRefTipIf_Call struct {
132+ *mock.Call
133+}
134+
135+// DeleteRefTipIf is a helper method to define mock.On call
136+// - ctx context.Context
137+// - repo domain.RepoName
138+// - ref string
139+// - sha string
140+func (_e *CIRefTipStore_Expecter) DeleteRefTipIf(ctx any, repo any, ref any, sha any) *CIRefTipStore_DeleteRefTipIf_Call {
141+ return &CIRefTipStore_DeleteRefTipIf_Call{Call: _e.mock.On("DeleteRefTipIf", ctx, repo, ref, sha)}
142+}
143+
144+func (_c *CIRefTipStore_DeleteRefTipIf_Call) Run(run func(ctx context.Context, repo domain.RepoName, ref string, sha string)) *CIRefTipStore_DeleteRefTipIf_Call {
145+ _c.Call.Run(func(args mock.Arguments) {
146+ var arg0 context.Context
147+ if args[0] != nil {
148+ arg0 = args[0].(context.Context)
149+ }
150+ var arg1 domain.RepoName
151+ if args[1] != nil {
152+ arg1 = args[1].(domain.RepoName)
153+ }
154+ var arg2 string
155+ if args[2] != nil {
156+ arg2 = args[2].(string)
157+ }
158+ var arg3 string
159+ if args[3] != nil {
160+ arg3 = args[3].(string)
161+ }
162+ run(
163+ arg0,
164+ arg1,
165+ arg2,
166+ arg3,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *CIRefTipStore_DeleteRefTipIf_Call) Return(err error) *CIRefTipStore_DeleteRefTipIf_Call {
173+ _c.Call.Return(err)
174+ return _c
175+}
176+
177+func (_c *CIRefTipStore_DeleteRefTipIf_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ref string, sha string) error) *CIRefTipStore_DeleteRefTipIf_Call {
178+ _c.Call.Return(run)
179+ return _c
180+}
181+
113182 // GetRefTip provides a mock function for the type CIRefTipStore
114183 func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) {
115184 ret := _mock.Called(ctx, repo, ref)
@@ -110,6 +110,75 @@ func (_c *CIRefTipStore_DeleteRefTip_Call) RunAndReturn(run func(ctx context.Con
110 return _c110 return _c
111 }111 }
112 112
113+// DeleteRefTipIf provides a mock function for the type CIRefTipStore
114+func (_mock *CIRefTipStore) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref string, sha string) error {
115+ ret := _mock.Called(ctx, repo, ref, sha)
116+
117+ if len(ret) == 0 {
118+ panic("no return value specified for DeleteRefTipIf")
119+ }
120+
121+ var r0 error
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, string) error); ok {
123+ r0 = returnFunc(ctx, repo, ref, sha)
124+ } else {
125+ r0 = ret.Error(0)
126+ }
127+ return r0
128+}
129+
130+// CIRefTipStore_DeleteRefTipIf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRefTipIf'
131+type CIRefTipStore_DeleteRefTipIf_Call struct {
132+ *mock.Call
133+}
134+
135+// DeleteRefTipIf is a helper method to define mock.On call
136+// - ctx context.Context
137+// - repo domain.RepoName
138+// - ref string
139+// - sha string
140+func (_e *CIRefTipStore_Expecter) DeleteRefTipIf(ctx any, repo any, ref any, sha any) *CIRefTipStore_DeleteRefTipIf_Call {
141+ return &CIRefTipStore_DeleteRefTipIf_Call{Call: _e.mock.On("DeleteRefTipIf", ctx, repo, ref, sha)}
142+}
143+
144+func (_c *CIRefTipStore_DeleteRefTipIf_Call) Run(run func(ctx context.Context, repo domain.RepoName, ref string, sha string)) *CIRefTipStore_DeleteRefTipIf_Call {
145+ _c.Call.Run(func(args mock.Arguments) {
146+ var arg0 context.Context
147+ if args[0] != nil {
148+ arg0 = args[0].(context.Context)
149+ }
150+ var arg1 domain.RepoName
151+ if args[1] != nil {
152+ arg1 = args[1].(domain.RepoName)
153+ }
154+ var arg2 string
155+ if args[2] != nil {
156+ arg2 = args[2].(string)
157+ }
158+ var arg3 string
159+ if args[3] != nil {
160+ arg3 = args[3].(string)
161+ }
162+ run(
163+ arg0,
164+ arg1,
165+ arg2,
166+ arg3,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *CIRefTipStore_DeleteRefTipIf_Call) Return(err error) *CIRefTipStore_DeleteRefTipIf_Call {
173+ _c.Call.Return(err)
174+ return _c
175+}
176+
177+func (_c *CIRefTipStore_DeleteRefTipIf_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ref string, sha string) error) *CIRefTipStore_DeleteRefTipIf_Call {
178+ _c.Call.Return(run)
179+ return _c
180+}
181+
113 // GetRefTip provides a mock function for the type CIRefTipStore182 // GetRefTip provides a mock function for the type CIRefTipStore
114 func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) {183 func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) {
115 ret := _mock.Called(ctx, repo, ref)184 ret := _mock.Called(ctx, repo, ref)
M internal/ports/mocks/MergeGit.go +162 -0
@@ -191,6 +191,90 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r
191191 return _c
192192 }
193193
194+// CountBehind provides a mock function for the type MergeGit
195+func (_mock *MergeGit) CountBehind(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error) {
196+ ret := _mock.Called(ctx, repo, head, target, limit)
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for CountBehind")
200+ }
201+
202+ var r0 int
203+ var r1 error
204+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) (int, error)); ok {
205+ return returnFunc(ctx, repo, head, target, limit)
206+ }
207+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) int); ok {
208+ r0 = returnFunc(ctx, repo, head, target, limit)
209+ } else {
210+ r0 = ret.Get(0).(int)
211+ }
212+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok {
213+ r1 = returnFunc(ctx, repo, head, target, limit)
214+ } else {
215+ r1 = ret.Error(1)
216+ }
217+ return r0, r1
218+}
219+
220+// MergeGit_CountBehind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountBehind'
221+type MergeGit_CountBehind_Call struct {
222+ *mock.Call
223+}
224+
225+// CountBehind is a helper method to define mock.On call
226+// - ctx context.Context
227+// - repo domain.RepoName
228+// - head domain.ObjectID
229+// - target domain.ObjectID
230+// - limit int
231+func (_e *MergeGit_Expecter) CountBehind(ctx any, repo any, head any, target any, limit any) *MergeGit_CountBehind_Call {
232+ return &MergeGit_CountBehind_Call{Call: _e.mock.On("CountBehind", ctx, repo, head, target, limit)}
233+}
234+
235+func (_c *MergeGit_CountBehind_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int)) *MergeGit_CountBehind_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ var arg0 context.Context
238+ if args[0] != nil {
239+ arg0 = args[0].(context.Context)
240+ }
241+ var arg1 domain.RepoName
242+ if args[1] != nil {
243+ arg1 = args[1].(domain.RepoName)
244+ }
245+ var arg2 domain.ObjectID
246+ if args[2] != nil {
247+ arg2 = args[2].(domain.ObjectID)
248+ }
249+ var arg3 domain.ObjectID
250+ if args[3] != nil {
251+ arg3 = args[3].(domain.ObjectID)
252+ }
253+ var arg4 int
254+ if args[4] != nil {
255+ arg4 = args[4].(int)
256+ }
257+ run(
258+ arg0,
259+ arg1,
260+ arg2,
261+ arg3,
262+ arg4,
263+ )
264+ })
265+ return _c
266+}
267+
268+func (_c *MergeGit_CountBehind_Call) Return(n int, err error) *MergeGit_CountBehind_Call {
269+ _c.Call.Return(n, err)
270+ return _c
271+}
272+
273+func (_c *MergeGit_CountBehind_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error)) *MergeGit_CountBehind_Call {
274+ _c.Call.Return(run)
275+ return _c
276+}
277+
194278 // DeleteBranch provides a mock function for the type MergeGit
195279 func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
196280 ret := _mock.Called(ctx, repo, branch, old)
@@ -670,6 +754,84 @@ func (_c *MergeGit_MergeBase_Call) RunAndReturn(run func(ctx context.Context, re
670754 return _c
671755 }
672756
757+// MergeTree provides a mock function for the type MergeGit
758+func (_mock *MergeGit) MergeTree(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error) {
759+ ret := _mock.Called(ctx, repo, base, head)
760+
761+ if len(ret) == 0 {
762+ panic("no return value specified for MergeTree")
763+ }
764+
765+ var r0 domain.MergeTreeResult
766+ var r1 error
767+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (domain.MergeTreeResult, error)); ok {
768+ return returnFunc(ctx, repo, base, head)
769+ }
770+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) domain.MergeTreeResult); ok {
771+ r0 = returnFunc(ctx, repo, base, head)
772+ } else {
773+ r0 = ret.Get(0).(domain.MergeTreeResult)
774+ }
775+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
776+ r1 = returnFunc(ctx, repo, base, head)
777+ } else {
778+ r1 = ret.Error(1)
779+ }
780+ return r0, r1
781+}
782+
783+// MergeGit_MergeTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MergeTree'
784+type MergeGit_MergeTree_Call struct {
785+ *mock.Call
786+}
787+
788+// MergeTree is a helper method to define mock.On call
789+// - ctx context.Context
790+// - repo domain.RepoName
791+// - base domain.ObjectID
792+// - head domain.ObjectID
793+func (_e *MergeGit_Expecter) MergeTree(ctx any, repo any, base any, head any) *MergeGit_MergeTree_Call {
794+ return &MergeGit_MergeTree_Call{Call: _e.mock.On("MergeTree", ctx, repo, base, head)}
795+}
796+
797+func (_c *MergeGit_MergeTree_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID)) *MergeGit_MergeTree_Call {
798+ _c.Call.Run(func(args mock.Arguments) {
799+ var arg0 context.Context
800+ if args[0] != nil {
801+ arg0 = args[0].(context.Context)
802+ }
803+ var arg1 domain.RepoName
804+ if args[1] != nil {
805+ arg1 = args[1].(domain.RepoName)
806+ }
807+ var arg2 domain.ObjectID
808+ if args[2] != nil {
809+ arg2 = args[2].(domain.ObjectID)
810+ }
811+ var arg3 domain.ObjectID
812+ if args[3] != nil {
813+ arg3 = args[3].(domain.ObjectID)
814+ }
815+ run(
816+ arg0,
817+ arg1,
818+ arg2,
819+ arg3,
820+ )
821+ })
822+ return _c
823+}
824+
825+func (_c *MergeGit_MergeTree_Call) Return(mergeTreeResult domain.MergeTreeResult, err error) *MergeGit_MergeTree_Call {
826+ _c.Call.Return(mergeTreeResult, err)
827+ return _c
828+}
829+
830+func (_c *MergeGit_MergeTree_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error)) *MergeGit_MergeTree_Call {
831+ _c.Call.Return(run)
832+ return _c
833+}
834+
673835 // UpdateBranch provides a mock function for the type MergeGit
674836 func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error {
675837 ret := _mock.Called(ctx, repo, branch, old, new)
@@ -191,6 +191,90 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r
191 return _c191 return _c
192 }192 }
193 193
194+// CountBehind provides a mock function for the type MergeGit
195+func (_mock *MergeGit) CountBehind(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error) {
196+ ret := _mock.Called(ctx, repo, head, target, limit)
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for CountBehind")
200+ }
201+
202+ var r0 int
203+ var r1 error
204+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) (int, error)); ok {
205+ return returnFunc(ctx, repo, head, target, limit)
206+ }
207+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) int); ok {
208+ r0 = returnFunc(ctx, repo, head, target, limit)
209+ } else {
210+ r0 = ret.Get(0).(int)
211+ }
212+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok {
213+ r1 = returnFunc(ctx, repo, head, target, limit)
214+ } else {
215+ r1 = ret.Error(1)
216+ }
217+ return r0, r1
218+}
219+
220+// MergeGit_CountBehind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountBehind'
221+type MergeGit_CountBehind_Call struct {
222+ *mock.Call
223+}
224+
225+// CountBehind is a helper method to define mock.On call
226+// - ctx context.Context
227+// - repo domain.RepoName
228+// - head domain.ObjectID
229+// - target domain.ObjectID
230+// - limit int
231+func (_e *MergeGit_Expecter) CountBehind(ctx any, repo any, head any, target any, limit any) *MergeGit_CountBehind_Call {
232+ return &MergeGit_CountBehind_Call{Call: _e.mock.On("CountBehind", ctx, repo, head, target, limit)}
233+}
234+
235+func (_c *MergeGit_CountBehind_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int)) *MergeGit_CountBehind_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ var arg0 context.Context
238+ if args[0] != nil {
239+ arg0 = args[0].(context.Context)
240+ }
241+ var arg1 domain.RepoName
242+ if args[1] != nil {
243+ arg1 = args[1].(domain.RepoName)
244+ }
245+ var arg2 domain.ObjectID
246+ if args[2] != nil {
247+ arg2 = args[2].(domain.ObjectID)
248+ }
249+ var arg3 domain.ObjectID
250+ if args[3] != nil {
251+ arg3 = args[3].(domain.ObjectID)
252+ }
253+ var arg4 int
254+ if args[4] != nil {
255+ arg4 = args[4].(int)
256+ }
257+ run(
258+ arg0,
259+ arg1,
260+ arg2,
261+ arg3,
262+ arg4,
263+ )
264+ })
265+ return _c
266+}
267+
268+func (_c *MergeGit_CountBehind_Call) Return(n int, err error) *MergeGit_CountBehind_Call {
269+ _c.Call.Return(n, err)
270+ return _c
271+}
272+
273+func (_c *MergeGit_CountBehind_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error)) *MergeGit_CountBehind_Call {
274+ _c.Call.Return(run)
275+ return _c
276+}
277+
194 // DeleteBranch provides a mock function for the type MergeGit278 // DeleteBranch provides a mock function for the type MergeGit
195 func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {279 func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
196 ret := _mock.Called(ctx, repo, branch, old)280 ret := _mock.Called(ctx, repo, branch, old)
@@ -670,6 +754,84 @@ func (_c *MergeGit_MergeBase_Call) RunAndReturn(run func(ctx context.Context, re
670 return _c754 return _c
671 }755 }
672 756
757+// MergeTree provides a mock function for the type MergeGit
758+func (_mock *MergeGit) MergeTree(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error) {
759+ ret := _mock.Called(ctx, repo, base, head)
760+
761+ if len(ret) == 0 {
762+ panic("no return value specified for MergeTree")
763+ }
764+
765+ var r0 domain.MergeTreeResult
766+ var r1 error
767+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (domain.MergeTreeResult, error)); ok {
768+ return returnFunc(ctx, repo, base, head)
769+ }
770+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) domain.MergeTreeResult); ok {
771+ r0 = returnFunc(ctx, repo, base, head)
772+ } else {
773+ r0 = ret.Get(0).(domain.MergeTreeResult)
774+ }
775+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
776+ r1 = returnFunc(ctx, repo, base, head)
777+ } else {
778+ r1 = ret.Error(1)
779+ }
780+ return r0, r1
781+}
782+
783+// MergeGit_MergeTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MergeTree'
784+type MergeGit_MergeTree_Call struct {
785+ *mock.Call
786+}
787+
788+// MergeTree is a helper method to define mock.On call
789+// - ctx context.Context
790+// - repo domain.RepoName
791+// - base domain.ObjectID
792+// - head domain.ObjectID
793+func (_e *MergeGit_Expecter) MergeTree(ctx any, repo any, base any, head any) *MergeGit_MergeTree_Call {
794+ return &MergeGit_MergeTree_Call{Call: _e.mock.On("MergeTree", ctx, repo, base, head)}
795+}
796+
797+func (_c *MergeGit_MergeTree_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID)) *MergeGit_MergeTree_Call {
798+ _c.Call.Run(func(args mock.Arguments) {
799+ var arg0 context.Context
800+ if args[0] != nil {
801+ arg0 = args[0].(context.Context)
802+ }
803+ var arg1 domain.RepoName
804+ if args[1] != nil {
805+ arg1 = args[1].(domain.RepoName)
806+ }
807+ var arg2 domain.ObjectID
808+ if args[2] != nil {
809+ arg2 = args[2].(domain.ObjectID)
810+ }
811+ var arg3 domain.ObjectID
812+ if args[3] != nil {
813+ arg3 = args[3].(domain.ObjectID)
814+ }
815+ run(
816+ arg0,
817+ arg1,
818+ arg2,
819+ arg3,
820+ )
821+ })
822+ return _c
823+}
824+
825+func (_c *MergeGit_MergeTree_Call) Return(mergeTreeResult domain.MergeTreeResult, err error) *MergeGit_MergeTree_Call {
826+ _c.Call.Return(mergeTreeResult, err)
827+ return _c
828+}
829+
830+func (_c *MergeGit_MergeTree_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error)) *MergeGit_MergeTree_Call {
831+ _c.Call.Return(run)
832+ return _c
833+}
834+
673 // UpdateBranch provides a mock function for the type MergeGit835 // UpdateBranch provides a mock function for the type MergeGit
674 func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error {836 func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error {
675 ret := _mock.Called(ctx, repo, branch, old, new)837 ret := _mock.Called(ctx, repo, branch, old, new)
M internal/ports/mocks/MergeRequestService.go +78 -0
@@ -942,6 +942,84 @@ func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Cont
942942 return _c
943943 }
944944
945+// Mergeability provides a mock function for the type MergeRequestService
946+func (_mock *MergeRequestService) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
947+ ret := _mock.Called(ctx, p, name, number)
948+
949+ if len(ret) == 0 {
950+ panic("no return value specified for Mergeability")
951+ }
952+
953+ var r0 domain.Mergeability
954+ var r1 error
955+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Mergeability, error)); ok {
956+ return returnFunc(ctx, p, name, number)
957+ }
958+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Mergeability); ok {
959+ r0 = returnFunc(ctx, p, name, number)
960+ } else {
961+ r0 = ret.Get(0).(domain.Mergeability)
962+ }
963+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
964+ r1 = returnFunc(ctx, p, name, number)
965+ } else {
966+ r1 = ret.Error(1)
967+ }
968+ return r0, r1
969+}
970+
971+// MergeRequestService_Mergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mergeability'
972+type MergeRequestService_Mergeability_Call struct {
973+ *mock.Call
974+}
975+
976+// Mergeability is a helper method to define mock.On call
977+// - ctx context.Context
978+// - p domain.Principal
979+// - name domain.RepoName
980+// - number int64
981+func (_e *MergeRequestService_Expecter) Mergeability(ctx any, p any, name any, number any) *MergeRequestService_Mergeability_Call {
982+ return &MergeRequestService_Mergeability_Call{Call: _e.mock.On("Mergeability", ctx, p, name, number)}
983+}
984+
985+func (_c *MergeRequestService_Mergeability_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Mergeability_Call {
986+ _c.Call.Run(func(args mock.Arguments) {
987+ var arg0 context.Context
988+ if args[0] != nil {
989+ arg0 = args[0].(context.Context)
990+ }
991+ var arg1 domain.Principal
992+ if args[1] != nil {
993+ arg1 = args[1].(domain.Principal)
994+ }
995+ var arg2 domain.RepoName
996+ if args[2] != nil {
997+ arg2 = args[2].(domain.RepoName)
998+ }
999+ var arg3 int64
1000+ if args[3] != nil {
1001+ arg3 = args[3].(int64)
1002+ }
1003+ run(
1004+ arg0,
1005+ arg1,
1006+ arg2,
1007+ arg3,
1008+ )
1009+ })
1010+ return _c
1011+}
1012+
1013+func (_c *MergeRequestService_Mergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestService_Mergeability_Call {
1014+ _c.Call.Return(mergeability, err)
1015+ return _c
1016+}
1017+
1018+func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestService_Mergeability_Call {
1019+ _c.Call.Return(run)
1020+ return _c
1021+}
1022+
9451023 // ResolveComment provides a mock function for the type MergeRequestService
9461024 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
9471025 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
@@ -942,6 +942,84 @@ func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Cont
942 return _c942 return _c
943 }943 }
944 944
945+// Mergeability provides a mock function for the type MergeRequestService
946+func (_mock *MergeRequestService) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
947+ ret := _mock.Called(ctx, p, name, number)
948+
949+ if len(ret) == 0 {
950+ panic("no return value specified for Mergeability")
951+ }
952+
953+ var r0 domain.Mergeability
954+ var r1 error
955+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Mergeability, error)); ok {
956+ return returnFunc(ctx, p, name, number)
957+ }
958+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Mergeability); ok {
959+ r0 = returnFunc(ctx, p, name, number)
960+ } else {
961+ r0 = ret.Get(0).(domain.Mergeability)
962+ }
963+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
964+ r1 = returnFunc(ctx, p, name, number)
965+ } else {
966+ r1 = ret.Error(1)
967+ }
968+ return r0, r1
969+}
970+
971+// MergeRequestService_Mergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mergeability'
972+type MergeRequestService_Mergeability_Call struct {
973+ *mock.Call
974+}
975+
976+// Mergeability is a helper method to define mock.On call
977+// - ctx context.Context
978+// - p domain.Principal
979+// - name domain.RepoName
980+// - number int64
981+func (_e *MergeRequestService_Expecter) Mergeability(ctx any, p any, name any, number any) *MergeRequestService_Mergeability_Call {
982+ return &MergeRequestService_Mergeability_Call{Call: _e.mock.On("Mergeability", ctx, p, name, number)}
983+}
984+
985+func (_c *MergeRequestService_Mergeability_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Mergeability_Call {
986+ _c.Call.Run(func(args mock.Arguments) {
987+ var arg0 context.Context
988+ if args[0] != nil {
989+ arg0 = args[0].(context.Context)
990+ }
991+ var arg1 domain.Principal
992+ if args[1] != nil {
993+ arg1 = args[1].(domain.Principal)
994+ }
995+ var arg2 domain.RepoName
996+ if args[2] != nil {
997+ arg2 = args[2].(domain.RepoName)
998+ }
999+ var arg3 int64
1000+ if args[3] != nil {
1001+ arg3 = args[3].(int64)
1002+ }
1003+ run(
1004+ arg0,
1005+ arg1,
1006+ arg2,
1007+ arg3,
1008+ )
1009+ })
1010+ return _c
1011+}
1012+
1013+func (_c *MergeRequestService_Mergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestService_Mergeability_Call {
1014+ _c.Call.Return(mergeability, err)
1015+ return _c
1016+}
1017+
1018+func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestService_Mergeability_Call {
1019+ _c.Call.Return(run)
1020+ return _c
1021+}
1022+
945 // ResolveComment provides a mock function for the type MergeRequestService1023 // ResolveComment provides a mock function for the type MergeRequestService
946 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {1024 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
947 ret := _mock.Called(ctx, p, name, number, commentID, resolved)1025 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
M internal/ports/mocks/MergeRequestStore.go +192 -0
@@ -179,6 +179,69 @@ func (_c *MergeRequestStore_Create_Call) RunAndReturn(run func(ctx context.Conte
179179 return _c
180180 }
181181
182+// DeleteMergeability provides a mock function for the type MergeRequestStore
183+func (_mock *MergeRequestStore) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
184+ ret := _mock.Called(ctx, repo, number)
185+
186+ if len(ret) == 0 {
187+ panic("no return value specified for DeleteMergeability")
188+ }
189+
190+ var r0 error
191+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) error); ok {
192+ r0 = returnFunc(ctx, repo, number)
193+ } else {
194+ r0 = ret.Error(0)
195+ }
196+ return r0
197+}
198+
199+// MergeRequestStore_DeleteMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMergeability'
200+type MergeRequestStore_DeleteMergeability_Call struct {
201+ *mock.Call
202+}
203+
204+// DeleteMergeability is a helper method to define mock.On call
205+// - ctx context.Context
206+// - repo domain.RepoName
207+// - number int64
208+func (_e *MergeRequestStore_Expecter) DeleteMergeability(ctx any, repo any, number any) *MergeRequestStore_DeleteMergeability_Call {
209+ return &MergeRequestStore_DeleteMergeability_Call{Call: _e.mock.On("DeleteMergeability", ctx, repo, number)}
210+}
211+
212+func (_c *MergeRequestStore_DeleteMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_DeleteMergeability_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ var arg0 context.Context
215+ if args[0] != nil {
216+ arg0 = args[0].(context.Context)
217+ }
218+ var arg1 domain.RepoName
219+ if args[1] != nil {
220+ arg1 = args[1].(domain.RepoName)
221+ }
222+ var arg2 int64
223+ if args[2] != nil {
224+ arg2 = args[2].(int64)
225+ }
226+ run(
227+ arg0,
228+ arg1,
229+ arg2,
230+ )
231+ })
232+ return _c
233+}
234+
235+func (_c *MergeRequestStore_DeleteMergeability_Call) Return(err error) *MergeRequestStore_DeleteMergeability_Call {
236+ _c.Call.Return(err)
237+ return _c
238+}
239+
240+func (_c *MergeRequestStore_DeleteMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) error) *MergeRequestStore_DeleteMergeability_Call {
241+ _c.Call.Return(run)
242+ return _c
243+}
244+
182245 // Get provides a mock function for the type MergeRequestStore
183246 func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {
184247 ret := _mock.Called(ctx, repo, number)
@@ -329,6 +392,78 @@ func (_c *MergeRequestStore_GetComment_Call) RunAndReturn(run func(ctx context.C
329392 return _c
330393 }
331394
395+// GetMergeability provides a mock function for the type MergeRequestStore
396+func (_mock *MergeRequestStore) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
397+ ret := _mock.Called(ctx, repo, number)
398+
399+ if len(ret) == 0 {
400+ panic("no return value specified for GetMergeability")
401+ }
402+
403+ var r0 domain.Mergeability
404+ var r1 error
405+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Mergeability, error)); ok {
406+ return returnFunc(ctx, repo, number)
407+ }
408+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Mergeability); ok {
409+ r0 = returnFunc(ctx, repo, number)
410+ } else {
411+ r0 = ret.Get(0).(domain.Mergeability)
412+ }
413+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
414+ r1 = returnFunc(ctx, repo, number)
415+ } else {
416+ r1 = ret.Error(1)
417+ }
418+ return r0, r1
419+}
420+
421+// MergeRequestStore_GetMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergeability'
422+type MergeRequestStore_GetMergeability_Call struct {
423+ *mock.Call
424+}
425+
426+// GetMergeability is a helper method to define mock.On call
427+// - ctx context.Context
428+// - repo domain.RepoName
429+// - number int64
430+func (_e *MergeRequestStore_Expecter) GetMergeability(ctx any, repo any, number any) *MergeRequestStore_GetMergeability_Call {
431+ return &MergeRequestStore_GetMergeability_Call{Call: _e.mock.On("GetMergeability", ctx, repo, number)}
432+}
433+
434+func (_c *MergeRequestStore_GetMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_GetMergeability_Call {
435+ _c.Call.Run(func(args mock.Arguments) {
436+ var arg0 context.Context
437+ if args[0] != nil {
438+ arg0 = args[0].(context.Context)
439+ }
440+ var arg1 domain.RepoName
441+ if args[1] != nil {
442+ arg1 = args[1].(domain.RepoName)
443+ }
444+ var arg2 int64
445+ if args[2] != nil {
446+ arg2 = args[2].(int64)
447+ }
448+ run(
449+ arg0,
450+ arg1,
451+ arg2,
452+ )
453+ })
454+ return _c
455+}
456+
457+func (_c *MergeRequestStore_GetMergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestStore_GetMergeability_Call {
458+ _c.Call.Return(mergeability, err)
459+ return _c
460+}
461+
462+func (_c *MergeRequestStore_GetMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestStore_GetMergeability_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
332467 // GetRead provides a mock function for the type MergeRequestStore
333468 func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) {
334469 ret := _mock.Called(ctx, repo, number)
@@ -845,6 +980,63 @@ func (_c *MergeRequestStore_OpenByTarget_Call) RunAndReturn(run func(ctx context
845980 return _c
846981 }
847982
983+// PutMergeability provides a mock function for the type MergeRequestStore
984+func (_mock *MergeRequestStore) PutMergeability(ctx context.Context, m domain.Mergeability) error {
985+ ret := _mock.Called(ctx, m)
986+
987+ if len(ret) == 0 {
988+ panic("no return value specified for PutMergeability")
989+ }
990+
991+ var r0 error
992+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Mergeability) error); ok {
993+ r0 = returnFunc(ctx, m)
994+ } else {
995+ r0 = ret.Error(0)
996+ }
997+ return r0
998+}
999+
1000+// MergeRequestStore_PutMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutMergeability'
1001+type MergeRequestStore_PutMergeability_Call struct {
1002+ *mock.Call
1003+}
1004+
1005+// PutMergeability is a helper method to define mock.On call
1006+// - ctx context.Context
1007+// - m domain.Mergeability
1008+func (_e *MergeRequestStore_Expecter) PutMergeability(ctx any, m any) *MergeRequestStore_PutMergeability_Call {
1009+ return &MergeRequestStore_PutMergeability_Call{Call: _e.mock.On("PutMergeability", ctx, m)}
1010+}
1011+
1012+func (_c *MergeRequestStore_PutMergeability_Call) Run(run func(ctx context.Context, m domain.Mergeability)) *MergeRequestStore_PutMergeability_Call {
1013+ _c.Call.Run(func(args mock.Arguments) {
1014+ var arg0 context.Context
1015+ if args[0] != nil {
1016+ arg0 = args[0].(context.Context)
1017+ }
1018+ var arg1 domain.Mergeability
1019+ if args[1] != nil {
1020+ arg1 = args[1].(domain.Mergeability)
1021+ }
1022+ run(
1023+ arg0,
1024+ arg1,
1025+ )
1026+ })
1027+ return _c
1028+}
1029+
1030+func (_c *MergeRequestStore_PutMergeability_Call) Return(err error) *MergeRequestStore_PutMergeability_Call {
1031+ _c.Call.Return(err)
1032+ return _c
1033+}
1034+
1035+func (_c *MergeRequestStore_PutMergeability_Call) RunAndReturn(run func(ctx context.Context, m domain.Mergeability) error) *MergeRequestStore_PutMergeability_Call {
1036+ _c.Call.Return(run)
1037+ return _c
1038+}
1039+
8481040 // SaveComment provides a mock function for the type MergeRequestStore
8491041 func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error {
8501042 ret := _mock.Called(ctx, mr, c)
@@ -179,6 +179,69 @@ func (_c *MergeRequestStore_Create_Call) RunAndReturn(run func(ctx context.Conte
179 return _c179 return _c
180 }180 }
181 181
182+// DeleteMergeability provides a mock function for the type MergeRequestStore
183+func (_mock *MergeRequestStore) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
184+ ret := _mock.Called(ctx, repo, number)
185+
186+ if len(ret) == 0 {
187+ panic("no return value specified for DeleteMergeability")
188+ }
189+
190+ var r0 error
191+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) error); ok {
192+ r0 = returnFunc(ctx, repo, number)
193+ } else {
194+ r0 = ret.Error(0)
195+ }
196+ return r0
197+}
198+
199+// MergeRequestStore_DeleteMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMergeability'
200+type MergeRequestStore_DeleteMergeability_Call struct {
201+ *mock.Call
202+}
203+
204+// DeleteMergeability is a helper method to define mock.On call
205+// - ctx context.Context
206+// - repo domain.RepoName
207+// - number int64
208+func (_e *MergeRequestStore_Expecter) DeleteMergeability(ctx any, repo any, number any) *MergeRequestStore_DeleteMergeability_Call {
209+ return &MergeRequestStore_DeleteMergeability_Call{Call: _e.mock.On("DeleteMergeability", ctx, repo, number)}
210+}
211+
212+func (_c *MergeRequestStore_DeleteMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_DeleteMergeability_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ var arg0 context.Context
215+ if args[0] != nil {
216+ arg0 = args[0].(context.Context)
217+ }
218+ var arg1 domain.RepoName
219+ if args[1] != nil {
220+ arg1 = args[1].(domain.RepoName)
221+ }
222+ var arg2 int64
223+ if args[2] != nil {
224+ arg2 = args[2].(int64)
225+ }
226+ run(
227+ arg0,
228+ arg1,
229+ arg2,
230+ )
231+ })
232+ return _c
233+}
234+
235+func (_c *MergeRequestStore_DeleteMergeability_Call) Return(err error) *MergeRequestStore_DeleteMergeability_Call {
236+ _c.Call.Return(err)
237+ return _c
238+}
239+
240+func (_c *MergeRequestStore_DeleteMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) error) *MergeRequestStore_DeleteMergeability_Call {
241+ _c.Call.Return(run)
242+ return _c
243+}
244+
182 // Get provides a mock function for the type MergeRequestStore245 // Get provides a mock function for the type MergeRequestStore
183 func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {246 func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {
184 ret := _mock.Called(ctx, repo, number)247 ret := _mock.Called(ctx, repo, number)
@@ -329,6 +392,78 @@ func (_c *MergeRequestStore_GetComment_Call) RunAndReturn(run func(ctx context.C
329 return _c392 return _c
330 }393 }
331 394
395+// GetMergeability provides a mock function for the type MergeRequestStore
396+func (_mock *MergeRequestStore) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
397+ ret := _mock.Called(ctx, repo, number)
398+
399+ if len(ret) == 0 {
400+ panic("no return value specified for GetMergeability")
401+ }
402+
403+ var r0 domain.Mergeability
404+ var r1 error
405+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Mergeability, error)); ok {
406+ return returnFunc(ctx, repo, number)
407+ }
408+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Mergeability); ok {
409+ r0 = returnFunc(ctx, repo, number)
410+ } else {
411+ r0 = ret.Get(0).(domain.Mergeability)
412+ }
413+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
414+ r1 = returnFunc(ctx, repo, number)
415+ } else {
416+ r1 = ret.Error(1)
417+ }
418+ return r0, r1
419+}
420+
421+// MergeRequestStore_GetMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergeability'
422+type MergeRequestStore_GetMergeability_Call struct {
423+ *mock.Call
424+}
425+
426+// GetMergeability is a helper method to define mock.On call
427+// - ctx context.Context
428+// - repo domain.RepoName
429+// - number int64
430+func (_e *MergeRequestStore_Expecter) GetMergeability(ctx any, repo any, number any) *MergeRequestStore_GetMergeability_Call {
431+ return &MergeRequestStore_GetMergeability_Call{Call: _e.mock.On("GetMergeability", ctx, repo, number)}
432+}
433+
434+func (_c *MergeRequestStore_GetMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_GetMergeability_Call {
435+ _c.Call.Run(func(args mock.Arguments) {
436+ var arg0 context.Context
437+ if args[0] != nil {
438+ arg0 = args[0].(context.Context)
439+ }
440+ var arg1 domain.RepoName
441+ if args[1] != nil {
442+ arg1 = args[1].(domain.RepoName)
443+ }
444+ var arg2 int64
445+ if args[2] != nil {
446+ arg2 = args[2].(int64)
447+ }
448+ run(
449+ arg0,
450+ arg1,
451+ arg2,
452+ )
453+ })
454+ return _c
455+}
456+
457+func (_c *MergeRequestStore_GetMergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestStore_GetMergeability_Call {
458+ _c.Call.Return(mergeability, err)
459+ return _c
460+}
461+
462+func (_c *MergeRequestStore_GetMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestStore_GetMergeability_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
332 // GetRead provides a mock function for the type MergeRequestStore467 // GetRead provides a mock function for the type MergeRequestStore
333 func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) {468 func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) {
334 ret := _mock.Called(ctx, repo, number)469 ret := _mock.Called(ctx, repo, number)
@@ -845,6 +980,63 @@ func (_c *MergeRequestStore_OpenByTarget_Call) RunAndReturn(run func(ctx context
845 return _c980 return _c
846 }981 }
847 982
983+// PutMergeability provides a mock function for the type MergeRequestStore
984+func (_mock *MergeRequestStore) PutMergeability(ctx context.Context, m domain.Mergeability) error {
985+ ret := _mock.Called(ctx, m)
986+
987+ if len(ret) == 0 {
988+ panic("no return value specified for PutMergeability")
989+ }
990+
991+ var r0 error
992+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Mergeability) error); ok {
993+ r0 = returnFunc(ctx, m)
994+ } else {
995+ r0 = ret.Error(0)
996+ }
997+ return r0
998+}
999+
1000+// MergeRequestStore_PutMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutMergeability'
1001+type MergeRequestStore_PutMergeability_Call struct {
1002+ *mock.Call
1003+}
1004+
1005+// PutMergeability is a helper method to define mock.On call
1006+// - ctx context.Context
1007+// - m domain.Mergeability
1008+func (_e *MergeRequestStore_Expecter) PutMergeability(ctx any, m any) *MergeRequestStore_PutMergeability_Call {
1009+ return &MergeRequestStore_PutMergeability_Call{Call: _e.mock.On("PutMergeability", ctx, m)}
1010+}
1011+
1012+func (_c *MergeRequestStore_PutMergeability_Call) Run(run func(ctx context.Context, m domain.Mergeability)) *MergeRequestStore_PutMergeability_Call {
1013+ _c.Call.Run(func(args mock.Arguments) {
1014+ var arg0 context.Context
1015+ if args[0] != nil {
1016+ arg0 = args[0].(context.Context)
1017+ }
1018+ var arg1 domain.Mergeability
1019+ if args[1] != nil {
1020+ arg1 = args[1].(domain.Mergeability)
1021+ }
1022+ run(
1023+ arg0,
1024+ arg1,
1025+ )
1026+ })
1027+ return _c
1028+}
1029+
1030+func (_c *MergeRequestStore_PutMergeability_Call) Return(err error) *MergeRequestStore_PutMergeability_Call {
1031+ _c.Call.Return(err)
1032+ return _c
1033+}
1034+
1035+func (_c *MergeRequestStore_PutMergeability_Call) RunAndReturn(run func(ctx context.Context, m domain.Mergeability) error) *MergeRequestStore_PutMergeability_Call {
1036+ _c.Call.Return(run)
1037+ return _c
1038+}
1039+
848 // SaveComment provides a mock function for the type MergeRequestStore1040 // SaveComment provides a mock function for the type MergeRequestStore
849 func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error {1041 func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error {
850 ret := _mock.Called(ctx, mr, c)1042 ret := _mock.Called(ctx, mr, c)
A internal/ports/mocks/MergeabilityReader.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityReader creates a new instance of MergeabilityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityReader{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityReader is an autogenerated mock type for the MergeabilityReader type
38+type MergeabilityReader struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityReader_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityReader) EXPECT() *MergeabilityReader_Expecter {
47+ return &MergeabilityReader_Expecter{mock: &_m.Mock}
48+}
49+
50+// Read provides a mock function for the type MergeabilityReader
51+func (_mock *MergeabilityReader) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
52+ ret := _mock.Called(ctx, mr, targetTip)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Read")
56+ }
57+
58+ var r0 domain.Mergeability
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MergeRequest, domain.ObjectID) domain.Mergeability); ok {
60+ r0 = returnFunc(ctx, mr, targetTip)
61+ } else {
62+ r0 = ret.Get(0).(domain.Mergeability)
63+ }
64+ return r0
65+}
66+
67+// MergeabilityReader_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
68+type MergeabilityReader_Read_Call struct {
69+ *mock.Call
70+}
71+
72+// Read is a helper method to define mock.On call
73+// - ctx context.Context
74+// - mr domain.MergeRequest
75+// - targetTip domain.ObjectID
76+func (_e *MergeabilityReader_Expecter) Read(ctx any, mr any, targetTip any) *MergeabilityReader_Read_Call {
77+ return &MergeabilityReader_Read_Call{Call: _e.mock.On("Read", ctx, mr, targetTip)}
78+}
79+
80+func (_c *MergeabilityReader_Read_Call) Run(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID)) *MergeabilityReader_Read_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.MergeRequest
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.MergeRequest)
89+ }
90+ var arg2 domain.ObjectID
91+ if args[2] != nil {
92+ arg2 = args[2].(domain.ObjectID)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeabilityReader_Read_Call) Return(mergeability domain.Mergeability) *MergeabilityReader_Read_Call {
104+ _c.Call.Return(mergeability)
105+ return _c
106+}
107+
108+func (_c *MergeabilityReader_Read_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability) *MergeabilityReader_Read_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityReader creates a new instance of MergeabilityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityReader{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityReader is an autogenerated mock type for the MergeabilityReader type
38+type MergeabilityReader struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityReader_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityReader) EXPECT() *MergeabilityReader_Expecter {
47+ return &MergeabilityReader_Expecter{mock: &_m.Mock}
48+}
49+
50+// Read provides a mock function for the type MergeabilityReader
51+func (_mock *MergeabilityReader) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
52+ ret := _mock.Called(ctx, mr, targetTip)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Read")
56+ }
57+
58+ var r0 domain.Mergeability
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MergeRequest, domain.ObjectID) domain.Mergeability); ok {
60+ r0 = returnFunc(ctx, mr, targetTip)
61+ } else {
62+ r0 = ret.Get(0).(domain.Mergeability)
63+ }
64+ return r0
65+}
66+
67+// MergeabilityReader_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
68+type MergeabilityReader_Read_Call struct {
69+ *mock.Call
70+}
71+
72+// Read is a helper method to define mock.On call
73+// - ctx context.Context
74+// - mr domain.MergeRequest
75+// - targetTip domain.ObjectID
76+func (_e *MergeabilityReader_Expecter) Read(ctx any, mr any, targetTip any) *MergeabilityReader_Read_Call {
77+ return &MergeabilityReader_Read_Call{Call: _e.mock.On("Read", ctx, mr, targetTip)}
78+}
79+
80+func (_c *MergeabilityReader_Read_Call) Run(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID)) *MergeabilityReader_Read_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.MergeRequest
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.MergeRequest)
89+ }
90+ var arg2 domain.ObjectID
91+ if args[2] != nil {
92+ arg2 = args[2].(domain.ObjectID)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeabilityReader_Read_Call) Return(mergeability domain.Mergeability) *MergeabilityReader_Read_Call {
104+ _c.Call.Return(mergeability)
105+ return _c
106+}
107+
108+func (_c *MergeabilityReader_Read_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability) *MergeabilityReader_Read_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
A internal/ports/mocks/MergeabilityTrigger.go +146 -0
@@ -0,0 +1,146 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityTrigger creates a new instance of MergeabilityTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityTrigger is an autogenerated mock type for the MergeabilityTrigger type
38+type MergeabilityTrigger struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityTrigger) EXPECT() *MergeabilityTrigger_Expecter {
47+ return &MergeabilityTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type MergeabilityTrigger
51+func (_mock *MergeabilityTrigger) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
52+ _mock.Called(ctx, repo, updates)
53+ return
54+}
55+
56+// MergeabilityTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type MergeabilityTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+func (_e *MergeabilityTrigger_Expecter) OnBranchesMoved(ctx any, repo any, updates any) *MergeabilityTrigger_OnBranchesMoved_Call {
66+ return &MergeabilityTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", ctx, repo, updates)}
67+}
68+
69+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Return() *MergeabilityTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnOpened provides a mock function for the type MergeabilityTrigger
103+func (_mock *MergeabilityTrigger) OnOpened(ctx context.Context, mr domain.MergeRequest) {
104+ _mock.Called(ctx, mr)
105+ return
106+}
107+
108+// MergeabilityTrigger_OnOpened_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOpened'
109+type MergeabilityTrigger_OnOpened_Call struct {
110+ *mock.Call
111+}
112+
113+// OnOpened is a helper method to define mock.On call
114+// - ctx context.Context
115+// - mr domain.MergeRequest
116+func (_e *MergeabilityTrigger_Expecter) OnOpened(ctx any, mr any) *MergeabilityTrigger_OnOpened_Call {
117+ return &MergeabilityTrigger_OnOpened_Call{Call: _e.mock.On("OnOpened", ctx, mr)}
118+}
119+
120+func (_c *MergeabilityTrigger_OnOpened_Call) Run(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
121+ _c.Call.Run(func(args mock.Arguments) {
122+ var arg0 context.Context
123+ if args[0] != nil {
124+ arg0 = args[0].(context.Context)
125+ }
126+ var arg1 domain.MergeRequest
127+ if args[1] != nil {
128+ arg1 = args[1].(domain.MergeRequest)
129+ }
130+ run(
131+ arg0,
132+ arg1,
133+ )
134+ })
135+ return _c
136+}
137+
138+func (_c *MergeabilityTrigger_OnOpened_Call) Return() *MergeabilityTrigger_OnOpened_Call {
139+ _c.Call.Return()
140+ return _c
141+}
142+
143+func (_c *MergeabilityTrigger_OnOpened_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
144+ _c.Run(run)
145+ return _c
146+}
@@ -0,0 +1,146 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityTrigger creates a new instance of MergeabilityTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityTrigger is an autogenerated mock type for the MergeabilityTrigger type
38+type MergeabilityTrigger struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityTrigger) EXPECT() *MergeabilityTrigger_Expecter {
47+ return &MergeabilityTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type MergeabilityTrigger
51+func (_mock *MergeabilityTrigger) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
52+ _mock.Called(ctx, repo, updates)
53+ return
54+}
55+
56+// MergeabilityTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type MergeabilityTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+func (_e *MergeabilityTrigger_Expecter) OnBranchesMoved(ctx any, repo any, updates any) *MergeabilityTrigger_OnBranchesMoved_Call {
66+ return &MergeabilityTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", ctx, repo, updates)}
67+}
68+
69+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Return() *MergeabilityTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnOpened provides a mock function for the type MergeabilityTrigger
103+func (_mock *MergeabilityTrigger) OnOpened(ctx context.Context, mr domain.MergeRequest) {
104+ _mock.Called(ctx, mr)
105+ return
106+}
107+
108+// MergeabilityTrigger_OnOpened_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOpened'
109+type MergeabilityTrigger_OnOpened_Call struct {
110+ *mock.Call
111+}
112+
113+// OnOpened is a helper method to define mock.On call
114+// - ctx context.Context
115+// - mr domain.MergeRequest
116+func (_e *MergeabilityTrigger_Expecter) OnOpened(ctx any, mr any) *MergeabilityTrigger_OnOpened_Call {
117+ return &MergeabilityTrigger_OnOpened_Call{Call: _e.mock.On("OnOpened", ctx, mr)}
118+}
119+
120+func (_c *MergeabilityTrigger_OnOpened_Call) Run(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
121+ _c.Call.Run(func(args mock.Arguments) {
122+ var arg0 context.Context
123+ if args[0] != nil {
124+ arg0 = args[0].(context.Context)
125+ }
126+ var arg1 domain.MergeRequest
127+ if args[1] != nil {
128+ arg1 = args[1].(domain.MergeRequest)
129+ }
130+ run(
131+ arg0,
132+ arg1,
133+ )
134+ })
135+ return _c
136+}
137+
138+func (_c *MergeabilityTrigger_OnOpened_Call) Return() *MergeabilityTrigger_OnOpened_Call {
139+ _c.Call.Return()
140+ return _c
141+}
142+
143+func (_c *MergeabilityTrigger_OnOpened_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
144+ _c.Run(run)
145+ return _c
146+}
M internal/ports/ports.go +37 -0
@@ -380,6 +380,17 @@ type MergeRequestStore interface {
380380 // domain.ErrConflict when concurrent writes to the repository keep it
381381 // from landing.
382382 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
383+ // GetMergeability returns the stored mergeability of an open merge request,
384+ // or domain.ErrNotFound when there is none.
385+ GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)
386+ // PutMergeability stores the mergeability. A Version of 0 requires that
387+ // none is stored, otherwise the stored one must be unchanged since it was
388+ // read (domain.ErrConflict). It returns domain.ErrNotFound when the merge
389+ // request is not stored or not open, so that a late write never resurrects
390+ // a record.
391+ PutMergeability(ctx context.Context, m domain.Mergeability) error
392+ // DeleteMergeability removes the stored mergeability; no error when absent.
393+ DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error
383394 }
384395
385396 // MergeGit is what merge requests need from the Git repositories beyond
@@ -415,6 +426,32 @@ type MergeGit interface {
415426 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
416427 // returns domain.ErrConflict when the branch moved or is gone.
417428 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
429+ // MergeTree merges head into base without creating a commit. When the
430+ // merge is not clean (or the commits share no history) Clean is false and
431+ // ConflictPaths lists every conflicted path, unbounded: the caller bounds
432+ // them (MergeTreeResult.Bounded).
433+ MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error)
434+ // CountBehind counts the commits of target that head lacks, stopping at
435+ // limit: the raw count, at most limit. The caller interprets it
436+ // (domain.BoundBehind).
437+ CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
438+}
439+
440+// MergeabilityTrigger queues mergeability checks. It only queues the work and
441+// returns.
442+type MergeabilityTrigger interface {
443+ // OnBranchesMoved queues the open merge requests whose source or target
444+ // branch was moved or deleted by updates.
445+ OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
446+ // OnOpened queues a newly opened merge request.
447+ OnOpened(ctx context.Context, mr domain.MergeRequest)
448+}
449+
450+// MergeabilityReader reads the mergeability of open merge requests.
451+type MergeabilityReader interface {
452+ // Read returns the stored mergeability for mr and targetTip, or a
453+ // "checking" one (and queues a check) when there is none or it is stale.
454+ Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability
418455 }
419456
420457 // CommitSigner signs the commits ermite creates (ADR 0022). The private key
@@ -380,6 +380,17 @@ type MergeRequestStore interface {
380 // domain.ErrConflict when concurrent writes to the repository keep it380 // domain.ErrConflict when concurrent writes to the repository keep it
381 // from landing.381 // from landing.
382 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error382 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
383+ // GetMergeability returns the stored mergeability of an open merge request,
384+ // or domain.ErrNotFound when there is none.
385+ GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)
386+ // PutMergeability stores the mergeability. A Version of 0 requires that
387+ // none is stored, otherwise the stored one must be unchanged since it was
388+ // read (domain.ErrConflict). It returns domain.ErrNotFound when the merge
389+ // request is not stored or not open, so that a late write never resurrects
390+ // a record.
391+ PutMergeability(ctx context.Context, m domain.Mergeability) error
392+ // DeleteMergeability removes the stored mergeability; no error when absent.
393+ DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error
383 }394 }
384 395
385 // MergeGit is what merge requests need from the Git repositories beyond396 // MergeGit is what merge requests need from the Git repositories beyond
@@ -415,6 +426,32 @@ type MergeGit interface {
415 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It426 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
416 // returns domain.ErrConflict when the branch moved or is gone.427 // returns domain.ErrConflict when the branch moved or is gone.
417 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error428 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
429+ // MergeTree merges head into base without creating a commit. When the
430+ // merge is not clean (or the commits share no history) Clean is false and
431+ // ConflictPaths lists every conflicted path, unbounded: the caller bounds
432+ // them (MergeTreeResult.Bounded).
433+ MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error)
434+ // CountBehind counts the commits of target that head lacks, stopping at
435+ // limit: the raw count, at most limit. The caller interprets it
436+ // (domain.BoundBehind).
437+ CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
438+}
439+
440+// MergeabilityTrigger queues mergeability checks. It only queues the work and
441+// returns.
442+type MergeabilityTrigger interface {
443+ // OnBranchesMoved queues the open merge requests whose source or target
444+ // branch was moved or deleted by updates.
445+ OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
446+ // OnOpened queues a newly opened merge request.
447+ OnOpened(ctx context.Context, mr domain.MergeRequest)
448+}
449+
450+// MergeabilityReader reads the mergeability of open merge requests.
451+type MergeabilityReader interface {
452+ // Read returns the stored mergeability for mr and targetTip, or a
453+ // "checking" one (and queues a check) when there is none or it is stale.
454+ Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability
418 }455 }
419 456
420 // CommitSigner signs the commits ermite creates (ADR 0022). The private key457 // CommitSigner signs the commits ermite creates (ADR 0022). The private key
M internal/ports/services.go +4 -0
@@ -124,6 +124,10 @@ type MergeRequestService interface {
124124 // CountOpen is the number of open merge requests of a repository the
125125 // principal can read. It reads no Git: the tab of every page shows it.
126126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
127+ // Mergeability is the mergeability of an open merge request, "checking"
128+ // until the server has a result for the current tips; zero for a request
129+ // that is not open. It reads no diff: the live pages call it on each change.
130+ Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)
127131 }
128132
129133 // IssueService is the issue use cases. *usecase.Issues implements it.
@@ -124,6 +124,10 @@ type MergeRequestService interface {
124 // CountOpen is the number of open merge requests of a repository the124 // CountOpen is the number of open merge requests of a repository the
125 // principal can read. It reads no Git: the tab of every page shows it.125 // principal can read. It reads no Git: the tab of every page shows it.
126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
127+ // Mergeability is the mergeability of an open merge request, "checking"
128+ // until the server has a result for the current tips; zero for a request
129+ // that is not open. It reads no diff: the live pages call it on each change.
130+ Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)
127 }131 }
128 132
129 // IssueService is the issue use cases. *usecase.Issues implements it.133 // IssueService is the issue use cases. *usecase.Issues implements it.
M internal/usecase/ci_unit_test.go +1 -0
@@ -54,6 +54,7 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit {
5454 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
5555 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
5656 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, m.head, tip).Return(false, nil)
5758 return m
5859 }
5960
@@ -54,6 +54,7 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit {
54 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)54 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
55 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)55 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
56 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)56 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, m.head, tip).Return(false, nil)
57 return m58 return m
58 }59 }
59 60
M internal/usecase/events.go +5 -1
@@ -198,6 +198,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
198198 return repoSnapshot(ev.Visibility, ev.CreatedAt)
199199 case domain.CommentResolved:
200200 return repoSnapshot(ev.Visibility, ev.CreatedAt)
201+ case domain.MergeRequestMergeability:
202+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
201203 case domain.RunUpdated:
202204 return repoSnapshot(ev.Visibility, ev.CreatedAt)
203205 case domain.JobUpdated:
@@ -541,6 +543,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
541543 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542544 case domain.CommentResolved:
543545 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
546+ case domain.MergeRequestMergeability:
547+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
544548 case domain.RunUpdated:
545549 // Home shows the CI state of each repository.
546550 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -645,7 +649,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
645649 func isItemEvent(ev domain.Event) bool {
646650 switch ev.(type) {
647651 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
648- domain.MergeRequestReady, domain.CommentAdded,
652+ domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
649653 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
650654 return true
651655 }
@@ -198,6 +198,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
198 return repoSnapshot(ev.Visibility, ev.CreatedAt)198 return repoSnapshot(ev.Visibility, ev.CreatedAt)
199 case domain.CommentResolved:199 case domain.CommentResolved:
200 return repoSnapshot(ev.Visibility, ev.CreatedAt)200 return repoSnapshot(ev.Visibility, ev.CreatedAt)
201+ case domain.MergeRequestMergeability:
202+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
201 case domain.RunUpdated:203 case domain.RunUpdated:
202 return repoSnapshot(ev.Visibility, ev.CreatedAt)204 return repoSnapshot(ev.Visibility, ev.CreatedAt)
203 case domain.JobUpdated:205 case domain.JobUpdated:
@@ -541,6 +543,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
541 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)543 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542 case domain.CommentResolved:544 case domain.CommentResolved:
543 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)545 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
546+ case domain.MergeRequestMergeability:
547+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
544 case domain.RunUpdated:548 case domain.RunUpdated:
545 // Home shows the CI state of each repository.549 // Home shows the CI state of each repository.
546 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)550 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -645,7 +649,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
645 func isItemEvent(ev domain.Event) bool {649 func isItemEvent(ev domain.Event) bool {
646 switch ev.(type) {650 switch ev.(type) {
647 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,651 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
648- domain.MergeRequestReady, domain.CommentAdded,652+ domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
649 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:653 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
650 return true654 return true
651 }655 }
M internal/usecase/events_test.go +13 -0
M internal/usecase/export_test.go +46 -0
A internal/usecase/mergeability.go +467 -0
A internal/usecase/mergeability_reconcile_test.go +91 -0
A internal/usecase/mergeability_test.go +673 -0
M internal/usecase/mergerequests.go +246 -70
M internal/usecase/mergerequests_branches_test.go +1 -0
M internal/usecase/mergerequests_delete_test.go +150 -1
M internal/usecase/mergerequests_integration_test.go +11 -6
A internal/usecase/mergerequests_merge_test.go +293 -0
M internal/usecase/pushes.go +23 -11
M proto/ermite/storage/merge_request.proto +28 -0
M skills/ermite/CLI.md +5 -4
M skills/ermite/SKILL.md +2 -2

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close