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
311 } 311 }
312 } 312 }
313 } 313 }
314 format := r.URL.Query().Get("format")
315 if format != "org" {
316 format = "md"
317 }
314 s.render(w, "issuenew.html", struct { 318 s.render(w, "issuenew.html", struct {
315 repoPage 319 repoPage
316 Body string 320 Body string
321 Format string
317 Template string 322 Template string
318 Templates []control.IssueTemplate 323 Templates []control.IssueTemplate
319 }{p, body, tplName, templates}) 324 }{p, body, format, tplName, templates})
320} 325}
321 326
322// Issue and merge request writes run the command the CLI runs, so the 327// 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
326func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 331func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
327 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 332 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
328 title := strings.TrimSpace(r.FormValue("title")) 333 title := strings.TrimSpace(r.FormValue("title"))
334 format := r.FormValue("format")
335 if format != "org" {
336 format = "md"
337 }
329 var created control.Created 338 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)
331 if code != protocol.ExitOK { 341 if code != protocol.ExitOK {
332 http.Error(w, msg, statusForExit(code)) 342 http.Error(w, msg, statusForExit(code))
333 return 343 return
internal/httpd/mractions.go +12 −3
@@ -151,6 +151,7 @@ type mrNewPage struct {
151 Target string 151 Target string
152 Title string 152 Title string
153 Body string 153 Body string
154 Format string
154 Notice string 155 Notice string
155} 156}
156 157
@@ -166,10 +167,14 @@ func (s *Server) mrCreateForm(w http.ResponseWriter, r *http.Request, u store.Us
166 if target == "" { 167 if target == "" {
167 target = p.Repo.DefaultBranch 168 target = p.Repo.DefaultBranch
168 } 169 }
170 format := q.Get("format")
171 if format != "org" {
172 format = "md"
173 }
169 s.render(w, "mrnew.html", mrNewPage{ 174 s.render(w, "mrnew.html", mrNewPage{
170 repoPage: p, Branches: branches, 175 repoPage: p, Branches: branches,
171 Source: q.Get("source"), Target: target, 176 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),
173 }) 178 })
174} 179}
175 180
@@ -182,9 +187,13 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
182 target := strings.TrimSpace(r.FormValue("target")) 187 target := strings.TrimSpace(r.FormValue("target"))
183 title := strings.TrimSpace(r.FormValue("title")) 188 title := strings.TrimSpace(r.FormValue("title"))
184 body := strings.TrimSpace(r.FormValue("body")) 189 body := strings.TrimSpace(r.FormValue("body"))
190 format := r.FormValue("format")
191 if format != "org" {
192 format = "md"
193 }
185 194
186 back := func(msg string) { 195 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}}
188 s.setFlash(w, msg) 197 s.setFlash(w, msg)
189 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/new?%s", p.Repo.Path(), q.Encode()), http.StatusSeeOther) 198 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/new?%s", p.Repo.Path(), q.Encode()), http.StatusSeeOther)
190 } 199 }
@@ -192,7 +201,7 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
192 back("pick a source branch and give the merge request a title") 201 back("pick a source branch and give the merge request a title")
193 return 202 return
194 } 203 }
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}
196 if target != "" { 205 if target != "" {
197 argv = append(argv, "--target", target) 206 argv = append(argv, "--target", target)
198 } 207 }
internal/web/templates/issuenew.html +1
@@ -6,6 +6,7 @@
6<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> 6<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
7<p><input type="text" name="title" aria-label="Title" placeholder="title" required></p> 7<p><input type="text" name="title" aria-label="Title" placeholder="title" required></p>
8<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> 8<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p>
9{{template "formatpicker" .Format}}
9<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p> 10<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p>
10<p><button type="submit">Open issue</button></p> 11<p><button type="submit">Open issue</button></p>
11</form> 12</form>
internal/web/templates/layout.html +11
@@ -122,6 +122,17 @@
122 122
123{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}} 123{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}}
124 124
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
125{{/* difffiles renders a parsed diff: one foldable section per file, with 136{{/* difffiles renders a parsed diff: one foldable section per file, with
126 line-number gutters and review threads inline. Base is the MR's 137 line-number gutters and review threads inline. Base is the MR's
127 endpoint and Viewer the signed-in account; the commit page passes 138 endpoint and Viewer the signed-in account; the commit page passes
internal/web/templates/mrnew.html +1
@@ -16,6 +16,7 @@
16</p> 16</p>
17<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p> 17<p><input type="text" name="title" aria-label="Title" placeholder="title" value="{{.Title}}" required></p>
18<p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p> 18<p><textarea name="body" aria-label="Description" rows="10" placeholder="what changes, and why">{{.Body}}</textarea></p>
19{{template "formatpicker" .Format}}
19<p><button type="submit">Open merge request</button></p> 20<p><button type="submit">Open merge request</button></p>
20</form> 21</form>
21<p class="meta">A branch in a fork opens from the CLI: 22<p class="meta">A branch in a fork opens from the CLI: