A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 12fb4ebe30

12fb4ebe30b6ccf4ad1d1bdd9e2c94e0804eaed6

parent: b8cda1cc1e

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-29T03:03:06Z

bodies: choose markdown or org per body

Issue and MR bodies, their comments, and release notes carry the markup
they were written in. `--format md|org` on create, comment and edit;
markdown when unsaid, so every body written before this renders exactly
as it did.

The format is stored with the text rather than read from an account
preference, matching about_format: a preference would reinterpret prose
that already exists the moment it changed.

Org bodies render through the same path as READMEs and wiki pages, so
they inherit its include guard and sanitising rather than growing a
second org renderer to keep in step. They render without go-org's table
of contents — a README is a document and carries one, a remark is not.

Not covered, and noted on the Parity page: diff-line comments have no
format column yet, and the web form and iOS composer write markdown
because neither offers a picker. Both surfaces render the stored format
correctly.

The issue show --json golden gains body_format; the field is reported
wherever a body is.

Closes #51.
e2e/issue_test.go +4 −1
@@ -70,8 +70,11 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) {
7070 if code != 0 {
7171 t.Fatal("issue show failed")
7272 }
73 // A body reports the markup it was written in; "md" is what a body with no
74 // --format carries, and what everything written before formats existed has.
7375 wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` +
74 `"comments":[{"author":"bob","body":"me too","created_at":"TS"}],` +
76 `"body_format":"md",` +
77 `"comments":[{"author":"bob","body":"me too","body_format":"md","created_at":"TS"}],` +
7578 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` +
7679 `"title":"first bug"},"protocol_version":1}`
7780 if g := golden(t, out); g != wantShow {
e2e/orgbody_test.go added +83
@@ -0,0 +1,83 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8// A body written in org renders as org, end to end: the format is chosen over
9// SSH, stored with the text, and honoured when the page is built. A body
10// without a format is markdown, which is what everything written before the
11// format existed carries.
12func TestOrgBodies(t *testing.T) {
13 inst := startInstance(t)
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice",
16 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17
18 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
19 t.Fatalf("repo create: %s", errOut)
20 }
21
22 const orgBody = "* A heading\n\nSome /emphasis/ and =code= here.\n"
23
24 // #1 is org, #2 is the same text left as markdown.
25 if _, errOut, code := inst.ssh(t, aliceKey, orgBody, "issue", "create", "alice/app",
26 "--title", "'org issue'", "--file", "-", "--format", "org"); code != 0 {
27 t.Fatalf("issue create --format org: %s", errOut)
28 }
29 if _, errOut, code := inst.ssh(t, aliceKey, orgBody, "issue", "create", "alice/app",
30 "--title", "'md issue'", "--file", "-"); code != 0 {
31 t.Fatalf("issue create: %s", errOut)
32 }
33
34 status, body := inst.get(t, "/alice/app/issues/1")
35 if status != 200 {
36 t.Fatalf("issue 1: status %d", status)
37 }
38 if !strings.Contains(body, "<em>emphasis</em>") || !strings.Contains(body, "<code>code</code>") {
39 t.Fatalf("org body did not render as org:\n%s", body)
40 }
41 // A remark is not a document: no table of contents above two headings.
42 if strings.Contains(body, `href="#headline-1"`) {
43 t.Fatalf("org body grew a table of contents:\n%s", body)
44 }
45
46 status, body = inst.get(t, "/alice/app/issues/2")
47 if status != 200 {
48 t.Fatalf("issue 2: status %d", status)
49 }
50 // Markdown leaves org markup alone; the heading stays literal text.
51 if strings.Contains(body, "<em>emphasis</em>") {
52 t.Fatalf("markdown body rendered as org:\n%s", body)
53 }
54
55 // Comments carry their own format, independent of the issue's.
56 if _, errOut, code := inst.ssh(t, aliceKey, "A /commented/ remark.\n",
57 "issue", "comment", "alice/app", "2", "--file", "-", "--format", "org"); code != 0 {
58 t.Fatalf("issue comment --format org: %s", errOut)
59 }
60 if status, body = inst.get(t, "/alice/app/issues/2"); status != 200 ||
61 !strings.Contains(body, "<em>commented</em>") {
62 t.Fatalf("org comment on a markdown issue did not render as org:\n%s", body)
63 }
64
65 // An edit that does not mention a format leaves the stored one alone.
66 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "edit", "alice/app", "1",
67 "--title", "'org issue, retitled'"); code != 0 {
68 t.Fatalf("issue edit: %s", errOut)
69 }
70 if status, body = inst.get(t, "/alice/app/issues/1"); status != 200 ||
71 !strings.Contains(body, "<em>emphasis</em>") {
72 t.Fatalf("editing the title dropped the body's org format:\n%s", body)
73 }
74
75 // The format is reported over the API, so other surfaces can honour it.
76 out, errOut, code := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json")
77 if code != 0 {
78 t.Fatalf("issue show --json: %s", errOut)
79 }
80 if !strings.Contains(out, `"body_format":"org"`) {
81 t.Fatalf("issue show did not report the body format:\n%s", out)
82 }
83}
internal/control/ghimport.go +4 −4
@@ -184,7 +184,7 @@ func runImportIssues(c *Ctx, args []string) int {
184184 return c.fail(protocol.ExitFailure, "%v", err)
185185 }
186186 body := attribution(src, it.Number, "pull request", it.User.Login, it.CreatedAt) + it.Body
187 localN, err = c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, pr.Head.Ref, pr.Base.Ref, it.Title, body, pr.Head.SHA)
187 localN, err = c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, pr.Head.Ref, pr.Base.Ref, it.Title, body, pr.Head.SHA, "md")
188188 if err != nil {
189189 return c.fail(protocol.ExitFailure, "%v", err)
190190 }
@@ -206,7 +206,7 @@ func runImportIssues(c *Ctx, args []string) int {
206206 mrs++
207207 } else {
208208 body := attribution(src, it.Number, "issue", it.User.Login, it.CreatedAt) + it.Body
209 localN, err = c.Store.CreateIssue(repo.ID, c.User.ID, it.Title, body)
209 localN, err = c.Store.CreateIssue(repo.ID, c.User.ID, it.Title, body, "md")
210210 if err != nil {
211211 return c.fail(protocol.ExitFailure, "%v", err)
212212 }
@@ -276,9 +276,9 @@ func importComments(c *Ctx, g *ghClient, repo store.Repo, from, src string, ghN,
276276 body := fmt.Sprintf("> @%s, %s\n\n%s", cm.User.Login, ghDate(cm.CreatedAt), cm.Body)
277277 var err error
278278 if isPR {
279 err = c.Store.AddMRComment(localMRID, c.User.ID, body)
279 err = c.Store.AddMRComment(localMRID, c.User.ID, body, "md")
280280 } else {
281 err = c.Store.AddIssueComment(localIssueID, c.User.ID, body)
281 err = c.Store.AddIssueComment(localIssueID, c.User.ID, body, "md")
282282 }
283283 if err != nil {
284284 return imported, err
internal/control/issue.go +86 −33
@@ -16,17 +16,17 @@ const maxBodyBytes = 64 << 10
1616
1717 func init() {
1818 register(Command{Path: []string{"issue", "create"},
19 Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]",
19 Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]",
2020 ReadsStdin: true, Run: runIssueCreate})
2121 register(Command{Path: []string{"issue", "list"},
2222 Summary: "list issues: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runIssueList})
2323 register(Command{Path: []string{"issue", "show"},
2424 Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow})
2525 register(Command{Path: []string{"issue", "edit"},
26 Summary: "edit title or body: issue edit <owner/name> <n> [--title <t>] [--body <b> | --file -]",
26 Summary: "edit title or body: issue edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
2727 ReadsStdin: true, Run: runIssueEdit})
2828 register(Command{Path: []string{"issue", "comment"},
29 Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]",
29 Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
3030 ReadsStdin: true, Run: runIssueComment})
3131 register(Command{Path: []string{"issue", "close"},
3232 Summary: "close an issue: issue close <owner/name> <n>", Run: runIssueClose})
@@ -76,16 +76,31 @@ func bodyFrom(c *Ctx, inline, file string) (string, error) {
7676 return inline, nil
7777 }
7878
79// markupFormat normalizes a --format value. Empty means the caller did not ask,
80// which the caller turns into "md" on create or "unchanged" on edit.
81func markupFormat(v string) (string, error) {
82 switch strings.ToLower(strings.TrimSpace(v)) {
83 case "":
84 return "", nil
85 case "md", "markdown":
86 return "md", nil
87 case "org", "org-mode":
88 return "org", nil
89 }
90 return "", fmt.Errorf("unknown --format %q (want md or org)", v)
91}
92
7993 type issueOut struct {
80 Number int64 `json:"number"`
81 Title string `json:"title"`
82 State string `json:"state"`
83 Author string `json:"author"`
84 Milestone string `json:"milestone,omitempty"`
85 Labels []string `json:"labels,omitempty"`
86 Assignees []string `json:"assignees,omitempty"`
87 Body string `json:"body,omitempty"`
88 CreatedAt string `json:"created_at"`
94 Number int64 `json:"number"`
95 Title string `json:"title"`
96 State string `json:"state"`
97 Author string `json:"author"`
98 Milestone string `json:"milestone,omitempty"`
99 Labels []string `json:"labels,omitempty"`
100 Assignees []string `json:"assignees,omitempty"`
101 Body string `json:"body,omitempty"`
102 BodyFormat string `json:"body_format,omitempty"`
103 CreatedAt string `json:"created_at"`
89104 }
90105
91106 func issueToOut(i store.Issue, withBody bool) issueOut {
@@ -93,14 +108,21 @@ func issueToOut(i store.Issue, withBody bool) issueOut {
93108 Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
94109 if withBody {
95110 o.Body = i.Body
111 o.BodyFormat = i.BodyFormat
96112 }
97113 return o
98114 }
99115
100116 func runIssueCreate(c *Ctx, args []string) int {
101 var path, title, body, file string
117 var path, title, body, file, format string
102118 for i := 0; i < len(args); i++ {
103119 switch args[i] {
120 case "--format":
121 if i+1 >= len(args) {
122 return c.fail(protocol.ExitUsage, "--format requires a value")
123 }
124 format = args[i+1]
125 i++
104126 case "--title":
105127 if i+1 >= len(args) {
106128 return c.fail(protocol.ExitUsage, "--title requires a value")
@@ -127,7 +149,14 @@ func runIssueCreate(c *Ctx, args []string) int {
127149 }
128150 }
129151 if path == "" || title == "" {
130 return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -]")
152 return c.fail(protocol.ExitUsage, "usage: issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]")
153 }
154 fmtName, err := markupFormat(format)
155 if err != nil {
156 return c.fail(protocol.ExitUsage, "%v", err)
157 }
158 if fmtName == "" {
159 fmtName = "md"
131160 }
132161 // Anyone who can read the repo can file an issue.
133162 repo, code := resolveRepo(c, path, policy.CanRead)
@@ -141,7 +170,7 @@ func runIssueCreate(c *Ctx, args []string) int {
141170 if err != nil {
142171 return c.fail(protocol.ExitUsage, "%v", err)
143172 }
144 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b)
173 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, title, b, fmtName)
145174 if err != nil {
146175 return c.fail(protocol.ExitFailure, "%v", err)
147176 }
@@ -215,13 +244,14 @@ func runIssueShow(c *Ctx, args []string) int {
215244 return c.fail(protocol.ExitFailure, "%v", err)
216245 }
217246 type commentOut struct {
218 Author string `json:"author"`
219 Body string `json:"body"`
220 CreatedAt string `json:"created_at"`
247 Author string `json:"author"`
248 Body string `json:"body"`
249 BodyFormat string `json:"body_format,omitempty"`
250 CreatedAt string `json:"created_at"`
221251 }
222252 var cs []commentOut
223253 for _, cm := range comments {
224 cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
254 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
225255 }
226256 d := struct {
227257 issueOut
@@ -247,9 +277,15 @@ func runIssueShow(c *Ctx, args []string) int {
247277
248278 func runIssueComment(c *Ctx, args []string) int {
249279 var rest []string
250 var message, file string
280 var message, file, format string
251281 for i := 0; i < len(args); i++ {
252282 switch args[i] {
283 case "--format":
284 if i+1 >= len(args) {
285 return c.fail(protocol.ExitUsage, "--format requires a value")
286 }
287 format = args[i+1]
288 i++
253289 case "--message":
254290 if i+1 >= len(args) {
255291 return c.fail(protocol.ExitUsage, "--message requires a value")
@@ -266,6 +302,13 @@ func runIssueComment(c *Ctx, args []string) int {
266302 rest = append(rest, args[i])
267303 }
268304 }
305 fmtName, err := markupFormat(format)
306 if err != nil {
307 return c.fail(protocol.ExitUsage, "%v", err)
308 }
309 if fmtName == "" {
310 fmtName = "md"
311 }
269312 repo, issue, code := issueRef(c, rest, policy.CanRead)
270313 if code >= 0 {
271314 return code
@@ -280,7 +323,7 @@ func runIssueComment(c *Ctx, args []string) int {
280323 if strings.TrimSpace(body) == "" {
281324 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
282325 }
283 if err := c.Store.AddIssueComment(issue.ID, c.User.ID, body); err != nil {
326 if err := c.Store.AddIssueComment(issue.ID, c.User.ID, body, fmtName); err != nil {
284327 return c.fail(protocol.ExitFailure, "%v", err)
285328 }
286329 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number))
@@ -330,15 +373,16 @@ func setIssueState(c *Ctx, args []string, state string) int {
330373 })
331374 }
332375
333// editText parses --title/--body/--file - and authorizes: author or write.
334func editText(c *Ctx, args []string, kind string) (rest []string, title, body *string, code int) {
335 var titleV, bodyV, file string
376// editText parses --title/--body/--file -/--format and authorizes: author or
377// write. A nil format means the stored markup format stays as it is.
378func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) {
379 var titleV, bodyV, file, formatV string
336380 haveTitle, haveBody := false, false
337381 for i := 0; i < len(args); i++ {
338382 switch args[i] {
339 case "--title", "--body", "--file":
383 case "--title", "--body", "--file", "--format":
340384 if i+1 >= len(args) {
341 return nil, nil, nil, c.fail(protocol.ExitUsage, "%s requires a value", args[i])
385 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%s requires a value", args[i])
342386 }
343387 switch args[i] {
344388 case "--title":
@@ -347,6 +391,8 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body *s
347391 bodyV, haveBody = args[i+1], true
348392 case "--file":
349393 file = args[i+1]
394 case "--format":
395 formatV = args[i+1]
350396 }
351397 i++
352398 default:
@@ -356,27 +402,34 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body *s
356402 if file != "" {
357403 b, err := bodyFrom(c, "", file)
358404 if err != nil {
359 return nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
405 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
360406 }
361407 bodyV, haveBody = b, true
362408 }
363 if !haveTitle && !haveBody {
364 return nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -]", kind)
409 fmtName, err := markupFormat(formatV)
410 if err != nil {
411 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err)
412 }
413 if !haveTitle && !haveBody && fmtName == "" {
414 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", kind)
365415 }
366416 if haveTitle {
367417 if strings.TrimSpace(titleV) == "" {
368 return nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
418 return nil, nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty")
369419 }
370420 title = &titleV
371421 }
372422 if haveBody {
373423 body = &bodyV
374424 }
375 return rest, title, body, -1
425 if fmtName != "" {
426 format = &fmtName
427 }
428 return rest, title, body, format, -1
376429 }
377430
378431 func runIssueEdit(c *Ctx, args []string) int {
379 rest, title, body, code := editText(c, args, "issue")
432 rest, title, body, format, code := editText(c, args, "issue")
380433 if code >= 0 {
381434 return code
382435 }
@@ -394,7 +447,7 @@ func runIssueEdit(c *Ctx, args []string) int {
394447 if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
395448 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue")
396449 }
397 if err := c.Store.UpdateIssueText(issue.ID, title, body); err != nil {
450 if err := c.Store.UpdateIssueText(issue.ID, title, body, format); err != nil {
398451 return c.fail(protocol.ExitFailure, "%v", err)
399452 }
400453 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) {
internal/control/migrate.go +4 −4
@@ -227,7 +227,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
227227 continue
228228 }
229229 body := migAttribution(src, "issue", bi.Author, bi.CreatedAt, bi.Number) + bi.Body
230 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, bi.Title, body)
230 n, err := c.Store.CreateIssue(repo.ID, c.User.ID, bi.Title, body, "md")
231231 if err != nil {
232232 return c.fail(protocol.ExitFailure, "%v", err)
233233 }
@@ -243,7 +243,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
243243 }
244244 for _, cm := range bi.Comments {
245245 c.Store.AddIssueComment(iss.ID, c.User.ID,
246 fmt.Sprintf("> %s, %.10s\n\n%s", cm.Author, cm.CreatedAt, cm.Body))
246 fmt.Sprintf("> %s, %.10s\n\n%s", cm.Author, cm.CreatedAt, cm.Body), "md")
247247 comments++
248248 }
249249 c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n))
@@ -256,7 +256,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
256256 continue
257257 }
258258 body := migAttribution(src, "merge request", bm.Author, bm.CreatedAt, bm.Number) + bm.Body
259 n, err := c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, bm.SourceRef, bm.TargetRef, bm.Title, body, "")
259 n, err := c.Store.CreateMR(repo.ID, c.User.ID, repo.ID, bm.SourceRef, bm.TargetRef, bm.Title, body, "", "md")
260260 if err != nil {
261261 return c.fail(protocol.ExitFailure, "%v", err)
262262 }
@@ -273,7 +273,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
273273 }
274274 for _, cm := range bm.Comments {
275275 c.Store.AddMRComment(mr.ID, c.User.ID,
276 fmt.Sprintf("> %s, %.10s\n\n%s", cm.Author, cm.CreatedAt, cm.Body))
276 fmt.Sprintf("> %s, %.10s\n\n%s", cm.Author, cm.CreatedAt, cm.Body), "md")
277277 comments++
278278 }
279279 c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n))
internal/control/mr.go +49 −27
@@ -27,7 +27,7 @@ func init() {
2727 register(Command{Path: []string{"repo", "settings", "require-signed"},
2828 Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
2929 register(Command{Path: []string{"mr", "create"},
30 Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]",
30 Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -] [--format md|org]",
3131 ReadsStdin: true, Run: runMRCreate})
3232 register(Command{Path: []string{"mr", "list"},
3333 Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList})
@@ -36,10 +36,10 @@ func init() {
3636 register(Command{Path: []string{"mr", "diff"},
3737 Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
3838 register(Command{Path: []string{"mr", "edit"},
39 Summary: "edit title or body: mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -]",
39 Summary: "edit title or body: mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
4040 ReadsStdin: true, Run: runMREdit})
4141 register(Command{Path: []string{"mr", "comment"},
42 Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]",
42 Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
4343 ReadsStdin: true, Run: runMRComment})
4444 register(Command{Path: []string{"mr", "review"},
4545 Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
@@ -210,10 +210,10 @@ func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool
210210 func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
211211
212212 func runMRCreate(c *Ctx, args []string) int {
213 var path, source, target, title, body, file string
213 var path, source, target, title, body, file, format string
214214 for i := 0; i < len(args); i++ {
215215 switch args[i] {
216 case "--source", "--target", "--title", "--body", "--file":
216 case "--source", "--target", "--title", "--body", "--file", "--format":
217217 if i+1 >= len(args) {
218218 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
219219 }
@@ -229,6 +229,8 @@ func runMRCreate(c *Ctx, args []string) int {
229229 body = v
230230 case "--file":
231231 file = v
232 case "--format":
233 format = v
232234 }
233235 i++
234236 default:
@@ -241,6 +243,13 @@ func runMRCreate(c *Ctx, args []string) int {
241243 if path == "" || source == "" || title == "" {
242244 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
243245 }
246 fmtName, err := markupFormat(format)
247 if err != nil {
248 return c.fail(protocol.ExitUsage, "%v", err)
249 }
250 if fmtName == "" {
251 fmtName = "md"
252 }
244253 repo, code := resolveRepo(c, path, policy.CanRead)
245254 if code >= 0 {
246255 return code
@@ -275,7 +284,7 @@ func runMRCreate(c *Ctx, args []string) int {
275284 if err != nil {
276285 return c.fail(protocol.ExitUsage, "%v", err)
277286 }
278 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA)
287 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName)
279288 if err != nil {
280289 return c.fail(protocol.ExitFailure, "%v", err)
281290 }
@@ -295,16 +304,17 @@ func runMRCreate(c *Ctx, args []string) int {
295304 }
296305
297306 type mrOut struct {
298 Number int64 `json:"number"`
299 Title string `json:"title"`
300 State string `json:"state"`
301 Author string `json:"author"`
302 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
303 TargetRef string `json:"target_ref"`
304 HeadSHA string `json:"head_sha"`
305 Body string `json:"body,omitempty"`
306 Milestone string `json:"milestone,omitempty"`
307 CreatedAt string `json:"created_at"`
307 Number int64 `json:"number"`
308 Title string `json:"title"`
309 State string `json:"state"`
310 Author string `json:"author"`
311 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
312 TargetRef string `json:"target_ref"`
313 HeadSHA string `json:"head_sha"`
314 Body string `json:"body,omitempty"`
315 BodyFormat string `json:"body_format,omitempty"`
316 Milestone string `json:"milestone,omitempty"`
317 CreatedAt string `json:"created_at"`
308318 }
309319
310320 func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
@@ -321,6 +331,7 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
321331 CreatedAt: m.CreatedAt}
322332 if withBody {
323333 o.Body = m.Body
334 o.BodyFormat = m.BodyFormat
324335 }
325336 return o
326337 }
@@ -398,9 +409,10 @@ func runMRShow(c *Ctx, args []string) int {
398409 return c.fail(protocol.ExitFailure, "%v", err)
399410 }
400411 type commentOut struct {
401 Author string `json:"author"`
402 Body string `json:"body"`
403 CreatedAt string `json:"created_at"`
412 Author string `json:"author"`
413 Body string `json:"body"`
414 BodyFormat string `json:"body_format,omitempty"`
415 CreatedAt string `json:"created_at"`
404416 }
405417 type reviewOut struct {
406418 Reviewer string `json:"reviewer"`
@@ -418,7 +430,7 @@ func runMRShow(c *Ctx, args []string) int {
418430 }
419431 var cs []commentOut
420432 for _, cm := range comments {
421 cs = append(cs, commentOut{cm.Author, cm.Body, cm.CreatedAt})
433 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
422434 }
423435 var rs []reviewOut
424436 for _, r := range reviews {
@@ -512,7 +524,7 @@ func runMRDiff(c *Ctx, args []string) int {
512524 }
513525
514526 func runMREdit(c *Ctx, args []string) int {
515 rest, title, body, code := editText(c, args, "mr")
527 rest, title, body, format, code := editText(c, args, "mr")
516528 if code >= 0 {
517529 return code
518530 }
@@ -530,7 +542,7 @@ func runMREdit(c *Ctx, args []string) int {
530542 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
531543 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request")
532544 }
533 if err := c.Store.UpdateMRText(mr.ID, title, body); err != nil {
545 if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
534546 return c.fail(protocol.ExitFailure, "%v", err)
535547 }
536548 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
@@ -540,23 +552,33 @@ func runMREdit(c *Ctx, args []string) int {
540552
541553 func runMRComment(c *Ctx, args []string) int {
542554 var rest []string
543 var message, file string
555 var message, file, format string
544556 for i := 0; i < len(args); i++ {
545557 switch args[i] {
546 case "--message", "--file":
558 case "--message", "--file", "--format":
547559 if i+1 >= len(args) {
548560 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
549561 }
550 if args[i] == "--message" {
562 switch args[i] {
563 case "--message":
551564 message = args[i+1]
552 } else {
565 case "--file":
553566 file = args[i+1]
567 case "--format":
568 format = args[i+1]
554569 }
555570 i++
556571 default:
557572 rest = append(rest, args[i])
558573 }
559574 }
575 fmtName, err := markupFormat(format)
576 if err != nil {
577 return c.fail(protocol.ExitUsage, "%v", err)
578 }
579 if fmtName == "" {
580 fmtName = "md"
581 }
560582 repo, mr, code := mrRef(c, rest, policy.CanRead)
561583 if code >= 0 {
562584 return code
@@ -571,7 +593,7 @@ func runMRComment(c *Ctx, args []string) int {
571593 if strings.TrimSpace(body) == "" {
572594 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
573595 }
574 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil {
596 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body, fmtName); err != nil {
575597 return c.fail(protocol.ExitFailure, "%v", err)
576598 }
577599 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number))
internal/control/release.go +39 −18
@@ -19,10 +19,10 @@ import (
1919
2020 func init() {
2121 register(Command{Path: []string{"release", "create"},
22 Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]",
22 Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]",
2323 ReadsStdin: true, Run: runReleaseCreate})
2424 register(Command{Path: []string{"release", "edit"},
25 Summary: "update a release's title and notes: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]",
25 Summary: "update a release's title and notes: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]",
2626 ReadsStdin: true, Run: runReleaseEdit})
2727 register(Command{Path: []string{"release", "list"},
2828 Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList})
@@ -68,10 +68,11 @@ func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string)
6868 }
6969
7070 func runReleaseCreate(c *Ctx, args []string) int {
71 var path, tag, title, notes, file string
71 const usage = "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]"
72 var path, tag, title, notes, file, format string
7273 for i := 0; i < len(args); i++ {
7374 switch args[i] {
74 case "--title", "--notes", "--file":
75 case "--title", "--notes", "--file", "--format":
7576 if i+1 >= len(args) {
7677 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
7778 }
@@ -82,6 +83,8 @@ func runReleaseCreate(c *Ctx, args []string) int {
8283 notes = args[i+1]
8384 case "--file":
8485 file = args[i+1]
86 case "--format":
87 format = args[i+1]
8588 }
8689 i++
8790 default:
@@ -90,12 +93,19 @@ func runReleaseCreate(c *Ctx, args []string) int {
9093 } else if tag == "" {
9194 tag = args[i]
9295 } else {
93 return c.fail(protocol.ExitUsage, "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]")
96 return c.fail(protocol.ExitUsage, usage)
9497 }
9598 }
9699 }
97100 if path == "" || tag == "" {
98 return c.fail(protocol.ExitUsage, "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]")
101 return c.fail(protocol.ExitUsage, usage)
102 }
103 fmtName, err := markupFormat(format)
104 if err != nil {
105 return c.fail(protocol.ExitUsage, "%v", err)
106 }
107 if fmtName == "" {
108 fmtName = "md"
99109 }
100110 repo, code := resolveRepo(c, path, policy.CanWrite)
101111 if code >= 0 {
@@ -115,7 +125,7 @@ func runReleaseCreate(c *Ctx, args []string) int {
115125 if title == "" {
116126 title = tag
117127 }
118 if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID); err != nil {
128 if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID, fmtName); err != nil {
119129 return c.fail(protocol.ExitUsage, "%v", err)
120130 }
121131 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
@@ -131,18 +141,20 @@ type assetOut struct {
131141 }
132142
133143 type releaseOut struct {
134 Tag string `json:"tag"`
135 Title string `json:"title"`
136 Notes string `json:"notes,omitempty"`
137 Author string `json:"author,omitempty"`
138 CreatedAt string `json:"created_at"`
139 Assets []assetOut `json:"assets,omitempty"`
144 Tag string `json:"tag"`
145 Title string `json:"title"`
146 Notes string `json:"notes,omitempty"`
147 NotesFormat string `json:"notes_format,omitempty"`
148 Author string `json:"author,omitempty"`
149 CreatedAt string `json:"created_at"`
150 Assets []assetOut `json:"assets,omitempty"`
140151 }
141152
142153 func releaseToOut(r store.Release, withNotes bool) releaseOut {
143154 o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt}
144155 if withNotes {
145156 o.Notes = r.Notes
157 o.NotesFormat = r.NotesFormat
146158 }
147159 for _, a := range r.Assets {
148160 o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256})
@@ -151,12 +163,12 @@ func releaseToOut(r store.Release, withNotes bool) releaseOut {
151163 }
152164
153165 func runReleaseEdit(c *Ctx, args []string) int {
154 const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]"
155 var path, tag, title, notes, file string
166 const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]"
167 var path, tag, title, notes, file, format string
156168 var setTitle, setNotes bool
157169 for i := 0; i < len(args); i++ {
158170 switch args[i] {
159 case "--title", "--notes", "--file":
171 case "--title", "--notes", "--file", "--format":
160172 if i+1 >= len(args) {
161173 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
162174 }
@@ -167,6 +179,8 @@ func runReleaseEdit(c *Ctx, args []string) int {
167179 notes, setNotes = args[i+1], true
168180 case "--file":
169181 file, setNotes = args[i+1], true
182 case "--format":
183 format = args[i+1]
170184 }
171185 i++
172186 default:
@@ -179,7 +193,11 @@ func runReleaseEdit(c *Ctx, args []string) int {
179193 }
180194 }
181195 }
182 if path == "" || tag == "" || (!setTitle && !setNotes) {
196 fmtName, err := markupFormat(format)
197 if err != nil {
198 return c.fail(protocol.ExitUsage, "%v", err)
199 }
200 if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") {
183201 return c.fail(protocol.ExitUsage, usage)
184202 }
185203 repo, code := resolveRepo(c, path, policy.CanWrite)
@@ -205,7 +223,10 @@ func runReleaseEdit(c *Ctx, args []string) int {
205223 return c.fail(protocol.ExitUsage, "%v", err)
206224 }
207225 }
208 if err := c.Store.UpdateRelease(repo.ID, tag, title, body); err != nil {
226 if fmtName == "" {
227 fmtName = rel.NotesFormat
228 }
229 if err := c.Store.UpdateRelease(repo.ID, tag, title, body, fmtName); err != nil {
209230 return c.fail(protocol.ExitFailure, "%v", err)
210231 }
211232 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
internal/httpd/accounts.go +5 −5
@@ -295,7 +295,7 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto
295295 http.Error(w, "title required", http.StatusBadRequest)
296296 return
297297 }
298 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
298 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"), "md")
299299 if err != nil {
300300 http.Error(w, "internal error", http.StatusInternalServerError)
301301 return
@@ -340,7 +340,7 @@ func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store
340340 return
341341 }
342342 body := r.FormValue("body")
343 if err := s.st.UpdateIssueText(iss.ID, &title, &body); err != nil {
343 if err := s.st.UpdateIssueText(iss.ID, &title, &body, nil); err != nil {
344344 http.Error(w, "internal error", http.StatusInternalServerError)
345345 return
346346 }
@@ -381,7 +381,7 @@ func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.Us
381381 return
382382 }
383383 body := r.FormValue("body")
384 if err := s.st.UpdateMRText(m.ID, &title, &body); err != nil {
384 if err := s.st.UpdateMRText(m.ID, &title, &body, nil); err != nil {
385385 http.Error(w, "internal error", http.StatusInternalServerError)
386386 return
387387 }
@@ -404,7 +404,7 @@ func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u st
404404 http.Error(w, "empty comment", http.StatusBadRequest)
405405 return
406406 }
407 if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
407 if err := s.st.AddIssueComment(iss.ID, u.ID, body, "md"); err != nil {
408408 http.Error(w, "internal error", http.StatusInternalServerError)
409409 return
410410 }
@@ -428,7 +428,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store
428428 http.Error(w, "empty comment", http.StatusBadRequest)
429429 return
430430 }
431 if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
431 if err := s.st.AddMRComment(m.ID, u.ID, body, "md"); err != nil {
432432 http.Error(w, "internal error", http.StatusInternalServerError)
433433 return
434434 }
internal/httpd/orgrender_test.go +53
@@ -112,3 +112,56 @@ func TestOrgRenderingIsUnaffectedByTheIncludeGuard(t *testing.T) {
112112 }
113113 }
114114 }
115
116// Bodies — issues, MRs, comments, release notes — render in the format they
117// were written in. The format travels with the text, so anything stored before
118// formats existed still renders as markdown.
119
120func TestUGCHTMLRendersOrgWhenAsked(t *testing.T) {
121 out := string(ugcHTML("* Heading\n\nSome /emphasis/ and =code=.", "org"))
122
123 if !strings.Contains(out, "<em>emphasis</em>") || !strings.Contains(out, "<code>code</code>") {
124 t.Errorf("org body did not render as org:\n%s", out)
125 }
126 if strings.Contains(out, "* Heading") {
127 t.Errorf("org heading left as literal text:\n%s", out)
128 }
129}
130
131func TestUGCHTMLDefaultsToMarkdown(t *testing.T) {
132 // "" is what every row written before the format column existed carries.
133 for _, format := range []string{"", "md"} {
134 out := string(ugcHTML("A **bold** claim.", format))
135 if !strings.Contains(out, "<strong>bold</strong>") {
136 t.Errorf("format %q did not render as markdown:\n%s", format, out)
137 }
138 }
139}
140
141// An org body is not a document, so it should not grow a table of contents the
142// way a README does.
143func TestUGCHTMLOmitsTheTableOfContents(t *testing.T) {
144 body := "* First\n\ntext\n\n* Second\n\nmore\n"
145
146 // The heading anchors themselves are section ids; a link *to* one is the
147 // table of contents, which is what a body must not grow.
148 if out := string(ugcHTML(body, "org")); strings.Contains(out, `href="#headline-1"`) {
149 t.Errorf("body sprouted a table of contents:\n%s", out)
150 }
151 // A README still gets one.
152 if out := string(renderReadme("README.org", []byte(body))); !strings.Contains(out, `href="#headline-1"`) {
153 t.Errorf("README lost its table of contents:\n%s", out)
154 }
155}
156
157// Bodies are the lowest-trust org on the instance: repo content needs push
158// access, but anyone who can comment can write one. The include guard must
159// cover them.
160func TestUGCHTMLOrgCannotReadServerFiles(t *testing.T) {
161 path := secretFile(t)
162 body := "#+INCLUDE: \"" + path + "\" src text\n"
163
164 if out := string(ugcHTML(body, "org")); strings.Contains(out, orgSecret) {
165 t.Fatalf("an org body read a server file:\n%s", out)
166 }
167}
internal/httpd/web.go +62 −26
@@ -571,7 +571,7 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
571571 }
572572 var views []relView
573573 for _, rel := range rels {
574 views = append(views, relView{rel, md(rel.Notes)})
574 views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
575575 }
576576 // Tags without a release yet are what a create form can offer.
577577 released := map[string]bool{}
@@ -1049,16 +1049,37 @@ func (r webResolver) UserURL(name string) string {
10491049 return ""
10501050 }
10511051
1052// ugcFor returns a renderer for user-authored markdown on one repo's pages:
1053// mdHTML plus cross-reference and mention autolinking for this viewer.
1054func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
1052// ugcRenderer renders one user-authored body in the format it was written in.
1053// The format travels with the body: it is recorded when the text is written, so
1054// changing a preference later cannot re-interpret prose that already exists.
1055type ugcRenderer func(raw, format string) template.HTML
1056
1057// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1058// so a body stored before formats existed — and any row whose column defaulted —
1059// renders exactly as it did before.
1060//
1061// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1062// about text take, so it inherits that function's include guard and sanitising
1063// rather than growing a second org renderer to keep in step.
1064func ugcHTML(raw, format string) template.HTML {
1065 if format == "org" {
1066 return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1067 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1068 })
1069 }
1070 return mdHTML(raw)
1071}
1072
1073// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1074// ugcHTML plus cross-reference and mention autolinking for this viewer.
1075func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
10551076 viewer := store.User{}
10561077 if s.cfg.Web.Mode == "accounts" {
10571078 viewer = s.viewer(r)
10581079 }
10591080 res := webResolver{s, viewer}
1060 return func(raw string) template.HTML {
1061 h := mdHTML(raw)
1081 return func(raw, format string) template.HTML {
1082 h := ugcHTML(raw, format)
10621083 if h == "" {
10631084 return h
10641085 }
@@ -1074,10 +1095,10 @@ type renderedComment struct {
10741095 BodyHTML template.HTML
10751096 }
10761097
1077func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
1098func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
10781099 var out []renderedComment
10791100 for _, c := range cs {
1080 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
1101 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
10811102 }
10821103 return out
10831104 }
@@ -1121,6 +1142,31 @@ func orgConfig() *org.Configuration {
11211142
11221143 var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
11231144
1145// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1146// of contents: a README or wiki page is a document and carries one, an issue
1147// comment is a remark and should not sprout one above two headings. `fallback`
1148// supplies the plaintext rendering used when the writer fails.
1149func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1150 c := orgConfig()
1151 if !contents {
1152 // DefaultSettings is a fresh map per org.New(), so this is local.
1153 c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1154 }
1155 doc := c.Parse(bytes.NewReader(raw), name)
1156 writer := org.NewHTMLWriter()
1157 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1158 if inline {
1159 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1160 }
1161 return fenceHighlight(source, lang)
1162 }
1163 out, err := doc.Write(writer)
1164 if err != nil {
1165 return fallback()
1166 }
1167 return template.HTML(ugcPolicy.Sanitize(out))
1168}
1169
11241170 func renderReadme(name string, raw []byte) template.HTML {
11251171 plain := func() template.HTML {
11261172 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
@@ -1136,19 +1182,7 @@ func renderReadme(name string, raw []byte) template.HTML {
11361182 }
11371183 return template.HTML(buf.String())
11381184 case ".org":
1139 doc := orgConfig().Parse(bytes.NewReader(raw), name)
1140 writer := org.NewHTMLWriter()
1141 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1142 if inline {
1143 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1144 }
1145 return fenceHighlight(source, lang)
1146 }
1147 out, err := doc.Write(writer)
1148 if err != nil {
1149 return plain()
1150 }
1151 return template.HTML(ugcPolicy.Sanitize(out))
1185 return renderOrg(name, raw, true, plain)
11521186 case ".html", ".htm":
11531187 return template.HTML(ugcPolicy.Sanitize(string(raw)))
11541188 default:
@@ -1166,23 +1200,25 @@ type diffThread struct {
11661200 // attachThreads injects review threads under their anchored diff lines;
11671201 // threads whose anchor no longer appears (stale after force-push, or on a
11681202 // context line outside the current diff) are returned separately.
1169func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) {
1203func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer) ([]diffFile, []diffThread) {
11701204 type anchor struct {
11711205 path string
11721206 side string
11731207 line int64
11741208 }
1209 // Diff-line comments have no stored format yet, so they stay markdown.
1210 // They are the one user-authored body left without the choice; see #51.
11751211 threads := map[int64]*diffThread{}
11761212 anchors := map[int64]anchor{}
11771213 var order []int64
11781214 for _, cm := range comments {
11791215 if cm.ReplyTo == 0 {
11801216 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1181 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1217 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
11821218 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
11831219 order = append(order, cm.ID)
11841220 } else if th, ok := threads[cm.ReplyTo]; ok {
1185 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1221 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
11861222 }
11871223 }
11881224 placed := map[int64]bool{}
@@ -1445,7 +1481,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
14451481 Milestones []store.Milestone
14461482 Notice string
14471483 LabelColors map[string]template.CSS
1448 }{p, iss, md(iss.Body), renderComments(comments, md),
1484 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
14491485 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
14501486 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
14511487 }
@@ -1594,7 +1630,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
15941630 Unresolved int
15951631 Notice string
15961632 DetachedThreads []diffThread
1597 }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1633 }{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md),
15981634 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
15991635 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
16001636 }
internal/store/issues.go +36 −30
@@ -8,30 +8,32 @@ import (
88 )
99
1010 type Issue struct {
11 ID int64
12 RepoID int64
13 Number int64
14 Author string
15 Title string
16 Body string
17 State string // open | closed
18 Milestone string
19 CreatedAt string
20 UpdatedAt string
21 Labels []string
22 Assignees []string
11 ID int64
12 RepoID int64
13 Number int64
14 Author string
15 Title string
16 Body string
17 BodyFormat string // md | org
18 State string // open | closed
19 Milestone string
20 CreatedAt string
21 UpdatedAt string
22 Labels []string
23 Assignees []string
2324 }
2425
2526 type IssueComment struct {
26 Author string
27 Body string
28 CreatedAt string
29 Kind string // comment | system
27 Author string
28 Body string
29 BodyFormat string // md | org
30 CreatedAt string
31 Kind string // comment | system
3032 }
3133
3234 // CreateIssue allocates the per-repo number from the repo counter inside the
3335 // same transaction as the insert — MAX(number)+1 races.
34func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64, error) {
36func (s *Store) CreateIssue(repoID, authorID int64, title, body, format string) (int64, error) {
3537 tx, err := s.DB.Begin()
3638 if err != nil {
3739 return 0, err
@@ -45,8 +47,8 @@ func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64,
4547 return 0, err
4648 }
4749 if _, err := tx.Exec(
48 "INSERT INTO issues (repo_id, number, author_id, title, body) VALUES (?, ?, ?, ?, ?)",
49 repoID, n, authorID, title, body); err != nil {
50 "INSERT INTO issues (repo_id, number, author_id, title, body, body_format) VALUES (?, ?, ?, ?, ?, ?)",
51 repoID, n, authorID, title, body, format); err != nil {
5052 return 0, err
5153 }
5254 return n, tx.Commit()
@@ -55,12 +57,12 @@ func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64,
5557 func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) {
5658 var i Issue
5759 err := s.DB.QueryRow(`
58 SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state,
60 SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.body_format, i.state,
5961 COALESCE(m.title, ''), i.created_at, i.updated_at
6062 FROM issues i JOIN users u ON u.id = i.author_id
6163 LEFT JOIN milestones m ON m.id = i.milestone_id
6264 WHERE i.repo_id = ? AND i.number = ?`, repoID, number).
63 Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.Milestone, &i.CreatedAt, &i.UpdatedAt)
65 Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.BodyFormat, &i.State, &i.Milestone, &i.CreatedAt, &i.UpdatedAt)
6466 if errors.Is(err, sql.ErrNoRows) {
6567 return i, ErrNotFound
6668 }
@@ -99,7 +101,7 @@ func (s *Store) issueStrings(issueID int64, query string) ([]string, error) {
99101 // "all". limit 0 means everything; before (an issue number) starts the
100102 // page strictly below it, matching the number-descending order.
101103 func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) {
102 q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state,
104 q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.body_format, i.state,
103105 COALESCE(m.title, ''), i.created_at, i.updated_at
104106 FROM issues i JOIN users u ON u.id = i.author_id
105107 LEFT JOIN milestones m ON m.id = i.milestone_id
@@ -126,7 +128,7 @@ func (s *Store) ListIssues(repoID int64, state string, limit int, before int64)
126128 var out []Issue
127129 for rows.Next() {
128130 var i Issue
129 if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.Milestone, &i.CreatedAt, &i.UpdatedAt); err != nil {
131 if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.BodyFormat, &i.State, &i.Milestone, &i.CreatedAt, &i.UpdatedAt); err != nil {
130132 return nil, err
131133 }
132134 out = append(out, i)
@@ -134,8 +136,9 @@ func (s *Store) ListIssues(repoID int64, state string, limit int, before int64)
134136 return out, rows.Err()
135137 }
136138
137// UpdateIssueText edits title and/or body; nil leaves a field unchanged.
138func (s *Store) UpdateIssueText(issueID int64, title, body *string) error {
139// UpdateIssueText edits title, body, and/or markup format; nil leaves a field
140// unchanged.
141func (s *Store) UpdateIssueText(issueID int64, title, body, format *string) error {
139142 set, args := []string{}, []any{}
140143 if title != nil {
141144 set, args = append(set, "title = ?"), append(args, *title)
@@ -143,6 +146,9 @@ func (s *Store) UpdateIssueText(issueID int64, title, body *string) error {
143146 if body != nil {
144147 set, args = append(set, "body = ?"), append(args, *body)
145148 }
149 if format != nil {
150 set, args = append(set, "body_format = ?"), append(args, *format)
151 }
146152 if len(set) == 0 {
147153 return nil
148154 }
@@ -171,15 +177,15 @@ func (s *Store) SetIssueState(issueID int64, state string) error {
171177 return nil
172178 }
173179
174func (s *Store) AddIssueComment(issueID, authorID int64, body string) error {
180func (s *Store) AddIssueComment(issueID, authorID int64, body, format string) error {
175181 tx, err := s.DB.Begin()
176182 if err != nil {
177183 return err
178184 }
179185 defer tx.Rollback()
180186 if _, err := tx.Exec(
181 "INSERT INTO issue_comments (issue_id, author_id, body) VALUES (?, ?, ?)",
182 issueID, authorID, body); err != nil {
187 "INSERT INTO issue_comments (issue_id, author_id, body, body_format) VALUES (?, ?, ?, ?)",
188 issueID, authorID, body, format); err != nil {
183189 return err
184190 }
185191 if _, err := tx.Exec(
@@ -192,7 +198,7 @@ func (s *Store) AddIssueComment(issueID, authorID int64, body string) error {
192198 func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
193199 rows, err := s.DB.Query(`
194200 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
195 c.body, c.created_at, c.kind
201 c.body, c.body_format, c.created_at, c.kind
196202 FROM issue_comments c JOIN users u ON u.id = c.author_id
197203 WHERE c.issue_id = ? ORDER BY c.id`, issueID)
198204 if err != nil {
@@ -202,7 +208,7 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
202208 var out []IssueComment
203209 for rows.Next() {
204210 var c IssueComment
205 if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt, &c.Kind); err != nil {
211 if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
206212 return nil, err
207213 }
208214 out = append(out, c)
internal/store/migrations/0027_body_format.down.sql added +5
@@ -0,0 +1,5 @@
1ALTER TABLE issues DROP COLUMN body_format;
2ALTER TABLE issue_comments DROP COLUMN body_format;
3ALTER TABLE merge_requests DROP COLUMN body_format;
4ALTER TABLE mr_comments DROP COLUMN body_format;
5ALTER TABLE releases DROP COLUMN notes_format;
internal/store/migrations/0027_body_format.up.sql added +5
@@ -0,0 +1,5 @@
1ALTER TABLE issues ADD COLUMN body_format TEXT NOT NULL DEFAULT 'md';
2ALTER TABLE issue_comments ADD COLUMN body_format TEXT NOT NULL DEFAULT 'md';
3ALTER TABLE merge_requests ADD COLUMN body_format TEXT NOT NULL DEFAULT 'md';
4ALTER TABLE mr_comments ADD COLUMN body_format TEXT NOT NULL DEFAULT 'md';
5ALTER TABLE releases ADD COLUMN notes_format TEXT NOT NULL DEFAULT 'md';
internal/store/mrs.go +18 −12
@@ -17,6 +17,7 @@ type MR struct {
1717 TargetRef string
1818 Title string
1919 Body string
20 BodyFormat string // md | org
2021 State string // open | merged | closed | source_gone
2122 Milestone string
2223 HeadSHA string
@@ -33,7 +34,7 @@ type MRReview struct {
3334 CreatedAt string
3435 }
3536
36func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA string) (int64, error) {
37func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA, format string) (int64, error) {
3738 tx, err := s.DB.Begin()
3839 if err != nil {
3940 return 0, err
@@ -47,9 +48,9 @@ func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, target
4748 return 0, err
4849 }
4950 if _, err := tx.Exec(`
50 INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha)
51 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
52 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA); err != nil {
51 INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format)
52 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
53 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format); err != nil {
5354 return 0, err
5455 }
5556 return n, tx.Commit()
@@ -59,7 +60,7 @@ const mrSelect = `
5960 SELECT m.id, m.repo_id, m.number, u.username,
6061 COALESCE(m.source_repo_id, 0),
6162 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
62 m.source_ref, m.target_ref, m.title, m.body, m.state,
63 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state,
6364 COALESCE(ms.title, ''), m.head_sha,
6465 m.merged_base, m.created_at, m.updated_at
6566 FROM merge_requests m
@@ -72,7 +73,7 @@ const mrSelect = `
7273 func scanMR(row interface{ Scan(...any) error }) (MR, error) {
7374 var m MR
7475 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
75 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase, &m.CreatedAt, &m.UpdatedAt)
76 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase, &m.CreatedAt, &m.UpdatedAt)
7677 return m, err
7778 }
7879
@@ -191,14 +192,16 @@ func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
191192 return err
192193 }
193194
194func (s *Store) AddMRComment(mrID, authorID int64, body string) error {
195func (s *Store) AddMRComment(mrID, authorID int64, body, format string) error {
195196 _, err := s.DB.Exec(
196 "INSERT INTO mr_comments (mr_id, author_id, body) VALUES (?, ?, ?)", mrID, authorID, body)
197 "INSERT INTO mr_comments (mr_id, author_id, body, body_format) VALUES (?, ?, ?, ?)",
198 mrID, authorID, body, format)
197199 return err
198200 }
199201
200// UpdateMRText edits title and/or body; nil leaves a field unchanged.
201func (s *Store) UpdateMRText(mrID int64, title, body *string) error {
202// UpdateMRText edits title, body, and/or markup format; nil leaves a field
203// unchanged.
204func (s *Store) UpdateMRText(mrID int64, title, body, format *string) error {
202205 set, args := []string{}, []any{}
203206 if title != nil {
204207 set, args = append(set, "title = ?"), append(args, *title)
@@ -206,6 +209,9 @@ func (s *Store) UpdateMRText(mrID int64, title, body *string) error {
206209 if body != nil {
207210 set, args = append(set, "body = ?"), append(args, *body)
208211 }
212 if format != nil {
213 set, args = append(set, "body_format = ?"), append(args, *format)
214 }
209215 if len(set) == 0 {
210216 return nil
211217 }
@@ -232,7 +238,7 @@ func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
232238 func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
233239 rows, err := s.DB.Query(`
234240 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
235 c.body, c.created_at, c.kind
241 c.body, c.body_format, c.created_at, c.kind
236242 FROM mr_comments c JOIN users u ON u.id = c.author_id
237243 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
238244 if err != nil {
@@ -242,7 +248,7 @@ func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
242248 var out []IssueComment
243249 for rows.Next() {
244250 var c IssueComment
245 if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt, &c.Kind); err != nil {
251 if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
246252 return nil, err
247253 }
248254 out = append(out, c)
internal/store/releases.go +19 −18
@@ -7,14 +7,15 @@ import (
77 )
88
99 type Release struct {
10 ID int64
11 RepoID int64
12 Tag string
13 Title string
14 Notes string
15 Author string
16 CreatedAt string
17 Assets []ReleaseAsset
10 ID int64
11 RepoID int64
12 Tag string
13 Title string
14 Notes string
15 NotesFormat string // md | org
16 Author string
17 CreatedAt string
18 Assets []ReleaseAsset
1819 }
1920
2021 type ReleaseAsset struct {
@@ -25,10 +26,10 @@ type ReleaseAsset struct {
2526 UploadedAt string
2627 }
2728
28func (s *Store) CreateRelease(repoID int64, tag, title, notes string, authorID int64) (int64, error) {
29func (s *Store) CreateRelease(repoID int64, tag, title, notes string, authorID int64, format string) (int64, error) {
2930 res, err := s.DB.Exec(
30 "INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)",
31 repoID, tag, title, notes, authorID)
31 "INSERT INTO releases (repo_id, tag, title, notes, author_id, notes_format) VALUES (?, ?, ?, ?, ?, ?)",
32 repoID, tag, title, notes, authorID, format)
3233 if err != nil {
3334 if isUniqueErr(err) {
3435 return 0, fmt.Errorf("a release for tag %q already exists", tag)
@@ -39,7 +40,7 @@ func (s *Store) CreateRelease(repoID int64, tag, title, notes string, authorID i
3940 }
4041
4142 const releaseSelect = `
42 SELECT r.id, r.repo_id, r.tag, r.title, r.notes, COALESCE(u.username, ''), r.created_at
43 SELECT r.id, r.repo_id, r.tag, r.title, r.notes, r.notes_format, COALESCE(u.username, ''), r.created_at
4344 FROM releases r LEFT JOIN users u ON u.id = r.author_id`
4445
4546 func (s *Store) releaseAssets(rel *Release) error {
@@ -60,11 +61,11 @@ func (s *Store) releaseAssets(rel *Release) error {
6061 return rows.Err()
6162 }
6263
63// UpdateRelease replaces a release's title and notes.
64func (s *Store) UpdateRelease(repoID int64, tag, title, notes string) error {
64// UpdateRelease replaces a release's title, notes, and markup format.
65func (s *Store) UpdateRelease(repoID int64, tag, title, notes, format string) error {
6566 res, err := s.DB.Exec(
66 "UPDATE releases SET title = ?, notes = ? WHERE repo_id = ? AND tag = ?",
67 title, notes, repoID, tag)
67 "UPDATE releases SET title = ?, notes = ?, notes_format = ? WHERE repo_id = ? AND tag = ?",
68 title, notes, format, repoID, tag)
6869 if err != nil {
6970 return err
7071 }
@@ -77,7 +78,7 @@ func (s *Store) UpdateRelease(repoID int64, tag, title, notes string) error {
7778 func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) {
7879 var r Release
7980 err := s.DB.QueryRow(releaseSelect+" WHERE r.repo_id = ? AND r.tag = ?", repoID, tag).
80 Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.Author, &r.CreatedAt)
81 Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.NotesFormat, &r.Author, &r.CreatedAt)
8182 if errors.Is(err, sql.ErrNoRows) {
8283 return r, ErrNotFound
8384 }
@@ -97,7 +98,7 @@ func (s *Store) ListReleases(repoID int64) ([]Release, error) {
9798 var out []Release
9899 for rows.Next() {
99100 var r Release
100 if err := rows.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.Author, &r.CreatedAt); err != nil {
101 if err := rows.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.NotesFormat, &r.Author, &r.CreatedAt); err != nil {
101102 return nil, err
102103 }
103104 out = append(out, r)