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) {
70 if code != 0 { 70 if code != 0 {
71 t.Fatal("issue show failed") 71 t.Fatal("issue show failed")
72 } 72 }
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.
73 wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` + 75 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"}],` +
75 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` + 78 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` +
76 `"title":"first bug"},"protocol_version":1}` 79 `"title":"first bug"},"protocol_version":1}`
77 if g := golden(t, out); g != wantShow { 80 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 {
184 return c.fail(protocol.ExitFailure, "%v", err) 184 return c.fail(protocol.ExitFailure, "%v", err)
185 } 185 }
186 body := attribution(src, it.Number, "pull request", it.User.Login, it.CreatedAt) + it.Body 186 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")
188 if err != nil { 188 if err != nil {
189 return c.fail(protocol.ExitFailure, "%v", err) 189 return c.fail(protocol.ExitFailure, "%v", err)
190 } 190 }
@@ -206,7 +206,7 @@ func runImportIssues(c *Ctx, args []string) int {
206 mrs++ 206 mrs++
207 } else { 207 } else {
208 body := attribution(src, it.Number, "issue", it.User.Login, it.CreatedAt) + it.Body 208 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")
210 if err != nil { 210 if err != nil {
211 return c.fail(protocol.ExitFailure, "%v", err) 211 return c.fail(protocol.ExitFailure, "%v", err)
212 } 212 }
@@ -276,9 +276,9 @@ func importComments(c *Ctx, g *ghClient, repo store.Repo, from, src string, ghN,
276 body := fmt.Sprintf("> @%s, %s\n\n%s", cm.User.Login, ghDate(cm.CreatedAt), cm.Body) 276 body := fmt.Sprintf("> @%s, %s\n\n%s", cm.User.Login, ghDate(cm.CreatedAt), cm.Body)
277 var err error 277 var err error
278 if isPR { 278 if isPR {
279 err = c.Store.AddMRComment(localMRID, c.User.ID, body) 279 err = c.Store.AddMRComment(localMRID, c.User.ID, body, "md")
280 } else { 280 } else {
281 err = c.Store.AddIssueComment(localIssueID, c.User.ID, body) 281 err = c.Store.AddIssueComment(localIssueID, c.User.ID, body, "md")
282 } 282 }
283 if err != nil { 283 if err != nil {
284 return imported, err 284 return imported, err
internal/control/issue.go +86 −33
@@ -16,17 +16,17 @@ const maxBodyBytes = 64 << 10
16 16
17func init() { 17func init() {
18 register(Command{Path: []string{"issue", "create"}, 18 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]",
20 ReadsStdin: true, Run: runIssueCreate}) 20 ReadsStdin: true, Run: runIssueCreate})
21 register(Command{Path: []string{"issue", "list"}, 21 register(Command{Path: []string{"issue", "list"},
22 Summary: "list issues: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runIssueList}) 22 Summary: "list issues: issue list <owner/name> [--state open|closed|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runIssueList})
23 register(Command{Path: []string{"issue", "show"}, 23 register(Command{Path: []string{"issue", "show"},
24 Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow}) 24 Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow})
25 register(Command{Path: []string{"issue", "edit"}, 25 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]",
27 ReadsStdin: true, Run: runIssueEdit}) 27 ReadsStdin: true, Run: runIssueEdit})
28 register(Command{Path: []string{"issue", "comment"}, 28 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]",
30 ReadsStdin: true, Run: runIssueComment}) 30 ReadsStdin: true, Run: runIssueComment})
31 register(Command{Path: []string{"issue", "close"}, 31 register(Command{Path: []string{"issue", "close"},
32 Summary: "close an issue: issue close <owner/name> <n>", Run: runIssueClose}) 32 Summary: "close an issue: issue close <owner/name> <n>", Run: runIssueClose})
@@ -76,16 +76,31 @@ func bodyFrom(c *Ctx, inline, file string) (string, error) {
76 return inline, nil 76 return inline, nil
77} 77}
78 78
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
79type issueOut struct { 93type issueOut struct {
80 Number int64 `json:"number"` 94 Number int64 `json:"number"`
81 Title string `json:"title"` 95 Title string `json:"title"`
82 State string `json:"state"` 96 State string `json:"state"`
83 Author string `json:"author"` 97 Author string `json:"author"`
84 Milestone string `json:"milestone,omitempty"` 98 Milestone string `json:"milestone,omitempty"`
85 Labels []string `json:"labels,omitempty"` 99 Labels []string `json:"labels,omitempty"`
86 Assignees []string `json:"assignees,omitempty"` 100 Assignees []string `json:"assignees,omitempty"`
87 Body string `json:"body,omitempty"` 101 Body string `json:"body,omitempty"`
88 CreatedAt string `json:"created_at"` 102 BodyFormat string `json:"body_format,omitempty"`
103 CreatedAt string `json:"created_at"`
89} 104}
90 105
91func issueToOut(i store.Issue, withBody bool) issueOut { 106func issueToOut(i store.Issue, withBody bool) issueOut {
@@ -93,14 +108,21 @@ func issueToOut(i store.Issue, withBody bool) issueOut {
93 Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt} 108 Milestone: i.Milestone, Labels: i.Labels, Assignees: i.Assignees, CreatedAt: i.CreatedAt}
94 if withBody { 109 if withBody {
95 o.Body = i.Body 110 o.Body = i.Body
111 o.BodyFormat = i.BodyFormat
96 } 112 }
97 return o 113 return o
98} 114}
99 115
100func runIssueCreate(c *Ctx, args []string) int { 116func runIssueCreate(c *Ctx, args []string) int {
101 var path, title, body, file string 117 var path, title, body, file, format string
102 for i := 0; i < len(args); i++ { 118 for i := 0; i < len(args); i++ {
103 switch args[i] { 119 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++
104 case "--title": 126 case "--title":
105 if i+1 >= len(args) { 127 if i+1 >= len(args) {
106 return c.fail(protocol.ExitUsage, "--title requires a value") 128 return c.fail(protocol.ExitUsage, "--title requires a value")
@@ -127,7 +149,14 @@ func runIssueCreate(c *Ctx, args []string) int {
127 } 149 }
128 } 150 }
129 if path == "" || title == "" { 151 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"
131 } 160 }
132 // Anyone who can read the repo can file an issue. 161 // Anyone who can read the repo can file an issue.
133 repo, code := resolveRepo(c, path, policy.CanRead) 162 repo, code := resolveRepo(c, path, policy.CanRead)
@@ -141,7 +170,7 @@ func runIssueCreate(c *Ctx, args []string) int {
141 if err != nil { 170 if err != nil {
142 return c.fail(protocol.ExitUsage, "%v", err) 171 return c.fail(protocol.ExitUsage, "%v", err)
143 } 172 }
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)
145 if err != nil { 174 if err != nil {
146 return c.fail(protocol.ExitFailure, "%v", err) 175 return c.fail(protocol.ExitFailure, "%v", err)
147 } 176 }
@@ -215,13 +244,14 @@ func runIssueShow(c *Ctx, args []string) int {
215 return c.fail(protocol.ExitFailure, "%v", err) 244 return c.fail(protocol.ExitFailure, "%v", err)
216 } 245 }
217 type commentOut struct { 246 type commentOut struct {
218 Author string `json:"author"` 247 Author string `json:"author"`
219 Body string `json:"body"` 248 Body string `json:"body"`
220 CreatedAt string `json:"created_at"` 249 BodyFormat string `json:"body_format,omitempty"`
250 CreatedAt string `json:"created_at"`
221 } 251 }
222 var cs []commentOut 252 var cs []commentOut
223 for _, cm := range comments { 253 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})
225 } 255 }
226 d := struct { 256 d := struct {
227 issueOut 257 issueOut
@@ -247,9 +277,15 @@ func runIssueShow(c *Ctx, args []string) int {
247 277
248func runIssueComment(c *Ctx, args []string) int { 278func runIssueComment(c *Ctx, args []string) int {
249 var rest []string 279 var rest []string
250 var message, file string 280 var message, file, format string
251 for i := 0; i < len(args); i++ { 281 for i := 0; i < len(args); i++ {
252 switch args[i] { 282 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++
253 case "--message": 289 case "--message":
254 if i+1 >= len(args) { 290 if i+1 >= len(args) {
255 return c.fail(protocol.ExitUsage, "--message requires a value") 291 return c.fail(protocol.ExitUsage, "--message requires a value")
@@ -266,6 +302,13 @@ func runIssueComment(c *Ctx, args []string) int {
266 rest = append(rest, args[i]) 302 rest = append(rest, args[i])
267 } 303 }
268 } 304 }
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 }
269 repo, issue, code := issueRef(c, rest, policy.CanRead) 312 repo, issue, code := issueRef(c, rest, policy.CanRead)
270 if code >= 0 { 313 if code >= 0 {
271 return code 314 return code
@@ -280,7 +323,7 @@ func runIssueComment(c *Ctx, args []string) int {
280 if strings.TrimSpace(body) == "" { 323 if strings.TrimSpace(body) == "" {
281 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") 324 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
282 } 325 }
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 {
284 return c.fail(protocol.ExitFailure, "%v", err) 327 return c.fail(protocol.ExitFailure, "%v", err)
285 } 328 }
286 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number)) 329 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 {
330 }) 373 })
331} 374}
332 375
333// editText parses --title/--body/--file - and authorizes: author or write. 376// editText parses --title/--body/--file -/--format and authorizes: author or
334func editText(c *Ctx, args []string, kind string) (rest []string, title, body *string, code int) { 377// write. A nil format means the stored markup format stays as it is.
335 var titleV, bodyV, file string 378func editText(c *Ctx, args []string, kind string) (rest []string, title, body, format *string, code int) {
379 var titleV, bodyV, file, formatV string
336 haveTitle, haveBody := false, false 380 haveTitle, haveBody := false, false
337 for i := 0; i < len(args); i++ { 381 for i := 0; i < len(args); i++ {
338 switch args[i] { 382 switch args[i] {
339 case "--title", "--body", "--file": 383 case "--title", "--body", "--file", "--format":
340 if i+1 >= len(args) { 384 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])
342 } 386 }
343 switch args[i] { 387 switch args[i] {
344 case "--title": 388 case "--title":
@@ -347,6 +391,8 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body *s
347 bodyV, haveBody = args[i+1], true 391 bodyV, haveBody = args[i+1], true
348 case "--file": 392 case "--file":
349 file = args[i+1] 393 file = args[i+1]
394 case "--format":
395 formatV = args[i+1]
350 } 396 }
351 i++ 397 i++
352 default: 398 default:
@@ -356,27 +402,34 @@ func editText(c *Ctx, args []string, kind string) (rest []string, title, body *s
356 if file != "" { 402 if file != "" {
357 b, err := bodyFrom(c, "", file) 403 b, err := bodyFrom(c, "", file)
358 if err != nil { 404 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)
360 } 406 }
361 bodyV, haveBody = b, true 407 bodyV, haveBody = b, true
362 } 408 }
363 if !haveTitle && !haveBody { 409 fmtName, err := markupFormat(formatV)
364 return nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -]", kind) 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)
365 } 415 }
366 if haveTitle { 416 if haveTitle {
367 if strings.TrimSpace(titleV) == "" { 417 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")
369 } 419 }
370 title = &titleV 420 title = &titleV
371 } 421 }
372 if haveBody { 422 if haveBody {
373 body = &bodyV 423 body = &bodyV
374 } 424 }
375 return rest, title, body, -1 425 if fmtName != "" {
426 format = &fmtName
427 }
428 return rest, title, body, format, -1
376} 429}
377 430
378func runIssueEdit(c *Ctx, args []string) int { 431func 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")
380 if code >= 0 { 433 if code >= 0 {
381 return code 434 return code
382 } 435 }
@@ -394,7 +447,7 @@ func runIssueEdit(c *Ctx, args []string) int {
394 if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) { 447 if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
395 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue") 448 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue")
396 } 449 }
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 {
398 return c.fail(protocol.ExitFailure, "%v", err) 451 return c.fail(protocol.ExitFailure, "%v", err)
399 } 452 }
400 return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) { 453 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 {
227 continue 227 continue
228 } 228 }
229 body := migAttribution(src, "issue", bi.Author, bi.CreatedAt, bi.Number) + bi.Body 229 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")
231 if err != nil { 231 if err != nil {
232 return c.fail(protocol.ExitFailure, "%v", err) 232 return c.fail(protocol.ExitFailure, "%v", err)
233 } 233 }
@@ -243,7 +243,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
243 } 243 }
244 for _, cm := range bi.Comments { 244 for _, cm := range bi.Comments {
245 c.Store.AddIssueComment(iss.ID, c.User.ID, 245 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")
247 comments++ 247 comments++
248 } 248 }
249 c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n)) 249 c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n))
@@ -256,7 +256,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
256 continue 256 continue
257 } 257 }
258 body := migAttribution(src, "merge request", bm.Author, bm.CreatedAt, bm.Number) + bm.Body 258 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")
260 if err != nil { 260 if err != nil {
261 return c.fail(protocol.ExitFailure, "%v", err) 261 return c.fail(protocol.ExitFailure, "%v", err)
262 } 262 }
@@ -273,7 +273,7 @@ func runAccountImportBundle(c *Ctx, args []string) int {
273 } 273 }
274 for _, cm := range bm.Comments { 274 for _, cm := range bm.Comments {
275 c.Store.AddMRComment(mr.ID, c.User.ID, 275 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")
277 comments++ 277 comments++
278 } 278 }
279 c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n)) 279 c.Store.SetImportMarker(repo.ID, key, fmt.Sprint(n))
internal/control/mr.go +49 −27
@@ -27,7 +27,7 @@ func init() {
27 register(Command{Path: []string{"repo", "settings", "require-signed"}, 27 register(Command{Path: []string{"repo", "settings", "require-signed"},
28 Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned}) 28 Summary: "require verified commit signatures: repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
29 register(Command{Path: []string{"mr", "create"}, 29 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]",
31 ReadsStdin: true, Run: runMRCreate}) 31 ReadsStdin: true, Run: runMRCreate})
32 register(Command{Path: []string{"mr", "list"}, 32 register(Command{Path: []string{"mr", "list"},
33 Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList}) 33 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() {
36 register(Command{Path: []string{"mr", "diff"}, 36 register(Command{Path: []string{"mr", "diff"},
37 Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) 37 Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
38 register(Command{Path: []string{"mr", "edit"}, 38 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]",
40 ReadsStdin: true, Run: runMREdit}) 40 ReadsStdin: true, Run: runMREdit})
41 register(Command{Path: []string{"mr", "comment"}, 41 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]",
43 ReadsStdin: true, Run: runMRComment}) 43 ReadsStdin: true, Run: runMRComment})
44 register(Command{Path: []string{"mr", "review"}, 44 register(Command{Path: []string{"mr", "review"},
45 Summary: "review: mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview}) 45 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
210func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } 210func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
211 211
212func runMRCreate(c *Ctx, args []string) int { 212func 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
214 for i := 0; i < len(args); i++ { 214 for i := 0; i < len(args); i++ {
215 switch args[i] { 215 switch args[i] {
216 case "--source", "--target", "--title", "--body", "--file": 216 case "--source", "--target", "--title", "--body", "--file", "--format":
217 if i+1 >= len(args) { 217 if i+1 >= len(args) {
218 return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) 218 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
219 } 219 }
@@ -229,6 +229,8 @@ func runMRCreate(c *Ctx, args []string) int {
229 body = v 229 body = v
230 case "--file": 230 case "--file":
231 file = v 231 file = v
232 case "--format":
233 format = v
232 } 234 }
233 i++ 235 i++
234 default: 236 default:
@@ -241,6 +243,13 @@ func runMRCreate(c *Ctx, args []string) int {
241 if path == "" || source == "" || title == "" { 243 if path == "" || source == "" || title == "" {
242 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>") 244 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
243 } 245 }
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 }
244 repo, code := resolveRepo(c, path, policy.CanRead) 253 repo, code := resolveRepo(c, path, policy.CanRead)
245 if code >= 0 { 254 if code >= 0 {
246 return code 255 return code
@@ -275,7 +284,7 @@ func runMRCreate(c *Ctx, args []string) int {
275 if err != nil { 284 if err != nil {
276 return c.fail(protocol.ExitUsage, "%v", err) 285 return c.fail(protocol.ExitUsage, "%v", err)
277 } 286 }
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)
279 if err != nil { 288 if err != nil {
280 return c.fail(protocol.ExitFailure, "%v", err) 289 return c.fail(protocol.ExitFailure, "%v", err)
281 } 290 }
@@ -295,16 +304,17 @@ func runMRCreate(c *Ctx, args []string) int {
295} 304}
296 305
297type mrOut struct { 306type mrOut struct {
298 Number int64 `json:"number"` 307 Number int64 `json:"number"`
299 Title string `json:"title"` 308 Title string `json:"title"`
300 State string `json:"state"` 309 State string `json:"state"`
301 Author string `json:"author"` 310 Author string `json:"author"`
302 Source string `json:"source"` // owner/name:branch, or branch, "" if gone 311 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
303 TargetRef string `json:"target_ref"` 312 TargetRef string `json:"target_ref"`
304 HeadSHA string `json:"head_sha"` 313 HeadSHA string `json:"head_sha"`
305 Body string `json:"body,omitempty"` 314 Body string `json:"body,omitempty"`
306 Milestone string `json:"milestone,omitempty"` 315 BodyFormat string `json:"body_format,omitempty"`
307 CreatedAt string `json:"created_at"` 316 Milestone string `json:"milestone,omitempty"`
317 CreatedAt string `json:"created_at"`
308} 318}
309 319
310func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { 320func 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 {
321 CreatedAt: m.CreatedAt} 331 CreatedAt: m.CreatedAt}
322 if withBody { 332 if withBody {
323 o.Body = m.Body 333 o.Body = m.Body
334 o.BodyFormat = m.BodyFormat
324 } 335 }
325 return o 336 return o
326} 337}
@@ -398,9 +409,10 @@ func runMRShow(c *Ctx, args []string) int {
398 return c.fail(protocol.ExitFailure, "%v", err) 409 return c.fail(protocol.ExitFailure, "%v", err)
399 } 410 }
400 type commentOut struct { 411 type commentOut struct {
401 Author string `json:"author"` 412 Author string `json:"author"`
402 Body string `json:"body"` 413 Body string `json:"body"`
403 CreatedAt string `json:"created_at"` 414 BodyFormat string `json:"body_format,omitempty"`
415 CreatedAt string `json:"created_at"`
404 } 416 }
405 type reviewOut struct { 417 type reviewOut struct {
406 Reviewer string `json:"reviewer"` 418 Reviewer string `json:"reviewer"`
@@ -418,7 +430,7 @@ func runMRShow(c *Ctx, args []string) int {
418 } 430 }
419 var cs []commentOut 431 var cs []commentOut
420 for _, cm := range comments { 432 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})
422 } 434 }
423 var rs []reviewOut 435 var rs []reviewOut
424 for _, r := range reviews { 436 for _, r := range reviews {
@@ -512,7 +524,7 @@ func runMRDiff(c *Ctx, args []string) int {
512} 524}
513 525
514func runMREdit(c *Ctx, args []string) int { 526func 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")
516 if code >= 0 { 528 if code >= 0 {
517 return code 529 return code
518 } 530 }
@@ -530,7 +542,7 @@ func runMREdit(c *Ctx, args []string) int {
530 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) { 542 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
531 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request") 543 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request")
532 } 544 }
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 {
534 return c.fail(protocol.ExitFailure, "%v", err) 546 return c.fail(protocol.ExitFailure, "%v", err)
535 } 547 }
536 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { 548 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
@@ -540,23 +552,33 @@ func runMREdit(c *Ctx, args []string) int {
540 552
541func runMRComment(c *Ctx, args []string) int { 553func runMRComment(c *Ctx, args []string) int {
542 var rest []string 554 var rest []string
543 var message, file string 555 var message, file, format string
544 for i := 0; i < len(args); i++ { 556 for i := 0; i < len(args); i++ {
545 switch args[i] { 557 switch args[i] {
546 case "--message", "--file": 558 case "--message", "--file", "--format":
547 if i+1 >= len(args) { 559 if i+1 >= len(args) {
548 return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) 560 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
549 } 561 }
550 if args[i] == "--message" { 562 switch args[i] {
563 case "--message":
551 message = args[i+1] 564 message = args[i+1]
552 } else { 565 case "--file":
553 file = args[i+1] 566 file = args[i+1]
567 case "--format":
568 format = args[i+1]
554 } 569 }
555 i++ 570 i++
556 default: 571 default:
557 rest = append(rest, args[i]) 572 rest = append(rest, args[i])
558 } 573 }
559 } 574 }
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 }
560 repo, mr, code := mrRef(c, rest, policy.CanRead) 582 repo, mr, code := mrRef(c, rest, policy.CanRead)
561 if code >= 0 { 583 if code >= 0 {
562 return code 584 return code
@@ -571,7 +593,7 @@ func runMRComment(c *Ctx, args []string) int {
571 if strings.TrimSpace(body) == "" { 593 if strings.TrimSpace(body) == "" {
572 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -") 594 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
573 } 595 }
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 {
575 return c.fail(protocol.ExitFailure, "%v", err) 597 return c.fail(protocol.ExitFailure, "%v", err)
576 } 598 }
577 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number)) 599 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 (
19 19
20func init() { 20func init() {
21 register(Command{Path: []string{"release", "create"}, 21 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]",
23 ReadsStdin: true, Run: runReleaseCreate}) 23 ReadsStdin: true, Run: runReleaseCreate})
24 register(Command{Path: []string{"release", "edit"}, 24 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]",
26 ReadsStdin: true, Run: runReleaseEdit}) 26 ReadsStdin: true, Run: runReleaseEdit})
27 register(Command{Path: []string{"release", "list"}, 27 register(Command{Path: []string{"release", "list"},
28 Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList}) 28 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)
68} 68}
69 69
70func runReleaseCreate(c *Ctx, args []string) int { 70func 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
72 for i := 0; i < len(args); i++ { 73 for i := 0; i < len(args); i++ {
73 switch args[i] { 74 switch args[i] {
74 case "--title", "--notes", "--file": 75 case "--title", "--notes", "--file", "--format":
75 if i+1 >= len(args) { 76 if i+1 >= len(args) {
76 return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) 77 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
77 } 78 }
@@ -82,6 +83,8 @@ func runReleaseCreate(c *Ctx, args []string) int {
82 notes = args[i+1] 83 notes = args[i+1]
83 case "--file": 84 case "--file":
84 file = args[i+1] 85 file = args[i+1]
86 case "--format":
87 format = args[i+1]
85 } 88 }
86 i++ 89 i++
87 default: 90 default:
@@ -90,12 +93,19 @@ func runReleaseCreate(c *Ctx, args []string) int {
90 } else if tag == "" { 93 } else if tag == "" {
91 tag = args[i] 94 tag = args[i]
92 } else { 95 } 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)
94 } 97 }
95 } 98 }
96 } 99 }
97 if path == "" || tag == "" { 100 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"
99 } 109 }
100 repo, code := resolveRepo(c, path, policy.CanWrite) 110 repo, code := resolveRepo(c, path, policy.CanWrite)
101 if code >= 0 { 111 if code >= 0 {
@@ -115,7 +125,7 @@ func runReleaseCreate(c *Ctx, args []string) int {
115 if title == "" { 125 if title == "" {
116 title = tag 126 title = tag
117 } 127 }
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 {
119 return c.fail(protocol.ExitUsage, "%v", err) 129 return c.fail(protocol.ExitUsage, "%v", err)
120 } 130 }
121 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag)) 131 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
@@ -131,18 +141,20 @@ type assetOut struct {
131} 141}
132 142
133type releaseOut struct { 143type releaseOut struct {
134 Tag string `json:"tag"` 144 Tag string `json:"tag"`
135 Title string `json:"title"` 145 Title string `json:"title"`
136 Notes string `json:"notes,omitempty"` 146 Notes string `json:"notes,omitempty"`
137 Author string `json:"author,omitempty"` 147 NotesFormat string `json:"notes_format,omitempty"`
138 CreatedAt string `json:"created_at"` 148 Author string `json:"author,omitempty"`
139 Assets []assetOut `json:"assets,omitempty"` 149 CreatedAt string `json:"created_at"`
150 Assets []assetOut `json:"assets,omitempty"`
140} 151}
141 152
142func releaseToOut(r store.Release, withNotes bool) releaseOut { 153func releaseToOut(r store.Release, withNotes bool) releaseOut {
143 o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt} 154 o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt}
144 if withNotes { 155 if withNotes {
145 o.Notes = r.Notes 156 o.Notes = r.Notes
157 o.NotesFormat = r.NotesFormat
146 } 158 }
147 for _, a := range r.Assets { 159 for _, a := range r.Assets {
148 o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256}) 160 o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256})
@@ -151,12 +163,12 @@ func releaseToOut(r store.Release, withNotes bool) releaseOut {
151} 163}
152 164
153func runReleaseEdit(c *Ctx, args []string) int { 165func runReleaseEdit(c *Ctx, args []string) int {
154 const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]" 166 const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]"
155 var path, tag, title, notes, file string 167 var path, tag, title, notes, file, format string
156 var setTitle, setNotes bool 168 var setTitle, setNotes bool
157 for i := 0; i < len(args); i++ { 169 for i := 0; i < len(args); i++ {
158 switch args[i] { 170 switch args[i] {
159 case "--title", "--notes", "--file": 171 case "--title", "--notes", "--file", "--format":
160 if i+1 >= len(args) { 172 if i+1 >= len(args) {
161 return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) 173 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
162 } 174 }
@@ -167,6 +179,8 @@ func runReleaseEdit(c *Ctx, args []string) int {
167 notes, setNotes = args[i+1], true 179 notes, setNotes = args[i+1], true
168 case "--file": 180 case "--file":
169 file, setNotes = args[i+1], true 181 file, setNotes = args[i+1], true
182 case "--format":
183 format = args[i+1]
170 } 184 }
171 i++ 185 i++
172 default: 186 default:
@@ -179,7 +193,11 @@ func runReleaseEdit(c *Ctx, args []string) int {
179 } 193 }
180 } 194 }
181 } 195 }
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 == "") {
183 return c.fail(protocol.ExitUsage, usage) 201 return c.fail(protocol.ExitUsage, usage)
184 } 202 }
185 repo, code := resolveRepo(c, path, policy.CanWrite) 203 repo, code := resolveRepo(c, path, policy.CanWrite)
@@ -205,7 +223,10 @@ func runReleaseEdit(c *Ctx, args []string) int {
205 return c.fail(protocol.ExitUsage, "%v", err) 223 return c.fail(protocol.ExitUsage, "%v", err)
206 } 224 }
207 } 225 }
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 {
209 return c.fail(protocol.ExitFailure, "%v", err) 230 return c.fail(protocol.ExitFailure, "%v", err)
210 } 231 }
211 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) { 232 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
295 http.Error(w, "title required", http.StatusBadRequest) 295 http.Error(w, "title required", http.StatusBadRequest)
296 return 296 return
297 } 297 }
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")
299 if err != nil { 299 if err != nil {
300 http.Error(w, "internal error", http.StatusInternalServerError) 300 http.Error(w, "internal error", http.StatusInternalServerError)
301 return 301 return
@@ -340,7 +340,7 @@ func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store
340 return 340 return
341 } 341 }
342 body := r.FormValue("body") 342 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 {
344 http.Error(w, "internal error", http.StatusInternalServerError) 344 http.Error(w, "internal error", http.StatusInternalServerError)
345 return 345 return
346 } 346 }
@@ -381,7 +381,7 @@ func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.Us
381 return 381 return
382 } 382 }
383 body := r.FormValue("body") 383 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 {
385 http.Error(w, "internal error", http.StatusInternalServerError) 385 http.Error(w, "internal error", http.StatusInternalServerError)
386 return 386 return
387 } 387 }
@@ -404,7 +404,7 @@ func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u st
404 http.Error(w, "empty comment", http.StatusBadRequest) 404 http.Error(w, "empty comment", http.StatusBadRequest)
405 return 405 return
406 } 406 }
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 {
408 http.Error(w, "internal error", http.StatusInternalServerError) 408 http.Error(w, "internal error", http.StatusInternalServerError)
409 return 409 return
410 } 410 }
@@ -428,7 +428,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store
428 http.Error(w, "empty comment", http.StatusBadRequest) 428 http.Error(w, "empty comment", http.StatusBadRequest)
429 return 429 return
430 } 430 }
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 {
432 http.Error(w, "internal error", http.StatusInternalServerError) 432 http.Error(w, "internal error", http.StatusInternalServerError)
433 return 433 return
434 } 434 }
internal/httpd/orgrender_test.go +53
@@ -112,3 +112,56 @@ func TestOrgRenderingIsUnaffectedByTheIncludeGuard(t *testing.T) {
112 } 112 }
113 } 113 }
114} 114}
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) {
571 } 571 }
572 var views []relView 572 var views []relView
573 for _, rel := range rels { 573 for _, rel := range rels {
574 views = append(views, relView{rel, md(rel.Notes)}) 574 views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
575 } 575 }
576 // Tags without a release yet are what a create form can offer. 576 // Tags without a release yet are what a create form can offer.
577 released := map[string]bool{} 577 released := map[string]bool{}
@@ -1049,16 +1049,37 @@ func (r webResolver) UserURL(name string) string {
1049 return "" 1049 return ""
1050} 1050}
1051 1051
1052// ugcFor returns a renderer for user-authored markdown on one repo's pages: 1052// ugcRenderer renders one user-authored body in the format it was written in.
1053// mdHTML plus cross-reference and mention autolinking for this viewer. 1053// The format travels with the body: it is recorded when the text is written, so
1054func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML { 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 {
1055 viewer := store.User{} 1076 viewer := store.User{}
1056 if s.cfg.Web.Mode == "accounts" { 1077 if s.cfg.Web.Mode == "accounts" {
1057 viewer = s.viewer(r) 1078 viewer = s.viewer(r)
1058 } 1079 }
1059 res := webResolver{s, viewer} 1080 res := webResolver{s, viewer}
1060 return func(raw string) template.HTML { 1081 return func(raw, format string) template.HTML {
1061 h := mdHTML(raw) 1082 h := ugcHTML(raw, format)
1062 if h == "" { 1083 if h == "" {
1063 return h 1084 return h
1064 } 1085 }
@@ -1074,10 +1095,10 @@ type renderedComment struct {
1074 BodyHTML template.HTML 1095 BodyHTML template.HTML
1075} 1096}
1076 1097
1077func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment { 1098func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1078 var out []renderedComment 1099 var out []renderedComment
1079 for _, c := range cs { 1100 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)})
1081 } 1102 }
1082 return out 1103 return out
1083} 1104}
@@ -1121,6 +1142,31 @@ func orgConfig() *org.Configuration {
1121 1142
1122var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled") 1143var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1123 1144
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
1124func renderReadme(name string, raw []byte) template.HTML { 1170func renderReadme(name string, raw []byte) template.HTML {
1125 plain := func() template.HTML { 1171 plain := func() template.HTML {
1126 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>") 1172 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
@@ -1136,19 +1182,7 @@ func renderReadme(name string, raw []byte) template.HTML {
1136 } 1182 }
1137 return template.HTML(buf.String()) 1183 return template.HTML(buf.String())
1138 case ".org": 1184 case ".org":
1139 doc := orgConfig().Parse(bytes.NewReader(raw), name) 1185 return renderOrg(name, raw, true, plain)
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))
1152 case ".html", ".htm": 1186 case ".html", ".htm":
1153 return template.HTML(ugcPolicy.Sanitize(string(raw))) 1187 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1154 default: 1188 default:
@@ -1166,23 +1200,25 @@ type diffThread struct {
1166// attachThreads injects review threads under their anchored diff lines; 1200// attachThreads injects review threads under their anchored diff lines;
1167// threads whose anchor no longer appears (stale after force-push, or on a 1201// threads whose anchor no longer appears (stale after force-push, or on a
1168// context line outside the current diff) are returned separately. 1202// 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) {
1170 type anchor struct { 1204 type anchor struct {
1171 path string 1205 path string
1172 side string 1206 side string
1173 line int64 1207 line int64
1174 } 1208 }
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.
1175 threads := map[int64]*diffThread{} 1211 threads := map[int64]*diffThread{}
1176 anchors := map[int64]anchor{} 1212 anchors := map[int64]anchor{}
1177 var order []int64 1213 var order []int64
1178 for _, cm := range comments { 1214 for _, cm := range comments {
1179 if cm.ReplyTo == 0 { 1215 if cm.ReplyTo == 0 {
1180 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA, 1216 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")}}}
1182 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line} 1218 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1183 order = append(order, cm.ID) 1219 order = append(order, cm.ID)
1184 } else if th, ok := threads[cm.ReplyTo]; ok { 1220 } 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")})
1186 } 1222 }
1187 } 1223 }
1188 placed := map[int64]bool{} 1224 placed := map[int64]bool{}
@@ -1445,7 +1481,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1445 Milestones []store.Milestone 1481 Milestones []store.Milestone
1446 Notice string 1482 Notice string
1447 LabelColors map[string]template.CSS 1483 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),
1449 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo), 1485 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1450 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)}) 1486 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1451} 1487}
@@ -1594,7 +1630,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1594 Unresolved int 1630 Unresolved int
1595 Notice string 1631 Notice string
1596 DetachedThreads []diffThread 1632 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),
1598 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author), 1634 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1599 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads}) 1635 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1600} 1636}
internal/store/issues.go +36 −30
@@ -8,30 +8,32 @@ import (
8) 8)
9 9
10type Issue struct { 10type Issue struct {
11 ID int64 11 ID int64
12 RepoID int64 12 RepoID int64
13 Number int64 13 Number int64
14 Author string 14 Author string
15 Title string 15 Title string
16 Body string 16 Body string
17 State string // open | closed 17 BodyFormat string // md | org
18 Milestone string 18 State string // open | closed
19 CreatedAt string 19 Milestone string
20 UpdatedAt string 20 CreatedAt string
21 Labels []string 21 UpdatedAt string
22 Assignees []string 22 Labels []string
23 Assignees []string
23} 24}
24 25
25type IssueComment struct { 26type IssueComment struct {
26 Author string 27 Author string
27 Body string 28 Body string
28 CreatedAt string 29 BodyFormat string // md | org
29 Kind string // comment | system 30 CreatedAt string
31 Kind string // comment | system
30} 32}
31 33
32// CreateIssue allocates the per-repo number from the repo counter inside the 34// CreateIssue allocates the per-repo number from the repo counter inside the
33// same transaction as the insert — MAX(number)+1 races. 35// 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) {
35 tx, err := s.DB.Begin() 37 tx, err := s.DB.Begin()
36 if err != nil { 38 if err != nil {
37 return 0, err 39 return 0, err
@@ -45,8 +47,8 @@ func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64,
45 return 0, err 47 return 0, err
46 } 48 }
47 if _, err := tx.Exec( 49 if _, err := tx.Exec(
48 "INSERT INTO issues (repo_id, number, author_id, title, body) VALUES (?, ?, ?, ?, ?)", 50 "INSERT INTO issues (repo_id, number, author_id, title, body, body_format) VALUES (?, ?, ?, ?, ?, ?)",
49 repoID, n, authorID, title, body); err != nil { 51 repoID, n, authorID, title, body, format); err != nil {
50 return 0, err 52 return 0, err
51 } 53 }
52 return n, tx.Commit() 54 return n, tx.Commit()
@@ -55,12 +57,12 @@ func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64,
55func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) { 57func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) {
56 var i Issue 58 var i Issue
57 err := s.DB.QueryRow(` 59 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,
59 COALESCE(m.title, ''), i.created_at, i.updated_at 61 COALESCE(m.title, ''), i.created_at, i.updated_at
60 FROM issues i JOIN users u ON u.id = i.author_id 62 FROM issues i JOIN users u ON u.id = i.author_id
61 LEFT JOIN milestones m ON m.id = i.milestone_id 63 LEFT JOIN milestones m ON m.id = i.milestone_id
62 WHERE i.repo_id = ? AND i.number = ?`, repoID, number). 64 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)
64 if errors.Is(err, sql.ErrNoRows) { 66 if errors.Is(err, sql.ErrNoRows) {
65 return i, ErrNotFound 67 return i, ErrNotFound
66 } 68 }
@@ -99,7 +101,7 @@ func (s *Store) issueStrings(issueID int64, query string) ([]string, error) {
99// "all". limit 0 means everything; before (an issue number) starts the 101// "all". limit 0 means everything; before (an issue number) starts the
100// page strictly below it, matching the number-descending order. 102// page strictly below it, matching the number-descending order.
101func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) { 103func (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,
103 COALESCE(m.title, ''), i.created_at, i.updated_at 105 COALESCE(m.title, ''), i.created_at, i.updated_at
104 FROM issues i JOIN users u ON u.id = i.author_id 106 FROM issues i JOIN users u ON u.id = i.author_id
105 LEFT JOIN milestones m ON m.id = i.milestone_id 107 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)
126 var out []Issue 128 var out []Issue
127 for rows.Next() { 129 for rows.Next() {
128 var i Issue 130 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 {
130 return nil, err 132 return nil, err
131 } 133 }
132 out = append(out, i) 134 out = append(out, i)
@@ -134,8 +136,9 @@ func (s *Store) ListIssues(repoID int64, state string, limit int, before int64)
134 return out, rows.Err() 136 return out, rows.Err()
135} 137}
136 138
137// UpdateIssueText edits title and/or body; nil leaves a field unchanged. 139// UpdateIssueText edits title, body, and/or markup format; nil leaves a field
138func (s *Store) UpdateIssueText(issueID int64, title, body *string) error { 140// unchanged.
141func (s *Store) UpdateIssueText(issueID int64, title, body, format *string) error {
139 set, args := []string{}, []any{} 142 set, args := []string{}, []any{}
140 if title != nil { 143 if title != nil {
141 set, args = append(set, "title = ?"), append(args, *title) 144 set, args = append(set, "title = ?"), append(args, *title)
@@ -143,6 +146,9 @@ func (s *Store) UpdateIssueText(issueID int64, title, body *string) error {
143 if body != nil { 146 if body != nil {
144 set, args = append(set, "body = ?"), append(args, *body) 147 set, args = append(set, "body = ?"), append(args, *body)
145 } 148 }
149 if format != nil {
150 set, args = append(set, "body_format = ?"), append(args, *format)
151 }
146 if len(set) == 0 { 152 if len(set) == 0 {
147 return nil 153 return nil
148 } 154 }
@@ -171,15 +177,15 @@ func (s *Store) SetIssueState(issueID int64, state string) error {
171 return nil 177 return nil
172} 178}
173 179
174func (s *Store) AddIssueComment(issueID, authorID int64, body string) error { 180func (s *Store) AddIssueComment(issueID, authorID int64, body, format string) error {
175 tx, err := s.DB.Begin() 181 tx, err := s.DB.Begin()
176 if err != nil { 182 if err != nil {
177 return err 183 return err
178 } 184 }
179 defer tx.Rollback() 185 defer tx.Rollback()
180 if _, err := tx.Exec( 186 if _, err := tx.Exec(
181 "INSERT INTO issue_comments (issue_id, author_id, body) VALUES (?, ?, ?)", 187 "INSERT INTO issue_comments (issue_id, author_id, body, body_format) VALUES (?, ?, ?, ?)",
182 issueID, authorID, body); err != nil { 188 issueID, authorID, body, format); err != nil {
183 return err 189 return err
184 } 190 }
185 if _, err := tx.Exec( 191 if _, err := tx.Exec(
@@ -192,7 +198,7 @@ func (s *Store) AddIssueComment(issueID, authorID int64, body string) error {
192func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { 198func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
193 rows, err := s.DB.Query(` 199 rows, err := s.DB.Query(`
194 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, 200 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
196 FROM issue_comments c JOIN users u ON u.id = c.author_id 202 FROM issue_comments c JOIN users u ON u.id = c.author_id
197 WHERE c.issue_id = ? ORDER BY c.id`, issueID) 203 WHERE c.issue_id = ? ORDER BY c.id`, issueID)
198 if err != nil { 204 if err != nil {
@@ -202,7 +208,7 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
202 var out []IssueComment 208 var out []IssueComment
203 for rows.Next() { 209 for rows.Next() {
204 var c IssueComment 210 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 {
206 return nil, err 212 return nil, err
207 } 213 }
208 out = append(out, c) 214 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 {
17 TargetRef string 17 TargetRef string
18 Title string 18 Title string
19 Body string 19 Body string
20 BodyFormat string // md | org
20 State string // open | merged | closed | source_gone 21 State string // open | merged | closed | source_gone
21 Milestone string 22 Milestone string
22 HeadSHA string 23 HeadSHA string
@@ -33,7 +34,7 @@ type MRReview struct {
33 CreatedAt string 34 CreatedAt string
34} 35}
35 36
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) {
37 tx, err := s.DB.Begin() 38 tx, err := s.DB.Begin()
38 if err != nil { 39 if err != nil {
39 return 0, err 40 return 0, err
@@ -47,9 +48,9 @@ func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, target
47 return 0, err 48 return 0, err
48 } 49 }
49 if _, err := tx.Exec(` 50 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 INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format)
51 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 52 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
52 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA); err != nil { 53 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format); err != nil {
53 return 0, err 54 return 0, err
54 } 55 }
55 return n, tx.Commit() 56 return n, tx.Commit()
@@ -59,7 +60,7 @@ const mrSelect = `
59 SELECT m.id, m.repo_id, m.number, u.username, 60 SELECT m.id, m.repo_id, m.number, u.username,
60 COALESCE(m.source_repo_id, 0), 61 COALESCE(m.source_repo_id, 0),
61 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''), 62 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,
63 COALESCE(ms.title, ''), m.head_sha, 64 COALESCE(ms.title, ''), m.head_sha,
64 m.merged_base, m.created_at, m.updated_at 65 m.merged_base, m.created_at, m.updated_at
65 FROM merge_requests m 66 FROM merge_requests m
@@ -72,7 +73,7 @@ const mrSelect = `
72func scanMR(row interface{ Scan(...any) error }) (MR, error) { 73func scanMR(row interface{ Scan(...any) error }) (MR, error) {
73 var m MR 74 var m MR
74 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, 75 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)
76 return m, err 77 return m, err
77} 78}
78 79
@@ -191,14 +192,16 @@ func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
191 return err 192 return err
192} 193}
193 194
194func (s *Store) AddMRComment(mrID, authorID int64, body string) error { 195func (s *Store) AddMRComment(mrID, authorID int64, body, format string) error {
195 _, err := s.DB.Exec( 196 _, 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)
197 return err 199 return err
198} 200}
199 201
200// UpdateMRText edits title and/or body; nil leaves a field unchanged. 202// UpdateMRText edits title, body, and/or markup format; nil leaves a field
201func (s *Store) UpdateMRText(mrID int64, title, body *string) error { 203// unchanged.
204func (s *Store) UpdateMRText(mrID int64, title, body, format *string) error {
202 set, args := []string{}, []any{} 205 set, args := []string{}, []any{}
203 if title != nil { 206 if title != nil {
204 set, args = append(set, "title = ?"), append(args, *title) 207 set, args = append(set, "title = ?"), append(args, *title)
@@ -206,6 +209,9 @@ func (s *Store) UpdateMRText(mrID int64, title, body *string) error {
206 if body != nil { 209 if body != nil {
207 set, args = append(set, "body = ?"), append(args, *body) 210 set, args = append(set, "body = ?"), append(args, *body)
208 } 211 }
212 if format != nil {
213 set, args = append(set, "body_format = ?"), append(args, *format)
214 }
209 if len(set) == 0 { 215 if len(set) == 0 {
210 return nil 216 return nil
211 } 217 }
@@ -232,7 +238,7 @@ func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
232func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { 238func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
233 rows, err := s.DB.Query(` 239 rows, err := s.DB.Query(`
234 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, 240 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
236 FROM mr_comments c JOIN users u ON u.id = c.author_id 242 FROM mr_comments c JOIN users u ON u.id = c.author_id
237 WHERE c.mr_id = ? ORDER BY c.id`, mrID) 243 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
238 if err != nil { 244 if err != nil {
@@ -242,7 +248,7 @@ func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
242 var out []IssueComment 248 var out []IssueComment
243 for rows.Next() { 249 for rows.Next() {
244 var c IssueComment 250 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 {
246 return nil, err 252 return nil, err
247 } 253 }
248 out = append(out, c) 254 out = append(out, c)
internal/store/releases.go +19 −18
@@ -7,14 +7,15 @@ import (
7) 7)
8 8
9type Release struct { 9type Release struct {
10 ID int64 10 ID int64
11 RepoID int64 11 RepoID int64
12 Tag string 12 Tag string
13 Title string 13 Title string
14 Notes string 14 Notes string
15 Author string 15 NotesFormat string // md | org
16 CreatedAt string 16 Author string
17 Assets []ReleaseAsset 17 CreatedAt string
18 Assets []ReleaseAsset
18} 19}
19 20
20type ReleaseAsset struct { 21type ReleaseAsset struct {
@@ -25,10 +26,10 @@ type ReleaseAsset struct {
25 UploadedAt string 26 UploadedAt string
26} 27}
27 28
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) {
29 res, err := s.DB.Exec( 30 res, err := s.DB.Exec(
30 "INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)", 31 "INSERT INTO releases (repo_id, tag, title, notes, author_id, notes_format) VALUES (?, ?, ?, ?, ?, ?)",
31 repoID, tag, title, notes, authorID) 32 repoID, tag, title, notes, authorID, format)
32 if err != nil { 33 if err != nil {
33 if isUniqueErr(err) { 34 if isUniqueErr(err) {
34 return 0, fmt.Errorf("a release for tag %q already exists", tag) 35 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
39} 40}
40 41
41const releaseSelect = ` 42const 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
43 FROM releases r LEFT JOIN users u ON u.id = r.author_id` 44 FROM releases r LEFT JOIN users u ON u.id = r.author_id`
44 45
45func (s *Store) releaseAssets(rel *Release) error { 46func (s *Store) releaseAssets(rel *Release) error {
@@ -60,11 +61,11 @@ func (s *Store) releaseAssets(rel *Release) error {
60 return rows.Err() 61 return rows.Err()
61} 62}
62 63
63// UpdateRelease replaces a release's title and notes. 64// UpdateRelease replaces a release's title, notes, and markup format.
64func (s *Store) UpdateRelease(repoID int64, tag, title, notes string) error { 65func (s *Store) UpdateRelease(repoID int64, tag, title, notes, format string) error {
65 res, err := s.DB.Exec( 66 res, err := s.DB.Exec(
66 "UPDATE releases SET title = ?, notes = ? WHERE repo_id = ? AND tag = ?", 67 "UPDATE releases SET title = ?, notes = ?, notes_format = ? WHERE repo_id = ? AND tag = ?",
67 title, notes, repoID, tag) 68 title, notes, format, repoID, tag)
68 if err != nil { 69 if err != nil {
69 return err 70 return err
70 } 71 }
@@ -77,7 +78,7 @@ func (s *Store) UpdateRelease(repoID int64, tag, title, notes string) error {
77func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) { 78func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) {
78 var r Release 79 var r Release
79 err := s.DB.QueryRow(releaseSelect+" WHERE r.repo_id = ? AND r.tag = ?", repoID, tag). 80 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)
81 if errors.Is(err, sql.ErrNoRows) { 82 if errors.Is(err, sql.ErrNoRows) {
82 return r, ErrNotFound 83 return r, ErrNotFound
83 } 84 }
@@ -97,7 +98,7 @@ func (s *Store) ListReleases(repoID int64) ([]Release, error) {
97 var out []Release 98 var out []Release
98 for rows.Next() { 99 for rows.Next() {
99 var r Release 100 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 {
101 return nil, err 102 return nil, err
102 } 103 }
103 out = append(out, r) 104 out = append(out, r)