Commit 235f40647d
Verified · cmc ci/build: success
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 | 114 | Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, |
| 115 | 115 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, |
| 116 | 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 | 126 | Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", |
| 118 | 127 | Handler: s.requireUser(s.editForm)}, |
| 119 | 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 | 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 | 1378 | func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool { |
| 1365 | 1379 | if s.cfg.Web.Mode != "accounts" { |
| 1366 | 1380 | return false |
| @@ -1480,6 +1494,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1480 | 1494 | // The diff is the reason most people open a merge request, so it gets |
| 1481 | 1495 | // its own view rather than a fold at the foot of the conversation. |
| 1482 | 1496 | // A query parameter keeps this working without JavaScript. |
| 1497 | unresolved, _ := s.st.UnresolvedThreadCount(m.ID) | |
| 1483 | 1498 | view := r.URL.Query().Get("view") |
| 1484 | 1499 | if view != "commits" && view != "diff" { |
| 1485 | 1500 | view = "conversation" |
| @@ -1497,9 +1512,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1497 | 1512 | Stat diffStat |
| 1498 | 1513 | Commits []commitRow |
| 1499 | 1514 | CanEdit bool |
| 1515 | CanWrite bool | |
| 1516 | Unresolved int | |
| 1517 | Notice string | |
| 1500 | 1518 | DetachedThreads []diffThread |
| 1501 | 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 | 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 | 540 | padding: var(--sp-2) var(--sp-3); |
| 541 | 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 | 554 | .meta { color: var(--muted); font-size: var(--fs-1); } |
| 544 | 555 | svg.icon { vertical-align: -0.125em; } |
| 545 | 556 | .lede { font-size: var(--fs-3); margin: var(--sp-2) 0; } |
internal/web/templates/mr.html +33 −2
| @@ -5,6 +5,8 @@ | ||
| 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 <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 | 10 | <div class="withaside"> |
| 9 | 11 | <div class="mainside"> |
| 10 | 12 | |
| @@ -33,7 +35,7 @@ | ||
| 33 | 35 | </article>{{end}} |
| 34 | 36 | {{end}} |
| 35 | 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 | 39 | {{if .Viewer}} |
| 38 | 40 | <form method="post" action="{{$base}}/comment" class="commentform"> |
| 39 | 41 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> |
| @@ -60,12 +62,39 @@ | ||
| 60 | 62 | {{else}} |
| 61 | 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 | 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 | 66 | {{end}} |
| 65 | 67 | |
| 66 | 68 | </div> |
| 67 | 69 | |
| 68 | 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 | 98 | <div class="grp"> |
| 70 | 99 | <h2>Reviews</h2> |
| 71 | 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 | 118 | </aside> |
| 90 | 119 | </div> |
| 91 | 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 | 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 | 89 | "add": func(a, b int) int { return a + b }, |
| 80 | 90 | "sub": func(a, b int) int { return a - b }, |
| 81 | 91 | // topTab maps a page's Tab to the repo header tab that should read as |