detached b6e9374

fix(web): give merge when ready its own mode choice and drop branch deletion from it

commit
b6e937407800bd6d1100a2aeac0d41cca931ce47
author
brice · · 2026-10-20 17:15 UTC
parent
355f5f4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +394 -317
M docs/merge-requests.md +1 -1
@@ -44,7 +44,7 @@ The merge box of the web page and the output of `mr merge --mode squash|merge` r
4444 A merge request can merge by itself once its checks passed.
4545
4646 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
47-- Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
47+- Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
4848 - The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.
4949 - The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
5050 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
@@ -44,7 +44,7 @@ The merge box of the web page and the output of `mr merge --mode squash|merge` r
44 A merge request can merge by itself once its checks passed.44 A merge request can merge by itself once its checks passed.
45 45
46 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).46 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
47-- Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.47+- Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. Its separate mode choice offers every repository-allowed mode, even while the head conflicts or is behind. Auto-merge keeps the source branch; the delete-source checkbox applies only to an immediate merge. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
48 - The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.48 - The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.
49 - The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.49 - The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
50 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.50 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
M internal/adapters/web/mr.templ +14 -9
@@ -558,17 +558,22 @@ templ mergeMenu(p MRPage) {
558558 :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
559559 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
560560 >Merge into { p.Target }</button>
561- if p.Box.CanAutoMerge {
562- <button
563- type="submit"
564- class="btn"
565- formaction={ templ.SafeURL(p.Box.AutoAction) }
566- disabled?={ len(p.Box.Modes) == 0 }
567- title="merge once the checks pass"
568- >Merge when ready</button>
569- }
570561 </div>
571562 </form>
563+ if p.Box.CanAutoMerge {
564+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.AutoAction) }>
565+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
566+ <fieldset class="d-modes">
567+ <legend>Auto-merge mode</legend>
568+ for i, m := range p.Box.Modes {
569+ <label><input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> { m.Label }</label>
570+ }
571+ </fieldset>
572+ <div class="d-btns">
573+ <button type="submit" class="btn" disabled?={ len(p.Box.Modes) == 0 } title="merge once the checks pass">Merge when ready</button>
574+ </div>
575+ </form>
576+ }
572577 }
573578 </div>
574579 </details>
@@ -558,17 +558,22 @@ templ mergeMenu(p MRPage) {
558 :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"558 :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
559 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"559 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
560 >Merge into { p.Target }</button>560 >Merge into { p.Target }</button>
561- if p.Box.CanAutoMerge {
562- <button
563- type="submit"
564- class="btn"
565- formaction={ templ.SafeURL(p.Box.AutoAction) }
566- disabled?={ len(p.Box.Modes) == 0 }
567- title="merge once the checks pass"
568- >Merge when ready</button>
569- }
570 </div>561 </div>
571 </form>562 </form>
563+ if p.Box.CanAutoMerge {
564+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.AutoAction) }>
565+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
566+ <fieldset class="d-modes">
567+ <legend>Auto-merge mode</legend>
568+ for i, m := range p.Box.Modes {
569+ <label><input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> { m.Label }</label>
570+ }
571+ </fieldset>
572+ <div class="d-btns">
573+ <button type="submit" class="btn" disabled?={ len(p.Box.Modes) == 0 } title="merge once the checks pass">Merge when ready</button>
574+ </div>
575+ </form>
576+ }
572 }577 }
573 </div>578 </div>
574 </details>579 </details>
M internal/adapters/web/mr_templ.go +361 -306 generated file: collapsed
M internal/adapters/web/mr_test.go +18 -1
@@ -215,8 +215,25 @@ func TestMergeBoxAutoMerge(t *testing.T) {
215215 page := MRPage{Target: "main", Box: on}
216216 html := renderToString(t, mergeMenu(page))
217217 assert.Contains(t, html, "Merge when ready")
218- assert.Contains(t, html, `formaction="`+on.AutoAction+`"`)
218+ assert.Contains(t, html, `action="`+on.AutoAction+`"`)
219219 assert.NotContains(t, html, "Cancel auto-merge")
220+ // Current mergeability must only gate the immediate form. Every allowed
221+ // mode remains selectable in the separate auto-merge form.
222+ for i := range on.Modes {
223+ on.Modes[i].Disabled = true
224+ }
225+ on.CanDeleteBranch = true
226+ page.Source = "feat"
227+ html = renderToString(t, mergeMenu(page))
228+ _, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`)
229+ require.True(t, found)
230+ autoForm, _, _ = strings.Cut(autoForm, "</form>")
231+ assert.NotContains(t, autoForm, "delete_branch")
232+ assert.NotContains(t, autoForm, "disabled")
233+ assert.Contains(t, autoForm, `name="head_id"`)
234+ for _, mode := range on.Modes {
235+ assert.Contains(t, autoForm, `value="`+mode.Value+`"`)
236+ }
220237 page.Box = mk(false, mocks.NewRunService(t))
221238 assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
222239
@@ -215,8 +215,25 @@ func TestMergeBoxAutoMerge(t *testing.T) {
215 page := MRPage{Target: "main", Box: on}215 page := MRPage{Target: "main", Box: on}
216 html := renderToString(t, mergeMenu(page))216 html := renderToString(t, mergeMenu(page))
217 assert.Contains(t, html, "Merge when ready")217 assert.Contains(t, html, "Merge when ready")
218- assert.Contains(t, html, `formaction="`+on.AutoAction+`"`)218+ assert.Contains(t, html, `action="`+on.AutoAction+`"`)
219 assert.NotContains(t, html, "Cancel auto-merge")219 assert.NotContains(t, html, "Cancel auto-merge")
220+ // Current mergeability must only gate the immediate form. Every allowed
221+ // mode remains selectable in the separate auto-merge form.
222+ for i := range on.Modes {
223+ on.Modes[i].Disabled = true
224+ }
225+ on.CanDeleteBranch = true
226+ page.Source = "feat"
227+ html = renderToString(t, mergeMenu(page))
228+ _, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`)
229+ require.True(t, found)
230+ autoForm, _, _ = strings.Cut(autoForm, "</form>")
231+ assert.NotContains(t, autoForm, "delete_branch")
232+ assert.NotContains(t, autoForm, "disabled")
233+ assert.Contains(t, autoForm, `name="head_id"`)
234+ for _, mode := range on.Modes {
235+ assert.Contains(t, autoForm, `value="`+mode.Value+`"`)
236+ }
220 page.Box = mk(false, mocks.NewRunService(t))237 page.Box = mk(false, mocks.NewRunService(t))
221 assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")238 assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
222 239

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close