web: review, resolve, merge, and close on the merge request page !66
8 files changed, +369 −3
Layout: unified · split
e2e/mrweb_test.go added +157
| @@ -0,0 +1,157 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "net/http" | ||
| 6 | "net/url" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "regexp" | ||
| 10 | "strings" | ||
| 11 | "testing" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // login returns a browser holding a session for the given key's account. | ||
| 15 | func (i *instance) login(t *testing.T, key string) *http.Client { | ||
| 16 | t.Helper() | ||
| 17 | out, errOut, code := i.ssh(t, key, "", "web", "login", "--json") | ||
| 18 | if code != 0 { | ||
| 19 | t.Fatalf("web login: %s", errOut) | ||
| 20 | } | ||
| 21 | var env struct { | ||
| 22 | Data struct { | ||
| 23 | URL string `json:"url"` | ||
| 24 | } `json:"data"` | ||
| 25 | } | ||
| 26 | json.Unmarshal([]byte(out), &env) | ||
| 27 | c := newBrowser(t) | ||
| 28 | path := env.Data.URL[strings.Index(env.Data.URL, "/login"):] | ||
| 29 | if status, _ := browserGet(t, c, i.base()+path); status != 200 { | ||
| 30 | t.Fatalf("login landed: %d", status) | ||
| 31 | } | ||
| 32 | return c | ||
| 33 | } | ||
| 34 | |||
| 35 | // TestMRWebReviewLoop drives review, thread resolution, and merge from the | ||
| 36 | // browser. Every action runs the same control command the CLI runs, so the | ||
| 37 | // test also proves the merge gates apply to web merges. | ||
| 38 | func TestMRWebReviewLoop(t *testing.T) { | ||
| 39 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 40 | aliceKey := inst.newKey(t, "alice") | ||
| 41 | bobKey := inst.newKey(t, "bob") | ||
| 42 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 43 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 44 | inst.admin(t, "admin", "user", "create", "bob", | ||
| 45 | "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") | ||
| 46 | |||
| 47 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 { | ||
| 48 | t.Fatalf("repo create: %s", errOut) | ||
| 49 | } | ||
| 50 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/lib", "bob", "write"); code != 0 { | ||
| 51 | t.Fatalf("grant: %s", errOut) | ||
| 52 | } | ||
| 53 | // Unresolved review threads block merges, so the gate is observable. | ||
| 54 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-resolved", "alice/lib", "on"); code != 0 { | ||
| 55 | t.Fatalf("require-resolved: %s", errOut) | ||
| 56 | } | ||
| 57 | |||
| 58 | env := inst.gitEnv(aliceKey) | ||
| 59 | work := t.TempDir() | ||
| 60 | mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w") | ||
| 61 | dir := filepath.Join(work, "w") | ||
| 62 | os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v1\n"), 0o644) | ||
| 63 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 64 | mustGit(t, dir, env, "add", ".") | ||
| 65 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 66 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 67 | |||
| 68 | // Bob proposes a change and leaves a review thread on it. | ||
| 69 | bobEnv := inst.gitEnv(bobKey) | ||
| 70 | bobWork := t.TempDir() | ||
| 71 | mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/lib"), "w") | ||
| 72 | bobDir := filepath.Join(bobWork, "w") | ||
| 73 | mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main") | ||
| 74 | os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644) | ||
| 75 | mustGit(t, bobDir, bobEnv, "add", ".") | ||
| 76 | mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature") | ||
| 77 | mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature") | ||
| 78 | if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib", | ||
| 79 | "--source", "feature", "--target", "main", "--title", "'add feature'"); code != 0 { | ||
| 80 | t.Fatalf("mr create: %s", errOut) | ||
| 81 | } | ||
| 82 | if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "diff-comment", "alice/lib", "1", | ||
| 83 | "--path", "feature.txt", "--line", "1", "--message", "'is this right?'"); code != 0 { | ||
| 84 | t.Fatalf("diff-comment: %s", errOut) | ||
| 85 | } | ||
| 86 | |||
| 87 | mrURL := inst.base() + "/alice/lib/mrs/1" | ||
| 88 | alice := inst.login(t, aliceKey) | ||
| 89 | |||
| 90 | // The controls are on the page, and carry the thread to resolve. | ||
| 91 | _, body := browserGet(t, alice, mrURL) | ||
| 92 | for _, want := range []string{`value="approve"`, `action="/alice/lib/mrs/1/merge"`} { | ||
| 93 | if !strings.Contains(body, want) { | ||
| 94 | t.Fatalf("MR page missing %q", want) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | // Review threads live on the diff view, where their lines are. | ||
| 98 | _, diffBody := browserGet(t, alice, mrURL+"?view=diff") | ||
| 99 | m := regexp.MustCompile(`name="thread" value="(\d+)"`).FindStringSubmatch(diffBody) | ||
| 100 | if m == nil { | ||
| 101 | t.Fatalf("no thread control on the diff view:\n%s", diffBody) | ||
| 102 | } | ||
| 103 | threadID := m[1] | ||
| 104 | |||
| 105 | // Approve from the browser; the CLI sees the review. | ||
| 106 | if status, _ := browserPost(t, alice, mrURL+"/review", url.Values{"verdict": {"approve"}}); status != 200 { | ||
| 107 | t.Fatalf("review post: %d", status) | ||
| 108 | } | ||
| 109 | show := inst.mrShow(t, aliceKey, "alice/lib", "1") | ||
| 110 | if len(show.Reviews) != 1 || show.Reviews[0].Reviewer != "alice" || show.Reviews[0].Verdict != "approve" { | ||
| 111 | t.Fatalf("review not recorded: %+v", show.Reviews) | ||
| 112 | } | ||
| 113 | |||
| 114 | // Merging is refused while the thread is open, and the page says why. | ||
| 115 | _, body = browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}}) | ||
| 116 | if !strings.Contains(body, "unresolved") { | ||
| 117 | t.Fatalf("merge gate not surfaced:\n%s", body) | ||
| 118 | } | ||
| 119 | if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "open" { | ||
| 120 | t.Fatalf("blocked merge changed state to %s", st) | ||
| 121 | } | ||
| 122 | |||
| 123 | // Resolve the thread, then merge. | ||
| 124 | if status, _ := browserPost(t, alice, mrURL+"/thread", | ||
| 125 | url.Values{"thread": {threadID}, "action": {"resolve"}}); status != 200 { | ||
| 126 | t.Fatalf("resolve post: %d", status) | ||
| 127 | } | ||
| 128 | out, _, _ := inst.ssh(t, aliceKey, "", "mr", "threads", "alice/lib", "1") | ||
| 129 | if !strings.Contains(out, "resolved") { | ||
| 130 | t.Fatalf("thread not resolved:\n%s", out) | ||
| 131 | } | ||
| 132 | if status, _ := browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}}); status != 200 { | ||
| 133 | t.Fatalf("merge post: %d", status) | ||
| 134 | } | ||
| 135 | if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "merged" { | ||
| 136 | t.Fatalf("MR state after web merge: %s", st) | ||
| 137 | } | ||
| 138 | mustGit(t, dir, env, "pull", "-q", "origin", "main") | ||
| 139 | if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil { | ||
| 140 | t.Fatal("merged content missing from main") | ||
| 141 | } | ||
| 142 | |||
| 143 | // Readers get no controls, and a forged POST is refused by the command. | ||
| 144 | _, anon := browserGet(t, newBrowser(t), mrURL) | ||
| 145 | if strings.Contains(anon, `value="approve"`) { | ||
| 146 | t.Fatal("anonymous visitor sees review controls") | ||
| 147 | } | ||
| 148 | carol := inst.newKey(t, "carol") | ||
| 149 | inst.admin(t, "admin", "user", "create", "carol", "--key", carol+".pub") | ||
| 150 | if _, errOut, code := inst.ssh(t, carol, "", "repo", "create", "carol/own"); code != 0 { | ||
| 151 | t.Fatalf("carol repo: %s", errOut) | ||
| 152 | } | ||
| 153 | _, denied := browserPost(t, inst.login(t, carol), mrURL+"/close", url.Values{}) | ||
| 154 | if !strings.Contains(denied, `class="error"`) || !strings.Contains(denied, "write access") { | ||
| 155 | t.Fatalf("reader was not refused:\n%s", denied) | ||
| 156 | } | ||
| 157 | } | ||
internal/httpd/control.go added +38
| @@ -0,0 +1,38 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/control" | ||
| 8 | "gitbay.org/gitbay/internal/protocol" | ||
| 9 | "gitbay.org/gitbay/internal/store" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // runControl executes a control command as the browser session's user, | ||
| 13 | // through the same registry the CLI and the JSON API reach. Web writes | ||
| 14 | // never reimplement command logic — merge gates, review rules, and audit | ||
| 15 | // entries stay in one place — so the surfaces cannot drift apart. | ||
| 16 | // | ||
| 17 | // ViaAPI is set, which refuses SSHOnly commands: anything whose input is a | ||
| 18 | // credential (secrets, mirror tokens, session minting) stays on SSH. | ||
| 19 | func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) { | ||
| 20 | var stdout, stderr bytes.Buffer | ||
| 21 | ctx := &control.Ctx{ | ||
| 22 | User: u, | ||
| 23 | Source: "web", | ||
| 24 | Scope: "full", | ||
| 25 | Store: s.st, | ||
| 26 | Cfg: s.cfg, | ||
| 27 | Stdin: strings.NewReader(""), | ||
| 28 | Stdout: &stdout, | ||
| 29 | Stderr: &stderr, | ||
| 30 | ViaAPI: true, | ||
| 31 | } | ||
| 32 | code := control.Dispatch(ctx, argv) | ||
| 33 | m := strings.TrimSpace(stderr.String()) | ||
| 34 | if m == "" { | ||
| 35 | m = strings.TrimSpace(stdout.String()) | ||
| 36 | } | ||
| 37 | return stdout.String(), m, code == protocol.ExitOK | ||
| 38 | } | ||
internal/httpd/mractions.go added +91
| @@ -0,0 +1,91 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "net/http" | ||
| 6 | "net/url" | ||
| 7 | "strconv" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/store" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // Merge request actions. Each one runs the control command the CLI runs, | ||
| 14 | // so review rules, merge gates, and audit entries have a single | ||
| 15 | // implementation; the browser only chooses arguments and shows the | ||
| 16 | // result. | ||
| 17 | |||
| 18 | // mrRedirect returns to the merge request, carrying a failure message the | ||
| 19 | // page renders as a banner. | ||
| 20 | func (s *Server) mrRedirect(w http.ResponseWriter, r *http.Request, msg string) { | ||
| 21 | dest := fmt.Sprintf("/%s/%s/mrs/%s", | ||
| 22 | r.PathValue("owner"), r.PathValue("repo"), r.PathValue("n")) | ||
| 23 | if msg != "" { | ||
| 24 | if len(msg) > 300 { | ||
| 25 | msg = msg[:300] | ||
| 26 | } | ||
| 27 | dest += "?e=" + url.QueryEscape(msg) | ||
| 28 | } | ||
| 29 | http.Redirect(w, r, dest, http.StatusSeeOther) | ||
| 30 | } | ||
| 31 | |||
| 32 | // mrArgs builds "<verb> owner/name <n>" for the mr command family. | ||
| 33 | func mrArgs(r *http.Request, verb string, extra ...string) []string { | ||
| 34 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | ||
| 35 | return append([]string{"mr", verb, repo, r.PathValue("n")}, extra...) | ||
| 36 | } | ||
| 37 | |||
| 38 | func (s *Server) mrReviewSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 39 | flag := map[string]string{ | ||
| 40 | "approve": "--approve", | ||
| 41 | "request-changes": "--request-changes", | ||
| 42 | "comment": "--comment", | ||
| 43 | }[r.FormValue("verdict")] | ||
| 44 | if flag == "" { | ||
| 45 | s.mrRedirect(w, r, "pick approve, request changes, or comment") | ||
| 46 | return | ||
| 47 | } | ||
| 48 | _, msg, ok := s.runControl(u, mrArgs(r, "review", flag)) | ||
| 49 | if ok { | ||
| 50 | msg = "" | ||
| 51 | } | ||
| 52 | s.mrRedirect(w, r, msg) | ||
| 53 | } | ||
| 54 | |||
| 55 | func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 56 | args := []string{} | ||
| 57 | if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" { | ||
| 58 | args = append(args, "--strategy", st) | ||
| 59 | } | ||
| 60 | _, msg, ok := s.runControl(u, mrArgs(r, "merge", args...)) | ||
| 61 | if ok { | ||
| 62 | msg = "" | ||
| 63 | } | ||
| 64 | s.mrRedirect(w, r, msg) | ||
| 65 | } | ||
| 66 | |||
| 67 | func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 68 | _, msg, ok := s.runControl(u, mrArgs(r, "close")) | ||
| 69 | if ok { | ||
| 70 | msg = "" | ||
| 71 | } | ||
| 72 | s.mrRedirect(w, r, msg) | ||
| 73 | } | ||
| 74 | |||
| 75 | // mrThreadSubmit resolves or reopens one review thread. | ||
| 76 | func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 77 | verb := "resolve" | ||
| 78 | if r.FormValue("action") == "unresolve" { | ||
| 79 | verb = "unresolve" | ||
| 80 | } | ||
| 81 | id := strings.TrimSpace(r.FormValue("thread")) | ||
| 82 | if _, err := strconv.ParseInt(id, 10, 64); err != nil { | ||
| 83 | s.mrRedirect(w, r, "bad thread id") | ||
| 84 | return | ||
| 85 | } | ||
| 86 | _, msg, ok := s.runControl(u, mrArgs(r, verb, id)) | ||
| 87 | if ok { | ||
| 88 | msg = "" | ||
| 89 | } | ||
| 90 | s.mrRedirect(w, r, msg) | ||
| 91 | } | ||
internal/httpd/routes.go +9
| @@ -114,6 +114,15 @@ func (s *Server) Routes() []Route { | |||
| 114 | Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, | 114 | Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, |
| 115 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, | 115 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, |
| 116 | Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, | 116 | Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, |
| 117 | // Review loop: each runs the matching mr command. | ||
| 118 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true, | ||
| 119 | Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))}, | ||
| 120 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/merge", Mutating: true, | ||
| 121 | Handler: s.checkOrigin(s.requireUser(s.mrMergeSubmit))}, | ||
| 122 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/close", Mutating: true, | ||
| 123 | Handler: s.checkOrigin(s.requireUser(s.mrCloseSubmit))}, | ||
| 124 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/thread", Mutating: true, | ||
| 125 | Handler: s.checkOrigin(s.requireUser(s.mrThreadSubmit))}, | ||
| 117 | Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", | 126 | Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", |
| 118 | Handler: s.requireUser(s.editForm)}, | 127 | Handler: s.requireUser(s.editForm)}, |
| 119 | Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true, | 128 | Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true, |
internal/httpd/web.go +20 −1
| @@ -1361,6 +1361,20 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | |||
| 1361 | } | 1361 | } |
| 1362 | 1362 | ||
| 1363 | // canEditItem: the author or anyone with write access may edit. | 1363 | // canEditItem: the author or anyone with write access may edit. |
| 1364 | // canWriteRepo reports whether the browser session may push to the repo, | ||
| 1365 | // which is what gates the review and merge controls. | ||
| 1366 | func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool { | ||
| 1367 | if s.cfg.Web.Mode != "accounts" { | ||
| 1368 | return false | ||
| 1369 | } | ||
| 1370 | u := s.viewer(r) | ||
| 1371 | if u.ID == 0 { | ||
| 1372 | return false | ||
| 1373 | } | ||
| 1374 | grant, _ := s.st.AccessRole(repo.ID, u.ID) | ||
| 1375 | return policy.CanWrite(u, repo, grant) | ||
| 1376 | } | ||
| 1377 | |||
| 1364 | func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool { | 1378 | func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool { |
| 1365 | if s.cfg.Web.Mode != "accounts" { | 1379 | if s.cfg.Web.Mode != "accounts" { |
| 1366 | return false | 1380 | return false |
| @@ -1480,6 +1494,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1480 | // The diff is the reason most people open a merge request, so it gets | 1494 | // The diff is the reason most people open a merge request, so it gets |
| 1481 | // its own view rather than a fold at the foot of the conversation. | 1495 | // its own view rather than a fold at the foot of the conversation. |
| 1482 | // A query parameter keeps this working without JavaScript. | 1496 | // A query parameter keeps this working without JavaScript. |
| 1497 | unresolved, _ := s.st.UnresolvedThreadCount(m.ID) | ||
| 1483 | view := r.URL.Query().Get("view") | 1498 | view := r.URL.Query().Get("view") |
| 1484 | if view != "commits" && view != "diff" { | 1499 | if view != "commits" && view != "diff" { |
| 1485 | view = "conversation" | 1500 | view = "conversation" |
| @@ -1497,9 +1512,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1497 | Stat diffStat | 1512 | Stat diffStat |
| 1498 | Commits []commitRow | 1513 | Commits []commitRow |
| 1499 | CanEdit bool | 1514 | CanEdit bool |
| 1515 | CanWrite bool | ||
| 1516 | Unresolved int | ||
| 1517 | Notice string | ||
| 1500 | DetachedThreads []diffThread | 1518 | DetachedThreads []diffThread |
| 1501 | }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), | 1519 | }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), |
| 1502 | reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), detachedThreads}) | 1520 | reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), |
| 1521 | s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads}) | ||
| 1503 | } | 1522 | } |
| 1504 | 1523 | ||
| 1505 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { | 1524 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |
internal/web/static/style.css +11
| @@ -540,6 +540,17 @@ code.fullsha { color: var(--muted); overflow-wrap: anywhere; } | |||
| 540 | padding: var(--sp-2) var(--sp-3); | 540 | padding: var(--sp-2) var(--sp-3); |
| 541 | margin-bottom: var(--sp-4); | 541 | margin-bottom: var(--sp-4); |
| 542 | } | 542 | } |
| 543 | /* merge request actions in the aside: stacked controls, full width */ | ||
| 544 | .aside form.actions { | ||
| 545 | display: flex; | ||
| 546 | flex-wrap: wrap; | ||
| 547 | gap: var(--sp-2); | ||
| 548 | margin-top: var(--sp-2); | ||
| 549 | } | ||
| 550 | .aside form.actions button { flex: 1 1 auto; } | ||
| 551 | .aside form.actions select { width: 100%; } | ||
| 552 | form.threadact { margin: var(--sp-1) 0 0; padding: 0 var(--sp-3) var(--sp-2); } | ||
| 553 | |||
| 543 | .meta { color: var(--muted); font-size: var(--fs-1); } | 554 | .meta { color: var(--muted); font-size: var(--fs-1); } |
| 544 | svg.icon { vertical-align: -0.125em; } | 555 | svg.icon { vertical-align: -0.125em; } |
| 545 | .lede { font-size: var(--fs-3); margin: var(--sp-2) 0; } | 556 | .lede { font-size: var(--fs-3); margin: var(--sp-2) 0; } |
internal/web/templates/mr.html +33 −2
| @@ -5,6 +5,8 @@ | |||
| 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 <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> | 6 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> |
| 7 | 7 | ||
| 8 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | ||
| 9 | |||
| 8 | <div class="withaside"> | 10 | <div class="withaside"> |
| 9 | <div class="mainside"> | 11 | <div class="mainside"> |
| 10 | 12 | ||
| @@ -33,7 +35,7 @@ | |||
| 33 | </article>{{end}} | 35 | </article>{{end}} |
| 34 | {{end}} | 36 | {{end}} |
| 35 | {{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> | 37 | {{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> |
| 36 | {{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}} | 38 | {{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}{{template "threadact" dict "ID" .ID "Resolved" .Resolved "Base" $base "Can" $.CanWrite}}</div>{{end}}{{end}} |
| 37 | {{if .Viewer}} | 39 | {{if .Viewer}} |
| 38 | <form method="post" action="{{$base}}/comment" class="commentform"> | 40 | <form method="post" action="{{$base}}/comment" class="commentform"> |
| 39 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> | 41 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> |
| @@ -60,12 +62,39 @@ | |||
| 60 | {{else}} | 62 | {{else}} |
| 61 | <p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></p> | 63 | <p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></p> |
| 62 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> | 64 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> |
| 63 | {{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div><pre class="diff">{{end}}{{end}}</pre> | 65 | {{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}{{template "threadact" dict "ID" .ID "Resolved" .Resolved "Base" $base "Can" $.CanWrite}}</div><pre class="diff">{{end}}{{end}}</pre> |
| 64 | {{end}} | 66 | {{end}} |
| 65 | 67 | ||
| 66 | </div> | 68 | </div> |
| 67 | 69 | ||
| 68 | <aside class="aside"> | 70 | <aside class="aside"> |
| 71 | {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}} | ||
| 72 | <div class="grp"> | ||
| 73 | <h2>Review</h2> | ||
| 74 | <form method="post" action="{{$base}}/review" class="actions"> | ||
| 75 | <button type="submit" name="verdict" value="approve">Approve</button> | ||
| 76 | <button type="submit" name="verdict" value="request-changes">Request changes</button> | ||
| 77 | </form> | ||
| 78 | </div> | ||
| 79 | <div class="grp"> | ||
| 80 | <h2>Merge</h2> | ||
| 81 | {{if .Unresolved}}<p class="row none">{{.Unresolved}} unresolved thread{{if ne .Unresolved 1}}s{{end}}</p>{{end}} | ||
| 82 | <form method="post" action="{{$base}}/merge" class="actions"> | ||
| 83 | <label class="none" for="strategy">Strategy</label> | ||
| 84 | <select id="strategy" name="strategy"> | ||
| 85 | <option value="auto">Automatic</option> | ||
| 86 | <option value="ff">Fast-forward</option> | ||
| 87 | <option value="merge">Merge commit</option> | ||
| 88 | <option value="squash">Squash</option> | ||
| 89 | <option value="rebase">Rebase</option> | ||
| 90 | </select> | ||
| 91 | <button type="submit" class="primary">Merge</button> | ||
| 92 | </form> | ||
| 93 | <form method="post" action="{{$base}}/close" class="actions"> | ||
| 94 | <button type="submit">Close without merging</button> | ||
| 95 | </form> | ||
| 96 | </div> | ||
| 97 | {{end}} | ||
| 69 | <div class="grp"> | 98 | <div class="grp"> |
| 70 | <h2>Reviews</h2> | 99 | <h2>Reviews</h2> |
| 71 | {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p> | 100 | {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p> |
| @@ -89,3 +118,5 @@ | |||
| 89 | </aside> | 118 | </aside> |
| 90 | </div> | 119 | </div> |
| 91 | {{end}} | 120 | {{end}} |
| 121 | |||
| 122 | {{define "threadact"}}{{if .Can}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{.ID}}"><button type="submit" name="action" value="{{if .Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if .Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}{{end}} | ||
internal/web/web.go +10
| @@ -76,6 +76,16 @@ var funcs = template.FuncMap{ | |||
| 76 | } | 76 | } |
| 77 | return s | 77 | return s |
| 78 | }, | 78 | }, |
| 79 | // dict builds a map for {{template}} calls that need several values. | ||
| 80 | "dict": func(pairs ...any) map[string]any { | ||
| 81 | m := map[string]any{} | ||
| 82 | for i := 0; i+1 < len(pairs); i += 2 { | ||
| 83 | if k, ok := pairs[i].(string); ok { | ||
| 84 | m[k] = pairs[i+1] | ||
| 85 | } | ||
| 86 | } | ||
| 87 | return m | ||
| 88 | }, | ||
| 79 | "add": func(a, b int) int { return a + b }, | 89 | "add": func(a, b int) int { return a + b }, |
| 80 | "sub": func(a, b int) int { return a - b }, | 90 | "sub": func(a, b int) int { return a - b }, |
| 81 | // topTab maps a page's Tab to the repo header tab that should read as | 91 | // topTab maps a page's Tab to the repo header tab that should read as |