bodies: choose markdown or org per body !113

merged merged by cmc on 2026-08-30 01:42 UTC · krz/gitbay:org-bodies into main

15 files changed, +472 −178

Layout: unified · split

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
1717func 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
7993type 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
91106func 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
100116func 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
248278func 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
378431func 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
210210func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
211211
212212func 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
297306type 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
310320func 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
514526func 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
541553func 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
2020func 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
7070func 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
133143type 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
142153func 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
153165func 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
11221143var 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
11241170func 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
1010type 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
2526type 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,
5557func (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.
101103func (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 {
192198func (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 = `
7273func 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 {
232238func (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
99type 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
2021type 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
4142const 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
4546func (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 {
7778func (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)