Commit b44a1e91cd
Verified · cmc
cmd/gitbay/main.go +2
| @@ -289,6 +289,7 @@ func issueCmd() *cobra.Command { | ||
| 289 | 289 | pass("reopen", "reopen an issue", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), |
| 290 | 290 | pass("label", "add or remove labels: [--add <l>]... [--remove <l>]...", passOpts{server: []string{"issue", "label"}, needsRepo: true}), |
| 291 | 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 | 293 | pass("milestone", "set or clear the milestone: <n> <title|none>", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}), |
| 293 | 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 | 337 | pass("review", "review: --approve|--request-changes|--comment", passOpts{server: []string{"mr", "review"}, needsRepo: true}), |
| 337 | 338 | pass("merge", "merge: [--strategy ff|merge|squash|rebase]", passOpts{server: []string{"mr", "merge"}, needsRepo: true}), |
| 338 | 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 | 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 | 180 | gitbay issue list [--state open|closed|all] |
| 181 | 181 | gitbay issue show 4 |
| 182 | 182 | gitbay issue comment 4 --message "same here" |
| 183 | gitbay issue edit 4 --title "better title" [--body|--file -] # author or write | |
| 183 | 184 | gitbay issue close 4 / reopen 4 |
| 184 | 185 | gitbay issue label 4 --add bug --remove wontfix |
| 185 | 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 | 22 | Summary: "list issues: issue list <owner/name> [--state open|closed|all]", 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 | 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 | 28 | register(Command{Path: []string{"issue", "comment"}, |
| 26 | 29 | Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]", |
| 27 | 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 | 398 | func runIssueClose(c *Ctx, args []string) int { return setIssueState(c, args, "closed") } |
| 324 | 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 | 34 | Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) |
| 35 | 35 | register(Command{Path: []string{"mr", "diff"}, |
| 36 | 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 | 40 | register(Command{Path: []string{"mr", "comment"}, |
| 38 | 41 | Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]", |
| 39 | 42 | ReadsStdin: true, Run: runMRComment}) |
| @@ -468,6 +471,33 @@ func runMRDiff(c *Ctx, args []string) int { | ||
| 468 | 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 | 501 | func runMRComment(c *Ctx, args []string) int { |
| 472 | 502 | var rest []string |
| 473 | 503 | var message, file string |
internal/httpd/accounts.go +74
| @@ -3,6 +3,7 @@ package httpd | ||
| 3 | 3 | import ( |
| 4 | 4 | "fmt" |
| 5 | 5 | "net/http" |
| 6 | "slices" | |
| 6 | 7 | "strconv" |
| 7 | 8 | "strings" |
| 8 | 9 | "time" |
| @@ -317,6 +318,79 @@ func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u sto | ||
| 317 | 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 | 394 | func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 321 | 395 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 322 | 396 | if !ok { |
internal/httpd/routes.go +4
| @@ -93,6 +93,10 @@ func (s *Server) Routes() []Route { | ||
| 93 | 93 | Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))}, |
| 94 | 94 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true, |
| 95 | 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 | 100 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, |
| 97 | 101 | Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, |
| 98 | 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 | 1175 | Issue store.Issue |
| 1176 | 1176 | BodyHTML template.HTML |
| 1177 | 1177 | Comments []renderedComment |
| 1178 | CanEdit bool | |
| 1178 | 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 | 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 | 1285 | Reviews []store.MRReview |
| 1268 | 1286 | DiffLines []diffLine |
| 1269 | 1287 | Stat diffStat |
| 1288 | CanEdit bool | |
| 1270 | 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 | 1294 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |
internal/store/issues.go +25
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "database/sql" |
| 5 | 5 | "errors" |
| 6 | 6 | "fmt" |
| 7 | "strings" | |
| 7 | 8 | ) |
| 8 | 9 | |
| 9 | 10 | type Issue struct { |
| @@ -123,6 +124,30 @@ func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) { | ||
| 123 | 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 | 151 | func (s *Store) SetIssueState(issueID int64, state string) error { |
| 127 | 152 | res, err := s.DB.Exec( |
| 128 | 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 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | 5 | "errors" |
| 6 | "strings" | |
| 6 | 7 | ) |
| 7 | 8 | |
| 8 | 9 | type MR struct { |
| @@ -10,7 +11,7 @@ type MR struct { | ||
| 10 | 11 | RepoID int64 |
| 11 | 12 | Number int64 |
| 12 | 13 | Author string |
| 13 | SourceRepoID int64 // 0 when the source repo is gone | |
| 14 | SourceRepoID int64 // 0 when the source repo is gone | |
| 14 | 15 | SourcePath string // owner/name of source repo, "" when gone |
| 15 | 16 | SourceRef string |
| 16 | 17 | TargetRef string |
| @@ -185,6 +186,30 @@ func (s *Store) AddMRComment(mrID, authorID int64, body string) error { | ||
| 185 | 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 | 213 | // AddMRSystemComment is the informational counterpart of AddMRComment. |
| 189 | 214 | func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error { |
| 190 | 215 | _, err := s.DB.Exec( |
internal/web/static/style.css +4
| @@ -629,6 +629,10 @@ article.comment .rendered { padding: 0 var(--sp-4); } | ||
| 629 | 629 | article.comment .rendered > :first-child { margin-top: var(--sp-3); } |
| 630 | 630 | article.comment .rendered > :last-child { margin-bottom: var(--sp-3); } |
| 631 | 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 | 636 | .syscomment { |
| 633 | 637 | max-width: 48rem; |
| 634 | 638 | color: var(--muted); |
internal/web/templates/issue.html +7
| @@ -7,6 +7,13 @@ | ||
| 7 | 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 | 8 | {{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}} |
| 9 | 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 | 17 | {{if .BodyHTML}}<article class="comment"> |
| 11 | 18 | <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> |
| 12 | 19 | <div class="rendered">{{.BodyHTML}}</div> |
internal/web/templates/mr.html +6
| @@ -5,6 +5,12 @@ | ||
| 5 | 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> |
| 6 | 6 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} |
| 7 | 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 | 14 | {{if .BodyHTML}}<article class="comment"> |
| 9 | 15 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> |
| 10 | 16 | <div class="rendered">{{.BodyHTML}}</div> |