detached e9c6360

fix(web): show session refusals in the async merge box and keep a single merge error alert

Gate refusals on htmx merge posts render a merge-box status fragment
instead of plain text, the merge error alert inside the action area only
renders in fragments, and the checks-label observer test targets the
stable MR bar.
commit
e9c6360e61647574ffacf24b446cc3a410e3efb4
author
brice · · 2026-10-22 01:01 UTC
parent
5fea81f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
4/4
5 files · +71 -14
M internal/adapters/web/jstest/mr.test.mjs +12 -4
@@ -152,8 +152,10 @@ test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
152152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
153153 const text0 = { v: '' }; let writes = 0;
154154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
155- const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
156- const h = footerHarness({ summary });
155+ const bar = {};
156+ const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null };
157+ const extra = { summary };
158+ const h = footerHarness(extra);
157159 let observed = null, callback = null;
158160 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
159161 const nav = document.querySelector('[data-mr-tabs]');
@@ -161,8 +163,14 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg
161163 try {
162164 window.ermiteMR.panels();
163165 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
164- assert.equal(observed[0], summary.parentElement);
165- assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
166+ assert.equal(observed[0], bar);
167+ assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements');
168+ // Replace #mr-actions, including its badge, then replace the badge alone.
169+ extra.summary = { title: 'checks running', textContent: '' }; callback();
170+ assert.equal(label.textContent, 'checks running');
171+ extra.summary = { title: 'checks passed', textContent: '' }; callback();
172+ assert.equal(label.textContent, 'checks passed');
173+ extra.summary = summary; callback();
166174 const before = writes; callback(); callback();
167175 assert.equal(writes, before, 'an unchanged label is not written');
168176 summary.title = ''; callback();
@@ -152,8 +152,10 @@ test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
153 const text0 = { v: '' }; let writes = 0;153 const text0 = { v: '' }; let writes = 0;
154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
155- const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };155+ const bar = {};
156- const h = footerHarness({ summary });156+ const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null };
157+ const extra = { summary };
158+ const h = footerHarness(extra);
157 let observed = null, callback = null;159 let observed = null, callback = null;
158 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };160 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
159 const nav = document.querySelector('[data-mr-tabs]');161 const nav = document.querySelector('[data-mr-tabs]');
@@ -161,8 +163,14 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg
161 try {163 try {
162 window.ermiteMR.panels();164 window.ermiteMR.panels();
163 assert.equal(label.textContent, '1/2 failed', 'initial label sync');165 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
164- assert.equal(observed[0], summary.parentElement);166+ assert.equal(observed[0], bar);
165- assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');167+ assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements');
168+ // Replace #mr-actions, including its badge, then replace the badge alone.
169+ extra.summary = { title: 'checks running', textContent: '' }; callback();
170+ assert.equal(label.textContent, 'checks running');
171+ extra.summary = { title: 'checks passed', textContent: '' }; callback();
172+ assert.equal(label.textContent, 'checks passed');
173+ extra.summary = summary; callback();
166 const before = writes; callback(); callback();174 const before = writes; callback(); callback();
167 assert.equal(writes, before, 'an unchanged label is not written');175 assert.equal(writes, before, 'an unchanged label is not written');
168 summary.title = ''; callback();176 summary.title = ''; callback();
M internal/adapters/web/mr.templ +4 -4
@@ -462,7 +462,7 @@ templ mrBar(p MRPage) {
462462 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
463463 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
464464 @diffViewMenu(p.DiffView)
465- @mrActions(p)
465+ @mrActions(p, false)
466466 </div>
467467 }
468468
@@ -781,7 +781,7 @@ templ MRMergeChecks(c ChecksSummary) {
781781 // MRStateFragment updates only state and actions, leaving review drafts alone.
782782 templ MRStateFragment(p MRPage) {
783783 @mrState(p)
784- @mrActions(p)
784+ @mrActions(p, true)
785785 }
786786
787787 templ mrState(p MRPage) {
@@ -793,14 +793,14 @@ templ mrState(p MRPage) {
793793 </dd>
794794 }
795795
796-templ mrActions(p MRPage) {
796+templ mrActions(p MRPage, fragment bool) {
797797 <div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798798 @MRChecksSum(p.ChecksSum)
799799 if p.Box != nil {
800800 @mergeMenu(p)
801801 }
802802 @moreMenu(p)
803- if p.MergeError != "" {
803+ if fragment && p.MergeError != "" {
804804 <p class="d-err" role="alert">{ p.MergeError }</p>
805805 }
806806 </div>
@@ -462,7 +462,7 @@ templ mrBar(p MRPage) {
462 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">462 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
463 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>463 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
464 @diffViewMenu(p.DiffView)464 @diffViewMenu(p.DiffView)
465- @mrActions(p)465+ @mrActions(p, false)
466 </div>466 </div>
467 }467 }
468 468
@@ -781,7 +781,7 @@ templ MRMergeChecks(c ChecksSummary) {
781 // MRStateFragment updates only state and actions, leaving review drafts alone.781 // MRStateFragment updates only state and actions, leaving review drafts alone.
782 templ MRStateFragment(p MRPage) {782 templ MRStateFragment(p MRPage) {
783 @mrState(p)783 @mrState(p)
784- @mrActions(p)784+ @mrActions(p, true)
785 }785 }
786 786
787 templ mrState(p MRPage) {787 templ mrState(p MRPage) {
@@ -793,14 +793,14 @@ templ mrState(p MRPage) {
793 </dd>793 </dd>
794 }794 }
795 795
796-templ mrActions(p MRPage) {796+templ mrActions(p MRPage, fragment bool) {
797 <div id="mr-actions" hx-swap-oob="true" class="mr-acts">797 <div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798 @MRChecksSum(p.ChecksSum)798 @MRChecksSum(p.ChecksSum)
799 if p.Box != nil {799 if p.Box != nil {
800 @mergeMenu(p)800 @mergeMenu(p)
801 }801 }
802 @moreMenu(p)802 @moreMenu(p)
803- if p.MergeError != "" {803+ if fragment && p.MergeError != "" {
804 <p class="d-err" role="alert">{ p.MergeError }</p>804 <p class="d-err" role="alert">{ p.MergeError }</p>
805 }805 }
806 </div>806 </div>
M internal/adapters/web/mr_async_test.go +39 -0
@@ -125,3 +125,42 @@ func TestAsyncMergeOtherFailures(t *testing.T) {
125125 })
126126 }
127127 }
128+
129+func TestMergeGateRefusalFragment(t *testing.T) {
130+ s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL)
131+ for _, origin := range []string{testPublicURL, "https://other.example"} {
132+ for _, hx := range []bool{false, true} {
133+ r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash"))
134+ r.Header.Set("Origin", origin)
135+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
136+ if hx {
137+ r.Header.Set("HX-Request", "true")
138+ }
139+ w := httptest.NewRecorder()
140+ s.ServeHTTP(w, r)
141+ assert.Equal(t, http.StatusForbidden, w.Code)
142+ if hx {
143+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
144+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
145+ if origin == testPublicURL {
146+ assert.Contains(t, w.Body.String(), "your session has expired")
147+ } else {
148+ assert.Contains(t, w.Body.String(), "origin was not accepted")
149+ }
150+ } else {
151+ assert.NotContains(t, w.Body.String(), "hx-swap-oob")
152+ }
153+ }
154+ }
155+}
156+
157+func TestNonHXMergeRefusalHasOneAlert(t *testing.T) {
158+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
159+ svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
160+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once()
161+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
162+ w := httptest.NewRecorder()
163+ s.mrAction(s.mrMerge)(w, r)
164+ assert.Equal(t, http.StatusConflict, w.Code)
165+ assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`))
166+}
@@ -125,3 +125,42 @@ func TestAsyncMergeOtherFailures(t *testing.T) {
125 })125 })
126 }126 }
127 }127 }
128+
129+func TestMergeGateRefusalFragment(t *testing.T) {
130+ s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL)
131+ for _, origin := range []string{testPublicURL, "https://other.example"} {
132+ for _, hx := range []bool{false, true} {
133+ r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash"))
134+ r.Header.Set("Origin", origin)
135+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
136+ if hx {
137+ r.Header.Set("HX-Request", "true")
138+ }
139+ w := httptest.NewRecorder()
140+ s.ServeHTTP(w, r)
141+ assert.Equal(t, http.StatusForbidden, w.Code)
142+ if hx {
143+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
144+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
145+ if origin == testPublicURL {
146+ assert.Contains(t, w.Body.String(), "your session has expired")
147+ } else {
148+ assert.Contains(t, w.Body.String(), "origin was not accepted")
149+ }
150+ } else {
151+ assert.NotContains(t, w.Body.String(), "hx-swap-oob")
152+ }
153+ }
154+ }
155+}
156+
157+func TestNonHXMergeRefusalHasOneAlert(t *testing.T) {
158+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
159+ svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
160+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once()
161+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
162+ w := httptest.NewRecorder()
163+ s.mrAction(s.mrMerge)(w, r)
164+ assert.Equal(t, http.StatusConflict, w.Code)
165+ assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`))
166+}
M internal/adapters/web/mr_templ.go +4 -4 generated file: collapsed
M internal/adapters/web/session.go +12 -2
@@ -178,11 +178,11 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
178178 // Host header, which the client chooses. A missing header, "null" and any
179179 // other origin are refused.
180180 if r.Header.Get("Origin") != s.origin {
181- denyForbidden(w)
181+ s.denyMergeGate(w, r, "the request origin was not accepted. reload this page and try again.")
182182 return false
183183 }
184184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {
185- denyForbidden(w)
185+ s.denyMergeGate(w, r, "your session has expired. sign in again and reload.")
186186 return false
187187 }
188188 return true
@@ -335,3 +335,13 @@ func (s *Server) loginRejectedPage() templ.Component {
335335 return s.stateOnly("sign in", "404", "login link not valid",
336336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
337337 }
338+
339+// denyMergeGate keeps gate refusals visible to the enhanced merge form.
340+func (s *Server) denyMergeGate(w http.ResponseWriter, r *http.Request, message string) {
341+ if !isHXMerge(r) {
342+ denyForbidden(w)
343+ return
344+ }
345+ w.Header().Set("Cache-Control", "no-store")
346+ s.write(r.Context(), w, r, http.StatusForbidden, MRActionStatus(message, strings.TrimSuffix(r.URL.Path, "/merge")))
347+}
@@ -178,11 +178,11 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
178 // Host header, which the client chooses. A missing header, "null" and any178 // Host header, which the client chooses. A missing header, "null" and any
179 // other origin are refused.179 // other origin are refused.
180 if r.Header.Get("Origin") != s.origin {180 if r.Header.Get("Origin") != s.origin {
181- denyForbidden(w)181+ s.denyMergeGate(w, r, "the request origin was not accepted. reload this page and try again.")
182 return false182 return false
183 }183 }
184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {
185- denyForbidden(w)185+ s.denyMergeGate(w, r, "your session has expired. sign in again and reload.")
186 return false186 return false
187 }187 }
188 return true188 return true
@@ -335,3 +335,13 @@ func (s *Server) loginRejectedPage() templ.Component {
335 return s.stateOnly("sign in", "404", "login link not valid",335 return s.stateOnly("sign in", "404", "login link not valid",
336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
337 }337 }
338+
339+// denyMergeGate keeps gate refusals visible to the enhanced merge form.
340+func (s *Server) denyMergeGate(w http.ResponseWriter, r *http.Request, message string) {
341+ if !isHXMerge(r) {
342+ denyForbidden(w)
343+ return
344+ }
345+ w.Header().Set("Cache-Control", "no-store")
346+ s.write(r.Context(), w, r, http.StatusForbidden, MRActionStatus(message, strings.TrimSuffix(r.URL.Path, "/merge")))
347+}

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