feat(web): add merge when ready and auto-merge state to the merge box
- commit
- 5dfb71bb8bc24da1f3014ae2efa9b4526969ef0a
- author
- brice · · 2026-10-20 16:08 UTC
- parent
- 31adb55
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +700 -351
M internal/adapters/web/fragments.go +5 -1
| @@ -61,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool { | |||
| 61 | 61 | } | |
| 62 | 62 | switch we.Event.(type) { | |
| 63 | 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 | 65 | domain.RunUpdated, domain.JobUpdated, | |
| 66 | 66 | domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved: | |
| 67 | 67 | return true | |
| @@ -189,6 +189,10 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 189 | 189 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | |
| 190 | 190 | case domain.MergeRequestReady: | |
| 191 | 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 | 196 | case domain.MergeRequestMergeability: | |
| 193 | 197 | if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n { | |
| 194 | 198 | 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 true | 67 | 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, nil | 198 | return nil, nil | ||
M internal/adapters/web/mr.go +26 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "net/url" | |
| 12 | 12 | "strconv" | |
| 13 | 13 | "strings" | |
| 14 | + | "time" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/a-h/templ" | |
| 16 | 17 | ||
| @@ -202,6 +203,18 @@ type MergeBox struct { | |||
| 202 | 203 | Fingerprint string | |
| 203 | 204 | // Error is why the last merge was refused, shown in the box. | |
| 204 | 205 | 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 | 220 | // 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 | 679 | if m.IsDraft() { | |
| 667 | 680 | box.ReadyAction = mrHref(d.name(), m.Number, "ready") | |
| 668 | 681 | } | |
| 682 | + | box.AutoMerge = autoMergeView(s.cfg.Now(), m.AutoMerge) | |
| 669 | 683 | 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 | 696 | // mrFacts formats the description, metadata, branch links and checks. | |
| 673 | 697 | func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error { | |
| 674 | 698 | m := res.MergeRequest | |
| @@ -790,6 +814,8 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead | |||
| 790 | 814 | CanMarkRead: canMarkRead, | |
| 791 | 815 | CanDeleteBranch: source != d.repo.DefaultBranch, | |
| 792 | 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 | 820 | var lines []string | |
| 795 | 821 | 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 string | 203 | 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 string | 205 | 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 the | 220 | // 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 box | 683 | 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.MergeRequest | 698 | 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 []string | 820 | var lines []string | ||
| 795 | cmd := "" | 821 | cmd := "" | ||
M internal/adapters/web/mr.templ +23 -0
| @@ -508,6 +508,20 @@ templ mergeMenu(p MRPage) { | |||
| 508 | 508 | </form> | |
| 509 | 509 | } else if !p.Box.HeadGone { | |
| 510 | 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 | 525 | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | |
| 512 | 526 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 513 | 527 | <fieldset class="d-modes"> | |
| @@ -544,6 +558,15 @@ templ mergeMenu(p MRPage) { | |||
| 544 | 558 | :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')" | |
| 545 | 559 | :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''" | |
| 546 | 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 | 570 | </div> | |
| 548 | 571 | </form> | |
| 549 | 572 | } | |
| @@ -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, | |||
| 391 | 391 | 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 | 456 | // mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head | |
| 395 | 457 | // they were shown as read. Reading a page never does it (see the top of mr.go). | |
| 396 | 458 | 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, nil | 391 | 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 head | 456 | // 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 +83 -0
| @@ -4,6 +4,9 @@ package web | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 7 | 10 | "testing" | |
| 8 | 11 | "time" | |
| 9 | 12 | ||
| @@ -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 | + | } | |
| @@ -4,6 +4,9 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "strings" | |||
| 7 | "testing" | 10 | "testing" | ||
| 8 | "time" | 11 | "time" | ||
| 9 | 12 | ||||
| @@ -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 { | |||
| 231 | 231 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment)) | |
| 232 | 232 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment)) | |
| 233 | 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 | 236 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose)) | |
| 235 | 237 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady)) | |
| 236 | 238 | 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)) | ||