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
@@ -102,7 +102,7 @@ templ Live(l Layout) {
102102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
103103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104104 htmx:sse:before:connection -> ermiteLive.gate(event);
105- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
105+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106106 gap -> ermiteLive.reload()"
107107 ></span>
108108 }
@@ -102,7 +102,7 @@ templ Live(l Layout) {
102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
103 htmx:sse:error -> ermiteLive.error(event.detail.status);103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104 htmx:sse:before:connection -> ermiteLive.gate(event);104 htmx:sse:before:connection -> ermiteLive.gate(event);
105- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);105+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106 gap -> ermiteLive.reload()"106 gap -> ermiteLive.reload()"
107 ></span>107 ></span>
108 }108 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/fragments_test.go +1 -0
@@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) {
3333 }{
3434 {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""},
3535 {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""},
36+ {"mergeability slot, as sent by the stream", MRMergeabilityFragment(MergeabilityView{State: "up_to_date", Label: "Ready to merge", FFOk: true, MergeOk: true}), "mr-mergeability", "Ready to merge"},
3637 {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"},
3738 {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"},
3839 } {
@@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) {
33 }{33 }{
34 {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""},34 {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""},
35 {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""},35 {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""},
36+ {"mergeability slot, as sent by the stream", MRMergeabilityFragment(MergeabilityView{State: "up_to_date", Label: "Ready to merge", FFOk: true, MergeOk: true}), "mr-mergeability", "Ready to merge"},
36 {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"},37 {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"},
37 {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"},38 {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"},
38 } {39 } {
M internal/adapters/web/mr.go +156 -18
@@ -196,10 +196,38 @@ type MergeBox struct {
196196 // CanDeleteBranch offers to delete the source after the merge: it is not
197197 // the default branch. The server checks again, with the open requests.
198198 CanDeleteBranch bool
199+ // Mergeability is the live badge of the box and what the modes depend on.
200+ Mergeability MergeabilityView
201+ // Signed is true when a mode that creates a commit (squash, merge commit)
202+ // is offered; Fingerprint is then the one of the key that signs it, empty
203+ // when the server does not tell.
204+ Signed bool
205+ Fingerprint string
206+ // Error is why the last merge was refused, shown in the box.
207+ Error string
199208 }
200209
201-// ModeOption is a merge mode the repository allows.
202-type ModeOption struct{ Value, Label string }
210+// ModeOption is a merge mode the repository allows. Disabled says that the
211+// mode is not possible for the current state of the request, Reason why.
212+type ModeOption struct {
213+ Value, Label string
214+ Disabled bool
215+ Reason string
216+}
217+
218+// MergeabilityView is the mergeability of an open request, ready to show: the
219+// badge, and what the merge modes depend on (the flags are what the controls
220+// of the box read, live).
221+type MergeabilityView struct {
222+ State, Label, Detail string
223+ Paths []string
224+ // PathsNote says that more paths conflict than are listed.
225+ PathsNote string
226+ // FFOk and MergeOk say whether fast-forward, squash and merge commit are
227+ // possible; the reasons say why not.
228+ FFOk, MergeOk bool
229+ FFReason, MergeReason string
230+}
203231
204232 // SinceNav switches between the whole diff and the diff since the last read.
205233 type SinceNav struct {
@@ -233,6 +261,16 @@ type MRPage struct {
233261 MergedShort string
234262 MergedHref string
235263 MergeMode string
264+ // Deleting is the source branch that a merge just asked to delete: the
265+ // deletion follows the answer.
266+ Deleting string
267+ // MergeError is why a merge was refused when the page has no merge box
268+ // to show it in (the request is not open any more).
269+ MergeError string
270+ // Canonical is the address of the request, set when the page answers a
271+ // refused action from the address of that action: the script puts it in
272+ // the address bar, so that a reload does not post again.
273+ Canonical string
236274
237275 Since *SinceNav
238276 SinceNote string
@@ -346,10 +384,59 @@ func modeName(m domain.MergeMode) string {
346384 return "fast-forward"
347385 case domain.MergeModeSquash:
348386 return "squash"
387+ case domain.MergeModeMerge:
388+ return "merge commit"
349389 }
350390 return ""
351391 }
352392
393+// mergeabilityView converts the mergeability of a request into the badge and
394+// the flags of the controls of the merge box. A request without result is
395+// being checked.
396+func mergeabilityView(m domain.Mergeability, target string) MergeabilityView {
397+ v := MergeabilityView{State: string(domain.MergeabilityChecking), Label: "Checking…"}
398+ v.FFOk, v.FFReason = m.Allows(domain.MergeModeFastForward)
399+ v.MergeOk, v.MergeReason = m.Allows(domain.MergeModeSquash)
400+ target = visibleText(target)
401+ switch m.State {
402+ case domain.MergeabilityUpToDate:
403+ v.State, v.Label = string(m.State), "Ready to merge"
404+ case domain.MergeabilityBehind:
405+ v.State, v.Label, v.Detail = string(m.State), "Behind "+target+" by "+behindText(m), "merges without conflicts"
406+ case domain.MergeabilityConflict:
407+ v.State, v.Label, v.Detail = string(m.State), "Conflicts with "+target, "rebase needed"
408+ v.Paths, v.PathsNote = conflictList(m)
409+ }
410+ return v
411+}
412+
413+// behindText is "2 commits", "1 commit" or "1000+ commits".
414+func behindText(m domain.Mergeability) string {
415+ if m.Behind == 1 && !m.BehindCapped {
416+ return "1 commit"
417+ }
418+ n := itoa(m.Behind)
419+ if m.BehindCapped {
420+ n += "+"
421+ }
422+ return n + " commits"
423+}
424+
425+// conflictList is the conflicting paths to show, and the note that says what
426+// the list lacks.
427+func conflictList(m domain.Mergeability) (paths []string, note string) {
428+ for _, p := range m.ConflictPaths {
429+ paths = append(paths, visibleText(p))
430+ }
431+ switch {
432+ case len(paths) == 0:
433+ note = "no common history."
434+ case m.ConflictPathsTruncated:
435+ note = "more files conflict than are listed."
436+ }
437+ return paths, note
438+}
439+
353440 // mrRow converts a merge request of a listing.
354441 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
355442 row := MRRow{
@@ -540,6 +627,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
540627 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
541628 p.MergeMode = modeName(m.MergeMode)
542629 }
630+ if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) {
631+ p.Deleting = visibleText(m.Source)
632+ }
543633 for _, mode := range d.repo.Settings.AllowedMergeModes {
544634 if name := modeName(mode); name != "" {
545635 p.Modes = append(p.Modes, name)
@@ -624,11 +714,18 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
624714 p.Invisible = invisible
625715 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
626716 if signed && open {
627- p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
717+ p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718+ p.Box.Error = mergeErrorOf(ctx)
628719 if m.IsDraft() {
629720 p.Box.ReadyAction = mrHref(d.name(), n, "ready")
630721 }
631722 }
723+ if mergeErrorOf(ctx) != "" {
724+ p.Canonical = self
725+ }
726+ if p.Box == nil {
727+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728+ }
632729 if canWrite {
633730 p.GeneralForm = &CommentForm{
634731 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
@@ -652,7 +749,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
652749 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
653750 }
654751 p.Live = true
655- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))
752+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
656753 l.HasRail = true
657754 l.MR = true
658755 return MergeRequest(l, p), http.StatusOK, nil
@@ -669,37 +766,78 @@ func (d *repoData) hasBranch(name string) bool {
669766 }
670767
671768 // mergeBox is what the owner can do with an open request.
672-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
769+func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {
673770 b := &MergeBox{
674771 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
675772 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
676773 CanMarkRead: head != "" && head != lastRead,
677774 CanDeleteBranch: source != d.repo.DefaultBranch,
775+ Mergeability: mg,
776+ }
777+ var lines []string
778+ cmd := ""
779+ if head != "" {
780+ cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
678781 }
679- ff, sq := false, false
680782 for _, mode := range d.repo.Settings.AllowedMergeModes {
681783 switch mode {
682784 case domain.MergeModeFastForward:
683- ff = true
684- b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
785+ b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)})
786+ lines = append(lines, cmd)
685787 case domain.MergeModeSquash:
686- sq = true
687- b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
788+ b.Signed = true
789+ b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
790+ lines = append(lines, cmd+" --mode squash")
791+ case domain.MergeModeMerge:
792+ b.Signed = true
793+ b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
794+ lines = append(lines, cmd+" --mode merge")
688795 }
689796 }
797+ if b.Signed && s.cfg.SigningKey != nil {
798+ b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint
799+ }
690800 if head != "" {
691- cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
692- b.CLI = Clone{ID: "cli-merge"}
693- if ff {
694- b.CLI.Lines = append(b.CLI.Lines, cmd)
695- }
696- if sq {
697- b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
698- }
801+ b.CLI = Clone{ID: "cli-merge", Lines: lines}
699802 }
700803 return b
701804 }
702805
806+func reasonIf(cond bool, reason string) string {
807+ if cond {
808+ return reason
809+ }
810+ return ""
811+}
812+
813+// firstModeOK is the index of the first mode that is possible, -1 for none.
814+func (b *MergeBox) firstModeOK() int {
815+ for i, m := range b.Modes {
816+ if !m.Disabled {
817+ return i
818+ }
819+ }
820+ return -1
821+}
822+
823+// Checked is the mode the form has checked: the first that is possible, else
824+// the first (it is disabled, and a live update may enable it).
825+func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) }
826+
827+// CanMerge tells whether some mode is possible: the button is enabled.
828+func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 }
829+
830+// MergeTitle is why the button is disabled, empty when it is not.
831+func (b *MergeBox) MergeTitle() string {
832+ if b.CanMerge() {
833+ return ""
834+ }
835+ if len(b.Modes) == 0 {
836+ return "the repository allows no merge mode"
837+ }
838+ return b.Modes[0].Reason
839+}
840+
703841 // shellWord quotes s for a shell unless it is made of safe characters, which
704842 // a repository name always is.
705843 func shellWord(s string) string {
@@ -196,10 +196,38 @@ type MergeBox struct {
196 // CanDeleteBranch offers to delete the source after the merge: it is not196 // CanDeleteBranch offers to delete the source after the merge: it is not
197 // the default branch. The server checks again, with the open requests.197 // the default branch. The server checks again, with the open requests.
198 CanDeleteBranch bool198 CanDeleteBranch bool
199+ // Mergeability is the live badge of the box and what the modes depend on.
200+ Mergeability MergeabilityView
201+ // Signed is true when a mode that creates a commit (squash, merge commit)
202+ // is offered; Fingerprint is then the one of the key that signs it, empty
203+ // when the server does not tell.
204+ Signed bool
205+ Fingerprint string
206+ // Error is why the last merge was refused, shown in the box.
207+ Error string
199 }208 }
200 209
201-// ModeOption is a merge mode the repository allows.210+// ModeOption is a merge mode the repository allows. Disabled says that the
202-type ModeOption struct{ Value, Label string }211+// mode is not possible for the current state of the request, Reason why.
212+type ModeOption struct {
213+ Value, Label string
214+ Disabled bool
215+ Reason string
216+}
217+
218+// MergeabilityView is the mergeability of an open request, ready to show: the
219+// badge, and what the merge modes depend on (the flags are what the controls
220+// of the box read, live).
221+type MergeabilityView struct {
222+ State, Label, Detail string
223+ Paths []string
224+ // PathsNote says that more paths conflict than are listed.
225+ PathsNote string
226+ // FFOk and MergeOk say whether fast-forward, squash and merge commit are
227+ // possible; the reasons say why not.
228+ FFOk, MergeOk bool
229+ FFReason, MergeReason string
230+}
203 231
204 // SinceNav switches between the whole diff and the diff since the last read.232 // SinceNav switches between the whole diff and the diff since the last read.
205 type SinceNav struct {233 type SinceNav struct {
@@ -233,6 +261,16 @@ type MRPage struct {
233 MergedShort string261 MergedShort string
234 MergedHref string262 MergedHref string
235 MergeMode string263 MergeMode string
264+ // Deleting is the source branch that a merge just asked to delete: the
265+ // deletion follows the answer.
266+ Deleting string
267+ // MergeError is why a merge was refused when the page has no merge box
268+ // to show it in (the request is not open any more).
269+ MergeError string
270+ // Canonical is the address of the request, set when the page answers a
271+ // refused action from the address of that action: the script puts it in
272+ // the address bar, so that a reload does not post again.
273+ Canonical string
236 274
237 Since *SinceNav275 Since *SinceNav
238 SinceNote string276 SinceNote string
@@ -346,10 +384,59 @@ func modeName(m domain.MergeMode) string {
346 return "fast-forward"384 return "fast-forward"
347 case domain.MergeModeSquash:385 case domain.MergeModeSquash:
348 return "squash"386 return "squash"
387+ case domain.MergeModeMerge:
388+ return "merge commit"
349 }389 }
350 return ""390 return ""
351 }391 }
352 392
393+// mergeabilityView converts the mergeability of a request into the badge and
394+// the flags of the controls of the merge box. A request without result is
395+// being checked.
396+func mergeabilityView(m domain.Mergeability, target string) MergeabilityView {
397+ v := MergeabilityView{State: string(domain.MergeabilityChecking), Label: "Checking…"}
398+ v.FFOk, v.FFReason = m.Allows(domain.MergeModeFastForward)
399+ v.MergeOk, v.MergeReason = m.Allows(domain.MergeModeSquash)
400+ target = visibleText(target)
401+ switch m.State {
402+ case domain.MergeabilityUpToDate:
403+ v.State, v.Label = string(m.State), "Ready to merge"
404+ case domain.MergeabilityBehind:
405+ v.State, v.Label, v.Detail = string(m.State), "Behind "+target+" by "+behindText(m), "merges without conflicts"
406+ case domain.MergeabilityConflict:
407+ v.State, v.Label, v.Detail = string(m.State), "Conflicts with "+target, "rebase needed"
408+ v.Paths, v.PathsNote = conflictList(m)
409+ }
410+ return v
411+}
412+
413+// behindText is "2 commits", "1 commit" or "1000+ commits".
414+func behindText(m domain.Mergeability) string {
415+ if m.Behind == 1 && !m.BehindCapped {
416+ return "1 commit"
417+ }
418+ n := itoa(m.Behind)
419+ if m.BehindCapped {
420+ n += "+"
421+ }
422+ return n + " commits"
423+}
424+
425+// conflictList is the conflicting paths to show, and the note that says what
426+// the list lacks.
427+func conflictList(m domain.Mergeability) (paths []string, note string) {
428+ for _, p := range m.ConflictPaths {
429+ paths = append(paths, visibleText(p))
430+ }
431+ switch {
432+ case len(paths) == 0:
433+ note = "no common history."
434+ case m.ConflictPathsTruncated:
435+ note = "more files conflict than are listed."
436+ }
437+ return paths, note
438+}
439+
353 // mrRow converts a merge request of a listing.440 // mrRow converts a merge request of a listing.
354 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {441 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
355 row := MRRow{442 row := MRRow{
@@ -540,6 +627,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
540 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())627 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
541 p.MergeMode = modeName(m.MergeMode)628 p.MergeMode = modeName(m.MergeMode)
542 }629 }
630+ if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) {
631+ p.Deleting = visibleText(m.Source)
632+ }
543 for _, mode := range d.repo.Settings.AllowedMergeModes {633 for _, mode := range d.repo.Settings.AllowedMergeModes {
544 if name := modeName(mode); name != "" {634 if name := modeName(mode); name != "" {
545 p.Modes = append(p.Modes, name)635 p.Modes = append(p.Modes, name)
@@ -624,11 +714,18 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
624 p.Invisible = invisible714 p.Invisible = invisible
625 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))715 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
626 if signed && open {716 if signed && open {
627- p.Box = s.mergeBox(d, n, m.Source, head, lastRead)717+ p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718+ p.Box.Error = mergeErrorOf(ctx)
628 if m.IsDraft() {719 if m.IsDraft() {
629 p.Box.ReadyAction = mrHref(d.name(), n, "ready")720 p.Box.ReadyAction = mrHref(d.name(), n, "ready")
630 }721 }
631 }722 }
723+ if mergeErrorOf(ctx) != "" {
724+ p.Canonical = self
725+ }
726+ if p.Box == nil {
727+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728+ }
632 if canWrite {729 if canWrite {
633 p.GeneralForm = &CommentForm{730 p.GeneralForm = &CommentForm{
634 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",731 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
@@ -652,7 +749,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
652 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})749 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
653 }750 }
654 p.Live = true751 p.Live = true
655- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))752+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
656 l.HasRail = true753 l.HasRail = true
657 l.MR = true754 l.MR = true
658 return MergeRequest(l, p), http.StatusOK, nil755 return MergeRequest(l, p), http.StatusOK, nil
@@ -669,37 +766,78 @@ func (d *repoData) hasBranch(name string) bool {
669 }766 }
670 767
671 // mergeBox is what the owner can do with an open request.768 // mergeBox is what the owner can do with an open request.
672-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {769+func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {
673 b := &MergeBox{770 b := &MergeBox{
674 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),771 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
675 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",772 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
676 CanMarkRead: head != "" && head != lastRead,773 CanMarkRead: head != "" && head != lastRead,
677 CanDeleteBranch: source != d.repo.DefaultBranch,774 CanDeleteBranch: source != d.repo.DefaultBranch,
775+ Mergeability: mg,
776+ }
777+ var lines []string
778+ cmd := ""
779+ if head != "" {
780+ cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
678 }781 }
679- ff, sq := false, false
680 for _, mode := range d.repo.Settings.AllowedMergeModes {782 for _, mode := range d.repo.Settings.AllowedMergeModes {
681 switch mode {783 switch mode {
682 case domain.MergeModeFastForward:784 case domain.MergeModeFastForward:
683- ff = true785+ b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)})
684- b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})786+ lines = append(lines, cmd)
685 case domain.MergeModeSquash:787 case domain.MergeModeSquash:
686- sq = true788+ b.Signed = true
687- b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})789+ b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
790+ lines = append(lines, cmd+" --mode squash")
791+ case domain.MergeModeMerge:
792+ b.Signed = true
793+ b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
794+ lines = append(lines, cmd+" --mode merge")
688 }795 }
689 }796 }
797+ if b.Signed && s.cfg.SigningKey != nil {
798+ b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint
799+ }
690 if head != "" {800 if head != "" {
691- cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head801+ b.CLI = Clone{ID: "cli-merge", Lines: lines}
692- b.CLI = Clone{ID: "cli-merge"}
693- if ff {
694- b.CLI.Lines = append(b.CLI.Lines, cmd)
695- }
696- if sq {
697- b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
698- }
699 }802 }
700 return b803 return b
701 }804 }
702 805
806+func reasonIf(cond bool, reason string) string {
807+ if cond {
808+ return reason
809+ }
810+ return ""
811+}
812+
813+// firstModeOK is the index of the first mode that is possible, -1 for none.
814+func (b *MergeBox) firstModeOK() int {
815+ for i, m := range b.Modes {
816+ if !m.Disabled {
817+ return i
818+ }
819+ }
820+ return -1
821+}
822+
823+// Checked is the mode the form has checked: the first that is possible, else
824+// the first (it is disabled, and a live update may enable it).
825+func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) }
826+
827+// CanMerge tells whether some mode is possible: the button is enabled.
828+func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 }
829+
830+// MergeTitle is why the button is disabled, empty when it is not.
831+func (b *MergeBox) MergeTitle() string {
832+ if b.CanMerge() {
833+ return ""
834+ }
835+ if len(b.Modes) == 0 {
836+ return "the repository allows no merge mode"
837+ }
838+ return b.Modes[0].Reason
839+}
840+
703 // shellWord quotes s for a shell unless it is made of safe characters, which841 // shellWord quotes s for a shell unless it is made of safe characters, which
704 // a repository name always is.842 // a repository name always is.
705 func shellWord(s string) string {843 func shellWord(s string) string {
M internal/adapters/web/mr.templ +92 -4
@@ -2,7 +2,10 @@
22
33 package web
44
5-import "strings"
5+import (
6+ "strconv"
7+ "strings"
8+)
69
710 // MRList is the list of the merge requests of a repository: the open ones,
811 // then the merged and closed ones.
@@ -145,6 +148,12 @@ templ MergeRequest(l Layout, p MRPage) {
145148 </article>
146149 }
147150 </section>
151+ if p.Deleting != "" {
152+ <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
153+ }
154+ if p.MergeError != "" {
155+ <p class="d-err" role="alert">{ p.MergeError }</p>
156+ }
148157 @LiveSlot("mr-notice")
149158 if p.Since != nil {
150159 <nav class="since" aria-label="Diff range">
@@ -178,6 +187,9 @@ templ MergeRequest(l Layout, p MRPage) {
178187 <div
179188 id="diffs"
180189 data-mr
190+ if p.Canonical != "" {
191+ data-canonical={ p.Canonical }
192+ }
181193 if p.Both {
182194 data-diff={ p.View }
183195 } else {
@@ -446,8 +458,13 @@ templ mrBar(p MRPage) {
446458 // mergeMenu is the merge of an open request, for the owner: the form (it
447459 // carries the head that was read), or "Ready for review" for a draft. The
448460 // mode and the branch deletion have keys (1-3, D) while it is open.
461+//
462+// The badge is a live slot (mr-mergeability); the modes and the button read
463+// its data-* attributes (hx-live bindings), so that they follow the slot when
464+// the stream replaces its content. The server renders the same state in the
465+// attributes, for a page without scripts.
449466 templ mergeMenu(p MRPage) {
450- <details class="menu mmenu" id="merge-menu" data-state="open">
467+ <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
451468 <summary class="btn primary" aria-keyshortcuts="Shift+M">
452469 if p.Box.ReadyAction != "" {
453470 Draft
@@ -469,19 +486,32 @@ templ mergeMenu(p MRPage) {
469486 <dt>into</dt>
470487 <dd>{ p.Target }</dd>
471488 </dl>
489+ if p.Box.Error != "" {
490+ <p class="d-err" role="alert">{ p.Box.Error }</p>
491+ }
472492 if p.Box.ReadyAction != "" {
473493 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
474494 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
475495 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
476496 </form>
477497 } else if !p.Box.HeadGone {
498+ @mrMergeabilitySlot(p.Box.Mergeability)
478499 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
479500 <input type="hidden" name="head_id" value={ p.Box.Head }/>
480501 <fieldset class="d-modes">
481502 <legend class="sr">merge mode</legend>
482503 for i, m := range p.Box.Modes {
483504 <label>
484- <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/>
505+ <input
506+ type="radio"
507+ name="mode"
508+ value={ m.Value }
509+ checked?={ i == p.Box.Checked() }
510+ disabled?={ m.Disabled }
511+ title={ m.Reason }
512+ :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
513+ :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
514+ />
485515 { m.Label }
486516 if i < 9 {
487517 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
@@ -489,11 +519,28 @@ templ mergeMenu(p MRPage) {
489519 </label>
490520 }
491521 </fieldset>
522+ if p.Box.Signed {
523+ <p class="d-note" id="mr-signing">
524+ squash and merge commits are signed with ermite's own key (generated on first start).
525+ if p.Box.Fingerprint != "" {
526+ fingerprint <code>{ p.Box.Fingerprint }</code>
527+ }
528+ <code>ermite signing-key show</code> prints it.
529+ </p>
530+ }
492531 if p.Box.CanDeleteBranch {
493532 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
494533 }
495534 <div class="d-btns">
496- <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button>
535+ <button
536+ type="submit"
537+ class="btn primary"
538+ data-default
539+ disabled?={ !p.Box.CanMerge() }
540+ title={ p.Box.MergeTitle() }
541+ :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
542+ :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
543+ >Merge into { p.Target }</button>
497544 </div>
498545 </form>
499546 }
@@ -501,6 +548,47 @@ templ mergeMenu(p MRPage) {
501548 </details>
502549 }
503550
551+// MRMergeability is the content of the mergeability slot: the badge, and the
552+// state the controls of the merge box read.
553+templ MRMergeability(v MergeabilityView) {
554+ <span
555+ data-mergeability
556+ data-state={ v.State }
557+ data-ff-ok={ strconv.FormatBool(v.FFOk) }
558+ data-merge-ok={ strconv.FormatBool(v.MergeOk) }
559+ data-ff-reason={ v.FFReason }
560+ data-merge-reason={ v.MergeReason }
561+ >
562+ <span class="badge" data-state={ v.State }>{ v.Label }</span>
563+ if v.Detail != "" {
564+ { " " }<span class="dim">{ v.Detail }</span>
565+ }
566+ </span>
567+ if len(v.Paths) > 0 {
568+ <ul class="mg-paths" aria-label="conflicting files">
569+ for _, path := range v.Paths {
570+ <li><code>{ path }</code></li>
571+ }
572+ </ul>
573+ }
574+ if v.PathsNote != "" {
575+ <p class="dim">{ v.PathsNote }</p>
576+ }
577+}
578+
579+// mrMergeabilitySlot is the status region of the badge, with its content: the
580+// stream replaces the children, never the element (see LiveSlot).
581+templ mrMergeabilitySlot(v MergeabilityView) {
582+ <div id="mr-mergeability" class="live-slot" role="status" data-slot>
583+ @MRMergeability(v)
584+ </div>
585+}
586+
587+// MRMergeabilityFragment is the slot as the stream sends it.
588+templ MRMergeabilityFragment(v MergeabilityView) {
589+ @mrMergeabilitySlot(v)
590+}
591+
504592 // moreMenu is what else the owner does with an open request: mark the head
505593 // read, close it, merge it from a terminal.
506594 templ moreMenu(p MRPage) {
@@ -2,7 +2,10 @@
2 2
3 package web3 package web
4 4
5-import "strings"5+import (
6+ "strconv"
7+ "strings"
8+)
6 9
7 // MRList is the list of the merge requests of a repository: the open ones,10 // MRList is the list of the merge requests of a repository: the open ones,
8 // then the merged and closed ones.11 // then the merged and closed ones.
@@ -145,6 +148,12 @@ templ MergeRequest(l Layout, p MRPage) {
145 </article>148 </article>
146 }149 }
147 </section>150 </section>
151+ if p.Deleting != "" {
152+ <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
153+ }
154+ if p.MergeError != "" {
155+ <p class="d-err" role="alert">{ p.MergeError }</p>
156+ }
148 @LiveSlot("mr-notice")157 @LiveSlot("mr-notice")
149 if p.Since != nil {158 if p.Since != nil {
150 <nav class="since" aria-label="Diff range">159 <nav class="since" aria-label="Diff range">
@@ -178,6 +187,9 @@ templ MergeRequest(l Layout, p MRPage) {
178 <div187 <div
179 id="diffs"188 id="diffs"
180 data-mr189 data-mr
190+ if p.Canonical != "" {
191+ data-canonical={ p.Canonical }
192+ }
181 if p.Both {193 if p.Both {
182 data-diff={ p.View }194 data-diff={ p.View }
183 } else {195 } else {
@@ -446,8 +458,13 @@ templ mrBar(p MRPage) {
446 // mergeMenu is the merge of an open request, for the owner: the form (it458 // mergeMenu is the merge of an open request, for the owner: the form (it
447 // carries the head that was read), or "Ready for review" for a draft. The459 // carries the head that was read), or "Ready for review" for a draft. The
448 // mode and the branch deletion have keys (1-3, D) while it is open.460 // mode and the branch deletion have keys (1-3, D) while it is open.
461+//
462+// The badge is a live slot (mr-mergeability); the modes and the button read
463+// its data-* attributes (hx-live bindings), so that they follow the slot when
464+// the stream replaces its content. The server renders the same state in the
465+// attributes, for a page without scripts.
449 templ mergeMenu(p MRPage) {466 templ mergeMenu(p MRPage) {
450- <details class="menu mmenu" id="merge-menu" data-state="open">467+ <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
451 <summary class="btn primary" aria-keyshortcuts="Shift+M">468 <summary class="btn primary" aria-keyshortcuts="Shift+M">
452 if p.Box.ReadyAction != "" {469 if p.Box.ReadyAction != "" {
453 Draft470 Draft
@@ -469,19 +486,32 @@ templ mergeMenu(p MRPage) {
469 <dt>into</dt>486 <dt>into</dt>
470 <dd>{ p.Target }</dd>487 <dd>{ p.Target }</dd>
471 </dl>488 </dl>
489+ if p.Box.Error != "" {
490+ <p class="d-err" role="alert">{ p.Box.Error }</p>
491+ }
472 if p.Box.ReadyAction != "" {492 if p.Box.ReadyAction != "" {
473 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>493 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
474 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>494 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
475 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>495 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
476 </form>496 </form>
477 } else if !p.Box.HeadGone {497 } else if !p.Box.HeadGone {
498+ @mrMergeabilitySlot(p.Box.Mergeability)
478 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>499 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
479 <input type="hidden" name="head_id" value={ p.Box.Head }/>500 <input type="hidden" name="head_id" value={ p.Box.Head }/>
480 <fieldset class="d-modes">501 <fieldset class="d-modes">
481 <legend class="sr">merge mode</legend>502 <legend class="sr">merge mode</legend>
482 for i, m := range p.Box.Modes {503 for i, m := range p.Box.Modes {
483 <label>504 <label>
484- <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/>505+ <input
506+ type="radio"
507+ name="mode"
508+ value={ m.Value }
509+ checked?={ i == p.Box.Checked() }
510+ disabled?={ m.Disabled }
511+ title={ m.Reason }
512+ :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
513+ :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
514+ />
485 { m.Label }515 { m.Label }
486 if i < 9 {516 if i < 9 {
487 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>517 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
@@ -489,11 +519,28 @@ templ mergeMenu(p MRPage) {
489 </label>519 </label>
490 }520 }
491 </fieldset>521 </fieldset>
522+ if p.Box.Signed {
523+ <p class="d-note" id="mr-signing">
524+ squash and merge commits are signed with ermite's own key (generated on first start).
525+ if p.Box.Fingerprint != "" {
526+ fingerprint <code>{ p.Box.Fingerprint }</code>
527+ }
528+ <code>ermite signing-key show</code> prints it.
529+ </p>
530+ }
492 if p.Box.CanDeleteBranch {531 if p.Box.CanDeleteBranch {
493 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>532 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
494 }533 }
495 <div class="d-btns">534 <div class="d-btns">
496- <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button>535+ <button
536+ type="submit"
537+ class="btn primary"
538+ data-default
539+ disabled?={ !p.Box.CanMerge() }
540+ title={ p.Box.MergeTitle() }
541+ :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
542+ :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
543+ >Merge into { p.Target }</button>
497 </div>544 </div>
498 </form>545 </form>
499 }546 }
@@ -501,6 +548,47 @@ templ mergeMenu(p MRPage) {
501 </details>548 </details>
502 }549 }
503 550
551+// MRMergeability is the content of the mergeability slot: the badge, and the
552+// state the controls of the merge box read.
553+templ MRMergeability(v MergeabilityView) {
554+ <span
555+ data-mergeability
556+ data-state={ v.State }
557+ data-ff-ok={ strconv.FormatBool(v.FFOk) }
558+ data-merge-ok={ strconv.FormatBool(v.MergeOk) }
559+ data-ff-reason={ v.FFReason }
560+ data-merge-reason={ v.MergeReason }
561+ >
562+ <span class="badge" data-state={ v.State }>{ v.Label }</span>
563+ if v.Detail != "" {
564+ { " " }<span class="dim">{ v.Detail }</span>
565+ }
566+ </span>
567+ if len(v.Paths) > 0 {
568+ <ul class="mg-paths" aria-label="conflicting files">
569+ for _, path := range v.Paths {
570+ <li><code>{ path }</code></li>
571+ }
572+ </ul>
573+ }
574+ if v.PathsNote != "" {
575+ <p class="dim">{ v.PathsNote }</p>
576+ }
577+}
578+
579+// mrMergeabilitySlot is the status region of the badge, with its content: the
580+// stream replaces the children, never the element (see LiveSlot).
581+templ mrMergeabilitySlot(v MergeabilityView) {
582+ <div id="mr-mergeability" class="live-slot" role="status" data-slot>
583+ @MRMergeability(v)
584+ </div>
585+}
586+
587+// MRMergeabilityFragment is the slot as the stream sends it.
588+templ MRMergeabilityFragment(v MergeabilityView) {
589+ @mrMergeabilitySlot(v)
590+}
591+
504 // moreMenu is what else the owner does with an open request: mark the head592 // moreMenu is what else the owner does with an open request: mark the head
505 // read, close it, merge it from a terminal.593 // read, close it, merge it from a terminal.
506 templ moreMenu(p MRPage) {594 templ moreMenu(p MRPage) {
M internal/adapters/web/mr_post.go +55 -3
@@ -60,6 +60,10 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6060 return
6161 }
6262 to, back, err := h(ctx, r)
63+ var re *rerender
64+ if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) {
65+ return
66+ }
6367 if err != nil {
6468 s.actionFailed(ctx, w, r, err, back)
6569 return
@@ -68,6 +72,43 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6872 }
6973 }
7074
75+// rerender is the error of a merge the use case refused for a reason the
76+// owner reads on the page of the request: the page is shown again, with the
77+// status, and the reason in its merge box. Any other error keeps actionFailed.
78+type rerender struct {
79+ status int
80+ msg string
81+ err error
82+}
83+
84+func (e *rerender) Error() string { return e.err.Error() }
85+func (e *rerender) Unwrap() error { return e.err }
86+
87+type mergeErrorKey struct{}
88+
89+// withMergeError carries the reason of a refused merge to the page.
90+func withMergeError(ctx context.Context, msg string) context.Context {
91+ return context.WithValue(ctx, mergeErrorKey{}, msg)
92+}
93+
94+func mergeErrorOf(ctx context.Context) string {
95+ msg, _ := ctx.Value(mergeErrorKey{}).(string)
96+ return msg
97+}
98+
99+// rerenderMR writes the page of the request with the reason of the refusal.
100+// It reports false when the page cannot be built; the caller then answers as
101+// for any refused action.
102+func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103+ c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)
104+ if err != nil {
105+ return false
106+ }
107+ w.Header().Set("Cache-Control", "no-store")
108+ s.write(ctx, w, r, re.status, c)
109+ return true
110+}
111+
71112 // actionFailed answers a refused action.
72113 func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
73114 // The words of the use case, for the rules the owner can fix.
@@ -322,8 +363,11 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
322363 mode = domain.MergeModeFastForward
323364 case "squash":
324365 mode = domain.MergeModeSquash
366+ case "merge":
367+ mode = domain.MergeModeMerge
325368 default:
326- return "", back, formError("mode must be fast-forward or squash")
369+ err := formError("mode must be fast-forward, squash or merge")
370+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
327371 }
328372 name, err := domain.ParseRepoName(repo)
329373 if err != nil {
@@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
333377 if err != nil {
334378 return "", back, err
335379 }
336- if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
380+ res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
337381 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
338- }); err != nil {
382+ })
383+ switch {
384+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
385+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
386+ case err != nil:
339387 return "", back, err
340388 }
389+ if res.SourceDeleted {
390+ // The deletion follows the answer: the page says it is being done.
391+ return back + "?deleting=1", back, nil
392+ }
341393 return back, back, nil
342394 }
343395
@@ -60,6 +60,10 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
60 return60 return
61 }61 }
62 to, back, err := h(ctx, r)62 to, back, err := h(ctx, r)
63+ var re *rerender
64+ if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) {
65+ return
66+ }
63 if err != nil {67 if err != nil {
64 s.actionFailed(ctx, w, r, err, back)68 s.actionFailed(ctx, w, r, err, back)
65 return69 return
@@ -68,6 +72,43 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
68 }72 }
69 }73 }
70 74
75+// rerender is the error of a merge the use case refused for a reason the
76+// owner reads on the page of the request: the page is shown again, with the
77+// status, and the reason in its merge box. Any other error keeps actionFailed.
78+type rerender struct {
79+ status int
80+ msg string
81+ err error
82+}
83+
84+func (e *rerender) Error() string { return e.err.Error() }
85+func (e *rerender) Unwrap() error { return e.err }
86+
87+type mergeErrorKey struct{}
88+
89+// withMergeError carries the reason of a refused merge to the page.
90+func withMergeError(ctx context.Context, msg string) context.Context {
91+ return context.WithValue(ctx, mergeErrorKey{}, msg)
92+}
93+
94+func mergeErrorOf(ctx context.Context) string {
95+ msg, _ := ctx.Value(mergeErrorKey{}).(string)
96+ return msg
97+}
98+
99+// rerenderMR writes the page of the request with the reason of the refusal.
100+// It reports false when the page cannot be built; the caller then answers as
101+// for any refused action.
102+func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103+ c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)
104+ if err != nil {
105+ return false
106+ }
107+ w.Header().Set("Cache-Control", "no-store")
108+ s.write(ctx, w, r, re.status, c)
109+ return true
110+}
111+
71 // actionFailed answers a refused action.112 // actionFailed answers a refused action.
72 func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {113 func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
73 // The words of the use case, for the rules the owner can fix.114 // The words of the use case, for the rules the owner can fix.
@@ -322,8 +363,11 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
322 mode = domain.MergeModeFastForward363 mode = domain.MergeModeFastForward
323 case "squash":364 case "squash":
324 mode = domain.MergeModeSquash365 mode = domain.MergeModeSquash
366+ case "merge":
367+ mode = domain.MergeModeMerge
325 default:368 default:
326- return "", back, formError("mode must be fast-forward or squash")369+ err := formError("mode must be fast-forward, squash or merge")
370+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
327 }371 }
328 name, err := domain.ParseRepoName(repo)372 name, err := domain.ParseRepoName(repo)
329 if err != nil {373 if err != nil {
@@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
333 if err != nil {377 if err != nil {
334 return "", back, err378 return "", back, err
335 }379 }
336- if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{380+ res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
337 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",381 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
338- }); err != nil {382+ })
383+ switch {
384+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
385+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
386+ case err != nil:
339 return "", back, err387 return "", back, err
340 }388 }
389+ if res.SourceDeleted {
390+ // The deletion follows the answer: the page says it is being done.
391+ return back + "?deleting=1", back, nil
392+ }
341 return back, back, nil393 return back, back, nil
342 }394 }
343 395
M internal/adapters/web/mr_templ.go +1185 -737 generated file: collapsed
A internal/adapters/web/mr_test.go +130 -0
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+func repoWithModes(modes ...domain.MergeMode) ports.RepoView {
19+ var v ports.RepoView
20+ v.Settings.AllowedMergeModes = modes
21+ return v
22+}
23+
24+func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} }
25+
26+func TestMergeabilityView(t *testing.T) {
27+ t.Parallel()
28+ for _, tt := range []struct {
29+ name string
30+ m domain.Mergeability
31+ label, detail string
32+ ffOK, mergeOK bool
33+ note string
34+ paths []string
35+ }{
36+ {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil},
37+ {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil},
38+ {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil},
39+ {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil},
40+ {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}},
41+ {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}},
42+ {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil},
43+ {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil},
44+ {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil},
45+ } {
46+ t.Run(tt.name, func(t *testing.T) {
47+ t.Parallel()
48+ v := mergeabilityView(tt.m, "main")
49+ assert.Equal(t, tt.label, v.Label)
50+ assert.Equal(t, tt.detail, v.Detail)
51+ assert.Equal(t, tt.ffOK, v.FFOk)
52+ assert.Equal(t, tt.mergeOK, v.MergeOk)
53+ assert.Equal(t, tt.note, v.PathsNote)
54+ assert.Equal(t, tt.paths, v.Paths)
55+ assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why")
56+ assert.Equal(t, !tt.mergeOK, v.MergeReason != "")
57+ })
58+ }
59+}
60+
61+func TestMergeBoxModes(t *testing.T) {
62+ t.Parallel()
63+ d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
64+ s := &Server{}
65+
66+ behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
67+ require.Len(t, behind.Modes, 3)
68+ assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
69+ assert.NotEmpty(t, behind.Modes[0].Reason)
70+ assert.False(t, behind.Modes[1].Disabled)
71+ assert.False(t, behind.Modes[2].Disabled)
72+ assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked")
73+ assert.True(t, behind.CanMerge())
74+ assert.True(t, behind.Signed)
75+
76+ conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
77+ assert.False(t, conflict.CanMerge())
78+ assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
79+ assert.Equal(t, 0, conflict.Checked())
80+ assert.Contains(t, conflict.CLI.Lines[2], "--mode merge")
81+ assert.NotContains(t, conflict.CLI.Lines[1], "--squash")
82+}
83+
84+func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
85+ t.Parallel()
86+ d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
88+ assert.False(t, b.Signed, "no commit is created, nothing to explain")
89+ assert.Len(t, b.Modes, 1)
90+}
91+
92+func TestMergeBoxNamesTheSigningKey(t *testing.T) {
93+ t.Parallel()
94+ key := mocks.NewSigningKeyService(t)
95+ key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
96+ d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))
98+ assert.Equal(t, "SHA256:abc", b.Fingerprint)
99+}
100+
101+func TestMergeErrorTravelsInTheContext(t *testing.T) {
102+ t.Parallel()
103+ assert.Empty(t, mergeErrorOf(context.Background()))
104+ assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom")))
105+}
106+
107+func TestMergeabilityLiveEvents(t *testing.T) {
108+ t.Parallel()
109+ assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1})))
110+ svc := mocks.NewMergeRequestService(t)
111+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
112+ Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil)
113+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"}
114+ ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
115+
116+ fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1}))
117+ require.NoError(t, err)
118+ require.Len(t, fs, 1)
119+ assert.Equal(t, "mr-mergeability", fs[0].event)
120+ assert.Contains(t, fs[0].html, "Behind main by 3 commits")
121+
122+ // Another request, or a page that is not one: nothing.
123+ fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2}))
124+ require.NoError(t, err)
125+ assert.Empty(t, fs)
126+ // The box is the owner's.
127+ fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1}))
128+ require.NoError(t, err)
129+ assert.Empty(t, fs)
130+}
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+func repoWithModes(modes ...domain.MergeMode) ports.RepoView {
19+ var v ports.RepoView
20+ v.Settings.AllowedMergeModes = modes
21+ return v
22+}
23+
24+func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} }
25+
26+func TestMergeabilityView(t *testing.T) {
27+ t.Parallel()
28+ for _, tt := range []struct {
29+ name string
30+ m domain.Mergeability
31+ label, detail string
32+ ffOK, mergeOK bool
33+ note string
34+ paths []string
35+ }{
36+ {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil},
37+ {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil},
38+ {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil},
39+ {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil},
40+ {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}},
41+ {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}},
42+ {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil},
43+ {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil},
44+ {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil},
45+ } {
46+ t.Run(tt.name, func(t *testing.T) {
47+ t.Parallel()
48+ v := mergeabilityView(tt.m, "main")
49+ assert.Equal(t, tt.label, v.Label)
50+ assert.Equal(t, tt.detail, v.Detail)
51+ assert.Equal(t, tt.ffOK, v.FFOk)
52+ assert.Equal(t, tt.mergeOK, v.MergeOk)
53+ assert.Equal(t, tt.note, v.PathsNote)
54+ assert.Equal(t, tt.paths, v.Paths)
55+ assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why")
56+ assert.Equal(t, !tt.mergeOK, v.MergeReason != "")
57+ })
58+ }
59+}
60+
61+func TestMergeBoxModes(t *testing.T) {
62+ t.Parallel()
63+ d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
64+ s := &Server{}
65+
66+ behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
67+ require.Len(t, behind.Modes, 3)
68+ assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
69+ assert.NotEmpty(t, behind.Modes[0].Reason)
70+ assert.False(t, behind.Modes[1].Disabled)
71+ assert.False(t, behind.Modes[2].Disabled)
72+ assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked")
73+ assert.True(t, behind.CanMerge())
74+ assert.True(t, behind.Signed)
75+
76+ conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
77+ assert.False(t, conflict.CanMerge())
78+ assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
79+ assert.Equal(t, 0, conflict.Checked())
80+ assert.Contains(t, conflict.CLI.Lines[2], "--mode merge")
81+ assert.NotContains(t, conflict.CLI.Lines[1], "--squash")
82+}
83+
84+func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
85+ t.Parallel()
86+ d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
88+ assert.False(t, b.Signed, "no commit is created, nothing to explain")
89+ assert.Len(t, b.Modes, 1)
90+}
91+
92+func TestMergeBoxNamesTheSigningKey(t *testing.T) {
93+ t.Parallel()
94+ key := mocks.NewSigningKeyService(t)
95+ key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
96+ d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))
98+ assert.Equal(t, "SHA256:abc", b.Fingerprint)
99+}
100+
101+func TestMergeErrorTravelsInTheContext(t *testing.T) {
102+ t.Parallel()
103+ assert.Empty(t, mergeErrorOf(context.Background()))
104+ assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom")))
105+}
106+
107+func TestMergeabilityLiveEvents(t *testing.T) {
108+ t.Parallel()
109+ assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1})))
110+ svc := mocks.NewMergeRequestService(t)
111+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
112+ Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil)
113+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"}
114+ ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
115+
116+ fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1}))
117+ require.NoError(t, err)
118+ require.Len(t, fs, 1)
119+ assert.Equal(t, "mr-mergeability", fs[0].event)
120+ assert.Contains(t, fs[0].html, "Behind main by 3 commits")
121+
122+ // Another request, or a page that is not one: nothing.
123+ fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2}))
124+ require.NoError(t, err)
125+ assert.Empty(t, fs)
126+ // The box is the owner's.
127+ fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1}))
128+ require.NoError(t, err)
129+ assert.Empty(t, fs)
130+}
M internal/adapters/web/static/css/60-mr.css +21 -0
@@ -65,6 +65,12 @@
6565 & .menu-pop .btn kbd { margin-left: auto; }
6666 }
6767 .mmenu {
68+ & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; }
69+ & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; }
70+ & .d-acts { display: grid; gap: var(--s3); }
71+ & .d-modes label, & .d-check { min-width: 0; }
72+ & code { overflow-wrap: anywhere; }
73+ & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; }
6874 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
6975 & .d-modes label, & .d-check {
7076 display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));
@@ -78,7 +84,22 @@
7884 & .d-btns { display: flex; margin-top: var(--s1); }
7985 & .d-btns .btn { flex: 1; justify-content: center; }
8086 & .d-draft { color: var(--ink-dim); }
87+ & .d-note { margin: 0; color: var(--ink-dim); }
88+ & .d-err { margin: 0; padding: var(--s2) var(--s3); color: var(--ink-hi); border: 1px solid var(--red); border-left-width: 3px; border-radius: var(--r-s); overflow-wrap: anywhere; }
89+ /* the badge is a live slot, but not a banner */
90+ & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); }
91+ & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; }
92+ & .mg-paths code { overflow-wrap: anywhere; }
93+ & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; }
94+ & .d-modes label:has(:disabled):hover { background: none; }
95+ & .btn:disabled { opacity: .5; cursor: not-allowed; }
8196 }
97+ .badge[data-state] { display: inline-block; padding: 0 var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); font-weight: 600; color: var(--ink-hi); }
98+ .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); }
99+ .badge[data-state="behind"] { border-color: var(--teal-2); }
100+ .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); }
101+ .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; }
102+ .note[role="status"] { color: var(--ink-dim); }
82103 .omenu {
83104 & > summary { font-size: 18px; line-height: 1; }
84105 & .menu-pop { width: 280px; }
@@ -65,6 +65,12 @@
65 & .menu-pop .btn kbd { margin-left: auto; }65 & .menu-pop .btn kbd { margin-left: auto; }
66 }66 }
67 .mmenu {67 .mmenu {
68+ & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; }
69+ & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; }
70+ & .d-acts { display: grid; gap: var(--s3); }
71+ & .d-modes label, & .d-check { min-width: 0; }
72+ & code { overflow-wrap: anywhere; }
73+ & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; }
68 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }74 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
69 & .d-modes label, & .d-check {75 & .d-modes label, & .d-check {
70 display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));76 display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));
@@ -78,7 +84,22 @@
78 & .d-btns { display: flex; margin-top: var(--s1); }84 & .d-btns { display: flex; margin-top: var(--s1); }
79 & .d-btns .btn { flex: 1; justify-content: center; }85 & .d-btns .btn { flex: 1; justify-content: center; }
80 & .d-draft { color: var(--ink-dim); }86 & .d-draft { color: var(--ink-dim); }
87+ & .d-note { margin: 0; color: var(--ink-dim); }
88+ & .d-err { margin: 0; padding: var(--s2) var(--s3); color: var(--ink-hi); border: 1px solid var(--red); border-left-width: 3px; border-radius: var(--r-s); overflow-wrap: anywhere; }
89+ /* the badge is a live slot, but not a banner */
90+ & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); }
91+ & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; }
92+ & .mg-paths code { overflow-wrap: anywhere; }
93+ & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; }
94+ & .d-modes label:has(:disabled):hover { background: none; }
95+ & .btn:disabled { opacity: .5; cursor: not-allowed; }
81 }96 }
97+ .badge[data-state] { display: inline-block; padding: 0 var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); font-weight: 600; color: var(--ink-hi); }
98+ .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); }
99+ .badge[data-state="behind"] { border-color: var(--teal-2); }
100+ .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); }
101+ .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; }
102+ .note[role="status"] { color: var(--ink-dim); }
82 .omenu {103 .omenu {
83 & > summary { font-size: 18px; line-height: 1; }104 & > summary { font-size: 18px; line-height: 1; }
84 & .menu-pop { width: 280px; }105 & .menu-pop { width: 280px; }
M internal/adapters/web/static/js/mr.js +10 -1
@@ -213,7 +213,12 @@ window.ermiteMR = (() => {
213213 const box = $('input[name="delete_branch"]', m);
214214 if (box) box.checked = !box.checked;
215215 } else {
216- radios[act.mode].checked = true;
216+ const r = radios[act.mode];
217+ // A change event lets the live bindings enable the Merge button again.
218+ if (!r.disabled && !r.checked) {
219+ r.checked = true;
220+ r.dispatchEvent(new Event('change', { bubbles: true }));
221+ }
217222 }
218223 $('[data-default]', m)?.focus();
219224 return true;
@@ -285,6 +290,10 @@ window.ermiteMR = (() => {
285290 let folded = false;
286291 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
287292 if (folded) foldFiles(true);
293+ // The page answers a refused action from the address of the action: show
294+ // the address of the request, so that a reload does not post again.
295+ const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
296+ if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
288297 watch();
289298 // A link to a file or a comment that is not loaded yet.
290299 const hash = () => {
@@ -213,7 +213,12 @@ window.ermiteMR = (() => {
213 const box = $('input[name="delete_branch"]', m);213 const box = $('input[name="delete_branch"]', m);
214 if (box) box.checked = !box.checked;214 if (box) box.checked = !box.checked;
215 } else {215 } else {
216- radios[act.mode].checked = true;216+ const r = radios[act.mode];
217+ // A change event lets the live bindings enable the Merge button again.
218+ if (!r.disabled && !r.checked) {
219+ r.checked = true;
220+ r.dispatchEvent(new Event('change', { bubbles: true }));
221+ }
217 }222 }
218 $('[data-default]', m)?.focus();223 $('[data-default]', m)?.focus();
219 return true;224 return true;
@@ -285,6 +290,10 @@ window.ermiteMR = (() => {
285 let folded = false;290 let folded = false;
286 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }291 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
287 if (folded) foldFiles(true);292 if (folded) foldFiles(true);
293+ // The page answers a refused action from the address of the action: show
294+ // the address of the request, so that a reload does not post again.
295+ const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
296+ if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
288 watch();297 watch();
289 // A link to a file or a comment that is not loaded yet.298 // A link to a file or a comment that is not loaded yet.
290 const hash = () => {299 const hash = () => {
M internal/adapters/web/web.go +4 -0
@@ -76,6 +76,10 @@ type Config struct {
7676 // leads to it. Without it there is neither. Leave it unset rather than
7777 // assigning a typed nil pointer.
7878 Inbox ports.InboxService
79+ // SigningKey tells the key that signs the squash and merge commits, shown
80+ // in the merge box. Without it the box does not name the key. Leave it
81+ // unset rather than assigning a typed nil pointer.
82+ SigningKey ports.SigningKeyService
7983 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
8084 // commits and merge requests. Without it there are no such pages. Leave it
8185 // unset rather than assigning a typed nil pointer.
@@ -76,6 +76,10 @@ type Config struct {
76 // leads to it. Without it there is neither. Leave it unset rather than76 // leads to it. Without it there is neither. Leave it unset rather than
77 // assigning a typed nil pointer.77 // assigning a typed nil pointer.
78 Inbox ports.InboxService78 Inbox ports.InboxService
79+ // SigningKey tells the key that signs the squash and merge commits, shown
80+ // in the merge box. Without it the box does not name the key. Leave it
81+ // unset rather than assigning a typed nil pointer.
82+ SigningKey ports.SigningKeyService
79 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to83 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
80 // commits and merge requests. Without it there are no such pages. Leave it84 // commits and merge requests. Without it there are no such pages. Leave it
81 // unset rather than assigning a typed nil pointer.85 // unset rather than assigning a typed nil pointer.
M internal/app/api_helpers_test.go +14 -1
@@ -167,8 +167,21 @@ func (h *harness) watch(c *clients, resume string, topics ...string) *stream {
167167 return s
168168 }
169169
170-// next returns the next event or fails after d.
170+// next returns the next event or fails after d. Mergeability events, which
171+// arrive asynchronously after a push or an opening, are skipped: use
172+// nextAny to see them.
171173 func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {
174+ t.Helper()
175+ for {
176+ ev := s.nextAny(t, d)
177+ if ev.GetMergeRequestMergeability() == nil {
178+ return ev
179+ }
180+ }
181+}
182+
183+// nextAny returns the next event, whatever its kind, or fails after d.
184+func (s *stream) nextAny(t *testing.T, d time.Duration) *pb.Event {
172185 t.Helper()
173186 select {
174187 case ev, ok := <-s.events:
@@ -167,8 +167,21 @@ func (h *harness) watch(c *clients, resume string, topics ...string) *stream {
167 return s167 return s
168 }168 }
169 169
170-// next returns the next event or fails after d.170+// next returns the next event or fails after d. Mergeability events, which
171+// arrive asynchronously after a push or an opening, are skipped: use
172+// nextAny to see them.
171 func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {173 func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {
174+ t.Helper()
175+ for {
176+ ev := s.nextAny(t, d)
177+ if ev.GetMergeRequestMergeability() == nil {
178+ return ev
179+ }
180+ }
181+}
182+
183+// nextAny returns the next event, whatever its kind, or fails after d.
184+func (s *stream) nextAny(t *testing.T, d time.Duration) *pb.Event {
172 t.Helper()185 t.Helper()
173 select {186 select {
174 case ev, ok := <-s.events:187 case ev, ok := <-s.events:
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/ci_schedule_trust_test.go +6 -2
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "errors"
67 "strings"
78 "testing"
89 "time"
@@ -230,8 +231,11 @@ func TestMergeRecordsTheTargetAndDropsTheSourceRecord(t *testing.T) {
230231 }))
231232 require.NoError(t, err)
232233
233- _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
234- assert.ErrorIs(t, err, domain.ErrNotFound, "the deleted source branch has no record")
234+ // The deletion and the removal of the record run after the merge returned.
235+ c.waitFor("the deleted source branch has no record", func() bool {
236+ _, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
237+ return errors.Is(err, domain.ErrNotFound)
238+ })
235239 main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
236240 require.NoError(t, err)
237241 assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip")
@@ -3,6 +3,7 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "errors"
6 "strings"7 "strings"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -230,8 +231,11 @@ func TestMergeRecordsTheTargetAndDropsTheSourceRecord(t *testing.T) {
230 }))231 }))
231 require.NoError(t, err)232 require.NoError(t, err)
232 233
233- _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")234+ // The deletion and the removal of the record run after the merge returned.
234- assert.ErrorIs(t, err, domain.ErrNotFound, "the deleted source branch has no record")235+ c.waitFor("the deleted source branch has no record", func() bool {
236+ _, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
237+ return errors.Is(err, domain.ErrNotFound)
238+ })
235 main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")239 main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
236 require.NoError(t, err)240 require.NoError(t, err)
237 assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip")241 assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip")
M internal/app/cli_e2e_test.go +14 -2
@@ -123,8 +123,20 @@ func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *wa
123123 return w
124124 }
125125
126-// next decodes the next event line or fails after d.
126+// next returns the next event line or fails after d. Mergeability events,
127+// which arrive asynchronously after a push or an opening, are skipped.
127128 func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
129+ t.Helper()
130+ for {
131+ l := w.nextAny(t, d)
132+ if !strings.Contains(l, `"type":"`+eventMRMergeability+`"`) {
133+ return l
134+ }
135+ }
136+}
137+
138+// nextAny returns the next event line, whatever its kind, or fails after d.
139+func (w *watchProc) nextAny(t *testing.T, d time.Duration) (raw string) {
128140 t.Helper()
129141 select {
130142 case l, ok := <-w.lines:
@@ -338,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338350 ver := decode[versionOut](t, res.stdout)
339351 assert.Equal(t, c.url, ver.Server.URL)
340352 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343355 stdout, stderr, code = c.bin(t, secret, "version")
344356 require.Equal(t, 0, code, stderr)
@@ -123,8 +123,20 @@ func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *wa
123 return w123 return w
124 }124 }
125 125
126-// next decodes the next event line or fails after d.126+// next returns the next event line or fails after d. Mergeability events,
127+// which arrive asynchronously after a push or an opening, are skipped.
127 func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {128 func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
129+ t.Helper()
130+ for {
131+ l := w.nextAny(t, d)
132+ if !strings.Contains(l, `"type":"`+eventMRMergeability+`"`) {
133+ return l
134+ }
135+ }
136+}
137+
138+// nextAny returns the next event line, whatever its kind, or fails after d.
139+func (w *watchProc) nextAny(t *testing.T, d time.Duration) (raw string) {
128 t.Helper()140 t.Helper()
129 select {141 select {
130 case l, ok := <-w.lines:142 case l, ok := <-w.lines:
@@ -338,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
339 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
340 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
344 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
M internal/app/cli_mr.go +150 -24
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "net/url"
11+ "slices"
1112 "strconv"
1213 "strings"
1314
@@ -31,6 +32,7 @@ const (
3132
3233 modeFastForward = "fast-forward"
3334 modeSquash = "squash"
35+ modeMerge = "merge"
3436 )
3537
3638 func stateName(s ermite.MergeRequestState) string {
@@ -65,6 +67,8 @@ func modeName(m ermite.MergeMode) string {
6567 return modeFastForward
6668 case ermite.MergeMode_MERGE_MODE_SQUASH:
6769 return modeSquash
70+ case ermite.MergeMode_MERGE_MODE_MERGE_COMMIT:
71+ return modeMerge
6872 }
6973 return ""
7074 }
@@ -75,8 +79,10 @@ func parseMode(s string) (ermite.MergeMode, error) {
7579 return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil
7680 case modeSquash:
7781 return ermite.MergeMode_MERGE_MODE_SQUASH, nil
82+ case modeMerge:
83+ return ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, nil
7884 }
79- return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash)
85+ return 0, fmt.Errorf("invalid merge mode %q: use %s, %s or %s", s, modeFastForward, modeSquash, modeMerge)
8086 }
8187
8288 func sideName(s ermite.DiffSide) string {
@@ -117,6 +123,71 @@ type jsonMergeRequest struct {
117123 MergeMode string `json:"merge_mode"`
118124 }
119125
126+// jsonMergeability is the stable JSON shape of the mergeability of an open
127+// merge request.
128+type jsonMergeability struct {
129+ // State is "checking", "up_to_date", "behind" or "conflict".
130+ State string `json:"state"`
131+ // Behind counts the commits of the target the head lacks (behind only);
132+ // BehindCapped says it is a lower bound.
133+ Behind int32 `json:"behind"`
134+ BehindCapped bool `json:"behind_capped"`
135+ ConflictPaths []string `json:"conflict_paths"`
136+ // ConflictPathsTruncated says that more paths conflict than listed.
137+ ConflictPathsTruncated bool `json:"conflict_paths_truncated"`
138+ TargetID string `json:"target_id"`
139+ HeadID string `json:"head_id"`
140+}
141+
142+func mergeabilityName(s ermite.MergeabilityState) string {
143+ switch s {
144+ case ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING:
145+ return "checking"
146+ case ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE:
147+ return "up_to_date"
148+ case ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND:
149+ return "behind"
150+ case ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT:
151+ return "conflict"
152+ }
153+ return ""
154+}
155+
156+// toJSONMergeability maps a mergeability; nil when the server sent none.
157+func toJSONMergeability(m *ermite.Mergeability) *jsonMergeability {
158+ if m == nil {
159+ return nil
160+ }
161+ return &jsonMergeability{
162+ State: mergeabilityName(m.GetState()), Behind: m.GetBehind(), BehindCapped: m.GetBehindCapped(),
163+ ConflictPaths: append([]string{}, m.GetConflictPaths()...), ConflictPathsTruncated: m.GetConflictPathsTruncated(),
164+ TargetID: m.GetTargetId(), HeadID: m.GetHeadId(),
165+ }
166+}
167+
168+// mergeabilityText is the human form of a mergeability: "up to date",
169+// "behind by 2", "conflict (a, b, ...)", "checking".
170+func mergeabilityText(m *jsonMergeability) string {
171+ switch m.State {
172+ case "up_to_date":
173+ return "up to date with the target, can be fast-forwarded"
174+ case "behind":
175+ return fmt.Sprintf("behind by %d%s, merges without conflicts (squash or merge commit; fast-forward needs a rebase)", m.Behind, plus(m.BehindCapped))
176+ case "conflict":
177+ if len(m.ConflictPaths) == 0 {
178+ return "conflict (no common history with the target), rebase needed"
179+ }
180+ more := ""
181+ if m.ConflictPathsTruncated {
182+ more = ", ..."
183+ }
184+ return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + "), rebase needed"
185+ case "checking":
186+ return "checking"
187+ }
188+ return m.State
189+}
190+
120191 // jsonMerged is the output of `mr merge --json`.
121192 type jsonMerged struct {
122193 jsonMergeRequest
@@ -173,9 +244,15 @@ const jsonMRHelp = `
173244 JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
174245 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
175246 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
176-branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty
247+branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty
177248 unless merged).`
178249
250+const jsonMergeabilityHelp = `
251+mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date",
252+"behind" or "conflict"), behind and behind_capped (commits of the target the head lacks; behind
253+only), conflict_paths and conflict_paths_truncated (conflict only), target_id and head_id (the
254+commits the result is for).`
255+
179256 const jsonCommentHelp = `
180257 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
181258 and empty side for a general comment), commit_id (the head when it was written), text,
@@ -412,6 +489,8 @@ type jsonCommit struct {
412489 // jsonMRDetail is the stable JSON shape of `mr show --json`.
413490 type jsonMRDetail struct {
414491 jsonMergeRequest
492+ // Mergeability is null unless the request is open and the server computes it.
493+ Mergeability *jsonMergeability `json:"mergeability"`
415494 Commits []jsonCommit `json:"commits"`
416495 CommitsTruncated bool `json:"commits_truncated"`
417496 DiffBaseID string `json:"diff_base_id"`
@@ -444,6 +523,7 @@ func lineKindName(k ermite.LineKind) string {
444523 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
445524 out := jsonMRDetail{
446525 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
526+ Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
447527 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
448528 DiffBaseID: d.GetDiffBaseId(),
449529 Stats: jsonStats{
@@ -496,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
496576 "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" +
497577 "the whole change; after a force-push it still works when the recorded commit exists, else the\n" +
498578 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +
499- "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" +
579+ "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" +
580+ "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" +
581+ "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
500582 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
501583 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
502584 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
@@ -569,6 +651,9 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
569651 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
570652 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
571653 }
654+ if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
655+ pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
656+ }
572657 if len(d.GetVerdicts()) > 0 {
573658 var verdicts []string
574659 for _, v := range d.GetVerdicts() {
@@ -957,36 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
9571042 }
9581043
9591044 func newMRMergeCmd(env *cliEnv) *cobra.Command {
960- var squash, deleteBranch bool
1045+ var mode string
1046+ var deleteBranch bool
9611047 var head string
9621048 cmd := &cobra.Command{
9631049 Use: "merge <repo> <number>",
9641050 Short: "Merge a merge request",
965- Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" +
966- "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" +
967- "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" +
968- "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" +
969- "only one of the modes.\n\n" +
1051+ Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" +
1052+ "only some modes, see `ermite repo edit --merge-modes`):\n" +
1053+ " fast-forward the target branch moves to the head and no commit is created. The target must be\n" +
1054+ " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" +
1055+ " squash one new commit with the tree of the merge of the head and the target, the target tip\n" +
1056+ " as only parent. The branch must merge without conflicts.\n" +
1057+ " merge a merge commit with two parents, the target tip and the head. The branch must merge\n" +
1058+ " without conflicts.\n" +
1059+ "A squash and a merge commit are signed with the server's own key, generated automatically when the\n" +
1060+ "server first starts; `ermite signing-key show` prints it. The server never rebases. `ermite mr show`\n" +
1061+ "prints the mergeability (up to date, behind, conflict); the server checks again when merging and\n" +
1062+ "refuses a merge that is not possible, saying why.\n\n" +
9701063 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
9711064 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
9721065 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
9731066 "mr:merge scope.\n\n" +
974- "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" +
975- "is the default branch or used by another open merge request. If the deletion fails after the\n" +
976- "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp +
977- "\n--json adds source_branch_deleted (true when the branch was deleted).",
1067+ "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
1068+ "the source branch right after, in the background. The merge is refused when the branch is the\n" +
1069+ "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
1070+ "moved, say), the merge stands and the branch is kept." + jsonMRHelp +
1071+ "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
9781072 Args: cobra.ExactArgs(2),
9791073 ValidArgsFunction: completeMRRepoThenNumber(env),
9801074 }
981- cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")
1075+ cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
9821076 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
9831077 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1078+ _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
1079+ return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
1080+ })
9841081 asJSON := addJSONFlag(cmd)
9851082 cmd.RunE = func(cmd *cobra.Command, args []string) error {
9861083 n, err := parseNumber(args[1])
9871084 if err != nil {
9881085 return err
9891086 }
1087+ pbMode, err := parseMode(mode)
1088+ if err != nil {
1089+ return err
1090+ }
9901091 head = strings.TrimSpace(head)
9911092 if head == "" {
9921093 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
@@ -997,35 +1098,60 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
9971098 return err
9981099 }
9991100 ctx := env.ctx(cmd)
1000- mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD
1001- if squash {
1002- mode = ermite.MergeMode_MERGE_MODE_SQUASH
1101+ if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
1102+ if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
1103+ return err
1104+ }
10031105 }
10041106 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1005- Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch,
1107+ Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch,
10061108 }))
10071109 if err != nil {
10081110 return c.Describe(err)
10091111 }
10101112 m := res.Msg.GetMergeRequest()
10111113 deleted := res.Msg.GetSourceBranchDeleted()
1012- if deleteBranch && !deleted {
1013- _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n",
1014- clean(m.GetSourceBranch()))
1015- }
10161114 if *asJSON {
10171115 return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
10181116 }
1019- _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
1117+ out := cmd.OutOrStdout()
1118+ _, err = fmt.Fprintf(out, "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
10201119 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
1120+ if err == nil && pbMode != ermite.MergeMode_MERGE_MODE_FAST_FORWARD {
1121+ _, err = fmt.Fprintln(out, signedNote(ctx, c))
1122+ }
10211123 if err == nil && deleted {
1022- _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch()))
1124+ _, err = fmt.Fprintf(out, "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch()))
10231125 }
10241126 return err
10251127 }
10261128 return cmd
10271129 }
10281130
1131+// signedNote says that a squash or merge commit is signed by the server's
1132+// key, with its fingerprint when the server tells it.
1133+func signedNote(ctx context.Context, c *client.Client) string {
1134+ const how = "; `ermite signing-key show` prints the key to verify it"
1135+ res, err := c.Server().GetSigningKey(ctx, connect.NewRequest(&ermite.GetSigningKeyRequest{}))
1136+ if err != nil || res.Msg.GetFingerprint() == "" {
1137+ return "the commit is signed by the server's key" + how
1138+ }
1139+ return "the commit is signed by the server's key " + clean(res.Msg.GetFingerprint()) + how
1140+}
1141+
1142+// requireFeature refuses to go on with a server that does not list the
1143+// feature. A server whose features cannot be read is refused too.
1144+func requireFeature(ctx context.Context, c *client.Client, feature, what string) error {
1145+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
1146+ if err != nil {
1147+ return c.Describe(err)
1148+ }
1149+ if !slices.Contains(res.Msg.GetFeatures(), feature) {
1150+ return fmt.Errorf("the server does not support %s, so it %s (upgrade the server, see `ermite version`)", feature, what)
1151+ }
1152+ return nil
1153+}
1154+
10291155 func newMRCloseCmd(env *cliEnv) *cobra.Command {
10301156 cmd := &cobra.Command{
10311157 Use: "close <repo> <number>",
@@ -8,6 +8,7 @@ import (
8 "fmt"8 "fmt"
9 "io"9 "io"
10 "net/url"10 "net/url"
11+ "slices"
11 "strconv"12 "strconv"
12 "strings"13 "strings"
13 14
@@ -31,6 +32,7 @@ const (
31 32
32 modeFastForward = "fast-forward"33 modeFastForward = "fast-forward"
33 modeSquash = "squash"34 modeSquash = "squash"
35+ modeMerge = "merge"
34 )36 )
35 37
36 func stateName(s ermite.MergeRequestState) string {38 func stateName(s ermite.MergeRequestState) string {
@@ -65,6 +67,8 @@ func modeName(m ermite.MergeMode) string {
65 return modeFastForward67 return modeFastForward
66 case ermite.MergeMode_MERGE_MODE_SQUASH:68 case ermite.MergeMode_MERGE_MODE_SQUASH:
67 return modeSquash69 return modeSquash
70+ case ermite.MergeMode_MERGE_MODE_MERGE_COMMIT:
71+ return modeMerge
68 }72 }
69 return ""73 return ""
70 }74 }
@@ -75,8 +79,10 @@ func parseMode(s string) (ermite.MergeMode, error) {
75 return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil79 return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil
76 case modeSquash:80 case modeSquash:
77 return ermite.MergeMode_MERGE_MODE_SQUASH, nil81 return ermite.MergeMode_MERGE_MODE_SQUASH, nil
82+ case modeMerge:
83+ return ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, nil
78 }84 }
79- return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash)85+ return 0, fmt.Errorf("invalid merge mode %q: use %s, %s or %s", s, modeFastForward, modeSquash, modeMerge)
80 }86 }
81 87
82 func sideName(s ermite.DiffSide) string {88 func sideName(s ermite.DiffSide) string {
@@ -117,6 +123,71 @@ type jsonMergeRequest struct {
117 MergeMode string `json:"merge_mode"`123 MergeMode string `json:"merge_mode"`
118 }124 }
119 125
126+// jsonMergeability is the stable JSON shape of the mergeability of an open
127+// merge request.
128+type jsonMergeability struct {
129+ // State is "checking", "up_to_date", "behind" or "conflict".
130+ State string `json:"state"`
131+ // Behind counts the commits of the target the head lacks (behind only);
132+ // BehindCapped says it is a lower bound.
133+ Behind int32 `json:"behind"`
134+ BehindCapped bool `json:"behind_capped"`
135+ ConflictPaths []string `json:"conflict_paths"`
136+ // ConflictPathsTruncated says that more paths conflict than listed.
137+ ConflictPathsTruncated bool `json:"conflict_paths_truncated"`
138+ TargetID string `json:"target_id"`
139+ HeadID string `json:"head_id"`
140+}
141+
142+func mergeabilityName(s ermite.MergeabilityState) string {
143+ switch s {
144+ case ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING:
145+ return "checking"
146+ case ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE:
147+ return "up_to_date"
148+ case ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND:
149+ return "behind"
150+ case ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT:
151+ return "conflict"
152+ }
153+ return ""
154+}
155+
156+// toJSONMergeability maps a mergeability; nil when the server sent none.
157+func toJSONMergeability(m *ermite.Mergeability) *jsonMergeability {
158+ if m == nil {
159+ return nil
160+ }
161+ return &jsonMergeability{
162+ State: mergeabilityName(m.GetState()), Behind: m.GetBehind(), BehindCapped: m.GetBehindCapped(),
163+ ConflictPaths: append([]string{}, m.GetConflictPaths()...), ConflictPathsTruncated: m.GetConflictPathsTruncated(),
164+ TargetID: m.GetTargetId(), HeadID: m.GetHeadId(),
165+ }
166+}
167+
168+// mergeabilityText is the human form of a mergeability: "up to date",
169+// "behind by 2", "conflict (a, b, ...)", "checking".
170+func mergeabilityText(m *jsonMergeability) string {
171+ switch m.State {
172+ case "up_to_date":
173+ return "up to date with the target, can be fast-forwarded"
174+ case "behind":
175+ return fmt.Sprintf("behind by %d%s, merges without conflicts (squash or merge commit; fast-forward needs a rebase)", m.Behind, plus(m.BehindCapped))
176+ case "conflict":
177+ if len(m.ConflictPaths) == 0 {
178+ return "conflict (no common history with the target), rebase needed"
179+ }
180+ more := ""
181+ if m.ConflictPathsTruncated {
182+ more = ", ..."
183+ }
184+ return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + "), rebase needed"
185+ case "checking":
186+ return "checking"
187+ }
188+ return m.State
189+}
190+
120 // jsonMerged is the output of `mr merge --json`.191 // jsonMerged is the output of `mr merge --json`.
121 type jsonMerged struct {192 type jsonMerged struct {
122 jsonMergeRequest193 jsonMergeRequest
@@ -173,9 +244,15 @@ const jsonMRHelp = `
173 JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,244 JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
174 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and245 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
175 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the246 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
176-branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty247+branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty
177 unless merged).`248 unless merged).`
178 249
250+const jsonMergeabilityHelp = `
251+mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date",
252+"behind" or "conflict"), behind and behind_capped (commits of the target the head lacks; behind
253+only), conflict_paths and conflict_paths_truncated (conflict only), target_id and head_id (the
254+commits the result is for).`
255+
179 const jsonCommentHelp = `256 const jsonCommentHelp = `
180 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0257 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
181 and empty side for a general comment), commit_id (the head when it was written), text,258 and empty side for a general comment), commit_id (the head when it was written), text,
@@ -412,6 +489,8 @@ type jsonCommit struct {
412 // jsonMRDetail is the stable JSON shape of `mr show --json`.489 // jsonMRDetail is the stable JSON shape of `mr show --json`.
413 type jsonMRDetail struct {490 type jsonMRDetail struct {
414 jsonMergeRequest491 jsonMergeRequest
492+ // Mergeability is null unless the request is open and the server computes it.
493+ Mergeability *jsonMergeability `json:"mergeability"`
415 Commits []jsonCommit `json:"commits"`494 Commits []jsonCommit `json:"commits"`
416 CommitsTruncated bool `json:"commits_truncated"`495 CommitsTruncated bool `json:"commits_truncated"`
417 DiffBaseID string `json:"diff_base_id"`496 DiffBaseID string `json:"diff_base_id"`
@@ -444,6 +523,7 @@ func lineKindName(k ermite.LineKind) string {
444 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {523 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
445 out := jsonMRDetail{524 out := jsonMRDetail{
446 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),525 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
526+ Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
447 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),527 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
448 DiffBaseID: d.GetDiffBaseId(),528 DiffBaseID: d.GetDiffBaseId(),
449 Stats: jsonStats{529 Stats: jsonStats{
@@ -496,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
496 "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" +576 "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" +
497 "the whole change; after a force-push it still works when the recorded commit exists, else the\n" +577 "the whole change; after a force-push it still works when the recorded commit exists, else the\n" +
498 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +578 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +
499- "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" +579+ "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" +
580+ "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" +
581+ "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
500 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +582 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
501 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +583 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
502 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +584 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
@@ -569,6 +651,9 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
569 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {651 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
570 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})652 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
571 }653 }
654+ if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
655+ pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
656+ }
572 if len(d.GetVerdicts()) > 0 {657 if len(d.GetVerdicts()) > 0 {
573 var verdicts []string658 var verdicts []string
574 for _, v := range d.GetVerdicts() {659 for _, v := range d.GetVerdicts() {
@@ -957,36 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
957 }1042 }
958 1043
959 func newMRMergeCmd(env *cliEnv) *cobra.Command {1044 func newMRMergeCmd(env *cliEnv) *cobra.Command {
960- var squash, deleteBranch bool1045+ var mode string
1046+ var deleteBranch bool
961 var head string1047 var head string
962 cmd := &cobra.Command{1048 cmd := &cobra.Command{
963 Use: "merge <repo> <number>",1049 Use: "merge <repo> <number>",
964 Short: "Merge a merge request",1050 Short: "Merge a merge request",
965- Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" +1051+ Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" +
966- "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" +1052+ "only some modes, see `ermite repo edit --merge-modes`):\n" +
967- "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" +1053+ " fast-forward the target branch moves to the head and no commit is created. The target must be\n" +
968- "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" +1054+ " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" +
969- "only one of the modes.\n\n" +1055+ " squash one new commit with the tree of the merge of the head and the target, the target tip\n" +
1056+ " as only parent. The branch must merge without conflicts.\n" +
1057+ " merge a merge commit with two parents, the target tip and the head. The branch must merge\n" +
1058+ " without conflicts.\n" +
1059+ "A squash and a merge commit are signed with the server's own key, generated automatically when the\n" +
1060+ "server first starts; `ermite signing-key show` prints it. The server never rebases. `ermite mr show`\n" +
1061+ "prints the mergeability (up to date, behind, conflict); the server checks again when merging and\n" +
1062+ "refuses a merge that is not possible, saying why.\n\n" +
970 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +1063 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
971 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +1064 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
972 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +1065 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
973 "mr:merge scope.\n\n" +1066 "mr:merge scope.\n\n" +
974- "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" +1067+ "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
975- "is the default branch or used by another open merge request. If the deletion fails after the\n" +1068+ "the source branch right after, in the background. The merge is refused when the branch is the\n" +
976- "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp +1069+ "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
977- "\n--json adds source_branch_deleted (true when the branch was deleted).",1070+ "moved, say), the merge stands and the branch is kept." + jsonMRHelp +
1071+ "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
978 Args: cobra.ExactArgs(2),1072 Args: cobra.ExactArgs(2),
979 ValidArgsFunction: completeMRRepoThenNumber(env),1073 ValidArgsFunction: completeMRRepoThenNumber(env),
980 }1074 }
981- cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")1075+ cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
982 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")1076 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
983 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")1077 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1078+ _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
1079+ return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
1080+ })
984 asJSON := addJSONFlag(cmd)1081 asJSON := addJSONFlag(cmd)
985 cmd.RunE = func(cmd *cobra.Command, args []string) error {1082 cmd.RunE = func(cmd *cobra.Command, args []string) error {
986 n, err := parseNumber(args[1])1083 n, err := parseNumber(args[1])
987 if err != nil {1084 if err != nil {
988 return err1085 return err
989 }1086 }
1087+ pbMode, err := parseMode(mode)
1088+ if err != nil {
1089+ return err
1090+ }
990 head = strings.TrimSpace(head)1091 head = strings.TrimSpace(head)
991 if head == "" {1092 if head == "" {
992 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+1093 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
@@ -997,35 +1098,60 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
997 return err1098 return err
998 }1099 }
999 ctx := env.ctx(cmd)1100 ctx := env.ctx(cmd)
1000- mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD1101+ if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
1001- if squash {1102+ if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
1002- mode = ermite.MergeMode_MERGE_MODE_SQUASH1103+ return err
1104+ }
1003 }1105 }
1004 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{1106 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1005- Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch,1107+ Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch,
1006 }))1108 }))
1007 if err != nil {1109 if err != nil {
1008 return c.Describe(err)1110 return c.Describe(err)
1009 }1111 }
1010 m := res.Msg.GetMergeRequest()1112 m := res.Msg.GetMergeRequest()
1011 deleted := res.Msg.GetSourceBranchDeleted()1113 deleted := res.Msg.GetSourceBranchDeleted()
1012- if deleteBranch && !deleted {
1013- _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n",
1014- clean(m.GetSourceBranch()))
1015- }
1016 if *asJSON {1114 if *asJSON {
1017 return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})1115 return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
1018 }1116 }
1019- _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),1117+ out := cmd.OutOrStdout()
1118+ _, err = fmt.Fprintf(out, "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
1020 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))1119 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
1120+ if err == nil && pbMode != ermite.MergeMode_MERGE_MODE_FAST_FORWARD {
1121+ _, err = fmt.Fprintln(out, signedNote(ctx, c))
1122+ }
1021 if err == nil && deleted {1123 if err == nil && deleted {
1022- _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch()))1124+ _, err = fmt.Fprintf(out, "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch()))
1023 }1125 }
1024 return err1126 return err
1025 }1127 }
1026 return cmd1128 return cmd
1027 }1129 }
1028 1130
1131+// signedNote says that a squash or merge commit is signed by the server's
1132+// key, with its fingerprint when the server tells it.
1133+func signedNote(ctx context.Context, c *client.Client) string {
1134+ const how = "; `ermite signing-key show` prints the key to verify it"
1135+ res, err := c.Server().GetSigningKey(ctx, connect.NewRequest(&ermite.GetSigningKeyRequest{}))
1136+ if err != nil || res.Msg.GetFingerprint() == "" {
1137+ return "the commit is signed by the server's key" + how
1138+ }
1139+ return "the commit is signed by the server's key " + clean(res.Msg.GetFingerprint()) + how
1140+}
1141+
1142+// requireFeature refuses to go on with a server that does not list the
1143+// feature. A server whose features cannot be read is refused too.
1144+func requireFeature(ctx context.Context, c *client.Client, feature, what string) error {
1145+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
1146+ if err != nil {
1147+ return c.Describe(err)
1148+ }
1149+ if !slices.Contains(res.Msg.GetFeatures(), feature) {
1150+ return fmt.Errorf("the server does not support %s, so it %s (upgrade the server, see `ermite version`)", feature, what)
1151+ }
1152+ return nil
1153+}
1154+
1029 func newMRCloseCmd(env *cliEnv) *cobra.Command {1155 func newMRCloseCmd(env *cliEnv) *cobra.Command {
1030 cmd := &cobra.Command{1156 cmd := &cobra.Command{
1031 Use: "close <repo> <number>",1157 Use: "close <repo> <number>",
M internal/app/cli_mr_e2e_test.go +6 -6
@@ -260,19 +260,19 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) {
260260 require.Error(t, res.err)
261261 assert.Contains(t, res.err.Error(), "precondition failed")
262262
263- // Squash, with --squash. Without --head the merge is refused before it reaches the server.
263+ // Squash, with --mode squash. Without --head the merge is refused before it reaches the server.
264264 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
265265 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
266266 c.writeFile(wt, "two.txt", []byte("two\n"))
267267 c.mustGit(wt, "push", "-q", url, "feat/two")
268268 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
269269 w.nextOfType(t, "merge_request_opened")
270- _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash")
270+ _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--mode", "squash")
271271 requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
272272 head4 := c.bareGit("proj", "rev-parse", "feat/two")
273- res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4)
273+ res = c.cli(t, secret, "mr", "merge", "proj", "2", "--mode", "squash", "--head", head4)
274274 require.NoError(t, res.err, res.stderr)
275- assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)
275+ assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\nthe commit is signed by the server's key SHA256:\S+; .ermite signing-key show. prints the key to verify it\n$`, res.stdout)
276276 sq := c.bareGit("proj", "rev-parse", "main")
277277 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
278278 assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))
@@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
338338 c.writeFile(wt, "x.txt", []byte("x\n"))
339339 c.mustGit(wt, "push", "-q", url, "main", "feat")
340340 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
341- res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
341+ res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--mode", "squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
342342 require.NoError(t, res.err, res.stderr)
343343 sq := c.bareGit("proj", "rev-parse", "main")
344344
@@ -381,7 +381,7 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
381381 MergeModes []string `json:"merge_modes"`
382382 FailingChecksBlock bool `json:"failing_checks_block"`
383383 }](t, res.stdout)
384- assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes)
384+ assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
385385 assert.True(t, repo.FailingChecksBlock)
386386
387387 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
@@ -260,19 +260,19 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) {
260 require.Error(t, res.err)260 require.Error(t, res.err)
261 assert.Contains(t, res.err.Error(), "precondition failed")261 assert.Contains(t, res.err.Error(), "precondition failed")
262 262
263- // Squash, with --squash. Without --head the merge is refused before it reaches the server.263+ // Squash, with --mode squash. Without --head the merge is refused before it reaches the server.
264 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)264 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
265 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")265 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
266 c.writeFile(wt, "two.txt", []byte("two\n"))266 c.writeFile(wt, "two.txt", []byte("two\n"))
267 c.mustGit(wt, "push", "-q", url, "feat/two")267 c.mustGit(wt, "push", "-q", url, "feat/two")
268 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)268 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
269 w.nextOfType(t, "merge_request_opened")269 w.nextOfType(t, "merge_request_opened")
270- _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash")270+ _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--mode", "squash")
271 requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")271 requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
272 head4 := c.bareGit("proj", "rev-parse", "feat/two")272 head4 := c.bareGit("proj", "rev-parse", "feat/two")
273- res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4)273+ res = c.cli(t, secret, "mr", "merge", "proj", "2", "--mode", "squash", "--head", head4)
274 require.NoError(t, res.err, res.stderr)274 require.NoError(t, res.err, res.stderr)
275- assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)275+ assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\nthe commit is signed by the server's key SHA256:\S+; .ermite signing-key show. prints the key to verify it\n$`, res.stdout)
276 sq := c.bareGit("proj", "rev-parse", "main")276 sq := c.bareGit("proj", "rev-parse", "main")
277 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))277 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
278 assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))278 assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))
@@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
338 c.writeFile(wt, "x.txt", []byte("x\n"))338 c.writeFile(wt, "x.txt", []byte("x\n"))
339 c.mustGit(wt, "push", "-q", url, "main", "feat")339 c.mustGit(wt, "push", "-q", url, "main", "feat")
340 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)340 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
341- res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat"))341+ res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--mode", "squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
342 require.NoError(t, res.err, res.stderr)342 require.NoError(t, res.err, res.stderr)
343 sq := c.bareGit("proj", "rev-parse", "main")343 sq := c.bareGit("proj", "rev-parse", "main")
344 344
@@ -381,7 +381,7 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
381 MergeModes []string `json:"merge_modes"`381 MergeModes []string `json:"merge_modes"`
382 FailingChecksBlock bool `json:"failing_checks_block"`382 FailingChecksBlock bool `json:"failing_checks_block"`
383 }](t, res.stdout)383 }](t, res.stdout)
384- assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes)384+ assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
385 assert.True(t, repo.FailingChecksBlock)385 assert.True(t, repo.FailingChecksBlock)
386 386
387 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)387 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
M internal/app/cli_mr_test.go +83 -7
@@ -3,6 +3,8 @@
33 package app
44
55 import (
6+ "encoding/json"
7+ "regexp"
68 "strings"
79 "testing"
810
@@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) {
174176 assert.Contains(t, res.stderr, "head you last read is gone")
175177 }
176178
179+// The mergeability of an open request is a line of `mr show` and a field of
180+// its JSON.
181+func TestMRShowMergeability(t *testing.T) {
182+ withState := func(mg *ermite.Mergeability) *ermite.GetMergeRequestResponse {
183+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
184+ m.Mergeability = mg
185+ return &ermite.GetMergeRequestResponse{MergeRequest: m}
186+ }
187+ var cur *ermite.GetMergeRequestResponse
188+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return cur, nil }}
189+ url := f.serve(t)
190+ for _, tt := range []struct {
191+ mg *ermite.Mergeability
192+ line string
193+ }{
194+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE}, "up to date"},
195+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 1000, BehindCapped: true}, "behind by 1000+"},
196+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b/c.go"}, ConflictPathsTruncated: true}, "conflict (a.txt, b/c.go, ...)"},
197+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING}, "checking"},
198+ } {
199+ cur = withState(tt.mg)
200+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
201+ assert.Regexp(t, `(?m)^mergeability\s+.*`+regexp.QuoteMeta(tt.line), res.stdout)
202+ }
203+ cur = withState(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2, TargetId: headA})
204+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
205+ var got struct {
206+ Mergeability *struct {
207+ State string `json:"state"`
208+ Behind int `json:"behind"`
209+ TargetID string `json:"target_id"`
210+ } `json:"mergeability"`
211+ }
212+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &got))
213+ require.NotNil(t, got.Mergeability)
214+ assert.Equal(t, "behind", got.Mergeability.State)
215+ assert.Equal(t, 2, got.Mergeability.Behind)
216+ assert.Equal(t, headA, got.Mergeability.TargetID)
217+
218+ // No mergeability (a merged request, or an older server): no line, null.
219+ cur = withState(nil)
220+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
221+ assert.NotContains(t, res.stdout, "mergeability")
222+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
223+ assert.Regexp(t, `"mergeability":\s*null`, res.stdout)
224+}
225+
177226 func TestMRShowMarkRead(t *testing.T) {
178227 f := &fakeAPI{
179228 getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
@@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) {
252301 closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {
253302 return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil
254303 },
304+ getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) {
305+ return &ermite.GetSigningKeyResponse{Fingerprint: "SHA256:abc"}, nil
306+ },
307+ getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
308+ return &ermite.GetVersionResponse{Version: "test", Features: []string{"merge_requests.mergeability"}}, nil
309+ },
255310 }
256311 url := f.serve(t)
257312
@@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) {
265320 assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())
266321
267322 // Merge with an explicit head and mode.
268- res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t)
269- assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout)
323+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t)
324+ assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n"+
325+ "the commit is signed by the server's key SHA256:abc; `ermite signing-key show` prints the key to verify it\n", res.stdout)
270326 assert.Empty(t, res.stderr)
271327 calls, reqs := f.snapshot()
272- assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first")
328+ assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest", "GetSigningKey"}, calls, "an explicit head needs no read first; the signing key is for the note")
273329 m := reqs[2].(*ermite.MergeMergeRequestRequest)
274330 assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())
275331 assert.Equal(t, headA, m.GetHeadId())
276332 assert.EqualValues(t, 1, m.GetNumber())
277333
334+ // A merge commit, and a mode that does not exist.
335+ merged.MergeMode = ermite.MergeMode_MERGE_MODE_MERGE_COMMIT
336+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "merge", "--head", headA).ok(t)
337+ assert.Contains(t, res.stdout, "merged alpha!1 into main (merge) at dddddddddd\n")
338+ assert.Contains(t, res.stdout, "signed by the server's key SHA256:abc")
339+ _, reqs = f.snapshot()
340+ var lastMerge *ermite.MergeMergeRequestRequest
341+ for _, r := range reqs {
342+ if mr, ok := r.(*ermite.MergeMergeRequestRequest); ok {
343+ lastMerge = mr
344+ }
345+ }
346+ require.NotNil(t, lastMerge)
347+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, lastMerge.GetMode())
348+ bad := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "rebase", "--head", headA)
349+ require.Error(t, bad.err)
350+ assert.Contains(t, bad.err.Error(), "invalid merge mode")
351+ assert.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).err, "--squash is gone")
352+ merged.MergeMode = ermite.MergeMode_MERGE_MODE_SQUASH
353+
278354 // The default mode is a fast-forward.
279355 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
280356 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
@@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) {
299375 url := f.serve(t)
300376 for _, args := range [][]string{
301377 {"mr", "merge", "alpha", "1"},
302- {"mr", "merge", "alpha", "1", "--squash"},
378+ {"mr", "merge", "alpha", "1", "--mode", "squash"},
303379 {"mr", "merge", "alpha", "1", "--head", " "},
304380 } {
305381 res := runCLI(t, envFor(url), "", args...)
@@ -326,7 +402,7 @@ func TestMRMergeDeleteBranch(t *testing.T) {
326402 url := f.serve(t)
327403
328404 res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
329- assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout)
405+ assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleting branch feat/x (done by the server after the merge)\n", res.stdout)
330406 assert.Empty(t, res.stderr)
331407 _, reqs := f.snapshot()
332408 assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
@@ -334,11 +410,11 @@ func TestMRMergeDeleteBranch(t *testing.T) {
334410 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
335411 assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
336412
337- // The merge stands when the branch is kept: a warning, no failure.
413+ // Deletion is asynchronous: the CLI makes no claim when the server did not
414+ // request it.
338415 deleted = false
339416 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
340417 assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
341- assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept")
342418
343419 // Without the flag nothing is asked and nothing is said.
344420 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
@@ -3,6 +3,8 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "encoding/json"
7+ "regexp"
6 "strings"8 "strings"
7 "testing"9 "testing"
8 10
@@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) {
174 assert.Contains(t, res.stderr, "head you last read is gone")176 assert.Contains(t, res.stderr, "head you last read is gone")
175 }177 }
176 178
179+// The mergeability of an open request is a line of `mr show` and a field of
180+// its JSON.
181+func TestMRShowMergeability(t *testing.T) {
182+ withState := func(mg *ermite.Mergeability) *ermite.GetMergeRequestResponse {
183+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
184+ m.Mergeability = mg
185+ return &ermite.GetMergeRequestResponse{MergeRequest: m}
186+ }
187+ var cur *ermite.GetMergeRequestResponse
188+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return cur, nil }}
189+ url := f.serve(t)
190+ for _, tt := range []struct {
191+ mg *ermite.Mergeability
192+ line string
193+ }{
194+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE}, "up to date"},
195+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 1000, BehindCapped: true}, "behind by 1000+"},
196+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b/c.go"}, ConflictPathsTruncated: true}, "conflict (a.txt, b/c.go, ...)"},
197+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING}, "checking"},
198+ } {
199+ cur = withState(tt.mg)
200+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
201+ assert.Regexp(t, `(?m)^mergeability\s+.*`+regexp.QuoteMeta(tt.line), res.stdout)
202+ }
203+ cur = withState(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2, TargetId: headA})
204+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
205+ var got struct {
206+ Mergeability *struct {
207+ State string `json:"state"`
208+ Behind int `json:"behind"`
209+ TargetID string `json:"target_id"`
210+ } `json:"mergeability"`
211+ }
212+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &got))
213+ require.NotNil(t, got.Mergeability)
214+ assert.Equal(t, "behind", got.Mergeability.State)
215+ assert.Equal(t, 2, got.Mergeability.Behind)
216+ assert.Equal(t, headA, got.Mergeability.TargetID)
217+
218+ // No mergeability (a merged request, or an older server): no line, null.
219+ cur = withState(nil)
220+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
221+ assert.NotContains(t, res.stdout, "mergeability")
222+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
223+ assert.Regexp(t, `"mergeability":\s*null`, res.stdout)
224+}
225+
177 func TestMRShowMarkRead(t *testing.T) {226 func TestMRShowMarkRead(t *testing.T) {
178 f := &fakeAPI{227 f := &fakeAPI{
179 getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {228 getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
@@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) {
252 closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {301 closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {
253 return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil302 return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil
254 },303 },
304+ getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) {
305+ return &ermite.GetSigningKeyResponse{Fingerprint: "SHA256:abc"}, nil
306+ },
307+ getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
308+ return &ermite.GetVersionResponse{Version: "test", Features: []string{"merge_requests.mergeability"}}, nil
309+ },
255 }310 }
256 url := f.serve(t)311 url := f.serve(t)
257 312
@@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) {
265 assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())320 assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())
266 321
267 // Merge with an explicit head and mode.322 // Merge with an explicit head and mode.
268- res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t)323+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t)
269- assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout)324+ assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n"+
325+ "the commit is signed by the server's key SHA256:abc; `ermite signing-key show` prints the key to verify it\n", res.stdout)
270 assert.Empty(t, res.stderr)326 assert.Empty(t, res.stderr)
271 calls, reqs := f.snapshot()327 calls, reqs := f.snapshot()
272- assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first")328+ assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest", "GetSigningKey"}, calls, "an explicit head needs no read first; the signing key is for the note")
273 m := reqs[2].(*ermite.MergeMergeRequestRequest)329 m := reqs[2].(*ermite.MergeMergeRequestRequest)
274 assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())330 assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())
275 assert.Equal(t, headA, m.GetHeadId())331 assert.Equal(t, headA, m.GetHeadId())
276 assert.EqualValues(t, 1, m.GetNumber())332 assert.EqualValues(t, 1, m.GetNumber())
277 333
334+ // A merge commit, and a mode that does not exist.
335+ merged.MergeMode = ermite.MergeMode_MERGE_MODE_MERGE_COMMIT
336+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "merge", "--head", headA).ok(t)
337+ assert.Contains(t, res.stdout, "merged alpha!1 into main (merge) at dddddddddd\n")
338+ assert.Contains(t, res.stdout, "signed by the server's key SHA256:abc")
339+ _, reqs = f.snapshot()
340+ var lastMerge *ermite.MergeMergeRequestRequest
341+ for _, r := range reqs {
342+ if mr, ok := r.(*ermite.MergeMergeRequestRequest); ok {
343+ lastMerge = mr
344+ }
345+ }
346+ require.NotNil(t, lastMerge)
347+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, lastMerge.GetMode())
348+ bad := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "rebase", "--head", headA)
349+ require.Error(t, bad.err)
350+ assert.Contains(t, bad.err.Error(), "invalid merge mode")
351+ assert.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).err, "--squash is gone")
352+ merged.MergeMode = ermite.MergeMode_MERGE_MODE_SQUASH
353+
278 // The default mode is a fast-forward.354 // The default mode is a fast-forward.
279 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)355 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
280 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)356 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
@@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) {
299 url := f.serve(t)375 url := f.serve(t)
300 for _, args := range [][]string{376 for _, args := range [][]string{
301 {"mr", "merge", "alpha", "1"},377 {"mr", "merge", "alpha", "1"},
302- {"mr", "merge", "alpha", "1", "--squash"},378+ {"mr", "merge", "alpha", "1", "--mode", "squash"},
303 {"mr", "merge", "alpha", "1", "--head", " "},379 {"mr", "merge", "alpha", "1", "--head", " "},
304 } {380 } {
305 res := runCLI(t, envFor(url), "", args...)381 res := runCLI(t, envFor(url), "", args...)
@@ -326,7 +402,7 @@ func TestMRMergeDeleteBranch(t *testing.T) {
326 url := f.serve(t)402 url := f.serve(t)
327 403
328 res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)404 res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
329- assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout)405+ assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleting branch feat/x (done by the server after the merge)\n", res.stdout)
330 assert.Empty(t, res.stderr)406 assert.Empty(t, res.stderr)
331 _, reqs := f.snapshot()407 _, reqs := f.snapshot()
332 assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())408 assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
@@ -334,11 +410,11 @@ func TestMRMergeDeleteBranch(t *testing.T) {
334 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)410 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
335 assert.Contains(t, res.stdout, `"source_branch_deleted": true`)411 assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
336 412
337- // The merge stands when the branch is kept: a warning, no failure.413+ // Deletion is asynchronous: the CLI makes no claim when the server did not
414+ // request it.
338 deleted = false415 deleted = false
339 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)416 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
340 assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)417 assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
341- assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept")
342 418
343 // Without the flag nothing is asked and nothing is said.419 // Without the flag nothing is asked and nothing is said.
344 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)420 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
M internal/app/cli_repo.go +9 -7
@@ -33,7 +33,7 @@ type jsonRepo struct {
3333 DefaultBranch string `json:"default_branch"`
3434 CreatedAt *string `json:"created_at"`
3535 PushedAt *string `json:"pushed_at"`
36- // MergeModes are the allowed merge modes ("fast-forward", "squash").
36+ // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
3939 // Issues is the issue mode ("off", "owner" or "public").
@@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) {
114114 const jsonRepoHelp = `
115115 JSON fields of a repository (--json): name, description, visibility ("public" or
116116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
117-null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or
118-"squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
117+null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
118+"merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119119 mode: "off", "owner" or "public").`
120120
121121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
315315 Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues",
316316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
317317 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
318- "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
318+ "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
319+ "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
320+ "commits are signed with the server's key, see `ermite signing-key show`), and\n" +
319321 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
320322 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
321323 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
@@ -327,7 +329,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
327329 cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
328330 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
329331 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
330- cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
332+ cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
331333 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
332334 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
333335 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -335,7 +337,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
335337 })
336338 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
337339 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
338- return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp
340+ return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
339341 })
340342 asJSON := addJSONFlag(cmd)
341343 cmd.RunE = func(cmd *cobra.Command, args []string) error {
@@ -400,7 +402,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
400402 modes = append(modes, mode)
401403 }
402404 if len(modes) == 0 {
403- return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash")
405+ return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge")
404406 }
405407 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
406408 paths = append(paths, "settings.allowed_merge_modes")
@@ -33,7 +33,7 @@ type jsonRepo struct {
33 DefaultBranch string `json:"default_branch"`33 DefaultBranch string `json:"default_branch"`
34 CreatedAt *string `json:"created_at"`34 CreatedAt *string `json:"created_at"`
35 PushedAt *string `json:"pushed_at"`35 PushedAt *string `json:"pushed_at"`
36- // MergeModes are the allowed merge modes ("fast-forward", "squash").36+ // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
37 MergeModes []string `json:"merge_modes"`37 MergeModes []string `json:"merge_modes"`
38 FailingChecksBlock bool `json:"failing_checks_block"`38 FailingChecksBlock bool `json:"failing_checks_block"`
39 // Issues is the issue mode ("off", "owner" or "public").39 // Issues is the issue mode ("off", "owner" or "public").
@@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) {
114 const jsonRepoHelp = `114 const jsonRepoHelp = `
115 JSON fields of a repository (--json): name, description, visibility ("public" or115 JSON fields of a repository (--json): name, description, visibility ("public" or
116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
117-null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or117+null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
118-"squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue118+"merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119 mode: "off", "owner" or "public").`119 mode: "off", "owner" or "public").`
120 120
121 func newRepoCmd(env *cliEnv) *cobra.Command {121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
315 Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues",315 Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues",
316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
317 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +317 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
318- "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +318+ "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
319+ "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
320+ "commits are signed with the server's key, see `ermite signing-key show`), and\n" +
319 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +321 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
320 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +322 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
321 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +323 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
@@ -327,7 +329,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
327 cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")329 cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
328 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")330 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
329 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")331 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
330- cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")332+ cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
331 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")333 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
332 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")334 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
333 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {335 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -335,7 +337,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
335 })337 })
336 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)338 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
337 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {339 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
338- return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp340+ return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
339 })341 })
340 asJSON := addJSONFlag(cmd)342 asJSON := addJSONFlag(cmd)
341 cmd.RunE = func(cmd *cobra.Command, args []string) error {343 cmd.RunE = func(cmd *cobra.Command, args []string) error {
@@ -400,7 +402,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
400 modes = append(modes, mode)402 modes = append(modes, mode)
401 }403 }
402 if len(modes) == 0 {404 if len(modes) == 0 {
403- return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash")405+ return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge")
404 }406 }
405 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}407 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
406 paths = append(paths, "settings.allowed_merge_modes")408 paths = append(paths, "settings.allowed_merge_modes")
M internal/app/cli_version.go +1 -0
@@ -114,6 +114,7 @@ var featureUse = map[string]string{
114114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115115 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116116 client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused",
117+ client.FeatureMergeability: "`ermite mr show` uses to show whether the request merges: the line is missing",
117118 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
118119 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
119120 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
@@ -114,6 +114,7 @@ var featureUse = map[string]string{
114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",115 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116 client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused",116 client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused",
117+ client.FeatureMergeability: "`ermite mr show` uses to show whether the request merges: the line is missing",
117 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",118 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
118 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",119 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
119 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",120 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
@@ -17,7 +17,7 @@ import (
17 )17 )
18 18
19 // allFeatures are the features this client uses.19 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
21 21
22 // Every feature the client uses needs a sentence for the version warning.22 // Every feature the client uses needs a sentence for the version warning.
23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {23 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
45 f := versionFake(allFeatures...)45 f := versionFake(allFeatures...)
46 url := f.serve(t)46 url := f.serve(t)
47 res := runCLI(t, envFor(url), "", "version").ok(t)47 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
49 assert.Empty(t, res.stderr, "no warning when every feature is there")49 assert.Empty(t, res.stderr, "no warning when every feature is there")
50 50
51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)51 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
53 }53 }
54 54
55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {55 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
57 url := f.serve(t)57 url := f.serve(t)
58 res := runCLI(t, envFor(url), "", "version").ok(t)58 res := runCLI(t, envFor(url), "", "version").ok(t)
59 assert.Contains(t, res.stdout, "v0.4.0")59 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
61 assert.NotContains(t, res.stderr, "repo.update")61 assert.NotContains(t, res.stderr, "repo.update")
62 62
63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)63 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")65 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
66 66
67 f = versionFake()67 f = versionFake()
M internal/app/cli_watch.go +30 -0
@@ -29,6 +29,8 @@ type jsonEvent struct {
2929 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`
3030 Number int64 `json:"number,omitempty"`
3131 Comment *jsonComment `json:"comment,omitempty"`
32+ // Mergeability is set for a merge_request_mergeability event.
33+ Mergeability *jsonMergeability `json:"mergeability,omitempty"`
3234 }
3335
3436 type jsonRefUpdate struct {
@@ -52,6 +54,7 @@ const (
5254 eventCommentEdited = "comment_edited"
5355 eventCommentDeleted = "comment_deleted"
5456 eventCommentResolved = "comment_resolved"
57+ eventMRMergeability = "merge_request_mergeability"
5558 )
5659
5760 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -94,6 +97,9 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
9497 case *ermite.Event_CommentResolved:
9598 c := toJSONComment(k.CommentResolved.GetComment())
9699 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
100+ case *ermite.Event_MergeRequestMergeability:
101+ out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102+ out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
97103 case *ermite.Event_Gap:
98104 out.Type = eventGap
99105 default:
@@ -126,12 +132,36 @@ func eventLine(e jsonEvent) string {
126132 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
127133 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
128134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135+ case eventMRMergeability:
136+ detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
129137 case eventGap:
130138 detail = "events were lost, reload what you display"
131139 }
132140 return clean(ts + " " + e.Type + " " + detail)
133141 }
134142
143+// mergeabilityEventText is the short form of a mergeability for a watch line:
144+// "up_to_date", "behind by 2", "conflict (a, b)", "checking".
145+func mergeabilityEventText(m *jsonMergeability) string {
146+ if m == nil {
147+ return "unknown"
148+ }
149+ switch m.State {
150+ case "behind":
151+ return fmt.Sprintf("behind by %d%s", m.Behind, plus(m.BehindCapped))
152+ case "conflict":
153+ if len(m.ConflictPaths) == 0 {
154+ return "conflict (no common history)"
155+ }
156+ more := ""
157+ if m.ConflictPathsTruncated {
158+ more = ", ..."
159+ }
160+ return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + ")"
161+ }
162+ return m.State
163+}
164+
135165 func refUpdateText(u jsonRefUpdate) string {
136166 short := func(id string) string {
137167 if len(id) > 8 {
@@ -29,6 +29,8 @@ type jsonEvent struct {
29 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`29 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`
30 Number int64 `json:"number,omitempty"`30 Number int64 `json:"number,omitempty"`
31 Comment *jsonComment `json:"comment,omitempty"`31 Comment *jsonComment `json:"comment,omitempty"`
32+ // Mergeability is set for a merge_request_mergeability event.
33+ Mergeability *jsonMergeability `json:"mergeability,omitempty"`
32 }34 }
33 35
34 type jsonRefUpdate struct {36 type jsonRefUpdate struct {
@@ -52,6 +54,7 @@ const (
52 eventCommentEdited = "comment_edited"54 eventCommentEdited = "comment_edited"
53 eventCommentDeleted = "comment_deleted"55 eventCommentDeleted = "comment_deleted"
54 eventCommentResolved = "comment_resolved"56 eventCommentResolved = "comment_resolved"
57+ eventMRMergeability = "merge_request_mergeability"
55 )58 )
56 59
57 // toJSONEvent maps an event; ok is false for kinds this client does not know,60 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -94,6 +97,9 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
94 case *ermite.Event_CommentResolved:97 case *ermite.Event_CommentResolved:
95 c := toJSONComment(k.CommentResolved.GetComment())98 c := toJSONComment(k.CommentResolved.GetComment())
96 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c99 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
100+ case *ermite.Event_MergeRequestMergeability:
101+ out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102+ out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
97 case *ermite.Event_Gap:103 case *ermite.Event_Gap:
98 out.Type = eventGap104 out.Type = eventGap
99 default:105 default:
@@ -126,12 +132,36 @@ func eventLine(e jsonEvent) string {
126 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)132 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
127 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:133 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
128 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135+ case eventMRMergeability:
136+ detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
129 case eventGap:137 case eventGap:
130 detail = "events were lost, reload what you display"138 detail = "events were lost, reload what you display"
131 }139 }
132 return clean(ts + " " + e.Type + " " + detail)140 return clean(ts + " " + e.Type + " " + detail)
133 }141 }
134 142
143+// mergeabilityEventText is the short form of a mergeability for a watch line:
144+// "up_to_date", "behind by 2", "conflict (a, b)", "checking".
145+func mergeabilityEventText(m *jsonMergeability) string {
146+ if m == nil {
147+ return "unknown"
148+ }
149+ switch m.State {
150+ case "behind":
151+ return fmt.Sprintf("behind by %d%s", m.Behind, plus(m.BehindCapped))
152+ case "conflict":
153+ if len(m.ConflictPaths) == 0 {
154+ return "conflict (no common history)"
155+ }
156+ more := ""
157+ if m.ConflictPathsTruncated {
158+ more = ", ..."
159+ }
160+ return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + ")"
161+ }
162+ return m.State
163+}
164+
135 func refUpdateText(u jsonRefUpdate) string {165 func refUpdateText(u jsonRefUpdate) string {
136 short := func(id string) string {166 short := func(id string) string {
137 if len(id) > 8 {167 if len(id) > 8 {
M internal/app/cli_watch_test.go +43 -0
@@ -200,6 +200,49 @@ func TestWatchMergeRequestEvents(t *testing.T) {
200200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201201 }
202202
203+// A mergeability event shows the state, with how far behind or the paths.
204+func TestWatchMergeabilityEvents(t *testing.T) {
205+ mg := func(m *ermite.Mergeability) func(*ermite.Event) {
206+ return func(e *ermite.Event) {
207+ e.Kind = &ermite.Event_MergeRequestMergeability{MergeRequestMergeability: &ermite.MergeRequestMergeability{Repo: "alpha", Number: 1, Mergeability: m}}
208+ }
209+ }
210+ events := []*ermite.WatchResponse{
211+ evt("1", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE})),
212+ evt("2", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2})),
213+ evt("3", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b.txt"}})),
214+ }
215+ f := &fakeAPI{watch: finishing(events)}
216+ url := f.serve(t)
217+
218+ res := runCLI(t, envFor(url), "", "watch")
219+ require.Error(t, res.err)
220+ assert.Equal(t, ""+
221+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: up_to_date\n"+
222+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: behind by 2\n"+
223+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: conflict (a.txt, b.txt)\n", res.stdout)
224+
225+ res = runCLI(t, envFor(url), "", "watch", "--json")
226+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
227+ require.Len(t, lines, 3)
228+ var ev struct {
229+ Type string `json:"type"`
230+ Number int64 `json:"number"`
231+ Mergeability struct {
232+ State string `json:"state"`
233+ Behind int `json:"behind"`
234+ ConflictPaths []string `json:"conflict_paths"`
235+ } `json:"mergeability"`
236+ }
237+ require.NoError(t, json.Unmarshal([]byte(lines[1]), &ev))
238+ assert.Equal(t, "merge_request_mergeability", ev.Type)
239+ assert.EqualValues(t, 1, ev.Number)
240+ assert.Equal(t, "behind", ev.Mergeability.State)
241+ assert.Equal(t, 2, ev.Mergeability.Behind)
242+ require.NoError(t, json.Unmarshal([]byte(lines[2]), &ev))
243+ assert.Equal(t, []string{"a.txt", "b.txt"}, ev.Mergeability.ConflictPaths)
244+}
245+
203246 func TestNoteWatchEvents(t *testing.T) {
204247 note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"}
205248 events := []*ermite.Event{
@@ -200,6 +200,49 @@ func TestWatchMergeRequestEvents(t *testing.T) {
200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201 }201 }
202 202
203+// A mergeability event shows the state, with how far behind or the paths.
204+func TestWatchMergeabilityEvents(t *testing.T) {
205+ mg := func(m *ermite.Mergeability) func(*ermite.Event) {
206+ return func(e *ermite.Event) {
207+ e.Kind = &ermite.Event_MergeRequestMergeability{MergeRequestMergeability: &ermite.MergeRequestMergeability{Repo: "alpha", Number: 1, Mergeability: m}}
208+ }
209+ }
210+ events := []*ermite.WatchResponse{
211+ evt("1", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE})),
212+ evt("2", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2})),
213+ evt("3", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b.txt"}})),
214+ }
215+ f := &fakeAPI{watch: finishing(events)}
216+ url := f.serve(t)
217+
218+ res := runCLI(t, envFor(url), "", "watch")
219+ require.Error(t, res.err)
220+ assert.Equal(t, ""+
221+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: up_to_date\n"+
222+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: behind by 2\n"+
223+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: conflict (a.txt, b.txt)\n", res.stdout)
224+
225+ res = runCLI(t, envFor(url), "", "watch", "--json")
226+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
227+ require.Len(t, lines, 3)
228+ var ev struct {
229+ Type string `json:"type"`
230+ Number int64 `json:"number"`
231+ Mergeability struct {
232+ State string `json:"state"`
233+ Behind int `json:"behind"`
234+ ConflictPaths []string `json:"conflict_paths"`
235+ } `json:"mergeability"`
236+ }
237+ require.NoError(t, json.Unmarshal([]byte(lines[1]), &ev))
238+ assert.Equal(t, "merge_request_mergeability", ev.Type)
239+ assert.EqualValues(t, 1, ev.Number)
240+ assert.Equal(t, "behind", ev.Mergeability.State)
241+ assert.Equal(t, 2, ev.Mergeability.Behind)
242+ require.NoError(t, json.Unmarshal([]byte(lines[2]), &ev))
243+ assert.Equal(t, []string{"a.txt", "b.txt"}, ev.Mergeability.ConflictPaths)
244+}
245+
203 func TestNoteWatchEvents(t *testing.T) {246 func TestNoteWatchEvents(t *testing.T) {
204 note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"}247 note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"}
205 events := []*ermite.Event{248 events := []*ermite.Event{
M internal/app/forge.go +14 -1
@@ -137,6 +137,8 @@ type forge struct {
137137 // Inbox serves the owner's inbox of the web.
138138 Inbox *usecase.Inbox
139139 SigningKey *usecase.SigningKey
140+ // Mergeability keeps the mergeability of the open merge requests up to date.
141+ Mergeability *usecase.Mergeability
140142 // Signatures are the trusted keys and the commit signature checks
141143 // (ADR 0030).
142144 Signatures *usecase.Signatures
@@ -331,11 +333,16 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
331333 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
332334 return nil, err
333335 }
336+ f.Mergeability = usecase.NewMergeability(ctx, usecase.MergeabilityConfig{
337+ Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
338+ })
339+ f.Pushes.WithMergeability(f.Mergeability)
334340 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
335341 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
336342 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
337- CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
343+ CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
338344 })
345+ f.Mergeability.Start()
339346 f.MergeRequests.WithVerifier(f.Signatures)
340347 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
341348 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
@@ -512,9 +519,15 @@ func (f *forge) Close(ctx context.Context) {
512519 if f.hooks != nil {
513520 f.hooks.Close(ctx)
514521 }
522+ if f.MergeRequests != nil {
523+ f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
524+ }
515525 if f.CI != nil {
516526 f.CI.Close(ctx)
517527 }
528+ if f.Mergeability != nil {
529+ f.Mergeability.Close(ctx)
530+ }
518531 if f.EventLog != nil {
519532 f.EventLog.Close(ctx)
520533 }
@@ -137,6 +137,8 @@ type forge struct {
137 // Inbox serves the owner's inbox of the web.137 // Inbox serves the owner's inbox of the web.
138 Inbox *usecase.Inbox138 Inbox *usecase.Inbox
139 SigningKey *usecase.SigningKey139 SigningKey *usecase.SigningKey
140+ // Mergeability keeps the mergeability of the open merge requests up to date.
141+ Mergeability *usecase.Mergeability
140 // Signatures are the trusted keys and the commit signature checks142 // Signatures are the trusted keys and the commit signature checks
141 // (ADR 0030).143 // (ADR 0030).
142 Signatures *usecase.Signatures144 Signatures *usecase.Signatures
@@ -331,11 +333,16 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
331 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {333 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
332 return nil, err334 return nil, err
333 }335 }
336+ f.Mergeability = usecase.NewMergeability(ctx, usecase.MergeabilityConfig{
337+ Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
338+ })
339+ f.Pushes.WithMergeability(f.Mergeability)
334 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{340 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
335 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,341 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
336 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,342 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
337- CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),343+ CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
338 })344 })
345+ f.Mergeability.Start()
339 f.MergeRequests.WithVerifier(f.Signatures)346 f.MergeRequests.WithVerifier(f.Signatures)
340 f.Issues = usecase.NewIssues(usecase.IssuesConfig{347 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
341 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,348 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
@@ -512,9 +519,15 @@ func (f *forge) Close(ctx context.Context) {
512 if f.hooks != nil {519 if f.hooks != nil {
513 f.hooks.Close(ctx)520 f.hooks.Close(ctx)
514 }521 }
522+ if f.MergeRequests != nil {
523+ f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
524+ }
515 if f.CI != nil {525 if f.CI != nil {
516 f.CI.Close(ctx)526 f.CI.Close(ctx)
517 }527 }
528+ if f.Mergeability != nil {
529+ f.Mergeability.Close(ctx)
530+ }
518 if f.EventLog != nil {531 if f.EventLog != nil {
519 f.EventLog.Close(ctx)532 f.EventLog.Close(ctx)
520 }533 }
M internal/app/mr_api_test.go +3 -2
@@ -298,7 +298,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
298298
299299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300300 require.NoError(t, err)
301- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
301+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
303303
304304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
@@ -375,7 +375,8 @@ func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) {
375375 Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true,
376376 }))
377377 require.NoError(t, err)
378- assert.True(t, res.Msg.GetSourceBranchDeleted())
378+ assert.True(t, res.Msg.GetSourceBranchDeleted(), "requested")
379+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the response
379380 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat"))
380381
381382 // The default branch as a source: refused before anything changes.
@@ -298,7 +298,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
298 298
299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300 require.NoError(t, err)300 require.NoError(t, err)
301- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())301+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
303 303
304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
@@ -375,7 +375,8 @@ func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) {
375 Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true,375 Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true,
376 }))376 }))
377 require.NoError(t, err)377 require.NoError(t, err)
378- assert.True(t, res.Msg.GetSourceBranchDeleted())378+ assert.True(t, res.Msg.GetSourceBranchDeleted(), "requested")
379+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the response
379 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat"))380 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat"))
380 381
381 // The default branch as a source: refused before anything changes.382 // The default branch as a source: refused before anything changes.
M internal/app/testdata/web/commits-root.html +1 -1
M internal/app/testdata/web/commits-small.html +1 -1
M internal/app/testdata/web/home.html +1 -1
M internal/app/testdata/web/refs.html +1 -1
M internal/app/testdata/web/summary.html +1 -1
M internal/app/web.go +5 -0
M internal/app/web_events_test.go +9 -0
M internal/app/web_inbox_test.go +15 -3
M internal/app/web_mr_test.go +202 -10
M internal/domain/event.go +14 -0
M internal/domain/mergerequest.go +155 -16
M internal/domain/mergerequest_test.go +50 -3
A internal/domain/mergetree_bound_test.go +53 -0
M internal/ports/ci.go +3 -0
M internal/ports/dto.go +8 -2
M internal/ports/mocks/CIRefTipStore.go +69 -0
M internal/ports/mocks/MergeGit.go +162 -0
M internal/ports/mocks/MergeRequestService.go +78 -0
M internal/ports/mocks/MergeRequestStore.go +192 -0
A internal/ports/mocks/MergeabilityReader.go +111 -0
A internal/ports/mocks/MergeabilityTrigger.go +146 -0
M internal/ports/ports.go +37 -0
M internal/ports/services.go +4 -0
M internal/usecase/ci_unit_test.go +1 -0
M internal/usecase/events.go +5 -1
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 45

↑↓ moveenter openesc close