detached bbf234f

Merge e9c6360e61647574ffacf24b446cc3a410e3efb4 into 801147f0f11f433513f19e73261445c99d7678a6

commit
bbf234fdf64dfabc5ec48d8173692994ccbdbe26
author
ermite · · 2026-10-22 00:59 UTC
merge of
801147f + e9c6360 · diff against the first parent
signature
unsigned
10 files · +1538 -1073
M internal/adapters/web/fragments.go +15 -0
@@ -6,6 +6,7 @@ import (
66 "bytes"
77 "context"
88 "errors"
9+ "net/http"
910 "strconv"
1011 "strings"
1112 "sync"
@@ -192,6 +193,20 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
192193 case domain.MergeRequestOpened:
193194 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
194195 case domain.MergeRequestMerged:
196+ if l.view == "mr" && strconv.FormatInt(ev.MergeRequest.Number, 10) == l.n {
197+ r, err := http.NewRequestWithContext(ctx, http.MethodGet, mrHref(l.repo, ev.MergeRequest.Number), nil)
198+ if err != nil {
199+ return nil, err
200+ }
201+ r.SetPathValue("repo", l.repo)
202+ r.SetPathValue("n", l.n)
203+ c, _, err := l.s.mrPageView(ctx, r, false, true)
204+ if err != nil {
205+ return nil, err
206+ }
207+ f, err := renderFragment(ctx, "mr-mergeability", c)
208+ return []fragment{f}, err
209+ }
195210 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
196211 case domain.MergeRequestClosed:
197212 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
@@ -6,6 +6,7 @@ import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8 "errors"8 "errors"
9+ "net/http"
9 "strconv"10 "strconv"
10 "strings"11 "strings"
11 "sync"12 "sync"
@@ -192,6 +193,20 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
192 case domain.MergeRequestOpened:193 case domain.MergeRequestOpened:
193 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")194 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
194 case domain.MergeRequestMerged:195 case domain.MergeRequestMerged:
196+ if l.view == "mr" && strconv.FormatInt(ev.MergeRequest.Number, 10) == l.n {
197+ r, err := http.NewRequestWithContext(ctx, http.MethodGet, mrHref(l.repo, ev.MergeRequest.Number), nil)
198+ if err != nil {
199+ return nil, err
200+ }
201+ r.SetPathValue("repo", l.repo)
202+ r.SetPathValue("n", l.n)
203+ c, _, err := l.s.mrPageView(ctx, r, false, true)
204+ if err != nil {
205+ return nil, err
206+ }
207+ f, err := renderFragment(ctx, "mr-mergeability", c)
208+ return []fragment{f}, err
209+ }
195 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")210 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
196 case domain.MergeRequestClosed:211 case domain.MergeRequestClosed:
197 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")212 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
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.go +7 -0
@@ -558,6 +558,10 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
558558 }
559559
560560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
561+ return s.mrPageView(ctx, r, since, false)
562+}
563+
564+func (s *Server) mrPageView(ctx context.Context, r *http.Request, since, fragment bool) (templ.Component, int, error) {
561565 if since && !SignedIn(ctx) {
562566 return nil, 0, errNotFound
563567 }
@@ -649,6 +653,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649653 }
650654 }
651655
656+ if fragment {
657+ return MRStateFragment(p), http.StatusOK, nil
658+ }
652659 l := s.repoLayout(ctx, d, "merge-requests")
653660 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
654661 if head != "" {
@@ -558,6 +558,10 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
558 }558 }
559 559
560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
561+ return s.mrPageView(ctx, r, since, false)
562+}
563+
564+func (s *Server) mrPageView(ctx context.Context, r *http.Request, since, fragment bool) (templ.Component, int, error) {
561 if since && !SignedIn(ctx) {565 if since && !SignedIn(ctx) {
562 return nil, 0, errNotFound566 return nil, 0, errNotFound
563 }567 }
@@ -649,6 +653,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649 }653 }
650 }654 }
651 655
656+ if fragment {
657+ return MRStateFragment(p), http.StatusOK, nil
658+ }
652 l := s.repoLayout(ctx, d, "merge-requests")659 l := s.repoLayout(ctx, d, "merge-requests")
653 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance660 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
654 if head != "" {661 if head != "" {
M internal/adapters/web/mr.templ +62 -29
@@ -13,7 +13,7 @@ templ MRList(l Layout, p MRListPage) {
1313 @Shell(l, mrListRail(p)) {
1414 if p.Live {
1515 @LiveSlot("mr-notice")
16- <div id="mr-refresh-status" role="status" aria-live="polite"></div>
16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
1717 }
1818 <div class="filter">
1919 <label for="mr-filter" class="sr">filter</label>
@@ -119,12 +119,7 @@ templ MergeRequest(l Layout, p MRPage) {
119119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
120120 <dl class="kv show-kv">
121121 <dt>state</dt>
122- <dd class="mr-state" data-state={ p.State }>
123- { p.State }
124- if p.MergedShort != "" {
125- { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
126- }
127- </dd>
122+ @mrState(p)
128123 <dt>branch</dt>
129124 <dd>
130125 @branchLink(p.Source, p.SourceHref)
@@ -215,19 +210,19 @@ templ MergeRequest(l Layout, p MRPage) {
215210 }
216211 </div>
217212 <div id="mr-comments">
218- <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
219- for _, c := range p.Loose {
220- @commentView(c, "")
221- }
222- for _, c := range p.General {
223- @commentView(c, "")
224- }
225- if p.NComments == 0 {
226- <p class="dim">no comments yet.</p>
227- }
228- if p.GeneralForm != nil {
229- @commentForm(*p.GeneralForm, "")
230- }
213+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
214+ for _, c := range p.Loose {
215+ @commentView(c, "")
216+ }
217+ for _, c := range p.General {
218+ @commentView(c, "")
219+ }
220+ if p.NComments == 0 {
221+ <p class="dim">no comments yet.</p>
222+ }
223+ if p.GeneralForm != nil {
224+ @commentForm(*p.GeneralForm, "")
225+ }
231226 </div>
232227 }
233228 }
@@ -467,13 +462,7 @@ templ mrBar(p MRPage) {
467462 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
468463 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
469464 @diffViewMenu(p.DiffView)
470- <div class="mr-acts">
471- @MRChecksSum(p.ChecksSum)
472- if p.Box != nil {
473- @mergeMenu(p)
474- }
475- @moreMenu(p)
476- </div>
465+ @mrActions(p, false)
477466 </div>
478467 }
479468
@@ -523,7 +512,15 @@ templ mergeMenu(p MRPage) {
523512 </div>
524513 </form>
525514 }
526- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
515+ <form
516+ class="d-acts"
517+ method="post"
518+ action={ templ.SafeURL(p.Box.MergeAction) }
519+ hx-post={ p.Box.MergeAction }
520+ hx-swap="none"
521+ hx-sync="this:drop"
522+ hx-disable="find button[type=submit]"
523+ >
527524 <input type="hidden" name="head_id" value={ p.Box.Head }/>
528525 <fieldset class="d-modes">
529526 <legend class="sr">merge mode</legend>
@@ -557,9 +554,10 @@ templ mergeMenu(p MRPage) {
557554 data-default
558555 disabled?={ !p.Box.CanMerge() }
559556 title={ p.Box.MergeTitle() }
560- :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
557+ :disabled="this.form.classList.contains('htmx-request') || !this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
561558 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
562559 >Merge into { p.Target }</button>
560+ <span class="htmx-indicator" role="status">Merging…</span>
563561 </div>
564562 </form>
565563 if p.Box.CanAutoMerge {
@@ -779,3 +777,38 @@ templ MRMergeChecks(c ChecksSummary) {
779777 }
780778 </div>
781779 }
780+
781+// MRStateFragment updates only state and actions, leaving review drafts alone.
782+templ MRStateFragment(p MRPage) {
783+ @mrState(p)
784+ @mrActions(p, true)
785+}
786+
787+templ mrState(p MRPage) {
788+ <dd id="mr-state" hx-swap-oob="true" class="mr-state" data-state={ p.State }>
789+ { p.State }
790+ if p.MergedShort != "" {
791+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
792+ }
793+ </dd>
794+}
795+
796+templ mrActions(p MRPage, fragment bool) {
797+ <div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798+ @MRChecksSum(p.ChecksSum)
799+ if p.Box != nil {
800+ @mergeMenu(p)
801+ }
802+ @moreMenu(p)
803+ if fragment && p.MergeError != "" {
804+ <p class="d-err" role="alert">{ p.MergeError }</p>
805+ }
806+ </div>
807+}
808+
809+// MRActionStatus remains visible even when the request detail cannot be read.
810+templ MRActionStatus(message, href string) {
811+ <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812+ <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813+ </div>
814+}
@@ -13,7 +13,7 @@ templ MRList(l Layout, p MRListPage) {
13 @Shell(l, mrListRail(p)) {13 @Shell(l, mrListRail(p)) {
14 if p.Live {14 if p.Live {
15 @LiveSlot("mr-notice")15 @LiveSlot("mr-notice")
16- <div id="mr-refresh-status" role="status" aria-live="polite"></div>16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
17 }17 }
18 <div class="filter">18 <div class="filter">
19 <label for="mr-filter" class="sr">filter</label>19 <label for="mr-filter" class="sr">filter</label>
@@ -119,12 +119,7 @@ templ MergeRequest(l Layout, p MRPage) {
119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
120 <dl class="kv show-kv">120 <dl class="kv show-kv">
121 <dt>state</dt>121 <dt>state</dt>
122- <dd class="mr-state" data-state={ p.State }>122+ @mrState(p)
123- { p.State }
124- if p.MergedShort != "" {
125- { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
126- }
127- </dd>
128 <dt>branch</dt>123 <dt>branch</dt>
129 <dd>124 <dd>
130 @branchLink(p.Source, p.SourceHref)125 @branchLink(p.Source, p.SourceHref)
@@ -215,19 +210,19 @@ templ MergeRequest(l Layout, p MRPage) {
215 }210 }
216 </div>211 </div>
217 <div id="mr-comments">212 <div id="mr-comments">
218- <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>213+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
219- for _, c := range p.Loose {214+ for _, c := range p.Loose {
220- @commentView(c, "")215+ @commentView(c, "")
221- }216+ }
222- for _, c := range p.General {217+ for _, c := range p.General {
223- @commentView(c, "")218+ @commentView(c, "")
224- }219+ }
225- if p.NComments == 0 {220+ if p.NComments == 0 {
226- <p class="dim">no comments yet.</p>221+ <p class="dim">no comments yet.</p>
227- }222+ }
228- if p.GeneralForm != nil {223+ if p.GeneralForm != nil {
229- @commentForm(*p.GeneralForm, "")224+ @commentForm(*p.GeneralForm, "")
230- }225+ }
231 </div>226 </div>
232 }227 }
233 }228 }
@@ -467,13 +462,7 @@ templ mrBar(p MRPage) {
467 <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">
468 <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>
469 @diffViewMenu(p.DiffView)464 @diffViewMenu(p.DiffView)
470- <div class="mr-acts">465+ @mrActions(p, false)
471- @MRChecksSum(p.ChecksSum)
472- if p.Box != nil {
473- @mergeMenu(p)
474- }
475- @moreMenu(p)
476- </div>
477 </div>466 </div>
478 }467 }
479 468
@@ -523,7 +512,15 @@ templ mergeMenu(p MRPage) {
523 </div>512 </div>
524 </form>513 </form>
525 }514 }
526- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>515+ <form
516+ class="d-acts"
517+ method="post"
518+ action={ templ.SafeURL(p.Box.MergeAction) }
519+ hx-post={ p.Box.MergeAction }
520+ hx-swap="none"
521+ hx-sync="this:drop"
522+ hx-disable="find button[type=submit]"
523+ >
527 <input type="hidden" name="head_id" value={ p.Box.Head }/>524 <input type="hidden" name="head_id" value={ p.Box.Head }/>
528 <fieldset class="d-modes">525 <fieldset class="d-modes">
529 <legend class="sr">merge mode</legend>526 <legend class="sr">merge mode</legend>
@@ -557,9 +554,10 @@ templ mergeMenu(p MRPage) {
557 data-default554 data-default
558 disabled?={ !p.Box.CanMerge() }555 disabled?={ !p.Box.CanMerge() }
559 title={ p.Box.MergeTitle() }556 title={ p.Box.MergeTitle() }
560- :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"557+ :disabled="this.form.classList.contains('htmx-request') || !this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
561 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"558 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
562 >Merge into { p.Target }</button>559 >Merge into { p.Target }</button>
560+ <span class="htmx-indicator" role="status">Merging…</span>
563 </div>561 </div>
564 </form>562 </form>
565 if p.Box.CanAutoMerge {563 if p.Box.CanAutoMerge {
@@ -779,3 +777,38 @@ templ MRMergeChecks(c ChecksSummary) {
779 }777 }
780 </div>778 </div>
781 }779 }
780+
781+// MRStateFragment updates only state and actions, leaving review drafts alone.
782+templ MRStateFragment(p MRPage) {
783+ @mrState(p)
784+ @mrActions(p, true)
785+}
786+
787+templ mrState(p MRPage) {
788+ <dd id="mr-state" hx-swap-oob="true" class="mr-state" data-state={ p.State }>
789+ { p.State }
790+ if p.MergedShort != "" {
791+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
792+ }
793+ </dd>
794+}
795+
796+templ mrActions(p MRPage, fragment bool) {
797+ <div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798+ @MRChecksSum(p.ChecksSum)
799+ if p.Box != nil {
800+ @mergeMenu(p)
801+ }
802+ @moreMenu(p)
803+ if fragment && p.MergeError != "" {
804+ <p class="d-err" role="alert">{ p.MergeError }</p>
805+ }
806+ </div>
807+}
808+
809+// MRActionStatus remains visible even when the request detail cannot be read.
810+templ MRActionStatus(message, href string) {
811+ <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812+ <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813+ </div>
814+}
A internal/adapters/web/mr_async_test.go +166 -0
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+func asyncMergeServer(t *testing.T, state domain.MRState) (*Server, *mocks.MergeRequestService) {
24+ t.Helper()
25+ svc := mocks.NewMergeRequestService(t)
26+ browse := mocks.NewBrowseService(t)
27+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Kind: domain.RefKindBranch, ShortName: "feature"}}}, nil).Maybe()
28+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{
29+ MergeRequest: domain.MergeRequest{Repo: repoName(t, "demo"), Number: 3, State: state, HeadID: objectID(t, strings.Repeat("a", domain.ObjectIDLen)), Source: "feature", Target: "main"},
30+ Mergeability: domain.Mergeability{State: domain.MergeabilityUpToDate},
31+ }, nil).Maybe()
32+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "demo")), Browse: browse, MergeRequests: svc, Now: time.Now}), svc
33+}
34+
35+func TestAsyncMergePost(t *testing.T) {
36+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
37+ for _, hx := range []bool{false, true} {
38+ t.Run(mode.String()+"/"+boolText(hx), func(t *testing.T) {
39+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
40+ svc.EXPECT().Merge(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), ports.MergeParams{Mode: mode}).Return(ports.MergeResult{}, nil).Once()
41+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode="+mode.String())
42+ if hx {
43+ r.Header.Set("HX-Request", "true")
44+ }
45+ w := httptest.NewRecorder()
46+ s.mrAction(s.mrMerge)(w, r)
47+ if !hx {
48+ assert.Equal(t, http.StatusSeeOther, w.Code)
49+ return
50+ }
51+ assert.Equal(t, http.StatusOK, w.Code)
52+ assert.Empty(t, w.Header().Get("Location"))
53+ assert.Contains(t, w.Body.String(), `id="mr-state"`)
54+ assert.Contains(t, w.Body.String(), `data-state="merged"`)
55+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
56+ assert.NotContains(t, w.Body.String(), "<html")
57+ assert.NotContains(t, w.Body.String(), `id="merge-menu"`)
58+ })
59+ }
60+ }
61+}
62+
63+func TestAsyncMergeRefusal(t *testing.T) {
64+ s, svc := asyncMergeServer(t, domain.MRStateOpen)
65+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict)
66+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
67+ r = r.WithContext(context.WithValue(r.Context(), signedInKey{}, struct{}{}))
68+ r.Header.Set("HX-Request", "true")
69+ w := httptest.NewRecorder()
70+ s.mrAction(s.mrMerge)(w, r)
71+ assert.Equal(t, http.StatusConflict, w.Code)
72+ assert.Contains(t, w.Body.String(), `role="alert"`)
73+ assert.Contains(t, w.Body.String(), domain.ErrConflict.Error())
74+ assert.Contains(t, w.Body.String(), `hx-post="/demo/merge-requests/3/merge"`)
75+ assert.Contains(t, w.Body.String(), `hx-sync="this:drop"`)
76+ assert.Contains(t, w.Body.String(), "Merging…")
77+ assert.NotContains(t, w.Body.String(), "<html")
78+}
79+
80+func TestAsyncMergeLive(t *testing.T) {
81+ s, _ := asyncMergeServer(t, domain.MRStateMerged)
82+ l := liveStream{s: s, repo: "demo", view: "mr", n: "3"}
83+ fs, err := l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 3}}))
84+ require.NoError(t, err)
85+ require.Len(t, fs, 1)
86+ assert.Equal(t, "mr-mergeability", fs[0].event)
87+ assert.Contains(t, fs[0].html, `data-state="merged"`)
88+ assert.Contains(t, fs[0].html, `id="mr-actions"`)
89+ assert.False(t, strings.Contains(fs[0].html, "reload"))
90+ fs, err = l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 4}}))
91+ require.NoError(t, err)
92+ assert.Empty(t, fs)
93+}
94+
95+func TestAsyncMergeOtherFailures(t *testing.T) {
96+ for _, tt := range []struct {
97+ name string
98+ mergeErr error
99+ readFails bool
100+ status int
101+ message string
102+ }{
103+ {"forbidden", domain.ErrForbidden, false, http.StatusForbidden, "the session is not allowed"},
104+ {"internal", errors.New("private backend detail"), false, http.StatusInternalServerError, "reload to check its state"},
105+ {"post-merge read", nil, true, http.StatusOK, "merged, but the updated page could not be loaded"},
106+ } {
107+ t.Run(tt.name, func(t *testing.T) {
108+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
109+ if tt.readFails {
110+ svc.On("Get", mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Unset()
111+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{}, errors.New("private read detail")).Once()
112+ }
113+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, tt.mergeErr).Once()
114+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
115+ r.Header.Set("HX-Request", "true")
116+ w := httptest.NewRecorder()
117+ s.mrAction(s.mrMerge)(w, r)
118+ assert.Equal(t, tt.status, w.Code)
119+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
120+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
121+ assert.Contains(t, w.Body.String(), `role="alert"`)
122+ assert.Contains(t, w.Body.String(), tt.message)
123+ assert.NotContains(t, w.Body.String(), "private")
124+ assert.NotContains(t, w.Body.String(), "<html")
125+ })
126+ }
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+}
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+func asyncMergeServer(t *testing.T, state domain.MRState) (*Server, *mocks.MergeRequestService) {
24+ t.Helper()
25+ svc := mocks.NewMergeRequestService(t)
26+ browse := mocks.NewBrowseService(t)
27+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Kind: domain.RefKindBranch, ShortName: "feature"}}}, nil).Maybe()
28+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{
29+ MergeRequest: domain.MergeRequest{Repo: repoName(t, "demo"), Number: 3, State: state, HeadID: objectID(t, strings.Repeat("a", domain.ObjectIDLen)), Source: "feature", Target: "main"},
30+ Mergeability: domain.Mergeability{State: domain.MergeabilityUpToDate},
31+ }, nil).Maybe()
32+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "demo")), Browse: browse, MergeRequests: svc, Now: time.Now}), svc
33+}
34+
35+func TestAsyncMergePost(t *testing.T) {
36+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
37+ for _, hx := range []bool{false, true} {
38+ t.Run(mode.String()+"/"+boolText(hx), func(t *testing.T) {
39+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
40+ svc.EXPECT().Merge(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), ports.MergeParams{Mode: mode}).Return(ports.MergeResult{}, nil).Once()
41+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode="+mode.String())
42+ if hx {
43+ r.Header.Set("HX-Request", "true")
44+ }
45+ w := httptest.NewRecorder()
46+ s.mrAction(s.mrMerge)(w, r)
47+ if !hx {
48+ assert.Equal(t, http.StatusSeeOther, w.Code)
49+ return
50+ }
51+ assert.Equal(t, http.StatusOK, w.Code)
52+ assert.Empty(t, w.Header().Get("Location"))
53+ assert.Contains(t, w.Body.String(), `id="mr-state"`)
54+ assert.Contains(t, w.Body.String(), `data-state="merged"`)
55+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
56+ assert.NotContains(t, w.Body.String(), "<html")
57+ assert.NotContains(t, w.Body.String(), `id="merge-menu"`)
58+ })
59+ }
60+ }
61+}
62+
63+func TestAsyncMergeRefusal(t *testing.T) {
64+ s, svc := asyncMergeServer(t, domain.MRStateOpen)
65+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict)
66+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
67+ r = r.WithContext(context.WithValue(r.Context(), signedInKey{}, struct{}{}))
68+ r.Header.Set("HX-Request", "true")
69+ w := httptest.NewRecorder()
70+ s.mrAction(s.mrMerge)(w, r)
71+ assert.Equal(t, http.StatusConflict, w.Code)
72+ assert.Contains(t, w.Body.String(), `role="alert"`)
73+ assert.Contains(t, w.Body.String(), domain.ErrConflict.Error())
74+ assert.Contains(t, w.Body.String(), `hx-post="/demo/merge-requests/3/merge"`)
75+ assert.Contains(t, w.Body.String(), `hx-sync="this:drop"`)
76+ assert.Contains(t, w.Body.String(), "Merging…")
77+ assert.NotContains(t, w.Body.String(), "<html")
78+}
79+
80+func TestAsyncMergeLive(t *testing.T) {
81+ s, _ := asyncMergeServer(t, domain.MRStateMerged)
82+ l := liveStream{s: s, repo: "demo", view: "mr", n: "3"}
83+ fs, err := l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 3}}))
84+ require.NoError(t, err)
85+ require.Len(t, fs, 1)
86+ assert.Equal(t, "mr-mergeability", fs[0].event)
87+ assert.Contains(t, fs[0].html, `data-state="merged"`)
88+ assert.Contains(t, fs[0].html, `id="mr-actions"`)
89+ assert.False(t, strings.Contains(fs[0].html, "reload"))
90+ fs, err = l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 4}}))
91+ require.NoError(t, err)
92+ assert.Empty(t, fs)
93+}
94+
95+func TestAsyncMergeOtherFailures(t *testing.T) {
96+ for _, tt := range []struct {
97+ name string
98+ mergeErr error
99+ readFails bool
100+ status int
101+ message string
102+ }{
103+ {"forbidden", domain.ErrForbidden, false, http.StatusForbidden, "the session is not allowed"},
104+ {"internal", errors.New("private backend detail"), false, http.StatusInternalServerError, "reload to check its state"},
105+ {"post-merge read", nil, true, http.StatusOK, "merged, but the updated page could not be loaded"},
106+ } {
107+ t.Run(tt.name, func(t *testing.T) {
108+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
109+ if tt.readFails {
110+ svc.On("Get", mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Unset()
111+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{}, errors.New("private read detail")).Once()
112+ }
113+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, tt.mergeErr).Once()
114+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
115+ r.Header.Set("HX-Request", "true")
116+ w := httptest.NewRecorder()
117+ s.mrAction(s.mrMerge)(w, r)
118+ assert.Equal(t, tt.status, w.Code)
119+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
120+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
121+ assert.Contains(t, w.Body.String(), `role="alert"`)
122+ assert.Contains(t, w.Body.String(), tt.message)
123+ assert.NotContains(t, w.Body.String(), "private")
124+ assert.NotContains(t, w.Body.String(), "<html")
125+ })
126+ }
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_post.go +33 -1
@@ -68,6 +68,20 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6868 s.actionFailed(ctx, w, r, err, back)
6969 return
7070 }
71+ if isHXMerge(r) {
72+ c, _, err := s.mrPageView(ctx, r, false, true)
73+ if err != nil {
74+ id := requestID()
75+ log := zerowrap.FromCtx(ctx)
76+ log.Error().Err(err).Str("request_id", id).Msg("web merged state failed")
77+ w.Header().Set("Cache-Control", "no-store")
78+ s.write(ctx, w, r, http.StatusOK, MRActionStatus("merged, but the updated page could not be loaded. reload to see the result. reference: "+id, back))
79+ return
80+ }
81+ w.Header().Set("Cache-Control", "no-store")
82+ s.write(ctx, w, r, http.StatusOK, c)
83+ return
84+ }
7185 seeOther(w, r, to)
7286 }
7387 }
@@ -100,7 +114,7 @@ func mergeErrorOf(ctx context.Context) string {
100114 // It reports false when the page cannot be built; the caller then answers as
101115 // for any refused action.
102116 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)
117+ c, _, err := s.mrPageView(withMergeError(ctx, re.msg), r, false, isHXMerge(r))
104118 if err != nil {
105119 return false
106120 }
@@ -120,6 +134,10 @@ func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *htt
120134 // The browser is gone.
121135 case errors.Is(err, domain.ErrNotFound):
122136 w.Header().Set("Cache-Control", "no-store")
137+ if isHXMerge(r) {
138+ s.write(ctx, w, r, http.StatusNotFound, MRActionStatus("the merge request was not found. reload to check its state.", back))
139+ return
140+ }
123141 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
124142 case errors.Is(err, domain.ErrAlreadyExists):
125143 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
@@ -143,6 +161,10 @@ func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *
143161 log := zerowrap.FromCtx(ctx)
144162 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
145163 w.Header().Set("Cache-Control", "no-store")
164+ if isHXMerge(r) {
165+ s.write(ctx, w, r, http.StatusInternalServerError, MRActionStatus("the merge could not be completed. reload to check its state. reference: "+id, strings.TrimSuffix(r.URL.Path, "/merge")))
166+ return
167+ }
146168 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
147169 }
148170
@@ -161,6 +183,11 @@ func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Requ
161183 if back == "" {
162184 back = "/"
163185 }
186+ if isHXMerge(r) {
187+ w.Header().Set("Cache-Control", "no-store")
188+ s.write(ctx, w, r, status, MRActionStatus(line, back))
189+ return
190+ }
164191 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
165192 w.Header().Set("Cache-Control", "no-store")
166193 s.write(ctx, w, r, status, State(l, StatePage{
@@ -523,3 +550,8 @@ func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string,
523550 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
524551 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
525552 }
553+
554+// isHXMerge limits fragment responses to the progressively enhanced merge form.
555+func isHXMerge(r *http.Request) bool {
556+ return r.Header.Get("HX-Request") == "true" && strings.HasSuffix(r.URL.Path, "/merge")
557+}
@@ -68,6 +68,20 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
68 s.actionFailed(ctx, w, r, err, back)68 s.actionFailed(ctx, w, r, err, back)
69 return69 return
70 }70 }
71+ if isHXMerge(r) {
72+ c, _, err := s.mrPageView(ctx, r, false, true)
73+ if err != nil {
74+ id := requestID()
75+ log := zerowrap.FromCtx(ctx)
76+ log.Error().Err(err).Str("request_id", id).Msg("web merged state failed")
77+ w.Header().Set("Cache-Control", "no-store")
78+ s.write(ctx, w, r, http.StatusOK, MRActionStatus("merged, but the updated page could not be loaded. reload to see the result. reference: "+id, back))
79+ return
80+ }
81+ w.Header().Set("Cache-Control", "no-store")
82+ s.write(ctx, w, r, http.StatusOK, c)
83+ return
84+ }
71 seeOther(w, r, to)85 seeOther(w, r, to)
72 }86 }
73 }87 }
@@ -100,7 +114,7 @@ func mergeErrorOf(ctx context.Context) string {
100 // It reports false when the page cannot be built; the caller then answers as114 // It reports false when the page cannot be built; the caller then answers as
101 // for any refused action.115 // for any refused action.
102 func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {116 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)117+ c, _, err := s.mrPageView(withMergeError(ctx, re.msg), r, false, isHXMerge(r))
104 if err != nil {118 if err != nil {
105 return false119 return false
106 }120 }
@@ -120,6 +134,10 @@ func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *htt
120 // The browser is gone.134 // The browser is gone.
121 case errors.Is(err, domain.ErrNotFound):135 case errors.Is(err, domain.ErrNotFound):
122 w.Header().Set("Cache-Control", "no-store")136 w.Header().Set("Cache-Control", "no-store")
137+ if isHXMerge(r) {
138+ s.write(ctx, w, r, http.StatusNotFound, MRActionStatus("the merge request was not found. reload to check its state.", back))
139+ return
140+ }
123 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())141 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
124 case errors.Is(err, domain.ErrAlreadyExists):142 case errors.Is(err, domain.ErrAlreadyExists):
125 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)143 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
@@ -143,6 +161,10 @@ func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *
143 log := zerowrap.FromCtx(ctx)161 log := zerowrap.FromCtx(ctx)
144 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")162 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
145 w.Header().Set("Cache-Control", "no-store")163 w.Header().Set("Cache-Control", "no-store")
164+ if isHXMerge(r) {
165+ s.write(ctx, w, r, http.StatusInternalServerError, MRActionStatus("the merge could not be completed. reload to check its state. reference: "+id, strings.TrimSuffix(r.URL.Path, "/merge")))
166+ return
167+ }
146 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))168 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
147 }169 }
148 170
@@ -161,6 +183,11 @@ func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Requ
161 if back == "" {183 if back == "" {
162 back = "/"184 back = "/"
163 }185 }
186+ if isHXMerge(r) {
187+ w.Header().Set("Cache-Control", "no-store")
188+ s.write(ctx, w, r, status, MRActionStatus(line, back))
189+ return
190+ }
164 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})191 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
165 w.Header().Set("Cache-Control", "no-store")192 w.Header().Set("Cache-Control", "no-store")
166 s.write(ctx, w, r, status, State(l, StatePage{193 s.write(ctx, w, r, status, State(l, StatePage{
@@ -523,3 +550,8 @@ func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string,
523 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))550 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
524 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err551 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
525 }552 }
553+
554+// isHXMerge limits fragment responses to the progressively enhanced merge form.
555+func isHXMerge(r *http.Request) bool {
556+ return r.Header.Get("HX-Request") == "true" && strings.HasSuffix(r.URL.Path, "/merge")
557+}
M internal/adapters/web/mr_templ.go +1226 -1034 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+}
M internal/adapters/web/static/css/60-mr.css +2 -0
@@ -1,6 +1,8 @@
11 @layer components {
22 /* merge requests: the review page. Hue only where it carries a verdict. */
33 .mr-h .show-kv { margin-top: 0; }
4+ #merge-menu .htmx-indicator { display: none; }
5+ #merge-menu .htmx-request .htmx-indicator { display: inline; }
46 /* one card: the header (title, facts) a shade lighter, the description below */
57 .mr-card {
68 background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;
@@ -1,6 +1,8 @@
1 @layer components {1 @layer components {
2 /* merge requests: the review page. Hue only where it carries a verdict. */2 /* merge requests: the review page. Hue only where it carries a verdict. */
3 .mr-h .show-kv { margin-top: 0; }3 .mr-h .show-kv { margin-top: 0; }
4+ #merge-menu .htmx-indicator { display: none; }
5+ #merge-menu .htmx-request .htmx-indicator { display: inline; }
4 /* one card: the header (title, facts) a shade lighter, the description below */6 /* one card: the header (title, facts) a shade lighter, the description below */
5 .mr-card {7 .mr-card {
6 background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;8 background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;
M internal/adapters/web/static/js/mr.js +3 -3
@@ -293,8 +293,8 @@ window.ermiteMR = (() => {
293293 let observer;
294294 const summary = $('#mr-ck');
295295 if (summary) {
296- // The live stream replaces #mr-ck as a whole: watch only the children of
297- // its parent, and write the label only when it changes. hx-live rewrites
296+ // Both #mr-ck and #mr-actions can be replaced: observe the stable bar,
297+ // and write the label only when it changes. hx-live rewrites
298298 // other text in the same bar on every mutation of the document; a write
299299 // here on each of them would wake hx-live again, a loop that never yields.
300300 const sync = () => {
@@ -303,7 +303,7 @@ window.ermiteMR = (() => {
303303 if (label && label.textContent !== text) label.textContent = text;
304304 };
305305 observer = new MutationObserver(sync);
306- observer.observe(summary.parentElement, { childList: true });
306+ observer.observe(summary.closest('.mr-bar'), { childList: true, subtree: true });
307307 sync();
308308 }
309309 show(initial);
@@ -293,8 +293,8 @@ window.ermiteMR = (() => {
293 let observer;293 let observer;
294 const summary = $('#mr-ck');294 const summary = $('#mr-ck');
295 if (summary) {295 if (summary) {
296- // The live stream replaces #mr-ck as a whole: watch only the children of296+ // Both #mr-ck and #mr-actions can be replaced: observe the stable bar,
297- // its parent, and write the label only when it changes. hx-live rewrites297+ // and write the label only when it changes. hx-live rewrites
298 // other text in the same bar on every mutation of the document; a write298 // other text in the same bar on every mutation of the document; a write
299 // here on each of them would wake hx-live again, a loop that never yields.299 // here on each of them would wake hx-live again, a loop that never yields.
300 const sync = () => {300 const sync = () => {
@@ -303,7 +303,7 @@ window.ermiteMR = (() => {
303 if (label && label.textContent !== text) label.textContent = text;303 if (label && label.textContent !== text) label.textContent = text;
304 };304 };
305 observer = new MutationObserver(sync);305 observer = new MutationObserver(sync);
306- observer.observe(summary.parentElement, { childList: true });306+ observer.observe(summary.closest('.mr-bar'), { childList: true, subtree: true });
307 sync();307 sync();
308 }308 }
309 show(initial);309 show(initial);

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