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