Commit 991b65712f

991b65712f57a252a87dd811fb33c46fe1c7294a

parent: 8fa927e43a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 03:35 UTC

web: queued merge state, merge-when-ready and cancel buttons on the MR page

Ref #289

Layout: unified · split

e2e/mergequeue_test.go added +103
@@ -0,0 +1,103 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// A merge queued with mr merge --when-ready waits on its gates, shows on
12// the page with a cancel button, and merges as the user who queued it
13// when a status turns the checks green (#289).
14func TestMergeWhenReady(t *testing.T) {
15 t.Parallel()
16 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
17 aliceKey := inst.newKey(t, "alice")
18 bobKey := inst.newKey(t, "bob")
19 inst.admin(t, "admin", "user", "create", "alice",
20 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
21 inst.admin(t, "admin", "user", "create", "bob",
22 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
23 for _, args := range [][]string{
24 {"repo", "create", "alice/lib"},
25 {"repo", "access", "grant", "alice/lib", "bob", "write"},
26 {"repo", "settings", "require-contexts", "alice/lib", "ext/test"},
27 } {
28 if _, errOut, code := inst.ssh(t, aliceKey, "", args...); code != 0 {
29 t.Fatalf("%v: %s", args, errOut)
30 }
31 }
32
33 env := inst.gitEnv(aliceKey)
34 work := t.TempDir()
35 mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/lib"), "w")
36 dir := filepath.Join(work, "w")
37 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
38 os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v1\n"), 0o644)
39 mustGit(t, dir, env, "add", ".")
40 mustGit(t, dir, env, "commit", "-q", "-m", "base")
41 mustGit(t, dir, env, "push", "-q", "origin", "main")
42 mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
43 os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v2\n"), 0o644)
44 mustGit(t, dir, env, "commit", "-q", "-am", "change")
45 mustGit(t, dir, env, "push", "-q", "origin", "feature")
46 head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
47 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/lib",
48 "--source", "feature", "--target", "main", "--title", "'change'"); code != 0 {
49 t.Fatalf("mr create: %s", errOut)
50 }
51
52 // Queued over SSH: the checks have not reported, so it waits.
53 out, errOut, code := inst.ssh(t, bobKey, "", "mr", "merge", "alice/lib", "1", "--when-ready")
54 if code != 0 || !strings.Contains(out, "queued") || !strings.Contains(out, "ext/test=missing") {
55 t.Fatalf("mr merge --when-ready: %d %s %s", code, out, errOut)
56 }
57 out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1", "--json")
58 if !strings.Contains(out, `"queued":{"by":"bob"`) {
59 t.Fatalf("mr show does not carry the queue:\n%s", out)
60 }
61 out, _, _ = inst.ssh(t, bobKey, "", "dashboard", "--json")
62 if !strings.Contains(out, `"queued":true`) {
63 t.Fatalf("dashboard does not carry the queue:\n%s", out)
64 }
65
66 // The page shows it; its cancel button dequeues and its queue button
67 // queues again, now as alice.
68 mrURL := inst.base() + "/alice/lib/mrs/1"
69 alice := inst.login(t, aliceKey)
70 _, body := browserGet(t, alice, mrURL)
71 for _, want := range []string{"Queued to merge by", "waiting: ", "Cancel queued merge"} {
72 if !strings.Contains(body, want) {
73 t.Fatalf("MR page missing %q:\n%s", want, body)
74 }
75 }
76 if status, _ := browserPost(t, alice, mrURL+"/merge", url.Values{"cancel": {"on"}}); status != 200 {
77 t.Fatalf("cancel post: %d", status)
78 }
79 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1", "--json"); strings.Contains(out, `"queued":`) {
80 t.Fatalf("web cancel left the queue:\n%s", out)
81 }
82 if status, _ := browserPost(t, alice, mrURL+"/merge",
83 url.Values{"strategy": {"auto"}, "when_ready": {"on"}}); status != 200 {
84 t.Fatalf("queue post: %d", status)
85 }
86 if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "open" {
87 t.Fatalf("queued MR is %s before its checks", st)
88 }
89
90 // The status that turns the checks green merges it, as alice.
91 if _, errOut, code := inst.ssh(t, bobKey, "", "status", "set", "alice/lib", head,
92 "--context", "ext/test", "--state", "success"); code != 0 {
93 t.Fatalf("status set: %s", errOut)
94 }
95 merged := inst.mrShow(t, aliceKey, "alice/lib", "1")
96 if merged.State != "merged" || merged.MergedBy != "alice" {
97 t.Fatalf("after green checks: state %s merged by %q", merged.State, merged.MergedBy)
98 }
99 mustGit(t, dir, env, "fetch", "-q", "origin")
100 if got := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "origin/main")); got != head {
101 t.Fatalf("main = %s, want %s", got, head)
102 }
103}
internal/httpd/mractions.go +9 −2
@@ -78,8 +78,15 @@ func (s *Server) mrLabelSubmit(w http.ResponseWriter, r *http.Request, u store.U
78 78
79func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 79func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
80 args := []string{} 80 args := []string{}
81 if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" { 81 if r.FormValue("cancel") == "on" {
82 args = append(args, "--strategy", st) 82 args = append(args, "--cancel")
83 } else {
84 if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" {
85 args = append(args, "--strategy", st)
86 }
87 if r.FormValue("when_ready") == "on" {
88 args = append(args, "--when-ready")
89 }
83 } 90 }
84 _, msg, code := s.runControlCode(u, mrArgs(r, "merge", args...)) 91 _, msg, code := s.runControlCode(u, mrArgs(r, "merge", args...))
85 s.done(w, r, code, msg, s.mrRedirect) 92 s.done(w, r, code, msg, s.mrRedirect)
internal/web/templates/mr.html +7
@@ -95,6 +95,12 @@
95 <div class="grp"> 95 <div class="grp">
96 <h2>Merge</h2> 96 <h2>Merge</h2>
97 {{if .Unresolved}}<p class="row none">{{.Unresolved}} unresolved thread{{if ne .Unresolved 1}}s{{end}}</p>{{end}} 97 {{if .Unresolved}}<p class="row none">{{.Unresolved}} unresolved thread{{if ne .Unresolved 1}}s{{end}}</p>{{end}}
98 {{if .MR.QueuedAt}}<p class="row"><span class="dot pend"></span>Queued to merge by <a href="/{{.MR.QueuedBy}}">{{.MR.QueuedBy}}</a>{{if .MR.QueueStrategy}} ({{.MR.QueueStrategy}}){{end}}<span class="sub">{{when .MR.QueuedAt}}</span></p>
99 {{with .MR.QueueReason}}<p class="row none">waiting: {{.}}</p>{{end}}
100 <form method="post" action="{{$base}}/merge" class="actions">
101 <input type="hidden" name="cancel" value="on">
102 <button type="submit" class="btn">Cancel queued merge</button>
103 </form>{{end}}
98 <form method="post" action="{{$base}}/merge" class="actions"> 104 <form method="post" action="{{$base}}/merge" class="actions">
99 <label class="none" for="strategy">Strategy</label> 105 <label class="none" for="strategy">Strategy</label>
100 <select id="strategy" name="strategy"> 106 <select id="strategy" name="strategy">
@@ -105,6 +111,7 @@
105 <option value="rebase">Rebase</option> 111 <option value="rebase">Rebase</option>
106 </select> 112 </select>
107 <button type="submit">Merge</button> 113 <button type="submit">Merge</button>
114 <button type="submit" name="when_ready" value="on" class="btn">Merge when ready</button>
108 </form> 115 </form>
109 <form method="post" action="{{$base}}/close" class="actions"> 116 <form method="post" action="{{$base}}/close" class="actions">
110 <label class="vh" for="by">Closed in favour of</label> 117 <label class="vh" for="by">Closed in favour of</label>