detached 67508cc

#4 feat(issues): native issues, owner inbox and moderated public form

state
merged · 67508cc · fast-forward
branch
feat/issue-form → main
opened
bnema ·
updated

Implements ADR 0031: native issues, the owner Inbox, and the moderated public issue form. It contains the three stacked branches feat/issues → feat/inbox → feat/issue-form, and each phase was reviewed before the next.

What it does

  • Issues:
    • Issues share the per-repo number with merge requests, so #n is one or the other and links redirect both ways.
    • New scope issue:write. Agents can create, comment, close and reopen issues without moderation.
    • Per-repo setting issues: off|owner|public, default off. A repo with issues off answers the same 404 as an unknown repo.
    • CLI ermite issue create|list|show|comment|close|reopen and repo edit --issues.
    • Web: an Issues tab, live updates, and owner forms.
  • Inbox (/-/inbox, owner only):
    • Counters at the top, then the sections Awaiting moderation, Open merge requests and Open issues.
    • Repository filter, and an owner live stream across all repositories.
    • Button at the top left of the home page, shown only when signed in.
  • Public form (mode public):
    • JavaScript required. A same-origin worker solves a proof of work signed by the server (18 to 21 bits, adaptive).
    • Honeypot field and a minimum fill time of 3 s.
    • Single-use challenge and form token.
    • Limits of 3/hour and 10/day per network, and a cap of 200 pending issues, both reserved atomically.
    • Bans last 30 days and are keyed on an HMAC of the address. No IP is written to disk.
    • Until the proof of work is valid, every refusal is the same generic 400, so nothing reveals a ban, a limit or a full queue.
    • Moderation (approve, reject, reject and ban) happens in the Inbox and over the API.
    • Approved anonymous issues render untrusted Markdown. Pending issues render as plain text.
  • Storage: schema 3 → 4. The upgrade adds issue:write to stored tokens that already hold every scope, so they keep their owner rights.

Verification

  • Phase reviews (go-reviewer) and a JS review (ts-reviewer). All findings are fixed, including:
    • comment loss after retries;
    • issue events delivered after issues were turned off;
    • the bypass of the per-network limit and of the cap under concurrency;
    • the ban oracle;
    • the emoji/NFC counters;
    • restoring the page from bfcache.
  • Final wrap-up review: every point is fixed except the deferred one listed below.
  • End-to-end test in headless Chromium at production difficulty, measured as:
    • a proof-of-work solve of 0.25 to 4.4 s;
    • moderation, then approval as #1;
    • 403 without Origin, 400 on token reuse, 429 with Retry-After;
    • the no-JS message shown instead of the form.
  • go vet, go test -race ./... (full suite), hexcheck, templ drift, buf generate, the mocks and go mod tidy all pass. All 155 JS tests pass. All commits are signed.

Before leaving draft

  • Merge MR !1 (feat/mr-notes) first. Then rebase this branch on main and extend the v3 → v4 upgrade so that it grants mr:review and issue:write to tokens holding the 9 original scopes. Also restore mr:review in client.AllScopes() and drop the temporary Unimplemented MR note handlers in rpc. Do not deploy main between the two merges: existing all-scope tokens would lose their owner rights.
  • Merge the ermite-api MR (feat/issue-form) and tag it. Replace the local replace bnema.dev/ermite-api with go get bnema.dev/ermite-api@<tag>, then run go mod tidy.
  • Deferred (GO-004, scaling): the home badge and every Inbox event compute the full Overview. This is fine on a solo instance; a lighter count-only path can come later.
  • Behind a reverse proxy, set ERMITE_TRUSTED_PROXIES. Without it, every visitor shares the proxy's per-network limit.
CommitSubjectAuthorDateSignatureStatus
67508cctest(usecase): run the issue moderation subtests in parallelbriceGPG Verified
f6f76c8build(deps): ermite-api v0.9.0briceGPG Verified
7e0e489feat(kvstore): grant mr:review and issue:write to full tokens in the schema 4 upgradebriceGPG Verified
fa37646merge: main with merge request notes into feat/issue-formbriceGPG Verified
346e37fdocs: correct issue access, limiter and comment docsbriceGPG Verified
e53302brefactor(kvstore): keep the hexcheck waiver on issue approvalbriceGPG Verified
ddab9a2fix(rpc): answer not found to non-owners before parsing moderation requestsbriceGPG Verified
f8b1e92refactor: drop the unused pending issue state, ListOpen and InboxMergeRequestbriceGPG Verified
1486f6drefactor(domain): build approved issues in the domainbriceGPG Verified
0284609perf(kvstore): page issues without decoding the whole historybriceGPG Verified
17b5f65fix(kvstore): bound the numbering locksbriceGPG Verified
b0db7adfix(usecase): refresh the inbox after a rejectionbriceGPG Verified
03a1ab8Merge branch 'feat/inbox' into feat/issue-formbriceGPG Verified
0092b37Merge branch 'feat/issues' into feat/inboxbriceGPG Verified
30f7beafix(rpc): build against the api with merge request notes and reject scope mr:reviewbriceGPG Verified
b5344bffix(usecase): verify the proof of work before any admission check and reserve quota and queue atomicallybriceGPG Verified
bc78630fix(web): count issue form limits like the server and refresh the challenge after back navigationbriceGPG Verified
e687d9cdocs: public issue form and moderationbriceGPG Verified
cce8cfafeat(web): public issue form and moderation in the inboxbriceGPG Verified
093dc62feat(web): proof of work solverbriceGPG Verified
3f9aa10feat(web): untrusted markdown for external issuesbriceGPG Verified
99db356feat(usecase): allow public issuesbriceGPG Verified
a26798bfeat(usecase): anonymous issue submission with adaptive proof of workbriceGPG Verified
ee8d39cfeat(rpc): serve pending issue moderation and wire it in the forgebriceGPG Verified
51f0135feat(usecase): add issue moderation, the form subkeys and the owner-only issue.submitted eventbriceGPG Verified
2576c64fix(clientlimit): keep the submission limits free of the domain importbriceGPG Verified
8d2ab49feat(clientlimit): add the sliding window submission limiterbriceGPG Verified
6a3e65dfeat(kvstore): store pending issues, bans and the issue form keybriceGPG Verified
dca1fe7feat(ports): add the pending issue, key, ban, submission limiter and moderation portsbriceGPG Verified
784ea9ffeat(domain): add pending issues, the issue submitted event and the form limitsbriceGPG Verified
babd8c2build(deps): use the local ermite-api issue form branchbriceGPG Verified
d66b108test(events): owner topic access matrix, token end, bearer refusal, stale indexbriceGPG Verified
063865bfix(events): deliver repo.updated and repo.deleted to owner watchers and refresh the inboxbriceGPG Verified
bc6524cfeat(web): owner inbox page and home buttonbriceGPG Verified
ab38ed2feat(events): owner topic across repositoriesbriceGPG Verified
974935dfeat(usecase): owner inbox overview across repositoriesbriceGPG Verified
e17a0e5feat(kvstore): list open merge requests and issues across repositoriesbriceGPG Verified
68460c1fix(kvstore): serialize numbering per repository so concurrent creates never exhaust retriesbriceGPG Verified
b2212f2test(app): merge the declaration with the assignment in the issue e2e test (staticcheck S1021)briceGPG Verified
2794dd4fix(kvstore): guard the repository key when updating an issue so a repo delete leaves no open-issue index keysbriceGPG Verified
02692c4fix(events): stop streaming issue events and views once issues are off or the repo is not visiblebriceGPG Verified
4f0da00fix(app): keep token preset cli working on servers without issues; return issue show write errors; correct issue help limitsbriceGPG Verified
a4f8ebdfix(usecase): report a conflict when every issue comment save conflicts instead of publishing IssueCommentedbriceGPG Verified
e3b6724merge(web): issue pages into feat/issuesbriceGPG Verified
68db1e6feat(web): annotate the issue tab and transition for hexcheckbriceGPG Verified
fd07554docs: document issuesbriceGPG Verified
582cf61feat(cli): add ermite issue commands and repo edit --issuesbriceGPG Verified
931429bfeat(web): add the issue pages, tab, redirects and live noticesbriceGPG Verified
a5d6827feat(rpc): serve IssueService and the issues setting, wire issuesbriceGPG Verified
4c8ce48feat(usecase): add the issue use cases and the issues settingbriceGPG Verified
c52c40ffeat(storage): store issues on the merge request sequence, schema 4briceGPG Verified
b41adf0feat(domain): add issues, the issues mode, text parsers and the issue:write scopebriceGPG Verified
6815c6cbuild(deps): use the local ermite-api issues branchbriceGPG Verified

Checks 8

