detached 718df65

merge: web auto-merge controls into feat/auto-merge

commit
718df6565490439ed23597b389853cd19fb24e44
author
brice · · 2026-10-20 16:17 UTC
merge of
b271fd8 + 5dfb71b · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +701 -352
M internal/adapters/web/fragments.go +5 -1
@@ -61,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool {
6161 }
6262 switch we.Event.(type) {
6363 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
64- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
64+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6565 domain.RunUpdated, domain.JobUpdated,
6666 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
6767 return true
@@ -189,6 +189,10 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
189189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
190190 case domain.MergeRequestReady:
191191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
192+ case domain.MergeRequestAutoMergeEnabled:
193+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was enabled — reload")
194+ case domain.MergeRequestAutoMergeCancelled:
195+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was cancelled — reload")
192196 case domain.MergeRequestMergeability:
193197 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
194198 return nil, nil
@@ -61,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool {
61 }61 }
62 switch we.Event.(type) {62 switch we.Event.(type) {
63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,63 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
64- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,64+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
65 domain.RunUpdated, domain.JobUpdated,65 domain.RunUpdated, domain.JobUpdated,
66 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:66 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
67 return true67 return true
@@ -189,6 +189,10 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")189 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
190 case domain.MergeRequestReady:190 case domain.MergeRequestReady:
191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")191 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
192+ case domain.MergeRequestAutoMergeEnabled:
193+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was enabled — reload")
194+ case domain.MergeRequestAutoMergeCancelled:
195+ return l.mrNotice(ctx, ev.MergeRequest.Number, "auto-merge was cancelled — reload")
192 case domain.MergeRequestMergeability:196 case domain.MergeRequestMergeability:
193 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {197 if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
194 return nil, nil198 return nil, nil
M internal/adapters/web/mr.go +26 -0
@@ -11,6 +11,7 @@ import (
1111 "net/url"
1212 "strconv"
1313 "strings"
14+ "time"
1415
1516 "github.com/a-h/templ"
1617
@@ -202,6 +203,18 @@ type MergeBox struct {
202203 Fingerprint string
203204 // Error is why the last merge was refused, shown in the box.
204205 Error string
206+ // AutoAction and CancelAutoAction post to the auto-merge routes.
207+ AutoAction, CancelAutoAction string
208+ // CanAutoMerge offers "Merge when ready": the repository allows
209+ // auto-merge and the server has CI to wait for.
210+ CanAutoMerge bool
211+ // AutoMerge is the pending auto-merge, nil when off.
212+ AutoMerge *AutoMergeView
213+}
214+
215+// AutoMergeView is a pending auto-merge, ready to show.
216+type AutoMergeView struct {
217+ Mode, EnabledBy, When, WhenAt, HeadShort string
205218 }
206219
207220 // ModeOption is a merge mode the repository allows. Disabled says that the
@@ -666,9 +679,20 @@ func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestD
666679 if m.IsDraft() {
667680 box.ReadyAction = mrHref(d.name(), m.Number, "ready")
668681 }
682+ box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge)
669683 return box
670684 }
671685
686+// autoMergeView is the pending auto-merge ready to show, nil when off.
687+func autoMergeView(now time.Time, am *domain.AutoMerge) *AutoMergeView {
688+ if am == nil {
689+ return nil
690+ }
691+ v := &AutoMergeView{Mode: am.Mode.String(), EnabledBy: am.EnablerLabel, HeadShort: short(am.HeadID.String())}
692+ v.When, v.WhenAt = whenOf(now, am.EnabledAt)
693+ return v
694+}
695+
672696 // mrFacts formats the description, metadata, branch links and checks.
673697 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
674698 m := res.MergeRequest
@@ -790,6 +814,8 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead
790814 CanMarkRead: canMarkRead,
791815 CanDeleteBranch: source != d.repo.DefaultBranch,
792816 Mergeability: mg,
817+ AutoAction: mrHref(d.name(), n, "auto-merge"), CancelAutoAction: mrHref(d.name(), n, "auto-merge/cancel"),
818+ CanAutoMerge: d.repo.Settings.AllowAutoMerge && s.cfg.Runs != nil,
793819 }
794820 var lines []string
795821 cmd := ""
@@ -11,6 +11,7 @@ import (
11 "net/url"11 "net/url"
12 "strconv"12 "strconv"
13 "strings"13 "strings"
14+ "time"
14 15
15 "github.com/a-h/templ"16 "github.com/a-h/templ"
16 17
@@ -202,6 +203,18 @@ type MergeBox struct {
202 Fingerprint string203 Fingerprint string
203 // Error is why the last merge was refused, shown in the box.204 // Error is why the last merge was refused, shown in the box.
204 Error string205 Error string
206+ // AutoAction and CancelAutoAction post to the auto-merge routes.
207+ AutoAction, CancelAutoAction string
208+ // CanAutoMerge offers "Merge when ready": the repository allows
209+ // auto-merge and the server has CI to wait for.
210+ CanAutoMerge bool
211+ // AutoMerge is the pending auto-merge, nil when off.
212+ AutoMerge *AutoMergeView
213+}
214+
215+// AutoMergeView is a pending auto-merge, ready to show.
216+type AutoMergeView struct {
217+ Mode, EnabledBy, When, WhenAt, HeadShort string
205 }218 }
206 219
207 // ModeOption is a merge mode the repository allows. Disabled says that the220 // ModeOption is a merge mode the repository allows. Disabled says that the
@@ -666,9 +679,20 @@ func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestD
666 if m.IsDraft() {679 if m.IsDraft() {
667 box.ReadyAction = mrHref(d.name(), m.Number, "ready")680 box.ReadyAction = mrHref(d.name(), m.Number, "ready")
668 }681 }
682+ box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge)
669 return box683 return box
670 }684 }
671 685
686+// autoMergeView is the pending auto-merge ready to show, nil when off.
687+func autoMergeView(now time.Time, am *domain.AutoMerge) *AutoMergeView {
688+ if am == nil {
689+ return nil
690+ }
691+ v := &AutoMergeView{Mode: am.Mode.String(), EnabledBy: am.EnablerLabel, HeadShort: short(am.HeadID.String())}
692+ v.When, v.WhenAt = whenOf(now, am.EnabledAt)
693+ return v
694+}
695+
672 // mrFacts formats the description, metadata, branch links and checks.696 // mrFacts formats the description, metadata, branch links and checks.
673 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {697 func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
674 m := res.MergeRequest698 m := res.MergeRequest
@@ -790,6 +814,8 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead
790 CanMarkRead: canMarkRead,814 CanMarkRead: canMarkRead,
791 CanDeleteBranch: source != d.repo.DefaultBranch,815 CanDeleteBranch: source != d.repo.DefaultBranch,
792 Mergeability: mg,816 Mergeability: mg,
817+ AutoAction: mrHref(d.name(), n, "auto-merge"), CancelAutoAction: mrHref(d.name(), n, "auto-merge/cancel"),
818+ CanAutoMerge: d.repo.Settings.AllowAutoMerge && s.cfg.Runs != nil,
793 }819 }
794 var lines []string820 var lines []string
795 cmd := ""821 cmd := ""
M internal/adapters/web/mr.templ +23 -0
@@ -508,6 +508,20 @@ templ mergeMenu(p MRPage) {
508508 </form>
509509 } else if !p.Box.HeadGone {
510510 @mrMergeabilitySlot(p.Box.Mergeability)
511+ if p.Box.AutoMerge != nil {
512+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.CancelAutoAction) }>
513+ <p class="dim" role="status">
514+ Auto-merge: { p.Box.AutoMerge.Mode } when ready, enabled by { p.Box.AutoMerge.EnabledBy }
515+ if p.Box.AutoMerge.When != "" {
516+ { " " }<time datetime={ p.Box.AutoMerge.WhenAt }>{ p.Box.AutoMerge.When }</time>
517+ }
518+ { " " }(head { p.Box.AutoMerge.HeadShort })
519+ </p>
520+ <div class="d-btns">
521+ <button type="submit" class="btn">Cancel auto-merge</button>
522+ </div>
523+ </form>
524+ }
511525 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
512526 <input type="hidden" name="head_id" value={ p.Box.Head }/>
513527 <fieldset class="d-modes">
@@ -544,6 +558,15 @@ templ mergeMenu(p MRPage) {
544558 :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
545559 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
546560 >Merge into { p.Target }</button>
561+ if p.Box.CanAutoMerge {
562+ <button
563+ type="submit"
564+ class="btn"
565+ formaction={ templ.SafeURL(p.Box.AutoAction) }
566+ disabled?={ len(p.Box.Modes) == 0 }
567+ title="merge once the checks pass"
568+ >Merge when ready</button>
569+ }
547570 </div>
548571 </form>
549572 }
@@ -508,6 +508,20 @@ templ mergeMenu(p MRPage) {
508 </form>508 </form>
509 } else if !p.Box.HeadGone {509 } else if !p.Box.HeadGone {
510 @mrMergeabilitySlot(p.Box.Mergeability)510 @mrMergeabilitySlot(p.Box.Mergeability)
511+ if p.Box.AutoMerge != nil {
512+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.CancelAutoAction) }>
513+ <p class="dim" role="status">
514+ Auto-merge: { p.Box.AutoMerge.Mode } when ready, enabled by { p.Box.AutoMerge.EnabledBy }
515+ if p.Box.AutoMerge.When != "" {
516+ { " " }<time datetime={ p.Box.AutoMerge.WhenAt }>{ p.Box.AutoMerge.When }</time>
517+ }
518+ { " " }(head { p.Box.AutoMerge.HeadShort })
519+ </p>
520+ <div class="d-btns">
521+ <button type="submit" class="btn">Cancel auto-merge</button>
522+ </div>
523+ </form>
524+ }
511 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>525 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
512 <input type="hidden" name="head_id" value={ p.Box.Head }/>526 <input type="hidden" name="head_id" value={ p.Box.Head }/>
513 <fieldset class="d-modes">527 <fieldset class="d-modes">
@@ -544,6 +558,15 @@ templ mergeMenu(p MRPage) {
544 :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"558 :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
545 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"559 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
546 >Merge into { p.Target }</button>560 >Merge into { p.Target }</button>
561+ if p.Box.CanAutoMerge {
562+ <button
563+ type="submit"
564+ class="btn"
565+ formaction={ templ.SafeURL(p.Box.AutoAction) }
566+ disabled?={ len(p.Box.Modes) == 0 }
567+ title="merge once the checks pass"
568+ >Merge when ready</button>
569+ }
547 </div>570 </div>
548 </form>571 </form>
549 }572 }
M internal/adapters/web/mr_post.go +62 -0
@@ -391,6 +391,68 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
391391 return back, back, nil
392392 }
393393
394+// mrAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge: the form of the
395+// merge (mode, head_id) asks to merge once the checks pass. The server records
396+// the head as it is when it enables (the params carry no head); head_id is only
397+// validated.
398+func (s *Server) mrAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
399+ repo := r.PathValue("repo")
400+ n, err := number(r)
401+ if err != nil {
402+ return "", "", err
403+ }
404+ back := mrHref(repo, n)
405+ var mode domain.MergeMode
406+ switch r.PostFormValue("mode") {
407+ case "fast-forward":
408+ mode = domain.MergeModeFastForward
409+ case "squash":
410+ mode = domain.MergeModeSquash
411+ case "merge":
412+ mode = domain.MergeModeMerge
413+ default:
414+ err := formError("mode must be fast-forward, squash or merge")
415+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
416+ }
417+ name, err := domain.ParseRepoName(repo)
418+ if err != nil {
419+ return "", back, err
420+ }
421+ if _, err := headID(r.PostFormValue("head_id")); err != nil {
422+ return "", back, err
423+ }
424+ _, err = s.cfg.MergeRequests.EnableAutoMerge(ctx, principal(ctx), name, n, ports.EnableAutoMergeParams{Mode: mode})
425+ switch {
426+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
427+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
428+ case err != nil:
429+ return "", back, err
430+ }
431+ return back, back, nil
432+}
433+
434+// mrCancelAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge/cancel.
435+func (s *Server) mrCancelAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
436+ repo := r.PathValue("repo")
437+ n, err := number(r)
438+ if err != nil {
439+ return "", "", err
440+ }
441+ back := mrHref(repo, n)
442+ name, err := domain.ParseRepoName(repo)
443+ if err != nil {
444+ return "", back, err
445+ }
446+ _, err = s.cfg.MergeRequests.CancelAutoMerge(ctx, principal(ctx), name, n)
447+ switch {
448+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
449+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
450+ case err != nil:
451+ return "", back, err
452+ }
453+ return back, back, nil
454+}
455+
394456 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
395457 // they were shown as read. Reading a page never does it (see the top of mr.go).
396458 func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
@@ -391,6 +391,68 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
391 return back, back, nil391 return back, back, nil
392 }392 }
393 393
394+// mrAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge: the form of the
395+// merge (mode, head_id) asks to merge once the checks pass. The server records
396+// the head as it is when it enables (the params carry no head); head_id is only
397+// validated.
398+func (s *Server) mrAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
399+ repo := r.PathValue("repo")
400+ n, err := number(r)
401+ if err != nil {
402+ return "", "", err
403+ }
404+ back := mrHref(repo, n)
405+ var mode domain.MergeMode
406+ switch r.PostFormValue("mode") {
407+ case "fast-forward":
408+ mode = domain.MergeModeFastForward
409+ case "squash":
410+ mode = domain.MergeModeSquash
411+ case "merge":
412+ mode = domain.MergeModeMerge
413+ default:
414+ err := formError("mode must be fast-forward, squash or merge")
415+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
416+ }
417+ name, err := domain.ParseRepoName(repo)
418+ if err != nil {
419+ return "", back, err
420+ }
421+ if _, err := headID(r.PostFormValue("head_id")); err != nil {
422+ return "", back, err
423+ }
424+ _, err = s.cfg.MergeRequests.EnableAutoMerge(ctx, principal(ctx), name, n, ports.EnableAutoMergeParams{Mode: mode})
425+ switch {
426+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
427+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
428+ case err != nil:
429+ return "", back, err
430+ }
431+ return back, back, nil
432+}
433+
434+// mrCancelAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge/cancel.
435+func (s *Server) mrCancelAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
436+ repo := r.PathValue("repo")
437+ n, err := number(r)
438+ if err != nil {
439+ return "", "", err
440+ }
441+ back := mrHref(repo, n)
442+ name, err := domain.ParseRepoName(repo)
443+ if err != nil {
444+ return "", back, err
445+ }
446+ _, err = s.cfg.MergeRequests.CancelAutoMerge(ctx, principal(ctx), name, n)
447+ switch {
448+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
449+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
450+ case err != nil:
451+ return "", back, err
452+ }
453+ return back, back, nil
454+}
455+
394 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head456 // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
395 // they were shown as read. Reading a page never does it (see the top of mr.go).457 // they were shown as read. Reading a page never does it (see the top of mr.go).
396 func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {458 func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
M internal/adapters/web/mr_templ.go +499 -350 generated file: collapsed
M internal/adapters/web/mr_test.go +84 -1
@@ -4,8 +4,11 @@ package web
44
55 import (
66 "context"
7- "time"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
810 "testing"
11+ "time"
912
1013 "github.com/stretchr/testify/assert"
1114 "github.com/stretchr/testify/mock"
@@ -195,3 +198,83 @@ func TestMRReadPresentationCommitCount(t *testing.T) {
195198 })
196199 }
197200 }
201+
202+func TestMergeBoxAutoMerge(t *testing.T) {
203+ t.Parallel()
204+ mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")
205+ mk := func(allow bool, runs ports.RunService) *MergeBox {
206+ v := repoWithModes(domain.MergeModes()...)
207+ v.Settings.AllowAutoMerge = allow
208+ return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg)
209+ }
210+ assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge)
211+ assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for")
212+ on := mk(true, mocks.NewRunService(t))
213+ assert.True(t, on.CanAutoMerge)
214+
215+ page := MRPage{Target: "main", Box: on}
216+ html := renderToString(t, mergeMenu(page))
217+ assert.Contains(t, html, "Merge when ready")
218+ assert.Contains(t, html, `formaction="`+on.AutoAction+`"`)
219+ assert.NotContains(t, html, "Cancel auto-merge")
220+ page.Box = mk(false, mocks.NewRunService(t))
221+ assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
222+
223+ on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"}
224+ html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on}))
225+ assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner")
226+ assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`)
227+ assert.Contains(t, html, "Cancel auto-merge")
228+}
229+
230+func autoMergeRequest(path, body string) *http.Request {
231+ r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
232+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
233+ r.SetPathValue("repo", "demo")
234+ r.SetPathValue("n", "3")
235+ return r
236+}
237+
238+func TestAutoMergeHandlers(t *testing.T) {
239+ t.Parallel()
240+ name, err := domain.ParseRepoName("demo")
241+ require.NoError(t, err)
242+ ctx := context.Background()
243+
244+ svc := mocks.NewMergeRequestService(t)
245+ svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash}).
246+ Return(domain.MergeRequest{}, nil).Once()
247+ svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once()
248+ s := &Server{cfg: Config{MergeRequests: svc}}
249+ to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash"))
250+ require.NoError(t, err)
251+ assert.Equal(t, mrHref("demo", 3), to)
252+ to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", ""))
253+ require.NoError(t, err)
254+ assert.Equal(t, mrHref("demo", 3), to)
255+
256+ // A bad mode is refused before the service.
257+ _, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase"))
258+ var re *rerender
259+ require.ErrorAs(t, err, &re)
260+ assert.Equal(t, http.StatusBadRequest, re.status)
261+
262+ // A refusal of the use case is shown in the box, like a refused merge.
263+ svc2 := mocks.NewMergeRequestService(t)
264+ svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything).
265+ Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once()
266+ svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).
267+ Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
268+ s2 := &Server{cfg: Config{MergeRequests: svc2}}
269+ _, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash"))
270+ require.ErrorAs(t, err, &re)
271+ assert.Equal(t, http.StatusConflict, re.status)
272+ _, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", ""))
273+ require.ErrorIs(t, err, domain.ErrNotFound)
274+}
275+
276+func TestAutoMergeLiveEvents(t *testing.T) {
277+ t.Parallel()
278+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
279+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
280+}
@@ -4,8 +4,11 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7- "time"7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
8 "testing"10 "testing"
11+ "time"
9 12
10 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
@@ -195,3 +198,83 @@ func TestMRReadPresentationCommitCount(t *testing.T) {
195 })198 })
196 }199 }
197 }200 }
201+
202+func TestMergeBoxAutoMerge(t *testing.T) {
203+ t.Parallel()
204+ mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")
205+ mk := func(allow bool, runs ports.RunService) *MergeBox {
206+ v := repoWithModes(domain.MergeModes()...)
207+ v.Settings.AllowAutoMerge = allow
208+ return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg)
209+ }
210+ assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge)
211+ assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for")
212+ on := mk(true, mocks.NewRunService(t))
213+ assert.True(t, on.CanAutoMerge)
214+
215+ page := MRPage{Target: "main", Box: on}
216+ html := renderToString(t, mergeMenu(page))
217+ assert.Contains(t, html, "Merge when ready")
218+ assert.Contains(t, html, `formaction="`+on.AutoAction+`"`)
219+ assert.NotContains(t, html, "Cancel auto-merge")
220+ page.Box = mk(false, mocks.NewRunService(t))
221+ assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
222+
223+ on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"}
224+ html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on}))
225+ assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner")
226+ assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`)
227+ assert.Contains(t, html, "Cancel auto-merge")
228+}
229+
230+func autoMergeRequest(path, body string) *http.Request {
231+ r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
232+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
233+ r.SetPathValue("repo", "demo")
234+ r.SetPathValue("n", "3")
235+ return r
236+}
237+
238+func TestAutoMergeHandlers(t *testing.T) {
239+ t.Parallel()
240+ name, err := domain.ParseRepoName("demo")
241+ require.NoError(t, err)
242+ ctx := context.Background()
243+
244+ svc := mocks.NewMergeRequestService(t)
245+ svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash}).
246+ Return(domain.MergeRequest{}, nil).Once()
247+ svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once()
248+ s := &Server{cfg: Config{MergeRequests: svc}}
249+ to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash"))
250+ require.NoError(t, err)
251+ assert.Equal(t, mrHref("demo", 3), to)
252+ to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", ""))
253+ require.NoError(t, err)
254+ assert.Equal(t, mrHref("demo", 3), to)
255+
256+ // A bad mode is refused before the service.
257+ _, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase"))
258+ var re *rerender
259+ require.ErrorAs(t, err, &re)
260+ assert.Equal(t, http.StatusBadRequest, re.status)
261+
262+ // A refusal of the use case is shown in the box, like a refused merge.
263+ svc2 := mocks.NewMergeRequestService(t)
264+ svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything).
265+ Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once()
266+ svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).
267+ Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
268+ s2 := &Server{cfg: Config{MergeRequests: svc2}}
269+ _, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash"))
270+ require.ErrorAs(t, err, &re)
271+ assert.Equal(t, http.StatusConflict, re.status)
272+ _, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", ""))
273+ require.ErrorIs(t, err, domain.ErrNotFound)
274+}
275+
276+func TestAutoMergeLiveEvents(t *testing.T) {
277+ t.Parallel()
278+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
279+ assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
280+}
M internal/adapters/web/web.go +2 -0
@@ -231,6 +231,8 @@ func (s *Server) routes() *http.ServeMux {
231231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
232232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
233233 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
234+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge", s.mrAction(s.mrAutoMerge))
235+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge/cancel", s.mrAction(s.mrCancelAutoMerge))
234236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
235237 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
236238 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
@@ -231,6 +231,8 @@ func (s *Server) routes() *http.ServeMux {
231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))231 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))232 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
233 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))233 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
234+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge", s.mrAction(s.mrAutoMerge))
235+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/auto-merge/cancel", s.mrAction(s.mrCancelAutoMerge))
234 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
235 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))237 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
236 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))238 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close