bodies: choose markdown or org per body !113
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 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func 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 | ||
| 17 | func init() { | 17 | func 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. | ||
| 81 | func 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 | |||
| 79 | type issueOut struct { | 93 | type 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 | ||
| 91 | func issueToOut(i store.Issue, withBody bool) issueOut { | 106 | func 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 | ||
| 100 | func runIssueCreate(c *Ctx, args []string) int { | 116 | func 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 | ||
| 248 | func runIssueComment(c *Ctx, args []string) int { | 278 | func 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 |
| 334 | func 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 | 378 | func 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 | ||
| 378 | func runIssueEdit(c *Ctx, args []string) int { | 431 | func runIssueEdit(c *Ctx, args []string) int { |
| 379 | rest, title, body, code := editText(c, args, "issue") | 432 | rest, title, body, format, code := editText(c, args, "issue") |
| 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 | |||
| 210 | func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } | 210 | func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } |
| 211 | 211 | ||
| 212 | func runMRCreate(c *Ctx, args []string) int { | 212 | func runMRCreate(c *Ctx, args []string) int { |
| 213 | var path, source, target, title, body, file string | 213 | var path, source, target, title, body, file, format string |
| 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 | ||
| 297 | type mrOut struct { | 306 | type 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 | ||
| 310 | func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { | 320 | func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { |
| @@ -321,6 +331,7 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { | |||
| 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 | ||
| 514 | func runMREdit(c *Ctx, args []string) int { | 526 | func runMREdit(c *Ctx, args []string) int { |
| 515 | rest, title, body, code := editText(c, args, "mr") | 527 | rest, title, body, format, code := editText(c, args, "mr") |
| 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 | ||
| 541 | func runMRComment(c *Ctx, args []string) int { | 553 | func 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 | ||
| 20 | func init() { | 20 | func 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 | ||
| 70 | func runReleaseCreate(c *Ctx, args []string) int { | 70 | func runReleaseCreate(c *Ctx, args []string) int { |
| 71 | var path, tag, title, notes, file string | 71 | const usage = "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]" |
| 72 | var path, tag, title, notes, file, format string | ||
| 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 | ||
| 133 | type releaseOut struct { | 143 | type 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 | ||
| 142 | func releaseToOut(r store.Release, withNotes bool) releaseOut { | 153 | func 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 | ||
| 153 | func runReleaseEdit(c *Ctx, args []string) int { | 165 | func 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 | |||
| 120 | func 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 | |||
| 131 | func 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. | ||
| 143 | func 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. | ||
| 160 | func 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 |
| 1054 | func (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. |
| 1055 | type 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. | ||
| 1064 | func 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. | ||
| 1075 | func (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 | ||
| 1077 | func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment { | 1098 | func 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 | ||
| 1122 | var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled") | 1143 | var 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. | ||
| 1149 | func 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 | |||
| 1124 | func renderReadme(name string, raw []byte) template.HTML { | 1170 | func 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. |
| 1169 | func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) { | 1203 | func 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 | ||
| 10 | type Issue struct { | 10 | type 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 | ||
| 25 | type IssueComment struct { | 26 | type 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. |
| 34 | func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64, error) { | 36 | func (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, | |||
| 55 | func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) { | 57 | func (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. |
| 101 | func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) { | 103 | func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) { |
| 102 | q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, | 104 | q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.body_format, i.state, |
| 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 |
| 138 | func (s *Store) UpdateIssueText(issueID int64, title, body *string) error { | 140 | // unchanged. |
| 141 | func (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 | ||
| 174 | func (s *Store) AddIssueComment(issueID, authorID int64, body string) error { | 180 | func (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 { | |||
| 192 | func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { | 198 | func (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 @@ | |||
| 1 | ALTER TABLE issues DROP COLUMN body_format; | ||
| 2 | ALTER TABLE issue_comments DROP COLUMN body_format; | ||
| 3 | ALTER TABLE merge_requests DROP COLUMN body_format; | ||
| 4 | ALTER TABLE mr_comments DROP COLUMN body_format; | ||
| 5 | ALTER TABLE releases DROP COLUMN notes_format; | ||
internal/store/migrations/0027_body_format.up.sql added +5
| @@ -0,0 +1,5 @@ | |||
| 1 | ALTER TABLE issues ADD COLUMN body_format TEXT NOT NULL DEFAULT 'md'; | ||
| 2 | ALTER TABLE issue_comments ADD COLUMN body_format TEXT NOT NULL DEFAULT 'md'; | ||
| 3 | ALTER TABLE merge_requests ADD COLUMN body_format TEXT NOT NULL DEFAULT 'md'; | ||
| 4 | ALTER TABLE mr_comments ADD COLUMN body_format TEXT NOT NULL DEFAULT 'md'; | ||
| 5 | ALTER 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 | ||
| 36 | func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA string) (int64, error) { | 37 | func (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 = ` | |||
| 72 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { | 73 | func 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 | ||
| 194 | func (s *Store) AddMRComment(mrID, authorID int64, body string) error { | 195 | func (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 |
| 201 | func (s *Store) UpdateMRText(mrID int64, title, body *string) error { | 203 | // unchanged. |
| 204 | func (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 { | |||
| 232 | func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { | 238 | func (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 | ||
| 9 | type Release struct { | 9 | type 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 | ||
| 20 | type ReleaseAsset struct { | 21 | type ReleaseAsset struct { |
| @@ -25,10 +26,10 @@ type ReleaseAsset struct { | |||
| 25 | UploadedAt string | 26 | UploadedAt string |
| 26 | } | 27 | } |
| 27 | 28 | ||
| 28 | func (s *Store) CreateRelease(repoID int64, tag, title, notes string, authorID int64) (int64, error) { | 29 | func (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 | ||
| 41 | const releaseSelect = ` | 42 | const releaseSelect = ` |
| 42 | SELECT r.id, r.repo_id, r.tag, r.title, r.notes, COALESCE(u.username, ''), r.created_at | 43 | SELECT r.id, r.repo_id, r.tag, r.title, r.notes, r.notes_format, COALESCE(u.username, ''), r.created_at |
| 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 | ||
| 45 | func (s *Store) releaseAssets(rel *Release) error { | 46 | func (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. |
| 64 | func (s *Store) UpdateRelease(repoID int64, tag, title, notes string) error { | 65 | func (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 { | |||
| 77 | func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) { | 78 | func (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) |