176 files · +18416 -1025
7/8
M README.md +1 -0
M docs/cli.md +7 -2
M docs/configuration.md +1 -1
A docs/issues.md +55 -0
M docs/tokens.md +2 -1
A gen/ermite/storage/issue.pb.go +541 -0
M gen/ermite/storage/merge_request.pb.go +109 -38
M go.mod +2 -2
M go.sum +2 -2
M internal/adapters/client/client.go +5 -0
M internal/adapters/client/client_test.go +3 -3
M internal/adapters/client/features.go +4 -1
M internal/adapters/client/scopes_test.go +2 -0
M internal/adapters/kvstore/export_test.go +41 -1
A internal/adapters/kvstore/issues.go +437 -0
A internal/adapters/kvstore/issues_pending.go +408 -0
A internal/adapters/kvstore/issues_pending_test.go +271 -0
A internal/adapters/kvstore/issues_test.go +561 -0
M internal/adapters/kvstore/mergerequests.go +123 -26
M internal/adapters/kvstore/mergerequests_test.go +1 -1
M internal/adapters/kvstore/repos.go +3 -1
M internal/adapters/kvstore/store.go +75 -4
M internal/adapters/kvstore/store_test.go +4 -4
M internal/adapters/rpc/api.go +8 -3
M internal/adapters/rpc/convert.go +19 -1
M internal/adapters/rpc/convert_test.go +40 -0
A internal/adapters/rpc/issue.go +262 -0
M internal/adapters/rpc/mergerequest.go +3 -1
M internal/adapters/rpc/mergerequest_test.go +11 -11
M internal/adapters/rpc/repo.go +10 -2
M internal/adapters/web/ASSETS.md +2 -0
M internal/adapters/web/assets.go +3 -1
M internal/adapters/web/events.go +59 -3
M internal/adapters/web/events_test.go +38 -1
M internal/adapters/web/fragments.go +36 -6
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/home.go +3 -0
A internal/adapters/web/inbox.go +294 -0
A internal/adapters/web/inbox.templ +115 -0
A internal/adapters/web/inbox_moderation_test.go +148 -0
A internal/adapters/web/inbox_templ.go +759 -0
A internal/adapters/web/inbox_test.go +310 -0
A internal/adapters/web/issues.go +363 -0
A internal/adapters/web/issues.templ +187 -0
A internal/adapters/web/issues_form.go +276 -0
A internal/adapters/web/issues_form.templ +67 -0
A internal/adapters/web/issues_form_templ.go +421 -0
A internal/adapters/web/issues_form_test.go +345 -0
A internal/adapters/web/issues_post.go +86 -0
A internal/adapters/web/issues_templ.go +885 -0 generated file: collapsed
A internal/adapters/web/issues_test.go +332 -0
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
A internal/adapters/web/jstest/issue-form.test.mjs +183 -0
@@ -0,0 +1,183 @@
1+// Tests of the public issue form: the proof-of-work solver (static/js/pow-worker.js)
2+// and the pure core of the form (static/js/issue-form.js). Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { createHash } from 'node:crypto';
6+import { test } from 'node:test';
7+
8+globalThis.window = globalThis;
9+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [], addEventListener() {} };
10+await import('../static/js/pow-worker.js');
11+await import('../static/js/issue-form.js');
12+const P = globalThis.ermitePow;
13+const C = window.ermiteIssueForm.core;
14+
15+// The vector of usecase/issue_pow_vector_test.go: the smallest solution for this
16+// nonce at this difficulty, found by the server's own powHash.
17+const VECTOR = { nonce: 'AAECAwQFBgcICQoLDA0ODw', difficulty: 12, solution: '2491' };
18+
19+test('leadingZeroBits counts the zero bits at the start', () => {
20+ assert.equal(P.leadingZeroBits(Uint8Array.of(0xff)), 0);
21+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x80)), 0);
22+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x40)), 1);
23+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x01)), 7);
24+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x80)), 8);
25+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x00, 0x1f)), 19);
26+ assert.equal(P.leadingZeroBits(Uint8Array.of(0, 0)), 16);
27+ assert.equal(P.leadingZeroBits(Uint8Array.of()), 0);
28+});
29+
30+test('b64urlDecode reads the nonce the server sends', () => {
31+ assert.deepEqual([...P.b64urlDecode(VECTOR.nonce)], [...Array(16).keys()]);
32+ assert.deepEqual([...P.b64urlDecode('-_8')], [0xfb, 0xff]);
33+});
34+
35+test('the hashed bytes are nonce || ":" || decimal solution', () => {
36+ const nonce = P.b64urlDecode(VECTOR.nonce);
37+ const got = P.input(nonce, 2491);
38+ assert.equal(got.length, 16 + 1 + 4);
39+ assert.deepEqual([...got.slice(0, 16)], [...nonce]);
40+ assert.equal(Buffer.from(got.slice(16)).toString('latin1'), ':2491');
41+});
42+
43+test('the solver finds the very solution of the Go vector', async () => {
44+ const sol = await P.solve(P.b64urlDecode(VECTOR.nonce), VECTOR.difficulty);
45+ assert.equal(sol, VECTOR.solution);
46+ const sum = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + sol)])).digest();
47+ assert.ok(P.leadingZeroBits(sum) >= VECTOR.difficulty);
48+ // and no smaller counter works
49+ for (let n = 0; n < Number(sol); n++) {
50+ const s = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + n)])).digest();
51+ assert.ok(P.leadingZeroBits(s) < VECTOR.difficulty, 'counter ' + n);
52+ }
53+});
54+
55+test('the solver is independent of the batch size and reports progress', async () => {
56+ const nonce = P.b64urlDecode(VECTOR.nonce);
57+ const seen = [];
58+ const sol = await P.solve(nonce, VECTOR.difficulty, { batch: 100, onProgress: (n) => { seen.push(n); } });
59+ assert.equal(sol, VECTOR.solution);
60+ assert.deepEqual(seen.slice(0, 2), [100, 200]);
61+ const stopped = await P.solve(nonce, 40, { batch: 10, onProgress: (n) => n >= 30 });
62+ assert.equal(stopped, null, 'stopped by the caller');
63+});
64+
65+test('counterClass: warn from 90 %, over above the maximum', () => {
66+ assert.equal(C.counterClass(0, 120), '');
67+ assert.equal(C.counterClass(107, 120), '');
68+ assert.equal(C.counterClass(108, 120), 'warn');
69+ assert.equal(C.counterClass(120, 120), 'warn');
70+ assert.equal(C.counterClass(121, 120), 'over');
71+ assert.equal(C.counterClass(9000, 10000), 'warn');
72+ assert.equal(C.counterClass(8999, 10000), '');
73+ assert.equal(C.counterClass(41, 40), 'over');
74+});
75+
76+test('length counts characters, not UTF-16 units', () => {
77+ assert.equal(C.length('abc'), 3);
78+ assert.equal(C.length('a😀b'), 3);
79+ assert.equal(C.length(''), 0);
80+});
81+
82+test('state: the button opens only when solved, waited and not over', () => {
83+ const base = { solved: false, now: 1000, minAt: 5000, sending: false, unsupported: false, failed: false, overs: false };
84+ const s = (o) => C.state({ ...base, ...o });
85+ assert.deepEqual(s({}), { disabled: true, status: 'Checking your browser… (this takes a few seconds)' });
86+ assert.equal(s({ solved: true }).disabled, true, 'solved too early');
87+ assert.equal(s({ solved: true, now: 4999 }).disabled, true);
88+ assert.equal(s({ now: 6000 }).disabled, true, 'waited but not solved');
89+ assert.equal(s({ solved: true, now: 5000 }).disabled, false);
90+ assert.equal(s({ solved: true, now: 6000, overs: true }).disabled, true, 'a field is too long');
91+ assert.equal(s({ solved: true, now: 6000, sending: true }).disabled, true, 'no double submit');
92+ assert.equal(s({ solved: true, now: 6000, sending: true }).status, 'Sending…');
93+ assert.equal(s({ unsupported: true }).status, 'Your browser cannot run the check.');
94+ assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95+ assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96+});
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
@@ -0,0 +1,183 @@
1+// Tests of the public issue form: the proof-of-work solver (static/js/pow-worker.js)
2+// and the pure core of the form (static/js/issue-form.js). Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { createHash } from 'node:crypto';
6+import { test } from 'node:test';
7+
8+globalThis.window = globalThis;
9+globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [], addEventListener() {} };
10+await import('../static/js/pow-worker.js');
11+await import('../static/js/issue-form.js');
12+const P = globalThis.ermitePow;
13+const C = window.ermiteIssueForm.core;
14+
15+// The vector of usecase/issue_pow_vector_test.go: the smallest solution for this
16+// nonce at this difficulty, found by the server's own powHash.
17+const VECTOR = { nonce: 'AAECAwQFBgcICQoLDA0ODw', difficulty: 12, solution: '2491' };
18+
19+test('leadingZeroBits counts the zero bits at the start', () => {
20+ assert.equal(P.leadingZeroBits(Uint8Array.of(0xff)), 0);
21+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x80)), 0);
22+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x40)), 1);
23+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x01)), 7);
24+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x80)), 8);
25+ assert.equal(P.leadingZeroBits(Uint8Array.of(0x00, 0x00, 0x1f)), 19);
26+ assert.equal(P.leadingZeroBits(Uint8Array.of(0, 0)), 16);
27+ assert.equal(P.leadingZeroBits(Uint8Array.of()), 0);
28+});
29+
30+test('b64urlDecode reads the nonce the server sends', () => {
31+ assert.deepEqual([...P.b64urlDecode(VECTOR.nonce)], [...Array(16).keys()]);
32+ assert.deepEqual([...P.b64urlDecode('-_8')], [0xfb, 0xff]);
33+});
34+
35+test('the hashed bytes are nonce || ":" || decimal solution', () => {
36+ const nonce = P.b64urlDecode(VECTOR.nonce);
37+ const got = P.input(nonce, 2491);
38+ assert.equal(got.length, 16 + 1 + 4);
39+ assert.deepEqual([...got.slice(0, 16)], [...nonce]);
40+ assert.equal(Buffer.from(got.slice(16)).toString('latin1'), ':2491');
41+});
42+
43+test('the solver finds the very solution of the Go vector', async () => {
44+ const sol = await P.solve(P.b64urlDecode(VECTOR.nonce), VECTOR.difficulty);
45+ assert.equal(sol, VECTOR.solution);
46+ const sum = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + sol)])).digest();
47+ assert.ok(P.leadingZeroBits(sum) >= VECTOR.difficulty);
48+ // and no smaller counter works
49+ for (let n = 0; n < Number(sol); n++) {
50+ const s = createHash('sha256').update(Buffer.concat([Buffer.from(P.b64urlDecode(VECTOR.nonce)), Buffer.from(':' + n)])).digest();
51+ assert.ok(P.leadingZeroBits(s) < VECTOR.difficulty, 'counter ' + n);
52+ }
53+});
54+
55+test('the solver is independent of the batch size and reports progress', async () => {
56+ const nonce = P.b64urlDecode(VECTOR.nonce);
57+ const seen = [];
58+ const sol = await P.solve(nonce, VECTOR.difficulty, { batch: 100, onProgress: (n) => { seen.push(n); } });
59+ assert.equal(sol, VECTOR.solution);
60+ assert.deepEqual(seen.slice(0, 2), [100, 200]);
61+ const stopped = await P.solve(nonce, 40, { batch: 10, onProgress: (n) => n >= 30 });
62+ assert.equal(stopped, null, 'stopped by the caller');
63+});
64+
65+test('counterClass: warn from 90 %, over above the maximum', () => {
66+ assert.equal(C.counterClass(0, 120), '');
67+ assert.equal(C.counterClass(107, 120), '');
68+ assert.equal(C.counterClass(108, 120), 'warn');
69+ assert.equal(C.counterClass(120, 120), 'warn');
70+ assert.equal(C.counterClass(121, 120), 'over');
71+ assert.equal(C.counterClass(9000, 10000), 'warn');
72+ assert.equal(C.counterClass(8999, 10000), '');
73+ assert.equal(C.counterClass(41, 40), 'over');
74+});
75+
76+test('length counts characters, not UTF-16 units', () => {
77+ assert.equal(C.length('abc'), 3);
78+ assert.equal(C.length('a😀b'), 3);
79+ assert.equal(C.length(''), 0);
80+});
81+
82+test('state: the button opens only when solved, waited and not over', () => {
83+ const base = { solved: false, now: 1000, minAt: 5000, sending: false, unsupported: false, failed: false, overs: false };
84+ const s = (o) => C.state({ ...base, ...o });
85+ assert.deepEqual(s({}), { disabled: true, status: 'Checking your browser… (this takes a few seconds)' });
86+ assert.equal(s({ solved: true }).disabled, true, 'solved too early');
87+ assert.equal(s({ solved: true, now: 4999 }).disabled, true);
88+ assert.equal(s({ now: 6000 }).disabled, true, 'waited but not solved');
89+ assert.equal(s({ solved: true, now: 5000 }).disabled, false);
90+ assert.equal(s({ solved: true, now: 6000, overs: true }).disabled, true, 'a field is too long');
91+ assert.equal(s({ solved: true, now: 6000, sending: true }).disabled, true, 'no double submit');
92+ assert.equal(s({ solved: true, now: 6000, sending: true }).status, 'Sending…');
93+ assert.equal(s({ unsupported: true }).status, 'Your browser cannot run the check.');
94+ assert.equal(s({ solved: true, now: 6000, unsupported: true }).disabled, true);
95+ assert.equal(s({ failed: true, solved: true, now: 6000 }).disabled, true);
96+});
97+
98+test('serverLength counts NFC code points like the server', () => {
99+ assert.equal(C.serverLength('title', 'e\u0301'.repeat(61)), 61);
100+ assert.equal(C.serverLength('title', '\u0344'.repeat(61)), 122);
101+ assert.equal(C.counterClass(122, 120), 'over');
102+ assert.equal(C.serverLength('title', '😀'.repeat(120)), 120);
103+ assert.equal(C.counterClass(120, 120), 'warn');
104+ assert.equal(C.serverLength('title', '😀'.repeat(121)), 121);
105+ assert.equal(C.counterClass(121, 120), 'over');
106+});
107+
108+test('serverLength drops what the server strips and trims', () => {
109+ assert.equal(C.serverLength('title', ' ab\u202e\u200f\u061c\u2066\u0007\u0085 '), 2);
110+ assert.equal(C.serverLength('title', ' \u202e '), 0);
111+ assert.equal(C.serverLength('author', ' me '), 2);
112+ assert.equal(C.serverLength('body', 'a\nb\tc\u0007\u202a\r\n'), 6);
113+ assert.equal(C.serverLength('body', ' a '), 3);
114+ assert.equal(C.serverLength('title', 'a\tb'), 2);
115+});
116+
117+test('state keeps the button disabled when a title is empty or a field is over', () => {
118+ const base = { solved: true, now: 2, minAt: 1, sending: false, unsupported: false, failed: false, overs: false, empty: false };
119+ assert.equal(C.state(base).disabled, false);
120+ assert.equal(C.state({ ...base, overs: true }).disabled, true);
121+ assert.equal(C.state({ ...base, empty: true }).disabled, true);
122+});
123+
124+function fakeStorage() {
125+ const m = new Map();
126+ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), m };
127+}
128+
129+test('drafts are saved, restored once, and scoped to the path', () => {
130+ const st = fakeStorage();
131+ const k = C.draftKey('/r/issues/new');
132+ assert.notEqual(k, C.draftKey('/other/issues/new'));
133+ C.saveDraft(st, k, { title: 't', body: 'b\nc', author: 'a', solution: 'x' });
134+ assert.deepEqual(C.restoreDraft(st, k), { title: 't', body: 'b\nc', author: 'a' });
135+ assert.equal(st.m.size, 0);
136+ assert.equal(C.restoreDraft(st, k), null);
137+ st.setItem(k, 'not json');
138+ assert.equal(C.restoreDraft(st, k), null);
139+});
140+
141+test('onPageShow reloads only for a bfcache restore', () => {
142+ assert.equal(C.onPageShow(true), 'reload');
143+ assert.equal(C.onPageShow(false), 'none');
144+ assert.equal(C.onPageShow(undefined), 'none');
145+});
146+
147+test('init restores a draft and reloads on a bfcache pageshow', () => {
148+ const listeners = {};
149+ const mk = (o) => ({ listeners: {}, addEventListener(t, f) { this.listeners[t] = f; }, ...o });
150+ const title = mk({ id: 'issue-title', name: 'title', value: '' });
151+ const body = mk({ id: 'issue-body', name: 'body', value: '' });
152+ const author = mk({ id: 'issue-author', name: 'author', value: '' });
153+ const outs = [['issue-title', 120], ['issue-body', 10000], ['issue-author', 40]].map(([f, max]) => ({ dataset: { for: f, max: String(max) }, textContent: '', className: '' }));
154+ const button = { disabled: false };
155+ const solution = { value: '' };
156+ const sel = { 'button[type="submit"]': button, '[data-status]': { textContent: '' }, 'input[name="solution"]': solution,
157+ '[name="title"]': title, '[name="body"]': body, '[name="author"]': author,
158+ '#issue-title': title, '#issue-body': body, '#issue-author': author };
159+ const form = { dataset: { minSubmitAt: '0' }, hidden: true, querySelector: (s) => sel[s] ?? null,
160+ querySelectorAll: () => outs, addEventListener() {}, removeAttribute() {} };
161+ const st = fakeStorage();
162+ let reloads = 0;
163+ const saved = { sessionStorage: window.sessionStorage, location: window.location, addEventListener: window.addEventListener, Worker: window.Worker };
164+ Object.assign(window, { sessionStorage: st, location: { pathname: '/r/issues/new', reload() { reloads++; } },
165+ addEventListener(t, f) { listeners[t] = f; }, Worker: undefined });
166+ try {
167+ C.saveDraft(st, C.draftKey('/r/issues/new'), { title: 'Hello', body: 'B', author: 'me' });
168+ window.ermiteIssueForm.init(form);
169+ assert.equal(title.value, 'Hello');
170+ assert.equal(outs[0].textContent, '5 / 120');
171+ assert.equal(st.m.size, 0);
172+ assert.equal(button.disabled, true);
173+ listeners.pageshow({ persisted: false });
174+ assert.equal(reloads, 0);
175+ title.value = 'Edited';
176+ listeners.pageshow({ persisted: true });
177+ assert.equal(reloads, 1);
178+ assert.deepEqual(JSON.parse(st.getItem(C.draftKey('/r/issues/new'))), { title: 'Edited', body: 'B', author: 'me' });
179+ assert.equal(button.disabled, true);
180+ } finally {
181+ for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete window[k]; else window[k] = v; }
182+ }
183+});
M internal/adapters/web/keys.templ +1 -0
@@ -27,6 +27,7 @@ templ Keys() {
2727 k-c from:body -> ermite.tab('c');
2828 k-r from:body -> ermite.tab('r');
2929 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
3031 k-next from:body -> ermite.nextNote();
3132 k-split from:body -> ermite.layout('split');
3233 k-unified from:body -> ermite.layout('unified');
@@ -27,6 +27,7 @@ templ Keys() {
27 k-c from:body -> ermite.tab('c');27 k-c from:body -> ermite.tab('c');
28 k-r from:body -> ermite.tab('r');28 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');29 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
30 k-next from:body -> ermite.nextNote();31 k-next from:body -> ermite.nextNote();
31 k-split from:body -> ermite.layout('split');32 k-split from:body -> ermite.layout('split');
32 k-unified from:body -> ermite.layout('unified');33 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/markdown.go +73 -7
@@ -64,19 +64,31 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
6464 // #fragment links chosen, so that several documents on one page (the summaries
6565 // of a run) do not share ids.
6666 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderDocument(source, repo, ref, dir, idPrefix, nil)
67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
6868 }
6969
7070 // renderNote uses the repository Markdown policy, then adds local note links.
7171 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72- h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)
72+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
7373 return h, err
7474 }
7575
76-func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {
77- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
76+// renderUntrusted renders Markdown written by an anonymous visitor (an issue
77+// approved from the public form). On top of the policy of render, which already
78+// drops raw HTML and never loads an image:
79+// - only http, https and mailto links are kept, every one with
80+// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81+// every other scheme lose their link and keep the text;
82+// - nothing points into the repository.
83+func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
85+ return h, err
86+}
87+
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
89+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
7890 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
79- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
91+ pol.source = source
8092 // Collect eligible text before the policy unwraps rejected links: their
8193 // labels must not accidentally become references either.
8294 var refs []*ast.Text
@@ -163,6 +175,20 @@ type linkPolicy struct {
163175 source []byte
164176 // idPrefix is the prefix of the heading ids that #fragment links point at.
165177 idPrefix string
178+ // untrusted is the policy of text from an anonymous visitor.
179+ untrusted bool
180+}
181+
182+const (
183+ relExternal = "nofollow noopener noreferrer"
184+ relUntrusted = "nofollow noopener noreferrer ugc"
185+)
186+
187+func (p linkPolicy) rel() []byte {
188+ if p.untrusted {
189+ return []byte(relUntrusted)
190+ }
191+ return []byte(relExternal)
166192 }
167193
168194 func (p linkPolicy) apply(doc ast.Node) {
@@ -195,13 +221,17 @@ func (p linkPolicy) apply(doc ast.Node) {
195221 l.Destination = []byte(dest)
196222 l.Title = nil
197223 if kind == external {
198- l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
224+ l.SetAttributeString("rel", p.rel())
199225 }
200226 }
201227 for _, n := range autos {
202228 a := n.(*ast.AutoLink)
203229 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
204- a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
230+ a.SetAttributeString("rel", p.rel())
231+ continue
232+ }
233+ if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
234+ a.SetAttributeString("rel", p.rel())
205235 continue
206236 }
207237 // An email address or another scheme: the label as plain text.
@@ -241,6 +271,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
241271 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
242272 return "", dropped
243273 }
274+ if p.untrusted {
275+ return untrustedTarget(dest)
276+ }
244277 if strings.HasPrefix(dest, "#") {
245278 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
246279 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
@@ -288,6 +321,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
288321 return href, internal
289322 }
290323
324+// untrustedTarget keeps absolute http and https links with a host, and mailto
325+// links to one plain address.
326+func untrustedTarget(dest string) (string, linkKind) {
327+ if strings.HasPrefix(dest, "//") {
328+ return "", dropped
329+ }
330+ u, err := url.Parse(dest)
331+ if err != nil {
332+ return "", dropped
333+ }
334+ switch strings.ToLower(u.Scheme) {
335+ case "http", "https":
336+ if u.Host == "" || u.User != nil {
337+ return "", dropped
338+ }
339+ return u.String(), external
340+ case "mailto":
341+ if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
342+ return "", dropped
343+ }
344+ return "mailto:" + u.Opaque, external
345+ }
346+ return "", dropped
347+}
348+
349+// validMailbox tells whether s is a bare address: no space, quote, angle
350+// bracket, percent sign, comma or second at sign.
351+func validMailbox(s string) bool {
352+ local, host, ok := strings.Cut(s, "@")
353+ return ok && local != "" && host != "" && len(s) <= 254 &&
354+ !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
355+}
356+
291357 func isWebURL(s string) bool {
292358 u, err := url.Parse(s)
293359 if err != nil || u.Host == "" {
@@ -64,19 +64,31 @@ func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML,
64 // #fragment links chosen, so that several documents on one page (the summaries64 // #fragment links chosen, so that several documents on one page (the summaries
65 // of a run) do not share ids.65 // of a run) do not share ids.
66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {66 func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
67- return m.renderDocument(source, repo, ref, dir, idPrefix, nil)67+ return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, nil)
68 }68 }
69 69
70 // renderNote uses the repository Markdown policy, then adds local note links.70 // renderNote uses the repository Markdown policy, then adds local note links.
71 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {71 func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, notes func(int64) bool) (template.HTML, error) {
72- h, _, err := m.renderDocument(source, repo, ref, "", idPrefix, notes)72+ h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, notes)
73 return h, err73 return h, err
74 }74 }
75 75
76-func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {76+// renderUntrusted renders Markdown written by an anonymous visitor (an issue
77- pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))77+// approved from the public form). On top of the policy of render, which already
78+// drops raw HTML and never loads an image:
79+// - only http, https and mailto links are kept, every one with
80+// rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
81+// every other scheme lose their link and keep the text;
82+// - nothing points into the repository.
83+func (m *markdown) renderUntrusted(source []byte, idPrefix string) (template.HTML, error) {
84+ h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, nil)
85+ return h, err
86+}
87+
88+func (m *markdown) renderPolicy(source []byte, pol linkPolicy, notes func(int64) bool) (template.HTML, []Heading, error) {
89+ pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
78 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))90 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
79- pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}91+ pol.source = source
80 // Collect eligible text before the policy unwraps rejected links: their92 // Collect eligible text before the policy unwraps rejected links: their
81 // labels must not accidentally become references either.93 // labels must not accidentally become references either.
82 var refs []*ast.Text94 var refs []*ast.Text
@@ -163,6 +175,20 @@ type linkPolicy struct {
163 source []byte175 source []byte
164 // idPrefix is the prefix of the heading ids that #fragment links point at.176 // idPrefix is the prefix of the heading ids that #fragment links point at.
165 idPrefix string177 idPrefix string
178+ // untrusted is the policy of text from an anonymous visitor.
179+ untrusted bool
180+}
181+
182+const (
183+ relExternal = "nofollow noopener noreferrer"
184+ relUntrusted = "nofollow noopener noreferrer ugc"
185+)
186+
187+func (p linkPolicy) rel() []byte {
188+ if p.untrusted {
189+ return []byte(relUntrusted)
190+ }
191+ return []byte(relExternal)
166 }192 }
167 193
168 func (p linkPolicy) apply(doc ast.Node) {194 func (p linkPolicy) apply(doc ast.Node) {
@@ -195,13 +221,17 @@ func (p linkPolicy) apply(doc ast.Node) {
195 l.Destination = []byte(dest)221 l.Destination = []byte(dest)
196 l.Title = nil222 l.Title = nil
197 if kind == external {223 if kind == external {
198- l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))224+ l.SetAttributeString("rel", p.rel())
199 }225 }
200 }226 }
201 for _, n := range autos {227 for _, n := range autos {
202 a := n.(*ast.AutoLink)228 a := n.(*ast.AutoLink)
203 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {229 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
204- a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))230+ a.SetAttributeString("rel", p.rel())
231+ continue
232+ }
233+ if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
234+ a.SetAttributeString("rel", p.rel())
205 continue235 continue
206 }236 }
207 // An email address or another scheme: the label as plain text.237 // An email address or another scheme: the label as plain text.
@@ -241,6 +271,9 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
241 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {271 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
242 return "", dropped272 return "", dropped
243 }273 }
274+ if p.untrusted {
275+ return untrustedTarget(dest)
276+ }
244 if strings.HasPrefix(dest, "#") {277 if strings.HasPrefix(dest, "#") {
245 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))278 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
246 if frag == "" || strings.ContainsAny(frag, " \"'<>") {279 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
@@ -288,6 +321,39 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
288 return href, internal321 return href, internal
289 }322 }
290 323
324+// untrustedTarget keeps absolute http and https links with a host, and mailto
325+// links to one plain address.
326+func untrustedTarget(dest string) (string, linkKind) {
327+ if strings.HasPrefix(dest, "//") {
328+ return "", dropped
329+ }
330+ u, err := url.Parse(dest)
331+ if err != nil {
332+ return "", dropped
333+ }
334+ switch strings.ToLower(u.Scheme) {
335+ case "http", "https":
336+ if u.Host == "" || u.User != nil {
337+ return "", dropped
338+ }
339+ return u.String(), external
340+ case "mailto":
341+ if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
342+ return "", dropped
343+ }
344+ return "mailto:" + u.Opaque, external
345+ }
346+ return "", dropped
347+}
348+
349+// validMailbox tells whether s is a bare address: no space, quote, angle
350+// bracket, percent sign, comma or second at sign.
351+func validMailbox(s string) bool {
352+ local, host, ok := strings.Cut(s, "@")
353+ return ok && local != "" && host != "" && len(s) <= 254 &&
354+ !strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
355+}
356+
291 func isWebURL(s string) bool {357 func isWebURL(s string) bool {
292 u, err := url.Parse(s)358 u, err := url.Parse(s)
293 if err != nil || u.Host == "" {359 if err != nil || u.Host == "" {
M internal/adapters/web/markdown_test.go +67 -0
@@ -137,6 +137,73 @@ func TestMarkdownGFM(t *testing.T) {
137137 assert.Contains(t, out, `type="checkbox"`)
138138 }
139139
140+func renderUntrustedMD(t *testing.T, src string) string {
141+ t.Helper()
142+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
143+ require.NoError(t, err)
144+ return string(h)
145+}
146+
147+// An issue from the public form is rendered with the strictest policy.
148+func TestMarkdownUntrusted(t *testing.T) {
149+ t.Parallel()
150+ tests := []struct {
151+ name, src string
152+ absent []string
153+ present []string
154+ }{
155+ {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
156+ {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
157+ {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
158+ {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
159+ {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
160+ {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
161+ {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
162+ {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
163+ []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
164+ {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
165+ {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
166+ {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
167+ {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
168+ {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
169+ {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170+ {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
171+ {"image is its alt text", "![pwn](https://evil.test/p.png)\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
172+ {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
173+ {"entity-encoded scheme", "[x](&#106;avascript:alert(1))\n", []string{"avascript:", "href"}, nil},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ t.Parallel()
178+ out := renderUntrustedMD(t, tt.src)
179+ for _, a := range tt.absent {
180+ assert.NotContains(t, out, a)
181+ }
182+ for _, p := range tt.present {
183+ assert.Contains(t, out, p)
184+ }
185+ })
186+ }
187+}
188+
189+func TestMarkdownUntrustedBigInputs(t *testing.T) {
190+ t.Parallel()
191+ long := strings.Repeat("a", 10000)
192+ out := renderUntrustedMD(t, long)
193+ assert.Contains(t, out, long)
194+
195+ rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
196+ out = renderUntrustedMD(t, rows)
197+ assert.NotContains(t, out, "<b>")
198+ assert.NotContains(t, out, "javascript")
199+ assert.NotContains(t, out, "style=")
200+
201+ deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
202+ assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
203+
204+ assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205+}
206+
140207 func TestNoteMarkdown(t *testing.T) {
141208 t.Parallel()
142209 tests := []struct {
@@ -137,6 +137,73 @@ func TestMarkdownGFM(t *testing.T) {
137 assert.Contains(t, out, `type="checkbox"`)137 assert.Contains(t, out, `type="checkbox"`)
138 }138 }
139 139
140+func renderUntrustedMD(t *testing.T, src string) string {
141+ t.Helper()
142+ h, err := newMarkdown().renderUntrusted([]byte(src), "issue-desc-")
143+ require.NoError(t, err)
144+ return string(h)
145+}
146+
147+// An issue from the public form is rendered with the strictest policy.
148+func TestMarkdownUntrusted(t *testing.T) {
149+ t.Parallel()
150+ tests := []struct {
151+ name, src string
152+ absent []string
153+ present []string
154+ }{
155+ {"script", "<script>alert(1)</script>\n", []string{"<script", "alert(1)</"}, nil},
156+ {"img onerror", "a <img src=x onerror=alert(1)> b\n", []string{"<img", "onerror"}, []string{"<p>a "}},
157+ {"javascript link", "[x](javascript:alert(1))\n", []string{"javascript", "href"}, []string{"<p>x</p>"}},
158+ {"data link", "[x](data:text/html,<script>alert(1)</script>)\n", []string{"data:", "href", "<script"}, nil},
159+ {"vbscript and file", "[a](vbscript:x) [b](file:///etc/passwd)\n", []string{"vbscript", "file:", "href"}, []string{"a b"}},
160+ {"relative links lose the link", "[a](../../etc) [b](/proj/blob/main/x) [c](#frag)\n", []string{"href"}, []string{"a b c"}},
161+ {"protocol relative", "[x](//evil.test/p)\n", []string{"href", "evil"}, nil},
162+ {"https is kept with ugc", "[x](https://example.org/p?q=1)\n", nil,
163+ []string{`href="https://example.org/p?q=1"`, `rel="nofollow noopener noreferrer ugc"`}},
164+ {"http is kept", "[x](http://example.org/)\n", nil, []string{`href="http://example.org/"`, "ugc"}},
165+ {"credentials are refused", "[x](https://user:pw@example.org/)\n", []string{"href", "pw"}, nil},
166+ {"mailto is kept", "[x](mailto:a@example.org)\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
167+ {"mailto with query is refused", "[x](mailto:a@example.org?cc=b@example.org)\n", []string{"href"}, nil},
168+ {"autolink url", "<https://example.org/a> and <javascript:alert(1)>\n", []string{"href=\"javascript"}, []string{`rel="nofollow noopener noreferrer ugc"`}},
169+ {"autolink email", "<a@example.org>\n", nil, []string{`href="mailto:a@example.org"`, "ugc"}},
170+ {"bare url (GFM)", "see https://example.org/x now\n", nil, []string{`href="https://example.org/x"`, "ugc"}},
171+ {"image is its alt text", "![pwn](https://evil.test/p.png)\n", []string{"<img", "evil.test"}, []string{`<span class="alt">[pwn]</span>`}},
172+ {"reference link to javascript", "[x][r]\n\n[r]: javascript:alert(1)\n", []string{"javascript", "href"}, nil},
173+ {"entity-encoded scheme", "[x](&#106;avascript:alert(1))\n", []string{"avascript:", "href"}, nil},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ t.Parallel()
178+ out := renderUntrustedMD(t, tt.src)
179+ for _, a := range tt.absent {
180+ assert.NotContains(t, out, a)
181+ }
182+ for _, p := range tt.present {
183+ assert.Contains(t, out, p)
184+ }
185+ })
186+ }
187+}
188+
189+func TestMarkdownUntrustedBigInputs(t *testing.T) {
190+ t.Parallel()
191+ long := strings.Repeat("a", 10000)
192+ out := renderUntrustedMD(t, long)
193+ assert.Contains(t, out, long)
194+
195+ rows := "| a | b |\n|---|---|\n" + strings.Repeat("| <b>x</b> | [l](javascript:1) |\n", 500)
196+ out = renderUntrustedMD(t, rows)
197+ assert.NotContains(t, out, "<b>")
198+ assert.NotContains(t, out, "javascript")
199+ assert.NotContains(t, out, "style=")
200+
201+ deep := strings.Repeat("> ", 200) + "[x](javascript:1)\n"
202+ assert.NotContains(t, renderUntrustedMD(t, deep), "javascript")
203+
204+ assert.NotContains(t, renderUntrustedMD(t, "[x](https://a.test/\"onmouseover=\"alert(1))\n"), "onmouseover=\"")
205+}
206+
140 func TestNoteMarkdown(t *testing.T) {207 func TestNoteMarkdown(t *testing.T) {
141 t.Parallel()208 t.Parallel()
142 tests := []struct {209 tests := []struct {
M internal/adapters/web/mr.go +13 -8
@@ -101,12 +101,15 @@ type MRListPage struct {
101101
102102 // CommentView is a comment of a merge request, ready to show.
103103 type CommentView struct {
104- ID string
105- Author string
106- When string
107- WhenAt string
108- Abs string
109- Body, BodySplit template.HTML
104+ ID string
105+ Author string
106+ When string
107+ WhenAt string
108+ Abs string
109+ Body, BodySplit template.HTML
110+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
110113 Replies []CommentView
111114 Edited, Deleted bool
112115 Verdict, Range, Quote string
@@ -156,8 +159,10 @@ type CommentForm struct {
156159 // Path, Line and Side designate the line; empty for a general comment.
157160 Path, Line, Side string
158161 StartLine, ReplyTo string
159- Cancel string
160- Focus bool
162+ // Plain is a form of an issue comment: plain text, no verdict.
163+ Plain bool
164+ Cancel string
165+ Focus bool
161166 // From is the window of the page the form is on (see mrWindow), sent
162167 // back so that the answer returns to it; 0 for the first.
163168 From int
@@ -101,12 +101,15 @@ type MRListPage struct {
101 101
102 // CommentView is a comment of a merge request, ready to show.102 // CommentView is a comment of a merge request, ready to show.
103 type CommentView struct {103 type CommentView struct {
104- ID string104+ ID string
105- Author string105+ Author string
106- When string106+ When string
107- WhenAt string107+ WhenAt string
108- Abs string108+ Abs string
109- Body, BodySplit template.HTML109+ Body, BodySplit template.HTML
110+ // Parts is a plain-text body (an issue comment); Body is the Markdown of
111+ // a merge request note.
112+ Parts []TextPart
110 Replies []CommentView113 Replies []CommentView
111 Edited, Deleted bool114 Edited, Deleted bool
112 Verdict, Range, Quote string115 Verdict, Range, Quote string
@@ -156,8 +159,10 @@ type CommentForm struct {
156 // Path, Line and Side designate the line; empty for a general comment.159 // Path, Line and Side designate the line; empty for a general comment.
157 Path, Line, Side string160 Path, Line, Side string
158 StartLine, ReplyTo string161 StartLine, ReplyTo string
159- Cancel string162+ // Plain is a form of an issue comment: plain text, no verdict.
160- Focus bool163+ Plain bool
164+ Cancel string
165+ Focus bool
161 // From is the window of the page the form is on (see mrWindow), sent166 // From is the window of the page the form is on (see mrWindow), sent
162 // back so that the answer returns to it; 0 for the first.167 // back so that the answer returns to it; 0 for the first.
163 From int168 From int
M internal/adapters/web/mr.templ +10 -3
@@ -247,7 +247,9 @@ templ commentView(c CommentView, suffix string) {
247247 { " " }<span class="c-where mute">{ c.Where }</span>
248248 }
249249 </p>
250- if c.Deleted {
250+ if len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
251253 if c.Verdict != "" {
252254 <p class="mute">text deleted</p>
253255 } else {
@@ -316,7 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
316318 name="text"
317319 rows="3"
318320 maxlength="8000"
319- placeholder="Markdown, 8000 characters at most"
321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
320327 if f.Focus {
321328 autofocus
322329 }
@@ -330,7 +337,7 @@ templ commentForm(f CommentForm, suffix string) {
330337 if f.ReplyTo != "" {
331338 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332339 }
333- if f.Path == "" && f.ReplyTo == "" {
340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
334341 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
335342 }
336343 if f.From > 0 {
@@ -247,7 +247,9 @@ templ commentView(c CommentView, suffix string) {
247 { " " }<span class="c-where mute">{ c.Where }</span>247 { " " }<span class="c-where mute">{ c.Where }</span>
248 }248 }
249 </p>249 </p>
250- if c.Deleted {250+ if len(c.Parts) > 0 {
251+ <p class="c-text">@userText(c.Parts)</p>
252+ } else if c.Deleted {
251 if c.Verdict != "" {253 if c.Verdict != "" {
252 <p class="mute">text deleted</p>254 <p class="mute">text deleted</p>
253 } else {255 } else {
@@ -316,7 +318,12 @@ templ commentForm(f CommentForm, suffix string) {
316 name="text"318 name="text"
317 rows="3"319 rows="3"
318 maxlength="8000"320 maxlength="8000"
319- placeholder="Markdown, 8000 characters at most"321+ if f.Plain {
322+ required
323+ placeholder="plain text, 8000 characters at most"
324+ } else {
325+ placeholder="Markdown, 8000 characters at most"
326+ }
320 if f.Focus {327 if f.Focus {
321 autofocus328 autofocus
322 }329 }
@@ -330,7 +337,7 @@ templ commentForm(f CommentForm, suffix string) {
330 if f.ReplyTo != "" {337 if f.ReplyTo != "" {
331 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>338 <input type="hidden" name="reply_to" value={ f.ReplyTo }/>
332 }339 }
333- if f.Path == "" && f.ReplyTo == "" {340+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
334 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>341 <fieldset><legend>Verdict</legend><label><input type="radio" name="verdict" value="" checked/>Note</label><label><input type="radio" name="verdict" value="approve"/>Approve</label><label><input type="radio" name="verdict" value="changes_requested"/>Request changes</label></fieldset>
335 }342 }
336 if f.From > 0 {343 if f.From > 0 {
M internal/adapters/web/mr_post.go +8 -1
@@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10
3131 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
3232 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
3333 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))
34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
38+ if strings.Contains(r.URL.Path, "/issues") {
39+ back, what = issuesBase(r.PathValue("repo")), "an issue"
40+ }
41+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
3542 return false
3643 }
3744 return true
@@ -31,7 +31,14 @@ const maxFormBytes = 128 << 10
31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
33 if err := r.ParseForm(); err != nil {33 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
38+ if strings.Contains(r.URL.Path, "/issues") {
39+ back, what = issuesBase(r.PathValue("repo")), "an issue"
40+ }
41+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
35 return false42 return false
36 }43 }
37 return true44 return true
M internal/adapters/web/mr_templ.go +289 -265
@@ -1406,20 +1406,33 @@ func commentView(c CommentView, suffix string) templ.Component {
14061406 if templ_7745c5c3_Err != nil {
14071407 return templ_7745c5c3_Err
14081408 }
1409- if c.Deleted {
1409+ if len(c.Parts) > 0 {
1410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"c-text\">")
1411+ if templ_7745c5c3_Err != nil {
1412+ return templ_7745c5c3_Err
1413+ }
1414+ templ_7745c5c3_Err = userText(c.Parts).Render(ctx, templ_7745c5c3_Buffer)
1415+ if templ_7745c5c3_Err != nil {
1416+ return templ_7745c5c3_Err
1417+ }
1418+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "</p>")
1419+ if templ_7745c5c3_Err != nil {
1420+ return templ_7745c5c3_Err
1421+ }
1422+ } else if c.Deleted {
14101423 if c.Verdict != "" {
1411- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"mute\">text deleted</p>")
1424+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<p class=\"mute\">text deleted</p>")
14121425 if templ_7745c5c3_Err != nil {
14131426 return templ_7745c5c3_Err
14141427 }
14151428 } else {
1416- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<p class=\"mute\">deleted note</p>")
1429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<p class=\"mute\">deleted note</p>")
14171430 if templ_7745c5c3_Err != nil {
14181431 return templ_7745c5c3_Err
14191432 }
14201433 }
14211434 } else {
1422- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<div class=\"c-body prose\">")
1435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<div class=\"c-body prose\">")
14231436 if templ_7745c5c3_Err != nil {
14241437 return templ_7745c5c3_Err
14251438 }
@@ -1434,147 +1447,147 @@ func commentView(c CommentView, suffix string) templ.Component {
14341447 return templ_7745c5c3_Err
14351448 }
14361449 }
1437- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</div>")
1450+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</div>")
14381451 if templ_7745c5c3_Err != nil {
14391452 return templ_7745c5c3_Err
14401453 }
14411454 }
14421455 if c.Edited {
1443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<span class=\"mute\">edited</span> ")
1456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<span class=\"mute\">edited</span> ")
14441457 if templ_7745c5c3_Err != nil {
14451458 return templ_7745c5c3_Err
14461459 }
14471460 }
14481461 if c.Verdict != "" {
1449- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<span class=\"c-state\">")
1462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<span class=\"c-state\">")
14501463 if templ_7745c5c3_Err != nil {
14511464 return templ_7745c5c3_Err
14521465 }
14531466 var templ_7745c5c3_Var70 string
14541467 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(c.Verdict)
14551468 if templ_7745c5c3_Err != nil {
1456- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 269, Col: 36}
1469+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 271, Col: 36}
14571470 }
14581471 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
14591472 if templ_7745c5c3_Err != nil {
14601473 return templ_7745c5c3_Err
14611474 }
1462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</span> ")
1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span> ")
14631476 if templ_7745c5c3_Err != nil {
14641477 return templ_7745c5c3_Err
14651478 }
14661479 }
14671480 if c.Range != "" {
1468- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<span class=\"mute\">")
1481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<span class=\"mute\">")
14691482 if templ_7745c5c3_Err != nil {
14701483 return templ_7745c5c3_Err
14711484 }
14721485 var templ_7745c5c3_Var71 string
14731486 templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.Range)
14741487 if templ_7745c5c3_Err != nil {
1475- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 272, Col: 31}
1488+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 31}
14761489 }
14771490 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
14781491 if templ_7745c5c3_Err != nil {
14791492 return templ_7745c5c3_Err
14801493 }
1481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span> ")
1494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</span> ")
14821495 if templ_7745c5c3_Err != nil {
14831496 return templ_7745c5c3_Err
14841497 }
14851498 }
14861499 if c.Quote != "" {
1487- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<button type=\"button\" class=\"btn\" data-js data-quote=\"")
1500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<button type=\"button\" class=\"btn\" data-js data-quote=\"")
14881501 if templ_7745c5c3_Err != nil {
14891502 return templ_7745c5c3_Err
14901503 }
14911504 var templ_7745c5c3_Var72 string
14921505 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Quote)
14931506 if templ_7745c5c3_Err != nil {
1494- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 65}
1507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 277, Col: 65}
14951508 }
14961509 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
14971510 if templ_7745c5c3_Err != nil {
14981511 return templ_7745c5c3_Err
14991512 }
1500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\">Quote</button> ")
1513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\">Quote</button> ")
15011514 if templ_7745c5c3_Err != nil {
15021515 return templ_7745c5c3_Err
15031516 }
15041517 }
15051518 if c.Edit != nil {
1506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<details><summary>Edit</summary><form method=\"post\" action=\"")
1519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<details><summary>Edit</summary><form method=\"post\" action=\"")
15071520 if templ_7745c5c3_Err != nil {
15081521 return templ_7745c5c3_Err
15091522 }
15101523 var templ_7745c5c3_Var73 templ.SafeURL
15111524 templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Edit.Action))
15121525 if templ_7745c5c3_Err != nil {
1513- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 92}
1526+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 92}
15141527 }
15151528 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
15161529 if templ_7745c5c3_Err != nil {
15171530 return templ_7745c5c3_Err
15181531 }
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\"><input type=\"hidden\" name=\"from\" value=\"")
1532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "\"><input type=\"hidden\" name=\"from\" value=\"")
15201533 if templ_7745c5c3_Err != nil {
15211534 return templ_7745c5c3_Err
15221535 }
15231536 var templ_7745c5c3_Var74 string
15241537 templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Edit.From))
15251538 if templ_7745c5c3_Err != nil {
1526- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 153}
1539+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 153}
15271540 }
15281541 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
15291542 if templ_7745c5c3_Err != nil {
15301543 return templ_7745c5c3_Err
15311544 }
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "\"><textarea name=\"text\" maxlength=\"8000\">")
1545+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\"><textarea name=\"text\" maxlength=\"8000\">")
15331546 if templ_7745c5c3_Err != nil {
15341547 return templ_7745c5c3_Err
15351548 }
15361549 var templ_7745c5c3_Var75 string
15371550 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)
15381551 if templ_7745c5c3_Err != nil {
1539- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 209}
1552+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 209}
15401553 }
15411554 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
15421555 if templ_7745c5c3_Err != nil {
15431556 return templ_7745c5c3_Err
15441557 }
1545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</textarea><button class=\"btn\" type=\"submit\">Save</button></form></details> ")
1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</textarea><button class=\"btn\" type=\"submit\">Save</button></form></details> ")
15461559 if templ_7745c5c3_Err != nil {
15471560 return templ_7745c5c3_Err
15481561 }
15491562 }
15501563 if c.Delete != nil {
1551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<details><summary>Delete</summary><form method=\"post\" action=\"")
1564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<details><summary>Delete</summary><form method=\"post\" action=\"")
15521565 if templ_7745c5c3_Err != nil {
15531566 return templ_7745c5c3_Err
15541567 }
15551568 var templ_7745c5c3_Var76 templ.SafeURL
15561569 templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Delete.Action))
15571570 if templ_7745c5c3_Err != nil {
1558- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 281, Col: 96}
1571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 96}
15591572 }
15601573 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
15611574 if templ_7745c5c3_Err != nil {
15621575 return templ_7745c5c3_Err
15631576 }
1564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\"><input type=\"hidden\" name=\"from\" value=\"")
1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\"><input type=\"hidden\" name=\"from\" value=\"")
15651578 if templ_7745c5c3_Err != nil {
15661579 return templ_7745c5c3_Err
15671580 }
15681581 var templ_7745c5c3_Var77 string
15691582 templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Delete.From))
15701583 if templ_7745c5c3_Err != nil {
1571- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 281, Col: 159}
1584+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 159}
15721585 }
15731586 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
15741587 if templ_7745c5c3_Err != nil {
15751588 return templ_7745c5c3_Err
15761589 }
1577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\"><p>Delete this note?</p><button class=\"btn\" type=\"submit\">Confirm delete</button></form></details> ")
1590+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><p>Delete this note?</p><button class=\"btn\" type=\"submit\">Confirm delete</button></form></details> ")
15781591 if templ_7745c5c3_Err != nil {
15791592 return templ_7745c5c3_Err
15801593 }
@@ -1586,7 +1599,7 @@ func commentView(c CommentView, suffix string) templ.Component {
15861599 }
15871600 }
15881601 if c.Reply != nil {
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<details data-reply><summary>Reply</summary>")
1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<details data-reply><summary>Reply</summary>")
15901603 if templ_7745c5c3_Err != nil {
15911604 return templ_7745c5c3_Err
15921605 }
@@ -1594,80 +1607,80 @@ func commentView(c CommentView, suffix string) templ.Component {
15941607 if templ_7745c5c3_Err != nil {
15951608 return templ_7745c5c3_Err
15961609 }
1597- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</details> ")
1610+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</details> ")
15981611 if templ_7745c5c3_Err != nil {
15991612 return templ_7745c5c3_Err
16001613 }
16011614 }
16021615 if c.Resolve != nil {
1603- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<form class=\"c-act\" method=\"post\" action=\"")
1616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<form class=\"c-act\" method=\"post\" action=\"")
16041617 if templ_7745c5c3_Err != nil {
16051618 return templ_7745c5c3_Err
16061619 }
16071620 var templ_7745c5c3_Var78 templ.SafeURL
16081621 templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
16091622 if templ_7745c5c3_Err != nil {
1610- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 292, Col: 77}
1623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 294, Col: 77}
16111624 }
16121625 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
16131626 if templ_7745c5c3_Err != nil {
16141627 return templ_7745c5c3_Err
16151628 }
1616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
1629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
16171630 if templ_7745c5c3_Err != nil {
16181631 return templ_7745c5c3_Err
16191632 }
16201633 var templ_7745c5c3_Var79 string
16211634 templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
16221635 if templ_7745c5c3_Err != nil {
1623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 293, Col: 74}
1636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 74}
16241637 }
16251638 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
16261639 if templ_7745c5c3_Err != nil {
16271640 return templ_7745c5c3_Err
16281641 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"> ")
1642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\"> ")
16301643 if templ_7745c5c3_Err != nil {
16311644 return templ_7745c5c3_Err
16321645 }
16331646 if c.Resolve.From > 0 {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<input type=\"hidden\" name=\"from\" value=\"")
1647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<input type=\"hidden\" name=\"from\" value=\"")
16351648 if templ_7745c5c3_Err != nil {
16361649 return templ_7745c5c3_Err
16371650 }
16381651 var templ_7745c5c3_Var80 string
16391652 templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
16401653 if templ_7745c5c3_Err != nil {
1641- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 66}
1654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 66}
16421655 }
16431656 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
16441657 if templ_7745c5c3_Err != nil {
16451658 return templ_7745c5c3_Err
16461659 }
1647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\"> ")
1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "\"> ")
16481661 if templ_7745c5c3_Err != nil {
16491662 return templ_7745c5c3_Err
16501663 }
16511664 }
1652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<button type=\"submit\" class=\"btn\">")
1665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<button type=\"submit\" class=\"btn\">")
16531666 if templ_7745c5c3_Err != nil {
16541667 return templ_7745c5c3_Err
16551668 }
16561669 var templ_7745c5c3_Var81 string
16571670 templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
16581671 if templ_7745c5c3_Err != nil {
1659- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 55}
1672+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 299, Col: 55}
16601673 }
16611674 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
16621675 if templ_7745c5c3_Err != nil {
16631676 return templ_7745c5c3_Err
16641677 }
1665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</button></form>")
1678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</button></form>")
16661679 if templ_7745c5c3_Err != nil {
16671680 return templ_7745c5c3_Err
16681681 }
16691682 }
1670- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</div>")
1683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</div>")
16711684 if templ_7745c5c3_Err != nil {
16721685 return templ_7745c5c3_Err
16731686 }
@@ -1704,198 +1717,209 @@ func commentForm(f CommentForm, suffix string) templ.Component {
17041717 templ_7745c5c3_Var82 = templ.NopComponent
17051718 }
17061719 ctx = templ.ClearChildren(ctx)
1707- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<form class=\"cform\" method=\"post\" action=\"")
1720+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<form class=\"cform\" method=\"post\" action=\"")
17081721 if templ_7745c5c3_Err != nil {
17091722 return templ_7745c5c3_Err
17101723 }
17111724 var templ_7745c5c3_Var83 templ.SafeURL
17121725 templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
17131726 if templ_7745c5c3_Err != nil {
1714- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 312, Col: 67}
1727+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 67}
17151728 }
17161729 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
17171730 if templ_7745c5c3_Err != nil {
17181731 return templ_7745c5c3_Err
17191732 }
1720- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\"><label for=\"")
1733+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\"><label for=\"")
17211734 if templ_7745c5c3_Err != nil {
17221735 return templ_7745c5c3_Err
17231736 }
17241737 var templ_7745c5c3_Var84 string
17251738 templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
17261739 if templ_7745c5c3_Err != nil {
1727- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 313, Col: 28}
1740+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 28}
17281741 }
17291742 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
17301743 if templ_7745c5c3_Err != nil {
17311744 return templ_7745c5c3_Err
17321745 }
1733- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\">")
1746+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\">")
17341747 if templ_7745c5c3_Err != nil {
17351748 return templ_7745c5c3_Err
17361749 }
17371750 var templ_7745c5c3_Var85 string
17381751 templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
17391752 if templ_7745c5c3_Err != nil {
1740- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 313, Col: 40}
1753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 40}
17411754 }
17421755 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
17431756 if templ_7745c5c3_Err != nil {
17441757 return templ_7745c5c3_Err
17451758 }
1746- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</label> <textarea id=\"")
1759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</label> <textarea id=\"")
17471760 if templ_7745c5c3_Err != nil {
17481761 return templ_7745c5c3_Err
17491762 }
17501763 var templ_7745c5c3_Var86 string
17511764 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
17521765 if templ_7745c5c3_Err != nil {
1753- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 21}
1766+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 317, Col: 21}
17541767 }
17551768 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
17561769 if templ_7745c5c3_Err != nil {
17571770 return templ_7745c5c3_Err
17581771 }
1759- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" name=\"text\" rows=\"3\" maxlength=\"8000\" placeholder=\"Markdown, 8000 characters at most\"")
1772+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\" name=\"text\" rows=\"3\" maxlength=\"8000\"")
17601773 if templ_7745c5c3_Err != nil {
17611774 return templ_7745c5c3_Err
17621775 }
1776+ if f.Plain {
1777+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, " required placeholder=\"plain text, 8000 characters at most\"")
1778+ if templ_7745c5c3_Err != nil {
1779+ return templ_7745c5c3_Err
1780+ }
1781+ } else {
1782+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " placeholder=\"Markdown, 8000 characters at most\"")
1783+ if templ_7745c5c3_Err != nil {
1784+ return templ_7745c5c3_Err
1785+ }
1786+ }
17631787 if f.Focus {
1764- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, " autofocus")
1788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, " autofocus")
17651789 if templ_7745c5c3_Err != nil {
17661790 return templ_7745c5c3_Err
17671791 }
17681792 }
1769- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "></textarea> ")
1793+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "></textarea> ")
17701794 if templ_7745c5c3_Err != nil {
17711795 return templ_7745c5c3_Err
17721796 }
17731797 if f.Path != "" {
1774- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<input type=\"hidden\" name=\"path\" value=\"")
1798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<input type=\"hidden\" name=\"path\" value=\"")
17751799 if templ_7745c5c3_Err != nil {
17761800 return templ_7745c5c3_Err
17771801 }
17781802 var templ_7745c5c3_Var87 string
17791803 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
17801804 if templ_7745c5c3_Err != nil {
1781- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 325, Col: 50}
1805+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 332, Col: 50}
17821806 }
17831807 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
17841808 if templ_7745c5c3_Err != nil {
17851809 return templ_7745c5c3_Err
17861810 }
1787- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "\"> <input type=\"hidden\" name=\"line\" value=\"")
1811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"> <input type=\"hidden\" name=\"line\" value=\"")
17881812 if templ_7745c5c3_Err != nil {
17891813 return templ_7745c5c3_Err
17901814 }
17911815 var templ_7745c5c3_Var88 string
17921816 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
17931817 if templ_7745c5c3_Err != nil {
1794- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 50}
1818+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 333, Col: 50}
17951819 }
17961820 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
17971821 if templ_7745c5c3_Err != nil {
17981822 return templ_7745c5c3_Err
17991823 }
1800- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\"> <input type=\"hidden\" name=\"side\" value=\"")
1824+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"> <input type=\"hidden\" name=\"side\" value=\"")
18011825 if templ_7745c5c3_Err != nil {
18021826 return templ_7745c5c3_Err
18031827 }
18041828 var templ_7745c5c3_Var89 string
18051829 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
18061830 if templ_7745c5c3_Err != nil {
1807- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 50}
1831+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 50}
18081832 }
18091833 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var89)
18101834 if templ_7745c5c3_Err != nil {
18111835 return templ_7745c5c3_Err
18121836 }
1813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\"> <label>from line <input type=\"number\" name=\"start_line\" min=\"1\" value=\"")
1837+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"> <label>from line <input type=\"number\" name=\"start_line\" min=\"1\" value=\"")
18141838 if templ_7745c5c3_Err != nil {
18151839 return templ_7745c5c3_Err
18161840 }
18171841 var templ_7745c5c3_Var90 string
18181842 templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.StartLine)
18191843 if templ_7745c5c3_Err != nil {
1820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 328, Col: 86}
1844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 86}
18211845 }
18221846 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
18231847 if templ_7745c5c3_Err != nil {
18241848 return templ_7745c5c3_Err
18251849 }
1826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "\"></label> ")
1850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"></label> ")
18271851 if templ_7745c5c3_Err != nil {
18281852 return templ_7745c5c3_Err
18291853 }
18301854 }
18311855 if f.ReplyTo != "" {
1832- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<input type=\"hidden\" name=\"reply_to\" value=\"")
1856+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<input type=\"hidden\" name=\"reply_to\" value=\"")
18331857 if templ_7745c5c3_Err != nil {
18341858 return templ_7745c5c3_Err
18351859 }
18361860 var templ_7745c5c3_Var91 string
18371861 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ReplyTo)
18381862 if templ_7745c5c3_Err != nil {
1839- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 57}
1863+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 57}
18401864 }
18411865 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
18421866 if templ_7745c5c3_Err != nil {
18431867 return templ_7745c5c3_Err
18441868 }
1845- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"> ")
1869+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "\"> ")
18461870 if templ_7745c5c3_Err != nil {
18471871 return templ_7745c5c3_Err
18481872 }
18491873 }
1850- if f.Path == "" && f.ReplyTo == "" {
1851- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<fieldset><legend>Verdict</legend><label><input type=\"radio\" name=\"verdict\" value=\"\" checked>Note</label><label><input type=\"radio\" name=\"verdict\" value=\"approve\">Approve</label><label><input type=\"radio\" name=\"verdict\" value=\"changes_requested\">Request changes</label></fieldset>")
1874+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
1875+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<fieldset><legend>Verdict</legend><label><input type=\"radio\" name=\"verdict\" value=\"\" checked>Note</label><label><input type=\"radio\" name=\"verdict\" value=\"approve\">Approve</label><label><input type=\"radio\" name=\"verdict\" value=\"changes_requested\">Request changes</label></fieldset>")
18521876 if templ_7745c5c3_Err != nil {
18531877 return templ_7745c5c3_Err
18541878 }
18551879 }
18561880 if f.From > 0 {
1857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "<input type=\"hidden\" name=\"from\" value=\"")
1881+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "<input type=\"hidden\" name=\"from\" value=\"")
18581882 if templ_7745c5c3_Err != nil {
18591883 return templ_7745c5c3_Err
18601884 }
18611885 var templ_7745c5c3_Var92 string
18621886 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
18631887 if templ_7745c5c3_Err != nil {
1864- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 337, Col: 56}
1888+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 56}
18651889 }
18661890 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
18671891 if templ_7745c5c3_Err != nil {
18681892 return templ_7745c5c3_Err
18691893 }
1870- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "\">")
1894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\">")
18711895 if templ_7745c5c3_Err != nil {
18721896 return templ_7745c5c3_Err
18731897 }
18741898 }
1875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
1899+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
18761900 if templ_7745c5c3_Err != nil {
18771901 return templ_7745c5c3_Err
18781902 }
18791903 if f.Cancel != "" {
1880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<a class=\"btn\" href=\"")
1904+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<a class=\"btn\" href=\"")
18811905 if templ_7745c5c3_Err != nil {
18821906 return templ_7745c5c3_Err
18831907 }
18841908 var templ_7745c5c3_Var93 templ.SafeURL
18851909 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
18861910 if templ_7745c5c3_Err != nil {
1887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 342, Col: 49}
1911+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 349, Col: 49}
18881912 }
18891913 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
18901914 if templ_7745c5c3_Err != nil {
18911915 return templ_7745c5c3_Err
18921916 }
1893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\">Cancel</a>")
1917+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "\">Cancel</a>")
18941918 if templ_7745c5c3_Err != nil {
18951919 return templ_7745c5c3_Err
18961920 }
18971921 }
1898- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</div></form>")
1922+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</div></form>")
18991923 if templ_7745c5c3_Err != nil {
19001924 return templ_7745c5c3_Err
19011925 }
@@ -1924,20 +1948,20 @@ func mrRail(p MRPage) templ.Component {
19241948 templ_7745c5c3_Var94 = templ.NopComponent
19251949 }
19261950 ctx = templ.ClearChildren(ctx)
1927- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
1951+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
19281952 if templ_7745c5c3_Err != nil {
19291953 return templ_7745c5c3_Err
19301954 }
19311955 var templ_7745c5c3_Var95 string
19321956 templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
19331957 if templ_7745c5c3_Err != nil {
1934- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 351, Col: 49}
1958+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 358, Col: 49}
19351959 }
19361960 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
19371961 if templ_7745c5c3_Err != nil {
19381962 return templ_7745c5c3_Err
19391963 }
1940- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>")
1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>")
19411965 if templ_7745c5c3_Err != nil {
19421966 return templ_7745c5c3_Err
19431967 }
@@ -1945,71 +1969,71 @@ func mrRail(p MRPage) templ.Component {
19451969 if templ_7745c5c3_Err != nil {
19461970 return templ_7745c5c3_Err
19471971 }
1948- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</section>")
1972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</section>")
19491973 if templ_7745c5c3_Err != nil {
19501974 return templ_7745c5c3_Err
19511975 }
19521976 if len(p.Verdicts) > 0 {
1953- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<section><h2>Verdicts</h2><ul>")
1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<section><h2>Verdicts</h2><ul>")
19541978 if templ_7745c5c3_Err != nil {
19551979 return templ_7745c5c3_Err
19561980 }
19571981 for _, v := range p.Verdicts {
1958- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<li><a href=\"")
1982+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<li><a href=\"")
19591983 if templ_7745c5c3_Err != nil {
19601984 return templ_7745c5c3_Err
19611985 }
19621986 var templ_7745c5c3_Var96 templ.SafeURL
19631987 templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(v.Href))
19641988 if templ_7745c5c3_Err != nil {
1965- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 37}
1989+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 37}
19661990 }
19671991 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
19681992 if templ_7745c5c3_Err != nil {
19691993 return templ_7745c5c3_Err
19701994 }
1971- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "\">")
1995+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "\">")
19721996 if templ_7745c5c3_Err != nil {
19731997 return templ_7745c5c3_Err
19741998 }
19751999 var templ_7745c5c3_Var97 string
19762000 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(v.Author)
19772001 if templ_7745c5c3_Err != nil {
1978- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 50}
2002+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 50}
19792003 }
19802004 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19812005 if templ_7745c5c3_Err != nil {
19822006 return templ_7745c5c3_Err
19832007 }
1984- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, ": ")
2008+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, ": ")
19852009 if templ_7745c5c3_Err != nil {
19862010 return templ_7745c5c3_Err
19872011 }
19882012 var templ_7745c5c3_Var98 string
19892013 templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
19902014 if templ_7745c5c3_Err != nil {
1991- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 63}
2015+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 63}
19922016 }
19932017 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
19942018 if templ_7745c5c3_Err != nil {
19952019 return templ_7745c5c3_Err
19962020 }
1997- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</a> ")
2021+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</a> ")
19982022 if templ_7745c5c3_Err != nil {
19992023 return templ_7745c5c3_Err
20002024 }
20012025 if v.Stale {
2002- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<span class=\"mute\">(stale)</span>")
2026+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<span class=\"mute\">(stale)</span>")
20032027 if templ_7745c5c3_Err != nil {
20042028 return templ_7745c5c3_Err
20052029 }
20062030 }
2007- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</li>")
2031+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</li>")
20082032 if templ_7745c5c3_Err != nil {
20092033 return templ_7745c5c3_Err
20102034 }
20112035 }
2012- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</ul></section>")
2036+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</ul></section>")
20132037 if templ_7745c5c3_Err != nil {
20142038 return templ_7745c5c3_Err
20152039 }
@@ -2018,74 +2042,74 @@ func mrRail(p MRPage) templ.Component {
20182042 if templ_7745c5c3_Err != nil {
20192043 return templ_7745c5c3_Err
20202044 }
2021- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
20222046 if templ_7745c5c3_Err != nil {
20232047 return templ_7745c5c3_Err
20242048 }
20252049 var templ_7745c5c3_Var99 string
20262050 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
20272051 if templ_7745c5c3_Err != nil {
2028- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 375, Col: 71}
2052+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 382, Col: 71}
20292053 }
20302054 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
20312055 if templ_7745c5c3_Err != nil {
20322056 return templ_7745c5c3_Err
20332057 }
2034- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</span></h2><ul class=\"pcommits\">")
2058+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "</span></h2><ul class=\"pcommits\">")
20352059 if templ_7745c5c3_Err != nil {
20362060 return templ_7745c5c3_Err
20372061 }
20382062 for _, c := range p.Commits {
2039- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<li")
2063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<li")
20402064 if templ_7745c5c3_Err != nil {
20412065 return templ_7745c5c3_Err
20422066 }
20432067 if c.New {
2044- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, " data-new")
2068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, " data-new")
20452069 if templ_7745c5c3_Err != nil {
20462070 return templ_7745c5c3_Err
20472071 }
20482072 }
2049- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "><a href=\"")
2073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "><a href=\"")
20502074 if templ_7745c5c3_Err != nil {
20512075 return templ_7745c5c3_Err
20522076 }
20532077 var templ_7745c5c3_Var100 templ.SafeURL
20542078 templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
20552079 if templ_7745c5c3_Err != nil {
2056- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 379, Col: 36}
2080+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 36}
20572081 }
20582082 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
20592083 if templ_7745c5c3_Err != nil {
20602084 return templ_7745c5c3_Err
20612085 }
2062- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\">")
2086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\">")
20632087 if templ_7745c5c3_Err != nil {
20642088 return templ_7745c5c3_Err
20652089 }
20662090 var templ_7745c5c3_Var101 string
20672091 templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
20682092 if templ_7745c5c3_Err != nil {
2069- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 379, Col: 48}
2093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 48}
20702094 }
20712095 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
20722096 if templ_7745c5c3_Err != nil {
20732097 return templ_7745c5c3_Err
20742098 }
2075- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a> <span class=\"s\">")
2099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</a> <span class=\"s\">")
20762100 if templ_7745c5c3_Err != nil {
20772101 return templ_7745c5c3_Err
20782102 }
20792103 var templ_7745c5c3_Var102 string
20802104 templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
20812105 if templ_7745c5c3_Err != nil {
2082- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 380, Col: 32}
2106+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 32}
20832107 }
20842108 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
20852109 if templ_7745c5c3_Err != nil {
20862110 return templ_7745c5c3_Err
20872111 }
2088- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span> ")
2112+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "</span> ")
20892113 if templ_7745c5c3_Err != nil {
20902114 return templ_7745c5c3_Err
20912115 }
@@ -2095,20 +2119,20 @@ func mrRail(p MRPage) templ.Component {
20952119 return templ_7745c5c3_Err
20962120 }
20972121 }
2098- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<span class=\"mute\">")
2122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "<span class=\"mute\">")
20992123 if templ_7745c5c3_Err != nil {
21002124 return templ_7745c5c3_Err
21012125 }
21022126 var templ_7745c5c3_Var103 string
21032127 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
21042128 if templ_7745c5c3_Err != nil {
2105- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 385, Col: 16}
2129+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 392, Col: 16}
21062130 }
21072131 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
21082132 if templ_7745c5c3_Err != nil {
21092133 return templ_7745c5c3_Err
21102134 }
2111- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, " ")
2135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, " ")
21122136 if templ_7745c5c3_Err != nil {
21132137 return templ_7745c5c3_Err
21142138 }
@@ -2116,39 +2140,39 @@ func mrRail(p MRPage) templ.Component {
21162140 var templ_7745c5c3_Var104 string
21172141 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
21182142 if templ_7745c5c3_Err != nil {
2119- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 15}
2143+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 15}
21202144 }
21212145 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
21222146 if templ_7745c5c3_Err != nil {
21232147 return templ_7745c5c3_Err
21242148 }
2125- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<time datetime=\"")
2149+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "<time datetime=\"")
21262150 if templ_7745c5c3_Err != nil {
21272151 return templ_7745c5c3_Err
21282152 }
21292153 var templ_7745c5c3_Var105 string
21302154 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
21312155 if templ_7745c5c3_Err != nil {
2132- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 42}
2156+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 42}
21332157 }
21342158 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
21352159 if templ_7745c5c3_Err != nil {
21362160 return templ_7745c5c3_Err
21372161 }
2138- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\">")
2162+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "\">")
21392163 if templ_7745c5c3_Err != nil {
21402164 return templ_7745c5c3_Err
21412165 }
21422166 var templ_7745c5c3_Var106 string
21432167 templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
21442168 if templ_7745c5c3_Err != nil {
2145- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 53}
2169+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 53}
21462170 }
21472171 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
21482172 if templ_7745c5c3_Err != nil {
21492173 return templ_7745c5c3_Err
21502174 }
2151- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</time> ")
2175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "</time> ")
21522176 if templ_7745c5c3_Err != nil {
21532177 return templ_7745c5c3_Err
21542178 }
@@ -2157,76 +2181,76 @@ func mrRail(p MRPage) templ.Component {
21572181 var templ_7745c5c3_Var107 string
21582182 templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
21592183 if templ_7745c5c3_Err != nil {
2160- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 390, Col: 18}
2184+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 397, Col: 18}
21612185 }
21622186 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
21632187 if templ_7745c5c3_Err != nil {
21642188 return templ_7745c5c3_Err
21652189 }
21662190 }
2167- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</span></li>")
2191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</span></li>")
21682192 if templ_7745c5c3_Err != nil {
21692193 return templ_7745c5c3_Err
21702194 }
21712195 }
2172- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</ul>")
2196+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</ul>")
21732197 if templ_7745c5c3_Err != nil {
21742198 return templ_7745c5c3_Err
21752199 }
21762200 if len(p.Commits) == 0 {
2177- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "<p class=\"dim\">no commit to merge.</p>")
2201+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<p class=\"dim\">no commit to merge.</p>")
21782202 if templ_7745c5c3_Err != nil {
21792203 return templ_7745c5c3_Err
21802204 }
21812205 }
21822206 if p.CommitsNote != "" {
2183- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<p class=\"note\">")
2207+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<p class=\"note\">")
21842208 if templ_7745c5c3_Err != nil {
21852209 return templ_7745c5c3_Err
21862210 }
21872211 var templ_7745c5c3_Var108 string
21882212 templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
21892213 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 400, Col: 34}
2214+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 34}
21912215 }
21922216 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
21932217 if templ_7745c5c3_Err != nil {
21942218 return templ_7745c5c3_Err
21952219 }
2196- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</p>")
2220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</p>")
21972221 if templ_7745c5c3_Err != nil {
21982222 return templ_7745c5c3_Err
21992223 }
22002224 }
2201- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
2225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
22022226 if templ_7745c5c3_Err != nil {
22032227 return templ_7745c5c3_Err
22042228 }
22052229 var templ_7745c5c3_Var109 string
22062230 templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
22072231 if templ_7745c5c3_Err != nil {
2208- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 36}
2232+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 36}
22092233 }
22102234 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
22112235 if templ_7745c5c3_Err != nil {
22122236 return templ_7745c5c3_Err
22132237 }
2214- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "</dd><dt>checks</dt><dd>")
2238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</dd><dt>checks</dt><dd>")
22152239 if templ_7745c5c3_Err != nil {
22162240 return templ_7745c5c3_Err
22172241 }
22182242 if p.ChecksBlock {
2219- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "failing ones block the merge")
2243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "failing ones block the merge")
22202244 if templ_7745c5c3_Err != nil {
22212245 return templ_7745c5c3_Err
22222246 }
22232247 } else {
2224- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "do not block the merge")
2248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "do not block the merge")
22252249 if templ_7745c5c3_Err != nil {
22262250 return templ_7745c5c3_Err
22272251 }
22282252 }
2229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</dd></dl></section>")
2253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</dd></dl></section>")
22302254 if templ_7745c5c3_Err != nil {
22312255 return templ_7745c5c3_Err
22322256 }
@@ -2259,46 +2283,46 @@ func mrBar(p MRPage) templ.Component {
22592283 templ_7745c5c3_Var110 = templ.NopComponent
22602284 }
22612285 ctx = templ.ClearChildren(ctx)
2262- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
2286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
22632287 if templ_7745c5c3_Err != nil {
22642288 return templ_7745c5c3_Err
22652289 }
22662290 var templ_7745c5c3_Var111 string
22672291 templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
22682292 if templ_7745c5c3_Err != nil {
2269- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 55}
2293+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 55}
22702294 }
22712295 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
22722296 if templ_7745c5c3_Err != nil {
22732297 return templ_7745c5c3_Err
22742298 }
2275- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, " · <span class=\"plus\">+")
2299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " · <span class=\"plus\">+")
22762300 if templ_7745c5c3_Err != nil {
22772301 return templ_7745c5c3_Err
22782302 }
22792303 var templ_7745c5c3_Var112 string
22802304 templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
22812305 if templ_7745c5c3_Err != nil {
2282- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 95}
2306+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 95}
22832307 }
22842308 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
22852309 if templ_7745c5c3_Err != nil {
22862310 return templ_7745c5c3_Err
22872311 }
2288- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</span> <span class=\"minus\">-")
2312+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</span> <span class=\"minus\">-")
22892313 if templ_7745c5c3_Err != nil {
22902314 return templ_7745c5c3_Err
22912315 }
22922316 var templ_7745c5c3_Var113 string
22932317 templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
22942318 if templ_7745c5c3_Err != nil {
2295- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 140}
2319+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 140}
22962320 }
22972321 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
22982322 if templ_7745c5c3_Err != nil {
22992323 return templ_7745c5c3_Err
23002324 }
2301- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</span></span>")
2325+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</span></span>")
23022326 if templ_7745c5c3_Err != nil {
23032327 return templ_7745c5c3_Err
23042328 }
@@ -2314,7 +2338,7 @@ func mrBar(p MRPage) templ.Component {
23142338 if templ_7745c5c3_Err != nil {
23152339 return templ_7745c5c3_Err
23162340 }
2317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<span class=\"grow\"></span>")
2341+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<span class=\"grow\"></span>")
23182342 if templ_7745c5c3_Err != nil {
23192343 return templ_7745c5c3_Err
23202344 }
@@ -2327,7 +2351,7 @@ func mrBar(p MRPage) templ.Component {
23272351 if templ_7745c5c3_Err != nil {
23282352 return templ_7745c5c3_Err
23292353 }
2330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " ")
2354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " ")
23312355 if templ_7745c5c3_Err != nil {
23322356 return templ_7745c5c3_Err
23332357 }
@@ -2336,7 +2360,7 @@ func mrBar(p MRPage) templ.Component {
23362360 return templ_7745c5c3_Err
23372361 }
23382362 }
2339- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</div>")
2363+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "</div>")
23402364 if templ_7745c5c3_Err != nil {
23412365 return templ_7745c5c3_Err
23422366 }
@@ -2368,27 +2392,27 @@ func mergeMenu(p MRPage) templ.Component {
23682392 templ_7745c5c3_Var114 = templ.NopComponent
23692393 }
23702394 ctx = templ.ClearChildren(ctx)
2371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
2395+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
23722396 if templ_7745c5c3_Err != nil {
23732397 return templ_7745c5c3_Err
23742398 }
23752399 if p.Box.ReadyAction != "" {
2376- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "Draft ")
2400+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "Draft ")
23772401 if templ_7745c5c3_Err != nil {
23782402 return templ_7745c5c3_Err
23792403 }
23802404 } else {
2381- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "Merge ")
2405+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "Merge ")
23822406 if templ_7745c5c3_Err != nil {
23832407 return templ_7745c5c3_Err
23842408 }
23852409 }
2386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
2410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
23872411 if templ_7745c5c3_Err != nil {
23882412 return templ_7745c5c3_Err
23892413 }
23902414 if p.Box.HeadGone {
2391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "gone: the source branch was deleted")
2415+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "gone: the source branch was deleted")
23922416 if templ_7745c5c3_Err != nil {
23932417 return templ_7745c5c3_Err
23942418 }
@@ -2396,186 +2420,186 @@ func mergeMenu(p MRPage) templ.Component {
23962420 var templ_7745c5c3_Var115 string
23972421 templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
23982422 if templ_7745c5c3_Err != nil {
2399- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 19}
2423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 466, Col: 19}
24002424 }
24012425 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
24022426 if templ_7745c5c3_Err != nil {
24032427 return templ_7745c5c3_Err
24042428 }
24052429 }
2406- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "</dd><dt>into</dt><dd>")
2430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</dd><dt>into</dt><dd>")
24072431 if templ_7745c5c3_Err != nil {
24082432 return templ_7745c5c3_Err
24092433 }
24102434 var templ_7745c5c3_Var116 string
24112435 templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
24122436 if templ_7745c5c3_Err != nil {
2413- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 463, Col: 18}
2437+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 18}
24142438 }
24152439 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
24162440 if templ_7745c5c3_Err != nil {
24172441 return templ_7745c5c3_Err
24182442 }
2419- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "</dd></dl>")
2443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "</dd></dl>")
24202444 if templ_7745c5c3_Err != nil {
24212445 return templ_7745c5c3_Err
24222446 }
24232447 if p.Box.ReadyAction != "" {
2424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
2448+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
24252449 if templ_7745c5c3_Err != nil {
24262450 return templ_7745c5c3_Err
24272451 }
24282452 var templ_7745c5c3_Var117 templ.SafeURL
24292453 templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
24302454 if templ_7745c5c3_Err != nil {
2431- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 467, Col: 80}
2455+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 474, Col: 80}
24322456 }
24332457 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
24342458 if templ_7745c5c3_Err != nil {
24352459 return templ_7745c5c3_Err
24362460 }
2437- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
2461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
24382462 if templ_7745c5c3_Err != nil {
24392463 return templ_7745c5c3_Err
24402464 }
24412465 } else if !p.Box.HeadGone {
2442- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "<form class=\"d-acts\" method=\"post\" action=\"")
2466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<form class=\"d-acts\" method=\"post\" action=\"")
24432467 if templ_7745c5c3_Err != nil {
24442468 return templ_7745c5c3_Err
24452469 }
24462470 var templ_7745c5c3_Var118 templ.SafeURL
24472471 templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
24482472 if templ_7745c5c3_Err != nil {
2449- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 471, Col: 80}
2473+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 478, Col: 80}
24502474 }
24512475 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
24522476 if templ_7745c5c3_Err != nil {
24532477 return templ_7745c5c3_Err
24542478 }
2455- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2479+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
24562480 if templ_7745c5c3_Err != nil {
24572481 return templ_7745c5c3_Err
24582482 }
24592483 var templ_7745c5c3_Var119 string
24602484 templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
24612485 if templ_7745c5c3_Err != nil {
2462- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 472, Col: 59}
2486+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 59}
24632487 }
24642488 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)
24652489 if templ_7745c5c3_Err != nil {
24662490 return templ_7745c5c3_Err
24672491 }
2468- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
2492+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
24692493 if templ_7745c5c3_Err != nil {
24702494 return templ_7745c5c3_Err
24712495 }
24722496 for i, m := range p.Box.Modes {
2473- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<label><input type=\"radio\" name=\"mode\" value=\"")
2497+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "<label><input type=\"radio\" name=\"mode\" value=\"")
24742498 if templ_7745c5c3_Err != nil {
24752499 return templ_7745c5c3_Err
24762500 }
24772501 var templ_7745c5c3_Var120 string
24782502 templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
24792503 if templ_7745c5c3_Err != nil {
2480- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 477, Col: 55}
2504+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 55}
24812505 }
24822506 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)
24832507 if templ_7745c5c3_Err != nil {
24842508 return templ_7745c5c3_Err
24852509 }
2486- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "\"")
2510+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "\"")
24872511 if templ_7745c5c3_Err != nil {
24882512 return templ_7745c5c3_Err
24892513 }
24902514 if i == 0 {
2491- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, " checked")
2515+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, " checked")
24922516 if templ_7745c5c3_Err != nil {
24932517 return templ_7745c5c3_Err
24942518 }
24952519 }
2496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "> ")
2520+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "> ")
24972521 if templ_7745c5c3_Err != nil {
24982522 return templ_7745c5c3_Err
24992523 }
25002524 var templ_7745c5c3_Var121 string
25012525 templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
25022526 if templ_7745c5c3_Err != nil {
2503- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 478, Col: 17}
2527+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 485, Col: 17}
25042528 }
25052529 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
25062530 if templ_7745c5c3_Err != nil {
25072531 return templ_7745c5c3_Err
25082532 }
2509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, " ")
2533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, " ")
25102534 if templ_7745c5c3_Err != nil {
25112535 return templ_7745c5c3_Err
25122536 }
25132537 if i < 9 {
2514- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<kbd aria-hidden=\"true\">")
2538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<kbd aria-hidden=\"true\">")
25152539 if templ_7745c5c3_Err != nil {
25162540 return templ_7745c5c3_Err
25172541 }
25182542 var templ_7745c5c3_Var122 string
25192543 templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
25202544 if templ_7745c5c3_Err != nil {
2521- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 46}
2545+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 46}
25222546 }
25232547 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
25242548 if templ_7745c5c3_Err != nil {
25252549 return templ_7745c5c3_Err
25262550 }
2527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "</kbd>")
2551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "</kbd>")
25282552 if templ_7745c5c3_Err != nil {
25292553 return templ_7745c5c3_Err
25302554 }
25312555 }
2532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</label>")
2556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "</label>")
25332557 if templ_7745c5c3_Err != nil {
25342558 return templ_7745c5c3_Err
25352559 }
25362560 }
2537- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "</fieldset>")
2561+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</fieldset>")
25382562 if templ_7745c5c3_Err != nil {
25392563 return templ_7745c5c3_Err
25402564 }
25412565 if p.Box.CanDeleteBranch {
2542- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
2566+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
25432567 if templ_7745c5c3_Err != nil {
25442568 return templ_7745c5c3_Err
25452569 }
25462570 var templ_7745c5c3_Var123 string
25472571 templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
25482572 if templ_7745c5c3_Err != nil {
2549- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 102}
2573+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 493, Col: 102}
25502574 }
25512575 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
25522576 if templ_7745c5c3_Err != nil {
25532577 return templ_7745c5c3_Err
25542578 }
2555- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
2579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
25562580 if templ_7745c5c3_Err != nil {
25572581 return templ_7745c5c3_Err
25582582 }
25592583 }
2560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
2584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
25612585 if templ_7745c5c3_Err != nil {
25622586 return templ_7745c5c3_Err
25632587 }
25642588 var templ_7745c5c3_Var124 string
25652589 templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
25662590 if templ_7745c5c3_Err != nil {
2567- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 82}
2591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 82}
25682592 }
25692593 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
25702594 if templ_7745c5c3_Err != nil {
25712595 return templ_7745c5c3_Err
25722596 }
2573- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</button></div></form>")
2597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "</button></div></form>")
25742598 if templ_7745c5c3_Err != nil {
25752599 return templ_7745c5c3_Err
25762600 }
25772601 }
2578- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</div></details>")
2602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</div></details>")
25792603 if templ_7745c5c3_Err != nil {
25802604 return templ_7745c5c3_Err
25812605 }
@@ -2606,61 +2630,61 @@ func moreMenu(p MRPage) templ.Component {
26062630 templ_7745c5c3_Var125 = templ.NopComponent
26072631 }
26082632 ctx = templ.ClearChildren(ctx)
2609- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")
2633+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")
26102634 if templ_7745c5c3_Err != nil {
26112635 return templ_7745c5c3_Err
26122636 }
26132637 if p.Box.CanMarkRead {
2614- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<form method=\"post\" action=\"")
2638+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<form method=\"post\" action=\"")
26152639 if templ_7745c5c3_Err != nil {
26162640 return templ_7745c5c3_Err
26172641 }
26182642 var templ_7745c5c3_Var126 templ.SafeURL
26192643 templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
26202644 if templ_7745c5c3_Err != nil {
2621- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 504, Col: 64}
2645+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 511, Col: 64}
26222646 }
26232647 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
26242648 if templ_7745c5c3_Err != nil {
26252649 return templ_7745c5c3_Err
26262650 }
2627- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2651+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
26282652 if templ_7745c5c3_Err != nil {
26292653 return templ_7745c5c3_Err
26302654 }
26312655 var templ_7745c5c3_Var127 string
26322656 templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
26332657 if templ_7745c5c3_Err != nil {
2634- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 505, Col: 59}
2658+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 512, Col: 59}
26352659 }
26362660 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)
26372661 if templ_7745c5c3_Err != nil {
26382662 return templ_7745c5c3_Err
26392663 }
2640- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
2664+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
26412665 if templ_7745c5c3_Err != nil {
26422666 return templ_7745c5c3_Err
26432667 }
26442668 }
2645- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "<form method=\"post\" action=\"")
2669+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "<form method=\"post\" action=\"")
26462670 if templ_7745c5c3_Err != nil {
26472671 return templ_7745c5c3_Err
26482672 }
26492673 var templ_7745c5c3_Var128 templ.SafeURL
26502674 templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
26512675 if templ_7745c5c3_Err != nil {
2652- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 509, Col: 64}
2676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 516, Col: 64}
26532677 }
26542678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
26552679 if templ_7745c5c3_Err != nil {
26562680 return templ_7745c5c3_Err
26572681 }
2658- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
2682+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
26592683 if templ_7745c5c3_Err != nil {
26602684 return templ_7745c5c3_Err
26612685 }
26622686 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
2663- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
2687+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
26642688 if templ_7745c5c3_Err != nil {
26652689 return templ_7745c5c3_Err
26662690 }
@@ -2668,12 +2692,12 @@ func moreMenu(p MRPage) templ.Component {
26682692 if templ_7745c5c3_Err != nil {
26692693 return templ_7745c5c3_Err
26702694 }
2671- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "</details>")
2695+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</details>")
26722696 if templ_7745c5c3_Err != nil {
26732697 return templ_7745c5c3_Err
26742698 }
26752699 }
2676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "</div></details>")
2700+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</div></details>")
26772701 if templ_7745c5c3_Err != nil {
26782702 return templ_7745c5c3_Err
26792703 }
@@ -2714,263 +2738,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
27142738 }()
27152739 }
27162740 ctx = templ.InitializeContext(ctx)
2717- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "<header class=\"show mr-h\"><h1>")
2741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "<header class=\"show mr-h\"><h1>")
27182742 if templ_7745c5c3_Err != nil {
27192743 return templ_7745c5c3_Err
27202744 }
27212745 var templ_7745c5c3_Var131 string
27222746 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
27232747 if templ_7745c5c3_Err != nil {
2724- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 525, Col: 17}
2748+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 17}
27252749 }
27262750 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
27272751 if templ_7745c5c3_Err != nil {
27282752 return templ_7745c5c3_Err
27292753 }
2730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, " → ")
2754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, " → ")
27312755 if templ_7745c5c3_Err != nil {
27322756 return templ_7745c5c3_Err
27332757 }
27342758 var templ_7745c5c3_Var132 string
27352759 templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
27362760 if templ_7745c5c3_Err != nil {
2737- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 525, Col: 34}
2761+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 34}
27382762 }
27392763 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
27402764 if templ_7745c5c3_Err != nil {
27412765 return templ_7745c5c3_Err
27422766 }
2743- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
2767+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
27442768 if templ_7745c5c3_Err != nil {
27452769 return templ_7745c5c3_Err
27462770 }
27472771 var templ_7745c5c3_Var133 templ.SafeURL
27482772 templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
27492773 if templ_7745c5c3_Err != nil {
2750- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 528, Col: 45}
2774+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 45}
27512775 }
27522776 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
27532777 if templ_7745c5c3_Err != nil {
27542778 return templ_7745c5c3_Err
27552779 }
2756- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "\">")
2780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "\">")
27572781 if templ_7745c5c3_Err != nil {
27582782 return templ_7745c5c3_Err
27592783 }
27602784 var templ_7745c5c3_Var134 string
27612785 templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
27622786 if templ_7745c5c3_Err != nil {
2763- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 528, Col: 58}
2787+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 58}
27642788 }
27652789 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
27662790 if templ_7745c5c3_Err != nil {
27672791 return templ_7745c5c3_Err
27682792 }
2769- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "</a></dd><dt>target</dt><dd><a href=\"")
2793+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "</a></dd><dt>target</dt><dd><a href=\"")
27702794 if templ_7745c5c3_Err != nil {
27712795 return templ_7745c5c3_Err
27722796 }
27732797 var templ_7745c5c3_Var135 templ.SafeURL
27742798 templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
27752799 if templ_7745c5c3_Err != nil {
2776- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 530, Col: 45}
2800+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 45}
27772801 }
27782802 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
27792803 if templ_7745c5c3_Err != nil {
27802804 return templ_7745c5c3_Err
27812805 }
2782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "\">")
2806+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "\">")
27832807 if templ_7745c5c3_Err != nil {
27842808 return templ_7745c5c3_Err
27852809 }
27862810 var templ_7745c5c3_Var136 string
27872811 templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
27882812 if templ_7745c5c3_Err != nil {
2789- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 530, Col: 58}
2813+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 58}
27902814 }
27912815 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
27922816 if templ_7745c5c3_Err != nil {
27932817 return templ_7745c5c3_Err
27942818 }
2795- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</a></dd><dt>ahead</dt><dd>")
2819+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</a></dd><dt>ahead</dt><dd>")
27962820 if templ_7745c5c3_Err != nil {
27972821 return templ_7745c5c3_Err
27982822 }
27992823 var templ_7745c5c3_Var137 string
28002824 templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
28012825 if templ_7745c5c3_Err != nil {
2802- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 21}
2826+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 21}
28032827 }
28042828 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
28052829 if templ_7745c5c3_Err != nil {
28062830 return templ_7745c5c3_Err
28072831 }
2808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "</dd></dl></header>")
2832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "</dd></dl></header>")
28092833 if templ_7745c5c3_Err != nil {
28102834 return templ_7745c5c3_Err
28112835 }
28122836 if p.Note != "" {
2813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "<p class=\"notice\">")
2837+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "<p class=\"notice\">")
28142838 if templ_7745c5c3_Err != nil {
28152839 return templ_7745c5c3_Err
28162840 }
28172841 var templ_7745c5c3_Var138 string
28182842 templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
28192843 if templ_7745c5c3_Err != nil {
2820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 536, Col: 29}
2844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 29}
28212845 }
28222846 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
28232847 if templ_7745c5c3_Err != nil {
28242848 return templ_7745c5c3_Err
28252849 }
2826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "</p>")
2850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</p>")
28272851 if templ_7745c5c3_Err != nil {
28282852 return templ_7745c5c3_Err
28292853 }
28302854 }
2831- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, " ")
2855+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, " ")
28322856 if templ_7745c5c3_Err != nil {
28332857 return templ_7745c5c3_Err
28342858 }
28352859 if p.Existing != nil {
2836- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
2860+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
28372861 if templ_7745c5c3_Err != nil {
28382862 return templ_7745c5c3_Err
28392863 }
28402864 var templ_7745c5c3_Var139 templ.SafeURL
28412865 templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
28422866 if templ_7745c5c3_Err != nil {
2843- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 114}
2867+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 114}
28442868 }
28452869 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
28462870 if templ_7745c5c3_Err != nil {
28472871 return templ_7745c5c3_Err
28482872 }
2849- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "\">")
2873+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "\">")
28502874 if templ_7745c5c3_Err != nil {
28512875 return templ_7745c5c3_Err
28522876 }
28532877 var templ_7745c5c3_Var140 string
28542878 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
28552879 if templ_7745c5c3_Err != nil {
2856- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 135}
2880+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 135}
28572881 }
28582882 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
28592883 if templ_7745c5c3_Err != nil {
28602884 return templ_7745c5c3_Err
28612885 }
2862- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</a></p>")
2886+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "</a></p>")
28632887 if templ_7745c5c3_Err != nil {
28642888 return templ_7745c5c3_Err
28652889 }
28662890 }
2867- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, " ")
2891+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, " ")
28682892 if templ_7745c5c3_Err != nil {
28692893 return templ_7745c5c3_Err
28702894 }
28712895 if p.Create != nil {
2872- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<form class=\"cform create\" method=\"post\" action=\"")
2896+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "<form class=\"cform create\" method=\"post\" action=\"")
28732897 if templ_7745c5c3_Err != nil {
28742898 return templ_7745c5c3_Err
28752899 }
28762900 var templ_7745c5c3_Var141 templ.SafeURL
28772901 templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
28782902 if templ_7745c5c3_Err != nil {
2879- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 542, Col: 83}
2903+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 549, Col: 83}
28802904 }
28812905 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
28822906 if templ_7745c5c3_Err != nil {
28832907 return templ_7745c5c3_Err
28842908 }
2885- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "\"><input type=\"hidden\" name=\"source\" value=\"")
2909+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "\"><input type=\"hidden\" name=\"source\" value=\"")
28862910 if templ_7745c5c3_Err != nil {
28872911 return templ_7745c5c3_Err
28882912 }
28892913 var templ_7745c5c3_Var142 string
28902914 templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
28912915 if templ_7745c5c3_Err != nil {
2892- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 62}
2916+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 550, Col: 62}
28932917 }
28942918 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var142)
28952919 if templ_7745c5c3_Err != nil {
28962920 return templ_7745c5c3_Err
28972921 }
2898- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "\"> <input type=\"hidden\" name=\"target\" value=\"")
2922+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "\"> <input type=\"hidden\" name=\"target\" value=\"")
28992923 if templ_7745c5c3_Err != nil {
29002924 return templ_7745c5c3_Err
29012925 }
29022926 var templ_7745c5c3_Var143 string
29032927 templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
29042928 if templ_7745c5c3_Err != nil {
2905- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 544, Col: 62}
2929+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 551, Col: 62}
29062930 }
29072931 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var143)
29082932 if templ_7745c5c3_Err != nil {
29092933 return templ_7745c5c3_Err
29102934 }
2911- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
2935+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
29122936 if templ_7745c5c3_Err != nil {
29132937 return templ_7745c5c3_Err
29142938 }
29152939 var templ_7745c5c3_Var144 string
29162940 templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
29172941 if templ_7745c5c3_Err != nil {
2918- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 97}
2942+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 553, Col: 97}
29192943 }
29202944 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
29212945 if templ_7745c5c3_Err != nil {
29222946 return templ_7745c5c3_Err
29232947 }
2924- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
2948+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
29252949 if templ_7745c5c3_Err != nil {
29262950 return templ_7745c5c3_Err
29272951 }
29282952 }
2929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, " ")
2953+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, " ")
29302954 if templ_7745c5c3_Err != nil {
29312955 return templ_7745c5c3_Err
29322956 }
29332957 if p.HasDiff {
2934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
2958+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
29352959 if templ_7745c5c3_Err != nil {
29362960 return templ_7745c5c3_Err
29372961 }
29382962 var templ_7745c5c3_Var145 string
29392963 templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
29402964 if templ_7745c5c3_Err != nil {
2941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 57}
2965+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 57}
29422966 }
29432967 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
29442968 if templ_7745c5c3_Err != nil {
29452969 return templ_7745c5c3_Err
29462970 }
2947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, " · <span class=\"plus\">+")
2971+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, " · <span class=\"plus\">+")
29482972 if templ_7745c5c3_Err != nil {
29492973 return templ_7745c5c3_Err
29502974 }
29512975 var templ_7745c5c3_Var146 string
29522976 templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
29532977 if templ_7745c5c3_Err != nil {
2954- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 97}
2978+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 97}
29552979 }
29562980 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
29572981 if templ_7745c5c3_Err != nil {
29582982 return templ_7745c5c3_Err
29592983 }
2960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "</span> <span class=\"minus\">-")
2984+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "</span> <span class=\"minus\">-")
29612985 if templ_7745c5c3_Err != nil {
29622986 return templ_7745c5c3_Err
29632987 }
29642988 var templ_7745c5c3_Var147 string
29652989 templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
29662990 if templ_7745c5c3_Err != nil {
2967- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 142}
2991+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 142}
29682992 }
29692993 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
29702994 if templ_7745c5c3_Err != nil {
29712995 return templ_7745c5c3_Err
29722996 }
2973- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "</span></span> <span class=\"grow\"></span>")
2997+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "</span></span> <span class=\"grow\"></span>")
29742998 if templ_7745c5c3_Err != nil {
29752999 return templ_7745c5c3_Err
29763000 }
@@ -2986,64 +3010,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
29863010 if templ_7745c5c3_Err != nil {
29873011 return templ_7745c5c3_Err
29883012 }
2989- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "</div>")
3013+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "</div>")
29903014 if templ_7745c5c3_Err != nil {
29913015 return templ_7745c5c3_Err
29923016 }
29933017 if p.Invisible {
2994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
3018+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
29953019 if templ_7745c5c3_Err != nil {
29963020 return templ_7745c5c3_Err
29973021 }
29983022 }
2999- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, " <div id=\"diffs\"")
3023+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, " <div id=\"diffs\"")
30003024 if templ_7745c5c3_Err != nil {
30013025 return templ_7745c5c3_Err
30023026 }
30033027 if p.Both {
3004- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, " data-diff=\"")
3028+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, " data-diff=\"")
30053029 if templ_7745c5c3_Err != nil {
30063030 return templ_7745c5c3_Err
30073031 }
30083032 var templ_7745c5c3_Var148 string
30093033 templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
30103034 if templ_7745c5c3_Err != nil {
3011- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 571, Col: 23}
3035+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 578, Col: 23}
30123036 }
30133037 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var148)
30143038 if templ_7745c5c3_Err != nil {
30153039 return templ_7745c5c3_Err
30163040 }
3017- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "\"")
3041+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "\"")
30183042 if templ_7745c5c3_Err != nil {
30193043 return templ_7745c5c3_Err
30203044 }
30213045 } else {
3022- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, " data-view=\"")
3046+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, " data-view=\"")
30233047 if templ_7745c5c3_Err != nil {
30243048 return templ_7745c5c3_Err
30253049 }
30263050 var templ_7745c5c3_Var149 string
30273051 templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
30283052 if templ_7745c5c3_Err != nil {
3029- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 573, Col: 23}
3053+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 580, Col: 23}
30303054 }
30313055 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var149)
30323056 if templ_7745c5c3_Err != nil {
30333057 return templ_7745c5c3_Err
30343058 }
3035- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, "\"")
3059+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "\"")
30363060 if templ_7745c5c3_Err != nil {
30373061 return templ_7745c5c3_Err
30383062 }
30393063 }
30403064 if p.Both && !p.Pinned {
3041- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
3065+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
30423066 if templ_7745c5c3_Err != nil {
30433067 return templ_7745c5c3_Err
30443068 }
30453069 }
3046- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, ">")
3070+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, ">")
30473071 if templ_7745c5c3_Err != nil {
30483072 return templ_7745c5c3_Err
30493073 }
@@ -3053,7 +3077,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
30533077 return templ_7745c5c3_Err
30543078 }
30553079 }
3056- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "</div>")
3080+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "</div>")
30573081 if templ_7745c5c3_Err != nil {
30583082 return templ_7745c5c3_Err
30593083 }
@@ -3089,77 +3113,77 @@ func compareRail(p ComparePage) templ.Component {
30893113 templ_7745c5c3_Var150 = templ.NopComponent
30903114 }
30913115 ctx = templ.ClearChildren(ctx)
3092- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
3116+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
30933117 if templ_7745c5c3_Err != nil {
30943118 return templ_7745c5c3_Err
30953119 }
30963120 var templ_7745c5c3_Var151 string
30973121 templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
30983122 if templ_7745c5c3_Err != nil {
3099- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 589, Col: 67}
3123+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 596, Col: 67}
31003124 }
31013125 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151))
31023126 if templ_7745c5c3_Err != nil {
31033127 return templ_7745c5c3_Err
31043128 }
3105- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "</span></h2><ul class=\"pcommits\">")
3129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "</span></h2><ul class=\"pcommits\">")
31063130 if templ_7745c5c3_Err != nil {
31073131 return templ_7745c5c3_Err
31083132 }
31093133 for _, c := range p.Ahead {
3110- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "<li><a href=\"")
3134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "<li><a href=\"")
31113135 if templ_7745c5c3_Err != nil {
31123136 return templ_7745c5c3_Err
31133137 }
31143138 var templ_7745c5c3_Var152 templ.SafeURL
31153139 templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
31163140 if templ_7745c5c3_Err != nil {
3117- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 593, Col: 37}
3141+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 37}
31183142 }
31193143 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
31203144 if templ_7745c5c3_Err != nil {
31213145 return templ_7745c5c3_Err
31223146 }
3123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "\">")
3147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "\">")
31243148 if templ_7745c5c3_Err != nil {
31253149 return templ_7745c5c3_Err
31263150 }
31273151 var templ_7745c5c3_Var153 string
31283152 templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
31293153 if templ_7745c5c3_Err != nil {
3130- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 593, Col: 49}
3154+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 49}
31313155 }
31323156 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153))
31333157 if templ_7745c5c3_Err != nil {
31343158 return templ_7745c5c3_Err
31353159 }
3136- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "</a> <span class=\"s\">")
3160+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "</a> <span class=\"s\">")
31373161 if templ_7745c5c3_Err != nil {
31383162 return templ_7745c5c3_Err
31393163 }
31403164 var templ_7745c5c3_Var154 string
31413165 templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
31423166 if templ_7745c5c3_Err != nil {
3143- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 594, Col: 32}
3167+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 601, Col: 32}
31443168 }
31453169 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154))
31463170 if templ_7745c5c3_Err != nil {
31473171 return templ_7745c5c3_Err
31483172 }
3149- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "</span> <span class=\"mute\">")
3173+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "</span> <span class=\"mute\">")
31503174 if templ_7745c5c3_Err != nil {
31513175 return templ_7745c5c3_Err
31523176 }
31533177 var templ_7745c5c3_Var155 string
31543178 templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
31553179 if templ_7745c5c3_Err != nil {
3156- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 596, Col: 16}
3180+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 603, Col: 16}
31573181 }
31583182 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155))
31593183 if templ_7745c5c3_Err != nil {
31603184 return templ_7745c5c3_Err
31613185 }
3162- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, " ")
3186+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, " ")
31633187 if templ_7745c5c3_Err != nil {
31643188 return templ_7745c5c3_Err
31653189 }
@@ -3167,90 +3191,90 @@ func compareRail(p ComparePage) templ.Component {
31673191 var templ_7745c5c3_Var156 string
31683192 templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
31693193 if templ_7745c5c3_Err != nil {
3170- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 15}
3194+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 15}
31713195 }
31723196 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
31733197 if templ_7745c5c3_Err != nil {
31743198 return templ_7745c5c3_Err
31753199 }
3176- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "<time datetime=\"")
3200+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "<time datetime=\"")
31773201 if templ_7745c5c3_Err != nil {
31783202 return templ_7745c5c3_Err
31793203 }
31803204 var templ_7745c5c3_Var157 string
31813205 templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
31823206 if templ_7745c5c3_Err != nil {
3183- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 42}
3207+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 42}
31843208 }
31853209 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var157)
31863210 if templ_7745c5c3_Err != nil {
31873211 return templ_7745c5c3_Err
31883212 }
3189- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "\">")
3213+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "\">")
31903214 if templ_7745c5c3_Err != nil {
31913215 return templ_7745c5c3_Err
31923216 }
31933217 var templ_7745c5c3_Var158 string
31943218 templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
31953219 if templ_7745c5c3_Err != nil {
3196- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 53}
3220+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 53}
31973221 }
31983222 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))
31993223 if templ_7745c5c3_Err != nil {
32003224 return templ_7745c5c3_Err
32013225 }
3202- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "</time>")
3226+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, "</time>")
32033227 if templ_7745c5c3_Err != nil {
32043228 return templ_7745c5c3_Err
32053229 }
32063230 }
3207- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, "</span></li>")
3231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</span></li>")
32083232 if templ_7745c5c3_Err != nil {
32093233 return templ_7745c5c3_Err
32103234 }
32113235 }
3212- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "</ul>")
3236+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "</ul>")
32133237 if templ_7745c5c3_Err != nil {
32143238 return templ_7745c5c3_Err
32153239 }
32163240 if len(p.Ahead) == 0 {
3217- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "<p class=\"dim\">no commit ahead of ")
3241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "<p class=\"dim\">no commit ahead of ")
32183242 if templ_7745c5c3_Err != nil {
32193243 return templ_7745c5c3_Err
32203244 }
32213245 var templ_7745c5c3_Var159 string
32223246 templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
32233247 if templ_7745c5c3_Err != nil {
3224- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 47}
3248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 612, Col: 47}
32253249 }
32263250 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159))
32273251 if templ_7745c5c3_Err != nil {
32283252 return templ_7745c5c3_Err
32293253 }
3230- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, ".</p>")
3254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, ".</p>")
32313255 if templ_7745c5c3_Err != nil {
32323256 return templ_7745c5c3_Err
32333257 }
32343258 }
3235- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</section>")
3259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "</section>")
32363260 if templ_7745c5c3_Err != nil {
32373261 return templ_7745c5c3_Err
32383262 }
32393263 if p.HasDiff {
3240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
3264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
32413265 if templ_7745c5c3_Err != nil {
32423266 return templ_7745c5c3_Err
32433267 }
32443268 var templ_7745c5c3_Var160 string
32453269 templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
32463270 if templ_7745c5c3_Err != nil {
3247- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 610, Col: 66}
3271+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 617, Col: 66}
32483272 }
32493273 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
32503274 if templ_7745c5c3_Err != nil {
32513275 return templ_7745c5c3_Err
32523276 }
3253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "</span></h2>")
3277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 304, "</span></h2>")
32543278 if templ_7745c5c3_Err != nil {
32553279 return templ_7745c5c3_Err
32563280 }
@@ -3258,12 +3282,12 @@ func compareRail(p ComparePage) templ.Component {
32583282 if templ_7745c5c3_Err != nil {
32593283 return templ_7745c5c3_Err
32603284 }
3261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, "</section>")
3285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 305, "</section>")
32623286 if templ_7745c5c3_Err != nil {
32633287 return templ_7745c5c3_Err
32643288 }
32653289 }
3266- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
3290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 306, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
32673291 if templ_7745c5c3_Err != nil {
32683292 return templ_7745c5c3_Err
32693293 }
@@ -3271,7 +3295,7 @@ func compareRail(p ComparePage) templ.Component {
32713295 if templ_7745c5c3_Err != nil {
32723296 return templ_7745c5c3_Err
32733297 }
3274- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "</section>")
3298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 307, "</section>")
32753299 if templ_7745c5c3_Err != nil {
32763300 return templ_7745c5c3_Err
32773301 }
@@ -1406,20 +1406,33 @@ func commentView(c CommentView, suffix string) templ.Component {
1406 if templ_7745c5c3_Err != nil {1406 if templ_7745c5c3_Err != nil {
1407 return templ_7745c5c3_Err1407 return templ_7745c5c3_Err
1408 }1408 }
1409- if c.Deleted {1409+ if len(c.Parts) > 0 {
1410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"c-text\">")
1411+ if templ_7745c5c3_Err != nil {
1412+ return templ_7745c5c3_Err
1413+ }
1414+ templ_7745c5c3_Err = userText(c.Parts).Render(ctx, templ_7745c5c3_Buffer)
1415+ if templ_7745c5c3_Err != nil {
1416+ return templ_7745c5c3_Err
1417+ }
1418+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "</p>")
1419+ if templ_7745c5c3_Err != nil {
1420+ return templ_7745c5c3_Err
1421+ }
1422+ } else if c.Deleted {
1410 if c.Verdict != "" {1423 if c.Verdict != "" {
1411- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"mute\">text deleted</p>")1424+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<p class=\"mute\">text deleted</p>")
1412 if templ_7745c5c3_Err != nil {1425 if templ_7745c5c3_Err != nil {
1413 return templ_7745c5c3_Err1426 return templ_7745c5c3_Err
1414 }1427 }
1415 } else {1428 } else {
1416- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<p class=\"mute\">deleted note</p>")1429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<p class=\"mute\">deleted note</p>")
1417 if templ_7745c5c3_Err != nil {1430 if templ_7745c5c3_Err != nil {
1418 return templ_7745c5c3_Err1431 return templ_7745c5c3_Err
1419 }1432 }
1420 }1433 }
1421 } else {1434 } else {
1422- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<div class=\"c-body prose\">")1435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<div class=\"c-body prose\">")
1423 if templ_7745c5c3_Err != nil {1436 if templ_7745c5c3_Err != nil {
1424 return templ_7745c5c3_Err1437 return templ_7745c5c3_Err
1425 }1438 }
@@ -1434,147 +1447,147 @@ func commentView(c CommentView, suffix string) templ.Component {
1434 return templ_7745c5c3_Err1447 return templ_7745c5c3_Err
1435 }1448 }
1436 }1449 }
1437- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</div>")1450+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</div>")
1438 if templ_7745c5c3_Err != nil {1451 if templ_7745c5c3_Err != nil {
1439 return templ_7745c5c3_Err1452 return templ_7745c5c3_Err
1440 }1453 }
1441 }1454 }
1442 if c.Edited {1455 if c.Edited {
1443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<span class=\"mute\">edited</span> ")1456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<span class=\"mute\">edited</span> ")
1444 if templ_7745c5c3_Err != nil {1457 if templ_7745c5c3_Err != nil {
1445 return templ_7745c5c3_Err1458 return templ_7745c5c3_Err
1446 }1459 }
1447 }1460 }
1448 if c.Verdict != "" {1461 if c.Verdict != "" {
1449- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<span class=\"c-state\">")1462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<span class=\"c-state\">")
1450 if templ_7745c5c3_Err != nil {1463 if templ_7745c5c3_Err != nil {
1451 return templ_7745c5c3_Err1464 return templ_7745c5c3_Err
1452 }1465 }
1453 var templ_7745c5c3_Var70 string1466 var templ_7745c5c3_Var70 string
1454 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(c.Verdict)1467 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(c.Verdict)
1455 if templ_7745c5c3_Err != nil {1468 if templ_7745c5c3_Err != nil {
1456- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 269, Col: 36}1469+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 271, Col: 36}
1457 }1470 }
1458 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))1471 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1459 if templ_7745c5c3_Err != nil {1472 if templ_7745c5c3_Err != nil {
1460 return templ_7745c5c3_Err1473 return templ_7745c5c3_Err
1461 }1474 }
1462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</span> ")1475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span> ")
1463 if templ_7745c5c3_Err != nil {1476 if templ_7745c5c3_Err != nil {
1464 return templ_7745c5c3_Err1477 return templ_7745c5c3_Err
1465 }1478 }
1466 }1479 }
1467 if c.Range != "" {1480 if c.Range != "" {
1468- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<span class=\"mute\">")1481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<span class=\"mute\">")
1469 if templ_7745c5c3_Err != nil {1482 if templ_7745c5c3_Err != nil {
1470 return templ_7745c5c3_Err1483 return templ_7745c5c3_Err
1471 }1484 }
1472 var templ_7745c5c3_Var71 string1485 var templ_7745c5c3_Var71 string
1473 templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.Range)1486 templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.Range)
1474 if templ_7745c5c3_Err != nil {1487 if templ_7745c5c3_Err != nil {
1475- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 272, Col: 31}1488+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 31}
1476 }1489 }
1477 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))1490 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1478 if templ_7745c5c3_Err != nil {1491 if templ_7745c5c3_Err != nil {
1479 return templ_7745c5c3_Err1492 return templ_7745c5c3_Err
1480 }1493 }
1481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span> ")1494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</span> ")
1482 if templ_7745c5c3_Err != nil {1495 if templ_7745c5c3_Err != nil {
1483 return templ_7745c5c3_Err1496 return templ_7745c5c3_Err
1484 }1497 }
1485 }1498 }
1486 if c.Quote != "" {1499 if c.Quote != "" {
1487- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<button type=\"button\" class=\"btn\" data-js data-quote=\"")1500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<button type=\"button\" class=\"btn\" data-js data-quote=\"")
1488 if templ_7745c5c3_Err != nil {1501 if templ_7745c5c3_Err != nil {
1489 return templ_7745c5c3_Err1502 return templ_7745c5c3_Err
1490 }1503 }
1491 var templ_7745c5c3_Var72 string1504 var templ_7745c5c3_Var72 string
1492 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Quote)1505 templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Quote)
1493 if templ_7745c5c3_Err != nil {1506 if templ_7745c5c3_Err != nil {
1494- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 275, Col: 65}1507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 277, Col: 65}
1495 }1508 }
1496 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)1509 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1497 if templ_7745c5c3_Err != nil {1510 if templ_7745c5c3_Err != nil {
1498 return templ_7745c5c3_Err1511 return templ_7745c5c3_Err
1499 }1512 }
1500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\">Quote</button> ")1513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\">Quote</button> ")
1501 if templ_7745c5c3_Err != nil {1514 if templ_7745c5c3_Err != nil {
1502 return templ_7745c5c3_Err1515 return templ_7745c5c3_Err
1503 }1516 }
1504 }1517 }
1505 if c.Edit != nil {1518 if c.Edit != nil {
1506- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<details><summary>Edit</summary><form method=\"post\" action=\"")1519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<details><summary>Edit</summary><form method=\"post\" action=\"")
1507 if templ_7745c5c3_Err != nil {1520 if templ_7745c5c3_Err != nil {
1508 return templ_7745c5c3_Err1521 return templ_7745c5c3_Err
1509 }1522 }
1510 var templ_7745c5c3_Var73 templ.SafeURL1523 var templ_7745c5c3_Var73 templ.SafeURL
1511 templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Edit.Action))1524 templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Edit.Action))
1512 if templ_7745c5c3_Err != nil {1525 if templ_7745c5c3_Err != nil {
1513- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 92}1526+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 92}
1514 }1527 }
1515 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))1528 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1516 if templ_7745c5c3_Err != nil {1529 if templ_7745c5c3_Err != nil {
1517 return templ_7745c5c3_Err1530 return templ_7745c5c3_Err
1518 }1531 }
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\"><input type=\"hidden\" name=\"from\" value=\"")1532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "\"><input type=\"hidden\" name=\"from\" value=\"")
1520 if templ_7745c5c3_Err != nil {1533 if templ_7745c5c3_Err != nil {
1521 return templ_7745c5c3_Err1534 return templ_7745c5c3_Err
1522 }1535 }
1523 var templ_7745c5c3_Var74 string1536 var templ_7745c5c3_Var74 string
1524 templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Edit.From))1537 templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Edit.From))
1525 if templ_7745c5c3_Err != nil {1538 if templ_7745c5c3_Err != nil {
1526- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 153}1539+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 153}
1527 }1540 }
1528 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)1541 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
1529 if templ_7745c5c3_Err != nil {1542 if templ_7745c5c3_Err != nil {
1530 return templ_7745c5c3_Err1543 return templ_7745c5c3_Err
1531 }1544 }
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "\"><textarea name=\"text\" maxlength=\"8000\">")1545+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\"><textarea name=\"text\" maxlength=\"8000\">")
1533 if templ_7745c5c3_Err != nil {1546 if templ_7745c5c3_Err != nil {
1534 return templ_7745c5c3_Err1547 return templ_7745c5c3_Err
1535 }1548 }
1536 var templ_7745c5c3_Var75 string1549 var templ_7745c5c3_Var75 string
1537 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)1550 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)
1538 if templ_7745c5c3_Err != nil {1551 if templ_7745c5c3_Err != nil {
1539- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 278, Col: 209}1552+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 209}
1540 }1553 }
1541 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))1554 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1542 if templ_7745c5c3_Err != nil {1555 if templ_7745c5c3_Err != nil {
1543 return templ_7745c5c3_Err1556 return templ_7745c5c3_Err
1544 }1557 }
1545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</textarea><button class=\"btn\" type=\"submit\">Save</button></form></details> ")1558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</textarea><button class=\"btn\" type=\"submit\">Save</button></form></details> ")
1546 if templ_7745c5c3_Err != nil {1559 if templ_7745c5c3_Err != nil {
1547 return templ_7745c5c3_Err1560 return templ_7745c5c3_Err
1548 }1561 }
1549 }1562 }
1550 if c.Delete != nil {1563 if c.Delete != nil {
1551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<details><summary>Delete</summary><form method=\"post\" action=\"")1564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<details><summary>Delete</summary><form method=\"post\" action=\"")
1552 if templ_7745c5c3_Err != nil {1565 if templ_7745c5c3_Err != nil {
1553 return templ_7745c5c3_Err1566 return templ_7745c5c3_Err
1554 }1567 }
1555 var templ_7745c5c3_Var76 templ.SafeURL1568 var templ_7745c5c3_Var76 templ.SafeURL
1556 templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Delete.Action))1569 templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Delete.Action))
1557 if templ_7745c5c3_Err != nil {1570 if templ_7745c5c3_Err != nil {
1558- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 281, Col: 96}1571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 96}
1559 }1572 }
1560 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))1573 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1561 if templ_7745c5c3_Err != nil {1574 if templ_7745c5c3_Err != nil {
1562 return templ_7745c5c3_Err1575 return templ_7745c5c3_Err
1563 }1576 }
1564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "\"><input type=\"hidden\" name=\"from\" value=\"")1577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\"><input type=\"hidden\" name=\"from\" value=\"")
1565 if templ_7745c5c3_Err != nil {1578 if templ_7745c5c3_Err != nil {
1566 return templ_7745c5c3_Err1579 return templ_7745c5c3_Err
1567 }1580 }
1568 var templ_7745c5c3_Var77 string1581 var templ_7745c5c3_Var77 string
1569 templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Delete.From))1582 templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Delete.From))
1570 if templ_7745c5c3_Err != nil {1583 if templ_7745c5c3_Err != nil {
1571- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 281, Col: 159}1584+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 159}
1572 }1585 }
1573 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)1586 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1574 if templ_7745c5c3_Err != nil {1587 if templ_7745c5c3_Err != nil {
1575 return templ_7745c5c3_Err1588 return templ_7745c5c3_Err
1576 }1589 }
1577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\"><p>Delete this note?</p><button class=\"btn\" type=\"submit\">Confirm delete</button></form></details> ")1590+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><p>Delete this note?</p><button class=\"btn\" type=\"submit\">Confirm delete</button></form></details> ")
1578 if templ_7745c5c3_Err != nil {1591 if templ_7745c5c3_Err != nil {
1579 return templ_7745c5c3_Err1592 return templ_7745c5c3_Err
1580 }1593 }
@@ -1586,7 +1599,7 @@ func commentView(c CommentView, suffix string) templ.Component {
1586 }1599 }
1587 }1600 }
1588 if c.Reply != nil {1601 if c.Reply != nil {
1589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<details data-reply><summary>Reply</summary>")1602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<details data-reply><summary>Reply</summary>")
1590 if templ_7745c5c3_Err != nil {1603 if templ_7745c5c3_Err != nil {
1591 return templ_7745c5c3_Err1604 return templ_7745c5c3_Err
1592 }1605 }
@@ -1594,80 +1607,80 @@ func commentView(c CommentView, suffix string) templ.Component {
1594 if templ_7745c5c3_Err != nil {1607 if templ_7745c5c3_Err != nil {
1595 return templ_7745c5c3_Err1608 return templ_7745c5c3_Err
1596 }1609 }
1597- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "</details> ")1610+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</details> ")
1598 if templ_7745c5c3_Err != nil {1611 if templ_7745c5c3_Err != nil {
1599 return templ_7745c5c3_Err1612 return templ_7745c5c3_Err
1600 }1613 }
1601 }1614 }
1602 if c.Resolve != nil {1615 if c.Resolve != nil {
1603- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<form class=\"c-act\" method=\"post\" action=\"")1616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<form class=\"c-act\" method=\"post\" action=\"")
1604 if templ_7745c5c3_Err != nil {1617 if templ_7745c5c3_Err != nil {
1605 return templ_7745c5c3_Err1618 return templ_7745c5c3_Err
1606 }1619 }
1607 var templ_7745c5c3_Var78 templ.SafeURL1620 var templ_7745c5c3_Var78 templ.SafeURL
1608 templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))1621 templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
1609 if templ_7745c5c3_Err != nil {1622 if templ_7745c5c3_Err != nil {
1610- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 292, Col: 77}1623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 294, Col: 77}
1611 }1624 }
1612 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))1625 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1613 if templ_7745c5c3_Err != nil {1626 if templ_7745c5c3_Err != nil {
1614 return templ_7745c5c3_Err1627 return templ_7745c5c3_Err
1615 }1628 }
1616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\"><input type=\"hidden\" name=\"resolved\" value=\"")1629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
1617 if templ_7745c5c3_Err != nil {1630 if templ_7745c5c3_Err != nil {
1618 return templ_7745c5c3_Err1631 return templ_7745c5c3_Err
1619 }1632 }
1620 var templ_7745c5c3_Var79 string1633 var templ_7745c5c3_Var79 string
1621 templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))1634 templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
1622 if templ_7745c5c3_Err != nil {1635 if templ_7745c5c3_Err != nil {
1623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 293, Col: 74}1636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 74}
1624 }1637 }
1625 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)1638 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1626 if templ_7745c5c3_Err != nil {1639 if templ_7745c5c3_Err != nil {
1627 return templ_7745c5c3_Err1640 return templ_7745c5c3_Err
1628 }1641 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"> ")1642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\"> ")
1630 if templ_7745c5c3_Err != nil {1643 if templ_7745c5c3_Err != nil {
1631 return templ_7745c5c3_Err1644 return templ_7745c5c3_Err
1632 }1645 }
1633 if c.Resolve.From > 0 {1646 if c.Resolve.From > 0 {
1634- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<input type=\"hidden\" name=\"from\" value=\"")1647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<input type=\"hidden\" name=\"from\" value=\"")
1635 if templ_7745c5c3_Err != nil {1648 if templ_7745c5c3_Err != nil {
1636 return templ_7745c5c3_Err1649 return templ_7745c5c3_Err
1637 }1650 }
1638 var templ_7745c5c3_Var80 string1651 var templ_7745c5c3_Var80 string
1639 templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))1652 templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
1640 if templ_7745c5c3_Err != nil {1653 if templ_7745c5c3_Err != nil {
1641- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 66}1654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 66}
1642 }1655 }
1643 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)1656 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1644 if templ_7745c5c3_Err != nil {1657 if templ_7745c5c3_Err != nil {
1645 return templ_7745c5c3_Err1658 return templ_7745c5c3_Err
1646 }1659 }
1647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\"> ")1660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "\"> ")
1648 if templ_7745c5c3_Err != nil {1661 if templ_7745c5c3_Err != nil {
1649 return templ_7745c5c3_Err1662 return templ_7745c5c3_Err
1650 }1663 }
1651 }1664 }
1652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<button type=\"submit\" class=\"btn\">")1665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<button type=\"submit\" class=\"btn\">")
1653 if templ_7745c5c3_Err != nil {1666 if templ_7745c5c3_Err != nil {
1654 return templ_7745c5c3_Err1667 return templ_7745c5c3_Err
1655 }1668 }
1656 var templ_7745c5c3_Var81 string1669 var templ_7745c5c3_Var81 string
1657 templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)1670 templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
1658 if templ_7745c5c3_Err != nil {1671 if templ_7745c5c3_Err != nil {
1659- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 55}1672+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 299, Col: 55}
1660 }1673 }
1661 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))1674 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1662 if templ_7745c5c3_Err != nil {1675 if templ_7745c5c3_Err != nil {
1663 return templ_7745c5c3_Err1676 return templ_7745c5c3_Err
1664 }1677 }
1665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</button></form>")1678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</button></form>")
1666 if templ_7745c5c3_Err != nil {1679 if templ_7745c5c3_Err != nil {
1667 return templ_7745c5c3_Err1680 return templ_7745c5c3_Err
1668 }1681 }
1669 }1682 }
1670- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "</div>")1683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</div>")
1671 if templ_7745c5c3_Err != nil {1684 if templ_7745c5c3_Err != nil {
1672 return templ_7745c5c3_Err1685 return templ_7745c5c3_Err
1673 }1686 }
@@ -1704,198 +1717,209 @@ func commentForm(f CommentForm, suffix string) templ.Component {
1704 templ_7745c5c3_Var82 = templ.NopComponent1717 templ_7745c5c3_Var82 = templ.NopComponent
1705 }1718 }
1706 ctx = templ.ClearChildren(ctx)1719 ctx = templ.ClearChildren(ctx)
1707- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<form class=\"cform\" method=\"post\" action=\"")1720+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<form class=\"cform\" method=\"post\" action=\"")
1708 if templ_7745c5c3_Err != nil {1721 if templ_7745c5c3_Err != nil {
1709 return templ_7745c5c3_Err1722 return templ_7745c5c3_Err
1710 }1723 }
1711 var templ_7745c5c3_Var83 templ.SafeURL1724 var templ_7745c5c3_Var83 templ.SafeURL
1712 templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))1725 templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
1713 if templ_7745c5c3_Err != nil {1726 if templ_7745c5c3_Err != nil {
1714- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 312, Col: 67}1727+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 67}
1715 }1728 }
1716 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))1729 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1717 if templ_7745c5c3_Err != nil {1730 if templ_7745c5c3_Err != nil {
1718 return templ_7745c5c3_Err1731 return templ_7745c5c3_Err
1719 }1732 }
1720- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\"><label for=\"")1733+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\"><label for=\"")
1721 if templ_7745c5c3_Err != nil {1734 if templ_7745c5c3_Err != nil {
1722 return templ_7745c5c3_Err1735 return templ_7745c5c3_Err
1723 }1736 }
1724 var templ_7745c5c3_Var84 string1737 var templ_7745c5c3_Var84 string
1725 templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)1738 templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1726 if templ_7745c5c3_Err != nil {1739 if templ_7745c5c3_Err != nil {
1727- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 313, Col: 28}1740+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 28}
1728 }1741 }
1729 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)1742 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
1730 if templ_7745c5c3_Err != nil {1743 if templ_7745c5c3_Err != nil {
1731 return templ_7745c5c3_Err1744 return templ_7745c5c3_Err
1732 }1745 }
1733- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\">")1746+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\">")
1734 if templ_7745c5c3_Err != nil {1747 if templ_7745c5c3_Err != nil {
1735 return templ_7745c5c3_Err1748 return templ_7745c5c3_Err
1736 }1749 }
1737 var templ_7745c5c3_Var85 string1750 var templ_7745c5c3_Var85 string
1738 templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)1751 templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
1739 if templ_7745c5c3_Err != nil {1752 if templ_7745c5c3_Err != nil {
1740- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 313, Col: 40}1753+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 40}
1741 }1754 }
1742 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))1755 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1743 if templ_7745c5c3_Err != nil {1756 if templ_7745c5c3_Err != nil {
1744 return templ_7745c5c3_Err1757 return templ_7745c5c3_Err
1745 }1758 }
1746- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</label> <textarea id=\"")1759+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</label> <textarea id=\"")
1747 if templ_7745c5c3_Err != nil {1760 if templ_7745c5c3_Err != nil {
1748 return templ_7745c5c3_Err1761 return templ_7745c5c3_Err
1749 }1762 }
1750 var templ_7745c5c3_Var86 string1763 var templ_7745c5c3_Var86 string
1751 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)1764 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1752 if templ_7745c5c3_Err != nil {1765 if templ_7745c5c3_Err != nil {
1753- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 21}1766+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 317, Col: 21}
1754 }1767 }
1755 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)1768 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
1756 if templ_7745c5c3_Err != nil {1769 if templ_7745c5c3_Err != nil {
1757 return templ_7745c5c3_Err1770 return templ_7745c5c3_Err
1758 }1771 }
1759- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\" name=\"text\" rows=\"3\" maxlength=\"8000\" placeholder=\"Markdown, 8000 characters at most\"")1772+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\" name=\"text\" rows=\"3\" maxlength=\"8000\"")
1760 if templ_7745c5c3_Err != nil {1773 if templ_7745c5c3_Err != nil {
1761 return templ_7745c5c3_Err1774 return templ_7745c5c3_Err
1762 }1775 }
1776+ if f.Plain {
1777+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, " required placeholder=\"plain text, 8000 characters at most\"")
1778+ if templ_7745c5c3_Err != nil {
1779+ return templ_7745c5c3_Err
1780+ }
1781+ } else {
1782+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " placeholder=\"Markdown, 8000 characters at most\"")
1783+ if templ_7745c5c3_Err != nil {
1784+ return templ_7745c5c3_Err
1785+ }
1786+ }
1763 if f.Focus {1787 if f.Focus {
1764- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, " autofocus")1788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, " autofocus")
1765 if templ_7745c5c3_Err != nil {1789 if templ_7745c5c3_Err != nil {
1766 return templ_7745c5c3_Err1790 return templ_7745c5c3_Err
1767 }1791 }
1768 }1792 }
1769- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "></textarea> ")1793+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "></textarea> ")
1770 if templ_7745c5c3_Err != nil {1794 if templ_7745c5c3_Err != nil {
1771 return templ_7745c5c3_Err1795 return templ_7745c5c3_Err
1772 }1796 }
1773 if f.Path != "" {1797 if f.Path != "" {
1774- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<input type=\"hidden\" name=\"path\" value=\"")1798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<input type=\"hidden\" name=\"path\" value=\"")
1775 if templ_7745c5c3_Err != nil {1799 if templ_7745c5c3_Err != nil {
1776 return templ_7745c5c3_Err1800 return templ_7745c5c3_Err
1777 }1801 }
1778 var templ_7745c5c3_Var87 string1802 var templ_7745c5c3_Var87 string
1779 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)1803 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
1780 if templ_7745c5c3_Err != nil {1804 if templ_7745c5c3_Err != nil {
1781- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 325, Col: 50}1805+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 332, Col: 50}
1782 }1806 }
1783 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)1807 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
1784 if templ_7745c5c3_Err != nil {1808 if templ_7745c5c3_Err != nil {
1785 return templ_7745c5c3_Err1809 return templ_7745c5c3_Err
1786 }1810 }
1787- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "\"> <input type=\"hidden\" name=\"line\" value=\"")1811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"> <input type=\"hidden\" name=\"line\" value=\"")
1788 if templ_7745c5c3_Err != nil {1812 if templ_7745c5c3_Err != nil {
1789 return templ_7745c5c3_Err1813 return templ_7745c5c3_Err
1790 }1814 }
1791 var templ_7745c5c3_Var88 string1815 var templ_7745c5c3_Var88 string
1792 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)1816 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
1793 if templ_7745c5c3_Err != nil {1817 if templ_7745c5c3_Err != nil {
1794- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 50}1818+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 333, Col: 50}
1795 }1819 }
1796 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)1820 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
1797 if templ_7745c5c3_Err != nil {1821 if templ_7745c5c3_Err != nil {
1798 return templ_7745c5c3_Err1822 return templ_7745c5c3_Err
1799 }1823 }
1800- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\"> <input type=\"hidden\" name=\"side\" value=\"")1824+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"> <input type=\"hidden\" name=\"side\" value=\"")
1801 if templ_7745c5c3_Err != nil {1825 if templ_7745c5c3_Err != nil {
1802 return templ_7745c5c3_Err1826 return templ_7745c5c3_Err
1803 }1827 }
1804 var templ_7745c5c3_Var89 string1828 var templ_7745c5c3_Var89 string
1805 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)1829 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
1806 if templ_7745c5c3_Err != nil {1830 if templ_7745c5c3_Err != nil {
1807- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 50}1831+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 50}
1808 }1832 }
1809 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var89)1833 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var89)
1810 if templ_7745c5c3_Err != nil {1834 if templ_7745c5c3_Err != nil {
1811 return templ_7745c5c3_Err1835 return templ_7745c5c3_Err
1812 }1836 }
1813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "\"> <label>from line <input type=\"number\" name=\"start_line\" min=\"1\" value=\"")1837+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"> <label>from line <input type=\"number\" name=\"start_line\" min=\"1\" value=\"")
1814 if templ_7745c5c3_Err != nil {1838 if templ_7745c5c3_Err != nil {
1815 return templ_7745c5c3_Err1839 return templ_7745c5c3_Err
1816 }1840 }
1817 var templ_7745c5c3_Var90 string1841 var templ_7745c5c3_Var90 string
1818 templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.StartLine)1842 templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.StartLine)
1819 if templ_7745c5c3_Err != nil {1843 if templ_7745c5c3_Err != nil {
1820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 328, Col: 86}1844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 86}
1821 }1845 }
1822 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)1846 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
1823 if templ_7745c5c3_Err != nil {1847 if templ_7745c5c3_Err != nil {
1824 return templ_7745c5c3_Err1848 return templ_7745c5c3_Err
1825 }1849 }
1826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "\"></label> ")1850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"></label> ")
1827 if templ_7745c5c3_Err != nil {1851 if templ_7745c5c3_Err != nil {
1828 return templ_7745c5c3_Err1852 return templ_7745c5c3_Err
1829 }1853 }
1830 }1854 }
1831 if f.ReplyTo != "" {1855 if f.ReplyTo != "" {
1832- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<input type=\"hidden\" name=\"reply_to\" value=\"")1856+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<input type=\"hidden\" name=\"reply_to\" value=\"")
1833 if templ_7745c5c3_Err != nil {1857 if templ_7745c5c3_Err != nil {
1834 return templ_7745c5c3_Err1858 return templ_7745c5c3_Err
1835 }1859 }
1836 var templ_7745c5c3_Var91 string1860 var templ_7745c5c3_Var91 string
1837 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ReplyTo)1861 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ReplyTo)
1838 if templ_7745c5c3_Err != nil {1862 if templ_7745c5c3_Err != nil {
1839- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 57}1863+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 57}
1840 }1864 }
1841 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)1865 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
1842 if templ_7745c5c3_Err != nil {1866 if templ_7745c5c3_Err != nil {
1843 return templ_7745c5c3_Err1867 return templ_7745c5c3_Err
1844 }1868 }
1845- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"> ")1869+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "\"> ")
1846 if templ_7745c5c3_Err != nil {1870 if templ_7745c5c3_Err != nil {
1847 return templ_7745c5c3_Err1871 return templ_7745c5c3_Err
1848 }1872 }
1849 }1873 }
1850- if f.Path == "" && f.ReplyTo == "" {1874+ if f.Path == "" && f.ReplyTo == "" && !f.Plain {
1851- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "<fieldset><legend>Verdict</legend><label><input type=\"radio\" name=\"verdict\" value=\"\" checked>Note</label><label><input type=\"radio\" name=\"verdict\" value=\"approve\">Approve</label><label><input type=\"radio\" name=\"verdict\" value=\"changes_requested\">Request changes</label></fieldset>")1875+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<fieldset><legend>Verdict</legend><label><input type=\"radio\" name=\"verdict\" value=\"\" checked>Note</label><label><input type=\"radio\" name=\"verdict\" value=\"approve\">Approve</label><label><input type=\"radio\" name=\"verdict\" value=\"changes_requested\">Request changes</label></fieldset>")
1852 if templ_7745c5c3_Err != nil {1876 if templ_7745c5c3_Err != nil {
1853 return templ_7745c5c3_Err1877 return templ_7745c5c3_Err
1854 }1878 }
1855 }1879 }
1856 if f.From > 0 {1880 if f.From > 0 {
1857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "<input type=\"hidden\" name=\"from\" value=\"")1881+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "<input type=\"hidden\" name=\"from\" value=\"")
1858 if templ_7745c5c3_Err != nil {1882 if templ_7745c5c3_Err != nil {
1859 return templ_7745c5c3_Err1883 return templ_7745c5c3_Err
1860 }1884 }
1861 var templ_7745c5c3_Var92 string1885 var templ_7745c5c3_Var92 string
1862 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))1886 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
1863 if templ_7745c5c3_Err != nil {1887 if templ_7745c5c3_Err != nil {
1864- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 337, Col: 56}1888+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 56}
1865 }1889 }
1866 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)1890 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
1867 if templ_7745c5c3_Err != nil {1891 if templ_7745c5c3_Err != nil {
1868 return templ_7745c5c3_Err1892 return templ_7745c5c3_Err
1869 }1893 }
1870- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "\">")1894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\">")
1871 if templ_7745c5c3_Err != nil {1895 if templ_7745c5c3_Err != nil {
1872 return templ_7745c5c3_Err1896 return templ_7745c5c3_Err
1873 }1897 }
1874 }1898 }
1875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")1899+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
1876 if templ_7745c5c3_Err != nil {1900 if templ_7745c5c3_Err != nil {
1877 return templ_7745c5c3_Err1901 return templ_7745c5c3_Err
1878 }1902 }
1879 if f.Cancel != "" {1903 if f.Cancel != "" {
1880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<a class=\"btn\" href=\"")1904+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<a class=\"btn\" href=\"")
1881 if templ_7745c5c3_Err != nil {1905 if templ_7745c5c3_Err != nil {
1882 return templ_7745c5c3_Err1906 return templ_7745c5c3_Err
1883 }1907 }
1884 var templ_7745c5c3_Var93 templ.SafeURL1908 var templ_7745c5c3_Var93 templ.SafeURL
1885 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))1909 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
1886 if templ_7745c5c3_Err != nil {1910 if templ_7745c5c3_Err != nil {
1887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 342, Col: 49}1911+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 349, Col: 49}
1888 }1912 }
1889 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))1913 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1890 if templ_7745c5c3_Err != nil {1914 if templ_7745c5c3_Err != nil {
1891 return templ_7745c5c3_Err1915 return templ_7745c5c3_Err
1892 }1916 }
1893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "\">Cancel</a>")1917+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "\">Cancel</a>")
1894 if templ_7745c5c3_Err != nil {1918 if templ_7745c5c3_Err != nil {
1895 return templ_7745c5c3_Err1919 return templ_7745c5c3_Err
1896 }1920 }
1897 }1921 }
1898- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "</div></form>")1922+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</div></form>")
1899 if templ_7745c5c3_Err != nil {1923 if templ_7745c5c3_Err != nil {
1900 return templ_7745c5c3_Err1924 return templ_7745c5c3_Err
1901 }1925 }
@@ -1924,20 +1948,20 @@ func mrRail(p MRPage) templ.Component {
1924 templ_7745c5c3_Var94 = templ.NopComponent1948 templ_7745c5c3_Var94 = templ.NopComponent
1925 }1949 }
1926 ctx = templ.ClearChildren(ctx)1950 ctx = templ.ClearChildren(ctx)
1927- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")1951+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
1928 if templ_7745c5c3_Err != nil {1952 if templ_7745c5c3_Err != nil {
1929 return templ_7745c5c3_Err1953 return templ_7745c5c3_Err
1930 }1954 }
1931 var templ_7745c5c3_Var95 string1955 var templ_7745c5c3_Var95 string
1932 templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))1956 templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
1933 if templ_7745c5c3_Err != nil {1957 if templ_7745c5c3_Err != nil {
1934- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 351, Col: 49}1958+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 358, Col: 49}
1935 }1959 }
1936 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))1960 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
1937 if templ_7745c5c3_Err != nil {1961 if templ_7745c5c3_Err != nil {
1938 return templ_7745c5c3_Err1962 return templ_7745c5c3_Err
1939 }1963 }
1940- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>")1964+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>")
1941 if templ_7745c5c3_Err != nil {1965 if templ_7745c5c3_Err != nil {
1942 return templ_7745c5c3_Err1966 return templ_7745c5c3_Err
1943 }1967 }
@@ -1945,71 +1969,71 @@ func mrRail(p MRPage) templ.Component {
1945 if templ_7745c5c3_Err != nil {1969 if templ_7745c5c3_Err != nil {
1946 return templ_7745c5c3_Err1970 return templ_7745c5c3_Err
1947 }1971 }
1948- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "</section>")1972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</section>")
1949 if templ_7745c5c3_Err != nil {1973 if templ_7745c5c3_Err != nil {
1950 return templ_7745c5c3_Err1974 return templ_7745c5c3_Err
1951 }1975 }
1952 if len(p.Verdicts) > 0 {1976 if len(p.Verdicts) > 0 {
1953- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<section><h2>Verdicts</h2><ul>")1977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<section><h2>Verdicts</h2><ul>")
1954 if templ_7745c5c3_Err != nil {1978 if templ_7745c5c3_Err != nil {
1955 return templ_7745c5c3_Err1979 return templ_7745c5c3_Err
1956 }1980 }
1957 for _, v := range p.Verdicts {1981 for _, v := range p.Verdicts {
1958- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<li><a href=\"")1982+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<li><a href=\"")
1959 if templ_7745c5c3_Err != nil {1983 if templ_7745c5c3_Err != nil {
1960 return templ_7745c5c3_Err1984 return templ_7745c5c3_Err
1961 }1985 }
1962 var templ_7745c5c3_Var96 templ.SafeURL1986 var templ_7745c5c3_Var96 templ.SafeURL
1963 templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(v.Href))1987 templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(v.Href))
1964 if templ_7745c5c3_Err != nil {1988 if templ_7745c5c3_Err != nil {
1965- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 37}1989+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 37}
1966 }1990 }
1967 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))1991 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1968 if templ_7745c5c3_Err != nil {1992 if templ_7745c5c3_Err != nil {
1969 return templ_7745c5c3_Err1993 return templ_7745c5c3_Err
1970 }1994 }
1971- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "\">")1995+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "\">")
1972 if templ_7745c5c3_Err != nil {1996 if templ_7745c5c3_Err != nil {
1973 return templ_7745c5c3_Err1997 return templ_7745c5c3_Err
1974 }1998 }
1975 var templ_7745c5c3_Var97 string1999 var templ_7745c5c3_Var97 string
1976 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(v.Author)2000 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(v.Author)
1977 if templ_7745c5c3_Err != nil {2001 if templ_7745c5c3_Err != nil {
1978- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 50}2002+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 50}
1979 }2003 }
1980 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))2004 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1981 if templ_7745c5c3_Err != nil {2005 if templ_7745c5c3_Err != nil {
1982 return templ_7745c5c3_Err2006 return templ_7745c5c3_Err
1983 }2007 }
1984- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, ": ")2008+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, ": ")
1985 if templ_7745c5c3_Err != nil {2009 if templ_7745c5c3_Err != nil {
1986 return templ_7745c5c3_Err2010 return templ_7745c5c3_Err
1987 }2011 }
1988 var templ_7745c5c3_Var98 string2012 var templ_7745c5c3_Var98 string
1989 templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)2013 templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
1990 if templ_7745c5c3_Err != nil {2014 if templ_7745c5c3_Err != nil {
1991- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 364, Col: 63}2015+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 63}
1992 }2016 }
1993 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))2017 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
1994 if templ_7745c5c3_Err != nil {2018 if templ_7745c5c3_Err != nil {
1995 return templ_7745c5c3_Err2019 return templ_7745c5c3_Err
1996 }2020 }
1997- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "</a> ")2021+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</a> ")
1998 if templ_7745c5c3_Err != nil {2022 if templ_7745c5c3_Err != nil {
1999 return templ_7745c5c3_Err2023 return templ_7745c5c3_Err
2000 }2024 }
2001 if v.Stale {2025 if v.Stale {
2002- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<span class=\"mute\">(stale)</span>")2026+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<span class=\"mute\">(stale)</span>")
2003 if templ_7745c5c3_Err != nil {2027 if templ_7745c5c3_Err != nil {
2004 return templ_7745c5c3_Err2028 return templ_7745c5c3_Err
2005 }2029 }
2006 }2030 }
2007- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "</li>")2031+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</li>")
2008 if templ_7745c5c3_Err != nil {2032 if templ_7745c5c3_Err != nil {
2009 return templ_7745c5c3_Err2033 return templ_7745c5c3_Err
2010 }2034 }
2011 }2035 }
2012- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "</ul></section>")2036+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</ul></section>")
2013 if templ_7745c5c3_Err != nil {2037 if templ_7745c5c3_Err != nil {
2014 return templ_7745c5c3_Err2038 return templ_7745c5c3_Err
2015 }2039 }
@@ -2018,74 +2042,74 @@ func mrRail(p MRPage) templ.Component {
2018 if templ_7745c5c3_Err != nil {2042 if templ_7745c5c3_Err != nil {
2019 return templ_7745c5c3_Err2043 return templ_7745c5c3_Err
2020 }2044 }
2021- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
2022 if templ_7745c5c3_Err != nil {2046 if templ_7745c5c3_Err != nil {
2023 return templ_7745c5c3_Err2047 return templ_7745c5c3_Err
2024 }2048 }
2025 var templ_7745c5c3_Var99 string2049 var templ_7745c5c3_Var99 string
2026 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))2050 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
2027 if templ_7745c5c3_Err != nil {2051 if templ_7745c5c3_Err != nil {
2028- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 375, Col: 71}2052+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 382, Col: 71}
2029 }2053 }
2030 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))2054 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2031 if templ_7745c5c3_Err != nil {2055 if templ_7745c5c3_Err != nil {
2032 return templ_7745c5c3_Err2056 return templ_7745c5c3_Err
2033 }2057 }
2034- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "</span></h2><ul class=\"pcommits\">")2058+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "</span></h2><ul class=\"pcommits\">")
2035 if templ_7745c5c3_Err != nil {2059 if templ_7745c5c3_Err != nil {
2036 return templ_7745c5c3_Err2060 return templ_7745c5c3_Err
2037 }2061 }
2038 for _, c := range p.Commits {2062 for _, c := range p.Commits {
2039- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "<li")2063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<li")
2040 if templ_7745c5c3_Err != nil {2064 if templ_7745c5c3_Err != nil {
2041 return templ_7745c5c3_Err2065 return templ_7745c5c3_Err
2042 }2066 }
2043 if c.New {2067 if c.New {
2044- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, " data-new")2068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, " data-new")
2045 if templ_7745c5c3_Err != nil {2069 if templ_7745c5c3_Err != nil {
2046 return templ_7745c5c3_Err2070 return templ_7745c5c3_Err
2047 }2071 }
2048 }2072 }
2049- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "><a href=\"")2073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "><a href=\"")
2050 if templ_7745c5c3_Err != nil {2074 if templ_7745c5c3_Err != nil {
2051 return templ_7745c5c3_Err2075 return templ_7745c5c3_Err
2052 }2076 }
2053 var templ_7745c5c3_Var100 templ.SafeURL2077 var templ_7745c5c3_Var100 templ.SafeURL
2054 templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))2078 templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
2055 if templ_7745c5c3_Err != nil {2079 if templ_7745c5c3_Err != nil {
2056- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 379, Col: 36}2080+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 36}
2057 }2081 }
2058 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))2082 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
2059 if templ_7745c5c3_Err != nil {2083 if templ_7745c5c3_Err != nil {
2060 return templ_7745c5c3_Err2084 return templ_7745c5c3_Err
2061 }2085 }
2062- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\">")2086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\">")
2063 if templ_7745c5c3_Err != nil {2087 if templ_7745c5c3_Err != nil {
2064 return templ_7745c5c3_Err2088 return templ_7745c5c3_Err
2065 }2089 }
2066 var templ_7745c5c3_Var101 string2090 var templ_7745c5c3_Var101 string
2067 templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)2091 templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
2068 if templ_7745c5c3_Err != nil {2092 if templ_7745c5c3_Err != nil {
2069- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 379, Col: 48}2093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 48}
2070 }2094 }
2071 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))2095 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
2072 if templ_7745c5c3_Err != nil {2096 if templ_7745c5c3_Err != nil {
2073 return templ_7745c5c3_Err2097 return templ_7745c5c3_Err
2074 }2098 }
2075- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</a> <span class=\"s\">")2099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</a> <span class=\"s\">")
2076 if templ_7745c5c3_Err != nil {2100 if templ_7745c5c3_Err != nil {
2077 return templ_7745c5c3_Err2101 return templ_7745c5c3_Err
2078 }2102 }
2079 var templ_7745c5c3_Var102 string2103 var templ_7745c5c3_Var102 string
2080 templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)2104 templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
2081 if templ_7745c5c3_Err != nil {2105 if templ_7745c5c3_Err != nil {
2082- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 380, Col: 32}2106+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 32}
2083 }2107 }
2084 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))2108 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
2085 if templ_7745c5c3_Err != nil {2109 if templ_7745c5c3_Err != nil {
2086 return templ_7745c5c3_Err2110 return templ_7745c5c3_Err
2087 }2111 }
2088- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span> ")2112+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "</span> ")
2089 if templ_7745c5c3_Err != nil {2113 if templ_7745c5c3_Err != nil {
2090 return templ_7745c5c3_Err2114 return templ_7745c5c3_Err
2091 }2115 }
@@ -2095,20 +2119,20 @@ func mrRail(p MRPage) templ.Component {
2095 return templ_7745c5c3_Err2119 return templ_7745c5c3_Err
2096 }2120 }
2097 }2121 }
2098- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<span class=\"mute\">")2122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "<span class=\"mute\">")
2099 if templ_7745c5c3_Err != nil {2123 if templ_7745c5c3_Err != nil {
2100 return templ_7745c5c3_Err2124 return templ_7745c5c3_Err
2101 }2125 }
2102 var templ_7745c5c3_Var103 string2126 var templ_7745c5c3_Var103 string
2103 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)2127 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
2104 if templ_7745c5c3_Err != nil {2128 if templ_7745c5c3_Err != nil {
2105- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 385, Col: 16}2129+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 392, Col: 16}
2106 }2130 }
2107 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))2131 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2108 if templ_7745c5c3_Err != nil {2132 if templ_7745c5c3_Err != nil {
2109 return templ_7745c5c3_Err2133 return templ_7745c5c3_Err
2110 }2134 }
2111- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, " ")2135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, " ")
2112 if templ_7745c5c3_Err != nil {2136 if templ_7745c5c3_Err != nil {
2113 return templ_7745c5c3_Err2137 return templ_7745c5c3_Err
2114 }2138 }
@@ -2116,39 +2140,39 @@ func mrRail(p MRPage) templ.Component {
2116 var templ_7745c5c3_Var104 string2140 var templ_7745c5c3_Var104 string
2117 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")2141 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
2118 if templ_7745c5c3_Err != nil {2142 if templ_7745c5c3_Err != nil {
2119- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 15}2143+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 15}
2120 }2144 }
2121 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))2145 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2122 if templ_7745c5c3_Err != nil {2146 if templ_7745c5c3_Err != nil {
2123 return templ_7745c5c3_Err2147 return templ_7745c5c3_Err
2124 }2148 }
2125- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<time datetime=\"")2149+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "<time datetime=\"")
2126 if templ_7745c5c3_Err != nil {2150 if templ_7745c5c3_Err != nil {
2127 return templ_7745c5c3_Err2151 return templ_7745c5c3_Err
2128 }2152 }
2129 var templ_7745c5c3_Var105 string2153 var templ_7745c5c3_Var105 string
2130 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)2154 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
2131 if templ_7745c5c3_Err != nil {2155 if templ_7745c5c3_Err != nil {
2132- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 42}2156+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 42}
2133 }2157 }
2134 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)2158 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
2135 if templ_7745c5c3_Err != nil {2159 if templ_7745c5c3_Err != nil {
2136 return templ_7745c5c3_Err2160 return templ_7745c5c3_Err
2137 }2161 }
2138- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "\">")2162+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "\">")
2139 if templ_7745c5c3_Err != nil {2163 if templ_7745c5c3_Err != nil {
2140 return templ_7745c5c3_Err2164 return templ_7745c5c3_Err
2141 }2165 }
2142 var templ_7745c5c3_Var106 string2166 var templ_7745c5c3_Var106 string
2143 templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)2167 templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
2144 if templ_7745c5c3_Err != nil {2168 if templ_7745c5c3_Err != nil {
2145- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 53}2169+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 53}
2146 }2170 }
2147 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))2171 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
2148 if templ_7745c5c3_Err != nil {2172 if templ_7745c5c3_Err != nil {
2149 return templ_7745c5c3_Err2173 return templ_7745c5c3_Err
2150 }2174 }
2151- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "</time> ")2175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "</time> ")
2152 if templ_7745c5c3_Err != nil {2176 if templ_7745c5c3_Err != nil {
2153 return templ_7745c5c3_Err2177 return templ_7745c5c3_Err
2154 }2178 }
@@ -2157,76 +2181,76 @@ func mrRail(p MRPage) templ.Component {
2157 var templ_7745c5c3_Var107 string2181 var templ_7745c5c3_Var107 string
2158 templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")2182 templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
2159 if templ_7745c5c3_Err != nil {2183 if templ_7745c5c3_Err != nil {
2160- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 390, Col: 18}2184+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 397, Col: 18}
2161 }2185 }
2162 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))2186 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
2163 if templ_7745c5c3_Err != nil {2187 if templ_7745c5c3_Err != nil {
2164 return templ_7745c5c3_Err2188 return templ_7745c5c3_Err
2165 }2189 }
2166 }2190 }
2167- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</span></li>")2191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</span></li>")
2168 if templ_7745c5c3_Err != nil {2192 if templ_7745c5c3_Err != nil {
2169 return templ_7745c5c3_Err2193 return templ_7745c5c3_Err
2170 }2194 }
2171 }2195 }
2172- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</ul>")2196+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</ul>")
2173 if templ_7745c5c3_Err != nil {2197 if templ_7745c5c3_Err != nil {
2174 return templ_7745c5c3_Err2198 return templ_7745c5c3_Err
2175 }2199 }
2176 if len(p.Commits) == 0 {2200 if len(p.Commits) == 0 {
2177- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "<p class=\"dim\">no commit to merge.</p>")2201+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<p class=\"dim\">no commit to merge.</p>")
2178 if templ_7745c5c3_Err != nil {2202 if templ_7745c5c3_Err != nil {
2179 return templ_7745c5c3_Err2203 return templ_7745c5c3_Err
2180 }2204 }
2181 }2205 }
2182 if p.CommitsNote != "" {2206 if p.CommitsNote != "" {
2183- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<p class=\"note\">")2207+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<p class=\"note\">")
2184 if templ_7745c5c3_Err != nil {2208 if templ_7745c5c3_Err != nil {
2185 return templ_7745c5c3_Err2209 return templ_7745c5c3_Err
2186 }2210 }
2187 var templ_7745c5c3_Var108 string2211 var templ_7745c5c3_Var108 string
2188 templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)2212 templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
2189 if templ_7745c5c3_Err != nil {2213 if templ_7745c5c3_Err != nil {
2190- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 400, Col: 34}2214+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 34}
2191 }2215 }
2192 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))2216 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2193 if templ_7745c5c3_Err != nil {2217 if templ_7745c5c3_Err != nil {
2194 return templ_7745c5c3_Err2218 return templ_7745c5c3_Err
2195 }2219 }
2196- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</p>")2220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</p>")
2197 if templ_7745c5c3_Err != nil {2221 if templ_7745c5c3_Err != nil {
2198 return templ_7745c5c3_Err2222 return templ_7745c5c3_Err
2199 }2223 }
2200 }2224 }
2201- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")2225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
2202 if templ_7745c5c3_Err != nil {2226 if templ_7745c5c3_Err != nil {
2203 return templ_7745c5c3_Err2227 return templ_7745c5c3_Err
2204 }2228 }
2205 var templ_7745c5c3_Var109 string2229 var templ_7745c5c3_Var109 string
2206 templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))2230 templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
2207 if templ_7745c5c3_Err != nil {2231 if templ_7745c5c3_Err != nil {
2208- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 36}2232+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 36}
2209 }2233 }
2210 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))2234 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2211 if templ_7745c5c3_Err != nil {2235 if templ_7745c5c3_Err != nil {
2212 return templ_7745c5c3_Err2236 return templ_7745c5c3_Err
2213 }2237 }
2214- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "</dd><dt>checks</dt><dd>")2238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</dd><dt>checks</dt><dd>")
2215 if templ_7745c5c3_Err != nil {2239 if templ_7745c5c3_Err != nil {
2216 return templ_7745c5c3_Err2240 return templ_7745c5c3_Err
2217 }2241 }
2218 if p.ChecksBlock {2242 if p.ChecksBlock {
2219- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "failing ones block the merge")2243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "failing ones block the merge")
2220 if templ_7745c5c3_Err != nil {2244 if templ_7745c5c3_Err != nil {
2221 return templ_7745c5c3_Err2245 return templ_7745c5c3_Err
2222 }2246 }
2223 } else {2247 } else {
2224- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "do not block the merge")2248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "do not block the merge")
2225 if templ_7745c5c3_Err != nil {2249 if templ_7745c5c3_Err != nil {
2226 return templ_7745c5c3_Err2250 return templ_7745c5c3_Err
2227 }2251 }
2228 }2252 }
2229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</dd></dl></section>")2253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</dd></dl></section>")
2230 if templ_7745c5c3_Err != nil {2254 if templ_7745c5c3_Err != nil {
2231 return templ_7745c5c3_Err2255 return templ_7745c5c3_Err
2232 }2256 }
@@ -2259,46 +2283,46 @@ func mrBar(p MRPage) templ.Component {
2259 templ_7745c5c3_Var110 = templ.NopComponent2283 templ_7745c5c3_Var110 = templ.NopComponent
2260 }2284 }
2261 ctx = templ.ClearChildren(ctx)2285 ctx = templ.ClearChildren(ctx)
2262- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")2286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
2263 if templ_7745c5c3_Err != nil {2287 if templ_7745c5c3_Err != nil {
2264 return templ_7745c5c3_Err2288 return templ_7745c5c3_Err
2265 }2289 }
2266 var templ_7745c5c3_Var111 string2290 var templ_7745c5c3_Var111 string
2267 templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))2291 templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2268 if templ_7745c5c3_Err != nil {2292 if templ_7745c5c3_Err != nil {
2269- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 55}2293+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 55}
2270 }2294 }
2271 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))2295 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
2272 if templ_7745c5c3_Err != nil {2296 if templ_7745c5c3_Err != nil {
2273 return templ_7745c5c3_Err2297 return templ_7745c5c3_Err
2274 }2298 }
2275- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, " · <span class=\"plus\">+")2299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " · <span class=\"plus\">+")
2276 if templ_7745c5c3_Err != nil {2300 if templ_7745c5c3_Err != nil {
2277 return templ_7745c5c3_Err2301 return templ_7745c5c3_Err
2278 }2302 }
2279 var templ_7745c5c3_Var112 string2303 var templ_7745c5c3_Var112 string
2280 templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))2304 templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2281 if templ_7745c5c3_Err != nil {2305 if templ_7745c5c3_Err != nil {
2282- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 95}2306+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 95}
2283 }2307 }
2284 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))2308 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
2285 if templ_7745c5c3_Err != nil {2309 if templ_7745c5c3_Err != nil {
2286 return templ_7745c5c3_Err2310 return templ_7745c5c3_Err
2287 }2311 }
2288- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</span> <span class=\"minus\">-")2312+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</span> <span class=\"minus\">-")
2289 if templ_7745c5c3_Err != nil {2313 if templ_7745c5c3_Err != nil {
2290 return templ_7745c5c3_Err2314 return templ_7745c5c3_Err
2291 }2315 }
2292 var templ_7745c5c3_Var113 string2316 var templ_7745c5c3_Var113 string
2293 templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))2317 templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2294 if templ_7745c5c3_Err != nil {2318 if templ_7745c5c3_Err != nil {
2295- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 140}2319+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 140}
2296 }2320 }
2297 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))2321 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
2298 if templ_7745c5c3_Err != nil {2322 if templ_7745c5c3_Err != nil {
2299 return templ_7745c5c3_Err2323 return templ_7745c5c3_Err
2300 }2324 }
2301- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</span></span>")2325+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</span></span>")
2302 if templ_7745c5c3_Err != nil {2326 if templ_7745c5c3_Err != nil {
2303 return templ_7745c5c3_Err2327 return templ_7745c5c3_Err
2304 }2328 }
@@ -2314,7 +2338,7 @@ func mrBar(p MRPage) templ.Component {
2314 if templ_7745c5c3_Err != nil {2338 if templ_7745c5c3_Err != nil {
2315 return templ_7745c5c3_Err2339 return templ_7745c5c3_Err
2316 }2340 }
2317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<span class=\"grow\"></span>")2341+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<span class=\"grow\"></span>")
2318 if templ_7745c5c3_Err != nil {2342 if templ_7745c5c3_Err != nil {
2319 return templ_7745c5c3_Err2343 return templ_7745c5c3_Err
2320 }2344 }
@@ -2327,7 +2351,7 @@ func mrBar(p MRPage) templ.Component {
2327 if templ_7745c5c3_Err != nil {2351 if templ_7745c5c3_Err != nil {
2328 return templ_7745c5c3_Err2352 return templ_7745c5c3_Err
2329 }2353 }
2330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " ")2354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " ")
2331 if templ_7745c5c3_Err != nil {2355 if templ_7745c5c3_Err != nil {
2332 return templ_7745c5c3_Err2356 return templ_7745c5c3_Err
2333 }2357 }
@@ -2336,7 +2360,7 @@ func mrBar(p MRPage) templ.Component {
2336 return templ_7745c5c3_Err2360 return templ_7745c5c3_Err
2337 }2361 }
2338 }2362 }
2339- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</div>")2363+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "</div>")
2340 if templ_7745c5c3_Err != nil {2364 if templ_7745c5c3_Err != nil {
2341 return templ_7745c5c3_Err2365 return templ_7745c5c3_Err
2342 }2366 }
@@ -2368,27 +2392,27 @@ func mergeMenu(p MRPage) templ.Component {
2368 templ_7745c5c3_Var114 = templ.NopComponent2392 templ_7745c5c3_Var114 = templ.NopComponent
2369 }2393 }
2370 ctx = templ.ClearChildren(ctx)2394 ctx = templ.ClearChildren(ctx)
2371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")2395+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
2372 if templ_7745c5c3_Err != nil {2396 if templ_7745c5c3_Err != nil {
2373 return templ_7745c5c3_Err2397 return templ_7745c5c3_Err
2374 }2398 }
2375 if p.Box.ReadyAction != "" {2399 if p.Box.ReadyAction != "" {
2376- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "Draft ")2400+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "Draft ")
2377 if templ_7745c5c3_Err != nil {2401 if templ_7745c5c3_Err != nil {
2378 return templ_7745c5c3_Err2402 return templ_7745c5c3_Err
2379 }2403 }
2380 } else {2404 } else {
2381- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "Merge ")2405+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "Merge ")
2382 if templ_7745c5c3_Err != nil {2406 if templ_7745c5c3_Err != nil {
2383 return templ_7745c5c3_Err2407 return templ_7745c5c3_Err
2384 }2408 }
2385 }2409 }
2386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")2410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
2387 if templ_7745c5c3_Err != nil {2411 if templ_7745c5c3_Err != nil {
2388 return templ_7745c5c3_Err2412 return templ_7745c5c3_Err
2389 }2413 }
2390 if p.Box.HeadGone {2414 if p.Box.HeadGone {
2391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "gone: the source branch was deleted")2415+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "gone: the source branch was deleted")
2392 if templ_7745c5c3_Err != nil {2416 if templ_7745c5c3_Err != nil {
2393 return templ_7745c5c3_Err2417 return templ_7745c5c3_Err
2394 }2418 }
@@ -2396,186 +2420,186 @@ func mergeMenu(p MRPage) templ.Component {
2396 var templ_7745c5c3_Var115 string2420 var templ_7745c5c3_Var115 string
2397 templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)2421 templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
2398 if templ_7745c5c3_Err != nil {2422 if templ_7745c5c3_Err != nil {
2399- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 19}2423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 466, Col: 19}
2400 }2424 }
2401 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))2425 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
2402 if templ_7745c5c3_Err != nil {2426 if templ_7745c5c3_Err != nil {
2403 return templ_7745c5c3_Err2427 return templ_7745c5c3_Err
2404 }2428 }
2405 }2429 }
2406- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "</dd><dt>into</dt><dd>")2430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</dd><dt>into</dt><dd>")
2407 if templ_7745c5c3_Err != nil {2431 if templ_7745c5c3_Err != nil {
2408 return templ_7745c5c3_Err2432 return templ_7745c5c3_Err
2409 }2433 }
2410 var templ_7745c5c3_Var116 string2434 var templ_7745c5c3_Var116 string
2411 templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2435 templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2412 if templ_7745c5c3_Err != nil {2436 if templ_7745c5c3_Err != nil {
2413- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 463, Col: 18}2437+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 18}
2414 }2438 }
2415 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))2439 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
2416 if templ_7745c5c3_Err != nil {2440 if templ_7745c5c3_Err != nil {
2417 return templ_7745c5c3_Err2441 return templ_7745c5c3_Err
2418 }2442 }
2419- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "</dd></dl>")2443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "</dd></dl>")
2420 if templ_7745c5c3_Err != nil {2444 if templ_7745c5c3_Err != nil {
2421 return templ_7745c5c3_Err2445 return templ_7745c5c3_Err
2422 }2446 }
2423 if p.Box.ReadyAction != "" {2447 if p.Box.ReadyAction != "" {
2424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")2448+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
2425 if templ_7745c5c3_Err != nil {2449 if templ_7745c5c3_Err != nil {
2426 return templ_7745c5c3_Err2450 return templ_7745c5c3_Err
2427 }2451 }
2428 var templ_7745c5c3_Var117 templ.SafeURL2452 var templ_7745c5c3_Var117 templ.SafeURL
2429 templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))2453 templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
2430 if templ_7745c5c3_Err != nil {2454 if templ_7745c5c3_Err != nil {
2431- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 467, Col: 80}2455+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 474, Col: 80}
2432 }2456 }
2433 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))2457 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
2434 if templ_7745c5c3_Err != nil {2458 if templ_7745c5c3_Err != nil {
2435 return templ_7745c5c3_Err2459 return templ_7745c5c3_Err
2436 }2460 }
2437- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")2461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
2438 if templ_7745c5c3_Err != nil {2462 if templ_7745c5c3_Err != nil {
2439 return templ_7745c5c3_Err2463 return templ_7745c5c3_Err
2440 }2464 }
2441 } else if !p.Box.HeadGone {2465 } else if !p.Box.HeadGone {
2442- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "<form class=\"d-acts\" method=\"post\" action=\"")2466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<form class=\"d-acts\" method=\"post\" action=\"")
2443 if templ_7745c5c3_Err != nil {2467 if templ_7745c5c3_Err != nil {
2444 return templ_7745c5c3_Err2468 return templ_7745c5c3_Err
2445 }2469 }
2446 var templ_7745c5c3_Var118 templ.SafeURL2470 var templ_7745c5c3_Var118 templ.SafeURL
2447 templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))2471 templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
2448 if templ_7745c5c3_Err != nil {2472 if templ_7745c5c3_Err != nil {
2449- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 471, Col: 80}2473+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 478, Col: 80}
2450 }2474 }
2451 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))2475 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
2452 if templ_7745c5c3_Err != nil {2476 if templ_7745c5c3_Err != nil {
2453 return templ_7745c5c3_Err2477 return templ_7745c5c3_Err
2454 }2478 }
2455- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "\"><input type=\"hidden\" name=\"head_id\" value=\"")2479+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2456 if templ_7745c5c3_Err != nil {2480 if templ_7745c5c3_Err != nil {
2457 return templ_7745c5c3_Err2481 return templ_7745c5c3_Err
2458 }2482 }
2459 var templ_7745c5c3_Var119 string2483 var templ_7745c5c3_Var119 string
2460 templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)2484 templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2461 if templ_7745c5c3_Err != nil {2485 if templ_7745c5c3_Err != nil {
2462- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 472, Col: 59}2486+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 59}
2463 }2487 }
2464 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)2488 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)
2465 if templ_7745c5c3_Err != nil {2489 if templ_7745c5c3_Err != nil {
2466 return templ_7745c5c3_Err2490 return templ_7745c5c3_Err
2467 }2491 }
2468- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")2492+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
2469 if templ_7745c5c3_Err != nil {2493 if templ_7745c5c3_Err != nil {
2470 return templ_7745c5c3_Err2494 return templ_7745c5c3_Err
2471 }2495 }
2472 for i, m := range p.Box.Modes {2496 for i, m := range p.Box.Modes {
2473- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<label><input type=\"radio\" name=\"mode\" value=\"")2497+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "<label><input type=\"radio\" name=\"mode\" value=\"")
2474 if templ_7745c5c3_Err != nil {2498 if templ_7745c5c3_Err != nil {
2475 return templ_7745c5c3_Err2499 return templ_7745c5c3_Err
2476 }2500 }
2477 var templ_7745c5c3_Var120 string2501 var templ_7745c5c3_Var120 string
2478 templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)2502 templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
2479 if templ_7745c5c3_Err != nil {2503 if templ_7745c5c3_Err != nil {
2480- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 477, Col: 55}2504+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 55}
2481 }2505 }
2482 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)2506 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)
2483 if templ_7745c5c3_Err != nil {2507 if templ_7745c5c3_Err != nil {
2484 return templ_7745c5c3_Err2508 return templ_7745c5c3_Err
2485 }2509 }
2486- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "\"")2510+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "\"")
2487 if templ_7745c5c3_Err != nil {2511 if templ_7745c5c3_Err != nil {
2488 return templ_7745c5c3_Err2512 return templ_7745c5c3_Err
2489 }2513 }
2490 if i == 0 {2514 if i == 0 {
2491- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, " checked")2515+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, " checked")
2492 if templ_7745c5c3_Err != nil {2516 if templ_7745c5c3_Err != nil {
2493 return templ_7745c5c3_Err2517 return templ_7745c5c3_Err
2494 }2518 }
2495 }2519 }
2496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "> ")2520+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "> ")
2497 if templ_7745c5c3_Err != nil {2521 if templ_7745c5c3_Err != nil {
2498 return templ_7745c5c3_Err2522 return templ_7745c5c3_Err
2499 }2523 }
2500 var templ_7745c5c3_Var121 string2524 var templ_7745c5c3_Var121 string
2501 templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)2525 templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2502 if templ_7745c5c3_Err != nil {2526 if templ_7745c5c3_Err != nil {
2503- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 478, Col: 17}2527+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 485, Col: 17}
2504 }2528 }
2505 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))2529 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2506 if templ_7745c5c3_Err != nil {2530 if templ_7745c5c3_Err != nil {
2507 return templ_7745c5c3_Err2531 return templ_7745c5c3_Err
2508 }2532 }
2509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, " ")2533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, " ")
2510 if templ_7745c5c3_Err != nil {2534 if templ_7745c5c3_Err != nil {
2511 return templ_7745c5c3_Err2535 return templ_7745c5c3_Err
2512 }2536 }
2513 if i < 9 {2537 if i < 9 {
2514- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<kbd aria-hidden=\"true\">")2538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<kbd aria-hidden=\"true\">")
2515 if templ_7745c5c3_Err != nil {2539 if templ_7745c5c3_Err != nil {
2516 return templ_7745c5c3_Err2540 return templ_7745c5c3_Err
2517 }2541 }
2518 var templ_7745c5c3_Var122 string2542 var templ_7745c5c3_Var122 string
2519 templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))2543 templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
2520 if templ_7745c5c3_Err != nil {2544 if templ_7745c5c3_Err != nil {
2521- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 46}2545+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 46}
2522 }2546 }
2523 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))2547 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
2524 if templ_7745c5c3_Err != nil {2548 if templ_7745c5c3_Err != nil {
2525 return templ_7745c5c3_Err2549 return templ_7745c5c3_Err
2526 }2550 }
2527- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "</kbd>")2551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "</kbd>")
2528 if templ_7745c5c3_Err != nil {2552 if templ_7745c5c3_Err != nil {
2529 return templ_7745c5c3_Err2553 return templ_7745c5c3_Err
2530 }2554 }
2531 }2555 }
2532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</label>")2556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "</label>")
2533 if templ_7745c5c3_Err != nil {2557 if templ_7745c5c3_Err != nil {
2534 return templ_7745c5c3_Err2558 return templ_7745c5c3_Err
2535 }2559 }
2536 }2560 }
2537- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "</fieldset>")2561+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</fieldset>")
2538 if templ_7745c5c3_Err != nil {2562 if templ_7745c5c3_Err != nil {
2539 return templ_7745c5c3_Err2563 return templ_7745c5c3_Err
2540 }2564 }
2541 if p.Box.CanDeleteBranch {2565 if p.Box.CanDeleteBranch {
2542- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")2566+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
2543 if templ_7745c5c3_Err != nil {2567 if templ_7745c5c3_Err != nil {
2544 return templ_7745c5c3_Err2568 return templ_7745c5c3_Err
2545 }2569 }
2546 var templ_7745c5c3_Var123 string2570 var templ_7745c5c3_Var123 string
2547 templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)2571 templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2548 if templ_7745c5c3_Err != nil {2572 if templ_7745c5c3_Err != nil {
2549- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 102}2573+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 493, Col: 102}
2550 }2574 }
2551 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))2575 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
2552 if templ_7745c5c3_Err != nil {2576 if templ_7745c5c3_Err != nil {
2553 return templ_7745c5c3_Err2577 return templ_7745c5c3_Err
2554 }2578 }
2555- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")2579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
2556 if templ_7745c5c3_Err != nil {2580 if templ_7745c5c3_Err != nil {
2557 return templ_7745c5c3_Err2581 return templ_7745c5c3_Err
2558 }2582 }
2559 }2583 }
2560- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")2584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
2561 if templ_7745c5c3_Err != nil {2585 if templ_7745c5c3_Err != nil {
2562 return templ_7745c5c3_Err2586 return templ_7745c5c3_Err
2563 }2587 }
2564 var templ_7745c5c3_Var124 string2588 var templ_7745c5c3_Var124 string
2565 templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2589 templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2566 if templ_7745c5c3_Err != nil {2590 if templ_7745c5c3_Err != nil {
2567- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 489, Col: 82}2591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 82}
2568 }2592 }
2569 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))2593 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
2570 if templ_7745c5c3_Err != nil {2594 if templ_7745c5c3_Err != nil {
2571 return templ_7745c5c3_Err2595 return templ_7745c5c3_Err
2572 }2596 }
2573- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</button></div></form>")2597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "</button></div></form>")
2574 if templ_7745c5c3_Err != nil {2598 if templ_7745c5c3_Err != nil {
2575 return templ_7745c5c3_Err2599 return templ_7745c5c3_Err
2576 }2600 }
2577 }2601 }
2578- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</div></details>")2602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</div></details>")
2579 if templ_7745c5c3_Err != nil {2603 if templ_7745c5c3_Err != nil {
2580 return templ_7745c5c3_Err2604 return templ_7745c5c3_Err
2581 }2605 }
@@ -2606,61 +2630,61 @@ func moreMenu(p MRPage) templ.Component {
2606 templ_7745c5c3_Var125 = templ.NopComponent2630 templ_7745c5c3_Var125 = templ.NopComponent
2607 }2631 }
2608 ctx = templ.ClearChildren(ctx)2632 ctx = templ.ClearChildren(ctx)
2609- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")2633+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")
2610 if templ_7745c5c3_Err != nil {2634 if templ_7745c5c3_Err != nil {
2611 return templ_7745c5c3_Err2635 return templ_7745c5c3_Err
2612 }2636 }
2613 if p.Box.CanMarkRead {2637 if p.Box.CanMarkRead {
2614- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<form method=\"post\" action=\"")2638+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<form method=\"post\" action=\"")
2615 if templ_7745c5c3_Err != nil {2639 if templ_7745c5c3_Err != nil {
2616 return templ_7745c5c3_Err2640 return templ_7745c5c3_Err
2617 }2641 }
2618 var templ_7745c5c3_Var126 templ.SafeURL2642 var templ_7745c5c3_Var126 templ.SafeURL
2619 templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))2643 templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
2620 if templ_7745c5c3_Err != nil {2644 if templ_7745c5c3_Err != nil {
2621- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 504, Col: 64}2645+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 511, Col: 64}
2622 }2646 }
2623 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))2647 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2624 if templ_7745c5c3_Err != nil {2648 if templ_7745c5c3_Err != nil {
2625 return templ_7745c5c3_Err2649 return templ_7745c5c3_Err
2626 }2650 }
2627- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "\"><input type=\"hidden\" name=\"head_id\" value=\"")2651+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2628 if templ_7745c5c3_Err != nil {2652 if templ_7745c5c3_Err != nil {
2629 return templ_7745c5c3_Err2653 return templ_7745c5c3_Err
2630 }2654 }
2631 var templ_7745c5c3_Var127 string2655 var templ_7745c5c3_Var127 string
2632 templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)2656 templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2633 if templ_7745c5c3_Err != nil {2657 if templ_7745c5c3_Err != nil {
2634- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 505, Col: 59}2658+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 512, Col: 59}
2635 }2659 }
2636 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)2660 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)
2637 if templ_7745c5c3_Err != nil {2661 if templ_7745c5c3_Err != nil {
2638 return templ_7745c5c3_Err2662 return templ_7745c5c3_Err
2639 }2663 }
2640- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")2664+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
2641 if templ_7745c5c3_Err != nil {2665 if templ_7745c5c3_Err != nil {
2642 return templ_7745c5c3_Err2666 return templ_7745c5c3_Err
2643 }2667 }
2644 }2668 }
2645- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "<form method=\"post\" action=\"")2669+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "<form method=\"post\" action=\"")
2646 if templ_7745c5c3_Err != nil {2670 if templ_7745c5c3_Err != nil {
2647 return templ_7745c5c3_Err2671 return templ_7745c5c3_Err
2648 }2672 }
2649 var templ_7745c5c3_Var128 templ.SafeURL2673 var templ_7745c5c3_Var128 templ.SafeURL
2650 templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))2674 templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
2651 if templ_7745c5c3_Err != nil {2675 if templ_7745c5c3_Err != nil {
2652- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 509, Col: 64}2676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 516, Col: 64}
2653 }2677 }
2654 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))2678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
2655 if templ_7745c5c3_Err != nil {2679 if templ_7745c5c3_Err != nil {
2656 return templ_7745c5c3_Err2680 return templ_7745c5c3_Err
2657 }2681 }
2658- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")2682+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
2659 if templ_7745c5c3_Err != nil {2683 if templ_7745c5c3_Err != nil {
2660 return templ_7745c5c3_Err2684 return templ_7745c5c3_Err
2661 }2685 }
2662 if !p.Box.HeadGone && p.Box.ReadyAction == "" {2686 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
2663- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")2687+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
2664 if templ_7745c5c3_Err != nil {2688 if templ_7745c5c3_Err != nil {
2665 return templ_7745c5c3_Err2689 return templ_7745c5c3_Err
2666 }2690 }
@@ -2668,12 +2692,12 @@ func moreMenu(p MRPage) templ.Component {
2668 if templ_7745c5c3_Err != nil {2692 if templ_7745c5c3_Err != nil {
2669 return templ_7745c5c3_Err2693 return templ_7745c5c3_Err
2670 }2694 }
2671- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "</details>")2695+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</details>")
2672 if templ_7745c5c3_Err != nil {2696 if templ_7745c5c3_Err != nil {
2673 return templ_7745c5c3_Err2697 return templ_7745c5c3_Err
2674 }2698 }
2675 }2699 }
2676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "</div></details>")2700+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</div></details>")
2677 if templ_7745c5c3_Err != nil {2701 if templ_7745c5c3_Err != nil {
2678 return templ_7745c5c3_Err2702 return templ_7745c5c3_Err
2679 }2703 }
@@ -2714,263 +2738,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
2714 }()2738 }()
2715 }2739 }
2716 ctx = templ.InitializeContext(ctx)2740 ctx = templ.InitializeContext(ctx)
2717- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "<header class=\"show mr-h\"><h1>")2741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "<header class=\"show mr-h\"><h1>")
2718 if templ_7745c5c3_Err != nil {2742 if templ_7745c5c3_Err != nil {
2719 return templ_7745c5c3_Err2743 return templ_7745c5c3_Err
2720 }2744 }
2721 var templ_7745c5c3_Var131 string2745 var templ_7745c5c3_Var131 string
2722 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)2746 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2723 if templ_7745c5c3_Err != nil {2747 if templ_7745c5c3_Err != nil {
2724- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 525, Col: 17}2748+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 17}
2725 }2749 }
2726 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))2750 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
2727 if templ_7745c5c3_Err != nil {2751 if templ_7745c5c3_Err != nil {
2728 return templ_7745c5c3_Err2752 return templ_7745c5c3_Err
2729 }2753 }
2730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, " → ")2754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, " → ")
2731 if templ_7745c5c3_Err != nil {2755 if templ_7745c5c3_Err != nil {
2732 return templ_7745c5c3_Err2756 return templ_7745c5c3_Err
2733 }2757 }
2734 var templ_7745c5c3_Var132 string2758 var templ_7745c5c3_Var132 string
2735 templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2759 templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2736 if templ_7745c5c3_Err != nil {2760 if templ_7745c5c3_Err != nil {
2737- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 525, Col: 34}2761+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 34}
2738 }2762 }
2739 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))2763 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
2740 if templ_7745c5c3_Err != nil {2764 if templ_7745c5c3_Err != nil {
2741 return templ_7745c5c3_Err2765 return templ_7745c5c3_Err
2742 }2766 }
2743- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")2767+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
2744 if templ_7745c5c3_Err != nil {2768 if templ_7745c5c3_Err != nil {
2745 return templ_7745c5c3_Err2769 return templ_7745c5c3_Err
2746 }2770 }
2747 var templ_7745c5c3_Var133 templ.SafeURL2771 var templ_7745c5c3_Var133 templ.SafeURL
2748 templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))2772 templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
2749 if templ_7745c5c3_Err != nil {2773 if templ_7745c5c3_Err != nil {
2750- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 528, Col: 45}2774+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 45}
2751 }2775 }
2752 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))2776 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
2753 if templ_7745c5c3_Err != nil {2777 if templ_7745c5c3_Err != nil {
2754 return templ_7745c5c3_Err2778 return templ_7745c5c3_Err
2755 }2779 }
2756- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "\">")2780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "\">")
2757 if templ_7745c5c3_Err != nil {2781 if templ_7745c5c3_Err != nil {
2758 return templ_7745c5c3_Err2782 return templ_7745c5c3_Err
2759 }2783 }
2760 var templ_7745c5c3_Var134 string2784 var templ_7745c5c3_Var134 string
2761 templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)2785 templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2762 if templ_7745c5c3_Err != nil {2786 if templ_7745c5c3_Err != nil {
2763- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 528, Col: 58}2787+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 58}
2764 }2788 }
2765 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))2789 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
2766 if templ_7745c5c3_Err != nil {2790 if templ_7745c5c3_Err != nil {
2767 return templ_7745c5c3_Err2791 return templ_7745c5c3_Err
2768 }2792 }
2769- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "</a></dd><dt>target</dt><dd><a href=\"")2793+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "</a></dd><dt>target</dt><dd><a href=\"")
2770 if templ_7745c5c3_Err != nil {2794 if templ_7745c5c3_Err != nil {
2771 return templ_7745c5c3_Err2795 return templ_7745c5c3_Err
2772 }2796 }
2773 var templ_7745c5c3_Var135 templ.SafeURL2797 var templ_7745c5c3_Var135 templ.SafeURL
2774 templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))2798 templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
2775 if templ_7745c5c3_Err != nil {2799 if templ_7745c5c3_Err != nil {
2776- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 530, Col: 45}2800+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 45}
2777 }2801 }
2778 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))2802 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
2779 if templ_7745c5c3_Err != nil {2803 if templ_7745c5c3_Err != nil {
2780 return templ_7745c5c3_Err2804 return templ_7745c5c3_Err
2781 }2805 }
2782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "\">")2806+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "\">")
2783 if templ_7745c5c3_Err != nil {2807 if templ_7745c5c3_Err != nil {
2784 return templ_7745c5c3_Err2808 return templ_7745c5c3_Err
2785 }2809 }
2786 var templ_7745c5c3_Var136 string2810 var templ_7745c5c3_Var136 string
2787 templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2811 templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2788 if templ_7745c5c3_Err != nil {2812 if templ_7745c5c3_Err != nil {
2789- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 530, Col: 58}2813+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 58}
2790 }2814 }
2791 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))2815 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
2792 if templ_7745c5c3_Err != nil {2816 if templ_7745c5c3_Err != nil {
2793 return templ_7745c5c3_Err2817 return templ_7745c5c3_Err
2794 }2818 }
2795- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</a></dd><dt>ahead</dt><dd>")2819+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</a></dd><dt>ahead</dt><dd>")
2796 if templ_7745c5c3_Err != nil {2820 if templ_7745c5c3_Err != nil {
2797 return templ_7745c5c3_Err2821 return templ_7745c5c3_Err
2798 }2822 }
2799 var templ_7745c5c3_Var137 string2823 var templ_7745c5c3_Var137 string
2800 templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)2824 templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
2801 if templ_7745c5c3_Err != nil {2825 if templ_7745c5c3_Err != nil {
2802- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 21}2826+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 21}
2803 }2827 }
2804 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))2828 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
2805 if templ_7745c5c3_Err != nil {2829 if templ_7745c5c3_Err != nil {
2806 return templ_7745c5c3_Err2830 return templ_7745c5c3_Err
2807 }2831 }
2808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "</dd></dl></header>")2832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "</dd></dl></header>")
2809 if templ_7745c5c3_Err != nil {2833 if templ_7745c5c3_Err != nil {
2810 return templ_7745c5c3_Err2834 return templ_7745c5c3_Err
2811 }2835 }
2812 if p.Note != "" {2836 if p.Note != "" {
2813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "<p class=\"notice\">")2837+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "<p class=\"notice\">")
2814 if templ_7745c5c3_Err != nil {2838 if templ_7745c5c3_Err != nil {
2815 return templ_7745c5c3_Err2839 return templ_7745c5c3_Err
2816 }2840 }
2817 var templ_7745c5c3_Var138 string2841 var templ_7745c5c3_Var138 string
2818 templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)2842 templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
2819 if templ_7745c5c3_Err != nil {2843 if templ_7745c5c3_Err != nil {
2820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 536, Col: 29}2844+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 29}
2821 }2845 }
2822 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))2846 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
2823 if templ_7745c5c3_Err != nil {2847 if templ_7745c5c3_Err != nil {
2824 return templ_7745c5c3_Err2848 return templ_7745c5c3_Err
2825 }2849 }
2826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "</p>")2850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</p>")
2827 if templ_7745c5c3_Err != nil {2851 if templ_7745c5c3_Err != nil {
2828 return templ_7745c5c3_Err2852 return templ_7745c5c3_Err
2829 }2853 }
2830 }2854 }
2831- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, " ")2855+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, " ")
2832 if templ_7745c5c3_Err != nil {2856 if templ_7745c5c3_Err != nil {
2833 return templ_7745c5c3_Err2857 return templ_7745c5c3_Err
2834 }2858 }
2835 if p.Existing != nil {2859 if p.Existing != nil {
2836- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")2860+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
2837 if templ_7745c5c3_Err != nil {2861 if templ_7745c5c3_Err != nil {
2838 return templ_7745c5c3_Err2862 return templ_7745c5c3_Err
2839 }2863 }
2840 var templ_7745c5c3_Var139 templ.SafeURL2864 var templ_7745c5c3_Var139 templ.SafeURL
2841 templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))2865 templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
2842 if templ_7745c5c3_Err != nil {2866 if templ_7745c5c3_Err != nil {
2843- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 114}2867+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 114}
2844 }2868 }
2845 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))2869 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
2846 if templ_7745c5c3_Err != nil {2870 if templ_7745c5c3_Err != nil {
2847 return templ_7745c5c3_Err2871 return templ_7745c5c3_Err
2848 }2872 }
2849- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "\">")2873+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "\">")
2850 if templ_7745c5c3_Err != nil {2874 if templ_7745c5c3_Err != nil {
2851 return templ_7745c5c3_Err2875 return templ_7745c5c3_Err
2852 }2876 }
2853 var templ_7745c5c3_Var140 string2877 var templ_7745c5c3_Var140 string
2854 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)2878 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
2855 if templ_7745c5c3_Err != nil {2879 if templ_7745c5c3_Err != nil {
2856- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 135}2880+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 135}
2857 }2881 }
2858 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))2882 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
2859 if templ_7745c5c3_Err != nil {2883 if templ_7745c5c3_Err != nil {
2860 return templ_7745c5c3_Err2884 return templ_7745c5c3_Err
2861 }2885 }
2862- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</a></p>")2886+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "</a></p>")
2863 if templ_7745c5c3_Err != nil {2887 if templ_7745c5c3_Err != nil {
2864 return templ_7745c5c3_Err2888 return templ_7745c5c3_Err
2865 }2889 }
2866 }2890 }
2867- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, " ")2891+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, " ")
2868 if templ_7745c5c3_Err != nil {2892 if templ_7745c5c3_Err != nil {
2869 return templ_7745c5c3_Err2893 return templ_7745c5c3_Err
2870 }2894 }
2871 if p.Create != nil {2895 if p.Create != nil {
2872- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<form class=\"cform create\" method=\"post\" action=\"")2896+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "<form class=\"cform create\" method=\"post\" action=\"")
2873 if templ_7745c5c3_Err != nil {2897 if templ_7745c5c3_Err != nil {
2874 return templ_7745c5c3_Err2898 return templ_7745c5c3_Err
2875 }2899 }
2876 var templ_7745c5c3_Var141 templ.SafeURL2900 var templ_7745c5c3_Var141 templ.SafeURL
2877 templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))2901 templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
2878 if templ_7745c5c3_Err != nil {2902 if templ_7745c5c3_Err != nil {
2879- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 542, Col: 83}2903+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 549, Col: 83}
2880 }2904 }
2881 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))2905 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
2882 if templ_7745c5c3_Err != nil {2906 if templ_7745c5c3_Err != nil {
2883 return templ_7745c5c3_Err2907 return templ_7745c5c3_Err
2884 }2908 }
2885- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "\"><input type=\"hidden\" name=\"source\" value=\"")2909+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "\"><input type=\"hidden\" name=\"source\" value=\"")
2886 if templ_7745c5c3_Err != nil {2910 if templ_7745c5c3_Err != nil {
2887 return templ_7745c5c3_Err2911 return templ_7745c5c3_Err
2888 }2912 }
2889 var templ_7745c5c3_Var142 string2913 var templ_7745c5c3_Var142 string
2890 templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)2914 templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
2891 if templ_7745c5c3_Err != nil {2915 if templ_7745c5c3_Err != nil {
2892- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 62}2916+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 550, Col: 62}
2893 }2917 }
2894 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var142)2918 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var142)
2895 if templ_7745c5c3_Err != nil {2919 if templ_7745c5c3_Err != nil {
2896 return templ_7745c5c3_Err2920 return templ_7745c5c3_Err
2897 }2921 }
2898- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "\"> <input type=\"hidden\" name=\"target\" value=\"")2922+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "\"> <input type=\"hidden\" name=\"target\" value=\"")
2899 if templ_7745c5c3_Err != nil {2923 if templ_7745c5c3_Err != nil {
2900 return templ_7745c5c3_Err2924 return templ_7745c5c3_Err
2901 }2925 }
2902 var templ_7745c5c3_Var143 string2926 var templ_7745c5c3_Var143 string
2903 templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)2927 templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
2904 if templ_7745c5c3_Err != nil {2928 if templ_7745c5c3_Err != nil {
2905- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 544, Col: 62}2929+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 551, Col: 62}
2906 }2930 }
2907 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var143)2931 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var143)
2908 if templ_7745c5c3_Err != nil {2932 if templ_7745c5c3_Err != nil {
2909 return templ_7745c5c3_Err2933 return templ_7745c5c3_Err
2910 }2934 }
2911- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")2935+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
2912 if templ_7745c5c3_Err != nil {2936 if templ_7745c5c3_Err != nil {
2913 return templ_7745c5c3_Err2937 return templ_7745c5c3_Err
2914 }2938 }
2915 var templ_7745c5c3_Var144 string2939 var templ_7745c5c3_Var144 string
2916 templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)2940 templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
2917 if templ_7745c5c3_Err != nil {2941 if templ_7745c5c3_Err != nil {
2918- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 97}2942+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 553, Col: 97}
2919 }2943 }
2920 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)2944 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
2921 if templ_7745c5c3_Err != nil {2945 if templ_7745c5c3_Err != nil {
2922 return templ_7745c5c3_Err2946 return templ_7745c5c3_Err
2923 }2947 }
2924- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")2948+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
2925 if templ_7745c5c3_Err != nil {2949 if templ_7745c5c3_Err != nil {
2926 return templ_7745c5c3_Err2950 return templ_7745c5c3_Err
2927 }2951 }
2928 }2952 }
2929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, " ")2953+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, " ")
2930 if templ_7745c5c3_Err != nil {2954 if templ_7745c5c3_Err != nil {
2931 return templ_7745c5c3_Err2955 return templ_7745c5c3_Err
2932 }2956 }
2933 if p.HasDiff {2957 if p.HasDiff {
2934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")2958+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
2935 if templ_7745c5c3_Err != nil {2959 if templ_7745c5c3_Err != nil {
2936 return templ_7745c5c3_Err2960 return templ_7745c5c3_Err
2937 }2961 }
2938 var templ_7745c5c3_Var145 string2962 var templ_7745c5c3_Var145 string
2939 templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))2963 templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2940 if templ_7745c5c3_Err != nil {2964 if templ_7745c5c3_Err != nil {
2941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 57}2965+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 57}
2942 }2966 }
2943 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))2967 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
2944 if templ_7745c5c3_Err != nil {2968 if templ_7745c5c3_Err != nil {
2945 return templ_7745c5c3_Err2969 return templ_7745c5c3_Err
2946 }2970 }
2947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, " · <span class=\"plus\">+")2971+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, " · <span class=\"plus\">+")
2948 if templ_7745c5c3_Err != nil {2972 if templ_7745c5c3_Err != nil {
2949 return templ_7745c5c3_Err2973 return templ_7745c5c3_Err
2950 }2974 }
2951 var templ_7745c5c3_Var146 string2975 var templ_7745c5c3_Var146 string
2952 templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))2976 templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2953 if templ_7745c5c3_Err != nil {2977 if templ_7745c5c3_Err != nil {
2954- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 97}2978+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 97}
2955 }2979 }
2956 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))2980 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
2957 if templ_7745c5c3_Err != nil {2981 if templ_7745c5c3_Err != nil {
2958 return templ_7745c5c3_Err2982 return templ_7745c5c3_Err
2959 }2983 }
2960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "</span> <span class=\"minus\">-")2984+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "</span> <span class=\"minus\">-")
2961 if templ_7745c5c3_Err != nil {2985 if templ_7745c5c3_Err != nil {
2962 return templ_7745c5c3_Err2986 return templ_7745c5c3_Err
2963 }2987 }
2964 var templ_7745c5c3_Var147 string2988 var templ_7745c5c3_Var147 string
2965 templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))2989 templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2966 if templ_7745c5c3_Err != nil {2990 if templ_7745c5c3_Err != nil {
2967- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 142}2991+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 142}
2968 }2992 }
2969 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))2993 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
2970 if templ_7745c5c3_Err != nil {2994 if templ_7745c5c3_Err != nil {
2971 return templ_7745c5c3_Err2995 return templ_7745c5c3_Err
2972 }2996 }
2973- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "</span></span> <span class=\"grow\"></span>")2997+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "</span></span> <span class=\"grow\"></span>")
2974 if templ_7745c5c3_Err != nil {2998 if templ_7745c5c3_Err != nil {
2975 return templ_7745c5c3_Err2999 return templ_7745c5c3_Err
2976 }3000 }
@@ -2986,64 +3010,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
2986 if templ_7745c5c3_Err != nil {3010 if templ_7745c5c3_Err != nil {
2987 return templ_7745c5c3_Err3011 return templ_7745c5c3_Err
2988 }3012 }
2989- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "</div>")3013+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "</div>")
2990 if templ_7745c5c3_Err != nil {3014 if templ_7745c5c3_Err != nil {
2991 return templ_7745c5c3_Err3015 return templ_7745c5c3_Err
2992 }3016 }
2993 if p.Invisible {3017 if p.Invisible {
2994- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")3018+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
2995 if templ_7745c5c3_Err != nil {3019 if templ_7745c5c3_Err != nil {
2996 return templ_7745c5c3_Err3020 return templ_7745c5c3_Err
2997 }3021 }
2998 }3022 }
2999- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, " <div id=\"diffs\"")3023+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, " <div id=\"diffs\"")
3000 if templ_7745c5c3_Err != nil {3024 if templ_7745c5c3_Err != nil {
3001 return templ_7745c5c3_Err3025 return templ_7745c5c3_Err
3002 }3026 }
3003 if p.Both {3027 if p.Both {
3004- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, " data-diff=\"")3028+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, " data-diff=\"")
3005 if templ_7745c5c3_Err != nil {3029 if templ_7745c5c3_Err != nil {
3006 return templ_7745c5c3_Err3030 return templ_7745c5c3_Err
3007 }3031 }
3008 var templ_7745c5c3_Var148 string3032 var templ_7745c5c3_Var148 string
3009 templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)3033 templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3010 if templ_7745c5c3_Err != nil {3034 if templ_7745c5c3_Err != nil {
3011- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 571, Col: 23}3035+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 578, Col: 23}
3012 }3036 }
3013 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var148)3037 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var148)
3014 if templ_7745c5c3_Err != nil {3038 if templ_7745c5c3_Err != nil {
3015 return templ_7745c5c3_Err3039 return templ_7745c5c3_Err
3016 }3040 }
3017- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "\"")3041+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "\"")
3018 if templ_7745c5c3_Err != nil {3042 if templ_7745c5c3_Err != nil {
3019 return templ_7745c5c3_Err3043 return templ_7745c5c3_Err
3020 }3044 }
3021 } else {3045 } else {
3022- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, " data-view=\"")3046+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, " data-view=\"")
3023 if templ_7745c5c3_Err != nil {3047 if templ_7745c5c3_Err != nil {
3024 return templ_7745c5c3_Err3048 return templ_7745c5c3_Err
3025 }3049 }
3026 var templ_7745c5c3_Var149 string3050 var templ_7745c5c3_Var149 string
3027 templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)3051 templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3028 if templ_7745c5c3_Err != nil {3052 if templ_7745c5c3_Err != nil {
3029- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 573, Col: 23}3053+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 580, Col: 23}
3030 }3054 }
3031 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var149)3055 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var149)
3032 if templ_7745c5c3_Err != nil {3056 if templ_7745c5c3_Err != nil {
3033 return templ_7745c5c3_Err3057 return templ_7745c5c3_Err
3034 }3058 }
3035- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, "\"")3059+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "\"")
3036 if templ_7745c5c3_Err != nil {3060 if templ_7745c5c3_Err != nil {
3037 return templ_7745c5c3_Err3061 return templ_7745c5c3_Err
3038 }3062 }
3039 }3063 }
3040 if p.Both && !p.Pinned {3064 if p.Both && !p.Pinned {
3041- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")3065+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
3042 if templ_7745c5c3_Err != nil {3066 if templ_7745c5c3_Err != nil {
3043 return templ_7745c5c3_Err3067 return templ_7745c5c3_Err
3044 }3068 }
3045 }3069 }
3046- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, ">")3070+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, ">")
3047 if templ_7745c5c3_Err != nil {3071 if templ_7745c5c3_Err != nil {
3048 return templ_7745c5c3_Err3072 return templ_7745c5c3_Err
3049 }3073 }
@@ -3053,7 +3077,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
3053 return templ_7745c5c3_Err3077 return templ_7745c5c3_Err
3054 }3078 }
3055 }3079 }
3056- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "</div>")3080+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "</div>")
3057 if templ_7745c5c3_Err != nil {3081 if templ_7745c5c3_Err != nil {
3058 return templ_7745c5c3_Err3082 return templ_7745c5c3_Err
3059 }3083 }
@@ -3089,77 +3113,77 @@ func compareRail(p ComparePage) templ.Component {
3089 templ_7745c5c3_Var150 = templ.NopComponent3113 templ_7745c5c3_Var150 = templ.NopComponent
3090 }3114 }
3091 ctx = templ.ClearChildren(ctx)3115 ctx = templ.ClearChildren(ctx)
3092- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")3116+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
3093 if templ_7745c5c3_Err != nil {3117 if templ_7745c5c3_Err != nil {
3094 return templ_7745c5c3_Err3118 return templ_7745c5c3_Err
3095 }3119 }
3096 var templ_7745c5c3_Var151 string3120 var templ_7745c5c3_Var151 string
3097 templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))3121 templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
3098 if templ_7745c5c3_Err != nil {3122 if templ_7745c5c3_Err != nil {
3099- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 589, Col: 67}3123+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 596, Col: 67}
3100 }3124 }
3101 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151))3125 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151))
3102 if templ_7745c5c3_Err != nil {3126 if templ_7745c5c3_Err != nil {
3103 return templ_7745c5c3_Err3127 return templ_7745c5c3_Err
3104 }3128 }
3105- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "</span></h2><ul class=\"pcommits\">")3129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "</span></h2><ul class=\"pcommits\">")
3106 if templ_7745c5c3_Err != nil {3130 if templ_7745c5c3_Err != nil {
3107 return templ_7745c5c3_Err3131 return templ_7745c5c3_Err
3108 }3132 }
3109 for _, c := range p.Ahead {3133 for _, c := range p.Ahead {
3110- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "<li><a href=\"")3134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "<li><a href=\"")
3111 if templ_7745c5c3_Err != nil {3135 if templ_7745c5c3_Err != nil {
3112 return templ_7745c5c3_Err3136 return templ_7745c5c3_Err
3113 }3137 }
3114 var templ_7745c5c3_Var152 templ.SafeURL3138 var templ_7745c5c3_Var152 templ.SafeURL
3115 templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))3139 templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
3116 if templ_7745c5c3_Err != nil {3140 if templ_7745c5c3_Err != nil {
3117- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 593, Col: 37}3141+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 37}
3118 }3142 }
3119 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))3143 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
3120 if templ_7745c5c3_Err != nil {3144 if templ_7745c5c3_Err != nil {
3121 return templ_7745c5c3_Err3145 return templ_7745c5c3_Err
3122 }3146 }
3123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "\">")3147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "\">")
3124 if templ_7745c5c3_Err != nil {3148 if templ_7745c5c3_Err != nil {
3125 return templ_7745c5c3_Err3149 return templ_7745c5c3_Err
3126 }3150 }
3127 var templ_7745c5c3_Var153 string3151 var templ_7745c5c3_Var153 string
3128 templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)3152 templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
3129 if templ_7745c5c3_Err != nil {3153 if templ_7745c5c3_Err != nil {
3130- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 593, Col: 49}3154+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 49}
3131 }3155 }
3132 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153))3156 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153))
3133 if templ_7745c5c3_Err != nil {3157 if templ_7745c5c3_Err != nil {
3134 return templ_7745c5c3_Err3158 return templ_7745c5c3_Err
3135 }3159 }
3136- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "</a> <span class=\"s\">")3160+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "</a> <span class=\"s\">")
3137 if templ_7745c5c3_Err != nil {3161 if templ_7745c5c3_Err != nil {
3138 return templ_7745c5c3_Err3162 return templ_7745c5c3_Err
3139 }3163 }
3140 var templ_7745c5c3_Var154 string3164 var templ_7745c5c3_Var154 string
3141 templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)3165 templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
3142 if templ_7745c5c3_Err != nil {3166 if templ_7745c5c3_Err != nil {
3143- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 594, Col: 32}3167+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 601, Col: 32}
3144 }3168 }
3145 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154))3169 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154))
3146 if templ_7745c5c3_Err != nil {3170 if templ_7745c5c3_Err != nil {
3147 return templ_7745c5c3_Err3171 return templ_7745c5c3_Err
3148 }3172 }
3149- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "</span> <span class=\"mute\">")3173+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "</span> <span class=\"mute\">")
3150 if templ_7745c5c3_Err != nil {3174 if templ_7745c5c3_Err != nil {
3151 return templ_7745c5c3_Err3175 return templ_7745c5c3_Err
3152 }3176 }
3153 var templ_7745c5c3_Var155 string3177 var templ_7745c5c3_Var155 string
3154 templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)3178 templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
3155 if templ_7745c5c3_Err != nil {3179 if templ_7745c5c3_Err != nil {
3156- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 596, Col: 16}3180+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 603, Col: 16}
3157 }3181 }
3158 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155))3182 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155))
3159 if templ_7745c5c3_Err != nil {3183 if templ_7745c5c3_Err != nil {
3160 return templ_7745c5c3_Err3184 return templ_7745c5c3_Err
3161 }3185 }
3162- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, " ")3186+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, " ")
3163 if templ_7745c5c3_Err != nil {3187 if templ_7745c5c3_Err != nil {
3164 return templ_7745c5c3_Err3188 return templ_7745c5c3_Err
3165 }3189 }
@@ -3167,90 +3191,90 @@ func compareRail(p ComparePage) templ.Component {
3167 var templ_7745c5c3_Var156 string3191 var templ_7745c5c3_Var156 string
3168 templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")3192 templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
3169 if templ_7745c5c3_Err != nil {3193 if templ_7745c5c3_Err != nil {
3170- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 15}3194+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 15}
3171 }3195 }
3172 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))3196 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
3173 if templ_7745c5c3_Err != nil {3197 if templ_7745c5c3_Err != nil {
3174 return templ_7745c5c3_Err3198 return templ_7745c5c3_Err
3175 }3199 }
3176- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "<time datetime=\"")3200+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "<time datetime=\"")
3177 if templ_7745c5c3_Err != nil {3201 if templ_7745c5c3_Err != nil {
3178 return templ_7745c5c3_Err3202 return templ_7745c5c3_Err
3179 }3203 }
3180 var templ_7745c5c3_Var157 string3204 var templ_7745c5c3_Var157 string
3181 templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)3205 templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
3182 if templ_7745c5c3_Err != nil {3206 if templ_7745c5c3_Err != nil {
3183- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 42}3207+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 42}
3184 }3208 }
3185 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var157)3209 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var157)
3186 if templ_7745c5c3_Err != nil {3210 if templ_7745c5c3_Err != nil {
3187 return templ_7745c5c3_Err3211 return templ_7745c5c3_Err
3188 }3212 }
3189- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "\">")3213+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "\">")
3190 if templ_7745c5c3_Err != nil {3214 if templ_7745c5c3_Err != nil {
3191 return templ_7745c5c3_Err3215 return templ_7745c5c3_Err
3192 }3216 }
3193 var templ_7745c5c3_Var158 string3217 var templ_7745c5c3_Var158 string
3194 templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)3218 templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
3195 if templ_7745c5c3_Err != nil {3219 if templ_7745c5c3_Err != nil {
3196- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 598, Col: 53}3220+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 53}
3197 }3221 }
3198 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))3222 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))
3199 if templ_7745c5c3_Err != nil {3223 if templ_7745c5c3_Err != nil {
3200 return templ_7745c5c3_Err3224 return templ_7745c5c3_Err
3201 }3225 }
3202- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "</time>")3226+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, "</time>")
3203 if templ_7745c5c3_Err != nil {3227 if templ_7745c5c3_Err != nil {
3204 return templ_7745c5c3_Err3228 return templ_7745c5c3_Err
3205 }3229 }
3206 }3230 }
3207- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, "</span></li>")3231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</span></li>")
3208 if templ_7745c5c3_Err != nil {3232 if templ_7745c5c3_Err != nil {
3209 return templ_7745c5c3_Err3233 return templ_7745c5c3_Err
3210 }3234 }
3211 }3235 }
3212- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "</ul>")3236+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "</ul>")
3213 if templ_7745c5c3_Err != nil {3237 if templ_7745c5c3_Err != nil {
3214 return templ_7745c5c3_Err3238 return templ_7745c5c3_Err
3215 }3239 }
3216 if len(p.Ahead) == 0 {3240 if len(p.Ahead) == 0 {
3217- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "<p class=\"dim\">no commit ahead of ")3241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "<p class=\"dim\">no commit ahead of ")
3218 if templ_7745c5c3_Err != nil {3242 if templ_7745c5c3_Err != nil {
3219 return templ_7745c5c3_Err3243 return templ_7745c5c3_Err
3220 }3244 }
3221 var templ_7745c5c3_Var159 string3245 var templ_7745c5c3_Var159 string
3222 templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)3246 templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3223 if templ_7745c5c3_Err != nil {3247 if templ_7745c5c3_Err != nil {
3224- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 47}3248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 612, Col: 47}
3225 }3249 }
3226 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159))3250 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159))
3227 if templ_7745c5c3_Err != nil {3251 if templ_7745c5c3_Err != nil {
3228 return templ_7745c5c3_Err3252 return templ_7745c5c3_Err
3229 }3253 }
3230- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, ".</p>")3254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, ".</p>")
3231 if templ_7745c5c3_Err != nil {3255 if templ_7745c5c3_Err != nil {
3232 return templ_7745c5c3_Err3256 return templ_7745c5c3_Err
3233 }3257 }
3234 }3258 }
3235- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</section>")3259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "</section>")
3236 if templ_7745c5c3_Err != nil {3260 if templ_7745c5c3_Err != nil {
3237 return templ_7745c5c3_Err3261 return templ_7745c5c3_Err
3238 }3262 }
3239 if p.HasDiff {3263 if p.HasDiff {
3240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")3264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
3241 if templ_7745c5c3_Err != nil {3265 if templ_7745c5c3_Err != nil {
3242 return templ_7745c5c3_Err3266 return templ_7745c5c3_Err
3243 }3267 }
3244 var templ_7745c5c3_Var160 string3268 var templ_7745c5c3_Var160 string
3245 templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))3269 templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
3246 if templ_7745c5c3_Err != nil {3270 if templ_7745c5c3_Err != nil {
3247- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 610, Col: 66}3271+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 617, Col: 66}
3248 }3272 }
3249 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))3273 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
3250 if templ_7745c5c3_Err != nil {3274 if templ_7745c5c3_Err != nil {
3251 return templ_7745c5c3_Err3275 return templ_7745c5c3_Err
3252 }3276 }
3253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "</span></h2>")3277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 304, "</span></h2>")
3254 if templ_7745c5c3_Err != nil {3278 if templ_7745c5c3_Err != nil {
3255 return templ_7745c5c3_Err3279 return templ_7745c5c3_Err
3256 }3280 }
@@ -3258,12 +3282,12 @@ func compareRail(p ComparePage) templ.Component {
3258 if templ_7745c5c3_Err != nil {3282 if templ_7745c5c3_Err != nil {
3259 return templ_7745c5c3_Err3283 return templ_7745c5c3_Err
3260 }3284 }
3261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, "</section>")3285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 305, "</section>")
3262 if templ_7745c5c3_Err != nil {3286 if templ_7745c5c3_Err != nil {
3263 return templ_7745c5c3_Err3287 return templ_7745c5c3_Err
3264 }3288 }
3265 }3289 }
3266- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")3290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 306, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
3267 if templ_7745c5c3_Err != nil {3291 if templ_7745c5c3_Err != nil {
3268 return templ_7745c5c3_Err3292 return templ_7745c5c3_Err
3269 }3293 }
@@ -3271,7 +3295,7 @@ func compareRail(p ComparePage) templ.Component {
3271 if templ_7745c5c3_Err != nil {3295 if templ_7745c5c3_Err != nil {
3272 return templ_7745c5c3_Err3296 return templ_7745c5c3_Err
3273 }3297 }
3274- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "</section>")3298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 307, "</section>")
3275 if templ_7745c5c3_Err != nil {3299 if templ_7745c5c3_Err != nil {
3276 return templ_7745c5c3_Err3300 return templ_7745c5c3_Err
3277 }3301 }
M internal/adapters/web/overlays.templ +1 -1
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
2626 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
3030 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3131 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
3232 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
M internal/adapters/web/overlays_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/pages.templ +8 -0
@@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) {
99 <div class="home" data-layout="cards">
1010 <section class="home-repos" aria-label="Repositories">
1111 <div class="home-bar">
12+ if p.Inbox != nil {
13+ <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14+ Inbox
15+ if p.Inbox.Count >= 0 {
16+ <span class="count">{ itoa(p.Inbox.Count) }</span>
17+ }
18+ </a>
19+ }
1220 <div class="filter">
1321 <label for="home-filter" class="sr">filter</label>
1422 <input
@@ -9,6 +9,14 @@ templ Home(l Layout, p HomePage) {
9 <div class="home" data-layout="cards">9 <div class="home" data-layout="cards">
10 <section class="home-repos" aria-label="Repositories">10 <section class="home-repos" aria-label="Repositories">
11 <div class="home-bar">11 <div class="home-bar">
12+ if p.Inbox != nil {
13+ <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14+ Inbox
15+ if p.Inbox.Count >= 0 {
16+ <span class="count">{ itoa(p.Inbox.Count) }</span>
17+ }
18+ </a>
19+ }
12 <div class="filter">20 <div class="filter">
13 <label for="home-filter" class="sr">filter</label>21 <label for="home-filter" class="sr">filter</label>
14 <input22 <input
M internal/adapters/web/pages_templ.go +409 -363 generated file: collapsed
M internal/adapters/web/repo.go +5 -0
@@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
131131 // layout is the Layout of the pages of a repository: the sections, the ref
132132 // switcher on the head ref and the ":" targets. The caller sets the title,
133133 // the crumbs, the status and the rail.
134+//
135+//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
134136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135137 l := s.base()
136138 l.Repo = d.name()
@@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155157 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156158 l.Sections = append(l.Sections, mrs)
157159 }
160+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
161+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162+ }
158163 l.Sections = s.withActions(l.Sections, d.name(), section)
159164 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160165
@@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
131 // layout is the Layout of the pages of a repository: the sections, the ref131 // layout is the Layout of the pages of a repository: the sections, the ref
132 // switcher on the head ref and the ":" targets. The caller sets the title,132 // switcher on the head ref and the ":" targets. The caller sets the title,
133 // the crumbs, the status and the rail.133 // the crumbs, the status and the rail.
134+//
135+//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
134 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
135 l := s.base()137 l := s.base()
136 l.Repo = d.name()138 l.Repo = d.name()
@@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155 mrs.Count = s.openMRCount(ctx, d.repo.Name)157 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156 l.Sections = append(l.Sections, mrs)158 l.Sections = append(l.Sections, mrs)
157 }159 }
160+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
161+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162+ }
158 l.Sections = s.withActions(l.Sections, d.name(), section)163 l.Sections = s.withActions(l.Sections, d.name(), section)
159 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}164 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160 165
M internal/adapters/web/session.go +16 -1
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196196 if r.Method != http.MethodPost {
197197 return false
198198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200215 }
201216
202217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196 if r.Method != http.MethodPost {196 if r.Method != http.MethodPost {
197 return false197 return false
198 }198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200 }215 }
201 216
202 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
M internal/adapters/web/shell.templ +3 -0
@@ -33,6 +33,9 @@ templ Shell(l Layout, rail templ.Component) {
3333 if l.MR {
3434 <script src={ l.Assets.MR } defer></script>
3535 }
36+ if l.IssueForm {
37+ <script src={ l.Assets.IssueForm } defer></script>
38+ }
3639 </head>
3740 <body
3841 data-ref={ l.RefPath }
@@ -33,6 +33,9 @@ templ Shell(l Layout, rail templ.Component) {
33 if l.MR {33 if l.MR {
34 <script src={ l.Assets.MR } defer></script>34 <script src={ l.Assets.MR } defer></script>
35 }35 }
36+ if l.IssueForm {
37+ <script src={ l.Assets.IssueForm } defer></script>
38+ }
36 </head>39 </head>
37 <body40 <body
38 data-ref={ l.RefPath }41 data-ref={ l.RefPath }
M internal/adapters/web/shell_templ.go +180 -161 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +25 -0
@@ -171,6 +171,22 @@
171171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
172172 }
173173
174+ /* the public issue form: counters, field errors, the honeypot out of sight */
175+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
176+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
177+ .cform output.over { color: var(--red); font-weight: 700; }
178+ .cform .f-error { color: var(--red); margin: 0; }
179+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
180+
181+ /* the inbox: items awaiting moderation, shown as text */
182+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
183+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
184+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
185+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
186+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
187+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
188+ .pending .p-btns form { margin: 0; }
189+
174190 .list.mrs {
175191 & .c-n { width: 64px; }
176192 & .c-state { width: 110px; }
@@ -184,6 +200,15 @@
184200 }
185201 #mr-notice:empty { display: none; }
186202
203+ /* the owner's inbox: counters, repository filter, then the two lists */
204+ .inbox {
205+ display: flex; flex-direction: column; gap: var(--s4);
206+ & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); }
207+ & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208+ & .since { align-self: flex-start; max-width: 100%; }
209+ }
210+ .inbox-btn { gap: var(--s2); }
211+
187212 @media (max-width: 700px) {
188213 .list.mrs .c-who { display: none; }
189214 }
@@ -171,6 +171,22 @@
171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }171 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
172 }172 }
173 173
174+ /* the public issue form: counters, field errors, the honeypot out of sight */
175+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
176+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
177+ .cform output.over { color: var(--red); font-weight: 700; }
178+ .cform .f-error { color: var(--red); margin: 0; }
179+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
180+
181+ /* the inbox: items awaiting moderation, shown as text */
182+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
183+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
184+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
185+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
186+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
187+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
188+ .pending .p-btns form { margin: 0; }
189+
174 .list.mrs {190 .list.mrs {
175 & .c-n { width: 64px; }191 & .c-n { width: 64px; }
176 & .c-state { width: 110px; }192 & .c-state { width: 110px; }
@@ -184,6 +200,15 @@
184 }200 }
185 #mr-notice:empty { display: none; }201 #mr-notice:empty { display: none; }
186 202
203+ /* the owner's inbox: counters, repository filter, then the two lists */
204+ .inbox {
205+ display: flex; flex-direction: column; gap: var(--s4);
206+ & .inbox-counts { font-size: var(--fs-l, var(--fs)); color: var(--ink-dim); }
207+ & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208+ & .since { align-self: flex-start; max-width: 100%; }
209+ }
210+ .inbox-btn { gap: var(--s2); }
211+
187 @media (max-width: 700px) {212 @media (max-width: 700px) {
188 .list.mrs .c-who { display: none; }213 .list.mrs .c-who { display: none; }
189 }214 }
M internal/adapters/web/static/js/ermite.js +2 -2
@@ -555,11 +555,11 @@
555555
556556 // 1. Keys.
557557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559559 // the merge request page (mr.js): the merge menu, mark read
560560 M: 'merge', R: 'read' };
561561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };
562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563563 let pending = 0;
564564 let pendingTimer = 0;
565565 addEventListener('keydown', (e) => {
@@ -555,11 +555,11 @@
555 555
556 // 1. Keys.556 // 1. Keys.
557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559 // the merge request page (mr.js): the merge menu, mark read559 // the merge request page (mr.js): the merge menu, mark read
560 M: 'merge', R: 'read' };560 M: 'merge', R: 'read' };
561 // "g t" is the key "t": the tabs have one event each.561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563 let pending = 0;563 let pending = 0;
564 let pendingTimer = 0;564 let pendingTimer = 0;
565 addEventListener('keydown', (e) => {565 addEventListener('keydown', (e) => {
A internal/adapters/web/static/js/issue-form.js +175 -0
@@ -0,0 +1,175 @@
1+// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2+// The server sends a proof of work with the form; this script starts the
3+// worker (pow-worker.js) when the page loads, so it solves while the visitor
4+// writes, shows the length counters, and enables the button once the solution
5+// is found and the minimum fill time has passed. Without scripts the form
6+// stays hidden (the page says why).
7+//
8+// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9+window.ermiteIssueForm = (() => {
10+ const core = {
11+ // counterClass is the class of a length counter: "over" above max, "warn"
12+ // from 90 % of it, else none.
13+ counterClass(n, max) {
14+ if (n > max) return 'over';
15+ if (n * 10 >= max * 9) return 'warn';
16+ return '';
17+ },
18+ // length counts code points, like the server.
19+ length(s) { return [...String(s)].length; },
20+ // serverLength is the length the server will see for field ('title',
21+ // 'body' or 'author'): NFC, control and bidi characters removed (body
22+ // keeps \n and \t), title and author trimmed (domain.cleanText).
23+ serverLength(field, value) {
24+ const multiline = field === 'body';
25+ let s = String(value).normalize('NFC');
26+ if (multiline) s = s.replace(/\r\n/g, '\n');
27+ let out = '';
28+ for (const ch of s) {
29+ const c = ch.codePointAt(0);
30+ if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31+ if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32+ if (c === 0x200e || c === 0x200f || c === 0x061c ||
33+ (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34+ out += ch;
35+ }
36+ if (!multiline) out = out.trim();
37+ return [...out].length;
38+ },
39+ draftKey(path) { return 'ermite:issue-draft:' + path; },
40+ // saveDraft keeps the text fields in storage (never the solution).
41+ saveDraft(storage, key, draft) {
42+ try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43+ },
44+ // restoreDraft returns the saved draft, or null, and clears it.
45+ restoreDraft(storage, key) {
46+ try {
47+ const raw = storage.getItem(key);
48+ if (raw === null || raw === undefined) return null;
49+ storage.removeItem(key);
50+ const d = JSON.parse(raw);
51+ if (!d || typeof d !== 'object') return null;
52+ return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53+ } catch (_) { return null; }
54+ },
55+ // onPageShow decides what to do on pageshow: a page restored from the
56+ // back/forward cache has a challenge that may be spent or expired.
57+ onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58+ // state is what the button and the status line show.
59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61+ if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62+ if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63+ if (sending) return { disabled: true, status: 'Sending…' };
64+ if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65+ if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66+ if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
68+ return { disabled: false, status: 'Ready.' };
69+ },
70+ };
71+
72+ function init(form) {
73+ const $ = (sel) => form.querySelector(sel);
74+ const button = $('button[type="submit"]');
75+ const status = $('[data-status]');
76+ const solution = $('input[name="solution"]');
77+ const minAt = Number(form.dataset.minSubmitAt) || 0;
78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79+ let timer = null;
80+
81+ function render() {
82+ const s = core.state({ ...ctx, now: Date.now(), minAt });
83+ button.disabled = s.disabled;
84+ if (status) status.textContent = s.status;
85+ clearTimeout(timer);
86+ if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87+ }
88+
89+ const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90+ const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91+ const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92+ if (storage) {
93+ const d = core.restoreDraft(storage, draftKey);
94+ if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95+ }
96+ window.addEventListener('pageshow', (e) => {
97+ if (core.onPageShow(e.persisted) !== 'reload') return;
98+ // The old challenge may be spent or expired: never reuse its solution.
99+ if (button) button.disabled = true;
100+ if (storage) {
101+ core.saveDraft(storage, draftKey, {
102+ title: fields.title ? fields.title.value : '',
103+ body: fields.body ? fields.body.value : '',
104+ author: fields.author ? fields.author.value : '',
105+ });
106+ }
107+ window.location.reload();
108+ });
109+
110+ const outs = [...form.querySelectorAll('output[data-for]')];
111+ for (const out of outs) {
112+ const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
114+ const max = Number(out.dataset.max);
115+ const update = () => {
116+ const n = core.serverLength(name, field.value);
117+ out.textContent = n + ' / ' + max;
118+ out.className = core.counterClass(n, max);
119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
121+ render();
122+ };
123+ field.addEventListener('input', update);
124+ update();
125+ }
126+
127+ form.addEventListener('submit', (e) => {
128+ if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129+ ctx.sending = true;
130+ render();
131+ });
132+
133+ form.hidden = false;
134+ form.removeAttribute('hidden');
135+
136+ if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137+ ctx.unsupported = true;
138+ render();
139+ return;
140+ }
141+ render();
142+ let worker;
143+ try {
144+ worker = new window.Worker(form.dataset.worker);
145+ } catch (_) {
146+ ctx.unsupported = true;
147+ render();
148+ return;
149+ }
150+ worker.onmessage = (e) => {
151+ const m = e.data || {};
152+ if (m.solution !== undefined) {
153+ solution.value = m.solution;
154+ ctx.solved = true;
155+ worker.terminate();
156+ } else if (m.error === 'nocrypto') {
157+ ctx.unsupported = true;
158+ } else if (m.error) {
159+ ctx.failed = true;
160+ } else {
161+ return;
162+ }
163+ render();
164+ };
165+ worker.onerror = () => { ctx.failed = true; render(); };
166+ worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167+ }
168+
169+ return { core, init };
170+})();
171+
172+document.addEventListener('DOMContentLoaded', () => {
173+ const form = document.querySelector('form[data-issue-form]');
174+ if (form) window.ermiteIssueForm.init(form);
175+});
@@ -0,0 +1,175 @@
1+// issue-form.js: the public issue form (/<repo>/issues/new for a visitor).
2+// The server sends a proof of work with the form; this script starts the
3+// worker (pow-worker.js) when the page loads, so it solves while the visitor
4+// writes, shows the length counters, and enables the button once the solution
5+// is found and the minimum fill time has passed. Without scripts the form
6+// stays hidden (the page says why).
7+//
8+// window.ermiteIssueForm.core is pure (jstest/issue-form.test.mjs).
9+window.ermiteIssueForm = (() => {
10+ const core = {
11+ // counterClass is the class of a length counter: "over" above max, "warn"
12+ // from 90 % of it, else none.
13+ counterClass(n, max) {
14+ if (n > max) return 'over';
15+ if (n * 10 >= max * 9) return 'warn';
16+ return '';
17+ },
18+ // length counts code points, like the server.
19+ length(s) { return [...String(s)].length; },
20+ // serverLength is the length the server will see for field ('title',
21+ // 'body' or 'author'): NFC, control and bidi characters removed (body
22+ // keeps \n and \t), title and author trimmed (domain.cleanText).
23+ serverLength(field, value) {
24+ const multiline = field === 'body';
25+ let s = String(value).normalize('NFC');
26+ if (multiline) s = s.replace(/\r\n/g, '\n');
27+ let out = '';
28+ for (const ch of s) {
29+ const c = ch.codePointAt(0);
30+ if (multiline && (c === 0x0a || c === 0x09)) { out += ch; continue; }
31+ if (c < 0x20 || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) continue;
32+ if (c === 0x200e || c === 0x200f || c === 0x061c ||
33+ (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) continue;
34+ out += ch;
35+ }
36+ if (!multiline) out = out.trim();
37+ return [...out].length;
38+ },
39+ draftKey(path) { return 'ermite:issue-draft:' + path; },
40+ // saveDraft keeps the text fields in storage (never the solution).
41+ saveDraft(storage, key, draft) {
42+ try { storage.setItem(key, JSON.stringify({ title: draft.title, body: draft.body, author: draft.author })); } catch (_) { /* storage unavailable */ }
43+ },
44+ // restoreDraft returns the saved draft, or null, and clears it.
45+ restoreDraft(storage, key) {
46+ try {
47+ const raw = storage.getItem(key);
48+ if (raw === null || raw === undefined) return null;
49+ storage.removeItem(key);
50+ const d = JSON.parse(raw);
51+ if (!d || typeof d !== 'object') return null;
52+ return { title: String(d.title || ''), body: String(d.body || ''), author: String(d.author || '') };
53+ } catch (_) { return null; }
54+ },
55+ // onPageShow decides what to do on pageshow: a page restored from the
56+ // back/forward cache has a challenge that may be spent or expired.
57+ onPageShow(persisted) { return persisted === true ? 'reload' : 'none'; },
58+ // state is what the button and the status line show.
59+ // {solved, now, minAt, sending, unsupported, failed, overs, empty}
60+ state({ solved, now, minAt, sending, unsupported, failed, overs, empty }) {
61+ if (unsupported) return { disabled: true, status: 'Your browser cannot run the check.' };
62+ if (failed) return { disabled: true, status: 'The check failed. Reload the page.' };
63+ if (sending) return { disabled: true, status: 'Sending…' };
64+ if (!solved) return { disabled: true, status: 'Checking your browser… (this takes a few seconds)' };
65+ if (now < minAt) return { disabled: true, status: 'Almost ready…' };
66+ if (overs) return { disabled: true, status: 'A field is too long.' };
67+ if (empty) return { disabled: true, status: 'Enter a title.' };
68+ return { disabled: false, status: 'Ready.' };
69+ },
70+ };
71+
72+ function init(form) {
73+ const $ = (sel) => form.querySelector(sel);
74+ const button = $('button[type="submit"]');
75+ const status = $('[data-status]');
76+ const solution = $('input[name="solution"]');
77+ const minAt = Number(form.dataset.minSubmitAt) || 0;
78+ const ctx = { solved: false, sending: false, unsupported: false, failed: false, overs: false, empty: true };
79+ let timer = null;
80+
81+ function render() {
82+ const s = core.state({ ...ctx, now: Date.now(), minAt });
83+ button.disabled = s.disabled;
84+ if (status) status.textContent = s.status;
85+ clearTimeout(timer);
86+ if (ctx.solved && Date.now() < minAt && !ctx.sending) timer = setTimeout(render, minAt - Date.now() + 20);
87+ }
88+
89+ const storage = (() => { try { return window.sessionStorage; } catch (_) { return null; } })();
90+ const draftKey = core.draftKey(window.location ? window.location.pathname : '');
91+ const fields = { title: $('[name="title"]'), body: $('[name="body"]'), author: $('[name="author"]') };
92+ if (storage) {
93+ const d = core.restoreDraft(storage, draftKey);
94+ if (d) for (const k of Object.keys(fields)) if (fields[k]) fields[k].value = d[k];
95+ }
96+ window.addEventListener('pageshow', (e) => {
97+ if (core.onPageShow(e.persisted) !== 'reload') return;
98+ // The old challenge may be spent or expired: never reuse its solution.
99+ if (button) button.disabled = true;
100+ if (storage) {
101+ core.saveDraft(storage, draftKey, {
102+ title: fields.title ? fields.title.value : '',
103+ body: fields.body ? fields.body.value : '',
104+ author: fields.author ? fields.author.value : '',
105+ });
106+ }
107+ window.location.reload();
108+ });
109+
110+ const outs = [...form.querySelectorAll('output[data-for]')];
111+ for (const out of outs) {
112+ const field = form.querySelector('#' + out.dataset.for);
113+ const name = field.name;
114+ const max = Number(out.dataset.max);
115+ const update = () => {
116+ const n = core.serverLength(name, field.value);
117+ out.textContent = n + ' / ' + max;
118+ out.className = core.counterClass(n, max);
119+ ctx.overs = outs.some((o) => o.className === 'over');
120+ if (name === 'title') ctx.empty = n === 0;
121+ render();
122+ };
123+ field.addEventListener('input', update);
124+ update();
125+ }
126+
127+ form.addEventListener('submit', (e) => {
128+ if (ctx.sending || !ctx.solved || Date.now() < minAt) { e.preventDefault(); return; }
129+ ctx.sending = true;
130+ render();
131+ });
132+
133+ form.hidden = false;
134+ form.removeAttribute('hidden');
135+
136+ if (!window.Worker || !window.crypto || !window.crypto.subtle || !form.dataset.worker) {
137+ ctx.unsupported = true;
138+ render();
139+ return;
140+ }
141+ render();
142+ let worker;
143+ try {
144+ worker = new window.Worker(form.dataset.worker);
145+ } catch (_) {
146+ ctx.unsupported = true;
147+ render();
148+ return;
149+ }
150+ worker.onmessage = (e) => {
151+ const m = e.data || {};
152+ if (m.solution !== undefined) {
153+ solution.value = m.solution;
154+ ctx.solved = true;
155+ worker.terminate();
156+ } else if (m.error === 'nocrypto') {
157+ ctx.unsupported = true;
158+ } else if (m.error) {
159+ ctx.failed = true;
160+ } else {
161+ return;
162+ }
163+ render();
164+ };
165+ worker.onerror = () => { ctx.failed = true; render(); };
166+ worker.postMessage({ nonce: form.dataset.nonce, difficulty: Number(form.dataset.difficulty) });
167+ }
168+
169+ return { core, init };
170+})();
171+
172+document.addEventListener('DOMContentLoaded', () => {
173+ const form = document.querySelector('form[data-issue-form]');
174+ if (form) window.ermiteIssueForm.init(form);
175+});
A internal/adapters/web/static/js/pow-worker.js +72 -0
@@ -0,0 +1,72 @@
1+// pow-worker.js: the proof of work of the public issue form, in a dedicated
2+// Worker (started by issue-form.js). It finds the smallest decimal counter n
3+// such that SHA-256(nonce bytes || ":" || n) has at least `difficulty` leading
4+// zero bits: the very check of the server (usecase/issue_submissions.go,
5+// powHash). The nonce arrives as base64url without padding, like the server
6+// sends it. Messages in: {nonce, difficulty}. Messages out:
7+// {progress: tried} now and then, then {solution: "<decimal>"} or {error}.
8+//
9+// window-less on purpose: ermitePow is also loaded by Node for the tests
10+// (jstest/issue-form.test.mjs).
11+(() => {
12+ const BATCH = 2048;
13+ const enc = new TextEncoder();
14+
15+ // leadingZeroBits counts the zero bits at the start of the bytes.
16+ function leadingZeroBits(bytes) {
17+ let n = 0;
18+ for (const b of bytes) {
19+ if (b === 0) { n += 8; continue; }
20+ return n + Math.clz32(b) - 24;
21+ }
22+ return n;
23+ }
24+
25+ // b64urlDecode turns unpadded base64url into bytes.
26+ function b64urlDecode(s) {
27+ const bin = atob(String(s).replace(/-/g, '+').replace(/_/g, '/'));
28+ const out = new Uint8Array(bin.length);
29+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30+ return out;
31+ }
32+
33+ // input is nonce || ":" || decimal(n), the bytes the server hashes.
34+ function input(nonce, n) {
35+ const tail = enc.encode(':' + n);
36+ const out = new Uint8Array(nonce.length + tail.length);
37+ out.set(nonce, 0);
38+ out.set(tail, nonce.length);
39+ return out;
40+ }
41+
42+ // solve returns the smallest solution as a decimal string. digest is
43+ // (Uint8Array) => Promise<ArrayBuffer-like>, crypto.subtle.digest by default.
44+ // Hashes are asked a batch at a time, in order, so the first hit of the first
45+ // batch that has one is the smallest. onProgress(tried) is called after each
46+ // batch; it may return true to stop (solve then returns null).
47+ async function solve(nonce, difficulty, { digest, batch = BATCH, onProgress } = {}) {
48+ const dig = digest || ((d) => globalThis.crypto.subtle.digest('SHA-256', d));
49+ for (let start = 0; ; start += batch) {
50+ const sums = await Promise.all(Array.from({ length: batch }, async (_, i) => new Uint8Array(await dig(input(nonce, start + i)))));
51+ for (let i = 0; i < batch; i++) {
52+ if (leadingZeroBits(sums[i]) >= difficulty) return String(start + i);
53+ }
54+ if (onProgress && onProgress(start + batch)) return null;
55+ }
56+ }
57+
58+ globalThis.ermitePow = { leadingZeroBits, b64urlDecode, input, solve };
59+
60+ if (typeof WorkerGlobalScope !== 'undefined' && typeof globalThis.postMessage === 'function') {
61+ globalThis.onmessage = async (e) => {
62+ try {
63+ if (!globalThis.crypto || !globalThis.crypto.subtle) { postMessage({ error: 'nocrypto' }); return; }
64+ const { nonce, difficulty } = e.data;
65+ const solution = await solve(b64urlDecode(nonce), difficulty, { onProgress: (tried) => { postMessage({ progress: tried }); } });
66+ postMessage({ solution });
67+ } catch (err) {
68+ postMessage({ error: String(err && err.message || err) });
69+ }
70+ };
71+ }
72+})();
@@ -0,0 +1,72 @@
1+// pow-worker.js: the proof of work of the public issue form, in a dedicated
2+// Worker (started by issue-form.js). It finds the smallest decimal counter n
3+// such that SHA-256(nonce bytes || ":" || n) has at least `difficulty` leading
4+// zero bits: the very check of the server (usecase/issue_submissions.go,
5+// powHash). The nonce arrives as base64url without padding, like the server
6+// sends it. Messages in: {nonce, difficulty}. Messages out:
7+// {progress: tried} now and then, then {solution: "<decimal>"} or {error}.
8+//
9+// window-less on purpose: ermitePow is also loaded by Node for the tests
10+// (jstest/issue-form.test.mjs).
11+(() => {
12+ const BATCH = 2048;
13+ const enc = new TextEncoder();
14+
15+ // leadingZeroBits counts the zero bits at the start of the bytes.
16+ function leadingZeroBits(bytes) {
17+ let n = 0;
18+ for (const b of bytes) {
19+ if (b === 0) { n += 8; continue; }
20+ return n + Math.clz32(b) - 24;
21+ }
22+ return n;
23+ }
24+
25+ // b64urlDecode turns unpadded base64url into bytes.
26+ function b64urlDecode(s) {
27+ const bin = atob(String(s).replace(/-/g, '+').replace(/_/g, '/'));
28+ const out = new Uint8Array(bin.length);
29+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30+ return out;
31+ }
32+
33+ // input is nonce || ":" || decimal(n), the bytes the server hashes.
34+ function input(nonce, n) {
35+ const tail = enc.encode(':' + n);
36+ const out = new Uint8Array(nonce.length + tail.length);
37+ out.set(nonce, 0);
38+ out.set(tail, nonce.length);
39+ return out;
40+ }
41+
42+ // solve returns the smallest solution as a decimal string. digest is
43+ // (Uint8Array) => Promise<ArrayBuffer-like>, crypto.subtle.digest by default.
44+ // Hashes are asked a batch at a time, in order, so the first hit of the first
45+ // batch that has one is the smallest. onProgress(tried) is called after each
46+ // batch; it may return true to stop (solve then returns null).
47+ async function solve(nonce, difficulty, { digest, batch = BATCH, onProgress } = {}) {
48+ const dig = digest || ((d) => globalThis.crypto.subtle.digest('SHA-256', d));
49+ for (let start = 0; ; start += batch) {
50+ const sums = await Promise.all(Array.from({ length: batch }, async (_, i) => new Uint8Array(await dig(input(nonce, start + i)))));
51+ for (let i = 0; i < batch; i++) {
52+ if (leadingZeroBits(sums[i]) >= difficulty) return String(start + i);
53+ }
54+ if (onProgress && onProgress(start + batch)) return null;
55+ }
56+ }
57+
58+ globalThis.ermitePow = { leadingZeroBits, b64urlDecode, input, solve };
59+
60+ if (typeof WorkerGlobalScope !== 'undefined' && typeof globalThis.postMessage === 'function') {
61+ globalThis.onmessage = async (e) => {
62+ try {
63+ if (!globalThis.crypto || !globalThis.crypto.subtle) { postMessage({ error: 'nocrypto' }); return; }
64+ const { nonce, difficulty } = e.data;
65+ const solution = await solve(b64urlDecode(nonce), difficulty, { onProgress: (tried) => { postMessage({ progress: tried }); } });
66+ postMessage({ solution });
67+ } catch (err) {
68+ postMessage({ error: String(err && err.message || err) });
69+ }
70+ };
71+ }
72+})();
M internal/adapters/web/view.go +12 -0
@@ -69,6 +69,8 @@ type Layout struct {
6969 Passkeys bool
7070 // MR loads the script of the merge request page (mr.js).
7171 MR bool
72+ // IssueForm loads the script of the public issue form (issue-form.js).
73+ IssueForm bool
7274 }
7375
7476 // Link is a plain label + href pair.
@@ -135,6 +137,16 @@ type SortLink struct {
135137 type HomePage struct {
136138 Sorts []SortLink
137139 Rows []HomeRow
140+ // Inbox is the button to the owner's inbox; nil unless the owner is signed
141+ // in.
142+ Inbox *InboxButton
143+}
144+
145+// InboxButton is the link to the inbox with the number of open items; Count is
146+// negative when it could not be read.
147+type InboxButton struct {
148+ Href string
149+ Count int
138150 }
139151
140152 // Fact is one key/value row of the facts block.
@@ -69,6 +69,8 @@ type Layout struct {
69 Passkeys bool69 Passkeys bool
70 // MR loads the script of the merge request page (mr.js).70 // MR loads the script of the merge request page (mr.js).
71 MR bool71 MR bool
72+ // IssueForm loads the script of the public issue form (issue-form.js).
73+ IssueForm bool
72 }74 }
73 75
74 // Link is a plain label + href pair.76 // Link is a plain label + href pair.
@@ -135,6 +137,16 @@ type SortLink struct {
135 type HomePage struct {137 type HomePage struct {
136 Sorts []SortLink138 Sorts []SortLink
137 Rows []HomeRow139 Rows []HomeRow
140+ // Inbox is the button to the owner's inbox; nil unless the owner is signed
141+ // in.
142+ Inbox *InboxButton
143+}
144+
145+// InboxButton is the link to the inbox with the number of open items; Count is
146+// negative when it could not be read.
147+type InboxButton struct {
148+ Href string
149+ Count int
138 }150 }
139 151
140 // Fact is one key/value row of the facts block.152 // Fact is one key/value row of the facts block.
M internal/adapters/web/web.go +46 -1
@@ -54,6 +54,28 @@ type Config struct {
5454 // there are no such pages, nor section for them. Leave it unset rather
5555 // than assigning a typed nil pointer.
5656 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
75+ // Inbox serves the owner's inbox page (/-/inbox) and the button that
76+ // leads to it. Without it there is neither. Leave it unset rather than
77+ // assigning a typed nil pointer.
78+ Inbox ports.InboxService
5779 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
5880 // commits and merge requests. Without it there are no such pages. Leave it
5981 // unset rather than assigning a typed nil pointer.
@@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux {
166188 if s.passkeysOn() {
167189 s.passkeyRoutes(mux)
168190 }
191+ if s.cfg.Inbox != nil {
192+ mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
198+ }
169199 mux.HandleFunc("GET /{$}", s.page(s.home))
170200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
171201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux {
182212 if s.cfg.MergeRequests != nil {
183213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184214 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
215+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -197,6 +227,21 @@ func (s *Server) routes() *http.ServeMux {
197227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
198228 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
199229 }
230+ if s.cfg.Issues != nil {
231+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
232+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
233+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
234+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
235+ // The writes: POST only, behind the gate (ServeHTTP).
236+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
237+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
238+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
239+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
240+ }
241+ if s.cfg.IssueForm != nil {
242+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
243+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
244+ }
200245 if s.cfg.Runs != nil {
201246 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
202247 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
@@ -54,6 +54,28 @@ type Config struct {
54 // there are no such pages, nor section for them. Leave it unset rather54 // there are no such pages, nor section for them. Leave it unset rather
55 // than assigning a typed nil pointer.55 // than assigning a typed nil pointer.
56 MergeRequests ports.MergeRequestService56 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
75+ // Inbox serves the owner's inbox page (/-/inbox) and the button that
76+ // leads to it. Without it there is neither. Leave it unset rather than
77+ // assigning a typed nil pointer.
78+ Inbox ports.InboxService
57 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to79 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58 // commits and merge requests. Without it there are no such pages. Leave it80 // commits and merge requests. Without it there are no such pages. Leave it
59 // unset rather than assigning a typed nil pointer.81 // unset rather than assigning a typed nil pointer.
@@ -166,6 +188,14 @@ func (s *Server) routes() *http.ServeMux {
166 if s.passkeysOn() {188 if s.passkeysOn() {
167 s.passkeyRoutes(mux)189 s.passkeyRoutes(mux)
168 }190 }
191+ if s.cfg.Inbox != nil {
192+ mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
198+ }
169 mux.HandleFunc("GET /{$}", s.page(s.home))199 mux.HandleFunc("GET /{$}", s.page(s.home))
170 mux.HandleFunc("GET /{repo}", s.page(s.summary))200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
171 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))201 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -182,7 +212,7 @@ func (s *Server) routes() *http.ServeMux {
182 if s.cfg.MergeRequests != nil {212 if s.cfg.MergeRequests != nil {
183 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))213 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))214 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))215+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))216 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))217 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))218 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -197,6 +227,21 @@ func (s *Server) routes() *http.ServeMux {
197 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))227 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
198 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))228 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
199 }229 }
230+ if s.cfg.Issues != nil {
231+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
232+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
233+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
234+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
235+ // The writes: POST only, behind the gate (ServeHTTP).
236+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
237+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
238+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
239+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
240+ }
241+ if s.cfg.IssueForm != nil {
242+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
243+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
244+ }
200 if s.cfg.Runs != nil {245 if s.cfg.Runs != nil {
201 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))246 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
202 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))247 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
M internal/app/api_helpers_test.go +2 -0
M internal/app/api_test.go +1 -1
M internal/app/cli_e2e_test.go +2 -2
M internal/app/cli_fake_test.go +27 -0
A internal/app/cli_issue.go +455 -0
A internal/app/cli_issue_e2e_test.go +154 -0
A internal/app/cli_issue_test.go +264 -0
M internal/app/cli_repo.go +61 -6
M internal/app/cli_repo_test.go +6 -5
M internal/app/cli_token.go +22 -0
M internal/app/cli_token_test.go +2 -3
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch_test.go +1 -1
M internal/app/forge.go +71 -22
A internal/app/issue_api_test.go +245 -0
A internal/app/issue_moderation_api_test.go +139 -0
M internal/app/root.go +1 -1
M internal/app/testdata/web/blob-binary.html +3 -1
M internal/app/testdata/web/blob-code.html +3 -1
M internal/app/testdata/web/blob-empty.html +3 -1
M internal/app/testdata/web/blob-image.html +3 -1
M internal/app/testdata/web/blob-markdown.html +3 -1
M internal/app/testdata/web/blob-svg.html +3 -1
M internal/app/testdata/web/blob-text.html +3 -1
M internal/app/testdata/web/blob-too-large.html +3 -1
M internal/app/testdata/web/blob-trojan.html +3 -1
M internal/app/testdata/web/commit-big.html +3 -1
M internal/app/testdata/web/commit-binary.html +3 -1
M internal/app/testdata/web/commit-merge.html +3 -1
M internal/app/testdata/web/commit-modify-split.html +3 -1
M internal/app/testdata/web/commit-modify.html +3 -1
M internal/app/testdata/web/commit-rename.html +3 -1
M internal/app/testdata/web/commit-root.html +3 -1
M internal/app/testdata/web/commit-trojan.html +3 -1
M internal/app/testdata/web/commits-page2.html +3 -1
M internal/app/testdata/web/commits-path.html +3 -1
M internal/app/testdata/web/commits-root.html +4 -2
M internal/app/testdata/web/commits-small.html +4 -2
M internal/app/testdata/web/empty.html +3 -1
M internal/app/testdata/web/home.html +4 -2
M internal/app/testdata/web/not-found-page.html +3 -1
M internal/app/testdata/web/not-found.html +3 -1
M internal/app/testdata/web/refs.html +4 -2
M internal/app/testdata/web/summary.html +4 -2
M internal/app/testdata/web/tree-nested.html +3 -1
M internal/app/testdata/web/tree-root.html +3 -1
M internal/app/testdata/web/tree-slash-ref.html +3 -1
M internal/app/testdata/web/tree-subdir.html +3 -1
M internal/app/web.go +5 -0
A internal/app/web_inbox_test.go +152 -0
A internal/app/web_issue_form_test.go +231 -0
A internal/app/web_issue_test.go +139 -0
M internal/clientlimit/export_test.go +7 -0
A internal/clientlimit/submissions.go +178 -0
A internal/clientlimit/submissions_test.go +148 -0
M internal/domain/event.go +72 -0
A internal/domain/issue.go +106 -0
A internal/domain/issue_test.go +138 -0
M internal/domain/mergerequest.go +3 -1
A internal/domain/pending_issue.go +122 -0
A internal/domain/pending_issue_test.go +90 -0
A internal/domain/submission.go +42 -0
M internal/domain/text.go +102 -0
M internal/domain/token.go +5 -1
M internal/domain/token_test.go +6 -1
M internal/ports/dto.go +69 -0
A internal/ports/mocks/InboxService.go +121 -0
A internal/ports/mocks/IssueBanStore.go +183 -0
A internal/ports/mocks/IssueKeyStore.go +109 -0
A internal/ports/mocks/IssueModerationService.go +280 -0
A internal/ports/mocks/IssueNumberResolver.go +126 -0
A internal/ports/mocks/IssueService.go +523 -0
A internal/ports/mocks/IssueStore.go +515 -0
A internal/ports/mocks/IssueSubmissionService.go +190 -0
M internal/ports/mocks/MergeRequestStore.go +62 -0
A internal/ports/mocks/PendingIssueStore.go +452 -0
A internal/ports/mocks/SubmissionLimiter.go +188 -0
M internal/ports/ports.go +81 -0
M internal/ports/services.go +94 -0
M internal/usecase/events.go +97 -3
A internal/usecase/events_owner_test.go +200 -0
M internal/usecase/events_test.go +43 -0
M internal/usecase/export_test.go +34 -0
A internal/usecase/inbox.go +164 -0
A internal/usecase/inbox_test.go +110 -0
A internal/usecase/issue_keys.go +62 -0
A internal/usecase/issue_moderation.go +119 -0
A internal/usecase/issue_moderation_test.go +251 -0
A internal/usecase/issue_pow_vector_test.go +39 -0
A internal/usecase/issue_submissions.go +383 -0
A internal/usecase/issue_submissions_test.go +670 -0
A internal/usecase/issues.go +291 -0
A internal/usecase/issues_integration_test.go +87 -0
A internal/usecase/issues_test.go +276 -0
M internal/usecase/mergerequests_integration_test.go +1 -1
M internal/usecase/repos_write.go +13 -3
M internal/usecase/repos_write_test.go +42 -0
A proto/ermite/storage/issue.proto +71 -0
M proto/ermite/storage/merge_request.proto +10 -0
M skills/ermite/CLI.md +15 -0

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 43

↑↓ moveenter openesc close