Commit 378df4415e

378df4415e9c5b26bfbc33d3ffd3bad007b3275f

parent: d79088e267

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-05 19:02 UTC

web: body markup picker on issue and merge request create forms

issue create and mr create both take --format md|org; the web forms had
no way to choose it, so anything written in the browser was markdown.
Both forms now offer the choice through a shared "formatpicker" partial,
defaulting to md.

Closes #160

Layout: unified · split

e2e/formatpickerweb_test.go added +78
@@ -0,0 +1,78 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestIssueMRWebFormat covers the format picker on the issue and merge
12// request create forms (#160). Neither form offered --format before; a
13// browser session could only ever write markdown.
14func TestIssueMRWebFormat(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
20 t.Fatalf("repo create: %s", errOut)
21 }
22 env := inst.gitEnv(aliceKey)
23 work := t.TempDir()
24 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
25 dir := filepath.Join(work, "w")
26 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
27 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
28 mustGit(t, dir, env, "add", ".")
29 mustGit(t, dir, env, "commit", "-q", "-m", "base")
30 mustGit(t, dir, env, "push", "-q", "origin", "main")
31 mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
32 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
33 mustGit(t, dir, env, "add", ".")
34 mustGit(t, dir, env, "commit", "-q", "-m", "topic")
35 mustGit(t, dir, env, "push", "-q", "origin", "topic")
36
37 alice := inst.login(t, aliceKey)
38 base := inst.base() + "/alice/app"
39 const orgBody = "Some /emphasis/ here.\n"
40
41 // The picker is on both forms, defaulting to Markdown.
42 for _, form := range []string{base + "/issues/new", base + "/mrs/new"} {
43 if _, body := browserGet(t, alice, form); !strings.Contains(body, `name="format"`) ||
44 !strings.Contains(body, `<option value="md" selected>`) {
45 t.Fatalf("no format picker (defaulting to md) on %s:\n%s", form, body)
46 }
47 }
48
49 // An issue written with the org choice renders as org, not markdown.
50 if status, _ := browserPost(t, alice, base+"/issues/new", url.Values{
51 "title": {"org issue"}, "body": {orgBody}, "format": {"org"},
52 }); status != 200 {
53 t.Fatalf("issue create: %d", status)
54 }
55 if _, body := browserGet(t, alice, base+"/issues/1"); !strings.Contains(body, "<em>emphasis</em>") {
56 t.Fatalf("issue body did not render as org:\n%s", body)
57 }
58
59 // A merge request written with the org choice renders as org too.
60 if status, _ := browserPost(t, alice, base+"/mrs/new", url.Values{
61 "source": {"topic"}, "target": {"main"}, "title": {"org mr"}, "body": {orgBody}, "format": {"org"},
62 }); status != 200 {
63 t.Fatalf("mr create: %d", status)
64 }
65 if _, body := browserGet(t, alice, base+"/mrs/1"); !strings.Contains(body, "<em>emphasis</em>") {
66 t.Fatalf("mr body did not render as org:\n%s", body)
67 }
68
69 // A form left on the default choice still writes markdown.
70 if status, _ := browserPost(t, alice, base+"/issues/new", url.Values{
71 "title": {"md issue"}, "body": {orgBody}, "format": {"md"},
72 }); status != 200 {
73 t.Fatalf("issue create: %d", status)
74 }
75 if _, body := browserGet(t, alice, base+"/issues/2"); strings.Contains(body, "<em>emphasis</em>") {
76 t.Fatalf("issue body rendered as org despite the md choice:\n%s", body)
77 }
78}
internal/httpd/accounts.go +12 −2
@@ -311,12 +311,17 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
311311 }
312312 }
313313 }
314 format := r.URL.Query().Get("format")
315 if format != "org" {
316 format = "md"
317 }
314318 s.render(w, "issuenew.html", struct {
315319 repoPage
316320 Body string
321 Format string
317322 Template string
318323 Templates []control.IssueTemplate
319 }{p, body, tplName, templates})
324 }{p, body, format, tplName, templates})
320325}
321326
322327// Issue and merge request writes run the command the CLI runs, so the
@@ -326,8 +331,13 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store
326331func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
327332 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
328333 title := strings.TrimSpace(r.FormValue("title"))
334 format := r.FormValue("format")
335 if format != "org" {
336 format = "md"
337 }
329338 var created control.Created
330 code, msg := s.dispatchIntoStdin(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body"), &created)
339 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
340 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
331341 if code != protocol.ExitOK {
332342 http.Error(w, msg, statusForExit(code))
333343 return
internal/httpd/mractions.go +12 −3
@@ -151,6 +151,7 @@ type mrNewPage struct {
151151 Target string
152152 Title string
153153 Body string
154 Format string
154155 Notice string
155156}
156157
@@ -166,10 +167,14 @@ func (s *Server) mrCreateForm(w http.ResponseWriter, r *http.Request, u store.Us
166167 if target == "" {
167168 target = p.Repo.DefaultBranch
168169 }
170 format := q.Get("format")
171 if format != "org" {
172 format = "md"
173 }
169174 s.render(w, "mrnew.html", mrNewPage{
170175 repoPage: p, Branches: branches,
171176 Source: q.Get("source"), Target: target,
172 Title: q.Get("title"), Body: q.Get("body"), Notice: s.takeFlash(w, r),
177 Title: q.Get("title"), Body: q.Get("body"), Format: format, Notice: s.takeFlash(w, r),
173178 })
174179}
175180
@@ -182,9 +187,13 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
182187 target := strings.TrimSpace(r.FormValue("target"))
183188 title := strings.TrimSpace(r.FormValue("title"))
184189 body := strings.TrimSpace(r.FormValue("body"))
190 format := r.FormValue("format")
191 if format != "org" {
192 format = "md"
193 }
185194
186195 back := func(msg string) {
187 q := url.Values{"source": {source}, "target": {target}, "title": {title}, "body": {body}}
196 q := url.Values{"source": {source}, "target": {target}, "title": {title}, "body": {body}, "format": {format}}
188197 s.setFlash(w, msg)
189198 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/new?%s", p.Repo.Path(), q.Encode()), http.StatusSeeOther)
190199 }
@@ -192,7 +201,7 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
192201 back("pick a source branch and give the merge request a title")
193202 return
194203 }
195 argv := []string{"mr", "create", p.Repo.Path(), "--source", source, "--title", title}
204 argv := []string{"mr", "create", p.Repo.Path(), "--source", source, "--title", title, "--format", format}
196205 if target != "" {
197206 argv = append(argv, "--target", target)
198207 }
internal/web/templates/issuenew.html +1
@@ -6,6 +6,7 @@
66<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
77<p><input type="text" name="title" aria-label="Title" placeholder="title" required></p>
88<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p>
9{{template "formatpicker" .Format}}
910<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p>
1011<p><button type="submit">Open issue</button></p>
1112</form>
internal/web/templates/layout.html +11
@@ -122,6 +122,17 @@
122122
123123{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}}
124124
125{{/* formatpicker is the md/org choice on a body: issue and MR create, and
126 a profile's about text. The argument is the currently selected
127 format; empty selects Markdown, today's default. */}}
128{{define "formatpicker"}}<p class="branchpick">
129 <label for="format">Body markup</label>
130 <select id="format" name="format">
131 <option value="md"{{if ne . "org"}} selected{{end}}>Markdown</option>
132 <option value="org"{{if eq . "org"}} selected{{end}}>Org</option>
133 </select>
134</p>{{end}}
135
125136{{/* difffiles renders a parsed diff: one foldable section per file, with
126137 line-number gutters and review threads inline. Base is the MR's
127138 endpoint and Viewer the signed-in account; the commit page passes
internal/web/templates/mrnew.html +1
@@ -16,6 +16,7 @@
1616</p>
1717<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p>
1818<p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p>
19{{template "formatpicker" .Format}}
1920<p><button type="submit">Open merge request</button></p>
2021</form>
2122<p class="meta">A branch in a fork opens from the CLI: