Editable issues and MRs (#10) !24
13 files changed, +386 −3
Layout: unified · split
cmd/gitbay/main.go +2
| @@ -289,6 +289,7 @@ func issueCmd() *cobra.Command { | |||
| 289 | pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), | 289 | pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), |
| 290 | pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}), | 290 | pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}), |
| 291 | pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}), | 291 | pass("assign", "assign users: [--add <u>]... [--remove <u>]...", passOpts{server: []string{"issue", "assign"}, needsRepo: true}), |
| 292 | pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}), | ||
| 292 | pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}), | 293 | pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}), |
| 293 | pass("templates", "list issue templates (.gitbay/issue-template*.md)", passOpts{server: []string{"issue", "templates"}, needsRepo: true}), | 294 | pass("templates", "list issue templates (.gitbay/issue-template*.md)", passOpts{server: []string{"issue", "templates"}, needsRepo: true}), |
| 294 | ) | 295 | ) |
| @@ -336,6 +337,7 @@ func mrCmd() *cobra.Command { | |||
| 336 | pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), | 337 | pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), |
| 337 | pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), | 338 | pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), |
| 338 | pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}), | 339 | pass("close", "close without merging", passOpts{server: []string{"mr", "close"}, needsRepo: true}), |
| 340 | pass("edit", "edit title or body: <n> [--title <t>] [--body <b>|--file -]", passOpts{server: []string{"mr", "edit"}, needsRepo: true, stdinOK: true}), | ||
| 339 | pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}), | 341 | pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"mr", "milestone"}, needsRepo: true}), |
| 340 | ) | 342 | ) |
| 341 | } | 343 | } |
docs/users.org +1
| @@ -180,6 +180,7 @@ gitbay issue create --title "it breaks" [--body "..." | --file -] | |||
| 180 | gitbay issue list [--state open|closed|all] | 180 | gitbay issue list [--state open|closed|all] |
| 181 | gitbay issue show 4 | 181 | gitbay issue show 4 |
| 182 | gitbay issue comment 4 --message "same here" | 182 | gitbay issue comment 4 --message "same here" |
| 183 | gitbay issue edit 4 --title "better title" [--body|--file -] # author or write | ||
| 183 | gitbay issue close 4 / reopen 4 | 184 | gitbay issue close 4 / reopen 4 |
| 184 | gitbay issue label 4 --add bug --remove wontfix | 185 | gitbay issue label 4 --add bug --remove wontfix |
| 185 | gitbay issue assign 4 --add alice | 186 | gitbay issue assign 4 --add alice |
e2e/edit_test.go added +110
| @@ -0,0 +1,110 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "net/url" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestIssueMREditing(t *testing.T) { | ||
| 13 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 14 | aliceKey := inst.newKey(t, "alice") | ||
| 15 | bobKey := inst.newKey(t, "bob") | ||
| 16 | eveKey := inst.newKey(t, "eve") | ||
| 17 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 18 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 19 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 20 | inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub") | ||
| 21 | |||
| 22 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 23 | t.Fatal("repo create failed") | ||
| 24 | } | ||
| 25 | // bob authors an issue (no write access); eve is an outsider. | ||
| 26 | if _, _, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app", "--title", "'typo titel'", "--body", "'first'"); code != 0 { | ||
| 27 | t.Fatal("issue create failed") | ||
| 28 | } | ||
| 29 | |||
| 30 | // SSH edit: the author may fix it; an outsider may not; empty titles | ||
| 31 | // are refused; write access (alice) may edit someone else's. | ||
| 32 | if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "edit", "alice/app", "1", "--title", "'typo title'"); code != 0 { | ||
| 33 | t.Fatalf("author edit: %s", errOut) | ||
| 34 | } | ||
| 35 | if _, _, code := inst.ssh(t, eveKey, "", "issue", "edit", "alice/app", "1", "--title", "'hax'"); code != 4 { | ||
| 36 | t.Fatal("outsider edited an issue") | ||
| 37 | } | ||
| 38 | if _, _, code := inst.ssh(t, bobKey, "", "issue", "edit", "alice/app", "1", "--title", "''"); code != 2 { | ||
| 39 | t.Fatal("empty title accepted") | ||
| 40 | } | ||
| 41 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "edit", "alice/app", "1", "--body", "'rewritten'"); code != 0 { | ||
| 42 | t.Fatal("write-access edit failed") | ||
| 43 | } | ||
| 44 | out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") | ||
| 45 | if !strings.Contains(out, "typo title") || !strings.Contains(out, "rewritten") { | ||
| 46 | t.Fatalf("edits not applied: %s", out) | ||
| 47 | } | ||
| 48 | |||
| 49 | // MR edit over SSH. | ||
| 50 | work := t.TempDir() | ||
| 51 | env := inst.gitEnv(aliceKey) | ||
| 52 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 53 | dir := filepath.Join(work, "w") | ||
| 54 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | ||
| 55 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 56 | mustGit(t, dir, env, "add", ".") | ||
| 57 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 58 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 59 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | ||
| 60 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) | ||
| 61 | mustGit(t, dir, env, "add", ".") | ||
| 62 | mustGit(t, dir, env, "commit", "-q", "-m", "feat") | ||
| 63 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | ||
| 64 | if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app", | ||
| 65 | "--source", "feat", "--target", "main", "--title", "'draft'"); code != 0 { | ||
| 66 | t.Fatal("mr create failed") | ||
| 67 | } | ||
| 68 | if _, _, code := inst.ssh(t, aliceKey, "", "mr", "edit", "alice/app", "1", "--title", "'ready'"); code != 0 { | ||
| 69 | t.Fatal("mr edit failed") | ||
| 70 | } | ||
| 71 | if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json"); !strings.Contains(out, "ready") { | ||
| 72 | t.Fatalf("mr edit not applied: %s", out) | ||
| 73 | } | ||
| 74 | |||
| 75 | // Web edit: form appears for the authorized viewer, POST applies, and | ||
| 76 | // with write access the label set is replaced. | ||
| 77 | out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json") | ||
| 78 | if code != 0 { | ||
| 79 | t.Fatal("web login failed") | ||
| 80 | } | ||
| 81 | var env2 struct { | ||
| 82 | Data struct { | ||
| 83 | URL string `json:"url"` | ||
| 84 | } `json:"data"` | ||
| 85 | } | ||
| 86 | json.Unmarshal([]byte(out), &env2) | ||
| 87 | browser := newBrowser(t) | ||
| 88 | browserGet(t, browser, inst.base()+env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]) | ||
| 89 | _, body := browserGet(t, browser, inst.base()+"/alice/app/issues/1") | ||
| 90 | if !strings.Contains(body, "/alice/app/issues/1/edit") { | ||
| 91 | t.Fatal("edit form missing for authorized viewer") | ||
| 92 | } | ||
| 93 | if status, _ := browserPost(t, browser, inst.base()+"/alice/app/issues/1/edit", | ||
| 94 | url.Values{"title": {"web-edited"}, "body": {"web body"}, "labels": {"bug"}}); status != 200 { | ||
| 95 | t.Fatal("web issue edit failed") | ||
| 96 | } | ||
| 97 | out, _, _ = inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") | ||
| 98 | if !strings.Contains(out, "web-edited") || !strings.Contains(out, `"labels":["bug"]`) { | ||
| 99 | t.Fatalf("web edit not applied: %s", out) | ||
| 100 | } | ||
| 101 | if status, _ := browserPost(t, browser, inst.base()+"/alice/app/mrs/1/edit", | ||
| 102 | url.Values{"title": {"web-mr"}, "body": {"mb"}}); status != 200 { | ||
| 103 | t.Fatal("web mr edit failed") | ||
| 104 | } | ||
| 105 | // Anonymous view shows no edit affordance. | ||
| 106 | _, body = inst.get(t, "/alice/app/issues/1") | ||
| 107 | if strings.Contains(body, "/issues/1/edit") { | ||
| 108 | t.Fatal("edit form leaked to anonymous viewer") | ||
| 109 | } | ||
| 110 | } | ||
internal/control/issue.go +75
| @@ -22,6 +22,9 @@ func init() { | |||
| 22 | Summary: "list issues: issue list <owner/name> [--state open|closed|all]", ReadOnly: true, Run: runIssueList}) | 22 | Summary: "list issues: issue list <owner/name> [--state open|closed|all]", 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"}, | ||
| 26 | Summary: "edit title or body: issue edit <owner/name> <n> [--title <t>] [--body <b> | --file -]", | ||
| 27 | ReadsStdin: true, Run: runIssueEdit}) | ||
| 25 | register(Command{Path: []string{"issue", "comment"}, | 28 | register(Command{Path: []string{"issue", "comment"}, |
| 26 | Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]", | 29 | Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]", |
| 27 | ReadsStdin: true, Run: runIssueComment}) | 30 | ReadsStdin: true, Run: runIssueComment}) |
| @@ -320,6 +323,78 @@ func setIssueState(c *Ctx, args []string, state string) int { | |||
| 320 | }) | 323 | }) |
| 321 | } | 324 | } |
| 322 | 325 | ||
| 326 | // editText parses --title/--body/--file - and authorizes: author or write. | ||
| 327 | func editText(c *Ctx, args []string, kind string) (rest []string, title, body *string, code int) { | ||
| 328 | var titleV, bodyV, file string | ||
| 329 | haveTitle, haveBody := false, false | ||
| 330 | for i := 0; i < len(args); i++ { | ||
| 331 | switch args[i] { | ||
| 332 | case "--title", "--body", "--file": | ||
| 333 | if i+1 >= len(args) { | ||
| 334 | return nil, nil, nil, c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | ||
| 335 | } | ||
| 336 | switch args[i] { | ||
| 337 | case "--title": | ||
| 338 | titleV, haveTitle = args[i+1], true | ||
| 339 | case "--body": | ||
| 340 | bodyV, haveBody = args[i+1], true | ||
| 341 | case "--file": | ||
| 342 | file = args[i+1] | ||
| 343 | } | ||
| 344 | i++ | ||
| 345 | default: | ||
| 346 | rest = append(rest, args[i]) | ||
| 347 | } | ||
| 348 | } | ||
| 349 | if file != "" { | ||
| 350 | b, err := bodyFrom(c, "", file) | ||
| 351 | if err != nil { | ||
| 352 | return nil, nil, nil, c.fail(protocol.ExitUsage, "%v", err) | ||
| 353 | } | ||
| 354 | bodyV, haveBody = b, true | ||
| 355 | } | ||
| 356 | if !haveTitle && !haveBody { | ||
| 357 | return nil, nil, nil, c.fail(protocol.ExitUsage, "usage: %s edit <owner/name> <n> [--title <t>] [--body <b> | --file -]", kind) | ||
| 358 | } | ||
| 359 | if haveTitle { | ||
| 360 | if strings.TrimSpace(titleV) == "" { | ||
| 361 | return nil, nil, nil, c.fail(protocol.ExitUsage, "--title must not be empty") | ||
| 362 | } | ||
| 363 | title = &titleV | ||
| 364 | } | ||
| 365 | if haveBody { | ||
| 366 | body = &bodyV | ||
| 367 | } | ||
| 368 | return rest, title, body, -1 | ||
| 369 | } | ||
| 370 | |||
| 371 | func runIssueEdit(c *Ctx, args []string) int { | ||
| 372 | rest, title, body, code := editText(c, args, "issue") | ||
| 373 | if code >= 0 { | ||
| 374 | return code | ||
| 375 | } | ||
| 376 | repo, issue, code := issueRef(c, rest, policy.CanRead) | ||
| 377 | if code >= 0 { | ||
| 378 | return code | ||
| 379 | } | ||
| 380 | if code := refuseArchived(c, repo); code >= 0 { | ||
| 381 | return code | ||
| 382 | } | ||
| 383 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) | ||
| 384 | if err != nil { | ||
| 385 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 386 | } | ||
| 387 | if issue.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) { | ||
| 388 | return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this issue") | ||
| 389 | } | ||
| 390 | if err := c.Store.UpdateIssueText(issue.ID, title, body); err != nil { | ||
| 391 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 392 | } | ||
| 393 | return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) { | ||
| 394 | fmt.Fprintf(w, "edited %s#%d\n", repo.Path(), issue.Number) | ||
| 395 | }) | ||
| 396 | } | ||
| 397 | |||
| 323 | func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") } | 398 | func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") } |
| 324 | func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") } | 399 | func runIssueReopen(c *Ctx, args []string) int { return setIssueState(c, args, "open") } |
| 325 | 400 | ||
internal/control/mr.go +30
| @@ -34,6 +34,9 @@ func init() { | |||
| 34 | Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) | 34 | Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) |
| 35 | register(Command{Path: []string{"mr", "diff"}, | 35 | register(Command{Path: []string{"mr", "diff"}, |
| 36 | Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) | 36 | Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) |
| 37 | register(Command{Path: []string{"mr", "edit"}, | ||
| 38 | Summary: "edit title or body: mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -]", | ||
| 39 | ReadsStdin: true, Run: runMREdit}) | ||
| 37 | register(Command{Path: []string{"mr", "comment"}, | 40 | register(Command{Path: []string{"mr", "comment"}, |
| 38 | Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]", | 41 | Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]", |
| 39 | ReadsStdin: true, Run: runMRComment}) | 42 | ReadsStdin: true, Run: runMRComment}) |
| @@ -468,6 +471,33 @@ func runMRDiff(c *Ctx, args []string) int { | |||
| 468 | return protocol.ExitOK | 471 | return protocol.ExitOK |
| 469 | } | 472 | } |
| 470 | 473 | ||
| 474 | func runMREdit(c *Ctx, args []string) int { | ||
| 475 | rest, title, body, code := editText(c, args, "mr") | ||
| 476 | if code >= 0 { | ||
| 477 | return code | ||
| 478 | } | ||
| 479 | repo, mr, code := mrRef(c, rest, policy.CanRead) | ||
| 480 | if code >= 0 { | ||
| 481 | return code | ||
| 482 | } | ||
| 483 | if code := refuseArchived(c, repo); code >= 0 { | ||
| 484 | return code | ||
| 485 | } | ||
| 486 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) | ||
| 487 | if err != nil { | ||
| 488 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 489 | } | ||
| 490 | if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) { | ||
| 491 | return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request") | ||
| 492 | } | ||
| 493 | if err := c.Store.UpdateMRText(mr.ID, title, body); err != nil { | ||
| 494 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 495 | } | ||
| 496 | return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { | ||
| 497 | fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number) | ||
| 498 | }) | ||
| 499 | } | ||
| 500 | |||
| 471 | func runMRComment(c *Ctx, args []string) int { | 501 | func runMRComment(c *Ctx, args []string) int { |
| 472 | var rest []string | 502 | var rest []string |
| 473 | var message, file string | 503 | var message, file string |
internal/httpd/accounts.go +74
| @@ -3,6 +3,7 @@ package httpd | |||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | 4 | "fmt" |
| 5 | "net/http" | 5 | "net/http" |
| 6 | "slices" | ||
| 6 | "strconv" | 7 | "strconv" |
| 7 | "strings" | 8 | "strings" |
| 8 | "time" | 9 | "time" |
| @@ -317,6 +318,79 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto | |||
| 317 | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) | 318 | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) |
| 318 | } | 319 | } |
| 319 | 320 | ||
| 321 | // issueEditSubmit edits title/body (author or write) and, with write | ||
| 322 | // access, replaces the label set. | ||
| 323 | func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 324 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | ||
| 325 | if !ok { | ||
| 326 | return | ||
| 327 | } | ||
| 328 | n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64) | ||
| 329 | iss, err := s.st.IssueByNumber(repo.ID, n) | ||
| 330 | if err != nil { | ||
| 331 | http.NotFound(w, r) | ||
| 332 | return | ||
| 333 | } | ||
| 334 | grant, _ := s.st.AccessRole(repo.ID, u.ID) | ||
| 335 | canWrite := policy.CanWrite(u, repo, grant) | ||
| 336 | if iss.Author != u.Username && !canWrite { | ||
| 337 | http.Error(w, "only the author or users with write access can edit", http.StatusForbidden) | ||
| 338 | return | ||
| 339 | } | ||
| 340 | title := strings.TrimSpace(r.FormValue("title")) | ||
| 341 | if title == "" { | ||
| 342 | http.Error(w, "title required", http.StatusBadRequest) | ||
| 343 | return | ||
| 344 | } | ||
| 345 | body := r.FormValue("body") | ||
| 346 | if err := s.st.UpdateIssueText(iss.ID, &title, &body); err != nil { | ||
| 347 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 348 | return | ||
| 349 | } | ||
| 350 | if canWrite { | ||
| 351 | want := strings.Fields(r.FormValue("labels")) | ||
| 352 | for _, l := range iss.Labels { | ||
| 353 | if !slices.Contains(want, l) { | ||
| 354 | s.st.SetIssueLabel(repo.ID, iss.ID, l, false) | ||
| 355 | } | ||
| 356 | } | ||
| 357 | for _, l := range want { | ||
| 358 | s.st.SetIssueLabel(repo.ID, iss.ID, l, true) | ||
| 359 | } | ||
| 360 | } | ||
| 361 | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) | ||
| 362 | } | ||
| 363 | |||
| 364 | // mrEditSubmit edits an MR's title/body (author or write). | ||
| 365 | func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 366 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | ||
| 367 | if !ok { | ||
| 368 | return | ||
| 369 | } | ||
| 370 | n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64) | ||
| 371 | m, err := s.st.MRByNumber(repo.ID, n) | ||
| 372 | if err != nil { | ||
| 373 | http.NotFound(w, r) | ||
| 374 | return | ||
| 375 | } | ||
| 376 | grant, _ := s.st.AccessRole(repo.ID, u.ID) | ||
| 377 | if m.Author != u.Username && !policy.CanWrite(u, repo, grant) { | ||
| 378 | http.Error(w, "only the author or users with write access can edit", http.StatusForbidden) | ||
| 379 | return | ||
| 380 | } | ||
| 381 | title := strings.TrimSpace(r.FormValue("title")) | ||
| 382 | if title == "" { | ||
| 383 | http.Error(w, "title required", http.StatusBadRequest) | ||
| 384 | return | ||
| 385 | } | ||
| 386 | body := r.FormValue("body") | ||
| 387 | if err := s.st.UpdateMRText(m.ID, &title, &body); err != nil { | ||
| 388 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 389 | return | ||
| 390 | } | ||
| 391 | http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther) | ||
| 392 | } | ||
| 393 | |||
| 320 | func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 394 | func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 321 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | 395 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 322 | if !ok { | 396 | if !ok { |
internal/httpd/routes.go +4
| @@ -93,6 +93,10 @@ func (s *Server) Routes() []Route { | |||
| 93 | Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))}, | 93 | Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))}, |
| 94 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true, | 94 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true, |
| 95 | Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, | 95 | Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, |
| 96 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true, | ||
| 97 | Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))}, | ||
| 98 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/edit", Mutating: true, | ||
| 99 | Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, | ||
| 96 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, | 100 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, |
| 97 | Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, | 101 | Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, |
| 98 | Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", | 102 | Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", |
internal/httpd/web.go +22 −2
| @@ -1175,8 +1175,26 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | |||
| 1175 | Issue store.Issue | 1175 | Issue store.Issue |
| 1176 | BodyHTML template.HTML | 1176 | BodyHTML template.HTML |
| 1177 | Comments []renderedComment | 1177 | Comments []renderedComment |
| 1178 | CanEdit bool | ||
| 1178 | LabelColors map[string]template.CSS | 1179 | LabelColors map[string]template.CSS |
| 1179 | }{p, iss, md(iss.Body), renderComments(comments, md), s.labelColors(p.Repo.ID)}) | 1180 | }{p, iss, md(iss.Body), renderComments(comments, md), |
| 1181 | s.canEditItem(r, p.Repo, iss.Author), s.labelColors(p.Repo.ID)}) | ||
| 1182 | } | ||
| 1183 | |||
| 1184 | // canEditItem: the author or anyone with write access may edit. | ||
| 1185 | func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool { | ||
| 1186 | if s.cfg.Web.Mode != "accounts" { | ||
| 1187 | return false | ||
| 1188 | } | ||
| 1189 | u := s.viewer(r) | ||
| 1190 | if u.ID == 0 { | ||
| 1191 | return false | ||
| 1192 | } | ||
| 1193 | if u.Username == author { | ||
| 1194 | return true | ||
| 1195 | } | ||
| 1196 | grant, _ := s.st.AccessRole(repo.ID, u.ID) | ||
| 1197 | return policy.CanWrite(u, repo, grant) | ||
| 1180 | } | 1198 | } |
| 1181 | 1199 | ||
| 1182 | func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { | 1200 | func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { |
| @@ -1267,8 +1285,10 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1267 | Reviews []store.MRReview | 1285 | Reviews []store.MRReview |
| 1268 | DiffLines []diffLine | 1286 | DiffLines []diffLine |
| 1269 | Stat diffStat | 1287 | Stat diffStat |
| 1288 | CanEdit bool | ||
| 1270 | DetachedThreads []diffThread | 1289 | DetachedThreads []diffThread |
| 1271 | }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, stat, detachedThreads}) | 1290 | }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), |
| 1291 | reviews, lines, stat, s.canEditItem(r, p.Repo, m.Author), detachedThreads}) | ||
| 1272 | } | 1292 | } |
| 1273 | 1293 | ||
| 1274 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { | 1294 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |
internal/store/issues.go +25
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "database/sql" | 4 | "database/sql" |
| 5 | "errors" | 5 | "errors" |
| 6 | "fmt" | 6 | "fmt" |
| 7 | "strings" | ||
| 7 | ) | 8 | ) |
| 8 | 9 | ||
| 9 | type Issue struct { | 10 | type Issue struct { |
| @@ -123,6 +124,30 @@ func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { | |||
| 123 | return out, rows.Err() | 124 | return out, rows.Err() |
| 124 | } | 125 | } |
| 125 | 126 | ||
| 127 | // UpdateIssueText edits title and/or body; nil leaves a field unchanged. | ||
| 128 | func (s *Store) UpdateIssueText(issueID int64, title, body *string) error { | ||
| 129 | set, args := []string{}, []any{} | ||
| 130 | if title != nil { | ||
| 131 | set, args = append(set, "title = ?"), append(args, *title) | ||
| 132 | } | ||
| 133 | if body != nil { | ||
| 134 | set, args = append(set, "body = ?"), append(args, *body) | ||
| 135 | } | ||
| 136 | if len(set) == 0 { | ||
| 137 | return nil | ||
| 138 | } | ||
| 139 | set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')") | ||
| 140 | args = append(args, issueID) | ||
| 141 | res, err := s.DB.Exec("UPDATE issues SET "+strings.Join(set, ", ")+" WHERE id = ?", args...) | ||
| 142 | if err != nil { | ||
| 143 | return err | ||
| 144 | } | ||
| 145 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 146 | return ErrNotFound | ||
| 147 | } | ||
| 148 | return nil | ||
| 149 | } | ||
| 150 | |||
| 126 | func (s *Store) SetIssueState(issueID int64, state string) error { | 151 | func (s *Store) SetIssueState(issueID int64, state string) error { |
| 127 | res, err := s.DB.Exec( | 152 | res, err := s.DB.Exec( |
| 128 | "UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", | 153 | "UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", |
internal/store/mrs.go +26 −1
| @@ -3,6 +3,7 @@ package store | |||
| 3 | import ( | 3 | import ( |
| 4 | "database/sql" | 4 | "database/sql" |
| 5 | "errors" | 5 | "errors" |
| 6 | "strings" | ||
| 6 | ) | 7 | ) |
| 7 | 8 | ||
| 8 | type MR struct { | 9 | type MR struct { |
| @@ -10,7 +11,7 @@ type MR struct { | |||
| 10 | RepoID int64 | 11 | RepoID int64 |
| 11 | Number int64 | 12 | Number int64 |
| 12 | Author string | 13 | Author string |
| 13 | SourceRepoID int64 // 0 when the source repo is gone | 14 | SourceRepoID int64 // 0 when the source repo is gone |
| 14 | SourcePath string // owner/name of source repo, "" when gone | 15 | SourcePath string // owner/name of source repo, "" when gone |
| 15 | SourceRef string | 16 | SourceRef string |
| 16 | TargetRef string | 17 | TargetRef string |
| @@ -185,6 +186,30 @@ func (s *Store) AddMRComment(mrID, authorID int64, body string) error { | |||
| 185 | return err | 186 | return err |
| 186 | } | 187 | } |
| 187 | 188 | ||
| 189 | // UpdateMRText edits title and/or body; nil leaves a field unchanged. | ||
| 190 | func (s *Store) UpdateMRText(mrID int64, title, body *string) error { | ||
| 191 | set, args := []string{}, []any{} | ||
| 192 | if title != nil { | ||
| 193 | set, args = append(set, "title = ?"), append(args, *title) | ||
| 194 | } | ||
| 195 | if body != nil { | ||
| 196 | set, args = append(set, "body = ?"), append(args, *body) | ||
| 197 | } | ||
| 198 | if len(set) == 0 { | ||
| 199 | return nil | ||
| 200 | } | ||
| 201 | set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')") | ||
| 202 | args = append(args, mrID) | ||
| 203 | res, err := s.DB.Exec("UPDATE merge_requests SET "+strings.Join(set, ", ")+" WHERE id = ?", args...) | ||
| 204 | if err != nil { | ||
| 205 | return err | ||
| 206 | } | ||
| 207 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 208 | return ErrNotFound | ||
| 209 | } | ||
| 210 | return nil | ||
| 211 | } | ||
| 212 | |||
| 188 | // AddMRSystemComment is the informational counterpart of AddMRComment. | 213 | // AddMRSystemComment is the informational counterpart of AddMRComment. |
| 189 | func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error { | 214 | func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error { |
| 190 | _, err := s.DB.Exec( | 215 | _, err := s.DB.Exec( |
internal/web/static/style.css +4
| @@ -629,6 +629,10 @@ article.comment .rendered { padding: 0 var(--sp-4); } | |||
| 629 | article.comment .rendered > :first-child { margin-top: var(--sp-3); } | 629 | article.comment .rendered > :first-child { margin-top: var(--sp-3); } |
| 630 | article.comment .rendered > :last-child { margin-bottom: var(--sp-3); } | 630 | article.comment .rendered > :last-child { margin-bottom: var(--sp-3); } |
| 631 | form.commentform { margin: var(--sp-4) 0; } | 631 | form.commentform { margin: var(--sp-4) 0; } |
| 632 | details.editbox { margin: var(--sp-2) 0; max-width: 48rem; } | ||
| 633 | details.editbox summary { cursor: pointer; color: var(--muted); font-size: var(--fs-2); } | ||
| 634 | details.editbox summary:hover { color: var(--accent); } | ||
| 635 | details.editbox input[type="text"] { width: 100%; } | ||
| 632 | .syscomment { | 636 | .syscomment { |
| 633 | max-width: 48rem; | 637 | max-width: 48rem; |
| 634 | color: var(--muted); | 638 | color: var(--muted); |
internal/web/templates/issue.html +7
| @@ -7,6 +7,13 @@ | |||
| 7 | {{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}} | 7 | {{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}} |
| 8 | {{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}} | 8 | {{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}} |
| 9 | {{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p> | 9 | {{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p> |
| 10 | {{if .CanEdit}}<details class="editbox"><summary>edit</summary> | ||
| 11 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform"> | ||
| 12 | <p><input type="text" name="title" value="{{.Issue.Title}}" required></p> | ||
| 13 | <p><textarea name="body" rows="8">{{.Issue.Body}}</textarea></p> | ||
| 14 | <p><input type="text" name="labels" value="{{range $i, $l := .Issue.Labels}}{{if $i}} {{end}}{{$l}}{{end}}" placeholder="labels, space-separated (write access)"></p> | ||
| 15 | <p><button type="submit">save</button></p> | ||
| 16 | </form></details>{{end}} | ||
| 10 | {{if .BodyHTML}}<article class="comment"> | 17 | {{if .BodyHTML}}<article class="comment"> |
| 11 | <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> | 18 | <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> |
| 12 | <div class="rendered">{{.BodyHTML}}</div> | 19 | <div class="rendered">{{.BodyHTML}}</div> |
internal/web/templates/mr.html +6
| @@ -5,6 +5,12 @@ | |||
| 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> | 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> |
| 6 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} | 6 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} |
| 7 | at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p> | 7 | at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p> |
| 8 | {{if .CanEdit}}<details class="editbox"><summary>edit</summary> | ||
| 9 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/edit" class="commentform"> | ||
| 10 | <p><input type="text" name="title" value="{{.MR.Title}}" required></p> | ||
| 11 | <p><textarea name="body" rows="8">{{.MR.Body}}</textarea></p> | ||
| 12 | <p><button type="submit">save</button></p> | ||
| 13 | </form></details>{{end}} | ||
| 8 | {{if .BodyHTML}}<article class="comment"> | 14 | {{if .BodyHTML}}<article class="comment"> |
| 9 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> | 15 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> |
| 10 | <div class="rendered">{{.BodyHTML}}</div> | 16 | <div class="rendered">{{.BodyHTML}}</div> |