web: review, resolve, merge, and close on the merge request page !66

merged merged by cmc on 2026-08-26 02:42 UTC · krz/gitbay:mr-web-actions into main

8 files changed, +369 −3

Layout: unified · split

e2e/mrweb_test.go added +157
@@ -0,0 +1,157 @@
1package e2e
2
3import (
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.
15func (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.
38func 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 @@
1package httpd
2
3import (
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.
19func (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 @@
1package httpd
2
3import (
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.
20func (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.
33func 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
38func (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
55func (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
67func (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.
76func (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.
1366func (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
1364func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool { 1378func (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
1505func (s *Server) refs(w http.ResponseWriter, r *http.Request) { 1524func (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%; }
552form.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); }
544svg.icon { vertical-align: -0.125em; } 555svg.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