detached aa7340c

web_mr_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"fmt"
7	"html"
8	"net/http"
9	"net/url"
10	"regexp"
11	"strings"
12	"testing"
13	"time"
14
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/require"
17
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22// mrRepo is a public repository "proj" with README.md on main and a branch
23// feat/search that is one commit ahead of it (search.go, three lines).
24type mrRepo struct {
25	*cliHarness
26	wt, url string
27}
28
29func newMRRepo(t *testing.T, vis domain.Visibility) *mrRepo {
30	t.Helper()
31	c := newCLIHarness(t)
32	_, err := c.forge.Repos.Create(c.ctx, domain.Root(), createParams(c.harness, "proj", vis))
33	require.NoError(t, err)
34	r := &mrRepo{cliHarness: c, wt: c.workTree("main", "README.md"), url: c.urlAuth(adminToken, "/proj.git")}
35	c.mustGit(r.wt, "push", "-q", r.url, "main")
36	c.mustGit(r.wt, "checkout", "-q", "-b", "feat/search")
37	c.writeFile(r.wt, "search.go", []byte("package search\n\nfunc Find() {}\n"))
38	c.mustGit(r.wt, "push", "-q", r.url, "feat/search")
39	return r
40}
41
42func (r *mrRepo) headOf(branch string) string { return r.bareGit("proj", "rev-parse", branch) }
43
44// form posts url-encoded values as the signed-in owner, from this site.
45func (b browser) form(path, token string, values url.Values) page {
46	b.h.t.Helper()
47	return b.do("POST", path, post(token), values.Encode())
48}
49
50var reHeadField = regexp.MustCompile(`name="head_id" value="([0-9a-f]{40})"`)
51
52// formHead reads the head the merge form of a page carries.
53func formHead(t *testing.T, body string) string {
54	t.Helper()
55	m := reHeadField.FindStringSubmatch(body)
56	require.Len(t, m, 2, "the page has a merge form")
57	return m[1]
58}
59
60// AC7: the pages as an anonymous visitor sees them on a public repository:
61// read-only, no forms, public cache headers; the same pages signed in carry
62// the forms and the private headers.
63func TestWebMergeRequestPagesRender(t *testing.T) {
64	r := newMRRepo(t, domain.VisibilityPublic)
65	b := browser{r.harness}
66	token := r.signIn(t)
67
68	// The compare page: anonymous reads, the owner gets the form.
69	anon := b.get("/proj/compare/feat/search", nil)
70	require.Equal(t, 200, anon.status, anon.body)
71	assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))
72	assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary"))
73	assert.Contains(t, anon.body, "feat/search")
74	assert.Contains(t, anon.body, `<span class="kn">package</span> search</td>`, "the diff of the single commit")
75	assert.NotContains(t, anon.body, "<form class=\"cform create\"")
76	assert.NotContains(t, anon.body, `<form class="cform create"`)
77	assertNoInline(t, anon.body)
78	owner := b.get("/proj/compare/feat/search", as(token))
79	require.Equal(t, 200, owner.status)
80	assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
81	assert.Contains(t, owner.hdr.Values("Vary"), "Cookie")
82	assert.Contains(t, owner.body, `<form class="cform create" method="post" action="/proj/merge-requests">`)
83	assert.Contains(t, owner.body, `name="source" value="feat/search"`)
84	assert.Contains(t, owner.body, `name="target" value="main"`)
85	assert.Equal(t, 404, b.get("/proj/compare/nope", nil).status)
86	assert.Equal(t, 404, b.get("/proj/compare/main/extra", nil).status)
87	main := b.get("/proj/compare/main", nil)
88	assert.Equal(t, 200, main.status)
89	assert.Contains(t, main.body, "nothing to compare")
90
91	// An empty list, with the way to start one.
92	list := b.get("/proj/merge-requests", nil)
93	require.Equal(t, 200, list.status)
94	assert.Contains(t, list.body, "no open merge requests")
95	assert.Contains(t, list.body, `href="/proj/compare/feat/search"`)
96	assert.Contains(t, list.body, `data-key="m"`, "the section of the nav")
97	assert.NotContains(t, strings.ToLower(list.body), "proposal")
98
99	// Open one from the compare page.
100	res := b.form("/proj/merge-requests", token, url.Values{
101		"source": {"feat/search"}, "target": {"main"}, "title": {"Add <b>search</b>"},
102		"description": {"## Summary\r\n\r\nFinds **things** with `Find()`.\r\n\r\n- See https://example.org/spec) and javascript:alert(1)\r\n- [x](javascript:alert(1)) <script>alert(1)</script>"},
103	})
104	require.Equal(t, http.StatusSeeOther, res.status, res.body)
105	assert.Equal(t, "/proj/merge-requests/1", res.hdr.Get("Location"))
106	assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
107	head := r.headOf("feat/search")
108
109	// The same pair twice is refused with a page, and nothing is created.
110	dup := b.form("/proj/merge-requests", token, url.Values{"source": {"feat/search"}, "target": {"main"}, "title": {"dup"}})
111	assert.Equal(t, http.StatusConflict, dup.status)
112	assert.Equal(t, "no-store", dup.hdr.Get("Cache-Control"))
113	assert.Contains(t, dup.body, "already exists")
114	bad := b.form("/proj/merge-requests", token, url.Values{"source": {"feat/search"}, "target": {"main"}, "title": {"bidi\u202etitle"}})
115	assert.Equal(t, http.StatusBadRequest, bad.status)
116	assert.NotContains(t, bad.body, "\u202e")
117
118	// The compare page now points at it.
119	assert.Contains(t, b.get("/proj/compare/feat/search", as(token)).body, `href="/proj/merge-requests/1"`)
120	assert.NotContains(t, b.get("/proj/compare/feat/search", as(token)).body, `<form class="cform create"`)
121
122	// The list.
123	list = b.get("/proj/merge-requests", nil)
124	assert.Contains(t, list.body, `<a href="/proj/merge-requests/1">Add &lt;b&gt;search&lt;/b&gt;</a>`)
125	assert.Contains(t, list.body, "feat/search → main")
126	assert.Equal(t, "no-cache", list.hdr.Get("Cache-Control"))
127	assertNoInline(t, list.body)
128	assert.Equal(t, "private, no-store", b.get("/proj/merge-requests", as(token)).hdr.Get("Cache-Control"))
129
130	// The page, anonymous: read-only.
131	mr := b.get("/proj/merge-requests/1", nil)
132	require.Equal(t, 200, mr.status, mr.body)
133	assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))
134	assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary"))
135	assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
136	assert.NotContains(t, mr.body, "<b>search</b>")
137	assert.Contains(t, mr.body, "feat/search")
138	assert.Contains(t, mr.body, `<span class="kn">package</span> search</td>`)
139	assert.Contains(t, mr.body, "no checks")
140	assert.Contains(t, mr.body, "fast-forward, squash")
141	// The description is Markdown, rendered as a README: no raw HTML, no
142	// script URL; its heading ids keep apart from the ids of the page.
143	assert.Contains(t, mr.body, `<article class="mr-desc prose" aria-label="description" hx-ignore>`)
144	assert.Contains(t, mr.body, `<h2 id="mr-desc-summary">Summary</h2>`)
145	assert.Contains(t, mr.body, `<strong>things</strong>`)
146	assert.Contains(t, mr.body, `<code>Find()</code>`)
147	assert.Contains(t, mr.body, `<a href="https://example.org/spec"`, "a bare address is linked")
148	assert.NotContains(t, mr.body, `href="javascript:`)
149	assert.NotContains(t, mr.body, `<script>alert(1)</script>`)
150	assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
151	assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")
152	assert.NotContains(t, mr.body, `id="merge-menu"`)
153	assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")
154	assert.NotContains(t, strings.ToLower(mr.body), "proposal")
155	assertNoInline(t, mr.body)
156	assert.Equal(t, 404, b.get("/proj/merge-requests/1/since", nil).status, "the read position is the owner's")
157	assert.Equal(t, 404, b.get("/proj/merge-requests/9", nil).status)
158	assert.Equal(t, 404, b.get("/proj/merge-requests/01", nil).status)
159	assert.Equal(t, 404, b.get("/proj/merge-requests/x", nil).status)
160
161	// Signed in: forms, the merge box with the head and the CLI lines.
162	own := b.get("/proj/merge-requests/1", as(token))
163	require.Equal(t, 200, own.status)
164	assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control"))
165	assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
166	assert.Equal(t, head, formHead(t, own.body))
167	assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
168	assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff")
169	assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default")
170	assert.Contains(t, own.body, `name="mode" value="squash"`)
171	assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default")
172	assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
173	assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)
174	assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
175	assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash")
176	assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge")
177	assert.NotContains(t, own.body, "--squash")
178	assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
179	assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
180	assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")
181	assertNoInline(t, own.body)
182
183	// Reading is safe: GET marked nothing as read.
184	assert.NotContains(t, own.body, "since your last read")
185	show := r.cli(t, adminToken, "mr", "show", "proj", "1", "--json").ok(t)
186	assert.Contains(t, show.stdout, `"last_read_id": ""`)
187}
188
189// A private repository is invisible to the anonymous visitor on every merge
190// request page, and the owner sees it.
191func TestWebMergeRequestPrivate(t *testing.T) {
192	r := newMRRepo(t, domain.VisibilityPrivate)
193	b := browser{r.harness}
194	token := r.signIn(t)
195	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret plan").ok(t)
196
197	missing := b.get("/nothere/merge-requests/1", nil)
198	for _, p := range []string{
199		"/proj/merge-requests", "/proj/merge-requests/1", "/proj/merge-requests/1/since", "/proj/compare/feat/search",
200	} {
201		got := b.get(p, nil)
202		assert.Equal(t, 404, got.status, p)
203		assert.NotContains(t, got.body, "Secret plan", p)
204		assert.NotContains(t, got.body, "search.go", p)
205	}
206	assert.Equal(t, missing.body, b.get("/proj/merge-requests/1", nil).body, "same page as a missing repository")
207	assert.Equal(t, 404, b.get("/-/events?topic=repo:proj&view=mrs", nil).status)
208
209	for _, p := range []string{"/proj/merge-requests", "/proj/merge-requests/1", "/proj/merge-requests/1/since", "/proj/compare/feat/search"} {
210		got := b.get(p, as(token))
211		assert.Equal(t, 200, got.status, p)
212		assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p)
213		assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p)
214	}
215	assert.Contains(t, b.get("/proj/merge-requests", as(token)).body, "Secret plan")
216}
217
218// AC2: every write needs the session and an Origin of this site.
219func TestWebMergeRequestWritesNeedSessionAndOrigin(t *testing.T) {
220	r := newMRRepo(t, domain.VisibilityPublic)
221	b := browser{r.harness}
222	token := r.signIn(t)
223	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
224	head := r.headOf("feat/search")
225
226	paths := map[string]string{
227		"/proj/merge-requests":                      "source=feat/search&target=main&title=x",
228		"/proj/merge-requests/1/comments":           "text=hello",
229		"/proj/merge-requests/1/comments/1/resolve": "resolved=true",
230		"/proj/merge-requests/1/merge":              "mode=fast-forward&head_id=" + head,
231		"/proj/merge-requests/1/close":              "",
232		"/proj/merge-requests/1/read":               "head_id=" + head,
233		"/proj/merge-requests/1/ready":              "",
234	}
235	for path, body := range paths {
236		noOrigin := b.do("POST", path, as(token), body)
237		assert.Equal(t, 403, noOrigin.status, path+" without Origin")
238		assert.Equal(t, "no-store", noOrigin.hdr.Get("Cache-Control"))
239		foreign := b.do("POST", path, map[string]string{"Cookie": sessionCookie + "=" + token, "Origin": "https://evil.example"}, body)
240		assert.Equal(t, 403, foreign.status, path+" foreign Origin")
241		anon := b.do("POST", path, map[string]string{"Origin": publicOrigin}, body)
242		assert.Equal(t, 403, anon.status, path+" without a session")
243	}
244	// Nothing happened.
245	out := r.cli(t, "", "mr", "show", "proj", "1", "--json").ok(t).stdout
246	assert.Contains(t, out, `"state": "open"`)
247	assert.NotContains(t, out, "hello")
248	assert.Equal(t, "", strings.TrimSpace(r.bareGit("proj", "rev-list", "main..feat/search", "--not", "feat/search")))
249
250	// A GET on a write route is not a page.
251	assert.Equal(t, 404, b.get("/proj/merge-requests/1/merge", as(token)).status)
252	assert.Equal(t, 404, b.get("/proj/merge-requests/1/close", as(token)).status)
253}
254
255var reNoteID = regexp.MustCompile(`id="c(\d+)"`)
256
257// The review of one request, in the browser's words: a note on a line, a
258// general one, resolve, since the last read after a push, a stale head, the
259// fast-forward merge.
260func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
261	r := newMRRepo(t, domain.VisibilityPublic)
262	b := browser{r.harness}
263	token := r.signIn(t)
264	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
265	head1 := r.headOf("feat/search")
266	base := "/proj/merge-requests/1"
267
268	// The form of a line, then the note under that line.
269	line := b.get(base+"?on=new:3:search.go", as(token))
270	require.Equal(t, 200, line.status)
271	assert.Contains(t, line.body, `<form class="cform"`)
272	assert.Contains(t, line.body, `name="path" value="search.go"`)
273	assert.Contains(t, line.body, `name="line" value="3"`)
274	assert.Contains(t, line.body, `name="side" value="new"`)
275	assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:99:search.go", as(token)).body, `name="line" value="99"`), "a line the diff lacks has no form")
276	assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor")
277
278	res := b.form(base+"/comments", token, url.Values{"text": {"Why <i>empty</i>?\r\nSee https://example.org/a."}, "path": {"search.go"}, "line": {"3"}, "side": {"new"}})
279	require.Equal(t, http.StatusSeeOther, res.status, res.body)
280	assert.Equal(t, base+"#c1", res.hdr.Get("Location"))
281	assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
282	res = b.form(base+"/comments", token, url.Values{"text": {"Looks fine overall."}})
283	require.Equal(t, http.StatusSeeOther, res.status, res.body)
284	assert.Equal(t, base+"#c2", res.hdr.Get("Location"))
285
286	anon := b.get(base, nil)
287	i := strings.Index(anon.body, `<tr class="fnote">`)
288	require.GreaterOrEqual(t, i, 0, "a note row under the line")
289	note := anon.body[i:]
290	assert.Less(t, strings.Index(note, "Why &lt;i&gt;empty&lt;/i&gt;?"), strings.Index(note, "</tr>"), "the note sits in the row under its line")
291	assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="nofollow noopener noreferrer">https://example.org/a</a>.`)
292	assert.NotContains(t, anon.body, "<i>empty</i>")
293	assert.Contains(t, anon.body, "Looks fine overall.")
294	assert.Equal(t, 2, len(reNoteID.FindAllString(anon.body, -1)))
295	assert.NotContains(t, anon.body, "[resolve]", "a visitor cannot resolve")
296
297	// A comment that breaks a rule is refused with the rule, nothing is lost.
298	bad := b.form(base+"/comments", token, url.Values{"text": {"ok"}, "path": {"search.go"}, "line": {"99"}, "side": {"new"}})
299	assert.Equal(t, http.StatusBadRequest, bad.status)
300	assert.Equal(t, "no-store", bad.hdr.Get("Cache-Control"))
301	assert.Contains(t, bad.body, "search.go")
302	bad = b.form(base+"/comments", token, url.Values{"text": {"x"}, "path": {"search.go"}, "line": {"zz"}, "side": {"new"}})
303	assert.Equal(t, http.StatusBadRequest, bad.status)
304	bad = b.form(base+"/comments", token, url.Values{"text": {"x"}, "path": {"search.go"}, "line": {"3"}, "side": {"sideways"}})
305	assert.Equal(t, http.StatusBadRequest, bad.status)
306	bad = b.form(base+"/comments", token, url.Values{"text": {"evil\u202etext"}})
307	assert.Equal(t, http.StatusBadRequest, bad.status)
308	assert.NotContains(t, bad.body, "\u202e")
309	assert.Equal(t, 404, b.form("/proj/merge-requests/9/comments", token, url.Values{"text": {"x"}}).status)
310
311	// Resolve and unresolve.
312	own := b.get(base, as(token))
313	assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments/1/resolve"`)
314	res = b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"true"}})
315	require.Equal(t, http.StatusSeeOther, res.status, res.body)
316	assert.Equal(t, base+"#c1", res.hdr.Get("Location"))
317	own = b.get(base, as(token))
318	assert.Contains(t, own.body, `data-resolved="true"`)
319	assert.Contains(t, own.body, ">Unresolve</button>")
320	assert.Equal(t, http.StatusSeeOther, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"false"}}).status)
321	assert.Equal(t, http.StatusBadRequest, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"maybe"}}).status)
322	assert.Equal(t, 404, b.form(base+"/comments/42/resolve", token, url.Values{"resolved": {"true"}}).status)
323
324	// Mark read, then push: the since page shows the new change only.
325	assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)
326	res = b.form(base+"/read", token, url.Values{"head_id": {head1}})
327	require.Equal(t, http.StatusSeeOther, res.status, res.body)
328	assert.NotContains(t, b.get(base, as(token)).body, "data-read>Mark read", "the head is the one that was read")
329	r.writeFile(r.wt, "extra.txt", []byte("extra\n"))
330	r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
331	head2 := r.headOf("feat/search")
332
333	all := b.get(base, as(token))
334	assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
335	assert.Contains(t, all.body, "since your last read")
336	assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")
337	assert.Contains(t, all.body, "data-read>Mark read")
338	assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
339	since := b.get(base+"/since", as(token))
340	require.Equal(t, 200, since.status, since.body)
341	assert.Equal(t, "private, no-store", since.hdr.Get("Cache-Control"))
342	assert.Contains(t, since.body, "changes since "+head1[:7])
343	assert.Contains(t, since.body, "extra</td>")
344	assert.NotContains(t, since.body, `<span class="kn">package</span> search</td>`, "only what changed after the read head")
345	assert.Contains(t, since.body, "empty", "the notes are listed below, their lines are not in this diff")
346	assert.NotContains(t, since.body, "<i>", "raw HTML is dropped")
347	assert.Contains(t, since.body, "search.go:3 (new)")
348	assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")
349
350	// The merge form of the page the owner read carries that head: the push
351	// that came after is not merged unseen.
352	stale := b.form(base+"/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head1}})
353	assert.Equal(t, http.StatusConflict, stale.status)
354	assert.Equal(t, "no-store", stale.hdr.Get("Cache-Control"))
355	assert.Contains(t, stale.body, "branch moved")
356	assert.NotEqual(t, head2, r.headOf("main"))
357	assert.Equal(t, http.StatusBadRequest, b.form(base+"/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head2}}).status)
358	assert.Equal(t, http.StatusBadRequest, b.form(base+"/merge", token, url.Values{"mode": {"squash"}, "head_id": {"abc"}}).status)
359
360	// Fast-forward with the head that was read.
361	fresh := b.get(base, as(token))
362	require.Equal(t, head2, formHead(t, fresh.body))
363	res = b.form(base+"/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, fresh.body)}})
364	require.Equal(t, http.StatusSeeOther, res.status, res.body)
365	assert.Equal(t, base, res.hdr.Get("Location"))
366	assert.Equal(t, head2, r.headOf("main"), "the target moved to the head")
367
368	done := b.get(base, nil)
369	assert.Contains(t, done.body, "merged")
370	assert.Contains(t, done.body, "fast-forward")
371	assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)
372	assert.NotContains(t, done.body, "<form")
373	assert.NotContains(t, b.get(base, as(token)).body, `id="merge-menu"`, "nothing to merge any more")
374	// Writes on a merged request are refused with a page.
375	late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})
376	assert.Equal(t, http.StatusConflict, late.status)
377	assert.Equal(t, http.StatusConflict, b.form(base+"/close", token, nil).status)
378	list := b.get("/proj/merge-requests", nil)
379	assert.Contains(t, list.body, "Merged and closed")
380	assert.Regexp(t, `(?s)Merged and closed.*Add search`, list.body)
381}
382
383// Squash and close, and the order of the list: open first, then the decided.
384func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
385	r := newMRRepo(t, domain.VisibilityPublic)
386	b := browser{r.harness}
387	token := r.signIn(t)
388	head := r.headOf("feat/search")
389	main0 := r.headOf("main")
390
391	// Open from compare with a description; the merge form squashes.
392	res := b.form("/proj/merge-requests", token, url.Values{
393		"source": {"feat/search"}, "target": {"main"}, "title": {"Squash me"}, "description": {""},
394	})
395	require.Equal(t, http.StatusSeeOther, res.status, res.body)
396	assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
397	res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
398	require.Equal(t, http.StatusSeeOther, res.status, res.body)
399	r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
400	assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
401	merged := r.headOf("main")
402	assert.NotEqual(t, head, merged, "squash makes a new commit")
403	assert.Equal(t, main0, r.bareGit("proj", "rev-parse", "main^"), "on top of the old target")
404	assert.Contains(t, r.bareGit("proj", "cat-file", "-p", "main"), "gpgsig", "signed by the instance key")
405	page := b.get("/proj/merge-requests/1", nil)
406	assert.Contains(t, page.body, "squash")
407	assert.Contains(t, page.body, `href="/proj/commit/`+merged+`"`)
408
409	// A second request: closed.
410	r.mustGit(r.wt, "checkout", "-q", "-b", "feat/other", "main")
411	r.mustGit(r.wt, "pull", "-q", r.url, "main")
412	r.writeFile(r.wt, "other.go", []byte("package other\n"))
413	r.mustGit(r.wt, "push", "-q", r.url, "feat/other")
414	r.mustGit(r.wt, "checkout", "-q", "-b", "feat/third", "feat/other")
415	r.writeFile(r.wt, "third.go", []byte("package third\n"))
416	r.mustGit(r.wt, "push", "-q", r.url, "feat/third")
417	for _, title := range []struct{ src, title string }{{"feat/other", "Other change"}, {"feat/third", "Third change"}} {
418		res = b.form("/proj/merge-requests", token, url.Values{"source": {title.src}, "target": {"main"}, "title": {title.title}})
419		require.Equal(t, http.StatusSeeOther, res.status, res.body)
420	}
421	res = b.form("/proj/merge-requests/2/close", token, nil)
422	require.Equal(t, http.StatusSeeOther, res.status, res.body)
423	assert.Equal(t, "/proj/merge-requests/2", res.hdr.Get("Location"))
424	assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
425	closed := b.get("/proj/merge-requests/2", nil)
426	assert.Contains(t, closed.body, `data-state="closed"`)
427	assert.NotContains(t, closed.body, "<form")
428	assert.Equal(t, http.StatusConflict, b.form("/proj/merge-requests/2/close", token, nil).status, "closing twice says so")
429
430	// Open one first, then merged and closed ones, newest first within each.
431	list := b.get("/proj/merge-requests", nil).body
432	third, squash, other := strings.Index(list, "Third change"), strings.Index(list, "Squash me"), strings.Index(list, "Other change")
433	require.True(t, third >= 0 && squash >= 0 && other >= 0, list)
434	assert.Less(t, third, squash, "the open request comes first")
435	assert.Less(t, third, other)
436	assert.Less(t, strings.Index(list, ">Open<"), strings.Index(list, "Merged and closed"))
437	assert.Less(t, other, squash, "newest first among the decided")
438
439	// A compare page of a branch that is behind says so.
440	r.mustGit(r.wt, "checkout", "-q", "-b", "feat/stale", main0)
441	r.writeFile(r.wt, "stale.go", []byte("package stale\n"))
442	r.mustGit(r.wt, "push", "-q", r.url, "feat/stale")
443	stale := b.get("/proj/compare/feat/stale", nil)
444	assert.Equal(t, 200, stale.status)
445	assert.Contains(t, stale.body, "has moved on")
446}
447
448// A "WIP:" request is a draft: the list and the page say so, the box offers
449// "Ready for review" instead of the merge form and the terminal lines, the
450// merge is refused; ready drops the prefix and the merge form comes back. The
451// tab counts the open requests.
452func TestWebMergeRequestDraft(t *testing.T) {
453	r := newMRRepo(t, domain.VisibilityPublic)
454	b := browser{r.harness}
455	token := r.signIn(t)
456	head := r.headOf("feat/search")
457	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "WIP: search").ok(t)
458
459	page := b.get("/proj/merge-requests/1", as(token)).body
460	assert.Contains(t, page, `data-state="draft"`)
461	assert.Contains(t, page, `action="/proj/merge-requests/1/ready"`)
462	assert.NotContains(t, page, `action="/proj/merge-requests/1/merge"`)
463	assert.NotContains(t, page, "ermite mr merge", "no terminal line for a draft")
464	assert.Contains(t, page, `<span class="count">1<span class="sr"> open</span></span>`, "the tab counts the open requests")
465	assert.Contains(t, b.get("/proj/merge-requests", nil).body, `data-state="draft"`)
466
467	res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
468	assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
469	assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address")
470	assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline")
471	assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker")
472
473	res = b.form("/proj/merge-requests/1/ready", token, nil)
474	require.Equal(t, http.StatusSeeOther, res.status, res.body)
475	page = b.get("/proj/merge-requests/1", as(token)).body
476	assert.Contains(t, page, "> search</h1>", "the prefix is gone")
477	assert.Contains(t, page, `data-state="open"`)
478	assert.Contains(t, page, `action="/proj/merge-requests/1/merge"`)
479	assert.Contains(t, page, `<details class="d-or">`, "the terminal lines fold away")
480	assert.Equal(t, http.StatusConflict, b.form("/proj/merge-requests/1/ready", token, nil).status, "not a draft any more")
481}
482
483// A request opened with a token shows the owner's name, not the label of the
484// token, which names a device.
485func TestWebMergeRequestAuthorIsTheOwner(t *testing.T) {
486	r := newMRRepo(t, domain.VisibilityPublic)
487	out, errOut, code := r.bin(t, adminToken, "token", "create", "laptop", "--preset", "cli")
488	require.Equal(t, 0, code, errOut)
489	r.cli(t, strings.TrimSpace(out), "mr", "create", "proj", "--source", "feat/search", "--title", "search").ok(t)
490
491	b := browser{r.harness}
492	for _, path := range []string{"/proj/merge-requests/1", "/proj/merge-requests"} {
493		body := b.get(path, nil).body
494		assert.NotContains(t, body, "laptop", path)
495	}
496	assert.Contains(t, b.get("/proj/merge-requests", nil).body, `<td class="c-who dim">owner</td>`)
497}
498
499// The box offers no deletion for the default branch; a fast-forward with the
500// box checked deletes the source.
501func TestWebMergeDeleteBranchBox(t *testing.T) {
502	r := newMRRepo(t, domain.VisibilityPublic)
503	b := browser{r.harness}
504	token := r.signIn(t)
505	// old is an orphan branch: main can be proposed to it, and main stays an
506	// ancestor of feat/search so that it fast-forwards.
507	r.mustGit(r.wt, "checkout", "-q", "--orphan", "old")
508	r.writeFile(r.wt, "old.txt", []byte("old\n")) // commits
509	r.mustGit(r.wt, "push", "-q", r.url, "old")
510
511	for _, mr := range []url.Values{
512		{"source": {"feat/search"}, "target": {"main"}, "title": {"Search"}},
513		{"source": {"main"}, "target": {"old"}, "title": {"Back"}},
514	} {
515		require.Equal(t, http.StatusSeeOther, b.form("/proj/merge-requests", token, mr).status)
516	}
517	assert.NotContains(t, b.get("/proj/merge-requests/2", as(token)).body, `name="delete_branch"`, "never for the default branch")
518
519	one := b.get("/proj/merge-requests/1", as(token))
520	res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}})
521	require.Equal(t, http.StatusSeeOther, res.status, res.body)
522	r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
523	assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
524	done := b.get("/proj/merge-requests/1", nil)
525	assert.Equal(t, 200, done.status, "the merged page reads without its branch")
526	assert.NotContains(t, done.body, `href="/proj/tree/feat/search"`, "no link to a gone branch")
527}
528
529// The comment limit of the API is a message, not a crash.
530func TestWebMergeRequestLimitIsFriendly(t *testing.T) {
531	r := newMRRepo(t, domain.VisibilityPublic)
532	b := browser{r.harness}
533	token := r.signIn(t)
534	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
535	for i := 0; i < 1000; i++ {
536		_, err := r.forge.MergeRequests.AddComment(r.ctx, domain.Root(), r.name("proj"), 1, ports.AddCommentParams{Text: "n"})
537		require.NoError(t, err)
538	}
539	full := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"one more"}})
540	assert.Equal(t, http.StatusConflict, full.status)
541	assert.Contains(t, full.body, "limit reached")
542	assert.Contains(t, full.body, "1000 comments")
543	assert.Equal(t, "no-store", full.hdr.Get("Cache-Control"))
544}
545
546// Text is escaped everywhere and invisible characters are shown as markers.
547func TestWebMergeRequestEscapesText(t *testing.T) {
548	r := newMRRepo(t, domain.VisibilityPublic)
549	b := browser{r.harness}
550	token := r.signIn(t)
551	res := b.form("/proj/merge-requests", token, url.Values{
552		"source": {"feat/search"}, "target": {"main"}, "title": {`"><script>alert(1)</script>`},
553		"description": {"<img src=x onerror=alert(1)>\nhttp://x.test/\"onmouseover=\"alert(1)"},
554	})
555	require.Equal(t, http.StatusSeeOther, res.status, res.body)
556	r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "<script>x</script> & more").ok(t)
557	for _, tok := range []string{"", token} {
558		var h map[string]string
559		if tok != "" {
560			h = as(tok)
561		}
562		body := b.get("/proj/merge-requests/1", h).body
563		assert.NotContains(t, body, "<script>alert")
564		assert.NotContains(t, body, "<img src=x")
565		assert.NotContains(t, body, "<script>x</script>")
566		assert.Contains(t, html.UnescapeString(body), `"><script>alert(1)</script>`)
567		// A note is safe Markdown: a line that opens with an HTML tag is an HTML
568		// block, dropped whole.
569		assert.Contains(t, body, `class="c-body prose"><!-- raw HTML omitted -->`)
570		assertNoInline(t, body)
571		assert.NotContains(t, body, `onmouseover="alert`, "an address stops at a quote")
572	}
573}
574
575// The merge request pages learn of changes over the event stream: a fixed
576// sentence and a link, never the text of a comment or a title.
577func TestWebMergeRequestEvents(t *testing.T) {
578	r := newMRRepo(t, domain.VisibilityPublic)
579	list := r.sse("topic=repo:proj&view=mrs", "")
580	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
581	ev := nextNamed(t, list, "mr-notice")
582	assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
583	assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
584	assert.NotContains(t, ev.data, "Secret")
585
586	one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
587	other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
588	r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
589	ev = nextNamed(t, one, "mr-notice")
590	assert.Contains(t, ev.data, "a comment was added")
591	assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
592	assert.NotContains(t, ev.data, "private words")
593
594	r.writeFile(r.wt, "more.txt", []byte("more\n"))
595	r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
596	ev = nextNamed(t, one, "mr-notice")
597	assert.Contains(t, ev.data, "the branch changed")
598
599	r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
600	assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
601
602	// A page of another request hears nothing of this one.
603	for drained := false; !drained; {
604		select {
605		case ev := <-other.events:
606			if !ev.comment && ev.name != "" {
607				t.Fatalf("unexpected event %+v", ev)
608			}
609		default:
610			drained = true
611		}
612	}
613	assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`&amp;target=main"`)
614}
615
616// nextNamed returns the next event that carries the fragment event: the
617// events that change nothing on the page (an id alone) are skipped.
618func nextNamed(t *testing.T, s *sseStream, name string) sseEvent {
619	t.Helper()
620	for range 10 {
621		if ev := s.next(t, watchWait); ev.name == name {
622			return ev
623		}
624	}
625	t.Fatalf("no %s event", name)
626	return sseEvent{}
627}
628
629func (r *mrRepo) get1(path string) string {
630	r.t.Helper()
631	_, _, body := r.get(path)
632	return body
633}
634
635// A rewritten branch that dropped the commit the owner read says so, and shows
636// the whole change.
637func TestWebMergeRequestSinceAfterForcePush(t *testing.T) {
638	r := newMRRepo(t, domain.VisibilityPublic)
639	b := browser{r.harness}
640	token := r.signIn(t)
641	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
642	base := "/proj/merge-requests/1"
643
644	nothing := b.get(base+"/since", as(token))
645	assert.Equal(t, 200, nothing.status)
646	assert.Contains(t, nothing.body, "nothing was marked read yet")
647
648	head1 := r.headOf("feat/search")
649	require.Equal(t, http.StatusSeeOther, b.form(base+"/read", token, url.Values{"head_id": {head1}}).status)
650	assert.Equal(t, http.StatusBadRequest, b.form(base+"/read", token, url.Values{"head_id": {strings.Repeat("0", 40)}}).status, "not a commit of the repository")
651	r.mustGit(r.wt, "reset", "-q", "--hard", "main")
652	r.writeFile(r.wt, "other.txt", []byte("o\n"))
653	r.mustGit(r.wt, "push", "-q", "--force", r.url, "feat/search")
654	r.bareGit("proj", "reflog", "expire", "--expire=now", "--all")
655	r.bareGit("proj", "gc", "-q", "--prune=now")
656
657	lost := b.get(base+"/since", as(token))
658	require.Equal(t, 200, lost.status)
659	assert.Contains(t, lost.body, "is gone, the branch was rewritten")
660	assert.Contains(t, lost.body, "other.txt", "the whole change is shown")
661}
662
663// A request of many files: the page lists every file and holds the hunks of
664// the first window only; the other files are placeholders that load their
665// window from /files, which carries the base and the head of the diff. A
666// comment on a far file comes with its window; a moved head answers 409; the
667// page of a window (?from=, the links of a page without scripts) shows its
668// hunks and its line forms.
669func TestWebMergeRequestFileWindows(t *testing.T) {
670	r := newMRRepo(t, domain.VisibilityPublic)
671	b := browser{r.harness}
672	token := r.signIn(t)
673	const n = 60
674	for i := range n {
675		r.writeFile(r.wt, fmt.Sprintf("many/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
676	}
677	r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
678	head := r.headOf("feat/search")
679	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Many").ok(t)
680	r.cli(t, adminToken, "mr", "comment", "proj", "1", "--path", "many/f55.txt", "--line", "1", "-m", "far note").ok(t)
681
682	page := b.get("/proj/merge-requests/1", nil)
683	require.Equal(t, 200, page.status)
684	assert.Equal(t, n+1, strings.Count(page.body, `<li><a href="#f`), "every file is listed")
685	assert.Equal(t, n+1-25, strings.Count(page.body, `class="dlazy`), "the files past the first window are placeholders")
686	assert.NotContains(t, page.body, "far note", "the comment of a far file comes with its window")
687	assert.Contains(t, page.body, `data-notes="c1"`, "its placeholder knows it")
688	assert.Contains(t, page.body, `<span class="cn" title="1 open comment">1</span>`, "the list counts it")
689	m := reWindowSrc.FindStringSubmatch(page.body)
690	require.Len(t, m, 2, "a placeholder holds the address of its window")
691	src := html.UnescapeString(m[1])
692	assert.Contains(t, src, "head="+head)
693
694	win := b.get(strings.Replace(src, "from=25", "from=50", 1), nil)
695	require.Equal(t, 200, win.status, win.body)
696	assert.Contains(t, win.body, "far note")
697	assert.Contains(t, win.body, `line 55</td>`)
698	assert.NotContains(t, win.body, "<html", "a window is a fragment")
699	assert.NotContains(t, win.body, `class="dlazy`)
700	// The window renders the layouts of the page: both when it switches them,
701	// the one it shows otherwise.
702	both := b.get(src+"&view=split&layouts=both", nil)
703	assert.Contains(t, both.body, `class="dt uni"`)
704	assert.Contains(t, both.body, `class="dt spl"`)
705	one := b.get(src+"&view=split&layouts=one", nil)
706	assert.NotContains(t, one.body, `class="dt uni"`)
707	assert.Contains(t, one.body, `class="dt spl"`)
708	assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=7", 1), nil).status, "not a window start")
709	assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=100", 1), nil).status, "past the files")
710
711	// The page of a window, without scripts: its hunks, the line forms of the
712	// owner point back to it.
713	own := b.get("/proj/merge-requests/1?from=50", as(token))
714	require.Equal(t, 200, own.status)
715	assert.Contains(t, own.body, "line 55</td>")
716	assert.Contains(t, own.body, `href="/proj/merge-requests/1?from=50&amp;on=new:1:many%2Ff55.txt#f`)
717	assert.NotContains(t, own.body, "line 0</td>", "the first window is a placeholder there")
718	assert.Contains(t, own.body, `href="/proj/merge-requests/1?view=split&amp;from=50"`, "the layout links keep the window")
719	assert.Equal(t, 404, b.get("/proj/merge-requests/1?from=3", nil).status)
720
721	// A comment on a line of a far window: its form, its cancel link and the
722	// answer return to that window.
723	form := b.get("/proj/merge-requests/1?from=50&on=new:1:many%2Ff55.txt", as(token))
724	require.Equal(t, 200, form.status)
725	assert.Contains(t, form.body, `name="from" value="50"`)
726	assert.Contains(t, form.body, `class="btn" href="/proj/merge-requests/1?from=50#f`)
727	res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"near it"}, "path": {"many/f55.txt"}, "line": {"1"}, "side": {"new"}, "from": {"50"}})
728	require.Equal(t, http.StatusSeeOther, res.status, res.body)
729	assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location"))
730	assert.Contains(t, b.get("/proj/merge-requests/1?from=50", as(token)).body, `<input type="hidden" name="from" value="50">`, "its resolve form too")
731	res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"true"}, "from": {"50"}})
732	assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location"))
733	res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"false"}, "from": {"7"}})
734	assert.Equal(t, "/proj/merge-requests/1#c2", res.hdr.Get("Location"), "a window that is none is dropped")
735
736	// The head moved: the window says to reload, never a file of another diff.
737	r.writeFile(r.wt, "later.txt", []byte("later\n"))
738	r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
739	stale := b.get(src, nil)
740	assert.Equal(t, http.StatusConflict, stale.status)
741	assert.Contains(t, stale.body, "data-moved")
742}
743
744var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
745
746// advanceMain pushes a commit to main that the feature branch lacks. file is
747// the file it adds (give search.go to conflict with the feature branch).
748func (r *mrRepo) advanceMain(file, content string) {
749	r.t.Helper()
750	r.mustGit(r.wt, "checkout", "-q", "main")
751	r.writeFile(r.wt, file, []byte(content))
752	r.mustGit(r.wt, "push", "-q", r.url, "main")
753	r.mustGit(r.wt, "checkout", "-q", "feat/search")
754}
755
756// mrPageWith polls the owner's page of !1 until it contains want.
757func (r *mrRepo) mrPageWith(token, want string) string {
758	r.t.Helper()
759	var body string
760	require.Eventually(r.t, func() bool {
761		body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body
762		return strings.Contains(body, want)
763	}, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want)
764	return body
765}
766
767// The merge box shows the mergeability of the request: ready, then behind
768// (fast-forward is disabled with its reason, the other modes are not), then
769// in conflict (no mode is possible and the button says why).
770func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
771	r := newMRRepo(t, domain.VisibilityPublic)
772	token := r.signIn(t)
773	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
774
775	body := r.mrPageWith(token, "Ready to merge")
776	assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
777	assert.Contains(t, body, `data-state="up_to_date"`)
778	assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
779	assert.NotContains(t, body, `value="fast-forward" checked disabled`)
780	assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
781	assert.Contains(t, body, "signed with ermite's own key (generated on first start)")
782	assert.Contains(t, body, "<code>ermite signing-key show</code>")
783	assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
784	assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
785	assertNoInline(t, body)
786
787	// Main moves on: the branch is behind by one commit and merges cleanly.
788	r.advanceMain("other.txt", "other\n")
789	body = r.mrPageWith(token, "Behind main by 1 commit")
790	assert.Contains(t, body, "merges without conflicts")
791	assert.Contains(t, body, `data-ff-ok="false"`)
792	assert.Contains(t, body, `data-merge-ok="true"`)
793	assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body)
794	assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`)
795	assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked")
796	assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body)
797
798	// A conflicting change to main: every mode is disabled and the files are listed.
799	r.mustGit(r.wt, "checkout", "-q", "main")
800	r.writeFile(r.wt, "search.go", []byte("package other\n"))
801	r.mustGit(r.wt, "push", "-q", r.url, "main")
802	body = r.mrPageWith(token, "Conflicts with main")
803	assert.Contains(t, body, "rebase needed")
804	assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`)
805	assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body)
806	assert.NotContains(t, body, `name="mode" value="squash" checked`)
807
808	// The server refuses it too, and says so in the box of the page.
809	res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}})
810	assert.Equal(t, http.StatusConflict, res.status)
811	assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
812	assert.Contains(t, res.body, "conflicts with main")
813}
814
815// A merge the server refuses (the target moved after the page was read) shows
816// the page of the request again, 409, with the reason in its merge box.
817func TestWebMergeRequestInlineMergeError(t *testing.T) {
818	r := newMRRepo(t, domain.VisibilityPublic)
819	b := browser{r.harness}
820	token := r.signIn(t)
821	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
822	body := r.mrPageWith(token, "Ready to merge")
823	head := formHead(t, body)
824
825	r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward
826	res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
827	assert.Equal(t, http.StatusConflict, res.status)
828	assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
829	assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page")
830	assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
831	assert.Contains(t, res.body, "the target has moved")
832	assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error")
833	assert.Equal(t, head, r.headOf("feat/search"))
834	assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error")
835
836	// A head that moved is refused the same way.
837	r.writeFile(r.wt, "more.txt", []byte("more\n"))
838	r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
839	res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
840	assert.Equal(t, http.StatusConflict, res.status)
841	assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
842	assert.Contains(t, res.body, "branch moved")
843
844	// Other refusals keep the state page; a mode that does not exist is a 400.
845	assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status)
846}
847
848// A merge commit through the page; with the branch deletion requested the
849// merged page says the branch is being deleted, not that it is gone.
850func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) {
851	r := newMRRepo(t, domain.VisibilityPublic)
852	b := browser{r.harness}
853	token := r.signIn(t)
854	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
855	r.advanceMain("other.txt", "other\n")
856	body := r.mrPageWith(token, "Behind main by 1 commit")
857	head := formHead(t, body)
858	main0 := r.headOf("main")
859
860	res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}})
861	require.Equal(t, http.StatusSeeOther, res.status, res.body)
862	assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location"))
863	merged := r.headOf("main")
864	assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1"))
865	assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2"))
866	assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig")
867	page := b.get("/proj/merge-requests/1?deleting=1", as(token))
868	assert.Contains(t, page.body, "merge commit")
869	if strings.Contains(page.body, "is being deleted") {
870		assert.Contains(t, page.body, "feat/search is being deleted")
871	}
872	assert.NotContains(t, page.body, "is deleted")
873	r.forge.MergeRequests.Wait(r.ctx)
874	assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
875	assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted")
876}
877
878// The badge follows the request live: a push to the target turns the fragment
879// of the page to "checking" or "behind", and the settled result follows.
880func TestWebMergeRequestMergeabilityLive(t *testing.T) {
881	r := newMRRepo(t, domain.VisibilityPublic)
882	token := r.signIn(t)
883	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
884	r.mrPageWith(token, "Ready to merge")
885
886	one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token))
887	anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "")
888	r.advanceMain("other.txt", "other\n")
889	deadline := time.After(watchWait)
890	for done := false; !done; {
891		ev := nextNamed(t, one, "mr-mergeability")
892		select {
893		case <-deadline:
894			t.Fatal("the badge never showed the result")
895		default:
896		}
897		if strings.Contains(ev.data, "Behind main by 1 commit") {
898			assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
899			assert.Contains(t, ev.data, `data-ff-ok="false"`)
900			assert.Contains(t, ev.data, `data-merge-ok="true"`)
901			done = true
902		} else {
903			assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks")
904		}
905	}
906	// The box is the owner's: a visitor's stream carries no badge.
907	for {
908		select {
909		case ev := <-anon.events:
910			assert.NotEqual(t, "mr-mergeability", ev.name)
911			continue
912		default:
913		}
914		break
915	}
916}

Keyboard shortcuts

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

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

Branches 2

Tags 46

↑↓ moveenter openesc close